mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
Fix an issue where parsing an OpenAPI schema without securitySchemas would crash the page. (#263)
This commit is contained in:
@@ -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,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,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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user