OpenAPI: support application/x-www-form-urlencoded

This commit is contained in:
Fuma Nama
2025-04-14 12:44:15 +08:00
parent 4d50bcfea1
commit b664784f4d
14 changed files with 984 additions and 153 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
support `application/x-www-form-urlencoded` content type
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -106,7 +106,7 @@ export function createBrowserFetcher(): Fetcher {
export async function createBodyFromValue(
mediaType: Required<RequestData>['bodyMediaType'],
value: unknown,
): Promise<string | FormData> {
): Promise<BodyInit> {
if (mediaType === 'application/json') {
return JSON.stringify(value);
}
@@ -118,6 +118,17 @@ export async function createBodyFromValue(
});
}
if (mediaType === 'application/x-www-form-urlencoded') {
if (typeof value !== 'object')
throw new Error(`Input value must be object, received: ${typeof value}`);
const params = new URLSearchParams();
for (const key in value) {
params.set(key, String(value[key as keyof object]));
}
return params;
}
const formData = new FormData();
if (typeof value !== 'object' || !value) {
@@ -21,7 +21,7 @@ import {
} from '@/render/operation/api-example';
import { MethodLabel } from '@/ui/components/method-label';
import type { RequestData } from '@/requests/_shared';
import { RequestData, supportedMediaTypes } from '@/requests/_shared';
export interface CodeSample {
lang: string;
@@ -77,8 +77,15 @@ export function Operation({
if (body) {
const type = getPreferredType(body.content);
if (!type)
throw new Error(`No supported media type for body content: ${path}`);
if (
!type ||
!supportedMediaTypes.includes(
String(type) as (typeof supportedMediaTypes)[number],
)
)
throw new Error(
`No supported media type for body content: ${path}, received: ${type}`,
);
bodyNode = (
<>
+8 -4
View File
@@ -1,5 +1,12 @@
import { getPathnameFromInput } from '@/utils/get-pathname-from-input';
export const supportedMediaTypes = [
'multipart/form-data',
'application/json',
'application/xml',
'application/x-www-form-urlencoded',
] as const;
export interface RequestData {
method: string;
@@ -9,10 +16,7 @@ export interface RequestData {
cookie: Record<string, string>;
body?: unknown;
bodyMediaType?:
| 'multipart/form-data'
| 'application/json'
| 'application/xml';
bodyMediaType?: (typeof supportedMediaTypes)[number];
}
export function getUrl(url: string, data: RequestData): string {
+8 -3
View File
@@ -26,9 +26,14 @@ export function getSampleRequest(url: string, data: RequestData): string {
}
} else if (data.body) {
s.push(`-H "Content-Type: ${data.bodyMediaType}"`);
s.push(
`-d ${inputToString(data.body, data.bodyMediaType, 'single-quote')}`,
);
if (data.bodyMediaType === 'application/json') {
s.push(`-d ${inputToString(data.body, 'json', 'single-quote')}`);
} else if (data.bodyMediaType === 'application/xml') {
s.push(`-d ${inputToString(data.body, 'xml', 'single-quote')}`);
} else {
s.push(`-d ${inputToString(data.body, 'url', 'single-quote')}`);
}
}
return s
+10 -7
View File
@@ -22,13 +22,10 @@ export function getSampleRequest(url: string, data: RequestData): string {
JSON.stringify(cookies.map((p) => `${p}=${data.cookie[p]}`).join('; ')),
);
if (data.body) {
if (data.body && data.bodyMediaType) {
headers.set('Content-Type', `"${data.bodyMediaType}"`);
if (
data.bodyMediaType === 'multipart/form-data' &&
typeof data.body === 'object'
) {
if (data.bodyMediaType === 'multipart/form-data') {
imports.push('mime/multipart', 'bytes');
variables.set('payload', `new(bytes.Buffer)`);
@@ -36,7 +33,7 @@ export function getSampleRequest(url: string, data: RequestData): string {
for (const [key, value] of Object.entries(data.body)) {
additional.push(
`mp.WriteField("${key}", ${inputToString(value, undefined, 'backtick')})`,
`mp.WriteField("${key}", ${inputToString(value, 'json', 'backtick')})`,
);
}
} else {
@@ -45,7 +42,13 @@ export function getSampleRequest(url: string, data: RequestData): string {
'payload',
`strings.NewReader(${inputToString(
data.body,
data.bodyMediaType,
(
{
'application/json': 'json',
'application/xml': 'xml',
'application/x-www-form-urlencoded': 'url',
} as const
)[data.bodyMediaType],
'backtick',
).replaceAll('\n', '\n ')})`,
);
+11 -7
View File
@@ -20,27 +20,31 @@ export function getSampleRequest(url: string, data: RequestData): string {
);
}
if (
data.bodyMediaType === 'multipart/form-data' &&
typeof data.body === 'object' &&
data.body
) {
if (data.bodyMediaType === 'multipart/form-data' && data.body) {
s.push(`const formData = new FormData();`);
for (const [key, value] of Object.entries(data.body))
s.push(`formData.set(${key}, ${inputToString(value)})`);
options.set('body', 'formData');
} else if (
data.body &&
data.bodyMediaType === 'application/x-www-form-urlencoded'
) {
options.set(
'body',
`new URLSearchParams(${JSON.stringify(data.body, null, 2)})`,
);
} else if (data.body) {
let code: string;
if (data.bodyMediaType === 'application/json') {
code =
typeof data.body === 'string'
? inputToString(data.body, data.bodyMediaType, 'backtick')
? inputToString(data.body, 'json', 'backtick')
: `JSON.stringify(${JSON.stringify(data.body, null, 2)})`;
} else {
code = inputToString(data.body, data.bodyMediaType, 'backtick');
code = inputToString(data.body, 'xml', 'backtick');
}
options.set('body', code.replaceAll('\n', '\n '));
+12 -3
View File
@@ -6,7 +6,7 @@ export function getSampleRequest(url: string, data: RequestData): string {
const variables = new Map<string, string>();
const headers = { ...data.header };
if (data.body) {
if (data.body && data.bodyMediaType) {
switch (data.bodyMediaType) {
case 'application/json':
variables.set('json', JSON.stringify(data.body, null, 2));
@@ -16,11 +16,20 @@ export function getSampleRequest(url: string, data: RequestData): string {
variables.set('data', JSON.stringify(data.body, null, 2));
break;
default:
if (data.bodyMediaType) headers['Content-Type'] = data.bodyMediaType;
headers['Content-Type'] = data.bodyMediaType;
variables.set(
'data',
inputToString(data.body, data.bodyMediaType, 'python'),
inputToString(
data.body,
(
{
'application/xml': 'xml',
'application/x-www-form-urlencoded': 'url',
} as const
)[data.bodyMediaType],
'python',
),
);
}
}
+16 -7
View File
@@ -5,7 +5,7 @@ import { type ElementCompact, js2xml } from 'xml-js';
*/
export function inputToString(
value: unknown,
mediaType = 'application/json',
format: 'xml' | 'json' | 'url' = 'json',
multiLine: 'single-quote' | 'backtick' | 'python' | 'none' = 'none',
): string {
const getStr = (v: string) => {
@@ -20,15 +20,24 @@ export function inputToString(
if (typeof value === 'string') return getStr(value);
if (mediaType === 'application/json' || mediaType === 'multipart/form-data') {
if (format === 'json') {
return getStr(JSON.stringify(value, null, 2));
}
if (mediaType === 'application/xml') {
return getStr(
js2xml(value as ElementCompact, { compact: true, spaces: 2 }),
);
if (format === 'url') {
const params = new URLSearchParams();
if (typeof value !== 'object')
throw new Error(
`For url encoded data, \`value\` must be an object, but received: ${typeof value}`,
);
for (const key in value) {
if (value[key as keyof object])
params.set(key, String(value[key as keyof object]));
}
return getStr(params.toString());
}
throw new Error(`Unsupported media type: ${mediaType}`);
return getStr(js2xml(value as ElementCompact, { compact: true, spaces: 2 }));
}
-3
View File
@@ -6,8 +6,5 @@
"rootDir": "src",
"sourceMap": false
},
"tsc-alias": {
"resolveFullPaths": true
},
"include": ["./src/**/*"]
}
+3
View File
@@ -5,5 +5,8 @@
"@/*": ["./src/*"]
}
},
"tsc-alias": {
"resolveFullPaths": true
},
"exclude": ["node_modules", "dist", "eslint.config.mjs", "tailwind.config.js"]
}
-3
View File
@@ -5,9 +5,6 @@
"outDir": "dist",
"rootDir": "src"
},
"tsc-alias": {
"resolveFullPaths": true
},
"include": ["./src/**/*"],
"exclude": ["./src/_registry"]
}
+3
View File
@@ -5,5 +5,8 @@
"@/*": ["./src/*"]
}
},
"tsc-alias": {
"resolveFullPaths": true
},
"exclude": ["node_modules", "dist", "eslint.config.mjs"]
}