Feat: support multiple examples in openapi operation #1370 (#1373)

* feat(packages/openapi): add feature to show multiple samples per operation

* chore: add changesset

* feat(packages/openapi): allow specifying a selectedSampleKey and exclusiveSampleKey when using <APIPage> in MDX

* fix(packages/openapi): remove shared groupId, which leads to unintended behaviour when showing multiple examples on one page

* fix(packages/openapi): also show examples for operations with not request body, cleanup data structure

---------

Co-authored-by: Michael Ermer <[email protected]>
This commit is contained in:
Michael Ermer
2025-02-06 23:31:49 +08:00
committed by GitHub
co-authored by Michael Ermer
parent 216708d1b1
commit cd894b1d7b
12 changed files with 279 additions and 66 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"fumadocs-openapi": patch
"docs": minor
---
Feat: support multiple examples in openapi operation #1370
Adds two new options to the ApiExample renderer "Samples" and "Sample"
+28 -1
View File
@@ -67,6 +67,10 @@ paths:
examples:
default_example:
$ref: '#/components/examples/CreateSpecialEventRequestExample'
secondary_example:
$ref: '#/components/examples/SecondaryCreateSpecialEventRequestExample'
tertiary_example:
$ref: '#/components/examples/TertiaryCreateSpecialEventRequestExample'
responses:
'201':
description: Created.
@@ -464,7 +468,8 @@ components:
ticketDate: '2023-09-05'
confirmationCode: ticket-event-9c55eg-8v82a
CreateSpecialEventRequestExample:
summary: Create special event
summary: Create special event 1
description: Example with no price.
value:
name: Mermaid Treasure Identification and Analysis
location: Under the seaaa 🦀 🎶 🌊.
@@ -473,6 +478,28 @@ components:
- '2023-09-05'
- '2023-09-08'
price: 0
SecondaryCreateSpecialEventRequestExample:
summary: Create special event 2
description: Example with a price.
value:
name: Mermaid Treasure Identification and Analysis
location: Under the seaaa 🦀 🎶 🌊.
eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel.
dates:
- '2023-09-05'
- '2023-09-08'
price: 1234
TertiaryCreateSpecialEventRequestExample:
summary: Create special event 3 and a dang long title
description: Example with a price.
value:
name: Mermaid Treasure Identification and Analysis
location: Under the seaaa 🦀 🎶 🌊.
eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel.
dates:
- '2023-09-05'
- '2023-09-08'
price: 1234
CreateSpecialEventResponseExample:
summary: Special event created
value:
+115 -31
View File
@@ -1,5 +1,9 @@
import { Fragment, type ReactElement, type ReactNode } from 'react';
import { generateSample, type EndpointSample } from '@/utils/generate-sample';
import {
generateSample,
type EndpointSample,
EndpointSamples,
} from '@/utils/generate-sample';
import * as CURL from '@/requests/curl';
import * as JS from '@/requests/javascript';
import * as Go from '@/requests/go';
@@ -29,7 +33,10 @@ interface CustomProperty {
export interface CodeSample {
lang: string;
label: string;
source: string | ((endpoint: EndpointSample) => string | undefined) | false;
source:
| string
| ((endpoint: EndpointSample, exampleKey: string) => string | undefined)
| false;
}
interface CodeSampleCompiled {
@@ -45,6 +52,8 @@ export function Operation({
ctx,
hasHead,
headingLevel = 2,
selectedSampleKey,
exclusiveSampleKey,
}: {
type?: 'webhook' | 'operation';
path: string;
@@ -53,6 +62,8 @@ export function Operation({
hasHead?: boolean;
headingLevel?: number;
selectedSampleKey?: string;
exclusiveSampleKey?: string;
}): ReactElement {
const body = method.requestBody;
const security = method.security ?? ctx.schema.document.security;
@@ -227,7 +238,13 @@ export function Operation({
return (
<ctx.renderer.API>
{info}
<APIExample method={method} endpoint={endpoint} ctx={ctx} />
<APIExample
method={method}
endpoint={endpoint}
ctx={ctx}
selectedSampleKey={selectedSampleKey}
exclusiveSampleKey={exclusiveSampleKey}
/>
</ctx.renderer.API>
);
} else {
@@ -262,46 +279,113 @@ async function APIExample({
method,
endpoint,
ctx,
selectedSampleKey,
exclusiveSampleKey,
}: {
method: MethodInformation;
endpoint: EndpointSample;
ctx: RenderContext;
selectedSampleKey?: string;
exclusiveSampleKey?: string;
}) {
const renderer = ctx.renderer;
const children: ReactNode[] = [];
const samples = dedupe([
...defaultSamples,
...(ctx.generateCodeSamples ? await ctx.generateCodeSamples(endpoint) : []),
...((method as CustomProperty)['x-codeSamples'] ?? []),
]).flatMap<CodeSampleCompiled>((sample) => {
if (sample.source === false) return [];
// fallback for methods that have no request body, we also want to show examples for
const existingSamples: EndpointSamples = endpoint.body?.samples ?? {
_default: {},
};
const samples: Record<
string,
{ samples: CodeSampleCompiled[]; title: string; description?: string }
> = {};
for (const exampleKey in existingSamples) {
samples[exampleKey] = {
title: existingSamples[exampleKey]?.summary ?? exampleKey,
description: existingSamples[exampleKey]?.description,
samples: dedupe([
...defaultSamples,
...(ctx.generateCodeSamples
? await ctx.generateCodeSamples(endpoint)
: []),
...((method as CustomProperty)['x-codeSamples'] ?? []),
]).flatMap<CodeSampleCompiled>((sample) => {
if (sample.source === false) return [];
const result =
typeof sample.source === 'function'
? sample.source(endpoint)
: sample.source;
if (result === undefined) return [];
const result =
typeof sample.source === 'function'
? sample.source(endpoint, exampleKey)
: sample.source;
if (result === undefined) return [];
return {
...sample,
source: result,
return {
...sample,
source: result,
};
}),
};
});
}
if (samples.length > 0) {
children.push(
<renderer.Requests key="requests" items={samples.map((s) => s.label)}>
{samples.map((s) => (
<renderer.Request
key={s.label}
name={s.label}
code={s.source}
language={s.lang}
/>
))}
</renderer.Requests>,
);
if (Object.keys(samples).length > 0) {
const sampleTabs: ReactNode[] = [];
const titles = [];
if (
(samples && Object.keys(samples).length === 1 && samples['_default']) ||
(exclusiveSampleKey && samples[exclusiveSampleKey])
) {
// if exclusiveSampleKey is present, we don't use tabs
// if only the fallback or non described openapi legacy example is present, we don't use tabs
const sampleKey = exclusiveSampleKey ?? '_default';
children.push(
<renderer.Requests
key={`requests-${sampleKey}`}
items={samples[sampleKey].samples.map((s) => s.label)}
>
{samples[sampleKey].samples.map((s) => (
<renderer.Request
key={`requests-${sampleKey}-${s.label}`}
name={s.label}
code={s.source}
language={s.lang}
/>
))}
</renderer.Requests>,
);
} else {
for (const sampleKey in samples) {
const title = samples[sampleKey].title;
titles.push(title);
sampleTabs.push(
<renderer.Sample key={sampleKey} value={title}>
{samples[sampleKey].description && (
<Markdown text={samples[sampleKey].description} />
)}
<renderer.Requests
key={`requests-${sampleKey}`}
items={samples[sampleKey].samples.map((s) => s.label)}
>
{samples[sampleKey].samples.map((s) => (
<renderer.Request
key={`requests-${sampleKey}-${s.label}`}
name={s.label}
code={s.source}
language={s.lang}
/>
))}
</renderer.Requests>
</renderer.Sample>,
);
}
children.push(
<renderer.Samples
items={titles}
key="samples"
defaultValue={selectedSampleKey}
>
{sampleTabs}
</renderer.Samples>,
);
}
}
children.push(
+15
View File
@@ -50,6 +50,16 @@ export interface RequestProps {
code: string;
}
export interface SampleProps {
value: string;
children: ReactNode;
}
export interface SamplesProps {
items: string[];
children: ReactNode;
defaultValue?: string;
}
export interface ResponseTypeProps {
lang: string;
code: string;
@@ -70,6 +80,8 @@ export interface Renderer {
Responses: ComponentType<ResponsesProps>;
Response: ComponentType<ResponseProps>;
Sample: ComponentType<SampleProps>;
Samples: ComponentType<SamplesProps>;
Requests: ComponentType<{ items: string[]; children: ReactNode }>;
Request: ComponentType<RequestProps>;
ResponseTypes: ComponentType<{ children: ReactNode }>;
@@ -132,6 +144,9 @@ export function createRenders(
Requests: (props) => (
<Tabs groupId="fumadocs_openapi_requests" {...props} />
),
Samples: (props) => <Tabs {...props} />,
Sample: Tab,
Request: (props) => (
<Tab value={props.name}>
<CodeBlock
+6 -3
View File
@@ -1,7 +1,10 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
export function getSampleRequest(
endpoint: EndpointSample,
sampleKey: string,
): string {
const s: string[] = [];
s.push(`curl -X ${endpoint.method} "${endpoint.url}"`);
@@ -21,7 +24,7 @@ export function getSampleRequest(endpoint: EndpointSample): string {
}
if (endpoint.body?.mediaType === 'multipart/form-data') {
const sample = endpoint.body.sample;
const sample = endpoint.body.samples[sampleKey]?.value;
if (sample && typeof sample === 'object') {
for (const [key, value] of Object.entries(sample)) {
@@ -31,7 +34,7 @@ export function getSampleRequest(endpoint: EndpointSample): string {
} else if (endpoint.body) {
s.push(`-H "Content-Type: ${endpoint.body.mediaType}"`);
s.push(
`-d ${inputToString(endpoint.body.sample, endpoint.body.mediaType, 'single-quote')}`,
`-d ${inputToString(endpoint.body.samples[sampleKey]?.value ?? '', endpoint.body.mediaType, 'single-quote')}`,
);
}
+9 -4
View File
@@ -1,7 +1,10 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
export function getSampleRequest(
endpoint: EndpointSample,
sampleKey: string,
): string {
const imports = ['fmt', 'net/http', 'io/ioutil'];
const headers = new Map<string, string>();
const variables = new Map<string, string>();
@@ -28,14 +31,16 @@ export function getSampleRequest(endpoint: EndpointSample): string {
if (
endpoint.body.mediaType === 'multipart/form-data' &&
typeof endpoint.body.sample === 'object'
typeof endpoint.body.samples[sampleKey]?.value === 'object'
) {
imports.push('mime/multipart', 'bytes');
variables.set('payload', `new(bytes.Buffer)`);
variables.set('mp', 'multipart.NewWriter(payload)');
for (const [key, value] of Object.entries(endpoint.body.sample ?? {})) {
for (const [key, value] of Object.entries(
endpoint.body.samples[sampleKey]?.value ?? {},
)) {
additional.push(
`mp.WriteField("${key}", ${inputToString(value, undefined, 'backtick')})`,
);
@@ -45,7 +50,7 @@ export function getSampleRequest(endpoint: EndpointSample): string {
variables.set(
'payload',
`strings.NewReader(${inputToString(
endpoint.body.sample,
endpoint.body.samples[sampleKey]?.value ?? '',
endpoint.body.mediaType,
'backtick',
).replaceAll('\n', '\n ')})`,
+13 -8
View File
@@ -1,7 +1,10 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
export function getSampleRequest(
endpoint: EndpointSample,
sampleKey: string,
): string {
const s: string[] = [];
const options = new Map<string, string>();
const headers = new Map<string, unknown>();
@@ -38,12 +41,14 @@ export function getSampleRequest(endpoint: EndpointSample): string {
if (
endpoint.body?.mediaType === 'multipart/form-data' &&
typeof endpoint.body.sample === 'object' &&
endpoint.body.sample
typeof endpoint.body.samples[sampleKey]?.value === 'object' &&
endpoint.body.samples[sampleKey]?.value
) {
s.push(`const formData = new FormData();`);
for (const [key, value] of Object.entries(endpoint.body.sample))
for (const [key, value] of Object.entries(
endpoint.body.samples[sampleKey]?.value,
))
s.push(`formData.set(${key}, ${inputToString(value)})`);
options.set('body', 'formData');
@@ -52,16 +57,16 @@ export function getSampleRequest(endpoint: EndpointSample): string {
if (endpoint.body.mediaType === 'application/json') {
code =
typeof endpoint.body.sample === 'string'
typeof endpoint.body.samples[sampleKey]?.value === 'string'
? inputToString(
endpoint.body.sample,
endpoint.body.samples[sampleKey]?.value,
endpoint.body.mediaType,
'backtick',
)
: `JSON.stringify(${JSON.stringify(endpoint.body.sample, null, 2)})`;
: `JSON.stringify(${JSON.stringify(endpoint.body.samples[sampleKey]?.value, null, 2)})`;
} else {
code = inputToString(
endpoint.body.sample,
endpoint.body.samples[sampleKey]?.value ?? '',
endpoint.body.mediaType,
'backtick',
);
+21 -4
View File
@@ -1,7 +1,10 @@
import { type EndpointSample } from '@/utils/generate-sample';
import { inputToString } from '@/utils/input-to-string';
export function getSampleRequest(endpoint: EndpointSample): string {
export function getSampleRequest(
endpoint: EndpointSample,
sampleKey: string,
): string {
const headers = new Map<string, unknown>();
const cookies = new Map<string, unknown>();
const variables = new Map<string, string>();
@@ -14,11 +17,25 @@ export function getSampleRequest(endpoint: EndpointSample): string {
if (endpoint.body) {
switch (endpoint.body.mediaType) {
case 'application/json':
variables.set('json', JSON.stringify(endpoint.body.sample, null, 2));
variables.set(
'json',
JSON.stringify(
endpoint.body.samples[sampleKey]?.value ?? {},
null,
2,
),
);
break;
case 'multipart/form-data':
headers.set('Content-Type', endpoint.body.mediaType);
variables.set('data', JSON.stringify(endpoint.body.sample, null, 2));
variables.set(
'data',
JSON.stringify(
endpoint.body.samples[sampleKey]?.value ?? {},
null,
2,
),
);
break;
default:
headers.set('Content-Type', endpoint.body.mediaType);
@@ -26,7 +43,7 @@ export function getSampleRequest(endpoint: EndpointSample): string {
variables.set(
'data',
inputToString(
endpoint.body.sample,
endpoint.body.samples[sampleKey]?.value ?? '',
endpoint.body.mediaType,
'backtick',
),
+10
View File
@@ -47,6 +47,14 @@ export interface WebhookItem {
export interface OperationItem {
path: string;
method: OpenAPIV3_1.HttpMethods;
/**
* The key of the sample to be selected
*/
selectedSampleKey?: string;
/**
* Only this sample will be shown
*/
exclusiveSampleKey?: string;
}
export async function APIPage(props: ApiPageProps) {
@@ -79,6 +87,8 @@ export async function APIPage(props: ApiPageProps) {
path={item.path}
ctx={ctx}
hasHead={hasHead}
selectedSampleKey={item.selectedSampleKey}
exclusiveSampleKey={item.exclusiveSampleKey}
/>
);
})}
+16 -3
View File
@@ -6,7 +6,14 @@ import {
type NoReference,
} from '@/utils/schema';
import { getSecurities, getSecurityPrefix } from '@/utils/get-security';
export interface EndpointSamples {
[key: string]: {
value?: unknown;
description?: string;
summary?: string;
externalValue?: string;
};
}
/**
* Sample info of endpoint
*/
@@ -19,7 +26,7 @@ export interface EndpointSample {
body?: {
schema: ParsedSchema;
mediaType: string;
sample: unknown;
samples: EndpointSamples;
};
responses: Record<string, ResponseSample>;
parameters: ParameterSample[];
@@ -107,7 +114,13 @@ export function generateSample(
bodyOutput = {
schema,
mediaType: type as string,
sample: body[type].example ?? generateBody(method.method, schema),
samples: body[type].examples
? body[type].examples
: {
_default: {
value: body[type].example ?? generateBody(method.method, schema),
},
},
};
}
+24 -12
View File
@@ -40,27 +40,39 @@ describe('Code Sample Generators', () => {
const endpoint = generateSample(path, info, ctx);
test(`Go: ${method} ${path}`, async () => {
await expect(Go.getSampleRequest(endpoint)).toMatchFileSnapshot(
`./out/samples/${path}/${method}.go`,
);
await expect(
Go.getSampleRequest(
endpoint,
Object.keys(endpoint.body?.samples ?? {})[0],
),
).toMatchFileSnapshot(`./out/samples/${path}/${method}.go`);
});
test(`Curl: ${method} ${path}`, async () => {
await expect(Curl.getSampleRequest(endpoint)).toMatchFileSnapshot(
`./out/samples/${path}/${method}.bash`,
);
await expect(
Curl.getSampleRequest(
endpoint,
Object.keys(endpoint.body?.samples ?? {})[0],
),
).toMatchFileSnapshot(`./out/samples/${path}/${method}.bash`);
});
test(`Python: ${method} ${path}`, async () => {
await expect(Python.getSampleRequest(endpoint)).toMatchFileSnapshot(
`./out/samples/${path}/${method}.py`,
);
await expect(
Python.getSampleRequest(
endpoint,
Object.keys(endpoint.body?.samples ?? {})[0],
),
).toMatchFileSnapshot(`./out/samples/${path}/${method}.py`);
});
test(`JavaScript: ${method} ${path}`, async () => {
await expect(JS.getSampleRequest(endpoint)).toMatchFileSnapshot(
`./out/samples/${path}/${method}.js`,
);
await expect(
JS.getSampleRequest(
endpoint,
Object.keys(endpoint.body?.samples ?? {})[0],
),
).toMatchFileSnapshot(`./out/samples/${path}/${method}.js`);
});
}
}
+14
View File
@@ -58,6 +58,8 @@ paths:
examples:
default_example:
$ref: '#/components/examples/CreateSpecialEventRequestExample'
seconday_example:
$ref: '#/components/examples/SecondaryCreateSpecialEventRequestExample'
responses:
'200':
description: Success
@@ -454,6 +456,7 @@ components:
confirmationCode: ticket-event-9c55eg-8v82a
CreateSpecialEventRequestExample:
summary: Create special event
description: Create a special event for the museum.
value:
name: Mermaid Treasure Identification and Analysis
location: Under the seaaa 🦀 🎶 🌊.
@@ -462,6 +465,17 @@ components:
- 2023-09-05
- 2023-09-08
price: 0
SecondaryCreateSpecialEventRequestExample:
summary: Create special event with price
description: Create a special event with a price.
value:
name: Mermaid Treasure Identification and Analysis
location: Under the seaaa 🦀 🎶 🌊.
eventDescription: Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel.
dates:
- 2023-09-05
- 2023-09-08
price: 1234
CreateSpecialEventResponseExample:
summary: Special event created
value: