OpenAPI: Display webhook request examples

This commit is contained in:
Fuma Nama
2025-11-25 20:27:50 +08:00
parent 254132bf65
commit e5c9f7f7ca
19 changed files with 792 additions and 675 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Display webhook request examples
+2 -6
View File
@@ -60,7 +60,6 @@ import {
SchemaProvider,
useResolvedSchema,
} from '@/playground/schema';
import { useOperationContext } from '@/ui/contexts/operation';
import {
Select,
SelectContent,
@@ -73,6 +72,7 @@ import type { ParsedSchema } from '@/utils/schema';
import type { RequestData } from '@/requests/types';
import ServerSelect from './components/server-select';
import { useStorageKey } from '@/ui/client/storage-key';
import { useExampleRequests } from '@/ui/operation/usage-tabs/client';
export interface FormValues {
path: Record<string, unknown>;
@@ -167,11 +167,7 @@ export default function PlaygroundClient({
proxyUrl,
...rest
}: PlaygroundClientProps) {
const {
example: exampleId,
examples,
setExampleData,
} = useOperationContext();
const { example: exampleId, examples, setExampleData } = useExampleRequests();
const storageKeys = useStorageKey();
const fieldInfoMap = useMemo(() => new Map<string, FieldInfo>(), []);
const {
@@ -4,7 +4,7 @@ import * as Go from './go';
import * as Python from './python';
import * as Java from './java';
import * as CSharp from './csharp';
import type { CodeUsageGenerator } from '@/ui/operation/example-panel';
import type { CodeUsageGenerator } from '@/ui/operation/usage-tabs';
export const defaultSamples: CodeUsageGenerator[] = [
{
+1 -1
View File
@@ -4,7 +4,7 @@ import {
processDocument,
type ProcessedDocument,
} from '@/utils/process-document';
import type { CodeUsageGenerator } from '@/ui/operation/example-panel';
import type { CodeUsageGenerator } from '@/ui/operation/usage-tabs';
/**
* schema id -> file path, URL, or downloaded schema object
+1 -1
View File
@@ -5,7 +5,7 @@ import type { ProcessedDocument } from '@/utils/process-document';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { OpenAPIOptions } from '@/server';
import type { CreateAPIPageOptions } from './ui/api-page';
import type { CodeUsageGenerator } from './ui/operation/example-panel';
import type { CodeUsageGenerator } from './ui/operation/usage-tabs';
import type { ReactNode } from 'react';
export type Document = V3_1.Document;
+13 -5
View File
@@ -14,10 +14,7 @@ import {
} from 'fumadocs-core/highlight';
import type { OpenAPIServer } from '@/server';
import type { APIPageClientOptions } from './client';
import type {
CodeUsageGenerator,
ResponseTab,
} from './operation/example-panel';
import type { CodeUsageGenerator } from './operation/usage-tabs';
import { ApiProviderLazy } from './contexts/api.lazy';
import { Heading } from 'fumadocs-ui/components/heading';
import {
@@ -32,6 +29,8 @@ import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as JsxRuntime from 'react/jsx-runtime';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
import type { SchemaUIOptions } from './schema';
import type { ResponseTab } from './operation/response-tabs';
import type { ExampleRequestItem } from './operation/request-tabs';
type Awaitable<T> = T | Promise<T>;
@@ -82,6 +81,14 @@ export interface CreateAPIPageOptions {
ctx: RenderContext,
) => Awaitable<ReactNode>;
renderRequestTabs: (
items: ExampleRequestItem[],
ctx: RenderContext & {
route: string;
operation: NoReference<MethodInformation>;
},
) => Awaitable<ReactNode>;
renderAPIExampleLayout?: (
slots: {
selector: ReactNode;
@@ -130,7 +137,7 @@ export interface CreateAPIPageOptions {
callbacks: ReactNode;
},
ctx: RenderContext,
method: MethodInformation,
method: NoReference<MethodInformation>,
) => Awaitable<ReactNode>;
renderWebhookLayout?: (slots: {
@@ -139,6 +146,7 @@ export interface CreateAPIPageOptions {
authSchemes: ReactNode;
paremeters: ReactNode;
body: ReactNode;
requests: ReactNode;
responses: ReactNode;
callbacks: ReactNode;
}) => Awaitable<ReactNode>;
+2 -2
View File
@@ -1,8 +1,8 @@
'use client';
import type { PlaygroundClientOptions } from '@/playground/client';
import type { MediaAdapter } from '@/requests/media/adapter';
import type { APIExampleItem } from '../operation/example-panel';
import type { FC } from 'react';
import type { ExampleRequestItem } from '../operation/request-tabs';
export interface APIPageClientOptions {
playground?: PlaygroundClientOptions;
@@ -25,7 +25,7 @@ export interface APIPageClientOptions {
export interface OperationClientOptions {
APIExampleSelector?: FC<{
items: APIExampleItem[];
items: ExampleRequestItem[];
value: string | undefined;
onValueChange: (id: string) => void;
@@ -1,6 +0,0 @@
'use client';
import { wrapLazy } from '../../utils/lazy';
export const OperationProviderLazy = wrapLazy(() =>
import('./operation').then((mod) => ({ default: mod.OperationProvider })),
);
@@ -1,99 +0,0 @@
'use client';
import {
createContext,
type ReactNode,
use,
useMemo,
useRef,
useState,
} from 'react';
import type { APIExampleItem } from '@/ui/operation/example-panel';
import type { RawRequestData, RequestData } from '@/requests/types';
export type ExampleUpdateListener = (
data: RawRequestData,
encoded: RequestData,
) => void;
const OperationContext = createContext<{
route: string;
examples: APIExampleItem[];
example: string | undefined;
setExample: (id: string) => void;
setExampleData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: ExampleUpdateListener) => void;
removeListener: (listener: ExampleUpdateListener) => void;
} | null>(null);
export function OperationProvider({
route,
examples,
defaultExampleId,
children,
}: {
route: string;
examples: APIExampleItem[];
defaultExampleId?: string;
children: ReactNode;
}) {
const [example, setExample] = useState(
() => defaultExampleId ?? examples.at(0)?.id,
);
const listeners = useRef<ExampleUpdateListener[]>([]);
return (
<OperationContext
value={useMemo(
() => ({
example,
route,
setExample: (newKey: string) => {
const example = examples.find((example) => example.id === newKey);
if (!example) return;
setExample(newKey);
for (const listener of listeners.current) {
listener(example.data, example.encoded);
}
},
examples,
setExampleData: (data, encoded) => {
for (const item of examples) {
if (item.id === example) {
// persistent changes
item.data = data;
item.encoded = encoded;
break;
}
}
for (const listener of listeners.current) {
listener(data, encoded);
}
},
removeListener: (listener) => {
listeners.current = listeners.current.filter(
(item) => item !== listener,
);
},
addListener: (listener) => {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
const active = examples.find((item) => item.id === example)!;
listener(active.data, active.encoded);
listeners.current.push(listener);
},
}),
[example, route, examples],
)}
>
{children}
</OperationContext>
);
}
export function useOperationContext() {
return use(OperationContext)!;
}
@@ -1,110 +0,0 @@
'use client';
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import {
useOperationContext,
type ExampleUpdateListener,
} from '@/ui/contexts/operation';
import {
joinURL,
withBase,
resolveServerUrl,
resolveRequestData,
} from '@/utils/url';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/ui/components/select';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { useState, useEffect, useMemo } from 'react';
import type { APIExampleItem, CodeUsageGenerator } from '.';
export function APIExampleSelector() {
const { example: key, setExample: setKey, examples } = useOperationContext();
const { APIExampleSelector: Override } =
useApiContext().client.operation ?? {};
if (Override) {
return <Override items={examples} value={key} onValueChange={setKey} />;
}
function renderItem(item: APIExampleItem) {
return (
<div>
<span className="font-medium text-sm">{item.name}</span>
<span className="text-fd-muted-foreground">{item.description}</span>
</div>
);
}
if (examples.length === 1) return null;
const selected = examples.find((item) => item.id === key);
return (
<Select value={key} onValueChange={setKey}>
<SelectTrigger className="not-prose mb-2">
{selected && <SelectValue asChild>{renderItem(selected)}</SelectValue>}
</SelectTrigger>
<SelectContent>
{examples.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderItem(item)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function APIExampleUsageTab(sample: CodeUsageGenerator) {
const { shikiOptions, mediaAdapters } = useApiContext();
const {
examples,
example: selectedExampleId,
route,
addListener,
removeListener,
} = useOperationContext();
const { server } = useServerSelectContext();
const [data, setData] = useState(
() => examples.find((example) => example.id === selectedExampleId)?.encoded,
);
useEffect(() => {
const listener: ExampleUpdateListener = (_, encoded) => setData(encoded);
addListener(listener);
return () => {
removeListener(listener);
};
}, [addListener, removeListener]);
const code = useMemo(() => {
if (!sample.source || !data) return;
if (typeof sample.source === 'string') return sample.source;
return sample.source(
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined'
? window.location.origin
: 'https://loading',
),
resolveRequestData(route, data),
),
data,
{
server: sample.serverContext,
mediaAdapters,
},
);
}, [mediaAdapters, sample, server, route, data]);
if (!code || !sample) return null;
return (
<DynamicCodeBlock lang={sample.lang} code={code} options={shikiOptions} />
);
}
@@ -1,328 +0,0 @@
import type { MethodInformation, RenderContext, ResponseObject } from '@/types';
import type { ReactNode } from 'react';
import { getPreferredType, type NoReference } from '@/utils/schema';
import { getRequestData } from '@/ui/operation/get-request-data';
import { sample } from 'openapi-sampler';
import type {
RawRequestData,
RequestData,
SampleGenerator,
} from '@/requests/types';
import { defaultSamples } from '@/requests/generators';
import { encodeRequestData } from '@/requests/media/encode';
import {
AccordionContent,
AccordionHeader,
AccordionItem,
Accordions,
AccordionTrigger,
} from '@/ui/components/accordion';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import {
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
} from 'fumadocs-ui/components/codeblock';
import { APIExampleSelectorLazy, APIExampleUsageTabLazy } from './lazy';
/**
* Generate code example for given programming language
*/
export interface CodeUsageGenerator<T = unknown> {
id: string;
lang: string;
label?: string;
/**
* either:
* - code
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | SampleGenerator<T> | false;
/**
* Pass extra context to client-side source generator
*/
serverContext?: T;
}
export interface APIExampleItem {
id: string;
name: string;
description?: string;
data: RawRequestData;
encoded: RequestData;
}
export interface ResponseTab {
/**
* HTTP response code
*/
code: string;
response: NoReference<ResponseObject>;
/**
* media type of response
*/
mediaType: string | null;
examples?: {
/**
* generated/defined example data
*/
sample: unknown;
label: string;
/**
* description (in Markdown)
*/
description?: string;
}[];
}
export function getAPIExamples(
path: string,
method: MethodInformation,
ctx: RenderContext,
): APIExampleItem[] {
const media = method.requestBody
? getPreferredType(method.requestBody.content)
: null;
const bodyOfType = media ? method.requestBody?.content[media] : null;
if (bodyOfType?.examples) {
const result: APIExampleItem[] = [];
for (const [key, value] of Object.entries(bodyOfType.examples)) {
const data = getRequestData(path, method, key, ctx);
result.push({
id: key,
name: value.summary ?? key,
description: value.description,
data,
encoded: encodeRequestData(
data,
ctx.mediaAdapters,
method.parameters ?? [],
),
});
}
if (result.length > 0) return result;
}
const data = getRequestData(path, method, null, ctx);
return [
{
id: '_default',
name: 'Default',
description: bodyOfType?.schema?.description,
data,
encoded: encodeRequestData(
data,
ctx.mediaAdapters,
method.parameters ?? [],
),
},
];
}
export async function APIExample({
method,
ctx,
}: {
method: MethodInformation;
ctx: RenderContext;
}) {
let { renderAPIExampleUsageTabs, renderAPIExampleLayout } = ctx.content ?? {};
renderAPIExampleLayout ??= (slots) => {
return (
<div className="prose-no-margin md:sticky md:top-[calc(var(--fd-docs-row-1,2rem)+1rem)] xl:w-[400px]">
{slots.selector}
{slots.usageTabs}
{slots.responseTabs}
</div>
);
};
renderAPIExampleUsageTabs ??= (generators) => {
if (generators.length === 0) return null;
return (
<CodeBlockTabs
groupId="fumadocs_openapi_requests"
defaultValue={generators[0].id}
>
<CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTabsTrigger key={item.id} value={item.id}>
{item.label ?? item.lang}
</CodeBlockTabsTrigger>
))}
</CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTab key={item.id} value={item.id}>
<APIExampleUsageTabLazy {...item} />
</CodeBlockTab>
))}
</CodeBlockTabs>
);
};
let generators: CodeUsageGenerator[] = [...defaultSamples];
if (ctx.generateCodeSamples) {
generators.push(...(await ctx.generateCodeSamples(method)));
}
if (method['x-codeSamples']) {
for (const sample of method['x-codeSamples']) {
generators.push(
'id' in sample && typeof sample.id === 'string'
? (sample as CodeUsageGenerator)
: {
id: sample.lang,
...sample,
},
);
}
}
generators = dedupe(generators);
return renderAPIExampleLayout(
{
selector: method['x-exclusiveCodeSample'] ? null : (
<APIExampleSelectorLazy />
),
usageTabs: await renderAPIExampleUsageTabs(generators, ctx),
responseTabs: <ResponseTabs operation={method} ctx={ctx} />,
},
ctx,
);
}
/**
* Remove duplicated ids
*/
function dedupe(samples: CodeUsageGenerator[]): CodeUsageGenerator[] {
const set = new Set<string>();
const out: CodeUsageGenerator[] = [];
for (let i = samples.length - 1; i >= 0; i--) {
const item = samples[i];
if (set.has(item.id)) continue;
set.add(item.id);
if (item.source !== false) out.unshift(item);
}
return out;
}
function ResponseTabs({
operation,
ctx,
}: {
operation: NoReference<MethodInformation>;
ctx: RenderContext;
}) {
if (!operation.responses) return null;
const tabs: ResponseTab[] = [];
for (const [code, response] of Object.entries(operation.responses)) {
const media = response.content ? getPreferredType(response.content) : null;
const responseOfType = media ? response.content?.[media] : null;
const tab: ResponseTab = {
code,
response,
mediaType: media as string | null,
};
if (responseOfType?.examples) {
tab.examples ??= [];
for (const [key, sample] of Object.entries(responseOfType.examples)) {
const title = sample?.summary ?? `Example ${key}`;
tab.examples.push({
label: title,
sample: sample.value,
description: sample?.description,
});
}
} else if (responseOfType?.example || responseOfType?.schema) {
tab.examples ??= [];
tab.examples.push({
label: 'Example',
sample:
responseOfType.example ?? sample(responseOfType.schema as object),
});
}
tabs.push(tab);
}
const { renderResponseTabs = renderResponseTabsDefault } = ctx.content ?? {};
return renderResponseTabs(tabs, ctx);
}
function renderResponseTabsDefault(
tabs: ResponseTab[],
ctx: RenderContext,
): ReactNode | Promise<ReactNode> {
async function renderResponse(tab: ResponseTab) {
const { examples = [] } = tab;
let slot: ReactNode = 'Empty';
if (examples.length > 1) {
slot = (
<Accordions
type="single"
className="pt-2"
defaultValue={examples[0].label}
>
{examples.map((example, i) => (
<AccordionItem key={i} value={example.label}>
<AccordionHeader>
<AccordionTrigger>{example.label}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{example.description && ctx.renderMarkdown(example.description)}
{ctx.renderCodeBlock(
'json',
JSON.stringify(example.sample, null, 2),
)}
</AccordionContent>
</AccordionItem>
))}
</Accordions>
);
} else if (examples.length === 1) {
const example = examples[0];
slot = (
<>
{example.description && ctx.renderMarkdown(example.description)}
{ctx.renderCodeBlock('json', JSON.stringify(example.sample, null, 2))}
</>
);
}
return <Tab value={tab.code}>{slot}</Tab>;
}
if (tabs.length === 0) return null;
return (
<Tabs
groupId="fumadocs_openapi_responses"
items={tabs.map((tab) => tab.code)}
>
{tabs.map(renderResponse)}
</Tabs>
);
}
@@ -1,10 +0,0 @@
'use client';
import { wrapLazy } from '../../../utils/lazy';
export const APIExampleSelectorLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.APIExampleSelector })),
);
export const APIExampleUsageTabLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.APIExampleUsageTab })),
);
@@ -1,92 +0,0 @@
import type { MethodInformation, RenderContext } from '@/types';
import type { ResolvedSchema } from '@/utils/schema';
import { getPreferredType } from '@/utils/schema';
import { sample } from 'openapi-sampler';
import type { RawRequestData } from '@/requests/types';
export function getRequestData(
path: string,
method: MethodInformation,
sampleKey: string | null,
_ctx: RenderContext,
): RawRequestData {
const result: RawRequestData = {
path: {},
cookie: {},
header: {},
query: {},
method: method.method,
};
for (const param of method.parameters ?? []) {
let schema = param.schema;
let value: unknown | undefined;
if (!schema && param.content) {
const type = getPreferredType(param.content);
const content = type ? param.content[type] : undefined;
if (!content || !content.schema)
throw new Error(
`Cannot find parameter schema for ${param.name} in ${path} ${method.method}`,
);
schema = content.schema;
value = content.example ?? param.example;
} else {
value = param.example;
}
if (param.required) {
value ??= sample(schema as object);
} else if (value === undefined) {
continue;
}
switch (param.in) {
case 'cookie':
result.cookie[param.name] = value;
break;
case 'header':
result.header[param.name] = value;
break;
case 'query':
result.query[param.name] = value;
break;
default:
result.path[param.name] = value;
}
}
if (method.requestBody) {
const body = method.requestBody.content;
const type = getPreferredType(body);
if (!type)
throw new Error(
`Cannot find body schema for ${path} ${method.method}: missing media type`,
);
result.bodyMediaType = type as RawRequestData['bodyMediaType'];
const bodyOfType = body[type];
if (bodyOfType.examples && sampleKey) {
result.body = bodyOfType.examples[sampleKey].value;
} else if (bodyOfType.example) {
result.body = bodyOfType.example;
} else {
result.body = generateBody(
method.method,
(bodyOfType?.schema ?? {}) as ResolvedSchema,
);
}
}
return result;
}
function generateBody(method: string, schema: ResolvedSchema): unknown {
return sample(schema as object, {
skipReadOnly: method !== 'GET',
skipWriteOnly: method === 'GET',
skipNonRequired: true,
});
}
+19 -14
View File
@@ -13,7 +13,7 @@ import {
} from '@/utils/schema';
import { idToTitle } from '@/utils/id-to-title';
import { Schema } from '../schema';
import { APIExample, getAPIExamples } from '@/ui/operation/example-panel';
import { UsageTabs } from '@/ui/operation/usage-tabs';
import { MethodLabel } from '@/ui/components/method-label';
import { getTypescriptSchema } from '@/utils/get-typescript-schema';
import {
@@ -32,7 +32,8 @@ import {
import { isMediaTypeSupported } from '@/requests/media/adapter';
import { cn } from 'fumadocs-ui/utils/cn';
import { APIPlayground } from '@/playground';
import { OperationProviderLazy } from '../contexts/operation.lazy';
import { RequestTabs, getExampleRequests } from './request-tabs';
import { UsageTabsProviderLazy } from './usage-tabs/lazy';
const ParamTypes = {
path: 'Path Parameters',
@@ -303,33 +304,36 @@ export async function Operation({
</code>
</div>
),
apiExample: <APIExample method={method} ctx={ctx} />,
apiExample: <UsageTabs method={method} ctx={ctx} />,
},
ctx,
method,
);
return (
<OperationProviderLazy
<UsageTabsProviderLazy
defaultExampleId={
method['x-exclusiveCodeSample'] ?? method['x-selectedCodeSample']
}
route={path}
examples={getAPIExamples(path, method, ctx)}
examples={getExampleRequests(path, method, ctx)}
>
{content}
</OperationProviderLazy>
</UsageTabsProviderLazy>
);
} else {
renderWebhookLayout ??= (slots) => (
<div>
{slots.header}
{slots.description}
{slots.authSchemes}
{slots.paremeters}
{slots.body}
{slots.responses}
{slots.callbacks}
<div className="flex flex-col-reverse gap-x-6 gap-y-4 @4xl:flex-row @4xl:items-start">
<div className="min-w-0 flex-1">
{slots.header}
{slots.description}
{slots.authSchemes}
{slots.paremeters}
{slots.body}
{slots.responses}
{slots.callbacks}
</div>
{slots.requests}
</div>
);
return renderWebhookLayout({
@@ -340,6 +344,7 @@ export async function Operation({
callbacks: callbacksNode,
paremeters: parameterNode,
responses: responseNode,
requests: <RequestTabs path={path} operation={method} ctx={ctx} />,
});
}
}
@@ -0,0 +1,262 @@
import type { MethodInformation, RenderContext } from '@/types';
import { type NoReference, getPreferredType } from '@/utils/schema';
import {
Accordions,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionContent,
} from '@/ui/components/accordion';
import { sample } from 'openapi-sampler';
import type { ReactNode } from 'react';
import type { RawRequestData, RequestData } from '@/requests/types';
import { encodeRequestData } from '@/requests/media/encode';
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from 'fumadocs-ui/components/tabs';
import { resolveRequestData } from '@/utils/url';
import { MethodLabel } from '../components/method-label';
export interface ExampleRequestItem {
id: string;
name: string;
description?: string;
data: RawRequestData;
encoded: RequestData;
}
export function getExampleRequests(
path: string,
operation: NoReference<MethodInformation>,
ctx: RenderContext,
): ExampleRequestItem[] {
const media = operation.requestBody
? getPreferredType(operation.requestBody.content)
: null;
const bodyOfType = media ? operation.requestBody?.content[media] : null;
if (bodyOfType?.examples) {
const result: ExampleRequestItem[] = [];
for (const [key, value] of Object.entries(bodyOfType.examples)) {
const data = getRequestData(path, operation, key, ctx);
result.push({
id: key,
name: value.summary ?? key,
description: value.description,
data,
encoded: encodeRequestData(
data,
ctx.mediaAdapters,
operation.parameters ?? [],
),
});
}
if (result.length > 0) return result;
}
const data = getRequestData(path, operation, null, ctx);
return [
{
id: '_default',
name: 'Default',
description: bodyOfType?.schema?.description,
data,
encoded: encodeRequestData(
data,
ctx.mediaAdapters,
operation.parameters ?? [],
),
},
];
}
function getRequestData(
path: string,
method: NoReference<MethodInformation>,
sampleKey: string | null,
_ctx: RenderContext,
): RawRequestData {
const result: RawRequestData = {
path: {},
cookie: {},
header: {},
query: {},
method: method.method,
};
for (const param of method.parameters ?? []) {
let schema = param.schema;
let value: unknown | undefined;
if (!schema && param.content) {
const type = getPreferredType(param.content);
const content = type ? param.content[type] : undefined;
if (!content || !content.schema)
throw new Error(
`Cannot find parameter schema for ${param.name} in ${path} ${method.method}`,
);
schema = content.schema;
value = content.example ?? param.example;
} else {
value = param.example;
}
if (param.required) {
value ??= sample(schema as object);
} else if (value === undefined) {
continue;
}
switch (param.in) {
case 'cookie':
result.cookie[param.name] = value;
break;
case 'header':
result.header[param.name] = value;
break;
case 'query':
result.query[param.name] = value;
break;
default:
result.path[param.name] = value;
}
}
if (method.requestBody) {
const body = method.requestBody.content;
const type = getPreferredType(body);
if (!type)
throw new Error(
`Cannot find body schema for ${path} ${method.method}: missing media type`,
);
result.bodyMediaType = type as RawRequestData['bodyMediaType'];
const bodyOfType = body[type];
if (bodyOfType.examples && sampleKey) {
result.body = bodyOfType.examples[sampleKey].value;
} else if (bodyOfType.example) {
result.body = bodyOfType.example;
} else {
result.body = sample((bodyOfType?.schema ?? {}) as object, {
skipReadOnly: method.method !== 'GET',
skipWriteOnly: method.method === 'GET',
skipNonRequired: true,
});
}
}
return result;
}
export async function RequestTabs({
path,
operation,
ctx,
}: {
path: string;
operation: NoReference<MethodInformation>;
ctx: RenderContext;
}) {
if (!operation.requestBody) return null;
const { renderRequestTabs = renderRequestTabsDefault } = ctx.content ?? {};
return (
<div className="@4xl:sticky @4xl:top-[calc(var(--fd-docs-row-1,2rem)+1rem)] @4xl:w-[400px]">
{await renderRequestTabs(getExampleRequests(path, operation, ctx), {
...ctx,
route: path,
operation,
})}
</div>
);
}
function renderRequestTabsDefault(
items: ExampleRequestItem[],
ctx: RenderContext & {
route: string;
operation: NoReference<MethodInformation>;
},
) {
function renderItem(item: ExampleRequestItem) {
const requestData = item.data;
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
body: (
<>
Body
<code className="text-xs text-fd-muted-foreground ms-auto">
{requestData.bodyMediaType}
</code>
</>
),
cookie: 'Cookie',
header: 'Header',
query: 'Query Parameters',
path: 'Path Parameters',
};
return (
<>
{item.description && ctx.renderMarkdown(item.description)}
<div className="flex flex-row gap-2 items-center justify-between">
<MethodLabel>{requestData.method}</MethodLabel>
<code>{resolveRequestData(ctx.route, item.encoded)}</code>
</div>
<Accordions type="multiple" className="mt-2">
{Object.entries(displayNames).map(([k, v]) => {
const data = requestData[k as keyof RawRequestData];
if (!data || Object.keys(data).length === 0) return;
return (
<AccordionItem key={k} value={k}>
<AccordionHeader>
<AccordionTrigger>{v}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{ctx.renderCodeBlock('json', JSON.stringify(data, null, 2))}
</AccordionContent>
</AccordionItem>
);
})}
</Accordions>
</>
);
}
if (items.length > 1) {
return (
<Tabs defaultValue={items[0].id}>
<TabsList>
{items.map((item) => (
<TabsTrigger key={item.id} value={item.id}>
{item.name}
</TabsTrigger>
))}
</TabsList>
{items.map((item) => (
<TabsContent key={item.id} value={item.id}>
{renderItem(item)}
</TabsContent>
))}
</Tabs>
);
} else if (items.length === 1) {
return (
<div className="p-3 rounded-xl border prose-no-margin bg-fd-card text-fd-card-foreground shadow-md">
<p className="font-semibold border-b pb-2">Example Requests</p>
{renderItem(items[0])}
</div>
);
}
return 'Empty';
}
@@ -0,0 +1,144 @@
import type { MethodInformation, RenderContext, ResponseObject } from '@/types';
import { type NoReference, getPreferredType } from '@/utils/schema';
import {
Accordions,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionContent,
} from '@/ui/components/accordion';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { sample } from 'openapi-sampler';
import type { ReactNode } from 'react';
export interface ResponseTab {
/**
* HTTP response code
*/
code: string;
response: NoReference<ResponseObject>;
/**
* media type of response
*/
mediaType: string | null;
examples?: ResponseExample[];
}
interface ResponseExample {
/**
* generated/defined example data
*/
sample: unknown;
label: string;
/**
* description (in Markdown)
*/
description?: string;
}
export function ResponseTabs({
operation,
ctx,
}: {
operation: NoReference<MethodInformation>;
ctx: RenderContext;
}) {
if (!operation.responses) return null;
const tabs: ResponseTab[] = [];
for (const [code, response] of Object.entries(operation.responses)) {
const media = response.content ? getPreferredType(response.content) : null;
const responseOfType = media ? response.content?.[media] : null;
const tab: ResponseTab = {
code,
response,
mediaType: media as string | null,
};
if (responseOfType?.examples) {
tab.examples ??= [];
for (const [key, sample] of Object.entries(responseOfType.examples)) {
const title = sample?.summary ?? `Example ${key}`;
tab.examples.push({
label: title,
sample: sample.value,
description: sample?.description,
});
}
} else if (responseOfType?.example || responseOfType?.schema) {
tab.examples ??= [];
tab.examples.push({
label: 'Example',
sample:
responseOfType.example ?? sample(responseOfType.schema as object),
});
}
tabs.push(tab);
}
const { renderResponseTabs = renderResponseTabsDefault } = ctx.content ?? {};
return renderResponseTabs(tabs, ctx);
}
function renderResponseTabsDefault(
tabs: ResponseTab[],
ctx: RenderContext,
): ReactNode | Promise<ReactNode> {
function renderExampleContent(example: ResponseExample) {
return (
<>
{example.description && ctx.renderMarkdown(example.description)}
{ctx.renderCodeBlock('json', JSON.stringify(example.sample, null, 2))}
</>
);
}
async function renderResponse(tab: ResponseTab) {
const { examples = [] } = tab;
let slot: ReactNode = 'Empty';
if (examples.length > 1) {
slot = (
<Accordions
type="single"
className="pt-2"
defaultValue={examples[0].label}
>
{examples.map((example, i) => (
<AccordionItem key={i} value={example.label}>
<AccordionHeader>
<AccordionTrigger>{example.label}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="prose-no-margin">
{renderExampleContent(example)}
</AccordionContent>
</AccordionItem>
))}
</Accordions>
);
} else if (examples.length === 1) {
slot = renderExampleContent(examples[0]);
}
return <Tab value={tab.code}>{slot}</Tab>;
}
if (tabs.length === 0) return null;
return (
<Tabs
groupId="fumadocs_openapi_responses"
items={tabs.map((tab) => tab.code)}
>
{tabs.map(renderResponse)}
</Tabs>
);
}
@@ -0,0 +1,204 @@
'use client';
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import {
joinURL,
withBase,
resolveServerUrl,
resolveRequestData,
} from '@/utils/url';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/ui/components/select';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import {
useState,
useEffect,
useMemo,
createContext,
ReactNode,
useRef,
use,
} from 'react';
import type { CodeUsageGenerator } from '.';
import type { ExampleRequestItem } from '../request-tabs';
import type { RawRequestData, RequestData } from '@/requests/types';
export type ExampleUpdateListener = (
data: RawRequestData,
encoded: RequestData,
) => void;
const Context = createContext<{
route: string;
examples: ExampleRequestItem[];
example: string | undefined;
setExample: (id: string) => void;
setExampleData: (data: RawRequestData, encoded: RequestData) => void;
addListener: (listener: ExampleUpdateListener) => void;
removeListener: (listener: ExampleUpdateListener) => void;
} | null>(null);
export function UsageTabsProvider({
route,
examples,
defaultExampleId,
children,
}: {
route: string;
examples: ExampleRequestItem[];
defaultExampleId?: string;
children: ReactNode;
}) {
const [example, setExample] = useState(
() => defaultExampleId ?? examples.at(0)?.id,
);
const listeners = useRef<ExampleUpdateListener[]>([]);
return (
<Context
value={useMemo(
() => ({
example,
route,
setExample: (newKey: string) => {
const example = examples.find((example) => example.id === newKey);
if (!example) return;
setExample(newKey);
for (const listener of listeners.current) {
listener(example.data, example.encoded);
}
},
examples,
setExampleData(data, encoded) {
for (const item of examples) {
if (item.id === example) {
// persistent changes
item.data = data;
item.encoded = encoded;
break;
}
}
for (const listener of listeners.current) {
listener(data, encoded);
}
},
removeListener(listener) {
listeners.current = listeners.current.filter(
(item) => item !== listener,
);
},
addListener(listener) {
// initial call to listeners to ensure their data is the latest
// this is necessary to avoid race conditions between `useEffect()`
const active = examples.find((item) => item.id === example)!;
listener(active.data, active.encoded);
listeners.current.push(listener);
},
}),
[example, route, examples],
)}
>
{children}
</Context>
);
}
export function useExampleRequests() {
return use(Context)!;
}
export function UsageTabsSelector() {
const { example: key, setExample: setKey, examples } = useExampleRequests();
const { APIExampleSelector: Override } =
useApiContext().client.operation ?? {};
if (Override) {
return <Override items={examples} value={key} onValueChange={setKey} />;
}
function renderItem(item: ExampleRequestItem) {
return (
<div>
<span className="font-medium text-sm">{item.name}</span>
<span className="text-fd-muted-foreground">{item.description}</span>
</div>
);
}
if (examples.length === 1) return null;
const selected = examples.find((item) => item.id === key);
return (
<Select value={key} onValueChange={setKey}>
<SelectTrigger className="not-prose mb-2">
{selected && <SelectValue asChild>{renderItem(selected)}</SelectValue>}
</SelectTrigger>
<SelectContent>
{examples.map((item) => (
<SelectItem key={item.id} value={item.id}>
{renderItem(item)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function UsageTab(sample: CodeUsageGenerator) {
const { shikiOptions, mediaAdapters } = useApiContext();
const {
examples,
example: selectedExampleId,
route,
addListener,
removeListener,
} = useExampleRequests();
const { server } = useServerSelectContext();
const [data, setData] = useState(
() => examples.find((example) => example.id === selectedExampleId)?.encoded,
);
useEffect(() => {
const listener: ExampleUpdateListener = (_, encoded) => setData(encoded);
addListener(listener);
return () => {
removeListener(listener);
};
}, [addListener, removeListener]);
const code = useMemo(() => {
if (!sample.source || !data) return;
if (typeof sample.source === 'string') return sample.source;
return sample.source(
joinURL(
withBase(
server ? resolveServerUrl(server.url, server.variables) : '/',
typeof window !== 'undefined'
? window.location.origin
: 'https://loading',
),
resolveRequestData(route, data),
),
data,
{
server: sample.serverContext,
mediaAdapters,
},
);
}, [mediaAdapters, sample, server, route, data]);
if (!code || !sample) return null;
return (
<DynamicCodeBlock lang={sample.lang} code={code} options={shikiOptions} />
);
}
@@ -0,0 +1,124 @@
import type { MethodInformation, RenderContext } from '@/types';
import type { SampleGenerator } from '@/requests/types';
import { defaultSamples } from '@/requests/generators';
import {
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
} from 'fumadocs-ui/components/codeblock';
import { UsageTabsSelectorLazy, UsageTabLazy } from './lazy';
import { ResponseTabs } from '../response-tabs';
/**
* Generate code example for given programming language
*/
export interface CodeUsageGenerator<T = unknown> {
id: string;
lang: string;
label?: string;
/**
* either:
* - code
* - a function imported from a file with "use client" directive
* - false (disabled)
*/
source?: string | SampleGenerator<T> | false;
/**
* Pass extra context to client-side source generator
*/
serverContext?: T;
}
export async function UsageTabs({
method,
ctx,
}: {
method: MethodInformation;
ctx: RenderContext;
}) {
let { renderAPIExampleUsageTabs, renderAPIExampleLayout } = ctx.content ?? {};
renderAPIExampleLayout ??= (slots) => {
return (
<div className="prose-no-margin @4xl:sticky @4xl:top-[calc(var(--fd-docs-row-1,2rem)+1rem)] @4xl:w-[400px]">
{slots.selector}
{slots.usageTabs}
{slots.responseTabs}
</div>
);
};
renderAPIExampleUsageTabs ??= (generators) => {
if (generators.length === 0) return null;
return (
<CodeBlockTabs
groupId="fumadocs_openapi_requests"
defaultValue={generators[0].id}
>
<CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTabsTrigger key={item.id} value={item.id}>
{item.label ?? item.lang}
</CodeBlockTabsTrigger>
))}
</CodeBlockTabsList>
{generators.map((item) => (
<CodeBlockTab key={item.id} value={item.id}>
<UsageTabLazy {...item} />
</CodeBlockTab>
))}
</CodeBlockTabs>
);
};
let generators: CodeUsageGenerator[] = [...defaultSamples];
if (ctx.generateCodeSamples) {
generators.push(...(await ctx.generateCodeSamples(method)));
}
if (method['x-codeSamples']) {
for (const sample of method['x-codeSamples']) {
generators.push(
'id' in sample && typeof sample.id === 'string'
? (sample as CodeUsageGenerator)
: {
id: sample.lang,
...sample,
},
);
}
}
generators = dedupe(generators);
return renderAPIExampleLayout(
{
selector: method['x-exclusiveCodeSample'] ? null : (
<UsageTabsSelectorLazy />
),
usageTabs: await renderAPIExampleUsageTabs(generators, ctx),
responseTabs: <ResponseTabs operation={method} ctx={ctx} />,
},
ctx,
);
}
/**
* Remove duplicated ids
*/
function dedupe(samples: CodeUsageGenerator[]): CodeUsageGenerator[] {
const set = new Set<string>();
const out: CodeUsageGenerator[] = [];
for (let i = samples.length - 1; i >= 0; i--) {
const item = samples[i];
if (set.has(item.id)) continue;
set.add(item.id);
if (item.source !== false) out.unshift(item);
}
return out;
}
@@ -0,0 +1,14 @@
'use client';
import { wrapLazy } from '../../../utils/lazy';
export const UsageTabsSelectorLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTabsSelector })),
);
export const UsageTabLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTab })),
);
export const UsageTabsProviderLazy = wrapLazy(() =>
import('./client').then((mod) => ({ default: mod.UsageTabsProvider })),
);