mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Improve OpenAPI abstraction to be more flexible on usage (#2832)
This commit is contained in:
@@ -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 (
|
||||
<div className={tcls('hidden')}>
|
||||
<p>
|
||||
Error with {error.url}: {error.message}
|
||||
Error with {error.rootURL}: {error.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { ContentRef, DocumentBlockOpenAPI } from '@gitbook/api';
|
||||
import { parseOpenAPI, OpenAPIParseError, traverse } from '@gitbook/openapi-parser';
|
||||
import {
|
||||
OpenAPIOperationData,
|
||||
fetchOpenAPIOperation,
|
||||
OpenAPIFetcher,
|
||||
} from '@gitbook/react-openapi';
|
||||
import { type OpenAPIOperationData, resolveOpenAPIOperation } from '@gitbook/react-openapi';
|
||||
|
||||
import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
|
||||
|
||||
@@ -27,14 +23,11 @@ export async function fetchOpenAPIBlock(
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchOpenAPIOperation(
|
||||
{
|
||||
url: resolved.href,
|
||||
path: block.data.path,
|
||||
method: block.data.method,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
const filesystem = await fetchFilesystem(resolved.href);
|
||||
const data = await resolveOpenAPIOperation(filesystem, {
|
||||
path: block.data.path,
|
||||
method: block.data.method,
|
||||
});
|
||||
|
||||
return { data, specUrl: resolved.href };
|
||||
} catch (error) {
|
||||
@@ -46,50 +39,44 @@ export async function fetchOpenAPIBlock(
|
||||
}
|
||||
}
|
||||
|
||||
const fetcher: OpenAPIFetcher = {
|
||||
fetch: cache({
|
||||
name: 'openapi.fetch.v5',
|
||||
get: async (url: string, options: CacheFunctionOptions) => {
|
||||
// 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:
|
||||
// https://github.com/cloudflare/workerd/issues/1957
|
||||
const response = await fetch(new URL(url), {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
const fetchFilesystem = cache({
|
||||
name: 'openapi.fetch.v5',
|
||||
get: async (url: string, options: CacheFunctionOptions) => {
|
||||
// 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:
|
||||
// https://github.com/cloudflare/workerd/issues/1957
|
||||
const response = await fetch(new URL(url), {
|
||||
...noCacheFetchOptions,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch OpenAPI file: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const filesystem = await parseOpenAPI({ url, value: text });
|
||||
const cache: Map<string, Promise<string>> = new Map();
|
||||
const transformedFs = await traverse(filesystem, async (node) => {
|
||||
if (
|
||||
'description' in node &&
|
||||
typeof node.description === 'string' &&
|
||||
node.description
|
||||
) {
|
||||
if (cache.has(node.description)) {
|
||||
node['x-description-html'] = await cache.get(node.description);
|
||||
} else {
|
||||
const promise = parseMarkdown(node.description);
|
||||
cache.set(node.description, promise);
|
||||
node['x-description-html'] = await promise;
|
||||
}
|
||||
const text = await response.text();
|
||||
const filesystem = await parseOpenAPI({ value: text, rootURL: url });
|
||||
const cache: Map<string, Promise<string>> = new Map();
|
||||
const transformedFs = await traverse(filesystem, async (node) => {
|
||||
if ('description' in node && typeof node.description === 'string' && node.description) {
|
||||
if (cache.has(node.description)) {
|
||||
node['x-description-html'] = await cache.get(node.description);
|
||||
} else {
|
||||
const promise = parseMarkdown(node.description);
|
||||
cache.set(node.description, promise);
|
||||
node['x-description-html'] = await promise;
|
||||
}
|
||||
return node;
|
||||
});
|
||||
return {
|
||||
// Cache for 4 hours
|
||||
ttl: 24 * 60 * 60,
|
||||
// Revalidate every 2 hours
|
||||
revalidateBefore: 22 * 60 * 60,
|
||||
data: transformedFs,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
return {
|
||||
// Cache for 4 hours
|
||||
ttl: 24 * 60 * 60,
|
||||
// Revalidate every 2 hours
|
||||
revalidateBefore: 22 * 60 * 60,
|
||||
data: transformedFs,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
type OpenAPIParseErrorCode =
|
||||
| 'invalid'
|
||||
| 'parse-v2-in-v3'
|
||||
| 'v2-conversion'
|
||||
| 'dereference'
|
||||
| 'yaml-parse';
|
||||
|
||||
/**
|
||||
* Error thrown when the OpenAPI document is invalid.
|
||||
*/
|
||||
export class OpenAPIParseError extends Error {
|
||||
public override name = 'OpenAPIParseError';
|
||||
public code: OpenAPIParseErrorCode;
|
||||
public rootURL: string | null;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
public readonly code?: 'invalid-spec' | 'v2-spec' | 'failed-dereference',
|
||||
options: {
|
||||
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 filesystem = await createFileSystem({
|
||||
value: url,
|
||||
baseUrl: url,
|
||||
rootURL: url,
|
||||
});
|
||||
expect(filesystem).toHaveLength(4);
|
||||
expect(filesystem[0]!.isEntrypoint).toBe(true);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -7,11 +7,18 @@ import type { Filesystem } from './types';
|
||||
* Fetches all the URLs specified in references and builds a filesystem.
|
||||
*/
|
||||
export async function createFileSystem(input: {
|
||||
/**
|
||||
* The OpenAPI document to create the filesystem from.
|
||||
*/
|
||||
value: AnyApiDefinitionFormat;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* The root URL of the specified OpenAPI document.
|
||||
* Used to resolve relative URLs.
|
||||
*/
|
||||
rootURL: string | null;
|
||||
}): Promise<Filesystem> {
|
||||
const { filesystem } = await load(input.value, {
|
||||
plugins: [fetchUrls({ baseUrl: input.baseUrl })],
|
||||
plugins: [fetchURLs({ rootURL: input.rootURL })],
|
||||
});
|
||||
return filesystem;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('#parseOpenAPI', () => {
|
||||
it('parses an OpenAPI document', async () => {
|
||||
const schema = await parseOpenAPI({
|
||||
value: spec,
|
||||
url: 'https://example.com',
|
||||
rootURL: null,
|
||||
});
|
||||
// Ensure the structure returned is not recursive (not dereferenced).
|
||||
JSON.stringify(schema);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
|
||||
import { OpenAPIParseError } from './error';
|
||||
import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
|
||||
import { parseOpenAPIV3 } from './v3';
|
||||
@@ -7,11 +8,20 @@ import { parseOpenAPIV3 } from './v3';
|
||||
* It will also convert Swagger 2.0 to OpenAPI 3.0.
|
||||
* 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 {
|
||||
return await parseOpenAPIV3(input);
|
||||
} catch (error) {
|
||||
if (error instanceof OpenAPIParseError && error.code === 'v2-spec') {
|
||||
if (error instanceof OpenAPIParseError && error.code === 'parse-v2-in-v3') {
|
||||
return convertOpenAPIV2ToOpenAPIV3(input);
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -4,11 +4,11 @@ export const fetchUrlsDefaultConfiguration = {
|
||||
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.
|
||||
@@ -53,7 +53,7 @@ export const fetchUrls: (customConfiguration: {
|
||||
|
||||
try {
|
||||
numberOfRequests++;
|
||||
const url = getReferenceUrl(value, configuration.baseUrl);
|
||||
const url = getReferenceUrl({ value, rootURL: configuration.rootURL });
|
||||
const response = await fetch(url);
|
||||
return await response.text();
|
||||
} catch (error: any) {
|
||||
@@ -66,6 +66,7 @@ export const fetchUrls: (customConfiguration: {
|
||||
|
||||
/**
|
||||
* Check if a path is relative.
|
||||
* Meaning it does not start with http://, https://, www., data:, or #/.
|
||||
*/
|
||||
function isRelativePath(path: string): boolean {
|
||||
// Exclude external URLs
|
||||
@@ -76,9 +77,13 @@ function isRelativePath(path: string): boolean {
|
||||
/**
|
||||
* 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)) {
|
||||
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;
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('#traverse', () => {
|
||||
it('traverses a complete filesystem', async () => {
|
||||
const filesystem = await createFileSystem({
|
||||
value: JSON.parse(recursiveSpec),
|
||||
baseUrl: 'https://example.com',
|
||||
rootURL: 'https://example.com',
|
||||
});
|
||||
|
||||
const transformedFilesystem = await traverse(filesystem, async (node) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('#convertOpenAPIV2ToOpenAPIV3', () => {
|
||||
it('converts an OpenAPIV2 in V3', async () => {
|
||||
const schema = await convertOpenAPIV2ToOpenAPIV3({
|
||||
value: specV2,
|
||||
url: 'https://example.com',
|
||||
rootURL: null,
|
||||
});
|
||||
// Ensure the structure returned is not recursive (not dereferenced).
|
||||
JSON.stringify(schema);
|
||||
|
||||
@@ -10,12 +10,18 @@ import type { Filesystem, OpenAPIV3xDocument } from './types';
|
||||
* Convert a Swagger 2.0 schema to an OpenAPI 3.0 schema.
|
||||
*/
|
||||
export async function convertOpenAPIV2ToOpenAPIV3(input: {
|
||||
/**
|
||||
* The API definition to parse.
|
||||
*/
|
||||
value: AnyApiDefinitionFormat;
|
||||
url: string;
|
||||
/**
|
||||
* The root URL of the specified OpenAPI document.
|
||||
*/
|
||||
rootURL: string | null;
|
||||
}): 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.
|
||||
const schema = typeof value === 'string' ? rawParseOpenAPI({ value, url }) : value;
|
||||
const schema = typeof value === 'string' ? rawParseOpenAPI({ value, rootURL }) : value;
|
||||
try {
|
||||
// @ts-expect-error Types are incompatible between the two libraries
|
||||
const convertResult = (await swagger2openapi.convertObj(schema, {
|
||||
@@ -29,13 +35,14 @@ export async function convertOpenAPIV2ToOpenAPIV3(input: {
|
||||
patch: true,
|
||||
})) as ConvertOutputOptions;
|
||||
|
||||
return parseOpenAPIV3({ url, value: convertResult.openapi });
|
||||
return parseOpenAPIV3({ rootURL, value: convertResult.openapi });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'S2OError') {
|
||||
throw new OpenAPIParseError(
|
||||
'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message,
|
||||
url,
|
||||
);
|
||||
throw new OpenAPIParseError('Failed to convert Swagger 2.0 to OpenAPI 3.0', {
|
||||
code: 'v2-conversion',
|
||||
rootURL,
|
||||
cause: error,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
@@ -46,8 +53,8 @@ export async function convertOpenAPIV2ToOpenAPIV3(input: {
|
||||
* Parse the config file from a raw string.
|
||||
* Useful to get the raw object from a file.
|
||||
*/
|
||||
function rawParseOpenAPI(input: { value: string; url: string }): unknown {
|
||||
const { value, url } = input;
|
||||
function rawParseOpenAPI(input: { value: string; rootURL: string | null }): unknown {
|
||||
const { value, rootURL } = input;
|
||||
|
||||
// Try with JSON
|
||||
try {
|
||||
@@ -58,7 +65,10 @@ function rawParseOpenAPI(input: { value: string; url: string }): unknown {
|
||||
return YAML.parse(value);
|
||||
} catch (yamlError) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,22 +9,34 @@ import type { Filesystem, OpenAPIV3xDocument } from './types';
|
||||
* It can throw an `OpenAPIFetchError` if the document is invalid.
|
||||
*/
|
||||
export async function parseOpenAPIV3(input: {
|
||||
/**
|
||||
* The API definition to parse.
|
||||
*/
|
||||
value: AnyApiDefinitionFormat;
|
||||
url: string;
|
||||
/**
|
||||
* The root URL of the specified OpenAPI document.
|
||||
*/
|
||||
rootURL: string | null;
|
||||
}): Promise<Filesystem<OpenAPIV3xDocument>> {
|
||||
const { value, url } = input;
|
||||
const { value, rootURL } = input;
|
||||
const result = await validate(value);
|
||||
|
||||
// Spec is invalid, we stop here.
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,9 +1,8 @@
|
||||
import { CodeSampleInput, codeSampleGenerators } from './code-samples';
|
||||
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
import { getServersURL } from './OpenAPIServerURL';
|
||||
import { OpenAPIContextProps } from './types';
|
||||
import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
|
||||
import { noReference } from './utils';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { Markdown } from './Markdown';
|
||||
import { OpenAPICodeSample } from './OpenAPICodeSample';
|
||||
import { OpenAPIResponseExample } from './OpenAPIResponseExample';
|
||||
import { OpenAPISpec } from './OpenAPISpec';
|
||||
import { OpenAPIClientContext, type OpenAPIContextProps } from './types';
|
||||
import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPIOperationData } from './types';
|
||||
import { OpenAPIPath } from './OpenAPIPath';
|
||||
import { resolveDescription } from './utils';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { ScalarApiButton } from './ScalarApiButton';
|
||||
import type { OpenAPIContextProps } from './types';
|
||||
import type { OpenAPIOperationData, OpenAPIContextProps } from './types';
|
||||
|
||||
/**
|
||||
* Display the path of an operation.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
|
||||
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { generateSchemaExample } from './generateSchemaExample';
|
||||
import type { OpenAPIContextProps } from './types';
|
||||
import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
|
||||
import { checkIsReference, noReference, resolveDescription } from './utils';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
|
||||
import type { OpenAPIClientContext } from './types';
|
||||
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
import { Markdown } from './Markdown';
|
||||
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { OpenAPISchemaName } from './OpenAPISchemaName';
|
||||
import { resolveDescription } from './utils';
|
||||
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import type { OpenAPI } from '@gitbook/openapi-parser';
|
||||
|
||||
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
|
||||
import { InteractiveSection } from './InteractiveSection';
|
||||
import { OpenAPIRequestBody } from './OpenAPIRequestBody';
|
||||
import { OpenAPIResponses } from './OpenAPIResponses';
|
||||
import { OpenAPISchemaProperties } from './OpenAPISchema';
|
||||
import { OpenAPISecurities } from './OpenAPISecurities';
|
||||
import { OpenAPIClientContext } from './types';
|
||||
import type { OpenAPIClientContext, OpenAPIOperationData } from './types';
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './fetchOpenAPIOperation';
|
||||
export * from './resolveOpenAPIOperation';
|
||||
export * from './OpenAPIOperation';
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+14
-48
@@ -1,61 +1,37 @@
|
||||
import { toJSON, fromJSON } from 'flatted';
|
||||
|
||||
import {
|
||||
type OpenAPICustomOperationProperties,
|
||||
type OpenAPICustomSpecProperties,
|
||||
type OpenAPIV3xDocument,
|
||||
type Filesystem,
|
||||
type OpenAPIV3,
|
||||
type OpenAPIV3_1,
|
||||
OpenAPIParseError,
|
||||
dereference,
|
||||
} from '@gitbook/openapi-parser';
|
||||
import { noReference } from './utils';
|
||||
|
||||
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][];
|
||||
}
|
||||
import { OpenAPIOperationData } from './types';
|
||||
|
||||
export { toJSON, fromJSON };
|
||||
|
||||
/**
|
||||
* Resolve an OpenAPI operation in a file and compile it to a more usable format.
|
||||
*/
|
||||
export async function fetchOpenAPIOperation(
|
||||
input: {
|
||||
url: string;
|
||||
export async function resolveOpenAPIOperation(
|
||||
filesystem: Filesystem<OpenAPIV3xDocument>,
|
||||
operationDescriptor: {
|
||||
path: string;
|
||||
method: string;
|
||||
},
|
||||
fetcher: OpenAPIFetcher,
|
||||
): Promise<OpenAPIOperationData | null> {
|
||||
const filesystem = await fetcher.fetch(input.url);
|
||||
const schema = await memoDereferenceFilesystem(filesystem, input.url);
|
||||
let operation = getOperationByPathAndMethod(schema, input.path, input.method);
|
||||
const { path, method } = operationDescriptor;
|
||||
const schema = await memoDereferenceFilesystem(filesystem);
|
||||
let operation = getOperationByPathAndMethod(schema, path, method);
|
||||
|
||||
if (!operation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve common parameters
|
||||
const commonParameters = getPathObjectParameter(schema, input.path);
|
||||
const commonParameters = getPathObjectParameter(schema, path);
|
||||
if (commonParameters) {
|
||||
operation = {
|
||||
...operation,
|
||||
@@ -81,8 +57,8 @@ export async function fetchOpenAPIOperation(
|
||||
return {
|
||||
servers,
|
||||
operation,
|
||||
method: input.method,
|
||||
path: input.path,
|
||||
method,
|
||||
path,
|
||||
securities,
|
||||
'x-codeSamples':
|
||||
typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined,
|
||||
@@ -98,15 +74,12 @@ const dereferenceCache = new WeakMap<Filesystem, Promise<OpenAPIV3xDocument>>();
|
||||
/**
|
||||
* Memoized version of `dereferenceSchema`.
|
||||
*/
|
||||
function memoDereferenceFilesystem(
|
||||
filesystem: Filesystem,
|
||||
url: string,
|
||||
): Promise<OpenAPIV3xDocument> {
|
||||
function memoDereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
|
||||
if (dereferenceCache.has(filesystem)) {
|
||||
return dereferenceCache.get(filesystem) as Promise<OpenAPIV3xDocument>;
|
||||
}
|
||||
|
||||
const promise = dereferenceFilesystem(filesystem, url);
|
||||
const promise = dereferenceFilesystem(filesystem);
|
||||
dereferenceCache.set(filesystem, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -114,18 +87,11 @@ function memoDereferenceFilesystem(
|
||||
/**
|
||||
* Dereference an OpenAPI schema.
|
||||
*/
|
||||
async function dereferenceFilesystem(
|
||||
filesystem: Filesystem,
|
||||
url: string,
|
||||
): Promise<OpenAPIV3xDocument> {
|
||||
async function dereferenceFilesystem(filesystem: Filesystem): Promise<OpenAPIV3xDocument> {
|
||||
const result = await dereference(filesystem);
|
||||
|
||||
if (!result.schema) {
|
||||
throw new OpenAPIParseError(
|
||||
'Failed to dereference OpenAPI document',
|
||||
url,
|
||||
'failed-dereference',
|
||||
);
|
||||
throw new Error('Failed to dereference OpenAPI document');
|
||||
}
|
||||
|
||||
return result.schema as OpenAPIV3xDocument;
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
OpenAPICustomOperationProperties,
|
||||
OpenAPICustomSpecProperties,
|
||||
OpenAPIV3,
|
||||
} from '@gitbook/openapi-parser';
|
||||
|
||||
export interface OpenAPIContextProps extends OpenAPIClientContext {
|
||||
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 */
|
||||
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][];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user