Use Scalar OpenAPI parser (#2780)

This commit is contained in:
Greg Bergé
2025-01-30 08:44:08 +01:00
committed by GitHub
parent 6f54826296
commit 12c7862250
32 changed files with 415 additions and 435 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@gitbook/react-openapi': major
---
Use `@scalar/openapi-parser` to be more resilient and perf on OpenAPI spec parsing:
- `fetcher.fetch` must now returns a valid OpenAPI document
- `parseOpenAPIV3` has been replaced by `parseOpenAPI`
+14 -3
View File
@@ -178,9 +178,10 @@
"version": "0.7.1",
"dependencies": {
"@scalar/api-client-react": "1.0.87",
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
"classnames": "^2.5.1",
"flatted": "^3.2.9",
"openapi-types": "^12.1.3",
"swagger2openapi": "^7.0.8",
"usehooks-ts": "^3.1.0",
"yaml": "1.10.2",
@@ -725,9 +726,9 @@
"@scalar/object-utils": ["@scalar/object-utils@1.1.12", "", { "dependencies": { "flatted": "^3.3.1", "just-clone": "^6.2.0", "ts-deepmerge": "^7.0.1" } }, "sha512-RMC4kKkFVpFKHL8QbJJpRGY4cAtc/6w4Gaf5zaZj6qtc0OZZRJyF+G9JfcqmYd98FW4DfmzN4Pr0nhYQJraZ0A=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.8.9", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-vTXrkl/hX3CG2dMe8mYutjsjfFLZOXGFJGqz6k8lUk8avFP/5eGEsd/eZe1UIcwtDwcjgJIyQ4p3pIC7jBvfuw=="],
"@scalar/openapi-parser": ["@scalar/openapi-parser@0.10.4", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-t0+PQcLWCRYtvkfhZf74uRsN/5FmwjxWjMEuj32aM1iseUmEHMirYFyyZMIkQpfO0DblEgHQZcmzj+v8O4aOjw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.6", "", {}, "sha512-V+KnESyVJqorJzEN0QFlu3tAImCHjnvPov6QcQvjfY7s0+CjrI3rRO3oVIRlXURTQrQGrnhxvK0SkXGAZ+dxvw=="],
"@scalar/themes": ["@scalar/themes@0.9.48", "", { "dependencies": { "@scalar/types": "0.0.19" } }, "sha512-lsehl9KnlKgWG8BOIIaX3iIuNMLjRFZ0ctb1KsQBhOSsoTrq3c9EUJdYJiiTWzm52zdhF+RkIx7uHaX/pvgctw=="],
@@ -2997,6 +2998,10 @@
"@rollup/plugin-commonjs/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"@scalar/api-client/@scalar/openapi-parser": ["@scalar/openapi-parser@0.8.9", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-vTXrkl/hX3CG2dMe8mYutjsjfFLZOXGFJGqz6k8lUk8avFP/5eGEsd/eZe1UIcwtDwcjgJIyQ4p3pIC7jBvfuw=="],
"@scalar/api-client/@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="],
"@scalar/api-client/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="],
"@scalar/api-client/pretty-ms": ["pretty-ms@8.0.0", "", { "dependencies": { "parse-ms": "^3.0.0" } }, "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q=="],
@@ -3005,14 +3010,20 @@
"@scalar/components/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="],
"@scalar/import/@scalar/openapi-parser": ["@scalar/openapi-parser@0.8.9", "", { "dependencies": { "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "^2.4.5" } }, "sha512-vTXrkl/hX3CG2dMe8mYutjsjfFLZOXGFJGqz6k8lUk8avFP/5eGEsd/eZe1UIcwtDwcjgJIyQ4p3pIC7jBvfuw=="],
"@scalar/import/yaml": ["yaml@2.6.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="],
"@scalar/oas-utils/@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="],
"@scalar/oas-utils/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="],
"@scalar/oas-utils/yaml": ["yaml@2.6.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="],
"@scalar/openapi-parser/yaml": ["yaml@2.6.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ=="],
"@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, "sha512-6geH9ehvQ/sG/xUyy3e0lyOw3BaY5s6nn22wHjEJhcobdmWyFER0O6m7AU0ZN4QTjle/gYvFJOjj552l/rsNSw=="],
"@scalar/use-toasts/nanoid": ["nanoid@5.0.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ=="],
"@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
+1
View File
@@ -17,6 +17,7 @@
"dev": "turbo run dev --ui=stream",
"build": "turbo run build",
"build:cloudflare": "turbo run build:cloudflare",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
"lint": "turbo run lint",
"lint:fix": "turbo run lint -- --fix",
"typecheck": "turbo run typecheck",
+6 -2
View File
@@ -142,7 +142,9 @@ const testCases: TestsCase[] = [
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
@@ -322,7 +324,9 @@ const testCases: TestsCase[] = [
name: 'AI Search',
url: '?q=What+is+GitBook%3F&ask=true',
run: async (page) => {
await expect(page.getByTestId('search-ask-answer')).toBeVisible();
await expect(page.getByTestId('search-ask-answer')).toBeVisible({
timeout: 15_000,
});
},
screenshot: false,
},
+6 -6
View File
@@ -3,8 +3,8 @@ import {
OpenAPIOperationData,
fetchOpenAPIOperation,
OpenAPIFetcher,
parseOpenAPIV3,
OpenAPIFetchError,
parseOpenAPI,
OpenAPIParseError,
} from '@gitbook/react-openapi';
import { cache, parseCacheResponse, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
@@ -20,7 +20,7 @@ export async function fetchOpenAPIBlock(
resolveContentRef: (ref: ContentRef) => Promise<ResolvedContentRef | null>,
): Promise<
| { data: OpenAPIOperationData | null; specUrl: string | null; error?: undefined }
| { error: OpenAPIFetchError; data?: undefined; specUrl?: undefined }
| { error: OpenAPIParseError; data?: undefined; specUrl?: undefined }
> {
const resolved = block.data.ref ? await resolveContentRef(block.data.ref) : null;
if (!resolved || !block.data.path || !block.data.method) {
@@ -39,7 +39,7 @@ export async function fetchOpenAPIBlock(
return { data, specUrl: resolved.href };
} catch (error) {
if (error instanceof OpenAPIFetchError) {
if (error instanceof OpenAPIParseError) {
return { error };
}
@@ -49,7 +49,7 @@ export async function fetchOpenAPIBlock(
const fetcher: OpenAPIFetcher = {
fetch: cache({
name: 'openapi.fetch',
name: 'openapi.fetch.v2',
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:
@@ -66,7 +66,7 @@ const fetcher: OpenAPIFetcher = {
}
const text = await response.text();
const data = await parseOpenAPIV3(url, text);
const data = await parseOpenAPI({ url, value: text });
return {
...parseCacheResponse(response),
data,
+4 -3
View File
@@ -11,12 +11,13 @@
"version": "0.7.1",
"dependencies": {
"@scalar/api-client-react": "1.0.87",
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
"classnames": "^2.5.1",
"flatted": "^3.2.9",
"openapi-types": "^12.1.3",
"swagger2openapi": "^7.0.8",
"yaml": "1.10.2",
"usehooks-ts": "^3.1.0"
"usehooks-ts": "^3.1.0",
"yaml": "1.10.2"
},
"devDependencies": {
"@types/swagger2openapi": "^7.0.4",
@@ -33,7 +33,7 @@ export function OpenAPICodeSample(props: {
const example = param.schema
? generateSchemaExample(noReference(param.schema))
: undefined;
if (example !== undefined) {
if (example !== undefined && param.name) {
headersObject[param.name] =
typeof example !== 'string' ? stringifyOpenAPI(example) : example;
}
@@ -41,7 +41,7 @@ export function OpenAPICodeSample(props: {
const example = param.schema
? generateSchemaExample(noReference(param.schema))
: undefined;
if (example !== undefined) {
if (example !== undefined && param.name) {
searchParams.append(
param.name,
String(Array.isArray(example) ? example[0] : example),
@@ -51,7 +51,10 @@ export function OpenAPICodeSample(props: {
});
const requestBody = noReference(data.operation.requestBody);
const requestBodyContent = requestBody ? Object.entries(requestBody.content)[0] : undefined;
const requestBodyContentEntries = requestBody?.content
? Object.entries(requestBody.content)
: undefined;
const requestBodyContent = requestBodyContentEntries?.[0];
const input: CodeSampleInput = {
url:
@@ -57,7 +57,7 @@ export function OpenAPIOperation(props: {
</div>
<div className={classNames('openapi-columns')}>
<div className={classNames('openapi-column-spec')}>
<OpenAPISpec rawData={toJSON(data)} context={clientContext} />
<OpenAPISpec data={data} context={clientContext} />
</div>
<div className={classNames('openapi-column-preview')}>
<div className={classNames('openapi-column-preview-body')}>
@@ -1,6 +1,6 @@
import * as React from 'react';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
import { OpenAPIRootSchema } from './OpenAPISchema';
import { noReference } from './utils';
import { OpenAPIClientContext } from './types';
@@ -1,6 +1,6 @@
import * as React from 'react';
import classNames from 'classnames';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
import { OpenAPIRootSchema, OpenAPISchemaProperties } from './OpenAPISchema';
import { noReference } from './utils';
import { OpenAPIClientContext } from './types';
@@ -1,16 +1,16 @@
import * as React from 'react';
import classNames from 'classnames';
import { OpenAPIV3 } from 'openapi-types';
import { createStateKey, noReference } from './utils';
import { OpenAPIResponse } from './OpenAPIResponse';
import { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
/**
* Display an interactive response body.
*/
export function OpenAPIResponses(props: {
responses: OpenAPIV3.ResponsesObject;
responses: OpenAPIV3.ResponsesObject | OpenAPIV3_1.ResponsesObject;
context: OpenAPIClientContext;
}) {
const { responses, context } = props;
@@ -1,6 +1,6 @@
import { it, describe, expect } from 'bun:test';
import { getSchemaAlternatives } from './OpenAPISchema';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
describe('getSchemaAlternatives', () => {
it('should flatten oneOf', () => {
+2 -7
View File
@@ -1,10 +1,9 @@
import classNames from 'classnames';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
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';
import { stringifyOpenAPI } from './stringifyOpenAPI';
@@ -362,7 +361,7 @@ function getSchemaTitle(
}
// Try using the discriminator
if (discriminator && schema.properties) {
if (discriminator?.propertyName && schema.properties) {
const discriminatorProperty = noReference(schema.properties[discriminator.propertyName]);
if (discriminatorProperty) {
if (discriminatorProperty.enum) {
@@ -395,10 +394,6 @@ function getSchemaTitle(
type = 'not';
}
if (SYMBOL_REF_RESOLVED in schema) {
type = `${schema[SYMBOL_REF_RESOLVED]} (${type})`;
}
if (schema.nullable) {
type = `nullable ${type}`;
}
@@ -1,5 +1,5 @@
import * as React from 'react';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
@@ -47,7 +47,7 @@ export function OpenAPISecurities(props: {
);
}
function getLabelForType(security: OpenAPIV3.SecuritySchemeObject) {
function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject): string {
switch (security.type) {
case 'apiKey':
return 'API Key';
@@ -1,5 +1,5 @@
import * as React from 'react';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
import { OpenAPIServerURLVariable } from './OpenAPIServerURLVariable';
/**
@@ -2,7 +2,7 @@
import * as React from 'react';
import classNames from 'classnames';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
/**
* Interactive component to show the value of a server variable and let the user change it.
+9 -9
View File
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3, OpenAPIV2, OpenAPI } from '@scalar/openapi-types';
import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
import { InteractiveSection } from './InteractiveSection';
@@ -18,13 +18,13 @@ import { noReference } from './utils';
* We use a client component as rendering recursive JSON schema in the server is expensive
* (the entire schema is rendered at once, while the client component only renders the visible part)
*/
export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext }) {
const { rawData, context } = props;
export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAPIClientContext }) {
const { data, context } = props;
const parsedData = fromJSON(rawData) as OpenAPIOperationData;
const { operation, securities } = parsedData;
const { operation, securities } = data;
const parameterGroups = groupParameters((operation.parameters || []).map(noReference));
const parameters = operation.parameters ?? [];
const parameterGroups = groupParameters(parameters);
return (
<>
@@ -72,17 +72,17 @@ export function OpenAPISpec(props: { rawData: any; context: OpenAPIClientContext
);
}
function groupParameters(parameters: OpenAPIV3.ParameterObject[]): Array<{
function groupParameters(parameters: OpenAPI.Parameters): Array<{
key: string;
label: string;
parameters: OpenAPIV3.ParameterObject[];
parameters: OpenAPI.Parameters;
}> {
const sorted = ['path', 'query', 'header'];
const groups: Array<{
key: string;
label: string;
parameters: OpenAPIV3.ParameterObject[];
parameters: OpenAPI.Parameters;
}> = [];
parameters.forEach((parameter) => {
@@ -1,12 +1,12 @@
import { it, expect } from 'bun:test';
import { fetchOpenAPIOperation, parseOpenAPIV3 } from './fetchOpenAPIOperation';
import { OpenAPIFetcher } from './types';
import { fetchOpenAPIOperation, OpenAPIFetcher } from './fetchOpenAPIOperation';
import { parseOpenAPI } from './parser';
const fetcher: OpenAPIFetcher = {
fetch: async (url) => {
const response = await fetch(url);
return parseOpenAPIV3(url, await response.text());
return parseOpenAPI({ value: await response.text(), url });
},
};
@@ -1,10 +1,27 @@
import { toJSON, fromJSON } from 'flatted';
import { OpenAPIV3 } from 'openapi-types';
import YAML from 'yaml';
import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi';
import { resolveOpenAPIPath } from './resolveOpenAPIPath';
import { OpenAPIFetcher } from './types';
import { OpenAPICustomSpecProperties } from './parser';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { noReference } from './utils';
import { traverse } from './parser/traverse';
import { AnyObject } from '@scalar/openapi-parser';
export interface OpenAPIFetcher {
/**
* Fetch an OpenAPI file by its URL. It should return a fully parsed OpenAPI v3 document.
*/
fetch: (
url: string,
) => Promise<
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
>;
/**
* Parse markdown to the react element to render.
*/
parseMarkdown?: (input: string) => Promise<string>;
}
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
path: string;
@@ -14,56 +31,12 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
servers: OpenAPIV3.ServerObject[];
/** Spec of the operation */
operation: OpenAPIV3.OperationObject & OpenAPICustomOperationProperties;
operation: OpenAPIV3.OperationObject;
/** Securities that should be used for this operation */
securities: [string, OpenAPIV3.SecuritySchemeObject][];
}
/**
* Custom properties that can be defined at the entire spec level.
*/
export interface OpenAPICustomSpecProperties {
/**
* If `true`, code samples will not be displayed.
* This option can be used to hide code samples for the entire spec.
*/
'x-codeSamples'?: boolean;
/**
* If `true`, the "Try it" button will not be displayed.
* This option can be used to hide code samples for the entire spec.
*/
'x-hideTryItPanel'?: boolean;
}
/**
* Custom properties that can be defined at the operation level.
* These properties are not part of the OpenAPI spec.
*/
export interface OpenAPICustomOperationProperties {
'x-code-samples'?: OpenAPICustomCodeSample[];
'x-codeSamples'?: OpenAPICustomCodeSample[] | false;
'x-custom-examples'?: OpenAPICustomCodeSample[];
/**
* If `true`, the "Try it" button will not be displayed.
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/
*/
'x-hideTryItPanel'?: boolean;
}
/**
* Custom code samples that can be defined at the operation level.
* It follows the spec defined by Redocly.
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/
*/
export interface OpenAPICustomCodeSample {
lang: string;
label: string;
source: string;
}
export { toJSON, fromJSON };
/**
@@ -79,24 +52,22 @@ export async function fetchOpenAPIOperation(
): Promise<OpenAPIOperationData | null> {
const fetcher = cacheFetcher(rawFetcher);
let operation = await resolveOpenAPIPath<OpenAPIV3.OperationObject>(
input.url,
['paths', input.path, input.method],
fetcher,
);
const schema = await fetcher.fetch(input.url);
let operation = getOperationByPathAndMethod(schema, input.path, input.method);
if (!operation) {
return null;
}
const specData = await fetcher.fetch(input.url);
// Parse description in markdown
const { parseMarkdown } = fetcher;
if (parseMarkdown) {
operation = await parseDescriptions(operation, parseMarkdown);
}
// Resolve common parameters
const commonParameters = await resolveOpenAPIPath<OpenAPIV3.ParameterObject[]>(
input.url,
['paths', input.path, 'parameters'],
fetcher,
);
const commonParameters = getPathObjectParameter(schema, input.path);
if (commonParameters) {
operation = {
...operation,
@@ -104,44 +75,108 @@ export async function fetchOpenAPIOperation(
};
}
// Resolve servers
const servers = await resolveOpenAPIPath<OpenAPIV3.ServerObject[]>(
input.url,
['servers'],
fetcher,
);
const servers = 'servers' in schema ? (schema.servers ?? []) : [];
const security = operation.security ?? schema.security ?? [];
// Resolve securities
const securities: OpenAPIOperationData['securities'] = [];
for (const security of operation.security ?? []) {
const securityKey = Object.keys(security)[0];
const securityScheme = await resolveOpenAPIPath<OpenAPIV3.SecuritySchemeObject>(
input.url,
['components', 'securitySchemes', securityKey],
fetcher,
);
for (const entry of security) {
const securityKey = Object.keys(entry)[0];
const securityScheme = schema.components?.securitySchemes?.[securityKey];
if (securityScheme) {
securities.push([securityKey, securityScheme]);
securities.push([securityKey, noReference(securityScheme)]);
}
}
return {
servers: servers ?? [],
servers,
operation,
method: input.method,
path: input.path,
securities,
'x-codeSamples':
typeof specData['x-codeSamples'] === 'boolean' ? specData['x-codeSamples'] : undefined,
typeof schema['x-codeSamples'] === 'boolean' ? schema['x-codeSamples'] : undefined,
'x-hideTryItPanel':
typeof specData['x-hideTryItPanel'] === 'boolean'
? specData['x-hideTryItPanel']
typeof schema['x-hideTryItPanel'] === 'boolean'
? schema['x-hideTryItPanel']
: undefined,
};
}
async function parseDescriptions<T extends AnyObject>(
spec: T,
parseMarkdown: (input: string) => Promise<string>,
): Promise<T> {
const promises: Record<string, Promise<string>> = {};
const results: Record<string, string> = {};
traverse(spec, (obj) => {
if ('description' in obj && typeof obj.description === 'string') {
promises[obj.description] = parseMarkdown(obj.description);
}
return obj;
});
await Promise.all(
Object.entries(promises).map(async ([key, promise]) => {
results[key] = await promise;
}),
);
return traverse(spec, (obj) => {
if ('description' in obj && typeof obj.description === 'string') {
obj.description = results[obj.description];
}
return obj;
}) as T;
}
/**
* Get a path object from its path.
*/
function getPathObject(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
path: string,
): OpenAPIV3.PathItemObject | OpenAPIV3_1.PathItemObject | null {
if (schema.paths?.[path]) {
return schema.paths[path];
}
return null;
}
/**
* Resolve parameters from a path in an OpenAPI schema.
*/
function getPathObjectParameter(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
path: string,
): OpenAPIV3.ParameterObject[] | OpenAPIV3_1.ParameterObject[] | null {
const pathObject = getPathObject(schema, path);
if (pathObject?.parameters) {
return pathObject.parameters.map(noReference) as
| OpenAPIV3.ParameterObject[]
| OpenAPIV3_1.ParameterObject[];
}
return null;
}
/**
* Get an operation by its path and method.
*/
function getOperationByPathAndMethod(
schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
path: string,
method: string,
): OpenAPIV3.OperationObject | null {
// Types are buffy for OpenAPIV3_1.OperationObject, so we use v3
const pathObject = getPathObject(schema, path);
if (!pathObject) {
return null;
}
const normalizedMethod = method.toLowerCase();
if (!pathObject[normalizedMethod]) {
return null;
}
return pathObject[normalizedMethod];
}
function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher {
const cache = new Map<string, Promise<any>>();
@@ -158,73 +193,3 @@ function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher {
parseMarkdown: fetcher.parseMarkdown,
};
}
/**
* Parse a raw string into an OpenAPI document.
* It will also convert Swagger 2.0 to OpenAPI 3.0.
* It can throw an `OpenAPIFetchError` if the document is invalid.
*/
export async function parseOpenAPIV3(url: string, text: string): Promise<OpenAPIV3.Document> {
// Parse the JSON or YAML
let data: unknown;
// Try with JSON
try {
data = JSON.parse(text);
} catch (jsonError) {
try {
// Try with YAML
data = YAML.parse(text);
} catch (yamlError) {
if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) {
throw new OpenAPIFetchError('Failed to parse YAML: ' + yamlError.message, url);
} else {
throw yamlError;
}
}
}
// Convert Swagger 2.0 to OpenAPI 3.0
// @ts-ignore
if (data && data.swagger) {
try {
// Convert Swagger 2.0 to OpenAPI 3.0
// @ts-ignore
const result = (await swagger2openapi.convertObj(data, {
resolve: false,
resolveInternal: false,
laxDefaults: true,
laxurls: true,
lint: false,
prevalidate: false,
anchors: true,
patch: true,
})) as ConvertOutputOptions;
data = result.openapi;
} catch (error) {
if ((error as Error).name === 'S2OError') {
throw new OpenAPIFetchError(
'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message,
url,
);
} else {
throw error;
}
}
}
// @ts-ignore
return data;
}
export class OpenAPIFetchError extends Error {
public name = 'OpenAPIFetchError';
constructor(
message: string,
public readonly url: string,
) {
super(message);
}
}
@@ -1,4 +1,4 @@
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
import { noReference } from './utils';
type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue };
+1 -1
View File
@@ -1,4 +1,4 @@
export * from './fetchOpenAPIOperation';
export * from './OpenAPIOperation';
export type { OpenAPIFetcher } from './types';
export * from './OpenAPIOperationContext';
export * from './parser';
@@ -0,0 +1,11 @@
export class OpenAPIParseError extends Error {
public name = 'OpenAPIParseError';
constructor(
message: string,
public readonly url: string,
public readonly code?: 'invalid-spec' | 'v2-spec',
) {
super(message);
}
}
@@ -0,0 +1,22 @@
import { OpenAPIParseError } from './error';
import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
import { parseOpenAPIV3 } from './v3';
/**
* Parse a raw string into an OpenAPI document.
* 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 }) {
try {
return await parseOpenAPIV3(input);
} catch (error) {
if (error instanceof OpenAPIParseError && error.code === 'v2-spec') {
return convertOpenAPIV2ToOpenAPIV3(input);
}
throw error;
}
}
export type * from './types';
export * from './error';
@@ -0,0 +1,38 @@
import { AnyObject } from '@scalar/openapi-parser';
/**
* Recursively traverses the specification and applies the transform function to each node.
* The one from @scalar/openapi-parser does not support recursion.
*/
export function traverse(
specification: AnyObject,
transform: (specification: AnyObject, path?: string[]) => AnyObject,
path: string[] = [],
seen = new WeakSet(),
) {
const result: AnyObject = {};
if (seen.has(specification)) {
return specification;
}
seen.add(specification);
for (const [key, value] of Object.entries(specification)) {
const currentPath = [...path, key];
if (Array.isArray(value)) {
result[key] = value.map((item, index) => {
if (typeof item === 'object' && !Array.isArray(item) && item !== null) {
return traverse(item, transform, [...currentPath, index.toString()], seen);
}
return item;
});
} else if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
result[key] = traverse(value, transform, currentPath, seen);
} else {
result[key] = value;
}
}
return transform(result, path);
}
@@ -0,0 +1,45 @@
export type IconComponent = React.ComponentType<{ className?: string }>;
/**
* Custom properties that can be defined at the entire spec level.
*/
export interface OpenAPICustomSpecProperties {
/**
* If `true`, code samples will not be displayed.
* This option can be used to hide code samples for the entire spec.
*/
'x-codeSamples'?: boolean;
/**
* If `true`, the "Try it" button will not be displayed.
* This option can be used to hide code samples for the entire spec.
*/
'x-hideTryItPanel'?: boolean;
}
/**
* Custom properties that can be defined at the operation level.
* These properties are not part of the OpenAPI spec.
*/
export interface OpenAPICustomOperationProperties {
'x-code-samples'?: OpenAPICustomCodeSample[];
'x-codeSamples'?: OpenAPICustomCodeSample[] | false;
'x-custom-examples'?: OpenAPICustomCodeSample[];
/**
* If `true`, the "Try it" button will not be displayed.
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/
*/
'x-hideTryItPanel'?: boolean;
}
/**
* Custom code samples that can be defined at the operation level.
* It follows the spec defined by Redocly.
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/
*/
export interface OpenAPICustomCodeSample {
lang: string;
label: string;
source: string;
}
+70
View File
@@ -0,0 +1,70 @@
import YAML from 'yaml';
import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi';
import { OpenAPICustomSpecProperties } from './types';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIParseError } from './error';
import { parseOpenAPIV3 } from './v3';
import { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
/**
* Convert a Swagger 2.0 schema to an OpenAPI 3.0 schema.
*/
export async function convertOpenAPIV2ToOpenAPIV3(input: {
value: AnyApiDefinitionFormat;
url: string;
}): Promise<
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
> {
const { value, url } = input;
// In this case we want the raw value to be able to convert it.
const schema = typeof value === 'string' ? rawParseOpenAPI({ value, url }) : value;
try {
// @ts-expect-error Types are incompatible between the two libraries
const convertResult = (await swagger2openapi.convertObj(schema, {
resolve: false,
resolveInternal: false,
laxDefaults: true,
laxurls: true,
lint: false,
prevalidate: false,
anchors: true,
patch: true,
})) as ConvertOutputOptions;
return parseOpenAPIV3({ url, 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,
);
} else {
throw error;
}
}
}
/**
* 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;
// Try with JSON
try {
return JSON.parse(value);
} catch (jsonError) {
try {
// Try with YAML
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 yamlError;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { OpenAPICustomSpecProperties } from './types';
import { AnyApiDefinitionFormat, dereference } from '@scalar/openapi-parser';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIParseError } from './error';
/**
* Parse a raw string into an OpenAPI document.
* It will also convert Swagger 2.0 to OpenAPI 3.0.
* It can throw an `OpenAPIFetchError` if the document is invalid.
*/
export async function parseOpenAPIV3(input: {
value: AnyApiDefinitionFormat;
url: string;
}): Promise<
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
> {
const { value, url } = input;
const result = await dereference(value);
// Spec is invalid, we stop here.
if (!result.schema) {
throw new OpenAPIParseError('Invalid OpenAPI document', url, 'invalid-spec');
}
switch (result.version) {
case '2.0':
throw new OpenAPIParseError('Only OpenAPI v3 is supported', url, 'v2-spec');
case '3.0':
return result.schema as OpenAPIV3.Document<OpenAPICustomSpecProperties>;
case '3.1':
default:
return result.schema as OpenAPIV3_1.Document<OpenAPICustomSpecProperties>;
}
}
@@ -1,60 +0,0 @@
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);
});
@@ -1,145 +0,0 @@
import { OpenAPIFetcher } from './types';
export 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;
}
+1 -15
View File
@@ -1,20 +1,6 @@
import { SYMBOL_MARKDOWN_PARSED, SYMBOL_REF_RESOLVED } from './resolveOpenAPIPath';
/**
* Stringify an OpenAPI object. Same API as JSON.stringify.
*/
export function stringifyOpenAPI(body: unknown, transformer?: null, indent?: number): string {
return JSON.stringify(
body,
(_key, value) => {
if (value && !Array.isArray(value) && typeof value === 'object') {
// Extract out internal keys used in parsing
const { [SYMBOL_MARKDOWN_PARSED]: _, [SYMBOL_REF_RESOLVED]: __, ...rest } = value;
return rest;
}
return value;
},
indent,
);
return JSON.stringify(body, transformer, indent);
}
-14
View File
@@ -1,5 +1,3 @@
export type IconComponent = React.ComponentType<{ className?: string }>;
export interface OpenAPIContextProps extends OpenAPIClientContext {
CodeBlock: React.ComponentType<{ code: string; syntax: string }>;
@@ -25,15 +23,3 @@ export interface OpenAPIClientContext {
/** Optional id attached to the OpenAPI Operation heading and used as an anchor */
id?: string;
}
export interface OpenAPIFetcher {
/**
* Fetch an OpenAPI file by its URL. It should return a fully parsed OpenAPI v3 document.
*/
fetch: (url: string) => Promise<any>;
/**
* Parse markdown to the react element to render.
*/
parseMarkdown?: (input: string) => Promise<string>;
}
+6 -2
View File
@@ -1,13 +1,17 @@
import { OpenAPIV3 } from 'openapi-types';
import { OpenAPIV3 } from '@scalar/openapi-types';
export function noReference<T>(input: T | OpenAPIV3.ReferenceObject): T {
if (typeof input === 'object' && !!input && '$ref' in input) {
if (checkIsReference(input)) {
throw new Error('Reference found');
}
return input;
}
function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceObject {
return typeof input === 'object' && !!input && '$ref' in input;
}
export function createStateKey(key: string, scope?: string) {
return scope ? `${scope}_${key}` : key;
}