mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Display webhook request examples
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Display webhook request examples
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 })),
|
||||
);
|
||||
Reference in New Issue
Block a user