Add support for multiple body content types / examples (#2991)

This commit is contained in:
Greg Bergé
2025-03-21 14:23:42 +01:00
committed by GitHub
parent 970ef8f886
commit 6eae764b7c
7 changed files with 323 additions and 94 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@gitbook/react-openapi": patch
"gitbook": patch
---
Support body examples
@@ -392,7 +392,11 @@
}
.openapi-codesample-footer {
@apply flex w-full justify-end;
@apply flex gap-3 w-full justify-between flex-wrap;
}
.openapi-codesample-selectors {
@apply flex flex-row items-center gap-3 flex-wrap;
}
/* Path */
+172 -76
View File
@@ -1,14 +1,20 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import {
OpenAPIMediaTypeExamplesBody,
OpenAPIMediaTypeExamplesSelector,
} from './OpenAPICodeSampleInteractive';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { ScalarApiButton } from './ScalarApiButton';
import { StaticSection } from './StaticSection';
import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
import { type CodeSampleGenerator, codeSampleGenerators } from './code-samples';
import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { getDefaultServerURL } from './util/server';
import { checkIsReference, createStateKey } from './utils';
const CUSTOM_CODE_SAMPLES_KEYS = ['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const;
/**
* Display code samples to execute the operation.
* It supports the Redocly custom syntax as well (https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/)
@@ -16,6 +22,43 @@ import { checkIsReference, createStateKey } from './utils';
export function OpenAPICodeSample(props: {
data: OpenAPIOperationData;
context: OpenAPIContextProps;
}) {
const { data } = props;
// If code samples are disabled at operation level, we don't display the code samples.
if (data.operation['x-codeSamples'] === false) {
return null;
}
const customCodeSamples = getCustomCodeSamples(props);
// If code samples are disabled at the top-level and not custom code samples are defined,
// we don't display the code samples.
if (data['x-codeSamples'] === false && !customCodeSamples) {
return null;
}
const samples = customCodeSamples ?? generateCodeSamples(props);
if (samples.length === 0) {
return null;
}
return (
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
);
}
/**
* Generate code samples for the operation.
*/
function generateCodeSamples(props: {
data: OpenAPIOperationData;
context: OpenAPIContextProps;
}) {
const { data, context } = props;
@@ -51,46 +94,137 @@ export function OpenAPICodeSample(props: {
const requestBody = !checkIsReference(data.operation.requestBody)
? data.operation.requestBody
: undefined;
const requestBodyContentEntries = requestBody?.content
? Object.entries(requestBody.content)
: undefined;
const requestBodyContent = requestBodyContentEntries?.[0];
const input: CodeSampleInput = {
url:
getDefaultServerURL(data.servers) +
data.path +
(searchParams.size ? `?${searchParams.toString()}` : ''),
method: data.method,
body: requestBodyContent ? generateMediaTypeExample(requestBodyContent[1]) : undefined,
headers: {
...getSecurityHeaders(data.securities),
...headersObject,
...(requestBodyContent
? {
'Content-Type': requestBodyContent[0],
}
: undefined),
},
const url =
getDefaultServerURL(data.servers) +
data.path +
(searchParams.size ? `?${searchParams.toString()}` : '');
const genericHeaders = {
...getSecurityHeaders(data.securities),
...headersObject,
};
const autoCodeSamples = codeSampleGenerators.map((generator) => ({
key: `default-${generator.id}`,
label: generator.label,
body: context.renderCodeBlock({
code: generator.generate(input),
syntax: generator.syntax,
}),
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
}));
const mediaTypeRendererFactories = Object.entries(requestBody?.content ?? {}).map(
([mediaType, mediaTypeObject]) => {
return (generator: CodeSampleGenerator) => {
const mediaTypeHeaders = {
...genericHeaders,
'Content-Type': mediaType,
};
return {
mediaType,
element: context.renderCodeBlock({
code: generator.generate({
url,
method: data.method,
body: undefined,
headers: mediaTypeHeaders,
}),
syntax: generator.syntax,
}),
examples: generateMediaTypeExamples(mediaTypeObject).map((example) => ({
example,
element: context.renderCodeBlock({
code: generator.generate({
url,
method: data.method,
body: example.value,
headers: mediaTypeHeaders,
}),
syntax: generator.syntax,
}),
})),
} satisfies MediaTypeRenderer;
};
}
);
return codeSampleGenerators.map((generator) => {
if (mediaTypeRendererFactories.length > 0) {
const renderers = mediaTypeRendererFactories.map((generate) => generate(generator));
return {
key: `default-${generator.id}`,
label: generator.label,
body: <OpenAPIMediaTypeExamplesBody data={data} renderers={renderers} />,
footer: (
<OpenAPICodeSampleFooter renderers={renderers} data={data} context={context} />
),
};
}
return {
key: `default-${generator.id}`,
label: generator.label,
body: context.renderCodeBlock({
code: generator.generate({
url,
method: data.method,
body: undefined,
headers: genericHeaders,
}),
syntax: generator.syntax,
}),
footer: <OpenAPICodeSampleFooter data={data} renderers={[]} context={context} />,
};
});
}
export interface MediaTypeRenderer {
mediaType: string;
element: React.ReactNode;
examples: Array<{
example: OpenAPIV3.ExampleObject;
element: React.ReactNode;
}>;
}
function OpenAPICodeSampleFooter(props: {
data: OpenAPIOperationData;
renderers: MediaTypeRenderer[];
context: OpenAPIContextProps;
}) {
const { data, context, renderers } = props;
const { method, path } = data;
const { specUrl } = context;
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
const hasMediaTypes = renderers.length > 0;
if (hideTryItPanel && !hasMediaTypes) {
return null;
}
if (!validateHttpMethod(method)) {
return null;
}
return (
<div className="openapi-codesample-footer">
{hasMediaTypes ? (
<OpenAPIMediaTypeExamplesSelector data={data} renderers={renderers} />
) : (
<span />
)}
{!hideTryItPanel && <ScalarApiButton method={method} path={path} specUrl={specUrl} />}
</div>
);
}
/**
* Get custom code samples for the operation.
*/
function getCustomCodeSamples(props: {
data: OpenAPIOperationData;
context: OpenAPIContextProps;
}) {
const { data, context } = props;
// Use custom samples if defined
let customCodeSamples: null | Array<{
key: string;
label: string;
body: React.ReactNode;
}> = null;
(['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const).forEach((key) => {
CUSTOM_CODE_SAMPLES_KEYS.forEach((key) => {
const customSamples = data.operation[key];
if (customSamples && Array.isArray(customSamples)) {
customCodeSamples = customSamples
@@ -102,58 +236,20 @@ export function OpenAPICodeSample(props: {
);
})
.map((sample, index) => ({
key: `redocly-${sample.lang}-${index}`,
key: `custom-sample-${sample.lang}-${index}`,
label: sample.label,
body: context.renderCodeBlock({
code: sample.source,
syntax: sample.lang,
}),
footer: <OpenAPICodeSampleFooter data={data} context={context} />,
footer: (
<OpenAPICodeSampleFooter renderers={[]} data={data} context={context} />
),
}));
}
});
// Code samples can be disabled at the top-level or at the operation level
// If code samples are defined at the operation level, it will override the top-level setting
const codeSamplesDisabled =
data['x-codeSamples'] === false || data.operation['x-codeSamples'] === false;
const samples = customCodeSamples ?? (!codeSamplesDisabled ? autoCodeSamples : []);
if (samples.length === 0) {
return null;
}
return (
<OpenAPITabs stateKey={createStateKey('codesample')} items={samples}>
<StaticSection header={<OpenAPITabsList />} className="openapi-codesample">
<OpenAPITabsPanels />
</StaticSection>
</OpenAPITabs>
);
}
function OpenAPICodeSampleFooter(props: {
data: OpenAPIOperationData;
context: OpenAPIContextProps;
}) {
const { data, context } = props;
const { method, path } = data;
const { specUrl } = context;
const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
if (hideTryItPanel) {
return null;
}
if (!validateHttpMethod(method)) {
return null;
}
return (
<div className="openapi-codesample-footer">
<ScalarApiButton method={method} path={path} specUrl={specUrl} />
</div>
);
return customCodeSamples;
}
function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
@@ -0,0 +1,114 @@
'use client';
import clsx from 'clsx';
import { useCallback } from 'react';
import { useStore } from 'zustand';
import type { MediaTypeRenderer } from './OpenAPICodeSample';
import type { OpenAPIOperationData } from './types';
import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
function useMediaTypeState(data: OpenAPIOperationData, defaultKey: string) {
const { method, path } = data;
const store = useStore(getOrCreateTabStoreByKey(`media-type-${method}-${path}`, defaultKey));
if (typeof store.tabKey !== 'string') {
throw new Error('Media type key is not a string');
}
return {
mediaType: store.tabKey,
setMediaType: useCallback((index: string) => store.setTabKey(index), [store.setTabKey]),
};
}
function useMediaTypeSampleIndexState(data: OpenAPIOperationData, mediaType: string) {
const { method, path } = data;
const store = useStore(
getOrCreateTabStoreByKey(`media-type-sample-${mediaType}-${method}-${path}`, 0)
);
if (typeof store.tabKey !== 'number') {
throw new Error('Example key is not a number');
}
return {
index: store.tabKey,
setIndex: useCallback((index: number) => store.setTabKey(index), [store.setTabKey]),
};
}
export function OpenAPIMediaTypeExamplesSelector(props: {
data: OpenAPIOperationData;
renderers: MediaTypeRenderer[];
}) {
const { data, renderers } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const state = useMediaTypeState(data, renderers[0].mediaType);
const selected = renderers.find((r) => r.mediaType === state.mediaType) || renderers[0];
return (
<div className="openapi-codesample-selectors">
<select
className={clsx('openapi-select')}
value={state.mediaType}
onChange={(e) => state.setMediaType(e.target.value)}
>
{renderers.map((renderer) => (
<option key={renderer.mediaType} value={renderer.mediaType}>
{renderer.mediaType}
</option>
))}
</select>
<ExamplesSelector data={data} renderer={selected} />
</div>
);
}
function ExamplesSelector(props: {
data: OpenAPIOperationData;
renderer: MediaTypeRenderer;
}) {
const { data, renderer } = props;
const state = useMediaTypeSampleIndexState(data, renderer.mediaType);
if (renderer.examples.length < 2) {
return null;
}
return (
<select
className={clsx('openapi-select')}
value={String(state.index)}
onChange={(e) => state.setIndex(Number(e.target.value))}
>
{renderer.examples.map((example, index) => (
<option key={index} value={index}>
{example.example.summary || `Example ${index + 1}`}
</option>
))}
</select>
);
}
export function OpenAPIMediaTypeExamplesBody(props: {
data: OpenAPIOperationData;
renderers: MediaTypeRenderer[];
}) {
const { renderers, data } = props;
if (!renderers[0]) {
throw new Error('No renderers provided');
}
const mediaTypeState = useMediaTypeState(data, renderers[0].mediaType);
const selected =
renderers.find((r) => r.mediaType === mediaTypeState.mediaType) ?? renderers[0];
if (selected.examples.length === 0) {
return selected.element;
}
return <ExamplesBody data={data} renderer={selected} />;
}
function ExamplesBody(props: { data: OpenAPIOperationData; renderer: MediaTypeRenderer }) {
const { data, renderer } = props;
const exampleState = useMediaTypeSampleIndexState(data, renderer.mediaType);
const example = renderer.examples[exampleState.index] ?? renderer.examples[0];
if (!example) {
throw new Error(`No example found for index ${exampleState.index}`);
}
return example.element;
}
+3 -3
View File
@@ -17,7 +17,7 @@ export interface CodeSampleInput {
body?: any;
}
interface CodeSampleGenerator {
export interface CodeSampleGenerator {
id: string;
label: string;
syntax: string;
@@ -240,7 +240,7 @@ const BodyGenerators = {
body = `--data '${String(body).replace(/"/g, '')}'`;
} else if (isXML(contentType) || isCSV(contentType)) {
// We use --data-binary to avoid cURL converting newlines to \r\n
body = `--data-binary $'${stringifyOpenAPI(body).replace(/"/g, '')}'`;
body = `--data-binary $'${stringifyOpenAPI(body).replace(/"/g, '').replace(/\\n/g, '\n')}'`;
} else if (isGraphQL(contentType)) {
body = `--data '${stringifyOpenAPI(body)}'`;
// Set Content-Type to application/json for GraphQL, recommended by GraphQL spec
@@ -249,7 +249,7 @@ const BodyGenerators = {
// We use --data-binary to avoid cURL converting newlines to \r\n
body = `--data-binary '@${String(body)}'`;
} else {
body = `--data '${stringifyOpenAPI(body, null, 2)}'`;
body = `--data '${stringifyOpenAPI(body, null, 2).replace(/\\n/g, '\n')}'`;
}
return {
@@ -1,4 +1,5 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { checkIsReference } from './utils';
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
@@ -28,29 +29,39 @@ export function generateSchemaExample(
/**
* Generate an example for a media type.
*/
export function generateMediaTypeExample(
export function generateMediaTypeExamples(
mediaType: OpenAPIV3.MediaTypeObject,
options?: GenerateSchemaExampleOptions
): JSONValue | undefined {
): OpenAPIV3.ExampleObject[] {
if (mediaType.example) {
return mediaType.example;
return [{ summary: 'default', value: mediaType.example }];
}
if (mediaType.examples) {
const key = Object.keys(mediaType.examples)[0];
if (key) {
const example = mediaType.examples[key];
if (example) {
return example.value;
}
const { examples } = mediaType;
const keys = Object.keys(examples);
if (keys.length > 0) {
return keys.reduce<OpenAPIV3.ExampleObject[]>((result, key) => {
const example = examples[key];
if (!example || checkIsReference(example)) {
return result;
}
result.push({
summary: example.summary || key,
value: example.value,
description: example.description,
externalValue: example.externalValue,
});
return result;
}, []);
}
}
if (mediaType.schema) {
return generateSchemaExample(mediaType.schema, options);
return [{ summary: 'default', value: generateSchemaExample(mediaType.schema, options) }];
}
return undefined;
return [];
}
/** Hard limit for rendering circular references */
+1 -3
View File
@@ -1,9 +1,7 @@
import type { AnyObject, OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { stringifyOpenAPI } from './stringifyOpenAPI';
export function checkIsReference(
input: unknown
): input is OpenAPIV3.ReferenceObject | OpenAPIV3_1.ReferenceObject {
export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
return typeof input === 'object' && !!input && '$ref' in input;
}