Improve OpenAPI abstraction to be more flexible on usage (#2832)

This commit is contained in:
Greg Bergé
2025-02-14 10:41:09 +01:00
committed by GitHub
parent dda0cc635e
commit 46edde9da6
25 changed files with 364 additions and 346 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@gitbook/openapi-parser': patch
'@gitbook/react-openapi': patch
'gitbook': patch
---
Improve the OpenAPI package API
@@ -33,7 +33,7 @@ async function OpenAPIBody(props: BlockProps<DocumentBlockOpenAPI>) {
return ( return (
<div className={tcls('hidden')}> <div className={tcls('hidden')}>
<p> <p>
Error with {error.url}: {error.message} Error with {error.rootURL}: {error.message}
</p> </p>
</div> </div>
); );
+44 -57
View File
@@ -1,10 +1,6 @@
import { ContentRef, DocumentBlockOpenAPI } from '@gitbook/api'; import { ContentRef, DocumentBlockOpenAPI } from '@gitbook/api';
import { parseOpenAPI, OpenAPIParseError, traverse } from '@gitbook/openapi-parser'; import { parseOpenAPI, OpenAPIParseError, traverse } from '@gitbook/openapi-parser';
import { import { type OpenAPIOperationData, resolveOpenAPIOperation } from '@gitbook/react-openapi';
OpenAPIOperationData,
fetchOpenAPIOperation,
OpenAPIFetcher,
} from '@gitbook/react-openapi';
import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache'; import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
@@ -27,14 +23,11 @@ export async function fetchOpenAPIBlock(
} }
try { try {
const data = await fetchOpenAPIOperation( const filesystem = await fetchFilesystem(resolved.href);
{ const data = await resolveOpenAPIOperation(filesystem, {
url: resolved.href, path: block.data.path,
path: block.data.path, method: block.data.method,
method: block.data.method, });
},
fetcher,
);
return { data, specUrl: resolved.href }; return { data, specUrl: resolved.href };
} catch (error) { } catch (error) {
@@ -46,50 +39,44 @@ export async function fetchOpenAPIBlock(
} }
} }
const fetcher: OpenAPIFetcher = { const fetchFilesystem = cache({
fetch: cache({ name: 'openapi.fetch.v5',
name: 'openapi.fetch.v5', get: async (url: string, options: CacheFunctionOptions) => {
get: async (url: string, options: CacheFunctionOptions) => { // Wrap the raw string to prevent invalid URLs from being passed to fetch.
// Wrap the raw string to prevent invalid URLs from being passed to fetch. // This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch:
// This can happen if the URL has whitespace, which is currently handled differently by Cloudflare's implementation of fetch: // https://github.com/cloudflare/workerd/issues/1957
// https://github.com/cloudflare/workerd/issues/1957 const response = await fetch(new URL(url), {
const response = await fetch(new URL(url), { ...noCacheFetchOptions,
...noCacheFetchOptions, signal: options.signal,
signal: options.signal, });
});
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`, `Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
); );
} }
const text = await response.text(); const text = await response.text();
const filesystem = await parseOpenAPI({ url, value: text }); const filesystem = await parseOpenAPI({ value: text, rootURL: url });
const cache: Map<string, Promise<string>> = new Map(); const cache: Map<string, Promise<string>> = new Map();
const transformedFs = await traverse(filesystem, async (node) => { const transformedFs = await traverse(filesystem, async (node) => {
if ( if ('description' in node && typeof node.description === 'string' && node.description) {
'description' in node && if (cache.has(node.description)) {
typeof node.description === 'string' && node['x-description-html'] = await cache.get(node.description);
node.description } else {
) { const promise = parseMarkdown(node.description);
if (cache.has(node.description)) { cache.set(node.description, promise);
node['x-description-html'] = await cache.get(node.description); node['x-description-html'] = await promise;
} else {
const promise = parseMarkdown(node.description);
cache.set(node.description, promise);
node['x-description-html'] = await promise;
}
} }
return node; }
}); return node;
return { });
// Cache for 4 hours return {
ttl: 24 * 60 * 60, // Cache for 4 hours
// Revalidate every 2 hours ttl: 24 * 60 * 60,
revalidateBefore: 22 * 60 * 60, // Revalidate every 2 hours
data: transformedFs, revalidateBefore: 22 * 60 * 60,
}; data: transformedFs,
}, };
}), },
}; });
+17 -3
View File
@@ -1,14 +1,28 @@
type OpenAPIParseErrorCode =
| 'invalid'
| 'parse-v2-in-v3'
| 'v2-conversion'
| 'dereference'
| 'yaml-parse';
/** /**
* Error thrown when the OpenAPI document is invalid. * Error thrown when the OpenAPI document is invalid.
*/ */
export class OpenAPIParseError extends Error { export class OpenAPIParseError extends Error {
public override name = 'OpenAPIParseError'; public override name = 'OpenAPIParseError';
public code: OpenAPIParseErrorCode;
public rootURL: string | null;
constructor( constructor(
message: string, message: string,
public readonly url: string, options: {
public readonly code?: 'invalid-spec' | 'v2-spec' | 'failed-dereference', code: OpenAPIParseErrorCode;
rootURL?: string | null;
cause?: Error;
},
) { ) {
super(message); super(message, { cause: options.cause });
this.code = options.code;
this.rootURL = options.rootURL ?? null;
} }
} }
@@ -34,7 +34,7 @@ describe('#createFileSystem', () => {
const url = new URL('/root/spec.yaml', server.url).href; const url = new URL('/root/spec.yaml', server.url).href;
const filesystem = await createFileSystem({ const filesystem = await createFileSystem({
value: url, value: url,
baseUrl: url, rootURL: url,
}); });
expect(filesystem).toHaveLength(4); expect(filesystem).toHaveLength(4);
expect(filesystem[0]!.isEntrypoint).toBe(true); expect(filesystem[0]!.isEntrypoint).toBe(true);
+10 -3
View File
@@ -1,5 +1,5 @@
import { type AnyApiDefinitionFormat, load } from '@scalar/openapi-parser'; import { type AnyApiDefinitionFormat, load } from '@scalar/openapi-parser';
import { fetchUrls } from './scalar-plugins/fetchURLs'; import { fetchURLs } from './scalar-plugins/fetchURLs';
import type { Filesystem } from './types'; import type { Filesystem } from './types';
/** /**
@@ -7,11 +7,18 @@ import type { Filesystem } from './types';
* Fetches all the URLs specified in references and builds a filesystem. * Fetches all the URLs specified in references and builds a filesystem.
*/ */
export async function createFileSystem(input: { export async function createFileSystem(input: {
/**
* The OpenAPI document to create the filesystem from.
*/
value: AnyApiDefinitionFormat; value: AnyApiDefinitionFormat;
baseUrl: string; /**
* The root URL of the specified OpenAPI document.
* Used to resolve relative URLs.
*/
rootURL: string | null;
}): Promise<Filesystem> { }): Promise<Filesystem> {
const { filesystem } = await load(input.value, { const { filesystem } = await load(input.value, {
plugins: [fetchUrls({ baseUrl: input.baseUrl })], plugins: [fetchURLs({ rootURL: input.rootURL })],
}); });
return filesystem; return filesystem;
} }
+1 -1
View File
@@ -7,7 +7,7 @@ describe('#parseOpenAPI', () => {
it('parses an OpenAPI document', async () => { it('parses an OpenAPI document', async () => {
const schema = await parseOpenAPI({ const schema = await parseOpenAPI({
value: spec, value: spec,
url: 'https://example.com', rootURL: null,
}); });
// Ensure the structure returned is not recursive (not dereferenced). // Ensure the structure returned is not recursive (not dereferenced).
JSON.stringify(schema); JSON.stringify(schema);
+12 -2
View File
@@ -1,3 +1,4 @@
import { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
import { OpenAPIParseError } from './error'; import { OpenAPIParseError } from './error';
import { convertOpenAPIV2ToOpenAPIV3 } from './v2'; import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
import { parseOpenAPIV3 } from './v3'; import { parseOpenAPIV3 } from './v3';
@@ -7,11 +8,20 @@ import { parseOpenAPIV3 } from './v3';
* It will also convert Swagger 2.0 to OpenAPI 3.0. * It will also convert Swagger 2.0 to OpenAPI 3.0.
* It can throw an `OpenAPIParseError` if the document is invalid. * It can throw an `OpenAPIParseError` if the document is invalid.
*/ */
export async function parseOpenAPI(input: { value: string; url: string }) { export async function parseOpenAPI(input: {
/**
* The API definition to parse.
*/
value: AnyApiDefinitionFormat;
/**
* The root URL of the specified OpenAPI document.
*/
rootURL: string | null;
}) {
try { try {
return await parseOpenAPIV3(input); return await parseOpenAPIV3(input);
} catch (error) { } catch (error) {
if (error instanceof OpenAPIParseError && error.code === 'v2-spec') { if (error instanceof OpenAPIParseError && error.code === 'parse-v2-in-v3') {
return convertOpenAPIV2ToOpenAPIV3(input); return convertOpenAPIV2ToOpenAPIV3(input);
} }
throw error; throw error;
@@ -4,11 +4,11 @@ export const fetchUrlsDefaultConfiguration = {
limit: 40, limit: 40,
}; };
export const fetchUrls: (customConfiguration: { export const fetchURLs: (customConfiguration: {
/** /**
* Base URL to use for relative paths. * Root URL to resolve relative URLs.
*/ */
baseUrl: string; rootURL: string | null;
/** /**
* Limit the number of requests. Set to `false` to disable the limit. * Limit the number of requests. Set to `false` to disable the limit.
@@ -53,7 +53,7 @@ export const fetchUrls: (customConfiguration: {
try { try {
numberOfRequests++; numberOfRequests++;
const url = getReferenceUrl(value, configuration.baseUrl); const url = getReferenceUrl({ value, rootURL: configuration.rootURL });
const response = await fetch(url); const response = await fetch(url);
return await response.text(); return await response.text();
} catch (error: any) { } catch (error: any) {
@@ -66,6 +66,7 @@ export const fetchUrls: (customConfiguration: {
/** /**
* Check if a path is relative. * Check if a path is relative.
* Meaning it does not start with http://, https://, www., data:, or #/.
*/ */
function isRelativePath(path: string): boolean { function isRelativePath(path: string): boolean {
// Exclude external URLs // Exclude external URLs
@@ -76,9 +77,13 @@ function isRelativePath(path: string): boolean {
/** /**
* Get the reference URL. * Get the reference URL.
*/ */
function getReferenceUrl(value: string, baseUrl: string) { function getReferenceUrl(input: { value: string; rootURL: string | null }) {
const { value, rootURL } = input;
if (isRelativePath(value)) { if (isRelativePath(value)) {
return new URL(value, baseUrl).href; if (!rootURL) {
throw new Error(`[fetchUrls] Cannot resolve relative path without rootURL (${value})`);
}
return new URL(value, rootURL).href;
} }
return value; return value;
+1 -1
View File
@@ -35,7 +35,7 @@ describe('#traverse', () => {
it('traverses a complete filesystem', async () => { it('traverses a complete filesystem', async () => {
const filesystem = await createFileSystem({ const filesystem = await createFileSystem({
value: JSON.parse(recursiveSpec), value: JSON.parse(recursiveSpec),
baseUrl: 'https://example.com', rootURL: 'https://example.com',
}); });
const transformedFilesystem = await traverse(filesystem, async (node) => { const transformedFilesystem = await traverse(filesystem, async (node) => {
+1 -1
View File
@@ -7,7 +7,7 @@ describe('#convertOpenAPIV2ToOpenAPIV3', () => {
it('converts an OpenAPIV2 in V3', async () => { it('converts an OpenAPIV2 in V3', async () => {
const schema = await convertOpenAPIV2ToOpenAPIV3({ const schema = await convertOpenAPIV2ToOpenAPIV3({
value: specV2, value: specV2,
url: 'https://example.com', rootURL: null,
}); });
// Ensure the structure returned is not recursive (not dereferenced). // Ensure the structure returned is not recursive (not dereferenced).
JSON.stringify(schema); JSON.stringify(schema);
+21 -11
View File
@@ -10,12 +10,18 @@ import type { Filesystem, OpenAPIV3xDocument } from './types';
* Convert a Swagger 2.0 schema to an OpenAPI 3.0 schema. * Convert a Swagger 2.0 schema to an OpenAPI 3.0 schema.
*/ */
export async function convertOpenAPIV2ToOpenAPIV3(input: { export async function convertOpenAPIV2ToOpenAPIV3(input: {
/**
* The API definition to parse.
*/
value: AnyApiDefinitionFormat; value: AnyApiDefinitionFormat;
url: string; /**
* The root URL of the specified OpenAPI document.
*/
rootURL: string | null;
}): Promise<Filesystem<OpenAPIV3xDocument>> { }): Promise<Filesystem<OpenAPIV3xDocument>> {
const { value, url } = input; const { value, rootURL } = input;
// In this case we want the raw value to be able to convert it. // In this case we want the raw value to be able to convert it.
const schema = typeof value === 'string' ? rawParseOpenAPI({ value, url }) : value; const schema = typeof value === 'string' ? rawParseOpenAPI({ value, rootURL }) : value;
try { try {
// @ts-expect-error Types are incompatible between the two libraries // @ts-expect-error Types are incompatible between the two libraries
const convertResult = (await swagger2openapi.convertObj(schema, { const convertResult = (await swagger2openapi.convertObj(schema, {
@@ -29,13 +35,14 @@ export async function convertOpenAPIV2ToOpenAPIV3(input: {
patch: true, patch: true,
})) as ConvertOutputOptions; })) as ConvertOutputOptions;
return parseOpenAPIV3({ url, value: convertResult.openapi }); return parseOpenAPIV3({ rootURL, value: convertResult.openapi });
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'S2OError') { if (error instanceof Error && error.name === 'S2OError') {
throw new OpenAPIParseError( throw new OpenAPIParseError('Failed to convert Swagger 2.0 to OpenAPI 3.0', {
'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message, code: 'v2-conversion',
url, rootURL,
); cause: error,
});
} else { } else {
throw error; throw error;
} }
@@ -46,8 +53,8 @@ export async function convertOpenAPIV2ToOpenAPIV3(input: {
* Parse the config file from a raw string. * Parse the config file from a raw string.
* Useful to get the raw object from a file. * Useful to get the raw object from a file.
*/ */
function rawParseOpenAPI(input: { value: string; url: string }): unknown { function rawParseOpenAPI(input: { value: string; rootURL: string | null }): unknown {
const { value, url } = input; const { value, rootURL } = input;
// Try with JSON // Try with JSON
try { try {
@@ -58,7 +65,10 @@ function rawParseOpenAPI(input: { value: string; url: string }): unknown {
return YAML.parse(value); return YAML.parse(value);
} catch (yamlError) { } catch (yamlError) {
if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) { if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) {
throw new OpenAPIParseError('Failed to parse YAML: ' + yamlError.message, url); throw new OpenAPIParseError('Failed to parse YAML: ' + yamlError.message, {
code: 'yaml-parse',
rootURL,
});
} }
throw yamlError; throw yamlError;
} }
+17 -5
View File
@@ -9,22 +9,34 @@ import type { Filesystem, OpenAPIV3xDocument } from './types';
* It can throw an `OpenAPIFetchError` if the document is invalid. * It can throw an `OpenAPIFetchError` if the document is invalid.
*/ */
export async function parseOpenAPIV3(input: { export async function parseOpenAPIV3(input: {
/**
* The API definition to parse.
*/
value: AnyApiDefinitionFormat; value: AnyApiDefinitionFormat;
url: string; /**
* The root URL of the specified OpenAPI document.
*/
rootURL: string | null;
}): Promise<Filesystem<OpenAPIV3xDocument>> { }): Promise<Filesystem<OpenAPIV3xDocument>> {
const { value, url } = input; const { value, rootURL } = input;
const result = await validate(value); const result = await validate(value);
// Spec is invalid, we stop here. // Spec is invalid, we stop here.
if (!result.specification) { if (!result.specification) {
throw new OpenAPIParseError('Invalid OpenAPI document', url, 'invalid-spec'); throw new OpenAPIParseError('Invalid OpenAPI document', {
code: 'invalid',
rootURL,
});
} }
if (result.version === '2.0') { if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', url, 'v2-spec'); throw new OpenAPIParseError('Only OpenAPI v3 is supported', {
code: 'parse-v2-in-v3',
rootURL,
});
} }
const filesystem = await createFileSystem({ value: result.specification, baseUrl: url }); const filesystem = await createFileSystem({ value: result.specification, rootURL });
return filesystem; return filesystem;
} }
File diff suppressed because one or more lines are too long
@@ -1,9 +1,8 @@
import { CodeSampleInput, codeSampleGenerators } from './code-samples'; import { CodeSampleInput, codeSampleGenerators } from './code-samples';
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample'; import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { getServersURL } from './OpenAPIServerURL'; import { getServersURL } from './OpenAPIServerURL';
import { OpenAPIContextProps } from './types'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { noReference } from './utils'; import { noReference } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI'; import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
@@ -1,11 +1,10 @@
import clsx from 'clsx'; import clsx from 'clsx';
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { OpenAPICodeSample } from './OpenAPICodeSample'; import { OpenAPICodeSample } from './OpenAPICodeSample';
import { OpenAPIResponseExample } from './OpenAPIResponseExample'; import { OpenAPIResponseExample } from './OpenAPIResponseExample';
import { OpenAPISpec } from './OpenAPISpec'; import { OpenAPISpec } from './OpenAPISpec';
import { OpenAPIClientContext, type OpenAPIContextProps } from './types'; import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPIOperationData } from './types';
import { OpenAPIPath } from './OpenAPIPath'; import { OpenAPIPath } from './OpenAPIPath';
import { resolveDescription } from './utils'; import { resolveDescription } from './utils';
+1 -2
View File
@@ -1,6 +1,5 @@
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { ScalarApiButton } from './ScalarApiButton'; import { ScalarApiButton } from './ScalarApiButton';
import type { OpenAPIContextProps } from './types'; import type { OpenAPIOperationData, OpenAPIContextProps } from './types';
/** /**
* Display the path of an operation. * Display the path of an operation.
@@ -1,7 +1,6 @@
import type { OpenAPIV3 } from '@gitbook/openapi-parser'; import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { generateSchemaExample } from './generateSchemaExample'; import { generateSchemaExample } from './generateSchemaExample';
import type { OpenAPIContextProps } from './types'; import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
import { checkIsReference, noReference, resolveDescription } from './utils'; import { checkIsReference, noReference, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI'; import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs'; import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
@@ -1,8 +1,7 @@
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser'; import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
import type { OpenAPIClientContext } from './types'; import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { OpenAPISchemaName } from './OpenAPISchemaName'; import { OpenAPISchemaName } from './OpenAPISchemaName';
import { resolveDescription } from './utils'; import { resolveDescription } from './utils';
+1 -2
View File
@@ -2,13 +2,12 @@
import type { OpenAPI } from '@gitbook/openapi-parser'; import type { OpenAPI } from '@gitbook/openapi-parser';
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { InteractiveSection } from './InteractiveSection'; import { InteractiveSection } from './InteractiveSection';
import { OpenAPIRequestBody } from './OpenAPIRequestBody'; import { OpenAPIRequestBody } from './OpenAPIRequestBody';
import { OpenAPIResponses } from './OpenAPIResponses'; import { OpenAPIResponses } from './OpenAPIResponses';
import { OpenAPISchemaProperties } from './OpenAPISchema'; import { OpenAPISchemaProperties } from './OpenAPISchema';
import { OpenAPISecurities } from './OpenAPISecurities'; import { OpenAPISecurities } from './OpenAPISecurities';
import { OpenAPIClientContext } from './types'; import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
import { noReference, resolveDescription } from './utils'; import { noReference, resolveDescription } from './utils';
/** /**
@@ -1,193 +0,0 @@
import { it, expect } from 'bun:test';
import { fetchOpenAPIOperation, type OpenAPIFetcher } from './fetchOpenAPIOperation';
import { parseOpenAPI, traverse } from '@gitbook/openapi-parser';
const fetcher: OpenAPIFetcher = {
fetch: async (url) => {
const response = await fetch(url);
const text = await response.text();
const filesystem = await parseOpenAPI({ value: text, url });
const transformedFs = await traverse(filesystem, async (node) => {
if ('description' in node && typeof node.description === 'string' && node.description) {
node['x-description-html'] = node.description;
}
return node;
});
return transformedFs;
},
};
it('should resolve refs', async () => {
const resolved = await fetchOpenAPIOperation(
{
url: 'https://petstore3.swagger.io/api/v3/openapi.json',
method: 'put',
path: '/pet',
},
fetcher,
);
expect(resolved).toMatchObject({
servers: [
{
url: '/api/v3',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: 'Update an existing pet by Id',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
it('should support yaml', async () => {
const resolved = await fetchOpenAPIOperation(
{
url: 'https://petstore3.swagger.io/api/v3/openapi.yaml',
method: 'put',
path: '/pet',
},
fetcher,
);
expect(resolved).toMatchObject({
servers: [
{
url: '/api/v3',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: 'Update an existing pet by Id',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
it('should resolve circular refs', async () => {
const resolved = await fetchOpenAPIOperation(
{
url: 'https://api.gitbook.com/openapi.json',
method: 'post',
path: '/search/ask',
},
fetcher,
);
expect(resolved).toMatchObject({
servers: [
{
url: '{host}/v1',
},
],
operation: {
operationId: 'askQuery',
},
});
});
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);
});
it('should parse Swagger 2.0', async () => {
const resolved = await fetchOpenAPIOperation(
{
url: 'https://petstore.swagger.io/v2/swagger.json',
method: 'put',
path: '/pet',
},
fetcher,
);
expect(resolved).toMatchObject({
servers: [
{
url: 'https://petstore.swagger.io/v2',
},
{
url: 'http://petstore.swagger.io/v2',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: '',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
it('should resolve a ref with whitespace', async () => {
const resolved = await fetchOpenAPIOperation(
{
url: ' https://petstore3.swagger.io/api/v3/openapi.json',
method: 'put',
path: '/pet',
},
fetcher,
);
expect(resolved).toMatchObject({
servers: [
{
url: '/api/v3',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: 'Update an existing pet by Id',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './fetchOpenAPIOperation'; export * from './resolveOpenAPIOperation';
export * from './OpenAPIOperation'; export * from './OpenAPIOperation';
export * from './OpenAPIOperationContext'; export * from './OpenAPIOperationContext';
export type { OpenAPIOperationData } from './types';
@@ -0,0 +1,177 @@
import { it, expect, describe } from 'bun:test';
import { resolveOpenAPIOperation } from './resolveOpenAPIOperation';
import { parseOpenAPI, traverse } from '@gitbook/openapi-parser';
async function fetchFilesystem(url: string) {
const response = await fetch(url);
const text = await response.text();
const filesystem = await parseOpenAPI({ value: text, rootURL: url });
const transformedFs = await traverse(filesystem, async (node) => {
if ('description' in node && typeof node.description === 'string' && node.description) {
node['x-description-html'] = node.description;
}
return node;
});
return transformedFs;
}
describe('#resolveOpenAPIOperation', () => {
it('should resolve refs', async () => {
const filesystem = await fetchFilesystem(
'https://petstore3.swagger.io/api/v3/openapi.json',
);
const resolved = await resolveOpenAPIOperation(filesystem, { method: 'put', path: '/pet' });
expect(resolved).toMatchObject({
servers: [
{
url: '/api/v3',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: 'Update an existing pet by Id',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
it('should support yaml', async () => {
const filesystem = await fetchFilesystem(
'https://petstore3.swagger.io/api/v3/openapi.yaml',
);
const resolved = await resolveOpenAPIOperation(filesystem, { method: 'put', path: '/pet' });
expect(resolved).toMatchObject({
servers: [
{
url: '/api/v3',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: 'Update an existing pet by Id',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
it('should resolve circular refs', async () => {
const filesystem = await fetchFilesystem('https://api.gitbook.com/openapi.json');
const resolved = await resolveOpenAPIOperation(filesystem, {
method: 'post',
path: '/search/ask',
});
expect(resolved).toMatchObject({
servers: [
{
url: '{host}/v1',
},
],
operation: {
operationId: 'askQuery',
},
});
});
it('should resolve to null if the method is not supported', async () => {
const filesystem = await fetchFilesystem(
'https://petstore3.swagger.io/api/v3/openapi.json',
);
const resolved = await resolveOpenAPIOperation(filesystem, {
method: 'dontexist',
path: '/pet',
});
expect(resolved).toBe(null);
});
it('should parse Swagger 2.0', async () => {
const filesystem = await fetchFilesystem('https://petstore.swagger.io/v2/swagger.json');
const resolved = await resolveOpenAPIOperation(filesystem, {
method: 'put',
path: '/pet',
});
expect(resolved).toMatchObject({
servers: [
{
url: 'https://petstore.swagger.io/v2',
},
{
url: 'http://petstore.swagger.io/v2',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: '',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
it('should resolve a ref with whitespace', async () => {
const filesystem = await fetchFilesystem(
' https://petstore3.swagger.io/api/v3/openapi.json',
);
const resolved = await resolveOpenAPIOperation(filesystem, {
method: 'put',
path: '/pet',
});
expect(resolved).toMatchObject({
servers: [
{
url: '/api/v3',
},
],
operation: {
tags: ['pet'],
summary: 'Update an existing pet',
description: 'Update an existing pet by Id',
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'photoUrls'],
},
},
},
},
},
});
});
});
@@ -1,61 +1,37 @@
import { toJSON, fromJSON } from 'flatted'; import { toJSON, fromJSON } from 'flatted';
import { import {
type OpenAPICustomOperationProperties,
type OpenAPICustomSpecProperties,
type OpenAPIV3xDocument, type OpenAPIV3xDocument,
type Filesystem, type Filesystem,
type OpenAPIV3, type OpenAPIV3,
type OpenAPIV3_1, type OpenAPIV3_1,
OpenAPIParseError,
dereference, dereference,
} from '@gitbook/openapi-parser'; } from '@gitbook/openapi-parser';
import { noReference } from './utils'; import { noReference } from './utils';
import { OpenAPIOperationData } from './types';
export interface OpenAPIFetcher {
/**
* Fetch an OpenAPI file by its URL. It should return a fully parsed OpenAPI v3 document.
*/
fetch: (url: string) => Promise<Filesystem<OpenAPIV3xDocument>>;
}
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
path: string;
method: string;
/** Servers to be used for this operation */
servers: OpenAPIV3.ServerObject[];
/** Spec of the operation */
operation: OpenAPIV3.OperationObject<OpenAPICustomOperationProperties>;
/** Securities that should be used for this operation */
securities: [string, OpenAPIV3.SecuritySchemeObject][];
}
export { toJSON, fromJSON }; export { toJSON, fromJSON };
/** /**
* Resolve an OpenAPI operation in a file and compile it to a more usable format. * Resolve an OpenAPI operation in a file and compile it to a more usable format.
*/ */
export async function fetchOpenAPIOperation( export async function resolveOpenAPIOperation(
input: { filesystem: Filesystem<OpenAPIV3xDocument>,
url: string; operationDescriptor: {
path: string; path: string;
method: string; method: string;
}, },
fetcher: OpenAPIFetcher,
): Promise<OpenAPIOperationData | null> { ): Promise<OpenAPIOperationData | null> {
const filesystem = await fetcher.fetch(input.url); const { path, method } = operationDescriptor;
const schema = await memoDereferenceFilesystem(filesystem, input.url); const schema = await memoDereferenceFilesystem(filesystem);
let operation = getOperationByPathAndMethod(schema, input.path, input.method); let operation = getOperationByPathAndMethod(schema, path, method);
if (!operation) { if (!operation) {
return null; return null;
} }
// Resolve common parameters // Resolve common parameters
const commonParameters = getPathObjectParameter(schema, input.path); const commonParameters = getPathObjectParameter(schema, path);
if (commonParameters) { if (commonParameters) {
operation = { operation = {
...operation, ...operation,
@@ -81,8 +57,8 @@ export async function fetchOpenAPIOperation(
return { return {
servers, servers,
operation, operation,
method: input.method, method,
path: input.path, path,
securities, securities,
'x-codeSamples': 'x-codeSamples':
typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined, typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined,
@@ -98,15 +74,12 @@ const dereferenceCache = new WeakMap<Filesystem, Promise<OpenAPIV3xDocument>>();
/** /**
* Memoized version of `dereferenceSchema`. * Memoized version of `dereferenceSchema`.
*/ */
function memoDereferenceFilesystem( function memoDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
filesystem: Filesystem,
url: string,
): Promise<OpenAPIV3xDocument> {
if (dereferenceCache.has(filesystem)) { if (dereferenceCache.has(filesystem)) {
return dereferenceCache.get(filesystem) as Promise<OpenAPIV3xDocument>; return dereferenceCache.get(filesystem) as Promise<OpenAPIV3xDocument>;
} }
const promise = dereferenceFilesystem(filesystem, url); const promise = dereferenceFilesystem(filesystem);
dereferenceCache.set(filesystem, promise); dereferenceCache.set(filesystem, promise);
return promise; return promise;
} }
@@ -114,18 +87,11 @@ function memoDereferenceFilesystem(
/** /**
* Dereference an OpenAPI schema. * Dereference an OpenAPI schema.
*/ */
async function dereferenceFilesystem( async function dereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
filesystem: Filesystem,
url: string,
): Promise<OpenAPIV3xDocument> {
const result = await dereference(filesystem); const result = await dereference(filesystem);
if (!result.schema) { if (!result.schema) {
throw new OpenAPIParseError( throw new Error('Failed to dereference OpenAPI document');
'Failed to dereference OpenAPI document',
url,
'failed-dereference',
);
} }
return result.schema as OpenAPIV3xDocument; return result.schema as OpenAPIV3xDocument;
+20
View File
@@ -1,3 +1,9 @@
import type {
OpenAPICustomOperationProperties,
OpenAPICustomSpecProperties,
OpenAPIV3,
} from '@gitbook/openapi-parser';
export interface OpenAPIContextProps extends OpenAPIClientContext { export interface OpenAPIContextProps extends OpenAPIClientContext {
CodeBlock: React.ComponentType<{ code: string; syntax: string }>; CodeBlock: React.ComponentType<{ code: string; syntax: string }>;
@@ -24,3 +30,17 @@ export interface OpenAPIClientContext {
/** Optional id attached to the OpenAPI Operation heading and used as an anchor */ /** Optional id attached to the OpenAPI Operation heading and used as an anchor */
id?: string; id?: string;
} }
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
path: string;
method: string;
/** Servers to be used for this operation */
servers: OpenAPIV3.ServerObject[];
/** Spec of the operation */
operation: OpenAPIV3.OperationObject<OpenAPICustomOperationProperties>;
/** Securities that should be used for this operation */
securities: [string, OpenAPIV3.SecuritySchemeObject][];
}