OpenAPI: Lazy load media adapters on client side

This commit is contained in:
Fuma Nama
2025-05-06 21:36:43 +08:00
parent a67057ea60
commit 12297defa1
18 changed files with 244 additions and 228 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Lazy load media adapters on client side
@@ -1,150 +0,0 @@
'use client';
import { inputToString } from '@/utils/input-to-string';
import type { MediaAdapter, MediaContext } from '@/media/adapter';
export const json: MediaAdapter = {
encode(data) {
return JSON.stringify(data.body);
},
generateExample(data, ctx) {
return str(data.body, 'json', ctx);
},
};
export const xml: MediaAdapter = {
async encode(data) {
const { js2xml } = await import('xml-js');
return js2xml(data.body as Record<string, unknown>, {
compact: true,
spaces: 2,
});
},
generateExample(data, ctx) {
return str(data.body, 'xml', ctx);
},
};
export const url: MediaAdapter = {
encode(data) {
if (typeof data.body !== 'object')
throw new Error(
`Input value must be object, received: ${typeof data.body}`,
);
const params = new URLSearchParams();
for (const key in data.body) {
params.set(key, String(data.body[key as keyof object]));
}
return params;
},
generateExample(data, ctx) {
if (ctx.lang === 'js') {
return `const body = new URLSearchParams(${JSON.stringify(data.body, null, 2)})`;
}
return str(data.body, 'url', ctx);
},
};
export const formData: MediaAdapter = {
encode(data) {
const formData = new FormData();
const body = data.body as Record<string, unknown>;
if (typeof body !== 'object' || !body) {
throw new Error(
`Unsupported body type: ${typeof body}, expected: object`,
);
}
for (const key in body) {
const prop = body[key];
if (typeof prop === 'object' && prop instanceof File) {
formData.set(key, prop);
}
if (Array.isArray(prop) && prop.every((item) => item instanceof File)) {
for (const item of prop) {
formData.append(key, item);
}
}
if (prop && !(prop instanceof File)) {
formData.set(key, JSON.stringify(prop));
}
}
return formData;
},
generateExample(data, ctx) {
const s: string[] = [];
if (ctx.lang === 'js') {
s.push(`const body = new FormData();`);
for (const [key, value] of Object.entries(data.body as object)) {
s.push(`body.set(${key}, ${inputToString(value)})`);
}
}
if (ctx.lang === 'go' && 'addImport' in ctx) {
ctx.addImport('mime/multipart');
ctx.addImport('bytes');
s.push('body := new(bytes.Buffer)');
s.push('mp := multipart.NewWriter(payload)');
for (const [key, value] of Object.entries(data.body as object)) {
s.push(
`mp.WriteField("${key}", ${inputToString(value, 'json', 'backtick')})`,
);
}
}
if (s.length > 0) return s.join('\n');
},
};
export const ndJson: MediaAdapter = {
encode(data) {
if (Array.isArray(data.body)) {
return data.body.map((v) => JSON.stringify(v)).join('\n');
}
return JSON.stringify(data.body);
},
generateExample(data, ctx) {
return str(data.body, 'ndjson', ctx);
},
};
export const octet: MediaAdapter = {
encode(data) {
return data.body as BodyInit;
},
generateExample() {
// not supported
return undefined;
},
};
function str(
init: unknown,
format: 'xml' | 'json' | 'url' | 'ndjson',
ctx: MediaContext,
) {
if (ctx.lang === 'js') {
if (format === 'json') {
return `const body = JSON.stringify(${JSON.stringify(init, null, 2)})`;
}
return `const body = ${inputToString(init, format, 'backtick')}`;
}
if (ctx.lang === 'python') {
return `body = ${inputToString(init, format, 'python')}`;
}
if (ctx.lang === 'go' && 'addImport' in ctx) {
ctx.addImport('strings');
return `body := strings.NewReader(${inputToString(init, format, 'backtick')})`;
}
}
+154 -9
View File
@@ -1,5 +1,6 @@
'use client';
import { inputToString } from '@/utils/input-to-string';
import type { RequestData } from '@/requests/_shared';
import * as adapters from './adapter.client';
interface GoContext {
lang: 'go';
@@ -42,11 +43,155 @@ export interface MediaAdapter {
generateExample: (data: RequestData, ctx: MediaContext) => string | undefined;
}
export const defaultAdapters: Record<string, MediaAdapter> = {
'application/json': adapters.json,
'application/xml': adapters.xml,
'application/x-ndjson': adapters.ndJson,
'application/x-www-form-urlencoded': adapters.url,
'multipart/form-data': adapters.formData,
'application/octet-stream': adapters.octet,
};
export const defaultAdapters = {
'application/json': {
encode(data) {
return JSON.stringify(data.body);
},
generateExample(data, ctx) {
return str(data.body, 'json', ctx);
},
},
'application/xml': {
async encode(data) {
// @ts-expect-error -- untyped
const { js2xml } = await import('xml-js/lib/js2xml');
return js2xml(data.body as Record<string, unknown>, {
compact: true,
spaces: 2,
});
},
generateExample(data, ctx) {
return str(data.body, 'xml', ctx);
},
},
'application/x-ndjson': {
encode(data) {
if (Array.isArray(data.body)) {
return data.body.map((v) => JSON.stringify(v)).join('\n');
}
return JSON.stringify(data.body);
},
generateExample(data, ctx) {
return str(data.body, 'ndjson', ctx);
},
},
'application/x-www-form-urlencoded': {
encode(data) {
if (typeof data.body !== 'object')
throw new Error(
`Input value must be object, received: ${typeof data.body}`,
);
const params = new URLSearchParams();
for (const key in data.body) {
params.set(key, String(data.body[key as keyof object]));
}
return params;
},
generateExample(data, ctx) {
if (ctx.lang === 'js') {
return `const body = new URLSearchParams(${JSON.stringify(data.body, null, 2)})`;
}
return str(data.body, 'url', ctx);
},
},
'multipart/form-data': {
encode(data) {
const formData = new FormData();
const body = data.body as Record<string, unknown>;
if (typeof body !== 'object' || !body) {
throw new Error(
`Unsupported body type: ${typeof body}, expected: object`,
);
}
for (const key in body) {
const prop = body[key];
if (typeof prop === 'object' && prop instanceof File) {
formData.set(key, prop);
}
if (Array.isArray(prop) && prop.every((item) => item instanceof File)) {
for (const item of prop) {
formData.append(key, item);
}
}
if (prop && !(prop instanceof File)) {
formData.set(key, JSON.stringify(prop));
}
}
return formData;
},
generateExample(data, ctx) {
if (ctx.lang === 'python') {
return `body = ${JSON.stringify(data.body, null, 2)}`;
}
const s: string[] = [];
if (ctx.lang === 'js') {
s.push(`const body = new FormData();`);
for (const [key, value] of Object.entries(data.body as object)) {
s.push(`body.set(${key}, ${inputToString(value)})`);
}
}
if (ctx.lang === 'go' && 'addImport' in ctx) {
ctx.addImport('mime/multipart');
ctx.addImport('bytes');
s.push('body := new(bytes.Buffer)');
s.push('mp := multipart.NewWriter(payload)');
for (const [key, value] of Object.entries(data.body as object)) {
s.push(
`mp.WriteField("${key}", ${inputToString(value, 'json', 'backtick')})`,
);
}
}
if (s.length > 0) return s.join('\n');
},
},
'application/octet-stream': {
encode(data) {
return data.body as BodyInit;
},
generateExample() {
// not supported
return undefined;
},
},
} satisfies Record<string, MediaAdapter>;
function str(
init: unknown,
format: 'xml' | 'json' | 'url' | 'ndjson',
ctx: MediaContext,
) {
if (ctx.lang === 'js') {
if (format === 'json') {
return `const body = JSON.stringify(${JSON.stringify(init, null, 2)})`;
}
return `const body = ${inputToString(init, format, 'backtick')}`;
}
if (ctx.lang === 'python') {
if (format === 'json') return `body = ${JSON.stringify(init, null, 2)}`;
return `body = ${inputToString(init, format, 'python')}`;
}
if (ctx.lang === 'go' && 'addImport' in ctx) {
ctx.addImport('strings');
return `body := strings.NewReader(${inputToString(init, format, 'backtick')})`;
}
}
@@ -1,4 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const ClientLazy = dynamic(() => import('./client'));
+1 -4
View File
@@ -48,7 +48,6 @@ import {
SchemaProvider,
useResolvedSchema,
} from '@/playground/schema';
import type { MediaAdapter } from '@/media/adapter';
interface FormValues {
authorization:
@@ -105,8 +104,6 @@ export type ClientProps = HTMLAttributes<HTMLFormElement> & {
components?: Partial<{
ResultDisplay: FC<{ data: FetchResult }>;
}>;
mediaAdapters: Record<string, MediaAdapter>;
};
function toRequestData(
@@ -136,7 +133,6 @@ export default function Client({
fields,
references,
proxyUrl,
mediaAdapters,
components: { ResultDisplay = DefaultResultDisplay } = {},
...rest
}: ClientProps) {
@@ -144,6 +140,7 @@ export default function Client({
const requestData = useRequestData();
const fieldInfoMap = useMemo(() => new Map<string, FieldInfo>(), []);
const authInfo = usePersistentAuthInfo(authorization);
const { mediaAdapters } = useApiContext();
const defaultValues: FormValues = useMemo(
() => ({
authorization: authInfo.info,
+1 -2
View File
@@ -2,7 +2,7 @@ import type { MethodInformation, RenderContext } from '@/types';
import { getPreferredType, type ParsedSchema } from '@/utils/schema';
import { getSecurities } from '@/utils/get-security';
import { type ClientProps } from './client';
import { ClientLazy } from '@/playground/client.lazy';
import { ClientLazy } from '@/ui/lazy';
export type ParameterField = {
name: string;
@@ -51,7 +51,6 @@ export async function APIPlayground({
authorization: getAuthorizationField(method, ctx),
method: method.method,
route: path,
mediaAdapters: ctx.mediaAdapters,
parameters: method.parameters?.map((v) => ({
name: v.name,
in: v.in as ParameterField['in'],
+9 -2
View File
@@ -10,7 +10,7 @@ import {
type ProcessedDocument,
} from '@/utils/process-document';
import { getUrl } from '@/utils/server-url';
import { defaultAdapters } from '@/media/adapter';
import type { defaultAdapters } from '@/media/adapter';
type ApiPageContextProps = Pick<
Partial<RenderContext>,
@@ -159,7 +159,14 @@ export async function getContext(
),
servers,
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,
},
slugger: new Slugger(),
@@ -3,10 +3,7 @@ import { type ReactNode } from 'react';
import { Markdown } from '@/render/markdown';
import { type CodeSample } from '@/render/operation/index';
import { CodeBlock } from '@/render/codeblock';
import {
CodeExample,
CodeExampleProvider,
} from '@/ui/contexts/code-example.lazy';
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';
+1 -1
View File
@@ -11,7 +11,7 @@ import {
} from '@/ui';
import type { RenderContext } from '@/types';
import { APIPlayground, type APIPlaygroundProps } from '@/playground';
import { CodeExampleSelector } from '@/ui/contexts/code-example.lazy';
import { CodeExampleSelector } from '@/ui/lazy';
export interface ResponsesProps {
items: string[];
+9 -15
View File
@@ -6,23 +6,17 @@ export const generator: SampleGenerator = (url, data, { mediaAdapters }) => {
const params = [`"${data.method}"`, 'url'];
let body: string | undefined;
if (data.body && data.bodyMediaType) {
if (data.bodyMediaType === 'application/json') {
params.push(`json = ${JSON.stringify(data.body, null, 2)}`);
} else if (data.bodyMediaType === 'multipart/form-data') {
headers['Content-Type'] = data.bodyMediaType;
if (data.body && data.bodyMediaType && data.bodyMediaType in mediaAdapters) {
headers['Content-Type'] = data.bodyMediaType;
params.push(`data = ${JSON.stringify(data.body, null, 2)}`);
} else if (data.bodyMediaType in mediaAdapters) {
headers['Content-Type'] = data.bodyMediaType;
body = mediaAdapters[data.bodyMediaType].generateExample(data, {
lang: 'python',
});
body = mediaAdapters[data.bodyMediaType].generateExample(data, {
lang: 'python',
});
if (body) {
params.push('data = body');
}
if (body && data.bodyMediaType === 'application/json') {
params.push('json = body');
} else if (body) {
params.push('data = body');
}
}
+1 -1
View File
@@ -86,5 +86,5 @@ export interface RenderContext {
*/
showResponseSchema?: boolean;
mediaAdapters: Record<string, MediaAdapter>;
mediaAdapters: Record<string, MediaAdapter | true>;
}
-2
View File
@@ -5,8 +5,6 @@ import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from 'fumadocs-ui/utils/cn';
import { Check, Copy } from 'lucide-react';
export { ClientLazy as APIPlayground } from '@/playground/client.lazy';
export function CopyResponseTypeScript({ code }: { code: string }) {
const [isChecked, onCopy] = useCopyButton(() => {
void navigator.clipboard.writeText(code);
+27 -14
View File
@@ -8,18 +8,13 @@ import {
useState,
} from 'react';
import type { RenderContext, ServerObject } from '@/types';
import type { MediaAdapter } from '@/media/adapter';
import { defaultAdapters, type MediaAdapter } from '@/media/adapter';
export interface ApiProviderProps {
export interface ApiProviderProps extends ApiContextType {
/**
* Base URL for API requests
*/
defaultBaseUrl?: string;
servers: ServerObject[];
shikiOptions: RenderContext['shikiOptions'];
mediaAdapters: Record<string, MediaAdapter>;
children?: ReactNode;
}
@@ -28,7 +23,11 @@ export interface SelectedServer {
variables: Record<string, string>;
}
type ApiContextType = Omit<ApiProviderProps, 'children' | 'defaultBaseUrl'>;
interface ApiContextType {
servers: ServerObject[];
shikiOptions: RenderContext['shikiOptions'];
mediaAdapters: Record<string, MediaAdapter>;
}
interface ServerSelectType {
server: SelectedServer | null;
@@ -56,12 +55,14 @@ export function useServerSelectContext(): ServerSelectType {
export function ApiProvider({
defaultBaseUrl,
children,
...props
servers,
mediaAdapters,
shikiOptions,
}: ApiProviderProps) {
const [server, setServer] = useState<SelectedServer | null>(() => {
const defaultItem = defaultBaseUrl
? props.servers.find((item) => item.url === defaultBaseUrl)
: undefined;
? servers.find((item) => item.url === defaultBaseUrl)
: null;
return defaultItem
? {
@@ -86,7 +87,19 @@ export function ApiProvider({
}, []);
return (
<ApiContext.Provider value={props}>
<ApiContext.Provider
value={useMemo(
() => ({
shikiOptions,
mediaAdapters: {
...defaultAdapters,
...mediaAdapters,
},
servers,
}),
[mediaAdapters, servers, shikiOptions],
)}
>
<ServerSelectContext.Provider
value={useMemo(
() => ({
@@ -101,7 +114,7 @@ export function ApiProvider({
});
},
setServer(value) {
const obj = props.servers.find((item) => item.url === value);
const obj = servers.find((item) => item.url === value);
if (!obj) return;
const result: SelectedServer = {
@@ -113,7 +126,7 @@ export function ApiProvider({
setServer(result);
},
}),
[server, props.servers],
[server, servers],
)}
>
{children}
@@ -1,12 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
export const CodeExampleProvider = dynamic(() =>
import('./code-example').then((mod) => mod.CodeExampleProvider),
);
export const CodeExample = dynamic(() =>
import('./code-example').then((mod) => mod.CodeExample),
);
export const CodeExampleSelector = dynamic(() =>
import('./code-example').then((mod) => mod.CodeExampleSelector),
);
+9 -2
View File
@@ -7,9 +7,10 @@ import {
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import { ApiProvider } from '@/ui/contexts/api';
import { ApiProvider } from '@/ui/lazy';
import { cn } from 'fumadocs-ui/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import type { MediaAdapter } from '@/media/adapter';
export function Root({
children,
@@ -26,7 +27,13 @@ export function Root({
{...props}
>
<ApiProvider
mediaAdapters={ctx.mediaAdapters}
mediaAdapters={
Object.fromEntries(
Object.entries(ctx.mediaAdapters).filter(
([_, v]) => typeof v !== 'boolean',
),
) as Record<string, MediaAdapter>
}
servers={ctx.servers}
shikiOptions={ctx.shikiOptions}
defaultBaseUrl={ctx.baseUrl}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import dynamic from 'next/dynamic';
export const CodeExampleProvider = dynamic(() =>
import('./contexts/code-example').then((mod) => mod.CodeExampleProvider),
);
export const CodeExample = dynamic(() =>
import('./contexts/code-example').then((mod) => mod.CodeExample),
);
export const CodeExampleSelector = dynamic(() =>
import('./contexts/code-example').then((mod) => mod.CodeExampleSelector),
);
export const ClientLazy = dynamic(() => import('@/playground/client'));
export const ApiProvider = dynamic(() =>
import('./contexts/api').then((mod) => mod.ApiProvider),
);
@@ -1,4 +1,5 @@
import { type ElementCompact, js2xml } from 'xml-js';
// @ts-expect-error -- nothing
import js2xml from 'xml-js/lib/js2xml';
/**
* Convert input value to hardcoded string (with quotes)
@@ -47,5 +48,5 @@ export function inputToString(
return getStr(params.toString());
}
return getStr(js2xml(value as ElementCompact, { compact: true, spaces: 2 }));
return getStr(js2xml(value, { compact: true, spaces: 2 }));
}
+5 -4
View File
@@ -1,11 +1,12 @@
import requests
url = "http://localhost:8080/hello_world?search=ai"
response = requests.request("GET", url, json = {
body = {
"id": "id"
}, headers = {
"authorization": "Bearer"
}
response = requests.request("GET", url, json = body, headers = {
"authorization": "Bearer",
"Content-Type": "application/json"
}, cookies = {
"mode": "light"
})