Support multiple examples and multiple responses example (#2856)

This commit is contained in:
Greg Bergé
2025-02-19 20:39:15 +01:00
committed by GitHub
parent 445baaaa61
commit bb5c6a42e7
9 changed files with 328 additions and 76 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@gitbook/react-openapi': patch
---
Support multiple response media types and examples
+3
View File
@@ -218,6 +218,7 @@
"@scalar/oas-utils": "^0.2.101",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"json-xml-parse": "^1.3.0",
"react-aria": "^3.37.0",
"react-aria-components": "^1.6.0",
"usehooks-ts": "^3.1.0",
@@ -2410,6 +2411,8 @@
"json-stringify-deterministic": ["json-stringify-deterministic@1.0.12", "", {}, "sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g=="],
"json-xml-parse": ["json-xml-parse@1.3.0", "", {}, "sha512-MVosauc/3W2wL4dd4yaJzH5oXw+HOUfptn0+d4+bFghMiJFop7MaqIwFXJNLiRnNYJNQ6L4o7B+53n5wcvoLFw=="],
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
+1
View File
@@ -16,6 +16,7 @@
"@scalar/oas-utils": "^0.2.101",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"json-xml-parse": "^1.3.0",
"react-aria-components": "^1.6.0",
"react-aria": "^3.37.0",
"usehooks-ts": "^3.1.0",
@@ -58,7 +58,9 @@ export function OpenAPICodeSample(props: {
(searchParams.size ? `?${searchParams.toString()}` : ''),
method: data.method,
body: requestBodyContent
? generateMediaTypeExample(requestBodyContent[1], { onlyRequired: true })
? generateMediaTypeExample(requestBodyContent[1], {
omitEmptyAndOptionalProperties: true,
})
: undefined,
headers: {
...getSecurityHeaders(data.securities),
@@ -2,9 +2,9 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { generateSchemaExample } from './generateSchemaExample';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { checkIsReference, createStateKey, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { InteractiveSection } from './InteractiveSection';
import { json2xml } from './json2xml';
/**
* Display an example of the response content.
@@ -38,84 +38,51 @@ export function OpenAPIResponseExample(props: {
return Number(a) - Number(b);
});
const examples = responses
.map(([key, value]) => {
const responseObject = value;
const mediaTypeObject = (() => {
if (!responseObject.content) {
return null;
}
const key = Object.keys(responseObject.content)[0];
return (
responseObject.content['application/json'] ??
(key ? responseObject.content[key] : null)
);
})();
const tabs = responses
.map(([key, responseObject]) => {
const description = resolveDescription(responseObject);
if (!mediaTypeObject) {
if (checkIsReference(responseObject)) {
return {
key: key,
label: key,
description: resolveDescription(responseObject),
body: <OpenAPIEmptyResponseExample />,
description,
body: (
<OpenAPIExample
example={getExampleFromReference(responseObject)}
context={context}
syntax="json"
/>
),
};
}
const example = handleUnresolvedReference(
(() => {
const { examples, example } = mediaTypeObject;
if (examples) {
const key = Object.keys(examples)[0];
if (key) {
// @TODO handle multiple examples
const firstExample = examples[key];
if (firstExample) {
return firstExample;
}
}
}
if (example) {
return { value: example };
}
const schema = mediaTypeObject.schema;
if (!schema) {
return null;
}
return { value: generateSchemaExample(schema) };
})(),
);
if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
return {
key: key,
label: key,
description,
body: <OpenAPIEmptyResponseExample />,
};
}
return {
key: key,
label: key,
description: resolveDescription(responseObject),
body: example?.value ? (
<context.CodeBlock
code={
typeof example.value === 'string'
? example.value
: stringifyOpenAPI(example.value, null, 2)
}
syntax="json"
/>
) : (
<OpenAPIEmptyResponseExample />
),
body: <OpenAPIResponse context={context} content={responseObject.content} />,
};
})
.filter((val): val is { key: string; label: string; body: any; description: string } =>
Boolean(val),
);
if (examples.length === 0) {
if (tabs.length === 0) {
return null;
}
return (
<OpenAPITabs stateKey={createStateKey('response-example')} items={examples}>
<OpenAPITabs stateKey={createStateKey('response-example')} items={tabs}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example">
<OpenAPITabsPanels />
</InteractiveSection>
@@ -123,6 +90,212 @@ export function OpenAPIResponseExample(props: {
);
}
function OpenAPIResponse(props: {
context: OpenAPIContextProps;
content: {
[media: string]: OpenAPIV3.MediaTypeObject;
};
}) {
const { context, content } = props;
const entries = Object.entries(content);
const firstEntry = entries[0];
if (!firstEntry) {
throw new Error('One media type is required');
}
if (entries.length === 1) {
const [mediaType, mediaTypeObject] = firstEntry;
return (
<OpenAPIResponseMediaType
context={context}
mediaType={mediaType}
mediaTypeObject={mediaTypeObject}
/>
);
}
const tabs = entries.map((entry) => {
const [mediaType, mediaTypeObject] = entry;
return {
key: mediaType,
label: mediaType,
body: (
<OpenAPIResponseMediaType
context={context}
mediaType={mediaType}
mediaTypeObject={mediaTypeObject}
/>
),
};
});
return (
<OpenAPITabs stateKey={createStateKey('response-media-types')} items={tabs}>
<InteractiveSection
header={<OpenAPITabsList />}
className="openapi-response-media-types"
>
<OpenAPITabsPanels />
</InteractiveSection>
</OpenAPITabs>
);
}
function OpenAPIResponseMediaType(props: {
mediaTypeObject: OpenAPIV3.MediaTypeObject;
mediaType: string;
context: OpenAPIContextProps;
}) {
const { mediaTypeObject, mediaType } = props;
const examples = getExamplesFromMediaTypeObject({ mediaTypeObject, mediaType });
const syntax = getSyntaxFromMediaType(mediaType);
const firstExample = examples[0];
if (!firstExample) {
return <OpenAPIEmptyResponseExample />;
}
if (examples.length === 1) {
return (
<OpenAPIExample
example={firstExample.example}
context={props.context}
syntax={syntax}
/>
);
}
const tabs = examples.map((example) => {
return {
key: example.key,
label: example.example.summary || example.key,
body: (
<OpenAPIExample
example={firstExample.example}
context={props.context}
syntax={syntax}
/>
),
};
});
return (
<OpenAPITabs stateKey={createStateKey('response-media-type-examples')} items={tabs}>
<InteractiveSection
header={<OpenAPITabsList />}
className="openapi-response-media-type-examples"
>
<OpenAPITabsPanels />
</InteractiveSection>
</OpenAPITabs>
);
}
/**
* Display an example.
*/
function OpenAPIExample(props: {
example: OpenAPIV3.ExampleObject;
context: OpenAPIContextProps;
syntax: string;
}) {
const { example, context, syntax } = props;
const code = stringifyExample({ example, xml: syntax === 'xml' });
if (code === null) {
return <OpenAPIEmptyResponseExample />;
}
return <context.CodeBlock code={code} syntax={syntax} />;
}
function stringifyExample(args: { example: OpenAPIV3.ExampleObject; xml: boolean }): string | null {
const { example, xml } = args;
if (!example.value) {
return null;
}
if (typeof example.value === 'string') {
return example.value;
}
if (xml) {
return json2xml(example.value);
}
return JSON.stringify(example.value, null, 2);
}
/**
* Get the syntax from a media type.
*/
function getSyntaxFromMediaType(mediaType: string): string {
if (mediaType.includes('json')) {
return 'json';
}
if (mediaType === 'application/xml') {
return 'xml';
}
return 'text';
}
/**
* Get examples from a media type object.
*/
function getExamplesFromMediaTypeObject(args: {
mediaType: string;
mediaTypeObject: OpenAPIV3.MediaTypeObject;
}): { key: string; example: OpenAPIV3.ExampleObject }[] {
const { mediaTypeObject, mediaType } = args;
if (mediaTypeObject.examples) {
return Object.entries(mediaTypeObject.examples).map(([key, example]) => {
return {
key,
example: checkIsReference(example) ? getExampleFromReference(example) : example,
};
});
}
if (mediaTypeObject.example) {
return [{ key: 'default', example: { value: mediaTypeObject.example } }];
}
if (mediaTypeObject.schema) {
if (mediaType === 'application/xml') {
// @TODO normally we should use the name of the schema but we don't have it
// fix it when we got the reference name
const root = mediaTypeObject.schema.xml?.name ?? 'object';
return [
{
key: 'default',
example: {
value: {
[root]: generateSchemaExample(mediaTypeObject.schema, {
xml: mediaType === 'application/xml',
}),
},
},
},
];
}
return [
{
key: 'default',
example: { value: generateSchemaExample(mediaTypeObject.schema) },
},
];
}
return [];
}
/**
* Empty response example.
*/
function OpenAPIEmptyResponseExample() {
return (
<pre className="openapi-response-example-empty">
@@ -131,15 +304,9 @@ function OpenAPIEmptyResponseExample() {
);
}
function handleUnresolvedReference(
input: OpenAPIV3.ExampleObject | null,
): OpenAPIV3.ExampleObject | null {
const isReference = checkIsReference(input?.value);
if (isReference) {
// If we find a reference that wasn't resolved or needed to be resolved externally, render out the URL
return { value: input.value.$ref };
}
return input;
/**
* Generate an example from a reference object.
*/
function getExampleFromReference(ref: OpenAPIV3.ReferenceObject): OpenAPIV3.ExampleObject {
return { summary: 'Unresolved reference', value: { $ref: ref.$ref } };
}
@@ -0,0 +1,18 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`getUrlFromServerState indents correctly 1`] = `
"<?xml version="1.0"?>
<id>10</id>
<name>doggie</name>
<category>
<id>1</id>
<name>Dogs</name>
</category>
<photoUrls>string</photoUrls>
<tags>
<id>0</id>
<name>string</name>
</tags>
<status>available</status>
"
`;
@@ -3,18 +3,21 @@ import { getExampleFromSchema } from '@scalar/oas-utils/spec-getters';
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
type ScalarGetExampleFromSchemaOptions = NonNullable<Parameters<typeof getExampleFromSchema>[1]>;
type GenerateSchemaExampleOptions = Pick<
ScalarGetExampleFromSchemaOptions,
'xml' | 'omitEmptyAndOptionalProperties' | 'mode'
>;
/**
* Generate a JSON example from a schema
*/
export function generateSchemaExample(
schema: OpenAPIV3.SchemaObject,
options: {
onlyRequired?: boolean;
} = {},
options?: GenerateSchemaExampleOptions,
): JSONValue | undefined {
return getExampleFromSchema(schema, {
emptyString: 'text',
omitEmptyAndOptionalProperties: options.onlyRequired,
variables: {
'date-time': new Date().toISOString(),
date: new Date().toISOString().split('T')[0],
@@ -28,6 +31,7 @@ export function generateSchemaExample(
byte: 'Ynl0ZXM=',
password: 'password',
},
...options,
});
}
@@ -36,9 +40,7 @@ export function generateSchemaExample(
*/
export function generateMediaTypeExample(
mediaType: OpenAPIV3.MediaTypeObject,
options: {
onlyRequired?: boolean;
} = {},
options?: GenerateSchemaExampleOptions,
): JSONValue | undefined {
if (mediaType.example) {
return mediaType.example;
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'bun:test';
import { json2xml } from './json2xml';
describe('getUrlFromServerState', () => {
it('transforms JSON to xml', () => {
const xml = json2xml({
foo: 'bar',
});
expect(xml).toBe('<?xml version="1.0"?>\n<foo>bar</foo>\n');
});
it('wraps array items', () => {
const xml = json2xml({
urls: {
url: ['https://example.com', 'https://example.com'],
},
});
expect(xml).toBe(
'<?xml version="1.0"?>\n<urls>\n\t<url>https://example.com</url>\n\t<url>https://example.com</url>\n</urls>\n',
);
});
it('indents correctly', () => {
const xml = json2xml({
id: 10,
name: 'doggie',
category: {
id: 1,
name: 'Dogs',
},
photoUrls: ['string'],
tags: [
{
id: 0,
name: 'string',
},
],
status: 'available',
});
expect(xml).toMatchSnapshot();
});
});
+8
View File
@@ -0,0 +1,8 @@
import { jsXml } from 'json-xml-parse';
/**
* This function converts an object to XML.
*/
export function json2xml(data: Record<string, any>) {
return jsXml.toXmlString(data, { beautify: true });
}