From 12c786225011fb476a4a3706a5b30bb0fe49d151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Thu, 30 Jan 2025 08:44:08 +0100 Subject: [PATCH] Use Scalar OpenAPI parser (#2780) --- .changeset/tender-ties-check.md | 8 + bun.lock | 17 +- package.json | 1 + packages/gitbook/e2e/pages.spec.ts | 8 +- packages/gitbook/src/lib/openapi.ts | 12 +- packages/react-openapi/package.json | 7 +- .../react-openapi/src/OpenAPICodeSample.tsx | 9 +- .../react-openapi/src/OpenAPIOperation.tsx | 2 +- .../react-openapi/src/OpenAPIRequestBody.tsx | 2 +- .../react-openapi/src/OpenAPIResponse.tsx | 2 +- .../react-openapi/src/OpenAPIResponses.tsx | 4 +- .../react-openapi/src/OpenAPISchema.test.ts | 2 +- packages/react-openapi/src/OpenAPISchema.tsx | 9 +- .../react-openapi/src/OpenAPISecurities.tsx | 4 +- .../react-openapi/src/OpenAPIServerURL.tsx | 2 +- .../src/OpenAPIServerURLVariable.tsx | 2 +- packages/react-openapi/src/OpenAPISpec.tsx | 18 +- .../src/fetchOpenAPIOperation.test.ts | 6 +- .../src/fetchOpenAPIOperation.ts | 267 ++++++++---------- .../src/generateSchemaExample.ts | 2 +- packages/react-openapi/src/index.ts | 2 +- packages/react-openapi/src/parser/error.ts | 11 + packages/react-openapi/src/parser/index.ts | 22 ++ packages/react-openapi/src/parser/traverse.ts | 38 +++ packages/react-openapi/src/parser/types.ts | 45 +++ packages/react-openapi/src/parser/v2.ts | 70 +++++ packages/react-openapi/src/parser/v3.ts | 35 +++ .../src/resolveOpenAPIPath.test.ts | 60 ---- .../react-openapi/src/resolveOpenAPIPath.ts | 145 ---------- .../react-openapi/src/stringifyOpenAPI.ts | 16 +- packages/react-openapi/src/types.ts | 14 - packages/react-openapi/src/utils.ts | 8 +- 32 files changed, 415 insertions(+), 435 deletions(-) create mode 100644 .changeset/tender-ties-check.md create mode 100644 packages/react-openapi/src/parser/error.ts create mode 100644 packages/react-openapi/src/parser/index.ts create mode 100644 packages/react-openapi/src/parser/traverse.ts create mode 100644 packages/react-openapi/src/parser/types.ts create mode 100644 packages/react-openapi/src/parser/v2.ts create mode 100644 packages/react-openapi/src/parser/v3.ts delete mode 100644 packages/react-openapi/src/resolveOpenAPIPath.test.ts delete mode 100644 packages/react-openapi/src/resolveOpenAPIPath.ts diff --git a/.changeset/tender-ties-check.md b/.changeset/tender-ties-check.md new file mode 100644 index 000000000..a604ffd09 --- /dev/null +++ b/.changeset/tender-ties-check.md @@ -0,0 +1,8 @@ +--- +'@gitbook/react-openapi': major +--- + +Use `@scalar/openapi-parser` to be more resilient and perf on OpenAPI spec parsing: + +- `fetcher.fetch` must now returns a valid OpenAPI document +- `parseOpenAPIV3` has been replaced by `parseOpenAPI` diff --git a/bun.lock b/bun.lock index d9c5b4d2b..f3ea88ab3 100644 --- a/bun.lock +++ b/bun.lock @@ -178,9 +178,10 @@ "version": "0.7.1", "dependencies": { "@scalar/api-client-react": "1.0.87", + "@scalar/openapi-parser": "^0.10.4", + "@scalar/openapi-types": "^0.1.6", "classnames": "^2.5.1", "flatted": "^3.2.9", - "openapi-types": "^12.1.3", "swagger2openapi": "^7.0.8", "usehooks-ts": "^3.1.0", "yaml": "1.10.2", @@ -725,9 +726,9 @@ "@scalar/object-utils": ["@scalar/object-utils@1.1.12", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1" } }, "sha512-RMC4kKkFVpFKHL8QbJJpRGY4cAtc/6w4Gaf5zaZj6qtc0OZZRJyF+G9JfcqmYd98FW4DfmzN4Pr0nhYQJraZ0A=="], - "@scalar/openapi-parser": ["@scalar/openapi-parser@0.8.9", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-vTXrkl/hX3CG2dMe8mYutjsjfFLZOXGFJGqz6k8lUk8avFP/5eGEsd/eZe1UIcwtDwcjgJIyQ4p3pIC7jBvfuw=="], + "@scalar/openapi-parser": ["@scalar/openapi-parser@0.10.4", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-t0+PQcLWCRYtvkfhZf74uRsN/5FmwjxWjMEuj32aM1iseUmEHMirYFyyZMIkQpfO0DblEgHQZcmzj+v8O4aOjw=="], - "@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="], + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.6", "", {}, "sha512-V+KnESyVJqorJzEN0QFlu3tAImCHjnvPov6QcQvjfY7s0+CjrI3rRO3oVIRlXURTQrQGrnhxvK0SkXGAZ+dxvw=="], "@scalar/themes": ["@scalar/themes@0.9.48", "", { "dependencies": { "@scalar/types": "0.0.19" } }, "sha512-lsehl9KnlKgWG8BOIIaX3iIuNMLjRFZ0ctb1KsQBhOSsoTrq3c9EUJdYJiiTWzm52zdhF+RkIx7uHaX/pvgctw=="], @@ -2997,6 +2998,10 @@ "@rollup/plugin-commonjs/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + "@scalar/api-client/@scalar/openapi-parser": ["@scalar/openapi-parser@0.8.9", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-vTXrkl/hX3CG2dMe8mYutjsjfFLZOXGFJGqz6k8lUk8avFP/5eGEsd/eZe1UIcwtDwcjgJIyQ4p3pIC7jBvfuw=="], + + "@scalar/api-client/@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="], + "@scalar/api-client/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="], "@scalar/api-client/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="], @@ -3005,14 +3010,20 @@ "@scalar/components/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="], + "@scalar/import/@scalar/openapi-parser": ["@scalar/openapi-parser@0.8.9", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-vTXrkl/hX3CG2dMe8mYutjsjfFLZOXGFJGqz6k8lUk8avFP/5eGEsd/eZe1UIcwtDwcjgJIyQ4p3pIC7jBvfuw=="], + "@scalar/import/yaml": ["yaml@2.6.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="], + "@scalar/oas-utils/@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="], + "@scalar/oas-utils/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="], "@scalar/oas-utils/yaml": ["yaml@2.6.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="], "@scalar/openapi-parser/yaml": ["yaml@2.6.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="], + "@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="], + "@scalar/use-toasts/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], diff --git a/package.json b/package.json index 9c5913fc8..2d89793da 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev": "turbo run dev --ui=stream", "build": "turbo run build", "build:cloudflare": "turbo run build:cloudflare", + "clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules", "lint": "turbo run lint", "lint:fix": "turbo run lint -- --fix", "typecheck": "turbo run typecheck", diff --git a/packages/gitbook/e2e/pages.spec.ts b/packages/gitbook/e2e/pages.spec.ts index 5c99c2ce8..ea696b3ca 100644 --- a/packages/gitbook/e2e/pages.spec.ts +++ b/packages/gitbook/e2e/pages.spec.ts @@ -142,7 +142,9 @@ const testCases: TestsCase[] = [ name: 'AI Search', url: '?q=What+is+GitBook%3F&ask=true', run: async (page) => { - await expect(page.getByTestId('search-ask-answer')).toBeVisible(); + await expect(page.getByTestId('search-ask-answer')).toBeVisible({ + timeout: 15_000, + }); }, screenshot: false, }, @@ -322,7 +324,9 @@ const testCases: TestsCase[] = [ name: 'AI Search', url: '?q=What+is+GitBook%3F&ask=true', run: async (page) => { - await expect(page.getByTestId('search-ask-answer')).toBeVisible(); + await expect(page.getByTestId('search-ask-answer')).toBeVisible({ + timeout: 15_000, + }); }, screenshot: false, }, diff --git a/packages/gitbook/src/lib/openapi.ts b/packages/gitbook/src/lib/openapi.ts index 879a626c3..8557e627a 100644 --- a/packages/gitbook/src/lib/openapi.ts +++ b/packages/gitbook/src/lib/openapi.ts @@ -3,8 +3,8 @@ import { OpenAPIOperationData, fetchOpenAPIOperation, OpenAPIFetcher, - parseOpenAPIV3, - OpenAPIFetchError, + parseOpenAPI, + OpenAPIParseError, } from '@gitbook/react-openapi'; import { cache, parseCacheResponse, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache'; @@ -20,7 +20,7 @@ export async function fetchOpenAPIBlock( resolveContentRef: (ref: ContentRef) => Promise, ): Promise< | { data: OpenAPIOperationData | null; specUrl: string | null; error?: undefined } - | { error: OpenAPIFetchError; data?: undefined; specUrl?: undefined } + | { error: OpenAPIParseError; data?: undefined; specUrl?: undefined } > { const resolved = block.data.ref ? await resolveContentRef(block.data.ref) : null; if (!resolved || !block.data.path || !block.data.method) { @@ -39,7 +39,7 @@ export async function fetchOpenAPIBlock( return { data, specUrl: resolved.href }; } catch (error) { - if (error instanceof OpenAPIFetchError) { + if (error instanceof OpenAPIParseError) { return { error }; } @@ -49,7 +49,7 @@ export async function fetchOpenAPIBlock( const fetcher: OpenAPIFetcher = { fetch: cache({ - name: 'openapi.fetch', + name: 'openapi.fetch.v2', get: async (url: string, options: CacheFunctionOptions) => { // Wrap the raw string to prevent invalid URLs from being passed to fetch. // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: @@ -66,7 +66,7 @@ const fetcher: OpenAPIFetcher = { } const text = await response.text(); - const data = await parseOpenAPIV3(url, text); + const data = await parseOpenAPI({ url, value: text }); return { ...parseCacheResponse(response), data, diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index 911b0dc78..bf2f7e459 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -11,12 +11,13 @@ "version": "0.7.1", "dependencies": { "@scalar/api-client-react": "1.0.87", + "@scalar/openapi-parser": "^0.10.4", + "@scalar/openapi-types": "^0.1.6", "classnames": "^2.5.1", "flatted": "^3.2.9", - "openapi-types": "^12.1.3", "swagger2openapi": "^7.0.8", - "yaml": "1.10.2", - "usehooks-ts": "^3.1.0" + "usehooks-ts": "^3.1.0", + "yaml": "1.10.2" }, "devDependencies": { "@types/swagger2openapi": "^7.0.4", diff --git a/packages/react-openapi/src/OpenAPICodeSample.tsx b/packages/react-openapi/src/OpenAPICodeSample.tsx index 19542ef31..5b1118d59 100644 --- a/packages/react-openapi/src/OpenAPICodeSample.tsx +++ b/packages/react-openapi/src/OpenAPICodeSample.tsx @@ -33,7 +33,7 @@ export function OpenAPICodeSample(props: { const example = param.schema ? generateSchemaExample(noReference(param.schema)) : undefined; - if (example !== undefined) { + if (example !== undefined && param.name) { headersObject[param.name] = typeof example !== 'string' ? stringifyOpenAPI(example) : example; } @@ -41,7 +41,7 @@ export function OpenAPICodeSample(props: { const example = param.schema ? generateSchemaExample(noReference(param.schema)) : undefined; - if (example !== undefined) { + if (example !== undefined && param.name) { searchParams.append( param.name, String(Array.isArray(example) ? example[0] : example), @@ -51,7 +51,10 @@ export function OpenAPICodeSample(props: { }); const requestBody = noReference(data.operation.requestBody); - const requestBodyContent = requestBody ? Object.entries(requestBody.content)[0] : undefined; + const requestBodyContentEntries = requestBody?.content + ? Object.entries(requestBody.content) + : undefined; + const requestBodyContent = requestBodyContentEntries?.[0]; const input: CodeSampleInput = { url: diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 031aec24c..0d1e97bce 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -57,7 +57,7 @@ export function OpenAPIOperation(props: {
- +
diff --git a/packages/react-openapi/src/OpenAPIRequestBody.tsx b/packages/react-openapi/src/OpenAPIRequestBody.tsx index c19a2341f..d8fb8feae 100644 --- a/packages/react-openapi/src/OpenAPIRequestBody.tsx +++ b/packages/react-openapi/src/OpenAPIRequestBody.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; import { OpenAPIRootSchema } from './OpenAPISchema'; import { noReference } from './utils'; import { OpenAPIClientContext } from './types'; diff --git a/packages/react-openapi/src/OpenAPIResponse.tsx b/packages/react-openapi/src/OpenAPIResponse.tsx index 29473397f..6fe795274 100644 --- a/packages/react-openapi/src/OpenAPIResponse.tsx +++ b/packages/react-openapi/src/OpenAPIResponse.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import classNames from 'classnames'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; import { OpenAPIRootSchema, OpenAPISchemaProperties } from './OpenAPISchema'; import { noReference } from './utils'; import { OpenAPIClientContext } from './types'; diff --git a/packages/react-openapi/src/OpenAPIResponses.tsx b/packages/react-openapi/src/OpenAPIResponses.tsx index 7b701acd7..33372f124 100644 --- a/packages/react-openapi/src/OpenAPIResponses.tsx +++ b/packages/react-openapi/src/OpenAPIResponses.tsx @@ -1,16 +1,16 @@ import * as React from 'react'; import classNames from 'classnames'; -import { OpenAPIV3 } from 'openapi-types'; import { createStateKey, noReference } from './utils'; import { OpenAPIResponse } from './OpenAPIResponse'; import { OpenAPIClientContext } from './types'; import { InteractiveSection } from './InteractiveSection'; +import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types'; /** * Display an interactive response body. */ export function OpenAPIResponses(props: { - responses: OpenAPIV3.ResponsesObject; + responses: OpenAPIV3.ResponsesObject | OpenAPIV3_1.ResponsesObject; context: OpenAPIClientContext; }) { const { responses, context } = props; diff --git a/packages/react-openapi/src/OpenAPISchema.test.ts b/packages/react-openapi/src/OpenAPISchema.test.ts index 67ea5ac8b..954a9eefb 100644 --- a/packages/react-openapi/src/OpenAPISchema.test.ts +++ b/packages/react-openapi/src/OpenAPISchema.test.ts @@ -1,6 +1,6 @@ import { it, describe, expect } from 'bun:test'; import { getSchemaAlternatives } from './OpenAPISchema'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; describe('getSchemaAlternatives', () => { it('should flatten oneOf', () => { diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index dbdada966..e914e3fa1 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -1,10 +1,9 @@ import classNames from 'classnames'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; import React, { useId } from 'react'; import { InteractiveSection } from './InteractiveSection'; import { Markdown } from './Markdown'; -import { SYMBOL_REF_RESOLVED } from './resolveOpenAPIPath'; import { OpenAPIClientContext } from './types'; import { noReference } from './utils'; import { stringifyOpenAPI } from './stringifyOpenAPI'; @@ -362,7 +361,7 @@ function getSchemaTitle( } // Try using the discriminator - if (discriminator && schema.properties) { + if (discriminator?.propertyName && schema.properties) { const discriminatorProperty = noReference(schema.properties[discriminator.propertyName]); if (discriminatorProperty) { if (discriminatorProperty.enum) { @@ -395,10 +394,6 @@ function getSchemaTitle( type = 'not'; } - if (SYMBOL_REF_RESOLVED in schema) { - type = `${schema[SYMBOL_REF_RESOLVED]} (${type})`; - } - if (schema.nullable) { type = `nullable ${type}`; } diff --git a/packages/react-openapi/src/OpenAPISecurities.tsx b/packages/react-openapi/src/OpenAPISecurities.tsx index bea9aa335..f9ea33e29 100644 --- a/packages/react-openapi/src/OpenAPISecurities.tsx +++ b/packages/react-openapi/src/OpenAPISecurities.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3_1 } from '@scalar/openapi-types'; import { OpenAPIClientContext } from './types'; import { InteractiveSection } from './InteractiveSection'; import { Markdown } from './Markdown'; @@ -47,7 +47,7 @@ export function OpenAPISecurities(props: { ); } -function getLabelForType(security: OpenAPIV3.SecuritySchemeObject) { +function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject): string { switch (security.type) { case 'apiKey': return 'API Key'; diff --git a/packages/react-openapi/src/OpenAPIServerURL.tsx b/packages/react-openapi/src/OpenAPIServerURL.tsx index e81fb47ba..49dfdb9bc 100644 --- a/packages/react-openapi/src/OpenAPIServerURL.tsx +++ b/packages/react-openapi/src/OpenAPIServerURL.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; import { OpenAPIServerURLVariable } from './OpenAPIServerURLVariable'; /** diff --git a/packages/react-openapi/src/OpenAPIServerURLVariable.tsx b/packages/react-openapi/src/OpenAPIServerURLVariable.tsx index 408c84117..c777432a7 100644 --- a/packages/react-openapi/src/OpenAPIServerURLVariable.tsx +++ b/packages/react-openapi/src/OpenAPIServerURLVariable.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import classNames from 'classnames'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; /** * Interactive component to show the value of a server variable and let the user change it. diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx index ab56435a2..6e87424bc 100644 --- a/packages/react-openapi/src/OpenAPISpec.tsx +++ b/packages/react-openapi/src/OpenAPISpec.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3, OpenAPIV2, OpenAPI } from '@scalar/openapi-types'; import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation'; import { InteractiveSection } from './InteractiveSection'; @@ -18,13 +18,13 @@ import { noReference } from './utils'; * We use a client component as rendering recursive JSON schema in the server is expensive * (the entire schema is rendered at once, while the client component only renders the visible part) */ -export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext }) { - const { rawData, context } = props; +export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAPIClientContext }) { + const { data, context } = props; - const parsedData = fromJSON(rawData) as OpenAPIOperationData; - const { operation, securities } = parsedData; + const { operation, securities } = data; - const parameterGroups = groupParameters((operation.parameters || []).map(noReference)); + const parameters = operation.parameters ?? []; + const parameterGroups = groupParameters(parameters); return ( <> @@ -72,17 +72,17 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext ); } -function groupParameters(parameters: OpenAPIV3.ParameterObject[]): Array<{ +function groupParameters(parameters: OpenAPI.Parameters): Array<{ key: string; label: string; - parameters: OpenAPIV3.ParameterObject[]; + parameters: OpenAPI.Parameters; }> { const sorted = ['path', 'query', 'header']; const groups: Array<{ key: string; label: string; - parameters: OpenAPIV3.ParameterObject[]; + parameters: OpenAPI.Parameters; }> = []; parameters.forEach((parameter) => { diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts index e8d49765a..ee20424af 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts @@ -1,12 +1,12 @@ import { it, expect } from 'bun:test'; -import { fetchOpenAPIOperation, parseOpenAPIV3 } from './fetchOpenAPIOperation'; -import { OpenAPIFetcher } from './types'; +import { fetchOpenAPIOperation, OpenAPIFetcher } from './fetchOpenAPIOperation'; +import { parseOpenAPI } from './parser'; const fetcher: OpenAPIFetcher = { fetch: async (url) => { const response = await fetch(url); - return parseOpenAPIV3(url, await response.text()); + return parseOpenAPI({ value: await response.text(), url }); }, }; diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.ts b/packages/react-openapi/src/fetchOpenAPIOperation.ts index f6b23d841..fb59debfc 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.ts @@ -1,10 +1,27 @@ import { toJSON, fromJSON } from 'flatted'; -import { OpenAPIV3 } from 'openapi-types'; -import YAML from 'yaml'; -import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi'; -import { resolveOpenAPIPath } from './resolveOpenAPIPath'; -import { OpenAPIFetcher } from './types'; +import { OpenAPICustomSpecProperties } from './parser'; +import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types'; +import { noReference } from './utils'; +import { traverse } from './parser/traverse'; +import { AnyObject } from '@scalar/openapi-parser'; + +export interface OpenAPIFetcher { + /** + * Fetch an OpenAPI file by its URL. It should return a fully parsed OpenAPI v3 document. + */ + fetch: ( + url: string, + ) => Promise< + | OpenAPIV3_1.Document + | OpenAPIV3.Document + >; + + /** + * Parse markdown to the react element to render. + */ + parseMarkdown?: (input: string) => Promise; +} export interface OpenAPIOperationData extends OpenAPICustomSpecProperties { path: string; @@ -14,56 +31,12 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties { servers: OpenAPIV3.ServerObject[]; /** Spec of the operation */ - operation: OpenAPIV3.OperationObject & OpenAPICustomOperationProperties; + operation: OpenAPIV3.OperationObject; /** Securities that should be used for this operation */ securities: [string, OpenAPIV3.SecuritySchemeObject][]; } -/** - * Custom properties that can be defined at the entire spec level. - */ -export interface OpenAPICustomSpecProperties { - /** - * If `true`, code samples will not be displayed. - * This option can be used to hide code samples for the entire spec. - */ - 'x-codeSamples'?: boolean; - - /** - * If `true`, the "Try it" button will not be displayed. - * This option can be used to hide code samples for the entire spec. - */ - 'x-hideTryItPanel'?: boolean; -} - -/** - * Custom properties that can be defined at the operation level. - * These properties are not part of the OpenAPI spec. - */ -export interface OpenAPICustomOperationProperties { - 'x-code-samples'?: OpenAPICustomCodeSample[]; - 'x-codeSamples'?: OpenAPICustomCodeSample[] | false; - 'x-custom-examples'?: OpenAPICustomCodeSample[]; - - /** - * If `true`, the "Try it" button will not be displayed. - * https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/ - */ - 'x-hideTryItPanel'?: boolean; -} - -/** - * Custom code samples that can be defined at the operation level. - * It follows the spec defined by Redocly. - * https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/ - */ -export interface OpenAPICustomCodeSample { - lang: string; - label: string; - source: string; -} - export { toJSON, fromJSON }; /** @@ -79,24 +52,22 @@ export async function fetchOpenAPIOperation( ): Promise { const fetcher = cacheFetcher(rawFetcher); - let operation = await resolveOpenAPIPath( - input.url, - ['paths', input.path, input.method], - fetcher, - ); + const schema = await fetcher.fetch(input.url); + + let operation = getOperationByPathAndMethod(schema, input.path, input.method); if (!operation) { return null; } - const specData = await fetcher.fetch(input.url); + // Parse description in markdown + const { parseMarkdown } = fetcher; + if (parseMarkdown) { + operation = await parseDescriptions(operation, parseMarkdown); + } // Resolve common parameters - const commonParameters = await resolveOpenAPIPath( - input.url, - ['paths', input.path, 'parameters'], - fetcher, - ); + const commonParameters = getPathObjectParameter(schema, input.path); if (commonParameters) { operation = { ...operation, @@ -104,44 +75,108 @@ export async function fetchOpenAPIOperation( }; } - // Resolve servers - const servers = await resolveOpenAPIPath( - input.url, - ['servers'], - fetcher, - ); + const servers = 'servers' in schema ? (schema.servers ?? []) : []; + const security = operation.security ?? schema.security ?? []; // Resolve securities const securities: OpenAPIOperationData['securities'] = []; - for (const security of operation.security ?? []) { - const securityKey = Object.keys(security)[0]; - - const securityScheme = await resolveOpenAPIPath( - input.url, - ['components', 'securitySchemes', securityKey], - fetcher, - ); - + for (const entry of security) { + const securityKey = Object.keys(entry)[0]; + const securityScheme = schema.components?.securitySchemes?.[securityKey]; if (securityScheme) { - securities.push([securityKey, securityScheme]); + securities.push([securityKey, noReference(securityScheme)]); } } return { - servers: servers ?? [], + servers, operation, method: input.method, path: input.path, securities, 'x-codeSamples': - typeof specData['x-codeSamples'] === 'boolean' ? specData['x-codeSamples'] : undefined, + typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined, 'x-hideTryItPanel': - typeof specData['x-hideTryItPanel'] === 'boolean' - ? specData['x-hideTryItPanel'] + typeof schema['x-hideTryItPanel'] === 'boolean' + ? schema['x-hideTryItPanel'] : undefined, }; } +async function parseDescriptions( + spec: T, + parseMarkdown: (input: string) => Promise, +): Promise { + const promises: Record> = {}; + const results: Record = {}; + traverse(spec, (obj) => { + if ('description' in obj && typeof obj.description === 'string') { + promises[obj.description] = parseMarkdown(obj.description); + } + return obj; + }); + await Promise.all( + Object.entries(promises).map(async ([key, promise]) => { + results[key] = await promise; + }), + ); + return traverse(spec, (obj) => { + if ('description' in obj && typeof obj.description === 'string') { + obj.description = results[obj.description]; + } + return obj; + }) as T; +} + +/** + * Get a path object from its path. + */ +function getPathObject( + schema: OpenAPIV3.Document | OpenAPIV3_1.Document, + path: string, +): OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject | null { + if (schema.paths?.[path]) { + return schema.paths[path]; + } + return null; +} + +/** + * Resolve parameters from a path in an OpenAPI schema. + */ +function getPathObjectParameter( + schema: OpenAPIV3.Document | OpenAPIV3_1.Document, + path: string, +): OpenAPIV3.ParameterObject[] | OpenAPIV3_1.ParameterObject[] | null { + const pathObject = getPathObject(schema, path); + if (pathObject?.parameters) { + return pathObject.parameters.map(noReference) as + | OpenAPIV3.ParameterObject[] + | OpenAPIV3_1.ParameterObject[]; + } + return null; +} + +/** + * Get an operation by its path and method. + */ +function getOperationByPathAndMethod( + schema: OpenAPIV3.Document | OpenAPIV3_1.Document, + path: string, + method: string, +): OpenAPIV3.OperationObject | null { + // Types are buffy for OpenAPIV3_1.OperationObject, so we use v3 + const pathObject = getPathObject(schema, path); + if (!pathObject) { + return null; + } + const normalizedMethod = method.toLowerCase(); + if (!pathObject[normalizedMethod]) { + return null; + } + return pathObject[normalizedMethod]; +} + function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher { const cache = new Map>(); @@ -158,73 +193,3 @@ function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher { parseMarkdown: fetcher.parseMarkdown, }; } - -/** - * Parse a raw string into an OpenAPI document. - * It will also convert Swagger 2.0 to OpenAPI 3.0. - * It can throw an `OpenAPIFetchError` if the document is invalid. - */ -export async function parseOpenAPIV3(url: string, text: string): Promise { - // Parse the JSON or YAML - let data: unknown; - - // Try with JSON - try { - data = JSON.parse(text); - } catch (jsonError) { - try { - // Try with YAML - data = YAML.parse(text); - } catch (yamlError) { - if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) { - throw new OpenAPIFetchError('Failed to parse YAML: ' + yamlError.message, url); - } else { - throw yamlError; - } - } - } - - // Convert Swagger 2.0 to OpenAPI 3.0 - // @ts-ignore - if (data && data.swagger) { - try { - // Convert Swagger 2.0 to OpenAPI 3.0 - // @ts-ignore - const result = (await swagger2openapi.convertObj(data, { - resolve: false, - resolveInternal: false, - laxDefaults: true, - laxurls: true, - lint: false, - prevalidate: false, - anchors: true, - patch: true, - })) as ConvertOutputOptions; - - data = result.openapi; - } catch (error) { - if ((error as Error).name === 'S2OError') { - throw new OpenAPIFetchError( - 'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message, - url, - ); - } else { - throw error; - } - } - } - - // @ts-ignore - return data; -} - -export class OpenAPIFetchError extends Error { - public name = 'OpenAPIFetchError'; - - constructor( - message: string, - public readonly url: string, - ) { - super(message); - } -} diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts index dd03ee674..66e68f604 100644 --- a/packages/react-openapi/src/generateSchemaExample.ts +++ b/packages/react-openapi/src/generateSchemaExample.ts @@ -1,4 +1,4 @@ -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; import { noReference } from './utils'; type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index bf22e8799..bc0a33fe5 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -1,4 +1,4 @@ export * from './fetchOpenAPIOperation'; export * from './OpenAPIOperation'; -export type { OpenAPIFetcher } from './types'; export * from './OpenAPIOperationContext'; +export * from './parser'; diff --git a/packages/react-openapi/src/parser/error.ts b/packages/react-openapi/src/parser/error.ts new file mode 100644 index 000000000..0630381c6 --- /dev/null +++ b/packages/react-openapi/src/parser/error.ts @@ -0,0 +1,11 @@ +export class OpenAPIParseError extends Error { + public name = 'OpenAPIParseError'; + + constructor( + message: string, + public readonly url: string, + public readonly code?: 'invalid-spec' | 'v2-spec', + ) { + super(message); + } +} diff --git a/packages/react-openapi/src/parser/index.ts b/packages/react-openapi/src/parser/index.ts new file mode 100644 index 000000000..1436cd704 --- /dev/null +++ b/packages/react-openapi/src/parser/index.ts @@ -0,0 +1,22 @@ +import { OpenAPIParseError } from './error'; +import { convertOpenAPIV2ToOpenAPIV3 } from './v2'; +import { parseOpenAPIV3 } from './v3'; + +/** + * Parse a raw string into an OpenAPI document. + * It will also convert Swagger 2.0 to OpenAPI 3.0. + * It can throw an `OpenAPIParseError` if the document is invalid. + */ +export async function parseOpenAPI(input: { value: string; url: string }) { + try { + return await parseOpenAPIV3(input); + } catch (error) { + if (error instanceof OpenAPIParseError && error.code === 'v2-spec') { + return convertOpenAPIV2ToOpenAPIV3(input); + } + throw error; + } +} + +export type * from './types'; +export * from './error'; diff --git a/packages/react-openapi/src/parser/traverse.ts b/packages/react-openapi/src/parser/traverse.ts new file mode 100644 index 000000000..9acd53f59 --- /dev/null +++ b/packages/react-openapi/src/parser/traverse.ts @@ -0,0 +1,38 @@ +import { AnyObject } from '@scalar/openapi-parser'; + +/** + * Recursively traverses the specification and applies the transform function to each node. + * The one from @scalar/openapi-parser does not support recursion. + */ +export function traverse( + specification: AnyObject, + transform: (specification: AnyObject, path?: string[]) => AnyObject, + path: string[] = [], + seen = new WeakSet(), +) { + const result: AnyObject = {}; + if (seen.has(specification)) { + return specification; + } + + seen.add(specification); + + for (const [key, value] of Object.entries(specification)) { + const currentPath = [...path, key]; + if (Array.isArray(value)) { + result[key] = value.map((item, index) => { + if (typeof item === 'object' && !Array.isArray(item) && item !== null) { + return traverse(item, transform, [...currentPath, index.toString()], seen); + } + + return item; + }); + } else if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + result[key] = traverse(value, transform, currentPath, seen); + } else { + result[key] = value; + } + } + + return transform(result, path); +} diff --git a/packages/react-openapi/src/parser/types.ts b/packages/react-openapi/src/parser/types.ts new file mode 100644 index 000000000..9edbe2596 --- /dev/null +++ b/packages/react-openapi/src/parser/types.ts @@ -0,0 +1,45 @@ +export type IconComponent = React.ComponentType<{ className?: string }>; + +/** + * Custom properties that can be defined at the entire spec level. + */ +export interface OpenAPICustomSpecProperties { + /** + * If `true`, code samples will not be displayed. + * This option can be used to hide code samples for the entire spec. + */ + 'x-codeSamples'?: boolean; + + /** + * If `true`, the "Try it" button will not be displayed. + * This option can be used to hide code samples for the entire spec. + */ + 'x-hideTryItPanel'?: boolean; +} + +/** + * Custom properties that can be defined at the operation level. + * These properties are not part of the OpenAPI spec. + */ +export interface OpenAPICustomOperationProperties { + 'x-code-samples'?: OpenAPICustomCodeSample[]; + 'x-codeSamples'?: OpenAPICustomCodeSample[] | false; + 'x-custom-examples'?: OpenAPICustomCodeSample[]; + + /** + * If `true`, the "Try it" button will not be displayed. + * https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/ + */ + 'x-hideTryItPanel'?: boolean; +} + +/** + * Custom code samples that can be defined at the operation level. + * It follows the spec defined by Redocly. + * https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/ + */ +export interface OpenAPICustomCodeSample { + lang: string; + label: string; + source: string; +} diff --git a/packages/react-openapi/src/parser/v2.ts b/packages/react-openapi/src/parser/v2.ts new file mode 100644 index 000000000..663333ba5 --- /dev/null +++ b/packages/react-openapi/src/parser/v2.ts @@ -0,0 +1,70 @@ +import YAML from 'yaml'; +import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi'; + +import { OpenAPICustomSpecProperties } from './types'; +import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types'; +import { OpenAPIParseError } from './error'; +import { parseOpenAPIV3 } from './v3'; +import { AnyApiDefinitionFormat } from '@scalar/openapi-parser'; + +/** + * Convert a Swagger 2.0 schema to an OpenAPI 3.0 schema. + */ +export async function convertOpenAPIV2ToOpenAPIV3(input: { + value: AnyApiDefinitionFormat; + url: string; +}): Promise< + | OpenAPIV3_1.Document + | OpenAPIV3.Document +> { + const { value, url } = input; + // In this case we want the raw value to be able to convert it. + const schema = typeof value === 'string' ? rawParseOpenAPI({ value, url }) : value; + try { + // @ts-expect-error Types are incompatible between the two libraries + const convertResult = (await swagger2openapi.convertObj(schema, { + resolve: false, + resolveInternal: false, + laxDefaults: true, + laxurls: true, + lint: false, + prevalidate: false, + anchors: true, + patch: true, + })) as ConvertOutputOptions; + + return parseOpenAPIV3({ url, value: convertResult.openapi }); + } catch (error) { + if (error instanceof Error && error.name === 'S2OError') { + throw new OpenAPIParseError( + 'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message, + url, + ); + } else { + throw error; + } + } +} + +/** + * Parse the config file from a raw string. + * Useful to get the raw object from a file. + */ +function rawParseOpenAPI(input: { value: string; url: string }): unknown { + const { value, url } = input; + + // Try with JSON + try { + return JSON.parse(value); + } catch (jsonError) { + try { + // Try with YAML + return YAML.parse(value); + } catch (yamlError) { + if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) { + throw new OpenAPIParseError('Failed to parse YAML: ' + yamlError.message, url); + } + throw yamlError; + } + } +} diff --git a/packages/react-openapi/src/parser/v3.ts b/packages/react-openapi/src/parser/v3.ts new file mode 100644 index 000000000..232ba0cbb --- /dev/null +++ b/packages/react-openapi/src/parser/v3.ts @@ -0,0 +1,35 @@ +import { OpenAPICustomSpecProperties } from './types'; +import { AnyApiDefinitionFormat, dereference } from '@scalar/openapi-parser'; +import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types'; +import { OpenAPIParseError } from './error'; + +/** + * Parse a raw string into an OpenAPI document. + * It will also convert Swagger 2.0 to OpenAPI 3.0. + * It can throw an `OpenAPIFetchError` if the document is invalid. + */ +export async function parseOpenAPIV3(input: { + value: AnyApiDefinitionFormat; + url: string; +}): Promise< + | OpenAPIV3.Document + | OpenAPIV3_1.Document +> { + const { value, url } = input; + const result = await dereference(value); + + // Spec is invalid, we stop here. + if (!result.schema) { + throw new OpenAPIParseError('Invalid OpenAPI document', url, 'invalid-spec'); + } + + switch (result.version) { + case '2.0': + throw new OpenAPIParseError('Only OpenAPI v3 is supported', url, 'v2-spec'); + case '3.0': + return result.schema as OpenAPIV3.Document; + case '3.1': + default: + return result.schema as OpenAPIV3_1.Document; + } +} diff --git a/packages/react-openapi/src/resolveOpenAPIPath.test.ts b/packages/react-openapi/src/resolveOpenAPIPath.test.ts deleted file mode 100644 index 8a36342f4..000000000 --- a/packages/react-openapi/src/resolveOpenAPIPath.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { it, expect } from 'bun:test'; - -import { resolveOpenAPIPath } from './resolveOpenAPIPath'; -import { OpenAPIFetcher } from './types'; - -const createFetcherForSchema = (schema: any): OpenAPIFetcher => { - return { - fetch: async (url) => { - return schema; - }, - }; -}; - -it('should resolve a simple path through objects', async () => { - const resolved = await resolveOpenAPIPath( - 'https://test.com', - ['a', 'b', 'c'], - createFetcherForSchema({ - a: { - b: { - c: 'hello', - }, - }, - }), - ); - - expect(resolved).toBe('hello'); -}); - -it('should return undefined if the last part of the path does not exists', async () => { - const resolved = await resolveOpenAPIPath( - 'https://test.com', - ['a', 'b', 'c'], - createFetcherForSchema({ - a: { - b: { - d: 'hello', - }, - }, - }), - ); - - expect(resolved).toBe(undefined); -}); - -it('should return undefined if a middle part of the path does not exists', async () => { - const resolved = await resolveOpenAPIPath( - 'https://test.com', - ['a', 'x', 'c'], - createFetcherForSchema({ - a: { - b: { - c: 'hello', - }, - }, - }), - ); - - expect(resolved).toBe(undefined); -}); diff --git a/packages/react-openapi/src/resolveOpenAPIPath.ts b/packages/react-openapi/src/resolveOpenAPIPath.ts deleted file mode 100644 index 6570fa337..000000000 --- a/packages/react-openapi/src/resolveOpenAPIPath.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { OpenAPIFetcher } from './types'; - -export const SYMBOL_MARKDOWN_PARSED = '__$markdownParsed'; -export const SYMBOL_REF_RESOLVED = '__$refResolved'; - -/** - * Resolve a path in a OpenAPI file. - * It resolves any reference needed to resolve the path, ignoring other references outside the path. - */ -export async function resolveOpenAPIPath( - url: string, - dataPath: string[], - fetcher: OpenAPIFetcher, -): Promise { - const data = await fetcher.fetch(url); - let value: unknown = data; - - if (!value) { - return undefined; - } - - const lastKey = dataPath[dataPath.length - 1]; - dataPath = dataPath.slice(0, -1); - - for (const part of dataPath) { - // @ts-ignore - if (isRef(value[part])) { - await transformAll(url, value, part, fetcher); - } - - // @ts-ignore - value = value[part]; - - // If any part along the path is undefined, return undefined. - if (typeof value !== 'object' || value === null) { - return undefined; - } - } - - await transformAll(url, value, lastKey, fetcher); - - // @ts-expect-error - return value[lastKey] as T; -} - -/** - * Recursively process a part of the OpenAPI spec to resolve all references. - */ -async function transformAll( - url: string, - data: any, - key: string | number, - fetcher: OpenAPIFetcher, -): Promise { - const value = data[key]; - - if ( - typeof value === 'string' && - key === 'description' && - fetcher.parseMarkdown && - !data[SYMBOL_MARKDOWN_PARSED] - ) { - // Parse markdown - data[SYMBOL_MARKDOWN_PARSED] = true; - data[key] = await fetcher.parseMarkdown(value); - } else if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' || - value === null - ) { - // Primitives - } else if (typeof value === 'object' && value !== null && SYMBOL_REF_RESOLVED in value) { - // Ref was already resolved - } else if (isRef(value)) { - const ref = value.$ref; - - // Delete the ref to avoid infinite loop with circular references - // @ts-ignore - delete value.$ref; - - data[key] = await resolveReference(url, ref, fetcher); - if (data[key]) { - data[key][SYMBOL_REF_RESOLVED] = extractRefName(ref); - } - } else if (Array.isArray(value)) { - // Recursively resolve all references in the array - await Promise.all(value.map((item, index) => transformAll(url, value, index, fetcher))); - } else if (typeof value === 'object' && value !== null) { - // Recursively resolve all references in the object - const keys = Object.keys(value); - for (const key of keys) { - await transformAll(url, value, key, fetcher); - } - } -} - -async function resolveReference( - origin: string, - ref: string, - fetcher: OpenAPIFetcher, -): Promise { - const parsed = parseReference(origin, ref); - return resolveOpenAPIPath(parsed.url, parsed.dataPath, fetcher); -} - -function parseReference(origin: string, ref: string): { url: string; dataPath: string[] } { - if (!ref) { - return { - url: origin, - dataPath: [], - }; - } - - if (ref.startsWith('#')) { - // Local references - const dataPath = ref.split('/').filter(Boolean).slice(1); - return { - url: origin, - dataPath, - }; - } - - // Absolute references - const url = new URL(ref, origin); - if (url.hash) { - const hash = url.hash; - url.hash = ''; - return parseReference(url.toString(), hash); - } - - return { - url: url.toString(), - dataPath: [], - }; -} - -function extractRefName(ref: string): string { - const parts = ref.split('/'); - return parts[parts.length - 1]; -} - -function isRef(ref: any): ref is { $ref: string } { - return typeof ref === 'object' && ref !== null && '$ref' in ref && ref.$ref; -} diff --git a/packages/react-openapi/src/stringifyOpenAPI.ts b/packages/react-openapi/src/stringifyOpenAPI.ts index 99186e550..33705a646 100644 --- a/packages/react-openapi/src/stringifyOpenAPI.ts +++ b/packages/react-openapi/src/stringifyOpenAPI.ts @@ -1,20 +1,6 @@ -import { SYMBOL_MARKDOWN_PARSED, SYMBOL_REF_RESOLVED } from './resolveOpenAPIPath'; - /** * Stringify an OpenAPI object. Same API as JSON.stringify. */ export function stringifyOpenAPI(body: unknown, transformer?: null, indent?: number): string { - return JSON.stringify( - body, - (_key, value) => { - if (value && !Array.isArray(value) && typeof value === 'object') { - // Extract out internal keys used in parsing - const { [SYMBOL_MARKDOWN_PARSED]: _, [SYMBOL_REF_RESOLVED]: __, ...rest } = value; - return rest; - } - - return value; - }, - indent, - ); + return JSON.stringify(body, transformer, indent); } diff --git a/packages/react-openapi/src/types.ts b/packages/react-openapi/src/types.ts index b176d2768..bd7051781 100644 --- a/packages/react-openapi/src/types.ts +++ b/packages/react-openapi/src/types.ts @@ -1,5 +1,3 @@ -export type IconComponent = React.ComponentType<{ className?: string }>; - export interface OpenAPIContextProps extends OpenAPIClientContext { CodeBlock: React.ComponentType<{ code: string; syntax: string }>; @@ -25,15 +23,3 @@ export interface OpenAPIClientContext { /** Optional id attached to the OpenAPI Operation heading and used as an anchor */ id?: string; } - -export interface OpenAPIFetcher { - /** - * Fetch an OpenAPI file by its URL. It should return a fully parsed OpenAPI v3 document. - */ - fetch: (url: string) => Promise; - - /** - * Parse markdown to the react element to render. - */ - parseMarkdown?: (input: string) => Promise; -} diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 5bc3ee092..7b2ae87d4 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -1,13 +1,17 @@ -import { OpenAPIV3 } from 'openapi-types'; +import { OpenAPIV3 } from '@scalar/openapi-types'; export function noReference(input: T | OpenAPIV3.ReferenceObject): T { - if (typeof input === 'object' && !!input && '$ref' in input) { + if (checkIsReference(input)) { throw new Error('Reference found'); } return input; } +function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject { + return typeof input === 'object' && !!input && '$ref' in input; +} + export function createStateKey(key: string, scope?: string) { return scope ? `${scope}_${key}` : key; }