Fix an issue where parsing an OpenAPI schema without securitySchemas would crash the page. (#263)

This commit is contained in:
Steven H
2024-03-15 10:27:23 +00:00
committed by GitHub
parent f77b2b6cd7
commit d8ffa5d69c
7 changed files with 255 additions and 169 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
import classNames from 'classnames';
import { OpenAPIV3 } from 'openapi-types';
import { noReference } from './utils';
import { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection';
import { SYMBOL_REF_RESOLVED } from './fetchOpenAPIOperation';
import React, { useId } from 'react';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
import { SYMBOL_REF_RESOLVED } from './resolveOpenAPIPath';
import { OpenAPIClientContext } from './types';
import { noReference } from './utils';
type CircularRefsIds = Map<OpenAPIV3.SchemaObject, string>;
@@ -1,5 +1,7 @@
import { it, expect } from 'bun:test';
import { OpenAPIFetcher, fetchOpenAPIOperation } from './fetchOpenAPIOperation';
import { fetchOpenAPIOperation } from './fetchOpenAPIOperation';
import { OpenAPIFetcher } from './types';
const fetcher: OpenAPIFetcher = {
fetch: async (url) => {
@@ -63,3 +65,16 @@ it('should resolve circular refs', async () => {
},
});
});
it('should resolve to null if the method is not supported', async () => {
const resolved = await fetchOpenAPIOperation(
{
url: 'https://petstore3.swagger.io/api/v3/openapi.json',
method: 'dontexist',
path: '/pet',
},
fetcher,
);
expect(resolved).toBe(null);
});
@@ -1,5 +1,8 @@
import { OpenAPIV3 } from 'openapi-types';
import { toJSON, fromJSON } from 'flatted';
import { OpenAPIV3 } from 'openapi-types';
import { resolveOpenAPIPath } from './resolveOpenAPIPath';
import { OpenAPIFetcher } from './types';
export interface OpenAPIOperationData {
path: string;
@@ -17,25 +20,6 @@ export interface OpenAPIOperationData {
export { toJSON, fromJSON };
export interface OpenAPIFetcher {
/**
* Fetch an OpenAPI file by its URL.
* It should the parsed JSON object or throw an error if the file is not found or can't be parsed.
*
* It should return a V3 spec.
* The data will be mutated.
*/
fetch: (url: string) => Promise<any>;
/**
* Parse markdown to the react element to render.
*/
parseMarkdown?: (input: string) => Promise<string>;
}
export const SYMBOL_REF_RESOLVED = '__$refResolved';
export const SYMBOL_MARKDOWN_PARSED = '__$markdownParsed';
/**
* Resolve an OpenAPI operation in a file and compile it to a more usable format.
*/
@@ -49,7 +33,7 @@ export async function fetchOpenAPIOperation<Markdown>(
): Promise<OpenAPIOperationData | null> {
const fetcher = cacheFetcher(rawFetcher);
let operation = await resolveOpenAPI<OpenAPIV3.OperationObject>(
let operation = await resolveOpenAPIPath<OpenAPIV3.OperationObject>(
input.url,
['paths', input.path, input.method],
fetcher,
@@ -60,7 +44,7 @@ export async function fetchOpenAPIOperation<Markdown>(
}
// Resolve common parameters
const commonParameters = await resolveOpenAPI<OpenAPIV3.ParameterObject[]>(
const commonParameters = await resolveOpenAPIPath<OpenAPIV3.ParameterObject[]>(
input.url,
['paths', input.path, 'parameters'],
fetcher,
@@ -73,14 +57,18 @@ export async function fetchOpenAPIOperation<Markdown>(
}
// Resolve servers
const servers = await resolveOpenAPI<OpenAPIV3.ServerObject[]>(input.url, ['servers'], fetcher);
const servers = await resolveOpenAPIPath<OpenAPIV3.ServerObject[]>(
input.url,
['servers'],
fetcher,
);
// Resolve securities
const securities: OpenAPIOperationData['securities'] = [];
for (const security of operation.security ?? []) {
const securityKey = Object.keys(security)[0];
const securityScheme = await resolveOpenAPI<OpenAPIV3.SecuritySchemeObject>(
const securityScheme = await resolveOpenAPIPath<OpenAPIV3.SecuritySchemeObject>(
input.url,
['components', 'securitySchemes', securityKey],
fetcher,
@@ -100,145 +88,6 @@ export async function fetchOpenAPIOperation<Markdown>(
};
}
/**
* Resolve a path in a OpenAPI file.
* It resolves any reference needed to resolve the path, ignoring other references outside the path.
*/
async function resolveOpenAPI<T>(
url: string,
dataPath: string[],
fetcher: OpenAPIFetcher,
): Promise<T | undefined> {
const data = await fetcher.fetch(url);
if (!data) {
return undefined;
}
let value: unknown = data;
const lastKey = dataPath[dataPath.length - 1];
dataPath = dataPath.slice(0, -1);
for (const part of dataPath) {
if (typeof value !== 'object' || value === null) {
return undefined;
}
// @ts-ignore
if (isRef(value[part])) {
await transformAll(url, value, part, fetcher);
}
// @ts-ignore
value = value[part];
}
await transformAll(url, value, lastKey, fetcher);
// @ts-ignore
return value[lastKey] as T;
}
/**
* Recursively process a part of the OpenAPI spec to resolve all references.
*/
async function transformAll(
url: string,
data: any,
key: string | number,
fetcher: OpenAPIFetcher,
): Promise<void> {
const value = data[key];
if (
typeof value === 'string' &&
key === 'description' &&
fetcher.parseMarkdown &&
!data[SYMBOL_MARKDOWN_PARSED]
) {
// Parse markdown
data[SYMBOL_MARKDOWN_PARSED] = true;
data[key] = await fetcher.parseMarkdown(value);
} else if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null
) {
// Primitives
} else if (typeof value === 'object' && value !== null && SYMBOL_REF_RESOLVED in value) {
// Ref was already resolved
} else if (isRef(value)) {
const ref = value.$ref;
// Delete the ref to avoid infinite loop with circular references
// @ts-ignore
delete value.$ref;
data[key] = await resolveReference(url, ref, fetcher);
if (data[key]) {
data[key][SYMBOL_REF_RESOLVED] = extractRefName(ref);
}
} else if (Array.isArray(value)) {
// Recursively resolve all references in the array
await Promise.all(value.map((item, index) => transformAll(url, value, index, fetcher)));
} else if (typeof value === 'object' && value !== null) {
// Recursively resolve all references in the object
const keys = Object.keys(value);
for (const key of keys) {
await transformAll(url, value, key, fetcher);
}
}
}
async function resolveReference(
origin: string,
ref: string,
fetcher: OpenAPIFetcher,
): Promise<any> {
const parsed = parseReference(origin, ref);
return resolveOpenAPI(parsed.url, parsed.dataPath, fetcher);
}
function parseReference(origin: string, ref: string): { url: string; dataPath: string[] } {
if (!ref) {
return {
url: origin,
dataPath: [],
};
}
if (ref.startsWith('#')) {
// Local references
const dataPath = ref.split('/').filter(Boolean).slice(1);
return {
url: origin,
dataPath,
};
}
// Absolute references
const url = new URL(ref, origin);
if (url.hash) {
const hash = url.hash;
url.hash = '';
return parseReference(url.toString(), hash);
}
return {
url: url.toString(),
dataPath: [],
};
}
function extractRefName(ref: string): string {
const parts = ref.split('/');
return parts[parts.length - 1];
}
function isRef(ref: any): ref is { $ref: string } {
return typeof ref === 'object' && ref !== null && '$ref' in ref && ref.$ref;
}
function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher {
const cache = new Map<string, Promise<any>>();
+1
View File
@@ -1,2 +1,3 @@
export * from './fetchOpenAPIOperation';
export * from './OpenAPIOperation';
export type { OpenAPIFetcher } from './types';
@@ -0,0 +1,60 @@
import { it, expect } from 'bun:test';
import { resolveOpenAPIPath } from './resolveOpenAPIPath';
import { OpenAPIFetcher } from './types';
const createFetcherForSchema = (schema: any): OpenAPIFetcher => {
return {
fetch: async (url) => {
return schema;
},
};
};
it('should resolve a simple path through objects', async () => {
const resolved = await resolveOpenAPIPath(
'https://test.com',
['a', 'b', 'c'],
createFetcherForSchema({
a: {
b: {
c: 'hello',
},
},
}),
);
expect(resolved).toBe('hello');
});
it('should return undefined if the last part of the path does not exists', async () => {
const resolved = await resolveOpenAPIPath(
'https://test.com',
['a', 'b', 'c'],
createFetcherForSchema({
a: {
b: {
d: 'hello',
},
},
}),
);
expect(resolved).toBe(undefined);
});
it('should return undefined if a middle part of the path does not exists', async () => {
const resolved = await resolveOpenAPIPath(
'https://test.com',
['a', 'x', 'c'],
createFetcherForSchema({
a: {
b: {
c: 'hello',
},
},
}),
);
expect(resolved).toBe(undefined);
});
@@ -0,0 +1,145 @@
import { OpenAPIFetcher } from './types';
const SYMBOL_MARKDOWN_PARSED = '__$markdownParsed';
export const SYMBOL_REF_RESOLVED = '__$refResolved';
/**
* Resolve a path in a OpenAPI file.
* It resolves any reference needed to resolve the path, ignoring other references outside the path.
*/
export async function resolveOpenAPIPath<T>(
url: string,
dataPath: string[],
fetcher: OpenAPIFetcher,
): Promise<T | undefined> {
const data = await fetcher.fetch(url);
let value: unknown = data;
if (!value) {
return undefined;
}
const lastKey = dataPath[dataPath.length - 1];
dataPath = dataPath.slice(0, -1);
for (const part of dataPath) {
// @ts-ignore
if (isRef(value[part])) {
await transformAll(url, value, part, fetcher);
}
// @ts-ignore
value = value[part];
// If any part along the path is undefined, return undefined.
if (typeof value !== 'object' || value === null) {
return undefined;
}
}
await transformAll(url, value, lastKey, fetcher);
// @ts-expect-error
return value[lastKey] as T;
}
/**
* Recursively process a part of the OpenAPI spec to resolve all references.
*/
async function transformAll(
url: string,
data: any,
key: string | number,
fetcher: OpenAPIFetcher,
): Promise<void> {
const value = data[key];
if (
typeof value === 'string' &&
key === 'description' &&
fetcher.parseMarkdown &&
!data[SYMBOL_MARKDOWN_PARSED]
) {
// Parse markdown
data[SYMBOL_MARKDOWN_PARSED] = true;
data[key] = await fetcher.parseMarkdown(value);
} else if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null
) {
// Primitives
} else if (typeof value === 'object' && value !== null && SYMBOL_REF_RESOLVED in value) {
// Ref was already resolved
} else if (isRef(value)) {
const ref = value.$ref;
// Delete the ref to avoid infinite loop with circular references
// @ts-ignore
delete value.$ref;
data[key] = await resolveReference(url, ref, fetcher);
if (data[key]) {
data[key][SYMBOL_REF_RESOLVED] = extractRefName(ref);
}
} else if (Array.isArray(value)) {
// Recursively resolve all references in the array
await Promise.all(value.map((item, index) => transformAll(url, value, index, fetcher)));
} else if (typeof value === 'object' && value !== null) {
// Recursively resolve all references in the object
const keys = Object.keys(value);
for (const key of keys) {
await transformAll(url, value, key, fetcher);
}
}
}
async function resolveReference(
origin: string,
ref: string,
fetcher: OpenAPIFetcher,
): Promise<any> {
const parsed = parseReference(origin, ref);
return resolveOpenAPIPath(parsed.url, parsed.dataPath, fetcher);
}
function parseReference(origin: string, ref: string): { url: string; dataPath: string[] } {
if (!ref) {
return {
url: origin,
dataPath: [],
};
}
if (ref.startsWith('#')) {
// Local references
const dataPath = ref.split('/').filter(Boolean).slice(1);
return {
url: origin,
dataPath,
};
}
// Absolute references
const url = new URL(ref, origin);
if (url.hash) {
const hash = url.hash;
url.hash = '';
return parseReference(url.toString(), hash);
}
return {
url: url.toString(),
dataPath: [],
};
}
function extractRefName(ref: string): string {
const parts = ref.split('/');
return parts[parts.length - 1];
}
function isRef(ref: any): ref is { $ref: string } {
return typeof ref === 'object' && ref !== null && '$ref' in ref && ref.$ref;
}
+16
View File
@@ -16,3 +16,19 @@ export interface OpenAPIClientContext {
*/
defaultInteractiveOpened?: boolean;
}
export interface OpenAPIFetcher {
/**
* Fetch an OpenAPI file by its URL.
* It should the parsed JSON object or throw an error if the file is not found or can't be parsed.
*
* It should return a V3 spec.
* The data will be mutated.
*/
fetch: (url: string) => Promise<any>;
/**
* Parse markdown to the react element to render.
*/
parseMarkdown?: (input: string) => Promise<string>;
}