OpenAPI: Support Parameter Serialization

This commit is contained in:
Fuma Nama
2025-07-08 21:30:39 +08:00
parent 5844c6f382
commit b60c8ed399
23 changed files with 445 additions and 223 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-openapi': minor
---
**Support Parameter Serialization**
Maybe need to update your code if you've added custom media adapters.
@@ -5,16 +5,14 @@ description: Support other media types
## Overview
A media adapter in Fumadocs handle:
A media adapter in Fumadocs supports:
- Encode: convert form data into `fetch()` body with corresponding media type.
- Example: generate code example based on different programming language/tool.
- Converting value into `fetch()` body compatible with corresponding media type.
- Generate code example based on different programming language/tool.
Put your media adapters in a separate file with `use client` directive.
```ts title="lib/media-adapters.ts" twoslash
'use client';
Put your media adapters in a separate file.
```ts tab="lib/media-adapters.ts" twoslash
import type { MediaAdapter } from 'fumadocs-openapi';
export const myAdapter: MediaAdapter = {
@@ -40,15 +38,28 @@ export const myAdapter: MediaAdapter = {
};
```
```ts tab="lib/media-adapters.client.ts"
'use client';
// forward them so that Fumadocs can also use your media adapter in a client component
export { myAdapter } from './media-adapters';
```
Pass the adapter.
```ts title="lib/source.ts"
import { createOpenAPI } from 'fumadocs-openapi/server';
import { myAdapter } from './media-adapters';
import * as Adapters from './media-adapters';
import * as ClientAdapters from './media-adapters.client';
export const openapi = createOpenAPI({
proxyUrl: '/api/proxy',
mediaAdapters: {
// [!code ++] override the default adapter of `application/json`
'application/json': myAdapter,
// [!code ++:4] override the default adapter of `application/json`
'application/json': {
...Adapters.myAdapter,
client: ClientAdapters.myAdapter,
},
},
});
```
+18 -10
View File
@@ -1,6 +1,6 @@
'use client';
import { escapeString, inputToString } from '@/utils/input-to-string';
import type { RequestData } from '@/requests/_shared';
// @ts-expect-error -- untyped
import { js2xml } from 'xml-js/lib/js2xml';
interface BaseContext {
/**
@@ -38,14 +38,21 @@ export type MediaContext =
export interface MediaAdapter {
/**
* encode request data into body for `fetch()`.
* the same adapter that's passed from a client component.
*
* It is needed for client-side serialization of values.
*/
client?: MediaAdapter;
/**
* encode data into specified media type for `fetch()`.
*
* Return the encoded form of `data.body` property.
*/
encode: (data: RequestData) => BodyInit | Promise<BodyInit>;
encode: (data: { body: unknown }) => BodyInit;
/**
* generate code for usage examples in a given programming language.
* generate code example for creating the body in a given programming language.
*
* @param data - request data.
* @param lang - name of programming language.
@@ -53,7 +60,10 @@ export interface MediaAdapter {
*
* @returns code that inits a `body` variable, or undefined if not supported (skip example for that language).
*/
generateExample: (data: RequestData, ctx: MediaContext) => string | undefined;
generateExample: (
data: { body: unknown },
ctx: MediaContext,
) => string | undefined;
}
export const defaultAdapters = {
@@ -66,10 +76,7 @@ export const defaultAdapters = {
},
},
'application/xml': {
async encode(data) {
// @ts-expect-error -- untyped
const { js2xml } = await import('xml-js/lib/js2xml');
encode(data) {
return js2xml(data.body as Record<string, unknown>, {
compact: true,
spaces: 2,
@@ -84,6 +91,7 @@ export const defaultAdapters = {
if (Array.isArray(data.body)) {
return data.body.map((v) => JSON.stringify(v)).join('\n');
}
return JSON.stringify(data.body);
},
generateExample(data, ctx) {
+34 -28
View File
@@ -46,7 +46,7 @@ import {
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown, LoaderCircle } from 'lucide-react';
import type { RequestData } from '@/requests/_shared';
import { encodeRequestData, type RequestData } from '@/requests/_shared';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import {
@@ -68,13 +68,16 @@ import {
SelectValue,
} from '@/ui/components/select';
import { labelVariants } from '@/ui/components/input';
import type { ParsedSchema } from '@/utils/schema';
interface FormValues {
path: Record<string, string>;
query: Record<string, string>;
header: Record<string, string>;
cookie: Record<string, string>;
path: Record<string, unknown>;
query: Record<string, unknown>;
header: Record<string, unknown>;
cookie: Record<string, unknown>;
body: unknown;
_encoded?: RequestData;
}
export interface CustomField<TName extends FieldPath<FormValues>, Info> {
@@ -120,22 +123,6 @@ export interface ClientProps extends HTMLAttributes<HTMLFormElement> {
const AuthPrefix = '__fumadocs_auth';
function toRequestData(
method: string,
mediaType: string | undefined,
value: FormValues,
): RequestData {
return {
path: value.path,
method,
header: value.header,
body: value.body,
bodyMediaType: mediaType,
cookie: value.cookie,
query: value.query,
};
}
const ServerSelect = lazy(() => import('@/ui/server-select'));
const OauthDialog = lazy(() =>
import('./auth/oauth-dialog').then((mod) => ({
@@ -152,7 +139,7 @@ export default function Client({
route,
method = 'GET',
securities,
parameters,
parameters = [],
body,
fields,
references,
@@ -187,7 +174,12 @@ export default function Client({
const fetcher = await import('./fetcher').then((mod) =>
mod.createBrowserFetcher(mediaAdapters),
);
const data = toRequestData(method, body?.mediaType, input);
input._encoded ??= encodeRequestData(
{ ...mapInputs(input), method, bodyMediaType: body?.mediaType },
mediaAdapters,
parameters,
);
return fetcher.fetch(
joinURL(
@@ -195,11 +187,11 @@ export default function Client({
server ? resolveServerUrl(server.url, server.variables) : '/',
window.location.origin,
),
resolveRequestData(route, data),
resolveRequestData(route, input._encoded),
),
{
proxyUrl,
...data,
...input._encoded,
},
);
});
@@ -254,7 +246,13 @@ export default function Client({
}
}
updater.setData(toRequestData(method, body?.mediaType, mapInputs(values)));
const data = {
...mapInputs(values),
method,
bodyMediaType: body?.mediaType,
};
values._encoded ??= encodeRequestData(data, mediaAdapters, parameters);
updater.setData(data, values._encoded);
});
useEffect(() => {
@@ -265,6 +263,9 @@ export default function Client({
values: true,
},
callback({ values }) {
// remove cached encoded request data
delete values._encoded;
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(
() => onUpdateDebounced(values),
@@ -425,11 +426,16 @@ function FormBody({
<CollapsiblePanel key={name} title={name}>
{param.map((field) => {
const fieldName = `${type}.${field.name}` as const;
const schema = (
field.content
? field.content[Object.keys(field.content)[0]].schema
: field.schema
) as ParsedSchema;
if (fields?.parameter) {
return renderCustomField(
fieldName,
field.schema,
schema,
fields.parameter,
field.name,
);
@@ -440,7 +446,7 @@ function FormBody({
key={fieldName}
name={field.name}
fieldName={fieldName}
field={field.schema}
field={schema}
/>
);
})}
+13 -24
View File
@@ -30,9 +30,13 @@ export function createBrowserFetcher(
headers.append('Content-Type', options.bodyMediaType);
for (const key in options.header) {
const paramValue = options.header[key];
const param = options.header[key];
if (paramValue.length > 0) headers.append(key, paramValue.toString());
if (!Array.isArray(param.value)) {
headers.append(key, param.value);
} else {
headers.append(key, param.value.join(','));
}
}
const proxyUrl = options.proxyUrl
@@ -54,34 +58,19 @@ export function createBrowserFetcher(
data: `[Fumadocs] No adapter for ${options.bodyMediaType}, you need to specify one from 'createOpenAPI()'.`,
};
body = await adapter.encode(options);
body = adapter.encode(options as { body: unknown });
}
// cookies
for (const key in options.cookie) {
const value = options.cookie[key];
if (!value) continue;
const param = options.cookie[key];
const segs: string[] = [`${key}=${param.value}`];
const cookie = {
[key]: value,
domain:
proxyUrl && proxyUrl.origin !== window.location.origin
? `domain=${proxyUrl.host}`
: undefined,
path: '/',
'max-age': 30,
};
if (proxyUrl && proxyUrl.origin !== window.location.origin)
segs.push(`domain=${proxyUrl.host}`);
segs.push('path=/', 'max-age=30');
let str = '';
for (const [key, value] of Object.entries(cookie)) {
if (value) {
if (str.length > 0) str += '; ';
str += `${key}=${value}`;
}
}
document.cookie = str;
document.cookie = segs.join('; ');
}
return fetch(url, {
+14 -10
View File
@@ -1,15 +1,18 @@
import type {
MethodInformation,
ParameterObject,
RenderContext,
SecuritySchemeObject,
} from '@/types';
import { getPreferredType, type ParsedSchema } from '@/utils/schema';
import {
getPreferredType,
type NoReference,
type ParsedSchema,
} from '@/utils/schema';
import { type ClientProps } from './client';
import { ClientLazy } from '@/ui/lazy';
export type ParameterField = {
name: string;
description?: string;
export type ParameterField = NoReference<ParameterObject> & {
schema: ParsedSchema;
in: 'cookie' | 'header' | 'query' | 'path';
};
@@ -59,12 +62,13 @@ export async function APIPlayground({
securities: parseSecurities(method, ctx),
method: method.method,
route: path,
parameters: method.parameters?.map((v) => ({
name: v.name,
in: v.in as ParameterField['in'],
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
description: v.description,
})),
parameters: method.parameters?.map(
(v) =>
({
...v,
schema: writeReferences((v.schema ?? true) as ParsedSchema, context),
}) as ParameterField,
),
body:
bodyContent && mediaType
? ({
+2 -9
View File
@@ -9,7 +9,7 @@ import {
processDocument,
type ProcessedDocument,
} from '@/utils/process-document';
import type { defaultAdapters } from '@/media/adapter';
import { defaultAdapters } from '@/media/adapter';
type ApiPageContextProps = Pick<
Partial<RenderContext>,
@@ -145,14 +145,7 @@ export async function getContext(
generateCodeSamples: options.generateCodeSamples,
servers,
mediaAdapters: {
...({
'application/octet-stream': true,
'application/json': true,
'multipart/form-data': true,
'application/xml': true,
'application/x-ndjson': true,
'application/x-www-form-urlencoded': true,
} satisfies Record<keyof typeof defaultAdapters, true>),
...defaultAdapters,
...options.mediaAdapters,
},
slugger: new Slugger(),
@@ -7,7 +7,11 @@ import { CodeExample, CodeExampleProvider } from '@/ui/lazy';
import { getPreferredType, type NoReference } from '@/utils/schema';
import { getRequestData } from '@/render/operation/get-request-data';
import { sample } from 'openapi-sampler';
import type { RequestData } from '@/requests/_shared';
import {
encodeRequestData,
type RawRequestData,
type RequestData,
} from '@/requests/_shared';
const defaultSamples: CodeSample[] = [
{
@@ -47,7 +51,8 @@ interface CodeExampleItem {
name: string;
description?: string;
data: RequestData;
data: RawRequestData;
encoded: RequestData;
}
export function APIExampleProvider({
@@ -67,10 +72,7 @@ export function APIExampleProvider({
<CodeExampleProvider
initialKey={exclusiveSampleKey}
route={route}
examples={examples.map((example) => ({
key: example.key,
data: example.data,
}))}
examples={examples}
>
{children}
</CodeExampleProvider>
@@ -91,24 +93,36 @@ export function getAPIExamples(
const result: CodeExampleItem[] = [];
for (const [key, value] of Object.entries(bodyOfType.examples)) {
const data = getRequestData(path, method, key, ctx);
result.push({
key,
name: value.summary ?? key,
description: value.description,
data: getRequestData(path, method, key, ctx),
data,
encoded: encodeRequestData(
data,
ctx.mediaAdapters,
method.parameters ?? [],
),
});
}
if (result.length > 0) return result;
}
const data = getRequestData(path, method, null, ctx);
return [
{
key: '_default',
name: 'Default',
description: bodyOfType?.schema?.description,
data: getRequestData(path, method, null, ctx),
data,
encoded: encodeRequestData(
data,
ctx.mediaAdapters,
method.parameters ?? [],
),
},
];
}
@@ -2,15 +2,15 @@ import type { MethodInformation, RenderContext } from '@/types';
import type { ResolvedSchema } from '@/utils/schema';
import { getPreferredType } from '@/utils/schema';
import { sample } from 'openapi-sampler';
import type { RequestData } from '@/requests/_shared';
import { type RawRequestData } from '@/requests/_shared';
export function getRequestData(
path: string,
method: MethodInformation,
sampleKey: string | null,
_ctx: RenderContext,
): RequestData {
const result: RequestData = {
): RawRequestData {
const result: RawRequestData = {
path: {},
cookie: {},
header: {},
@@ -55,7 +55,7 @@ export function getRequestData(
throw new Error(
`Cannot find body schema for ${path} ${method.method}: missing media type`,
);
result.bodyMediaType = type as RequestData['bodyMediaType'];
result.bodyMediaType = type as RawRequestData['bodyMediaType'];
const bodyOfType = body[type];
if (bodyOfType.examples && sampleKey) {
+147 -5
View File
@@ -1,4 +1,6 @@
import type { MediaAdapter } from '@/media/adapter';
import type { NoReference } from '@/utils/schema';
import type { ParameterObject } from '@/types';
export type SampleGenerator = (
url: string,
@@ -8,15 +10,31 @@ export type SampleGenerator = (
},
) => string;
export interface RawRequestData {
method: string;
path: Record<string, unknown>;
query: Record<string, unknown>;
header: Record<string, unknown>;
cookie: Record<string, unknown>;
body?: unknown;
bodyMediaType?: string;
}
interface EncodedParameter {
readonly value: string | string[];
}
export interface RequestData {
method: string;
path: Record<string, string>;
query: Record<string, string>;
header: Record<string, string>;
cookie: Record<string, string>;
body?: unknown;
path: Record<string, EncodedParameter>;
query: Record<string, EncodedParameter>;
header: Record<string, EncodedParameter>;
cookie: Record<string, EncodedParameter>;
body?: unknown;
bodyMediaType?: string;
}
@@ -26,3 +44,127 @@ export function ident(code: string, tab: number = 1) {
.map((v) => ' '.repeat(tab) + v)
.join('\n');
}
export function encodeRequestData(
from: RawRequestData,
adapters: Record<string, MediaAdapter>,
parameters: NoReference<ParameterObject>[],
): RequestData {
function getMediaEncoder(field: NoReference<ParameterObject>) {
if (!field.content) return;
for (const k in field.content) {
if (k in adapters) {
return (v: unknown) => String(adapters[k].encode({ body: v }));
}
}
}
function writeObject(
parentKey: string,
value: object,
swallow: boolean,
output: Record<string, EncodedParameter>,
) {
for (const k in value) {
const prop: unknown = value[k as keyof object];
if (prop == null) continue;
const key = swallow ? k : `${parentKey}[${k}]`;
if (swallow || typeof prop !== 'object') {
output[key] = {
value: String(prop),
};
continue;
}
writeObject(key, value, swallow, output);
}
}
function write(
key: string,
value: unknown,
field: NoReference<ParameterObject>,
output: Record<string, EncodedParameter> = {},
): Record<string, EncodedParameter> {
const encoder = getMediaEncoder(field);
if (encoder) {
output[key] = { value: encoder(value) };
return output;
}
const explode = field.explode ?? true;
if (Array.isArray(value)) {
// header & cookie doesn't support explode for array values
if (explode && field.in !== 'header' && field.in !== 'cookie') {
output[key] = {
value: value.map(String),
};
return output;
}
const sep =
{
spaceDelimited: ' ',
pipeDelimited: '|',
}[field.style ?? 'form'] ?? ',';
output[key] = {
value: value.map(String).join(sep),
};
return output;
}
if (typeof value === 'object' && value) {
// header uses the original key
if (explode && field.in === 'header') {
output[key] = {
value: Object.entries(value)
.map(([k, v]) => `${k}=${v}`)
.join(','),
};
return output;
}
if (explode || field.style === 'deepObject') {
writeObject(key, value, explode, output);
return output;
}
output[key] = {
value: Object.entries(value).flat().join(','),
};
return output;
}
output[key] = {
value: String(value),
};
return output;
}
const result: Partial<RequestData> = {
method: from.method,
body: from.body,
bodyMediaType: from.bodyMediaType,
};
for (const type of ['cookie', 'query', 'header', 'path'] as const) {
const out = {};
for (const k in from[type]) {
const value = from[type][k];
if (value == null) continue;
const field = parameters.find((p) => p.name === k && p.in === type);
if (!field) continue;
write(k, value, field, out);
}
result[type] = out;
}
return result as RequestData;
}
+24 -17
View File
@@ -1,6 +1,5 @@
'use client';
import type { SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -10,12 +9,15 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
// Handle request body
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
body = mediaAdapters[data.bodyMediaType].generateExample(data, {
lang: 'csharp',
addImport(from) {
imports.add(from);
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'csharp',
addImport(from) {
imports.add(from);
},
},
});
);
}
for (const specifier of imports) {
@@ -29,31 +31,36 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
s.push('var client = new HttpClient();');
const headerLines: string[] = [];
function addHeader(key: string, value: unknown) {
headerLines.push(
`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`,
);
}
for (const k in headers) {
addHeader(k, headers[k].value);
}
// Add cookie header if cookies are present
if (Object.keys(data.cookie).length > 0) {
headers['cookie'] = Object.entries(data.cookie)
.map(([key, value]) => `${key}=${value}`)
const cookie = Object.entries(data.cookie)
.map(([key, param]) => `${key}=${param.value}`)
.join('; ');
}
// Add headers
const headerLines = Object.entries(headers).map(
([key, value]) =>
`client.DefaultRequestHeaders.Add("${key}", ${JSON.stringify(value)});`,
);
addHeader('cookie', cookie);
}
s.push(...headerLines);
// Build the request
const resolvedUrl = resolveRequestData(url, data);
const method =
data.method[0].toUpperCase() + data.method.slice(1).toLowerCase() + 'Async';
if (body) {
s.push(`var response = await client.${method}("${resolvedUrl}", body);`);
s.push(`var response = await client.${method}("${url}", body);`);
} else {
s.push(`var response = await client.${method}("${resolvedUrl}");`);
s.push(`var response = await client.${method}("${url}");`);
}
// Add response handling
+5 -6
View File
@@ -1,22 +1,21 @@
'use client';
import { escapeString, inputToString } from '@/utils/input-to-string';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data) => {
const s: string[] = [];
s.push(`curl -X ${data.method} "${resolveRequestData(url, data)}"`);
s.push(`curl -X ${data.method} "${url}"`);
for (const header in data.header) {
const value = `${header}: ${data.header[header]}`;
const value = `${header}: ${data.header[header].value}`;
s.push(`-H "${value}"`);
}
for (const cookie in data.cookie) {
const value = JSON.stringify(`${cookie}=${data.cookie[cookie]}`);
for (const k in data.cookie) {
const param = data.cookie[k];
s.push(`--cookie ${value}`);
s.push(`--cookie ${JSON.stringify(`${k}=${param.value}`)}`);
}
if (data.body && data.bodyMediaType === 'multipart/form-data') {
+17 -12
View File
@@ -1,34 +1,39 @@
'use client';
import { ident, SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
import { ident, type SampleGenerator } from '@/requests/_shared';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const imports = ['fmt', 'net/http', 'io/ioutil'];
const headers = new Map<string, string>();
const variables = new Map<string, string>();
variables.set('url', JSON.stringify(resolveRequestData(url, data)));
variables.set('url', JSON.stringify(url));
for (const header in data.header) {
headers.set(header, JSON.stringify(data.header[header]));
headers.set(header, JSON.stringify(data.header[header].value));
}
const cookies = Object.keys(data.cookie);
if (cookies.length > 0)
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers.set(
'Cookie',
JSON.stringify(cookies.map((p) => `${p}=${data.cookie[p]}`).join('; ')),
JSON.stringify(
cookies.map(([k, param]) => `${k}=${param.value}`).join('; '),
),
);
}
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
headers.set('Content-Type', `"${data.bodyMediaType}"`);
body = mediaAdapters[data.bodyMediaType].generateExample(data, {
lang: 'go',
addImport(from) {
imports.push(from);
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'go',
addImport(from) {
imports.push(from);
},
},
});
);
}
return `package main
+12 -11
View File
@@ -1,6 +1,5 @@
'use client';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
@@ -19,7 +18,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
const adapter = mediaAdapters[data.bodyMediaType];
body = adapter.generateExample(data, {
body = adapter.generateExample(data as { body: unknown }, {
lang: 'java',
addImport(specifier) {
imports.add(specifier);
@@ -44,13 +43,13 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
// Build request
s.push('HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()');
s.push(
ident(`.uri(URI.create(${JSON.stringify(resolveRequestData(url, data))}))`),
);
s.push(ident(`.uri(URI.create(${JSON.stringify(url)}))`));
// Add headers
for (const [key, value] of Object.entries(headers)) {
s.push(ident(`.header(${JSON.stringify(key)}, ${JSON.stringify(value)})`));
for (const [key, param] of Object.entries(headers)) {
s.push(
ident(`.header(${JSON.stringify(key)}, ${JSON.stringify(param.value)})`),
);
}
if (data.bodyMediaType) {
@@ -58,11 +57,13 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
// Add cookies if present
const cookieString = Object.entries(data.cookie)
.map(([key, value]) => `${key}=${value}`)
.join('; ');
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
const cookieString = cookies
.map(([key, param]) => `${key}=${param.value}`)
.join('; ');
if (cookieString.length > 0) {
s.push(ident(`.header("Cookie", ${JSON.stringify(cookieString)})`));
}
+18 -11
View File
@@ -1,15 +1,19 @@
'use client';
import { ident, type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const s: string[] = [];
const options = new Map<string, string>();
const headers = { ...data.header };
const headers: Record<string, string> = {};
if (Object.keys(data.cookie).length > 0) {
headers['cookie'] = Object.entries(data.cookie)
.map(([key, value]) => `${key}=${value}`)
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value as string;
}
const cookies = Object.entries(data.cookie);
if (cookies.length > 0) {
headers['cookie'] = cookies
.map(([key, param]) => `${key}=${param.value}`)
.join('; ');
}
@@ -19,12 +23,15 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
body = mediaAdapters[data.bodyMediaType].generateExample(data, {
lang: 'js',
addImport(from, name) {
s.unshift(`import { ${name} } from "${from}"`);
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'js',
addImport(from, name) {
s.unshift(`import { ${name} } from "${from}"`);
},
},
});
);
}
if (body) {
@@ -32,7 +39,7 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
options.set('body', 'body');
}
const params = [JSON.stringify(resolveRequestData(url, data))];
const params = [JSON.stringify(url)];
if (options.size > 0) {
const str = Array.from(options.entries())
.map(([k, v]) => ident(k === v ? k : `${k}: ${v}`))
+21 -8
View File
@@ -1,18 +1,20 @@
'use client';
import { type SampleGenerator } from '@/requests/_shared';
import { resolveRequestData } from '@/utils/url';
export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const headers = { ...data.header };
const headers: Record<string, string> = {};
const params = [`"${data.method}"`, 'url'];
let body: string | undefined;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
headers['Content-Type'] = data.bodyMediaType;
body = mediaAdapters[data.bodyMediaType].generateExample(data, {
lang: 'python',
});
body = mediaAdapters[data.bodyMediaType].generateExample(
data as { body: unknown },
{
lang: 'python',
},
);
if (body && data.bodyMediaType === 'application/json') {
params.push('json = body');
@@ -21,17 +23,28 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
}
}
for (const [k, v] of Object.entries(data.header)) {
headers[k] = v.value as string;
}
if (Object.keys(headers).length > 0) {
params.push(`headers = ${JSON.stringify(headers, null, 2)}`);
}
if (Object.keys(data.cookie).length > 0) {
params.push(`cookies = ${JSON.stringify(data.cookie, null, 2)}`);
const inputCookies = Object.entries(data.cookie);
if (inputCookies.length > 0) {
const cookies: Record<string, string> = {};
for (const [k, v] of inputCookies) {
cookies[k] = v.value as string;
}
params.push(`cookies = ${JSON.stringify(cookies, null, 2)}`);
}
return `import requests
url = ${JSON.stringify(resolveRequestData(url, data))}
url = ${JSON.stringify(url)}
${body ?? ''}
response = requests.request(${params.join(', ')})
+1 -1
View File
@@ -85,5 +85,5 @@ export interface RenderContext {
*/
showResponseSchema?: boolean;
mediaAdapters: Record<string, MediaAdapter | true>;
mediaAdapters: Record<string, MediaAdapter>;
}
+3 -1
View File
@@ -10,12 +10,14 @@ import {
import type { RenderContext, ServerObject } from '@/types';
import { defaultAdapters, type MediaAdapter } from '@/media/adapter';
export interface ApiProviderProps extends ApiContextType {
export interface ApiProviderProps
extends Omit<ApiContextType, 'mediaAdapters'> {
/**
* Base URL for API requests
*/
defaultBaseUrl?: string;
children?: ReactNode;
mediaAdapters?: Record<string, MediaAdapter>;
}
export interface SelectedServer {
@@ -21,23 +21,29 @@ import {
SelectValue,
} from '@/ui/components/select';
import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
import { joinURL, resolveServerUrl, withBase } from '@/utils/url';
import type { RequestData } from '@/requests/_shared';
import {
joinURL,
resolveRequestData,
resolveServerUrl,
withBase,
} from '@/utils/url';
import type { RawRequestData, RequestData } from '@/requests/_shared';
import { defaultSamples } from '@/requests';
type UpdateListener = (data: RequestData) => void;
type UpdateListener = (data: RawRequestData, encoded: RequestData) => void;
const CodeExampleContext = createContext<{
route: string;
examples: {
key: string;
data: RequestData;
data: RawRequestData;
encoded: RequestData;
}[];
key: string;
setKey: (key: string) => void;
setData: (data: RequestData) => void;
setData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: UpdateListener) => void;
removeListener: (listener: UpdateListener) => void;
@@ -52,7 +58,8 @@ export function CodeExampleProvider({
route: string;
examples: {
key: string;
data: RequestData;
data: RawRequestData;
encoded: RequestData;
}[];
initialKey?: string;
children: ReactNode;
@@ -60,33 +67,38 @@ export function CodeExampleProvider({
const [key, setKey] = useState(initialKey ?? examples[0].key);
const listeners = useRef<UpdateListener[]>([]);
const setData = useEffectEvent((newData: RequestData) => {
for (const example of examples) {
if (example.key === key) {
// persistent changes
example.data = newData;
const setData = useEffectEvent(
(data: RawRequestData, encoded: RequestData) => {
for (const example of examples) {
if (example.key === key) {
// persistent changes
example.data = data;
example.encoded = encoded;
break;
}
}
}
for (const listener of listeners.current) {
listener(newData);
}
});
for (const listener of listeners.current) {
listener(data, encoded);
}
},
);
const updateKey = useEffectEvent((newKey: string) => {
const data = examples.find((example) => example.key === newKey)?.data;
if (!data) return;
const example = examples.find((example) => example.key === newKey);
if (!example) return;
setKey(newKey);
for (const listener of listeners.current) {
listener(data);
listener(example.data, example.encoded);
}
});
const addListener = useEffectEvent((listener: UpdateListener) => {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
listener(examples.find((example) => example.key === key)!.data);
const example = examples.find((example) => example.key === key)!;
listener(example.data, example.encoded);
listeners.current.push(listener);
});
@@ -120,7 +132,7 @@ export function CodeExample(props: CodeSample) {
useContext(CodeExampleContext)!;
const { server } = useServerSelectContext();
const [data, setData] = useState(() => {
return examples.find((example) => example.key === key)!.data;
return examples.find((example) => example.key === key)!.encoded;
});
const sample = props.source
@@ -128,7 +140,7 @@ export function CodeExample(props: CodeSample) {
: defaultSamples.find((item) => item.label === props.label);
useEffect(() => {
const listener = setData;
const listener: UpdateListener = (_, encoded) => setData(encoded);
addListener(listener);
return () => {
@@ -148,7 +160,7 @@ export function CodeExample(props: CodeSample) {
? window.location.origin
: 'https://loading',
),
route,
resolveRequestData(route, data),
),
data,
{
+8 -7
View File
@@ -18,16 +18,17 @@ export function Root({
ctx,
...props
}: RootProps & HTMLAttributes<HTMLDivElement>) {
const mediaAdapters: Record<string, MediaAdapter> = {};
for (const k in ctx.mediaAdapters) {
const adapter = ctx.mediaAdapters[k];
if (adapter.client) mediaAdapters[k] = adapter.client;
}
return (
<div className={cn('flex flex-col gap-24 text-sm', className)} {...props}>
<ApiProvider
mediaAdapters={
Object.fromEntries(
Object.entries(ctx.mediaAdapters).filter(
([_, v]) => typeof v !== 'boolean',
),
) as Record<string, MediaAdapter>
}
mediaAdapters={mediaAdapters}
servers={ctx.servers}
shikiOptions={ctx.shikiOptions}
>
+10 -10
View File
@@ -36,25 +36,25 @@ export function resolveRequestData(
{ path, query }: RequestData,
): string {
for (const key in path) {
if (path[key] === '') continue;
const param = path[key];
if (Array.isArray(path[key])) {
pathname = pathname.replace(`{${key}}`, path[key].join('/'));
if (Array.isArray(param.value)) {
pathname = pathname.replace(`{${key}}`, param.value.join('/'));
} else {
pathname = pathname.replace(`{${key}}`, String(path[key]));
pathname = pathname.replace(`{${key}}`, param.value);
}
}
const searchParams = new URLSearchParams();
for (const key in query) {
if (Array.isArray(query[key])) {
for (const value of query[key]) {
if (!query[key]) continue;
const param = query[key];
searchParams.append(key, String(value));
if (Array.isArray(param.value)) {
for (const item of param.value) {
searchParams.append(key, item);
}
} else if (query[key] && query[key] !== '') {
searchParams.set(key, String(query[key]));
} else {
searchParams.append(key, param.value);
}
}
+6 -5
View File
@@ -5,12 +5,12 @@ import * as Curl from '@/requests/curl';
import * as JS from '@/requests/javascript';
import type { RequestData } from '@/requests/_shared';
import { defaultAdapters } from '@/media/adapter';
import { resolveRequestData } from '@/utils/url';
describe('Code Sample Generators', () => {
const url = 'http://localhost:8080/{test}';
const data: RequestData = {
path: {
test: 'hello_world',
test: { value: 'hello_world' },
},
body: {
id: 'id',
@@ -18,15 +18,16 @@ describe('Code Sample Generators', () => {
bodyMediaType: 'application/json',
method: 'GET',
cookie: {
mode: 'light',
mode: { value: 'light' },
},
header: {
authorization: 'Bearer',
authorization: { value: 'Bearer' },
},
query: {
search: 'ai',
search: { value: 'ai' },
},
};
const url = resolveRequestData('http://localhost:8080/{test}', data);
const context = {
mediaAdapters: defaultAdapters,
+2 -2
View File
@@ -5,8 +5,8 @@ body = {
"id": "id"
}
response = requests.request("GET", url, json = body, headers = {
"authorization": "Bearer",
"Content-Type": "application/json"
"Content-Type": "application/json",
"authorization": "Bearer"
}, cookies = {
"mode": "light"
})