Skip to content

Commit d8ffa5d

Browse files
authored
Fix an issue where parsing an OpenAPI schema without securitySchemas would crash the page. (#263)
1 parent f77b2b6 commit d8ffa5d

File tree

7 files changed

+255
-169
lines changed

7 files changed

+255
-169
lines changed

packages/react-openapi/src/OpenAPISchema.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import classNames from 'classnames';
2-
32
import { OpenAPIV3 } from 'openapi-types';
4-
import { noReference } from './utils';
5-
import { OpenAPIClientContext } from './types';
6-
import { InteractiveSection } from './InteractiveSection';
7-
import { SYMBOL_REF_RESOLVED } from './fetchOpenAPIOperation';
83
import React, { useId } from 'react';
4+
5+
import { InteractiveSection } from './InteractiveSection';
96
import { Markdown } from './Markdown';
7+
import { SYMBOL_REF_RESOLVED } from './resolveOpenAPIPath';
8+
import { OpenAPIClientContext } from './types';
9+
import { noReference } from './utils';
1010

1111
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
1212

packages/react-openapi/src/fetchOpenAPIOperation.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { it, expect } from 'bun:test';
2-
import { OpenAPIFetcher, fetchOpenAPIOperation } from './fetchOpenAPIOperation';
2+
3+
import { fetchOpenAPIOperation } from './fetchOpenAPIOperation';
4+
import { OpenAPIFetcher } from './types';
35

46
const fetcher: OpenAPIFetcher = {
57
fetch: async (url) => {
@@ -63,3 +65,16 @@ it('should resolve circular refs', async () => {
6365
},
6466
});
6567
});
68+
69+
it('should resolve to null if the method is not supported', async () => {
70+
const resolved = await fetchOpenAPIOperation(
71+
{
72+
url: 'https://petstore3.swagger.io/api/v3/openapi.json',
73+
method: 'dontexist',
74+
path: '/pet',
75+
},
76+
fetcher,
77+
);
78+
79+
expect(resolved).toBe(null);
80+
});

packages/react-openapi/src/fetchOpenAPIOperation.ts

Lines changed: 12 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { OpenAPIV3 } from 'openapi-types';
21
import { toJSON, fromJSON } from 'flatted';
2+
import { OpenAPIV3 } from 'openapi-types';
3+
4+
import { resolveOpenAPIPath } from './resolveOpenAPIPath';
5+
import { OpenAPIFetcher } from './types';
36

47
export interface OpenAPIOperationData {
58
path: string;
@@ -17,25 +20,6 @@ export interface OpenAPIOperationData {
1720

1821
export { toJSON, fromJSON };
1922

20-
export interface OpenAPIFetcher {
21-
/**
22-
* Fetch an OpenAPI file by its URL.
23-
* It should the parsed JSON object or throw an error if the file is not found or can't be parsed.
24-
*
25-
* It should return a V3 spec.
26-
* The data will be mutated.
27-
*/
28-
fetch: (url: string) => Promise<any>;
29-
30-
/**
31-
* Parse markdown to the react element to render.
32-
*/
33-
parseMarkdown?: (input: string) => Promise<string>;
34-
}
35-
36-
export const SYMBOL_REF_RESOLVED = '__$refResolved';
37-
export const SYMBOL_MARKDOWN_PARSED = '__$markdownParsed';
38-
3923
/**
4024
* Resolve an OpenAPI operation in a file and compile it to a more usable format.
4125
*/
@@ -49,7 +33,7 @@ export async function fetchOpenAPIOperation<Markdown>(
4933
): Promise<OpenAPIOperationData | null> {
5034
const fetcher = cacheFetcher(rawFetcher);
5135

52-
let operation = await resolveOpenAPI<OpenAPIV3.OperationObject>(
36+
let operation = await resolveOpenAPIPath<OpenAPIV3.OperationObject>(
5337
input.url,
5438
['paths', input.path, input.method],
5539
fetcher,
@@ -60,7 +44,7 @@ export async function fetchOpenAPIOperation<Markdown>(
6044
}
6145

6246
// Resolve common parameters
63-
const commonParameters = await resolveOpenAPI<OpenAPIV3.ParameterObject[]>(
47+
const commonParameters = await resolveOpenAPIPath<OpenAPIV3.ParameterObject[]>(
6448
input.url,
6549
['paths', input.path, 'parameters'],
6650
fetcher,
@@ -73,14 +57,18 @@ export async function fetchOpenAPIOperation<Markdown>(
7357
}
7458

7559
// Resolve servers
76-
const servers = await resolveOpenAPI<OpenAPIV3.ServerObject[]>(input.url, ['servers'], fetcher);
60+
const servers = await resolveOpenAPIPath<OpenAPIV3.ServerObject[]>(
61+
input.url,
62+
['servers'],
63+
fetcher,
64+
);
7765

7866
// Resolve securities
7967
const securities: OpenAPIOperationData['securities'] = [];
8068
for (const security of operation.security ?? []) {
8169
const securityKey = Object.keys(security)[0];
8270

83-
const securityScheme = await resolveOpenAPI<OpenAPIV3.SecuritySchemeObject>(
71+
const securityScheme = await resolveOpenAPIPath<OpenAPIV3.SecuritySchemeObject>(
8472
input.url,
8573
['components', 'securitySchemes', securityKey],
8674
fetcher,
@@ -100,145 +88,6 @@ export async function fetchOpenAPIOperation<Markdown>(
10088
};
10189
}
10290

103-
/**
104-
* Resolve a path in a OpenAPI file.
105-
* It resolves any reference needed to resolve the path, ignoring other references outside the path.
106-
*/
107-
async function resolveOpenAPI<T>(
108-
url: string,
109-
dataPath: string[],
110-
fetcher: OpenAPIFetcher,
111-
): Promise<T | undefined> {
112-
const data = await fetcher.fetch(url);
113-
if (!data) {
114-
return undefined;
115-
}
116-
117-
let value: unknown = data;
118-
119-
const lastKey = dataPath[dataPath.length - 1];
120-
dataPath = dataPath.slice(0, -1);
121-
122-
for (const part of dataPath) {
123-
if (typeof value !== 'object' || value === null) {
124-
return undefined;
125-
}
126-
127-
// @ts-ignore
128-
if (isRef(value[part])) {
129-
await transformAll(url, value, part, fetcher);
130-
}
131-
132-
// @ts-ignore
133-
value = value[part];
134-
}
135-
136-
await transformAll(url, value, lastKey, fetcher);
137-
// @ts-ignore
138-
return value[lastKey] as T;
139-
}
140-
141-
/**
142-
* Recursively process a part of the OpenAPI spec to resolve all references.
143-
*/
144-
async function transformAll(
145-
url: string,
146-
data: any,
147-
key: string | number,
148-
fetcher: OpenAPIFetcher,
149-
): Promise<void> {
150-
const value = data[key];
151-
152-
if (
153-
typeof value === 'string' &&
154-
key === 'description' &&
155-
fetcher.parseMarkdown &&
156-
!data[SYMBOL_MARKDOWN_PARSED]
157-
) {
158-
// Parse markdown
159-
data[SYMBOL_MARKDOWN_PARSED] = true;
160-
data[key] = await fetcher.parseMarkdown(value);
161-
} else if (
162-
typeof value === 'string' ||
163-
typeof value === 'number' ||
164-
typeof value === 'boolean' ||
165-
value === null
166-
) {
167-
// Primitives
168-
} else if (typeof value === 'object' && value !== null && SYMBOL_REF_RESOLVED in value) {
169-
// Ref was already resolved
170-
} else if (isRef(value)) {
171-
const ref = value.$ref;
172-
173-
// Delete the ref to avoid infinite loop with circular references
174-
// @ts-ignore
175-
delete value.$ref;
176-
177-
data[key] = await resolveReference(url, ref, fetcher);
178-
if (data[key]) {
179-
data[key][SYMBOL_REF_RESOLVED] = extractRefName(ref);
180-
}
181-
} else if (Array.isArray(value)) {
182-
// Recursively resolve all references in the array
183-
await Promise.all(value.map((item, index) => transformAll(url, value, index, fetcher)));
184-
} else if (typeof value === 'object' && value !== null) {
185-
// Recursively resolve all references in the object
186-
const keys = Object.keys(value);
187-
for (const key of keys) {
188-
await transformAll(url, value, key, fetcher);
189-
}
190-
}
191-
}
192-
193-
async function resolveReference(
194-
origin: string,
195-
ref: string,
196-
fetcher: OpenAPIFetcher,
197-
): Promise<any> {
198-
const parsed = parseReference(origin, ref);
199-
return resolveOpenAPI(parsed.url, parsed.dataPath, fetcher);
200-
}
201-
202-
function parseReference(origin: string, ref: string): { url: string; dataPath: string[] } {
203-
if (!ref) {
204-
return {
205-
url: origin,
206-
dataPath: [],
207-
};
208-
}
209-
210-
if (ref.startsWith('#')) {
211-
// Local references
212-
const dataPath = ref.split('/').filter(Boolean).slice(1);
213-
return {
214-
url: origin,
215-
dataPath,
216-
};
217-
}
218-
219-
// Absolute references
220-
const url = new URL(ref, origin);
221-
if (url.hash) {
222-
const hash = url.hash;
223-
url.hash = '';
224-
return parseReference(url.toString(), hash);
225-
}
226-
227-
return {
228-
url: url.toString(),
229-
dataPath: [],
230-
};
231-
}
232-
233-
function extractRefName(ref: string): string {
234-
const parts = ref.split('/');
235-
return parts[parts.length - 1];
236-
}
237-
238-
function isRef(ref: any): ref is { $ref: string } {
239-
return typeof ref === 'object' && ref !== null && '$ref' in ref && ref.$ref;
240-
}
241-
24291
function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher {
24392
const cache = new Map<string, Promise<any>>();
24493

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './fetchOpenAPIOperation';
22
export * from './OpenAPIOperation';
3+
export type { OpenAPIFetcher } from './types';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { it, expect } from 'bun:test';
2+
3+
import { resolveOpenAPIPath } from './resolveOpenAPIPath';
4+
import { OpenAPIFetcher } from './types';
5+
6+
const createFetcherForSchema = (schema: any): OpenAPIFetcher => {
7+
return {
8+
fetch: async (url) => {
9+
return schema;
10+
},
11+
};
12+
};
13+
14+
it('should resolve a simple path through objects', async () => {
15+
const resolved = await resolveOpenAPIPath(
16+
'https://test.com',
17+
['a', 'b', 'c'],
18+
createFetcherForSchema({
19+
a: {
20+
b: {
21+
c: 'hello',
22+
},
23+
},
24+
}),
25+
);
26+
27+
expect(resolved).toBe('hello');
28+
});
29+
30+
it('should return undefined if the last part of the path does not exists', async () => {
31+
const resolved = await resolveOpenAPIPath(
32+
'https://test.com',
33+
['a', 'b', 'c'],
34+
createFetcherForSchema({
35+
a: {
36+
b: {
37+
d: 'hello',
38+
},
39+
},
40+
}),
41+
);
42+
43+
expect(resolved).toBe(undefined);
44+
});
45+
46+
it('should return undefined if a middle part of the path does not exists', async () => {
47+
const resolved = await resolveOpenAPIPath(
48+
'https://test.com',
49+
['a', 'x', 'c'],
50+
createFetcherForSchema({
51+
a: {
52+
b: {
53+
c: 'hello',
54+
},
55+
},
56+
}),
57+
);
58+
59+
expect(resolved).toBe(undefined);
60+
});

0 commit comments

Comments
 (0)