From d8ffa5d69ceef70a9d962fbcc8586d0ec1539d3d Mon Sep 17 00:00:00 2001 From: Steven H Date: Fri, 15 Mar 2024 10:27:23 +0000 Subject: [PATCH] Fix an issue where parsing an OpenAPI schema without securitySchemas would crash the page. (#263) --- packages/react-openapi/src/OpenAPISchema.tsx | 10 +- .../src/fetchOpenAPIOperation.test.ts | 17 +- .../src/fetchOpenAPIOperation.ts | 175 ++---------------- packages/react-openapi/src/index.ts | 1 + .../src/resolveOpenAPIPath.test.ts | 60 ++++++ .../react-openapi/src/resolveOpenAPIPath.ts | 145 +++++++++++++++ packages/react-openapi/src/types.ts | 16 ++ 7 files changed, 255 insertions(+), 169 deletions(-) create mode 100644 packages/react-openapi/src/resolveOpenAPIPath.test.ts create mode 100644 packages/react-openapi/src/resolveOpenAPIPath.ts diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index b8edd7dec..8056c15e7 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -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; diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts index abacee6a1..e96370d81 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts @@ -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); +}); diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.ts b/packages/react-openapi/src/fetchOpenAPIOperation.ts index 5ff30ca2e..7e3fe4795 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.ts @@ -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; - - /** - * Parse markdown to the react element to render. - */ - parseMarkdown?: (input: string) => Promise; -} - -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( ): Promise { const fetcher = cacheFetcher(rawFetcher); - let operation = await resolveOpenAPI( + let operation = await resolveOpenAPIPath( input.url, ['paths', input.path, input.method], fetcher, @@ -60,7 +44,7 @@ export async function fetchOpenAPIOperation( } // Resolve common parameters - const commonParameters = await resolveOpenAPI( + const commonParameters = await resolveOpenAPIPath( input.url, ['paths', input.path, 'parameters'], fetcher, @@ -73,14 +57,18 @@ export async function fetchOpenAPIOperation( } // Resolve servers - const servers = await resolveOpenAPI(input.url, ['servers'], fetcher); + const servers = await resolveOpenAPIPath( + 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( + const securityScheme = await resolveOpenAPIPath( input.url, ['components', 'securitySchemes', securityKey], fetcher, @@ -100,145 +88,6 @@ export async function fetchOpenAPIOperation( }; } -/** - * 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( - url: string, - dataPath: string[], - fetcher: OpenAPIFetcher, -): Promise { - 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 { - 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 { - 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>(); diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index 5ffdc9aa9..7d92a021d 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -1,2 +1,3 @@ export * from './fetchOpenAPIOperation'; export * from './OpenAPIOperation'; +export type { OpenAPIFetcher } from './types'; diff --git a/packages/react-openapi/src/resolveOpenAPIPath.test.ts b/packages/react-openapi/src/resolveOpenAPIPath.test.ts new file mode 100644 index 000000000..8a36342f4 --- /dev/null +++ b/packages/react-openapi/src/resolveOpenAPIPath.test.ts @@ -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); +}); diff --git a/packages/react-openapi/src/resolveOpenAPIPath.ts b/packages/react-openapi/src/resolveOpenAPIPath.ts new file mode 100644 index 000000000..cc247476e --- /dev/null +++ b/packages/react-openapi/src/resolveOpenAPIPath.ts @@ -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( + url: string, + dataPath: string[], + fetcher: OpenAPIFetcher, +): Promise { + 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 { + 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 { + 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; +} diff --git a/packages/react-openapi/src/types.ts b/packages/react-openapi/src/types.ts index 956d51bfc..0b7b5b78a 100644 --- a/packages/react-openapi/src/types.ts +++ b/packages/react-openapi/src/types.ts @@ -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; + + /** + * Parse markdown to the react element to render. + */ + parseMarkdown?: (input: string) => Promise; +}