mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-12 05:48:57 +00:00
Use Scalar OpenAPI parser (#2780)
This commit is contained in:
@@ -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`
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user