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", "@scalar/oas-utils": "^0.2.101",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flatted": "^3.2.9", "flatted": "^3.2.9",
"json-xml-parse": "^1.3.0",
"react-aria": "^3.37.0", "react-aria": "^3.37.0",
"react-aria-components": "^1.6.0", "react-aria-components": "^1.6.0",
"usehooks-ts": "^3.1.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-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=="], "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=="], "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", "@scalar/oas-utils": "^0.2.101",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"flatted": "^3.2.9", "flatted": "^3.2.9",
"json-xml-parse": "^1.3.0",
"react-aria-components": "^1.6.0", "react-aria-components": "^1.6.0",
"react-aria": "^3.37.0", "react-aria": "^3.37.0",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
@@ -58,7 +58,9 @@ export function OpenAPICodeSample(props: {
(searchParams.size ? `?${searchParams.toString()}` : ''), (searchParams.size ? `?${searchParams.toString()}` : ''),
method: data.method, method: data.method,
body: requestBodyContent body: requestBodyContent
? generateMediaTypeExample(requestBodyContent[1], { onlyRequired: true }) ? generateMediaTypeExample(requestBodyContent[1], {
omitEmptyAndOptionalProperties: true,
})
: undefined, : undefined,
headers: { headers: {
...getSecurityHeaders(data.securities), ...getSecurityHeaders(data.securities),
@@ -2,9 +2,9 @@ import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { generateSchemaExample } from './generateSchemaExample'; import { generateSchemaExample } from './generateSchemaExample';
import type { OpenAPIContextProps, OpenAPIOperationData } from './types'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { checkIsReference, createStateKey, resolveDescription } from './utils'; import { checkIsReference, createStateKey, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { json2xml } from './json2xml';
/** /**
* Display an example of the response content. * Display an example of the response content.
@@ -38,84 +38,51 @@ export function OpenAPIResponseExample(props: {
return Number(a) - Number(b); return Number(a) - Number(b);
}); });
const examples = responses const tabs = responses
.map(([key, value]) => { .map(([key, responseObject]) => {
const responseObject = value; const description = resolveDescription(responseObject);
const mediaTypeObject = (() => {
if (!responseObject.content) {
return null;
}
const key = Object.keys(responseObject.content)[0];
return (
responseObject.content['application/json'] ??
(key ? responseObject.content[key] : null)
);
})();
if (!mediaTypeObject) { if (checkIsReference(responseObject)) {
return { return {
key: key, key: key,
label: key, label: key,
description: resolveDescription(responseObject), description,
body: <OpenAPIEmptyResponseExample />, body: (
<OpenAPIExample
example={getExampleFromReference(responseObject)}
context={context}
syntax="json"
/>
),
}; };
} }
const example = handleUnresolvedReference( if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
(() => { return {
const { examples, example } = mediaTypeObject; key: key,
if (examples) { label: key,
const key = Object.keys(examples)[0]; description,
if (key) { body: <OpenAPIEmptyResponseExample />,
// @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) };
})(),
);
return { return {
key: key, key: key,
label: key, label: key,
description: resolveDescription(responseObject), description: resolveDescription(responseObject),
body: example?.value ? ( body: <OpenAPIResponse context={context} content={responseObject.content} />,
<context.CodeBlock
code={
typeof example.value === 'string'
? example.value
: stringifyOpenAPI(example.value, null, 2)
}
syntax="json"
/>
) : (
<OpenAPIEmptyResponseExample />
),
}; };
}) })
.filter((val): val is { key: string; label: string; body: any; description: string } => .filter((val): val is { key: string; label: string; body: any; description: string } =>
Boolean(val), Boolean(val),
); );
if (examples.length === 0) { if (tabs.length === 0) {
return null; return null;
} }
return ( return (
<OpenAPITabs stateKey={createStateKey('response-example')} items={examples}> <OpenAPITabs stateKey={createStateKey('response-example')} items={tabs}>
<InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example"> <InteractiveSection header={<OpenAPITabsList />} className="openapi-response-example">
<OpenAPITabsPanels /> <OpenAPITabsPanels />
</InteractiveSection> </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() { function OpenAPIEmptyResponseExample() {
return ( return (
<pre className="openapi-response-example-empty"> <pre className="openapi-response-example-empty">
@@ -131,15 +304,9 @@ function OpenAPIEmptyResponseExample() {
); );
} }
function handleUnresolvedReference( /**
input: OpenAPIV3.ExampleObject | null, * Generate an example from a reference object.
): OpenAPIV3.ExampleObject | null { */
const isReference = checkIsReference(input?.value); function getExampleFromReference(ref: OpenAPIV3.ReferenceObject): OpenAPIV3.ExampleObject {
return { summary: 'Unresolved reference', value: { $ref: ref.$ref } };
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;
} }
@@ -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 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 * Generate a JSON example from a schema
*/ */
export function generateSchemaExample( export function generateSchemaExample(
schema: OpenAPIV3.SchemaObject, schema: OpenAPIV3.SchemaObject,
options: { options?: GenerateSchemaExampleOptions,
onlyRequired?: boolean;
} = {},
): JSONValue | undefined { ): JSONValue | undefined {
return getExampleFromSchema(schema, { return getExampleFromSchema(schema, {
emptyString: 'text', emptyString: 'text',
omitEmptyAndOptionalProperties: options.onlyRequired,
variables: { variables: {
'date-time': new Date().toISOString(), 'date-time': new Date().toISOString(),
date: new Date().toISOString().split('T')[0], date: new Date().toISOString().split('T')[0],
@@ -28,6 +31,7 @@ export function generateSchemaExample(
byte: 'Ynl0ZXM=', byte: 'Ynl0ZXM=',
password: 'password', password: 'password',
}, },
...options,
}); });
} }
@@ -36,9 +40,7 @@ export function generateSchemaExample(
*/ */
export function generateMediaTypeExample( export function generateMediaTypeExample(
mediaType: OpenAPIV3.MediaTypeObject, mediaType: OpenAPIV3.MediaTypeObject,
options: { options?: GenerateSchemaExampleOptions,
onlyRequired?: boolean;
} = {},
): JSONValue | undefined { ): JSONValue | undefined {
if (mediaType.example) { if (mediaType.example) {
return 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 });
}