Skip to content

Commit

Permalink
Avoid dereferencing before caching (#2793)
Browse files Browse the repository at this point in the history
  • Loading branch information
gregberge authored Jan 30, 2025
1 parent 0d615e3 commit d9c8d57
Show file tree
Hide file tree
Showing 8 changed files with 56,008 additions and 15 deletions.
6 changes: 6 additions & 0 deletions .changeset/khaki-zebras-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@gitbook/react-openapi': patch
'gitbook': patch
---

Do not dereference before caching OpenAPI spec.
2 changes: 1 addition & 1 deletion packages/gitbook/src/lib/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function fetchOpenAPIBlock(

const fetcher: OpenAPIFetcher = {
fetch: cache({
name: 'openapi.fetch.v3',
name: 'openapi.fetch.v4',
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:
Expand Down
41 changes: 37 additions & 4 deletions packages/react-openapi/src/fetchOpenAPIOperation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { toJSON, fromJSON } from 'flatted';

import { OpenAPICustomSpecProperties } from './parser';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPICustomSpecProperties, OpenAPIParseError } from './parser';
import { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { noReference } from './utils';
import { parseDescriptions } from './parser/markdown';
import { dereference } from '@scalar/openapi-parser';

export interface OpenAPIFetcher {
/**
Expand Down Expand Up @@ -44,7 +44,8 @@ export async function fetchOpenAPIOperation(
},
fetcher: OpenAPIFetcher,
): Promise<OpenAPIOperationData | null> {
const schema = await fetcher.fetch(input.url);
const refSchema = await fetcher.fetch(input.url);
const schema = await memoDereferenceSchema(refSchema, input.url);

let operation = getOperationByPathAndMethod(schema, input.path, input.method);

Expand Down Expand Up @@ -89,6 +90,38 @@ export async function fetchOpenAPIOperation(
};
}

const dereferenceSchemaCache = new WeakMap<OpenAPI.Document, Promise<OpenAPI.Document>>();

/**
* Memoized version of `dereferenceSchema`.
*/
function memoDereferenceSchema<T extends OpenAPI.Document>(schema: T, url: string): Promise<T> {
if (dereferenceSchemaCache.has(schema)) {
return dereferenceSchemaCache.get(schema) as Promise<T>;
}

const promise = dereferenceSchema(schema, url);
dereferenceSchemaCache.set(schema, promise);
return promise;
}

/**
* Dereference an OpenAPI schema.
*/
async function dereferenceSchema<T extends OpenAPI.Document>(schema: T, url: string): Promise<T> {
const derefResult = await dereference(schema);

if (!derefResult.schema) {
throw new OpenAPIParseError(
'Failed to dereference OpenAPI document',
url,
'failed-dereference',
);
}

return derefResult.schema as T;
}

/**
* Get a path object from its path.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/react-openapi/src/parser/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class OpenAPIParseError extends Error {
constructor(
message: string,
public readonly url: string,
public readonly code?: 'invalid-spec' | 'v2-spec',
public readonly code?: 'invalid-spec' | 'v2-spec' | 'failed-dereference',
) {
super(message);
}
Expand Down
55,942 changes: 55,942 additions & 0 deletions packages/react-openapi/src/parser/fixtures/spec-example.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/react-openapi/src/parser/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, it } from 'bun:test';
import { readFile } from 'node:fs/promises';
import { parseOpenAPI } from '.';

it('should parse and not give a recursive structure', async () => {
const schema = await parseOpenAPI({
value: await readFile(new URL('./fixtures/spec-example.json', import.meta.url), 'utf-8'),
url: 'https://example.com',
parseMarkdown: async (input) => input,
});

JSON.stringify(schema);
expect(schema.openapi).toBe('3.0.0');
});
2 changes: 0 additions & 2 deletions packages/react-openapi/src/parser/traverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ export function traverse(
return specification;
}

seen.add(specification);

for (const [key, value] of Object.entries(specification)) {
const currentPath = [...path, key];
if (Array.isArray(value)) {
Expand Down
14 changes: 7 additions & 7 deletions packages/react-openapi/src/parser/v3.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { OpenAPICustomSpecProperties } from './types';
import { AnyApiDefinitionFormat, dereference } from '@scalar/openapi-parser';
import { AnyApiDefinitionFormat, validate } from '@scalar/openapi-parser';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIParseError } from './error';
import { parseDescriptions } from './markdown';
Expand All @@ -18,27 +18,27 @@ export async function parseOpenAPIV3(input: {
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
> {
const { value, url } = input;
const result = await dereference(value);
const result = await validate(value);

// Spec is invalid, we stop here.
if (!result.schema) {
if (!result.specification) {
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,
const specification = await parseDescriptions({
specification: result.specification,
parseMarkdown: input.parseMarkdown,
});

switch (result.version) {
case '3.0':
return schema as OpenAPIV3.Document<OpenAPICustomSpecProperties>;
return specification as OpenAPIV3.Document<OpenAPICustomSpecProperties>;
case '3.1':
default:
return schema as OpenAPIV3_1.Document<OpenAPICustomSpecProperties>;
return specification as OpenAPIV3_1.Document<OpenAPICustomSpecProperties>;
}
}

0 comments on commit d9c8d57

Please sign in to comment.