mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Support multiple examples and multiple responses example (#2856)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Support multiple response media types and examples
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user