Skip to content

Commit b60c8ed

Browse files
committed
OpenAPI: Support Parameter Serialization
1 parent 5844c6f commit b60c8ed

File tree

23 files changed

+446
-224
lines changed

23 files changed

+446
-224
lines changed

.changeset/spicy-mails-brake.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'fumadocs-openapi': minor
3+
---
4+
5+
**Support Parameter Serialization**
6+
7+
Maybe need to update your code if you've added custom media adapters.

apps/docs/content/docs/ui/(integrations)/openapi/media-adapters.mdx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,14 @@ description: Support other media types
55

66
## Overview
77

8-
A media adapter in Fumadocs handle:
8+
A media adapter in Fumadocs supports:
99

10-
- Encode: convert form data into `fetch()` body with corresponding media type.
11-
- Example: generate code example based on different programming language/tool.
10+
- Converting value into `fetch()` body compatible with corresponding media type.
11+
- Generate code example based on different programming language/tool.
1212

13-
Put your media adapters in a separate file with `use client` directive.
14-
15-
```ts title="lib/media-adapters.ts" twoslash
16-
'use client';
13+
Put your media adapters in a separate file.
1714

15+
```ts tab="lib/media-adapters.ts" twoslash
1816
import type { MediaAdapter } from 'fumadocs-openapi';
1917

2018
export const myAdapter: MediaAdapter = {
@@ -40,15 +38,28 @@ export const myAdapter: MediaAdapter = {
4038
};
4139
```
4240

41+
```ts tab="lib/media-adapters.client.ts"
42+
'use client';
43+
44+
// forward them so that Fumadocs can also use your media adapter in a client component
45+
export { myAdapter } from './media-adapters';
46+
```
47+
48+
Pass the adapter.
49+
4350
```ts title="lib/source.ts"
4451
import { createOpenAPI } from 'fumadocs-openapi/server';
45-
import { myAdapter } from './media-adapters';
52+
import * as Adapters from './media-adapters';
53+
import * as ClientAdapters from './media-adapters.client';
4654

4755
export const openapi = createOpenAPI({
4856
proxyUrl: '/api/proxy',
4957
mediaAdapters: {
50-
// [!code ++] override the default adapter of `application/json`
51-
'application/json': myAdapter,
58+
// [!code ++:4] override the default adapter of `application/json`
59+
'application/json': {
60+
...Adapters.myAdapter,
61+
client: ClientAdapters.myAdapter,
62+
},
5263
},
5364
});
5465
```

packages/openapi/src/media/adapter.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
'use client';
21
import { escapeString, inputToString } from '@/utils/input-to-string';
3-
import type { RequestData } from '@/requests/_shared';
2+
// @ts-expect-error -- untyped
3+
import { js2xml } from 'xml-js/lib/js2xml';
44

55
interface BaseContext {
66
/**
@@ -38,22 +38,32 @@ export type MediaContext =
3838

3939
export interface MediaAdapter {
4040
/**
41-
* encode request data into body for `fetch()`.
41+
* the same adapter that's passed from a client component.
42+
*
43+
* It is needed for client-side serialization of values.
44+
*/
45+
client?: MediaAdapter;
46+
47+
/**
48+
* encode data into specified media type for `fetch()`.
4249
*
4350
* Return the encoded form of `data.body` property.
4451
*/
45-
encode: (data: RequestData) => BodyInit | Promise<BodyInit>;
52+
encode: (data: { body: unknown }) => BodyInit;
4653

4754
/**
48-
* generate code for usage examples in a given programming language.
55+
* generate code example for creating the body in a given programming language.
4956
*
5057
* @param data - request data.
5158
* @param lang - name of programming language.
5259
* @param ctx - context passed from the generator of programming language.
5360
*
5461
* @returns code that inits a `body` variable, or undefined if not supported (skip example for that language).
5562
*/
56-
generateExample: (data: RequestData, ctx: MediaContext) => string | undefined;
63+
generateExample: (
64+
data: { body: unknown },
65+
ctx: MediaContext,
66+
) => string | undefined;
5767
}
5868

5969
export const defaultAdapters = {
@@ -66,10 +76,7 @@ export const defaultAdapters = {
6676
},
6777
},
6878
'application/xml': {
69-
async encode(data) {
70-
// @ts-expect-error -- untyped
71-
const { js2xml } = await import('xml-js/lib/js2xml');
72-
79+
encode(data) {
7380
return js2xml(data.body as Record<string, unknown>, {
7481
compact: true,
7582
spaces: 2,
@@ -84,6 +91,7 @@ export const defaultAdapters = {
8491
if (Array.isArray(data.body)) {
8592
return data.body.map((v) => JSON.stringify(v)).join('\n');
8693
}
94+
8795
return JSON.stringify(data.body);
8896
},
8997
generateExample(data, ctx) {

packages/openapi/src/playground/client.tsx

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
CollapsibleTrigger,
4747
} from 'fumadocs-ui/components/ui/collapsible';
4848
import { ChevronDown, LoaderCircle } from 'lucide-react';
49-
import type { RequestData } from '@/requests/_shared';
49+
import { encodeRequestData, type RequestData } from '@/requests/_shared';
5050
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
5151
import { cn } from 'fumadocs-ui/utils/cn';
5252
import {
@@ -68,13 +68,16 @@ import {
6868
SelectValue,
6969
} from '@/ui/components/select';
7070
import { labelVariants } from '@/ui/components/input';
71+
import type { ParsedSchema } from '@/utils/schema';
7172

7273
interface FormValues {
73-
path: Record<string, string>;
74-
query: Record<string, string>;
75-
header: Record<string, string>;
76-
cookie: Record<string, string>;
74+
path: Record<string, unknown>;
75+
query: Record<string, unknown>;
76+
header: Record<string, unknown>;
77+
cookie: Record<string, unknown>;
7778
body: unknown;
79+
80+
_encoded?: RequestData;
7881
}
7982

8083
export interface CustomField<TName extends FieldPath<FormValues>, Info> {
@@ -120,22 +123,6 @@ export interface ClientProps extends HTMLAttributes<HTMLFormElement> {
120123

121124
const AuthPrefix = '__fumadocs_auth';
122125

123-
function toRequestData(
124-
method: string,
125-
mediaType: string | undefined,
126-
value: FormValues,
127-
): RequestData {
128-
return {
129-
path: value.path,
130-
method,
131-
header: value.header,
132-
body: value.body,
133-
bodyMediaType: mediaType,
134-
cookie: value.cookie,
135-
query: value.query,
136-
};
137-
}
138-
139126
const ServerSelect = lazy(() => import('@/ui/server-select'));
140127
const OauthDialog = lazy(() =>
141128
import('./auth/oauth-dialog').then((mod) => ({
@@ -152,7 +139,7 @@ export default function Client({
152139
route,
153140
method = 'GET',
154141
securities,
155-
parameters,
142+
parameters = [],
156143
body,
157144
fields,
158145
references,
@@ -187,19 +174,24 @@ export default function Client({
187174
const fetcher = await import('./fetcher').then((mod) =>
188175
mod.createBrowserFetcher(mediaAdapters),
189176
);
190-
const data = toRequestData(method, body?.mediaType, input);
177+
178+
input._encoded ??= encodeRequestData(
179+
{ ...mapInputs(input), method, bodyMediaType: body?.mediaType },
180+
mediaAdapters,
181+
parameters,
182+
);
191183

192184
return fetcher.fetch(
193185
joinURL(
194186
withBase(
195187
server ? resolveServerUrl(server.url, server.variables) : '/',
196188
window.location.origin,
197189
),
198-
resolveRequestData(route, data),
190+
resolveRequestData(route, input._encoded),
199191
),
200192
{
201193
proxyUrl,
202-
...data,
194+
...input._encoded,
203195
},
204196
);
205197
});
@@ -254,7 +246,13 @@ export default function Client({
254246
}
255247
}
256248

257-
updater.setData(toRequestData(method, body?.mediaType, mapInputs(values)));
249+
const data = {
250+
...mapInputs(values),
251+
method,
252+
bodyMediaType: body?.mediaType,
253+
};
254+
values._encoded ??= encodeRequestData(data, mediaAdapters, parameters);
255+
updater.setData(data, values._encoded);
258256
});
259257

260258
useEffect(() => {
@@ -265,6 +263,9 @@ export default function Client({
265263
values: true,
266264
},
267265
callback({ values }) {
266+
// remove cached encoded request data
267+
delete values._encoded;
268+
268269
if (timer) window.clearTimeout(timer);
269270
timer = window.setTimeout(
270271
() => onUpdateDebounced(values),
@@ -425,11 +426,16 @@ function FormBody({
425426
<CollapsiblePanel key={name} title={name}>
426427
{param.map((field) => {
427428
const fieldName = `${type}.${field.name}` as const;
429+
const schema = (
430+
field.content
431+
? field.content[Object.keys(field.content)[0]].schema
432+
: field.schema
433+
) as ParsedSchema;
428434

429435
if (fields?.parameter) {
430436
return renderCustomField(
431437
fieldName,
432-
field.schema,
438+
schema,
433439
fields.parameter,
434440
field.name,
435441
);
@@ -440,7 +446,7 @@ function FormBody({
440446
key={fieldName}
441447
name={field.name}
442448
fieldName={fieldName}
443-
field={field.schema}
449+
field={schema}
444450
/>
445451
);
446452
})}

packages/openapi/src/playground/fetcher.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ export function createBrowserFetcher(
3030
headers.append('Content-Type', options.bodyMediaType);
3131

3232
for (const key in options.header) {
33-
const paramValue = options.header[key];
33+
const param = options.header[key];
3434

35-
if (paramValue.length > 0) headers.append(key, paramValue.toString());
35+
if (!Array.isArray(param.value)) {
36+
headers.append(key, param.value);
37+
} else {
38+
headers.append(key, param.value.join(','));
39+
}
3640
}
3741

3842
const proxyUrl = options.proxyUrl
@@ -54,34 +58,19 @@ export function createBrowserFetcher(
5458
data: `[Fumadocs] No adapter for ${options.bodyMediaType}, you need to specify one from 'createOpenAPI()'.`,
5559
};
5660

57-
body = await adapter.encode(options);
61+
body = adapter.encode(options as { body: unknown });
5862
}
5963

6064
// cookies
6165
for (const key in options.cookie) {
62-
const value = options.cookie[key];
63-
if (!value) continue;
64-
65-
const cookie = {
66-
[key]: value,
67-
domain:
68-
proxyUrl && proxyUrl.origin !== window.location.origin
69-
? `domain=${proxyUrl.host}`
70-
: undefined,
71-
path: '/',
72-
'max-age': 30,
73-
};
74-
75-
let str = '';
76-
for (const [key, value] of Object.entries(cookie)) {
77-
if (value) {
78-
if (str.length > 0) str += '; ';
79-
80-
str += `${key}=${value}`;
81-
}
82-
}
66+
const param = options.cookie[key];
67+
const segs: string[] = [`${key}=${param.value}`];
68+
69+
if (proxyUrl && proxyUrl.origin !== window.location.origin)
70+
segs.push(`domain=${proxyUrl.host}`);
71+
segs.push('path=/', 'max-age=30');
8372

84-
document.cookie = str;
73+
document.cookie = segs.join('; ');
8574
}
8675

8776
return fetch(url, {

packages/openapi/src/playground/index.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import type {
22
MethodInformation,
3+
ParameterObject,
34
RenderContext,
45
SecuritySchemeObject,
56
} from '@/types';
6-
import { getPreferredType, type ParsedSchema } from '@/utils/schema';
7+
import {
8+
getPreferredType,
9+
type NoReference,
10+
type ParsedSchema,
11+
} from '@/utils/schema';
712
import { type ClientProps } from './client';
813
import { ClientLazy } from '@/ui/lazy';
914

10-
export type ParameterField = {
11-
name: string;
12-
description?: string;
15+
export type ParameterField = NoReference<ParameterObject> & {
1316
schema: ParsedSchema;
1417
in: 'cookie' | 'header' | 'query' | 'path';
1518
};
@@ -59,12 +62,13 @@ export async function APIPlayground({
5962
securities: parseSecurities(method, ctx),
6063
method: method.method,
6164
route: path,
62-
parameters: method.parameters?.map((v) => ({
63-
name: v.name,
64-
in: v.in as ParameterField['in'],
65-
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
66-
description: v.description,
67-
})),
65+
parameters: method.parameters?.map(
66+
(v) =>
67+
({
68+
...v,
69+
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
70+
}) as ParameterField,
71+
),
6872
body:
6973
bodyContent && mediaType
7074
? ({

packages/openapi/src/render/api-page.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
processDocument,
1010
type ProcessedDocument,
1111
} from '@/utils/process-document';
12-
import type { defaultAdapters } from '@/media/adapter';
12+
import { defaultAdapters } from '@/media/adapter';
1313

1414
type ApiPageContextProps = Pick<
1515
Partial<RenderContext>,
@@ -145,14 +145,7 @@ export async function getContext(
145145
generateCodeSamples: options.generateCodeSamples,
146146
servers,
147147
mediaAdapters: {
148-
...({
149-
'application/octet-stream': true,
150-
'application/json': true,
151-
'multipart/form-data': true,
152-
'application/xml': true,
153-
'application/x-ndjson': true,
154-
'application/x-www-form-urlencoded': true,
155-
} satisfies Record<keyof typeof defaultAdapters, true>),
148+
...defaultAdapters,
156149
...options.mediaAdapters,
157150
},
158151
slugger: new Slugger(),

0 commit comments

Comments
 (0)