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;
}