From bb5c6a42e71a8e06a02ddf7bff2374cf23e88786 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 19 Feb 2025 20:39:15 +0100 Subject: [PATCH] Support multiple examples and multiple responses example (#2856) --- .changeset/green-kings-fix.md | 5 + bun.lock | 3 + packages/react-openapi/package.json | 1 + .../react-openapi/src/OpenAPICodeSample.tsx | 4 +- .../src/OpenAPIResponseExample.tsx | 303 ++++++++++++++---- .../src/__snapshots__/json2xml.test.ts.snap | 18 ++ .../src/generateSchemaExample.ts | 16 +- packages/react-openapi/src/json2xml.test.ts | 46 +++ packages/react-openapi/src/json2xml.ts | 8 + 9 files changed, 328 insertions(+), 76 deletions(-) create mode 100644 .changeset/green-kings-fix.md create mode 100644 packages/react-openapi/src/__snapshots__/json2xml.test.ts.snap create mode 100644 packages/react-openapi/src/json2xml.test.ts create mode 100644 packages/react-openapi/src/json2xml.ts diff --git a/.changeset/green-kings-fix.md b/.changeset/green-kings-fix.md new file mode 100644 index 000000000..5953968d4 --- /dev/null +++ b/.changeset/green-kings-fix.md @@ -0,0 +1,5 @@ +--- +'@gitbook/react-openapi': patch +--- + +Support multiple response media types and examples diff --git a/bun.lock b/bun.lock index 0f48fcf4b..e5a4a5029 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index 2fe722f97..adf3a3d2d 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -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", diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 69ba204fb..bda4537b4 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -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), diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx index 2658e16ce..e8f19dea4 100644 --- a/packages/react-openapi/src/OpenAPIResponseExample.tsx +++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx @@ -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: , + description, + body: ( + + ), }; } - 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: , + }; + } return { key: key, label: key, description: resolveDescription(responseObject), - body: example?.value ? ( - - ) : ( - - ), + body: , }; }) .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 ( - + } className="openapi-response-example"> @@ -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 ( + + ); + } + + const tabs = entries.map((entry) => { + const [mediaType, mediaTypeObject] = entry; + return { + key: mediaType, + label: mediaType, + body: ( + + ), + }; + }); + + return ( + + } + className="openapi-response-media-types" + > + + + + ); +} + +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 ; + } + + if (examples.length === 1) { + return ( + + ); + } + + const tabs = examples.map((example) => { + return { + key: example.key, + label: example.example.summary || example.key, + body: ( + + ), + }; + }); + + return ( + + } + className="openapi-response-media-type-examples" + > + + + + ); +} + +/** + * 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 ; + } + + return ; +} + +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 (
@@ -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 } };
 }
diff --git a/packages/react-openapi/src/__snapshots__/json2xml.test.ts.snap b/packages/react-openapi/src/__snapshots__/json2xml.test.ts.snap
new file mode 100644
index 000000000..d7ac3e47c
--- /dev/null
+++ b/packages/react-openapi/src/__snapshots__/json2xml.test.ts.snap
@@ -0,0 +1,18 @@
+// Bun Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`getUrlFromServerState indents correctly 1`] = `
+"
+10
+doggie
+
+	1
+	Dogs
+
+string
+
+	0
+	string
+
+available
+"
+`;
diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts
index 9d9c26acd..c00cb55d3 100644
--- a/packages/react-openapi/src/generateSchemaExample.ts
+++ b/packages/react-openapi/src/generateSchemaExample.ts
@@ -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[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;
diff --git a/packages/react-openapi/src/json2xml.test.ts b/packages/react-openapi/src/json2xml.test.ts
new file mode 100644
index 000000000..ac2147fd7
--- /dev/null
+++ b/packages/react-openapi/src/json2xml.test.ts
@@ -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('\nbar\n');
+    });
+
+    it('wraps array items', () => {
+        const xml = json2xml({
+            urls: {
+                url: ['https://example.com', 'https://example.com'],
+            },
+        });
+
+        expect(xml).toBe(
+            '\n\n\thttps://example.com\n\thttps://example.com\n\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();
+    });
+});
diff --git a/packages/react-openapi/src/json2xml.ts b/packages/react-openapi/src/json2xml.ts
new file mode 100644
index 000000000..44501251e
--- /dev/null
+++ b/packages/react-openapi/src/json2xml.ts
@@ -0,0 +1,8 @@
+import { jsXml } from 'json-xml-parse';
+
+/**
+ * This function converts an object to XML.
+ */
+export function json2xml(data: Record) {
+    return jsXml.toXmlString(data, { beautify: true });
+}