From 46fb3b57d5602dc48a5dd94154e6e2fb53e10e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 19 Feb 2025 17:09:40 +0100 Subject: [PATCH 1/2] Support multiple examples and multiple responses example --- .changeset/green-kings-fix.md | 5 + bun.lock | 3 + packages/react-openapi/package.json | 1 + .../react-openapi/src/OpenAPICodeSample.tsx | 4 +- .../src/OpenAPIResponseExample.tsx | 272 +++++++++++++----- .../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, 296 insertions(+), 77 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 0000000000..5953968d43 --- /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 d0591adc22..bfae4865ca 100644 --- a/bun.lock +++ b/bun.lock @@ -210,6 +210,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", @@ -2400,6 +2401,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 2fe722f975..adf3a3d2d3 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 69ba204fb9..bda4537b43 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 2658e16ce4..20f058b5be 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,136 @@ 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) - ); - })(); - - if (!mediaTypeObject) { + const tabs = responses + .map(([key, responseObject]) => { + const description = resolveDescription(responseObject); + + 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"> + + + + ); +} + +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-example"> + + + + ); +} + +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.summary || 'Example', + label: example.summary || 'Example', + body: , + }; + }); + + return ( + } className="openapi-response-example"> @@ -123,6 +175,94 @@ export function OpenAPIResponseExample(props: { ); } +/** + * 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; +}): OpenAPIV3.ExampleObject[] { + const { mediaTypeObject, mediaType } = args; + if (mediaTypeObject.examples) { + return Object.values(mediaTypeObject.examples).map((example) => { + return checkIsReference(example) ? getExampleFromReference(example) : example; + }); + } + + if (mediaTypeObject.example) { + return [{ value: mediaTypeObject.example }]; + } + + if (mediaTypeObject.schema) { + // @TODO normally we should use the name of the schema but we don't have it + const root = mediaTypeObject.schema.xml?.name ?? 'object'; + return [ + { + value: { + [root]: generateSchemaExample(mediaTypeObject.schema, { + xml: mediaType === 'application/xml', + }), + }, + }, + ]; + } + return []; +} + +/** + * Empty response example. + */ function OpenAPIEmptyResponseExample() { return (
@@ -131,15 +271,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 0000000000..d7ac3e47ce
--- /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 9d9c26acd6..c00cb55d3f 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 0000000000..ac2147fd78
--- /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 0000000000..44501251e4
--- /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 });
+}

From 47eb945df753e88a0a8ac053e113f6227c3b88c2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Greg=20Berg=C3=A9?= 
Date: Wed, 19 Feb 2025 18:07:46 +0100
Subject: [PATCH 2/2] Fix UI display

---
 .../src/OpenAPIResponseExample.tsx            | 71 ++++++++++++++-----
 1 file changed, 52 insertions(+), 19 deletions(-)

diff --git a/packages/react-openapi/src/OpenAPIResponseExample.tsx b/packages/react-openapi/src/OpenAPIResponseExample.tsx
index 20f058b5be..e8f19dea45 100644
--- a/packages/react-openapi/src/OpenAPIResponseExample.tsx
+++ b/packages/react-openapi/src/OpenAPIResponseExample.tsx
@@ -132,8 +132,11 @@ function OpenAPIResponse(props: {
     });
 
     return (
-        
-            } className="openapi-response-example">
+        
+            }
+                className="openapi-response-media-types"
+            >
                 
             
         
@@ -155,20 +158,35 @@ function OpenAPIResponseMediaType(props: {
     }
 
     if (examples.length === 1) {
-        return ;
+        return (
+            
+        );
     }
 
     const tabs = examples.map((example) => {
         return {
-            key: example.summary || 'Example',
-            label: example.summary || 'Example',
-            body: ,
+            key: example.key,
+            label: example.example.summary || example.key,
+            body: (
+                
+            ),
         };
     });
 
     return (
-        
-            } className="openapi-response-example">
+        
+            }
+                className="openapi-response-media-type-examples"
+            >
                 
             
         
@@ -232,28 +250,43 @@ function getSyntaxFromMediaType(mediaType: string): string {
 function getExamplesFromMediaTypeObject(args: {
     mediaType: string;
     mediaTypeObject: OpenAPIV3.MediaTypeObject;
-}): OpenAPIV3.ExampleObject[] {
+}): { key: string; example: OpenAPIV3.ExampleObject }[] {
     const { mediaTypeObject, mediaType } = args;
     if (mediaTypeObject.examples) {
-        return Object.values(mediaTypeObject.examples).map((example) => {
-            return checkIsReference(example) ? getExampleFromReference(example) : example;
+        return Object.entries(mediaTypeObject.examples).map(([key, example]) => {
+            return {
+                key,
+                example: checkIsReference(example) ? getExampleFromReference(example) : example,
+            };
         });
     }
 
     if (mediaTypeObject.example) {
-        return [{ value: mediaTypeObject.example }];
+        return [{ key: 'default', example: { value: mediaTypeObject.example } }];
     }
 
     if (mediaTypeObject.schema) {
-        // @TODO normally we should use the name of the schema but we don't have it
-        const root = mediaTypeObject.schema.xml?.name ?? 'object';
+        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 [
             {
-                value: {
-                    [root]: generateSchemaExample(mediaTypeObject.schema, {
-                        xml: mediaType === 'application/xml',
-                    }),
-                },
+                key: 'default',
+                example: { value: generateSchemaExample(mediaTypeObject.schema) },
             },
         ];
     }