Skip to content

Commit 46edde9

Browse files
authored
Improve OpenAPI abstraction to be more flexible on usage (#2832)
1 parent dda0cc6 commit 46edde9

25 files changed

+364
-346
lines changed

.changeset/honest-ants-appear.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@gitbook/openapi-parser': patch
3+
'@gitbook/react-openapi': patch
4+
'gitbook': patch
5+
---
6+
7+
Improve the OpenAPI package API

packages/gitbook/src/components/DocumentView/OpenAPI/OpenAPI.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockOpenAPI>) {
3333
return (
3434
<div className={tcls('hidden')}>
3535
<p>
36-
Error with {error.url}: {error.message}
36+
Error with {error.rootURL}: {error.message}
3737
</p>
3838
</div>
3939
);

packages/gitbook/src/lib/openapi.ts

Lines changed: 44 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { ContentRef, DocumentBlockOpenAPI } from '@gitbook/api';
22
import { parseOpenAPI, OpenAPIParseError, traverse } from '@gitbook/openapi-parser';
3-
import {
4-
OpenAPIOperationData,
5-
fetchOpenAPIOperation,
6-
OpenAPIFetcher,
7-
} from '@gitbook/react-openapi';
3+
import { type OpenAPIOperationData, resolveOpenAPIOperation } from '@gitbook/react-openapi';
84

95
import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
106

@@ -27,14 +23,11 @@ export async function fetchOpenAPIBlock(
2723
}
2824

2925
try {
30-
const data = await fetchOpenAPIOperation(
31-
{
32-
url: resolved.href,
33-
path: block.data.path,
34-
method: block.data.method,
35-
},
36-
fetcher,
37-
);
26+
const filesystem = await fetchFilesystem(resolved.href);
27+
const data = await resolveOpenAPIOperation(filesystem, {
28+
path: block.data.path,
29+
method: block.data.method,
30+
});
3831

3932
return { data, specUrl: resolved.href };
4033
} catch (error) {
@@ -46,50 +39,44 @@ export async function fetchOpenAPIBlock(
4639
}
4740
}
4841

49-
const fetcher: OpenAPIFetcher = {
50-
fetch: cache({
51-
name: 'openapi.fetch.v5',
52-
get: async (url: string, options: CacheFunctionOptions) => {
53-
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
54-
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
55-
// https://github.com/cloudflare/workerd/issues/1957
56-
const response = await fetch(new URL(url), {
57-
...noCacheFetchOptions,
58-
signal: options.signal,
59-
});
42+
const fetchFilesystem = cache({
43+
name: 'openapi.fetch.v5',
44+
get: async (url: string, options: CacheFunctionOptions) => {
45+
// Wrap the raw string to prevent invalid URLs from being passed to fetch.
46+
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
47+
// https://github.com/cloudflare/workerd/issues/1957
48+
const response = await fetch(new URL(url), {
49+
...noCacheFetchOptions,
50+
signal: options.signal,
51+
});
6052

61-
if (!response.ok) {
62-
throw new Error(
63-
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
64-
);
65-
}
53+
if (!response.ok) {
54+
throw new Error(
55+
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
56+
);
57+
}
6658

67-
const text = await response.text();
68-
const filesystem = await parseOpenAPI({ url, value: text });
69-
const cache: Map<string, Promise<string>> = new Map();
70-
const transformedFs = await traverse(filesystem, async (node) => {
71-
if (
72-
'description' in node &&
73-
typeof node.description === 'string' &&
74-
node.description
75-
) {
76-
if (cache.has(node.description)) {
77-
node['x-description-html'] = await cache.get(node.description);
78-
} else {
79-
const promise = parseMarkdown(node.description);
80-
cache.set(node.description, promise);
81-
node['x-description-html'] = await promise;
82-
}
59+
const text = await response.text();
60+
const filesystem = await parseOpenAPI({ value: text, rootURL: url });
61+
const cache: Map<string, Promise<string>> = new Map();
62+
const transformedFs = await traverse(filesystem, async (node) => {
63+
if ('description' in node && typeof node.description === 'string' && node.description) {
64+
if (cache.has(node.description)) {
65+
node['x-description-html'] = await cache.get(node.description);
66+
} else {
67+
const promise = parseMarkdown(node.description);
68+
cache.set(node.description, promise);
69+
node['x-description-html'] = await promise;
8370
}
84-
return node;
85-
});
86-
return {
87-
// Cache for 4 hours
88-
ttl: 24 * 60 * 60,
89-
// Revalidate every 2 hours
90-
revalidateBefore: 22 * 60 * 60,
91-
data: transformedFs,
92-
};
93-
},
94-
}),
95-
};
71+
}
72+
return node;
73+
});
74+
return {
75+
// Cache for 4 hours
76+
ttl: 24 * 60 * 60,
77+
// Revalidate every 2 hours
78+
revalidateBefore: 22 * 60 * 60,
79+
data: transformedFs,
80+
};
81+
},
82+
});

packages/openapi-parser/src/error.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
1+
type OpenAPIParseErrorCode =
2+
| 'invalid'
3+
| 'parse-v2-in-v3'
4+
| 'v2-conversion'
5+
| 'dereference'
6+
| 'yaml-parse';
7+
18
/**
29
* Error thrown when the OpenAPI document is invalid.
310
*/
411
export class OpenAPIParseError extends Error {
512
public override name = 'OpenAPIParseError';
13+
public code: OpenAPIParseErrorCode;
14+
public rootURL: string | null;
615

716
constructor(
817
message: string,
9-
public readonly url: string,
10-
public readonly code?: 'invalid-spec' | 'v2-spec' | 'failed-dereference',
18+
options: {
19+
code: OpenAPIParseErrorCode;
20+
rootURL?: string | null;
21+
cause?: Error;
22+
},
1123
) {
12-
super(message);
24+
super(message, { cause: options.cause });
25+
this.code = options.code;
26+
this.rootURL = options.rootURL ?? null;
1327
}
1428
}

packages/openapi-parser/src/filesystem.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('#createFileSystem', () => {
3434
const url = new URL('/root/spec.yaml', server.url).href;
3535
const filesystem = await createFileSystem({
3636
value: url,
37-
baseUrl: url,
37+
rootURL: url,
3838
});
3939
expect(filesystem).toHaveLength(4);
4040
expect(filesystem[0]!.isEntrypoint).toBe(true);
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { type AnyApiDefinitionFormat, load } from '@scalar/openapi-parser';
2-
import { fetchUrls } from './scalar-plugins/fetchURLs';
2+
import { fetchURLs } from './scalar-plugins/fetchURLs';
33
import type { Filesystem } from './types';
44

55
/**
66
* Create a filesystem from an OpenAPI document.
77
* Fetches all the URLs specified in references and builds a filesystem.
88
*/
99
export async function createFileSystem(input: {
10+
/**
11+
* The OpenAPI document to create the filesystem from.
12+
*/
1013
value: AnyApiDefinitionFormat;
11-
baseUrl: string;
14+
/**
15+
* The root URL of the specified OpenAPI document.
16+
* Used to resolve relative URLs.
17+
*/
18+
rootURL: string | null;
1219
}): Promise<Filesystem> {
1320
const { filesystem } = await load(input.value, {
14-
plugins: [fetchUrls({ baseUrl: input.baseUrl })],
21+
plugins: [fetchURLs({ rootURL: input.rootURL })],
1522
});
1623
return filesystem;
1724
}

packages/openapi-parser/src/parse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe('#parseOpenAPI', () => {
77
it('parses an OpenAPI document', async () => {
88
const schema = await parseOpenAPI({
99
value: spec,
10-
url: 'https://example.com',
10+
rootURL: null,
1111
});
1212
// Ensure the structure returned is not recursive (not dereferenced).
1313
JSON.stringify(schema);

packages/openapi-parser/src/parse.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
12
import { OpenAPIParseError } from './error';
23
import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
34
import { parseOpenAPIV3 } from './v3';
@@ -7,11 +8,20 @@ import { parseOpenAPIV3 } from './v3';
78
* It will also convert Swagger 2.0 to OpenAPI 3.0.
89
* It can throw an `OpenAPIParseError` if the document is invalid.
910
*/
10-
export async function parseOpenAPI(input: { value: string; url: string }) {
11+
export async function parseOpenAPI(input: {
12+
/**
13+
* The API definition to parse.
14+
*/
15+
value: AnyApiDefinitionFormat;
16+
/**
17+
* The root URL of the specified OpenAPI document.
18+
*/
19+
rootURL: string | null;
20+
}) {
1121
try {
1222
return await parseOpenAPIV3(input);
1323
} catch (error) {
14-
if (error instanceof OpenAPIParseError && error.code === 'v2-spec') {
24+
if (error instanceof OpenAPIParseError && error.code === 'parse-v2-in-v3') {
1525
return convertOpenAPIV2ToOpenAPIV3(input);
1626
}
1727
throw error;

packages/openapi-parser/src/scalar-plugins/fetchURLs.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ export const fetchUrlsDefaultConfiguration = {
44
limit: 40,
55
};
66

7-
export const fetchUrls: (customConfiguration: {
7+
export const fetchURLs: (customConfiguration: {
88
/**
9-
* Base URL to use for relative paths.
9+
* Root URL to resolve relative URLs.
1010
*/
11-
baseUrl: string;
11+
rootURL: string | null;
1212

1313
/**
1414
* Limit the number of requests. Set to `false` to disable the limit.
@@ -53,7 +53,7 @@ export const fetchUrls: (customConfiguration: {
5353

5454
try {
5555
numberOfRequests++;
56-
const url = getReferenceUrl(value, configuration.baseUrl);
56+
const url = getReferenceUrl({ value, rootURL: configuration.rootURL });
5757
const response = await fetch(url);
5858
return await response.text();
5959
} catch (error: any) {
@@ -66,6 +66,7 @@ export const fetchUrls: (customConfiguration: {
6666

6767
/**
6868
* Check if a path is relative.
69+
* Meaning it does not start with http://, https://, www., data:, or #/.
6970
*/
7071
function isRelativePath(path: string): boolean {
7172
// Exclude external URLs
@@ -76,9 +77,13 @@ function isRelativePath(path: string): boolean {
7677
/**
7778
* Get the reference URL.
7879
*/
79-
function getReferenceUrl(value: string, baseUrl: string) {
80+
function getReferenceUrl(input: { value: string; rootURL: string | null }) {
81+
const { value, rootURL } = input;
8082
if (isRelativePath(value)) {
81-
return new URL(value, baseUrl).href;
83+
if (!rootURL) {
84+
throw new Error(`[fetchUrls] Cannot resolve relative path without rootURL (${value})`);
85+
}
86+
return new URL(value, rootURL).href;
8287
}
8388

8489
return value;

packages/openapi-parser/src/traverse.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('#traverse', () => {
3535
it('traverses a complete filesystem', async () => {
3636
const filesystem = await createFileSystem({
3737
value: JSON.parse(recursiveSpec),
38-
baseUrl: 'https://example.com',
38+
rootURL: 'https://example.com',
3939
});
4040

4141
const transformedFilesystem = await traverse(filesystem, async (node) => {

0 commit comments

Comments
 (0)