Improve OpenAPI performances (#2784)

This commit is contained in:
Greg Bergé
2025-01-30 11:13:01 +01:00
committed by GitHub
parent 12c7862250
commit b41d425993
11 changed files with 89 additions and 77 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve OpenAPI rendering performances by caching markdown parsing
@@ -17,12 +17,10 @@ import './scalar.css';
* Render an OpenAPI block.
*/
export async function OpenAPI(props: BlockProps<DocumentBlockOpenAPI>) {
const { block, style } = props;
const { style } = props;
return (
<div className={tcls('w-full', 'flex', 'flex-row', style, 'max-w-full')}>
<React.Suspense fallback={<OpenAPIFallback />}>
<OpenAPIBody {...props} />
</React.Suspense>
<OpenAPIBody {...props} />
</div>
);
}
+4 -3
View File
@@ -9,13 +9,14 @@ import { unified } from 'unified';
* Parse markdown and output HTML.
*/
export async function parseMarkdown(markdown: string): Promise<string> {
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;
}
+2 -3
View File
@@ -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,
};
@@ -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';
@@ -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 });
},
};
@@ -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<OpenAPICustomSpecProperties>
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
>;
/**
* Parse markdown to the react element to render.
*/
parseMarkdown?: (input: string) => Promise<string>;
}
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
@@ -48,10 +42,8 @@ export async function fetchOpenAPIOperation(
path: string;
method: string;
},
rawFetcher: OpenAPIFetcher,
fetcher: OpenAPIFetcher,
): Promise<OpenAPIOperationData | null> {
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<T extends AnyObject>(
spec: T,
parseMarkdown: (input: string) => Promise<string>,
): Promise<T> {
const promises: Record<string, Promise<string>> = {};
const results: Record<string, string> = {};
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<string, Promise<any>>();
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,
};
}
+5 -1
View File
@@ -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<string>;
}) {
try {
return await parseOpenAPIV3(input);
} catch (error) {
@@ -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<T extends AnyObject>(input: {
specification: T;
parseMarkdown: (input: string) => Promise<string>;
}): Promise<T> {
const { specification, parseMarkdown } = input;
const promises: Record<string, Promise<string>> = {};
const results: Record<string, string> = {};
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('/');
}
+3 -2
View File
@@ -13,11 +13,12 @@ import { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
export async function convertOpenAPIV2ToOpenAPIV3(input: {
value: AnyApiDefinitionFormat;
url: string;
parseMarkdown: (input: string) => Promise<string>;
}): Promise<
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
> {
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(
+13 -4
View File
@@ -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<string>;
}): Promise<
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
@@ -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<OpenAPICustomSpecProperties>;
return schema as OpenAPIV3.Document<OpenAPICustomSpecProperties>;
case '3.1':
default:
return result.schema as OpenAPIV3_1.Document<OpenAPICustomSpecProperties>;
return schema as OpenAPIV3_1.Document<OpenAPICustomSpecProperties>;
}
}