diff --git a/.changeset/thin-camels-boil.md b/.changeset/thin-camels-boil.md new file mode 100644 index 000000000..8c3591059 --- /dev/null +++ b/.changeset/thin-camels-boil.md @@ -0,0 +1,6 @@ +--- +'@gitbook/react-openapi': patch +'gitbook': patch +--- + +Improve OpenAPI rendering performances by caching markdown parsing diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx index 60bd5624d..5bf534984 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx @@ -17,12 +17,10 @@ import './scalar.css'; * Render an OpenAPI block. */ export async function OpenAPI(props: BlockProps) { - const { block, style } = props; + const { style } = props; return (
- }> - - +
); } diff --git a/packages/gitbook/src/lib/markdown.ts b/packages/gitbook/src/lib/markdown.ts index 2416f38a6..f60208207 100644 --- a/packages/gitbook/src/lib/markdown.ts +++ b/packages/gitbook/src/lib/markdown.ts @@ -9,13 +9,14 @@ import { unified } from 'unified'; * Parse markdown and output HTML. */ export async function parseMarkdown(markdown: string): Promise { - const file = await unified() + const promise = unified() .use(remarkParse) .use(remarkGfm) .use(remarkRehype) .use(rehypeSanitize) .use(rehypeStringify) - .process(markdown); + .process(markdown) + .then((file) => file.toString()); - return file.toString(); + return promise; } diff --git a/packages/gitbook/src/lib/openapi.ts b/packages/gitbook/src/lib/openapi.ts index 8557e627a..2762f61dd 100644 --- a/packages/gitbook/src/lib/openapi.ts +++ b/packages/gitbook/src/lib/openapi.ts @@ -49,7 +49,7 @@ export async function fetchOpenAPIBlock( const fetcher: OpenAPIFetcher = { fetch: cache({ - name: 'openapi.fetch.v2', + name: 'openapi.fetch.v3', 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,12 +66,11 @@ const fetcher: OpenAPIFetcher = { } const text = await response.text(); - const data = await parseOpenAPI({ url, value: text }); + const data = await parseOpenAPI({ url, value: text, parseMarkdown }); return { ...parseCacheResponse(response), data, }; }, }), - parseMarkdown, }; diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 0d1e97bce..618a3cf06 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import classNames from 'classnames'; import { ApiClientModalProvider } from '@scalar/api-client-react'; -import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation'; +import { OpenAPIOperationData } from './fetchOpenAPIOperation'; import { Markdown } from './Markdown'; import { OpenAPICodeSample } from './OpenAPICodeSample'; import { OpenAPIResponseExample } from './OpenAPIResponseExample'; diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts index ee20424af..c801e0d5f 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts @@ -6,7 +6,7 @@ import { parseOpenAPI } from './parser'; const fetcher: OpenAPIFetcher = { fetch: async (url) => { const response = await fetch(url); - return parseOpenAPI({ value: await response.text(), url }); + return parseOpenAPI({ value: await response.text(), url, parseMarkdown: async (v) => v }); }, }; diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.ts b/packages/react-openapi/src/fetchOpenAPIOperation.ts index fb59debfc..7997f09cf 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.ts @@ -3,8 +3,7 @@ import { toJSON, fromJSON } from 'flatted'; 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'; +import { parseDescriptions } from './parser/markdown'; export interface OpenAPIFetcher { /** @@ -16,11 +15,6 @@ export interface OpenAPIFetcher { | OpenAPIV3_1.Document | OpenAPIV3.Document >; - - /** - * Parse markdown to the react element to render. - */ - parseMarkdown?: (input: string) => Promise; } export interface OpenAPIOperationData extends OpenAPICustomSpecProperties { @@ -48,10 +42,8 @@ export async function fetchOpenAPIOperation( path: string; method: string; }, - rawFetcher: OpenAPIFetcher, + fetcher: OpenAPIFetcher, ): Promise { - const fetcher = cacheFetcher(rawFetcher); - const schema = await fetcher.fetch(input.url); let operation = getOperationByPathAndMethod(schema, input.path, input.method); @@ -60,12 +52,6 @@ export async function fetchOpenAPIOperation( return null; } - // Parse description in markdown - const { parseMarkdown } = fetcher; - if (parseMarkdown) { - operation = await parseDescriptions(operation, parseMarkdown); - } - // Resolve common parameters const commonParameters = getPathObjectParameter(schema, input.path); if (commonParameters) { @@ -103,31 +89,6 @@ export async function fetchOpenAPIOperation( }; } -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. */ @@ -176,20 +137,3 @@ function getOperationByPathAndMethod( } return pathObject[normalizedMethod]; } - -function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher { - const cache = new Map>(); - - return { - async fetch(url) { - if (cache.has(url)) { - return cache.get(url); - } - - const promise = fetcher.fetch(url); - cache.set(url, promise); - return promise; - }, - parseMarkdown: fetcher.parseMarkdown, - }; -} diff --git a/packages/react-openapi/src/parser/index.ts b/packages/react-openapi/src/parser/index.ts index 1436cd704..9bc94472b 100644 --- a/packages/react-openapi/src/parser/index.ts +++ b/packages/react-openapi/src/parser/index.ts @@ -7,7 +7,11 @@ import { parseOpenAPIV3 } from './v3'; * 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 }) { +export async function parseOpenAPI(input: { + value: string; + url: string; + parseMarkdown: (input: string) => Promise; +}) { try { return await parseOpenAPIV3(input); } catch (error) { diff --git a/packages/react-openapi/src/parser/markdown.ts b/packages/react-openapi/src/parser/markdown.ts new file mode 100644 index 000000000..1291f1acc --- /dev/null +++ b/packages/react-openapi/src/parser/markdown.ts @@ -0,0 +1,50 @@ +import { AnyObject } from '@scalar/openapi-parser'; +import { traverse } from './traverse'; + +/** + * Parse descriptions in the spec to markdown. + */ +export async function parseDescriptions(input: { + specification: T; + parseMarkdown: (input: string) => Promise; +}): Promise { + const { specification, parseMarkdown } = input; + const promises: Record> = {}; + const results: Record = {}; + traverse(specification, (obj, path) => { + if (checkHasDescription(obj) && path) { + promises[hashPath(path)] = parseMarkdown(obj.description); + } + return obj; + }); + await Promise.all( + Object.entries(promises).map(async ([key, promise]) => { + results[key] = await promise; + }), + ); + return traverse(specification, (obj, path) => { + if ( + checkHasDescription(obj) && + typeof obj.description === 'string' && + path && + results[hashPath(path)] + ) { + obj.description = results[hashPath(path)]; + } + return obj; + }) as T; +} + +/** + * Check if the object contains a description. + */ +function checkHasDescription(obj: AnyObject): obj is { description: string } { + return 'description' in obj && typeof obj.description === 'string'; +} + +/** + * Hash a path. + */ +function hashPath(path: string[]): string { + return path.join('/'); +} diff --git a/packages/react-openapi/src/parser/v2.ts b/packages/react-openapi/src/parser/v2.ts index 663333ba5..f44064596 100644 --- a/packages/react-openapi/src/parser/v2.ts +++ b/packages/react-openapi/src/parser/v2.ts @@ -13,11 +13,12 @@ import { AnyApiDefinitionFormat } from '@scalar/openapi-parser'; export async function convertOpenAPIV2ToOpenAPIV3(input: { value: AnyApiDefinitionFormat; url: string; + parseMarkdown: (input: string) => Promise; }): Promise< | OpenAPIV3_1.Document | OpenAPIV3.Document > { - const { value, url } = input; + const { value, url, parseMarkdown } = 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 { @@ -33,7 +34,7 @@ export async function convertOpenAPIV2ToOpenAPIV3(input: { patch: true, })) as ConvertOutputOptions; - return parseOpenAPIV3({ url, value: convertResult.openapi }); + return parseOpenAPIV3({ url, value: convertResult.openapi, parseMarkdown }); } catch (error) { if (error instanceof Error && error.name === 'S2OError') { throw new OpenAPIParseError( diff --git a/packages/react-openapi/src/parser/v3.ts b/packages/react-openapi/src/parser/v3.ts index 232ba0cbb..140e9382e 100644 --- a/packages/react-openapi/src/parser/v3.ts +++ b/packages/react-openapi/src/parser/v3.ts @@ -2,6 +2,7 @@ import { OpenAPICustomSpecProperties } from './types'; import { AnyApiDefinitionFormat, dereference } from '@scalar/openapi-parser'; import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types'; import { OpenAPIParseError } from './error'; +import { parseDescriptions } from './markdown'; /** * Parse a raw string into an OpenAPI document. @@ -11,6 +12,7 @@ import { OpenAPIParseError } from './error'; export async function parseOpenAPIV3(input: { value: AnyApiDefinitionFormat; url: string; + parseMarkdown: (input: string) => Promise; }): Promise< | OpenAPIV3.Document | OpenAPIV3_1.Document @@ -23,13 +25,20 @@ export async function parseOpenAPIV3(input: { throw new OpenAPIParseError('Invalid OpenAPI document', url, 'invalid-spec'); } + if (result.version === '2.0') { + throw new OpenAPIParseError('Only OpenAPI v3 is supported', url, 'v2-spec'); + } + + const schema = await parseDescriptions({ + specification: result.schema, + parseMarkdown: input.parseMarkdown, + }); + 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; + return schema as OpenAPIV3.Document; case '3.1': default: - return result.schema as OpenAPIV3_1.Document; + return schema as OpenAPIV3_1.Document; } }