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