OpenAPI: Fix Parameter Serialization

This commit is contained in:
Fuma Nama
2025-12-31 18:04:17 +08:00
parent dd7498fc4d
commit 7e58c8eee5
11 changed files with 231 additions and 136 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"fumadocs-openapi": patch
---
Fix Parameter Serialization
+1
View File
@@ -12,6 +12,7 @@ export default [
'no-console': 'off',
// some arrays will not be changed
'react/no-array-index-key': 'off',
'no-fallthrough': 'off',
},
},
];
+1 -6
View File
@@ -33,12 +33,7 @@ export function createBrowserFetcher(
for (const key in options.header) {
const param = options.header[key];
if (!Array.isArray(param.value)) {
headers.append(key, param.value);
} else {
headers.append(key, param.value.join(','));
}
headers.append(key, param.value);
}
const proxyUrl = options.proxyUrl ? new URL(options.proxyUrl, document.baseURI) : null;
@@ -32,7 +32,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
s.push('var client = new HttpClient();');
const headerLines: string[] = [];
function addHeader(key: string, value: unknown) {
function addHeader(key: string, value: string) {
headerLines.push(`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`);
}
for (const k in headers) {
@@ -14,7 +14,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value as string;
headers[k] = v.value;
}
const cookies = Object.entries(data.cookie);
@@ -25,7 +25,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value as string;
headers[k] = v.value;
}
if (Object.keys(headers).length > 0) {
@@ -37,7 +37,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const cookies: Record<string, string> = {};
for (const [k, v] of inputCookies) {
cookies[k] = v.value as string;
cookies[k] = v.value;
}
params.push(`cookies = ${generatePythonObject(cookies, imports)}`);
+196 -94
View File
@@ -5,117 +5,67 @@ import type { ParameterObject } from '@/types';
import type { RawRequestData, RequestData } from '@/requests/types';
export interface EncodedParameter {
readonly value: string | string[];
readonly value: string;
}
const FormDelimiter = {
spaceDelimited: ' ',
pipeDelimited: '|',
};
const PathPrefix = {
label: '.',
matrix: ';',
};
export interface EncodedParameterMultiple {
readonly values: string[];
}
/**
* serialize parameters, see https://swagger.io/docs/specification/v3_0/serialization.
*/
export function encodeRequestData(
from: RawRequestData,
adapters: Record<string, MediaAdapter>,
parameters: NoReference<ParameterObject>[],
): RequestData {
const result: Partial<RequestData> = {
const result: RequestData = {
method: from.method,
body: from.body,
bodyMediaType: from.bodyMediaType,
cookie: {},
header: {},
path: {},
query: {},
};
for (const type of ['cookie', 'query', 'header', 'path'] as const) {
const output: Record<string, EncodedParameter> = {};
for (const key in from[type]) {
const value = from[type][key];
if (value == null) continue;
const field = parameters.find((p) => p.name === key && p.in === type);
if (!field) {
output[key] = { value: String(value) };
continue;
}
// skip unknown parameters
if (!field) continue;
const encoder = getMediaEncoder(field, adapters);
if (encoder) {
output[key] = { value: encoder(value) };
result[type][key] = { value: encoder(value) };
continue;
}
const explode = field.explode ?? true;
let prefix = '';
let sep = ',';
if (field.in === 'path') {
const style = field.style ?? 'simple';
if (style in PathPrefix) {
prefix = PathPrefix[style as keyof typeof PathPrefix];
if (explode) sep = prefix;
}
}
if (Array.isArray(value)) {
// header & cookie doesn't support explode for array values
if (explode && field.in !== 'header' && field.in !== 'cookie') {
output[key] = {
value: prefix + value.map(String),
switch (type) {
case 'path':
serializePathParameter(field, value, result.path);
break;
case 'query':
serializeQueryParameter(field, value, result.query);
break;
case 'header': {
result.header[key] = {
value: serializeSimple(value, field.explode ?? false),
};
continue;
break;
}
if (field.in === 'query') {
const style = field.style ?? 'form';
if (style in FormDelimiter) sep = FormDelimiter[style as keyof typeof FormDelimiter];
}
output[key] = {
value: prefix + value.map(String).join(sep),
};
continue;
case 'cookie':
serializeCookieParameter(field, value, result.cookie);
break;
}
if (typeof value === 'object' && value) {
// header & path creates key-value pairs
if (explode && (field.in === 'header' || field.in === 'path')) {
output[key] = {
value:
prefix +
Object.entries(value)
.map(([k, v]) => `${k}=${v}`)
.join(sep),
};
continue;
}
if (explode || field.style === 'deepObject') {
writeObject(key, value, field.style === 'deepObject', output);
continue;
}
output[key] = {
value: prefix + Object.entries(value).flat().join(sep),
};
continue;
}
output[key] = {
value: prefix + String(value),
};
}
result[type] = output;
}
return result as RequestData;
return result;
}
function getMediaEncoder(
@@ -132,25 +82,177 @@ function getMediaEncoder(
}
}
function writeObject(
parentKey: string,
value: object,
deep: boolean,
function serializeSimple(value: NonNullable<unknown>, explode: boolean): string {
if (Array.isArray(value)) {
return value.join(',');
}
if (typeof value === 'object') {
return explode
? Object.entries(value)
.map(([k, v]) => `${k}=${v}`)
.join(',')
: Object.entries(value).flat().join(',');
}
return String(value);
}
function serializePathParameter(
field: NoReference<ParameterObject>,
value: NonNullable<unknown>,
// write output
output: Record<string, EncodedParameter>,
) {
for (const k in value) {
const prop: unknown = value[k as keyof object];
if (prop == null) continue;
): void {
const { explode = false, name } = field;
const key = deep ? `${parentKey}[${k}]` : k;
if (!deep || typeof prop !== 'object') {
output[key] = {
value: String(prop),
switch (field.style) {
case 'label':
if (Array.isArray(value)) {
output[field.name] = {
value: '.' + value.join(explode ? '.' : ','),
};
break;
}
if (typeof value === 'object') {
output[field.name] = {
value:
'.' +
(explode
? Object.entries(value)
.map(([k, v]) => `${k}=${v}`)
.join('.')
: Object.entries(value).flat().join(',')),
};
break;
}
output[field.name] = {
value: `.${value}`,
};
break;
case 'matrix': {
const specifier = `;${name}=`;
continue;
if (Array.isArray(value)) {
output[field.name] = {
value: explode
? `${specifier}${value.join(',')}`
: `${specifier}${value.join(specifier)}`,
};
break;
}
if (typeof value === 'object') {
output[field.name] = {
value: explode
? Object.entries(value)
.map(([k, v]) => `;${k}=${v}`)
.join('')
: specifier + Object.entries(value).flat().join(','),
};
break;
}
output[field.name] = {
value: `${specifier}${value}`,
};
break;
}
// simple
default:
output[field.name] = {
value: serializeSimple(value, explode),
};
}
}
writeObject(key, value, deep, output);
function serializeQueryParameter(
field: NoReference<ParameterObject>,
value: NonNullable<unknown>,
// write output
output: Record<string, EncodedParameterMultiple>,
): void {
const { explode = true } = field;
switch (field.style) {
case 'spaceDelimited':
if (!explode && Array.isArray(value)) {
output[field.name] = {
values: [value.join(' ')],
};
break;
}
case 'pipeDelimited':
if (!explode && Array.isArray(value)) {
output[field.name] = {
values: [value.join('|')],
};
break;
}
case 'deepObject':
if (!Array.isArray(value) && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
output[`${field.name}[${k}]`] = {
// note: the behaviour of nested array is undefined, we do this to avoid edge cases
values: Array.isArray(v) ? v : [String(v)],
};
}
break;
}
// form
default:
if (Array.isArray(value)) {
output[field.name] = {
values: explode ? value : [value.join(',')],
};
break;
}
if (typeof value === 'object' && explode) {
for (const [k, v] of Object.entries(value)) {
output[k] = {
values: [String(v)],
};
}
break;
}
if (typeof value === 'object') {
output[field.name] = {
values: [Object.entries(value).flat().join(',')],
};
break;
}
output[field.name] = {
values: [String(value)],
};
}
}
function serializeCookieParameter(
field: NoReference<ParameterObject>,
value: NonNullable<unknown>,
// write output
output: Record<string, EncodedParameter>,
) {
const { explode = true } = field;
// form
if (Array.isArray(value)) {
output[field.name] = {
value: explode ? value.map((v) => `${field.name}=${v}`).join('&') : value.join(','),
};
} else if (typeof value === 'object' && explode) {
for (const [k, v] of Object.entries(value)) {
output[k] = {
value: String(v),
};
}
} else if (typeof value === 'object') {
output[field.name] = {
value: Object.entries(value).flat().join(','),
};
} else {
output[field.name] = {
value: String(value),
};
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type { MediaAdapter } from '@/requests/media/adapter';
import type { EncodedParameter } from '@/requests/media/encode';
import type { EncodedParameter, EncodedParameterMultiple } from '@/requests/media/encode';
export type SampleGenerator<ServerContext = unknown> = (
url: string,
@@ -26,7 +26,7 @@ export interface RequestData {
method: string;
path: Record<string, EncodedParameter>;
query: Record<string, EncodedParameter>;
query: Record<string, EncodedParameterMultiple>;
header: Record<string, EncodedParameter>;
cookie: Record<string, EncodedParameter>;
+6 -14
View File
@@ -37,11 +37,7 @@ export function resolveRequestData(pathname: string, { path, query }: RequestDat
for (const key in path) {
const param = path[key];
if (Array.isArray(param.value)) {
pathname = pathname.replace(`{${key}}`, param.value.join('/'));
} else {
pathname = pathname.replace(`{${key}}`, param.value);
}
pathname = pathname.replace(`{${key}}`, param.value);
}
// Check if pathname already contains query parameters (legacy API support)
@@ -53,16 +49,12 @@ export function resolveRequestData(pathname: string, { path, query }: RequestDat
// Add new query parameters from the RequestData
for (const key in query) {
const param = query[key];
if (param.values.length === 0) continue;
if (Array.isArray(param.value)) {
// Remove existing parameter first to avoid duplicates
searchParams.delete(key);
for (const item of param.value) {
searchParams.append(key, item);
}
} else {
// Set (replace if exists) the parameter value
searchParams.set(key, param.value);
// Remove existing parameter first to avoid duplicates
searchParams.delete(key);
for (const item of param.values) {
searchParams.append(key, item);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ describe('Code Sample Generators', () => {
authorization: { value: 'Bearer' },
},
query: {
search: { value: 'ai' },
search: { values: ['ai'] },
},
};
const url = resolveRequestData('http://localhost:8080/{test}', data);
+15 -15
View File
@@ -315,7 +315,7 @@ describe('URL utilities', () => {
const requestData: RequestData = {
method: 'GET',
path: {
segments: { value: ['api', 'v1', 'users'] },
segments: { value: 'api/v1/users' },
},
query: {},
header: {},
@@ -330,8 +330,8 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
limit: { value: '10' },
offset: { value: '20' },
limit: { values: ['10'] },
offset: { values: ['20'] },
},
header: {},
cookie: {},
@@ -345,7 +345,7 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
tags: { value: ['javascript', 'typescript'] },
tags: { values: ['javascript', 'typescript'] },
},
header: {},
cookie: {},
@@ -362,7 +362,7 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
limit: { value: '5' },
limit: { values: ['5'] },
},
header: {},
cookie: {},
@@ -378,8 +378,8 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
limit: { value: '10' },
sort: { value: 'date' },
limit: { values: ['10'] },
sort: { values: ['date'] },
},
header: {},
cookie: {},
@@ -397,7 +397,7 @@ describe('URL utilities', () => {
userId: { value: '123' },
},
query: {
include: { value: 'profile' },
include: { values: ['profile'] },
},
header: {},
cookie: {},
@@ -413,7 +413,7 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
verbose: { value: 'false' },
verbose: { values: ['false'] },
},
header: {},
cookie: {},
@@ -429,7 +429,7 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
newParam: { value: 'value' },
newParam: { values: ['value'] },
},
header: {},
cookie: {},
@@ -445,7 +445,7 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
categories: { value: ['tech', 'science'] },
categories: { values: ['tech', 'science'] },
},
header: {},
cookie: {},
@@ -461,7 +461,7 @@ describe('URL utilities', () => {
method: 'GET',
path: {},
query: {
tags: { value: ['new', 'updated'] },
tags: { values: ['new', 'updated'] },
},
header: {},
cookie: {},
@@ -494,9 +494,9 @@ describe('URL utilities', () => {
projectId: { value: 'web-app' },
},
query: {
include: { value: ['members', 'settings'] },
format: { value: 'json' },
debug: { value: 'true' },
include: { values: ['members', 'settings'] },
format: { values: ['json'] },
debug: { values: ['true'] },
},
header: {},
cookie: {},