mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Add support for multiple body content types / examples (#2991)
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user