diff --git a/.changeset/green-lizards-change.md b/.changeset/green-lizards-change.md new file mode 100644 index 0000000000..cecc815a25 --- /dev/null +++ b/.changeset/green-lizards-change.md @@ -0,0 +1,6 @@ +--- +"@gitbook/react-openapi": patch +"gitbook": patch +--- + +Support body examples diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css index dd0ea9ebca..1c2592c3bc 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/style.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/style.css @@ -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 */ diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index bb6042e52a..10410da57d 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -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 ( + + } className="openapi-codesample"> + + + + ); +} + +/** + * Generate code samples for the operation. + */ +function generateCodeSamples(props: { + data: OpenAPIOperationData; + context: OpenAPIContextProps; }) { const { data, context } = props; @@ -51,97 +94,102 @@ 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: , - })); - - // 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) => { - const customSamples = data.operation[key]; - if (customSamples && Array.isArray(customSamples)) { - customCodeSamples = customSamples - .filter((sample) => { - return ( - typeof sample.label === 'string' && - typeof sample.source === 'string' && - typeof sample.lang === 'string' - ); - }) - .map((sample, index) => ({ - key: `redocly-${sample.lang}-${index}`, - label: sample.label, - body: context.renderCodeBlock({ - code: sample.source, - syntax: sample.lang, + 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, }), - footer: , - })); + 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; + }; } - }); - - // 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 codeSampleGenerators.map((generator) => { + if (mediaTypeRendererFactories.length > 0) { + const renderers = mediaTypeRendererFactories.map((generate) => generate(generator)); + return { + key: `default-${generator.id}`, + label: generator.label, + body: , + footer: ( + + ), + }; + } + 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: , + }; + }); +} - return ( - - } className="openapi-codesample"> - - - - ); +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 } = props; + 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) { + if (hideTryItPanel && !hasMediaTypes) { return null; } @@ -151,11 +199,59 @@ function OpenAPICodeSampleFooter(props: { return (
- + {hasMediaTypes ? ( + + ) : ( + + )} + {!hideTryItPanel && }
); } +/** + * Get custom code samples for the operation. + */ +function getCustomCodeSamples(props: { + data: OpenAPIOperationData; + context: OpenAPIContextProps; +}) { + const { data, context } = props; + + let customCodeSamples: null | Array<{ + key: string; + label: string; + body: React.ReactNode; + }> = null; + + CUSTOM_CODE_SAMPLES_KEYS.forEach((key) => { + const customSamples = data.operation[key]; + if (customSamples && Array.isArray(customSamples)) { + customCodeSamples = customSamples + .filter((sample) => { + return ( + typeof sample.label === 'string' && + typeof sample.source === 'string' && + typeof sample.lang === 'string' + ); + }) + .map((sample, index) => ({ + key: `custom-sample-${sample.lang}-${index}`, + label: sample.label, + body: context.renderCodeBlock({ + code: sample.source, + syntax: sample.lang, + }), + footer: ( + + ), + })); + } + }); + + return customCodeSamples; +} + function getSecurityHeaders(securities: OpenAPIOperationData['securities']): { [key: string]: string; } { diff --git a/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx new file mode 100644 index 0000000000..df96330fea --- /dev/null +++ b/packages/react-openapi/src/OpenAPICodeSampleInteractive.tsx @@ -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 ( +
+ + +
+ ); +} + +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 ( + + ); +} + +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 ; +} + +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; +} diff --git a/packages/react-openapi/src/code-samples.ts b/packages/react-openapi/src/code-samples.ts index bb07f1f20c..24bd1ca72c 100644 --- a/packages/react-openapi/src/code-samples.ts +++ b/packages/react-openapi/src/code-samples.ts @@ -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 { diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts index eda8b53450..01d06565cd 100644 --- a/packages/react-openapi/src/generateSchemaExample.ts +++ b/packages/react-openapi/src/generateSchemaExample.ts @@ -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((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 */ diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 0b53baf1d7..b882804fdf 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -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; }