Refactor and optimize OpenAPI parsing (#2830)

This commit is contained in:
Greg Bergé
2025-02-13 16:30:34 +01:00
committed by GitHub
parent 9f30af1b33
commit 727bde2d16
65 changed files with 2239 additions and 407 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@gitbook/openapi-parser': major
'@gitbook/react-openapi': major
'gitbook': patch
---
Improve and split OpenAPI parser into its own package
+33 -7
View File
@@ -37,6 +37,7 @@
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
@@ -148,6 +149,23 @@
"react": "*",
},
},
"packages/openapi-parser": {
"name": "@gitbook/openapi-parser",
"version": "0.0.0",
"dependencies": {
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
"swagger2openapi": "^7.0.8",
"yaml": "1.10.2",
},
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"@tsconfig/strictest": "^2.0.5",
"@types/swagger2openapi": "^7.0.4",
"bun-types": "^1.1.20",
"typescript": "^5.5.3",
},
},
"packages/proxy": {
"name": "@gitbook/proxy",
"version": "0.1.0",
@@ -191,20 +209,16 @@
"name": "@gitbook/react-openapi",
"version": "0.7.1",
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "1.0.87",
"@scalar/oas-utils": "^0.2.101",
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"react-aria": "^3.37.0",
"react-aria-components": "^1.6.0",
"swagger2openapi": "^7.0.8",
"usehooks-ts": "^3.1.0",
"yaml": "1.10.2",
},
"devDependencies": {
"@types/swagger2openapi": "^7.0.4",
"bun-types": "^1.1.20",
"typescript": "^5.5.3",
},
@@ -589,6 +603,8 @@
"@gitbook/icons": ["@gitbook/icons@workspace:packages/icons"],
"@gitbook/openapi-parser": ["@gitbook/openapi-parser@workspace:packages/openapi-parser"],
"@gitbook/proxy": ["@gitbook/proxy@workspace:packages/proxy"],
"@gitbook/react-contentkit": ["@gitbook/react-contentkit@workspace:packages/react-contentkit"],
@@ -1327,6 +1343,10 @@
"@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="],
"@tsconfig/node20": ["@tsconfig/node20@20.1.4", "", {}, "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg=="],
"@tsconfig/strictest": ["@tsconfig/strictest@2.0.5", "", {}, "sha512-ec4tjL2Rr0pkZ5hww65c+EEPYwxOi4Ryv+0MtjeaSQRJyq322Q27eOQiFbuNgw2hpL4hB1/W/HBGk3VKS43osg=="],
"@types/connect": ["@types/connect@3.4.36", "", { "dependencies": { "@types/node": "*" } }, "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
@@ -1701,7 +1721,7 @@
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
"clsx": ["clsx@2.0.0", "", {}, "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
@@ -4219,6 +4239,10 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
"@react-aria/focus/clsx": ["clsx@2.0.0", "", {}, "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q=="],
"@react-aria/utils/clsx": ["clsx@2.0.0", "", {}, "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q=="],
"@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/oas-utils": ["@scalar/oas-utils@0.2.75", "", { "dependencies": { "@hyperjump/json-schema": "^1.9.6", "@scalar/object-utils": "1.1.12", "@scalar/openapi-types": "0.1.5", "@scalar/themes": "0.9.48", "@scalar/types": "0.0.19", "flatted": "^3.3.1", "microdiff": "^1.4.0", "nanoid": "^5.0.7", "yaml": "^2.4.5", "zod": "^3.23.8" } }, "sha512-deBH359aA9hO+QzdcdJkd0KpZFlSf9xYf+58nl+sVOQL+uEay/LAn1kO+iiE0JAkL78wlW+nqje9sVBKEAdVdw=="],
@@ -4551,6 +4575,8 @@
"convict/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"cva/clsx": ["clsx@2.0.0", "", {}, "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q=="],
"decamelize-keys/map-obj": ["map-obj@1.0.1", "", {}, "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg=="],
"deep-equal/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
+2 -2
View File
@@ -15,8 +15,8 @@
},
"private": true,
"scripts": {
"dev": "turbo run dev --ui=stream",
"dev:v2": "turbo run dev:v2 --ui=stream",
"dev": "turbo run dev",
"dev:v2": "turbo run dev:v2",
"build": "turbo run build",
"build:v2": "turbo run build:v2",
"clean-deps": "rm -rf node_modules && rm -rf packages/*/node_modules",
+1
View File
@@ -20,6 +20,7 @@
"@gitbook/cache-do": "workspace:*",
"@gitbook/emoji-codepoints": "workspace:*",
"@gitbook/icons": "workspace:*",
"@gitbook/openapi-parser": "workspace:*",
"@gitbook/react-contentkit": "workspace:*",
"@gitbook/react-math": "workspace:*",
"@gitbook/react-openapi": "workspace:*",
+21 -5
View File
@@ -1,10 +1,9 @@
import { ContentRef, DocumentBlockOpenAPI } from '@gitbook/api';
import { parseOpenAPI, OpenAPIParseError, traverse } from '@gitbook/openapi-parser';
import {
OpenAPIOperationData,
fetchOpenAPIOperation,
OpenAPIFetcher,
parseOpenAPI,
OpenAPIParseError,
} from '@gitbook/react-openapi';
import { cache, noCacheFetchOptions, CacheFunctionOptions } from '@/lib/cache';
@@ -49,7 +48,7 @@ export async function fetchOpenAPIBlock(
const fetcher: OpenAPIFetcher = {
fetch: cache({
name: 'openapi.fetch.v4',
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:
@@ -66,13 +65,30 @@ const fetcher: OpenAPIFetcher = {
}
const text = await response.text();
const data = await parseOpenAPI({ url, value: text, parseMarkdown });
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;
}
}
return node;
});
return {
// Cache for 4 hours
ttl: 24 * 60 * 60,
// Revalidate every 2 hours
revalidateBefore: 22 * 60 * 60,
data,
data: transformedFs,
};
},
}),
+6 -1
View File
@@ -26,5 +26,10 @@
]
},
"include": ["next-env.d.ts", "cf-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "packages/react-openapi", "packages/react-math"]
"exclude": [
"node_modules",
"packages/openapi-parser",
"packages/react-openapi",
"packages/react-math"
]
}
+1
View File
@@ -0,0 +1 @@
dist
+3
View File
@@ -0,0 +1,3 @@
# `@gitbook/openapi-parser`
Modern OpenAPI parser written in TypeScript with support for OpenAPI 3.1, OpenAPI 3.0 and Swagger 2.0.
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@gitbook/openapi-parser",
"description": "Modern OpenAPI parser written in TypeScript with support for OpenAPI 3.1, OpenAPI 3.0 and Swagger 2.0.",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
},
"version": "0.0.0",
"sideEffects": false,
"dependencies": {
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
"swagger2openapi": "^7.0.8",
"yaml": "1.10.2"
},
"devDependencies": {
"@tsconfig/strictest": "^2.0.5",
"@tsconfig/node20": "^20.1.4",
"@types/swagger2openapi": "^7.0.4",
"bun-types": "^1.1.20",
"typescript": "^5.5.3"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"dev": "bun run build -- --watch",
"clean": "rm -rf ./dist"
},
"files": [
"dist",
"src",
"README.md",
"CHANGELOG.md"
]
}
@@ -1,5 +1,8 @@
/**
* Error thrown when the OpenAPI document is invalid.
*/
export class OpenAPIParseError extends Error {
public name = 'OpenAPIParseError';
public override name = 'OpenAPIParseError';
constructor(
message: string,
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { createFileSystem } from './filesystem';
import { Server } from 'bun';
async function serveFixture(fixture: string) {
return new Response(await Bun.file(new URL(`./fixtures/${fixture}`, import.meta.url)).bytes(), {
headers: { 'Content-Type': 'application/yaml' },
});
}
describe('#createFileSystem', () => {
let server: Server;
beforeEach(async () => {
server = Bun.serve({
static: {
'/root/spec.yaml': await serveFixture('/remote-ref/root/spec.yaml'),
'/root/user.yaml': await serveFixture('/remote-ref/root/user.yaml'),
'/root/pet.yaml': await serveFixture('/remote-ref/root/pet.yaml'),
'/tag.yaml': await serveFixture('/remote-ref/tag.yaml'),
},
fetch() {
return new Response('404!');
},
port: 3020,
});
});
afterEach(async () => {
await server.stop();
});
it('creates a filesystem by resolving URLs', async () => {
const url = new URL('/root/spec.yaml', server.url).href;
const filesystem = await createFileSystem({
value: url,
baseUrl: url,
});
expect(filesystem).toHaveLength(4);
expect(filesystem[0]!.isEntrypoint).toBe(true);
expect(filesystem[1]!.isEntrypoint).toBe(false);
expect(filesystem[1]!.filename).toBe('user.yaml');
expect(filesystem[2]!.filename).toBe('../tag.yaml');
expect(filesystem[3]!.filename).toBe('http://localhost:3020/root/pet.yaml');
});
});
+17
View File
@@ -0,0 +1,17 @@
import { type AnyApiDefinitionFormat, load } from '@scalar/openapi-parser';
import { fetchUrls } from './scalar-plugins/fetchURLs';
import type { Filesystem } from './types';
/**
* Create a filesystem from an OpenAPI document.
* Fetches all the URLs specified in references and builds a filesystem.
*/
export async function createFileSystem(input: {
value: AnyApiDefinitionFormat;
baseUrl: string;
}): Promise<Filesystem> {
const { filesystem } = await load(input.value, {
plugins: [fetchUrls({ baseUrl: input.baseUrl })],
});
return filesystem;
}
@@ -0,0 +1,42 @@
components:
schemas:
x-swagger-router-model: io.swagger.petstore.model.Pet
required:
- name
- photoUrls
properties:
id:
type: integer
format: int64
example: 10
name:
type: string
example: doggie
category:
$ref: '#/components/schemas/Category'
photoUrls:
type: array
xml:
wrapped: true
items:
type: string
xml:
name: photoUrl
tags:
type: array
xml:
wrapped: true
items:
$ref: '#/components/schemas/Tag'
xml:
name: tag
status:
type: string
description: pet status in the store
enum:
- available
- pending
- sold
xml:
name: pet
type: object
@@ -0,0 +1,740 @@
openapi: 3.0.2
servers:
- url: /v3
info:
description: |-
This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about
Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!
You can now help us improve the API whether it's by making changes to the definition itself or to the code.
That way, with time, we can improve the API in general, and expose some of the new features in OAS3.
Some useful links:
- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)
- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)
version: 1.0.20-SNAPSHOT
title: Swagger Petstore - OpenAPI 3.0
termsOfService: 'http://swagger.io/terms/'
contact:
email: apiteam@swagger.io
license:
name: Apache 2.0
url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
tags:
- name: pet
description: Everything about your Pets
externalDocs:
description: Find out more
url: 'http://swagger.io'
- name: store
description: Access to Petstore orders
externalDocs:
description: Find out more about our store
url: 'http://swagger.io'
- name: user
description: Operations about user
paths:
/pet:
post:
tags:
- pet
summary: Add a new pet to the store
description: Add a new pet to the store
operationId: addPet
responses:
'200':
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
description: Create a new pet in the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
put:
tags:
- pet
summary: Update an existing pet
description: Update an existing pet by Id
operationId: updatePet
responses:
'200':
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
'405':
description: Validation exception
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
description: Update an existent pet in the store
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Pet'
/pet/findByStatus:
get:
tags:
- pet
summary: Finds Pets by status
description: Multiple status values can be provided with comma separated strings
operationId: findPetsByStatus
parameters:
- name: status
in: query
description: Status values that need to be considered for filter
required: false
explode: true
schema:
type: string
enum:
- available
- pending
- sold
default: available
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid status value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
/pet/findByTags:
get:
tags:
- pet
summary: Finds Pets by tags
description: >-
Multiple tags can be provided with comma separated strings. Use tag1,
tag2, tag3 for testing.
operationId: findPetsByTags
parameters:
- name: tags
in: query
description: Tags to filter by
required: false
explode: true
schema:
type: array
items:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid tag value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}':
get:
tags:
- pet
summary: Find pet by ID
description: Returns a single pet
operationId: getPetById
parameters:
- name: petId
in: path
description: ID of pet to return
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Pet'
application/json:
schema:
$ref: '#/components/schemas/Pet'
'400':
description: Invalid ID supplied
'404':
description: Pet not found
security:
- api_key: []
- petstore_auth:
- 'write:pets'
- 'read:pets'
post:
tags:
- pet
summary: Updates a pet in the store with form data
description: ''
operationId: updatePetWithForm
parameters:
- name: petId
in: path
description: ID of pet that needs to be updated
required: true
schema:
type: integer
format: int64
- name: name
in: query
description: Name of pet that needs to be updated
schema:
type: string
- name: status
in: query
description: Status of pet that needs to be updated
schema:
type: string
responses:
'405':
description: Invalid input
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
delete:
tags:
- pet
summary: Deletes a pet
description: ''
operationId: deletePet
parameters:
- name: api_key
in: header
description: ''
required: false
schema:
type: string
- name: petId
in: path
description: Pet id to delete
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid pet value
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
'/pet/{petId}/uploadImage':
post:
tags:
- pet
summary: uploads an image
description: ''
operationId: uploadFile
parameters:
- name: petId
in: path
description: ID of pet to update
required: true
schema:
type: integer
format: int64
- name: additionalMetadata
in: query
description: Additional Metadata
required: false
schema:
type: string
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
security:
- petstore_auth:
- 'write:pets'
- 'read:pets'
requestBody:
content:
application/octet-stream:
schema:
type: string
format: binary
/store/inventory:
get:
tags:
- store
summary: Returns pet inventories by status
description: Returns a map of status codes to quantities
operationId: getInventory
x-swagger-router-controller: OrderController
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
format: int32
security:
- api_key: []
/store/order:
post:
tags:
- store
summary: Place an order for a pet
description: Place a new order in the store
operationId: placeOrder
x-swagger-router-controller: OrderController
responses:
'200':
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'405':
description: Invalid input
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Order'
'/store/order/{orderId}':
get:
tags:
- store
summary: Find purchase order by ID
x-swagger-router-controller: OrderController
description: >-
For valid response try integer IDs with value <= 5 or > 10. Other values
will generate exceptions.
operationId: getOrderById
parameters:
- name: orderId
in: path
description: ID of order that needs to be fetched
required: true
schema:
type: integer
format: int64
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/Order'
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
description: Invalid ID supplied
'404':
description: Order not found
delete:
tags:
- store
summary: Delete purchase order by ID
x-swagger-router-controller: OrderController
description: >-
For valid response try integer IDs with value < 1000. Anything above
1000 or nonintegers will generate API errors
operationId: deleteOrder
parameters:
- name: orderId
in: path
description: ID of the order that needs to be deleted
required: true
schema:
type: integer
format: int64
responses:
'400':
description: Invalid ID supplied
'404':
description: Order not found
/user:
post:
tags:
- user
summary: Create user
description: This can only be done by the logged in user.
operationId: createUser
responses:
default:
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
description: Created user object
/user/createWithList:
post:
tags:
- user
summary: Creates list of users with given input array
description: 'Creates list of users with given input array'
x-swagger-router-controller: UserController
operationId: createUsersWithListInput
responses:
'200':
description: Successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
application/json:
schema:
$ref: '#/components/schemas/User'
default:
description: successful operation
requestBody:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
/user/login:
get:
tags:
- user
summary: Logs user into the system
description: ''
operationId: loginUser
parameters:
- name: username
in: query
description: The user name for login
required: false
schema:
type: string
- name: password
in: query
description: The password for login in clear text
required: false
schema:
type: string
responses:
'200':
description: successful operation
headers:
X-Rate-Limit:
description: calls per hour allowed by the user
schema:
type: integer
format: int32
X-Expires-After:
description: date in UTC when token expires
schema:
type: string
format: date-time
content:
application/xml:
schema:
type: string
application/json:
schema:
type: string
'400':
description: Invalid username/password supplied
/user/logout:
get:
tags:
- user
summary: Logs out current logged in user session
description: ''
operationId: logoutUser
parameters: []
responses:
default:
description: successful operation
'/user/{username}':
get:
tags:
- user
summary: Get user by user name
description: ''
operationId: getUserByName
parameters:
- name: username
in: path
description: 'The name that needs to be fetched. Use user1 for testing. '
required: true
schema:
type: string
responses:
'200':
description: successful operation
content:
application/xml:
schema:
$ref: '#/components/schemas/User'
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
description: Invalid username supplied
'404':
description: User not found
put:
tags:
- user
summary: Update user
x-swagger-router-controller: UserController
description: This can only be done by the logged in user.
operationId: updateUser
parameters:
- name: username
in: path
description: name that needs to be updated
required: true
schema:
type: string
responses:
default:
description: successful operation
requestBody:
description: Update an existent user in the store
content:
application/json:
schema:
$ref: '#/components/schemas/User'
application/xml:
schema:
$ref: '#/components/schemas/User'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/User'
delete:
tags:
- user
summary: Delete user
description: This can only be done by the logged in user.
operationId: deleteUser
parameters:
- name: username
in: path
description: The name that needs to be deleted
required: true
schema:
type: string
responses:
'400':
description: Invalid username supplied
'404':
description: User not found
externalDocs:
description: Find out more about Swagger
url: 'http://swagger.io'
components:
schemas:
Order:
x-swagger-router-model: io.swagger.petstore.model.Order
properties:
id:
type: integer
format: int64
example: 10
petId:
type: integer
format: int64
example: 198772
quantity:
type: integer
format: int32
example: 7
shipDate:
type: string
format: date-time
status:
type: string
description: Order Status
enum:
- placed
- approved
- delivered
example: approved
complete:
type: boolean
xml:
name: order
type: object
Customer:
properties:
id:
type: integer
format: int64
example: 100000
username:
type: string
example: fehguy
address:
type: array
items:
$ref: '#/components/schemas/Address'
xml:
wrapped: true
name: addresses
xml:
name: customer
type: object
Address:
properties:
street:
type: string
example: 437 Lytton
city:
type: string
example: Palo Alto
state:
type: string
example: CA
zip:
type: string
example: 94301
xml:
name: address
type: object
Category:
x-swagger-router-model: io.swagger.petstore.model.Category
properties:
id:
type: integer
format: int64
example: 1
name:
type: string
example: Dogs
xml:
name: category
type: object
User:
$ref: 'user.yaml#/components/schemas/User'
Tag:
$ref: '../tag.yaml#/components/schemas/Tag'
Pet:
$ref: 'http://localhost:3020/root/pet.yaml#/components/schemas/Pet'
ApiResponse:
properties:
code:
type: integer
format: int32
type:
type: string
message:
type: string
xml:
name: '##default'
type: object
requestBodies:
Pet:
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
application/xml:
schema:
$ref: '#/components/schemas/Pet'
description: Pet object that needs to be added to the store
UserArray:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
description: List of user object
securitySchemes:
petstore_auth:
type: oauth2
flows:
implicit:
authorizationUrl: 'https://petstore.swagger.io/oauth/authorize'
scopes:
'write:pets': modify pets in your account
'read:pets': read your pets
api_key:
type: apiKey
name: api_key
in: header
@@ -0,0 +1,35 @@
components:
schemas:
User:
x-swagger-router-model: io.swagger.petstore.model.User
properties:
id:
type: integer
format: int64
example: 10
username:
type: string
example: theUser
firstName:
type: string
example: John
lastName:
type: string
example: James
email:
type: string
example: john@email.com
password:
type: string
example: 12345
phone:
type: string
example: 12345
userStatus:
type: integer
format: int32
example: 1
description: User Status
xml:
name: user
type: object
@@ -0,0 +1,13 @@
components:
schemas:
Tag:
x-swagger-router-model: io.swagger.petstore.model.Tag
properties:
id:
type: integer
format: int64
name:
type: string
xml:
name: tag
type: object
@@ -0,0 +1,643 @@
{
"swagger": "2.0",
"info": {
"description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.",
"version": "1.0.7",
"title": "Swagger Petstore",
"termsOfService": "http://swagger.io/terms/",
"contact": { "email": "apiteam@swagger.io" },
"license": {
"name": "Apache 2.0",
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
}
},
"host": "petstore.swagger.io",
"basePath": "/v2",
"tags": [
{
"name": "pet",
"description": "Everything about your Pets",
"externalDocs": { "description": "Find out more", "url": "http://swagger.io" }
},
{ "name": "store", "description": "Access to Petstore orders" },
{
"name": "user",
"description": "Operations about user",
"externalDocs": {
"description": "Find out more about our store",
"url": "http://swagger.io"
}
}
],
"schemes": ["https", "http"],
"paths": {
"/pet/{petId}/uploadImage": {
"post": {
"tags": ["pet"],
"summary": "uploads an image",
"description": "",
"operationId": "uploadFile",
"consumes": ["multipart/form-data"],
"produces": ["application/json"],
"parameters": [
{
"name": "petId",
"in": "path",
"description": "ID of pet to update",
"required": true,
"type": "integer",
"format": "int64"
},
{
"name": "additionalMetadata",
"in": "formData",
"description": "Additional data to pass to server",
"required": false,
"type": "string"
},
{
"name": "file",
"in": "formData",
"description": "file to upload",
"required": false,
"type": "file"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "$ref": "#/definitions/ApiResponse" }
}
},
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
}
},
"/pet": {
"post": {
"tags": ["pet"],
"summary": "Add a new pet to the store",
"description": "",
"operationId": "addPet",
"consumes": ["application/json", "application/xml"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"in": "body",
"name": "body",
"description": "Pet object that needs to be added to the store",
"required": true,
"schema": { "$ref": "#/definitions/Pet" }
}
],
"responses": { "405": { "description": "Invalid input" } },
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
},
"put": {
"tags": ["pet"],
"summary": "Update an existing pet",
"description": "",
"operationId": "updatePet",
"consumes": ["application/json", "application/xml"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"in": "body",
"name": "body",
"description": "Pet object that needs to be added to the store",
"required": true,
"schema": { "$ref": "#/definitions/Pet" }
}
],
"responses": {
"400": { "description": "Invalid ID supplied" },
"404": { "description": "Pet not found" },
"405": { "description": "Validation exception" }
},
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
}
},
"/pet/findByStatus": {
"get": {
"tags": ["pet"],
"summary": "Finds Pets by status",
"description": "Multiple status values can be provided with comma separated strings",
"operationId": "findPetsByStatus",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "status",
"in": "query",
"description": "Status values that need to be considered for filter",
"required": true,
"type": "array",
"items": {
"type": "string",
"enum": ["available", "pending", "sold"],
"default": "available"
},
"collectionFormat": "multi"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "type": "array", "items": { "$ref": "#/definitions/Pet" } }
},
"400": { "description": "Invalid status value" }
},
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
}
},
"/pet/findByTags": {
"get": {
"tags": ["pet"],
"summary": "Finds Pets by tags",
"description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.",
"operationId": "findPetsByTags",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "tags",
"in": "query",
"description": "Tags to filter by",
"required": true,
"type": "array",
"items": { "type": "string" },
"collectionFormat": "multi"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "type": "array", "items": { "$ref": "#/definitions/Pet" } }
},
"400": { "description": "Invalid tag value" }
},
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }],
"deprecated": true
}
},
"/pet/{petId}": {
"get": {
"tags": ["pet"],
"summary": "Find pet by ID",
"description": "Returns a single pet",
"operationId": "getPetById",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "petId",
"in": "path",
"description": "ID of pet to return",
"required": true,
"type": "integer",
"format": "int64"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "$ref": "#/definitions/Pet" }
},
"400": { "description": "Invalid ID supplied" },
"404": { "description": "Pet not found" }
},
"security": [{ "api_key": [] }]
},
"post": {
"tags": ["pet"],
"summary": "Updates a pet in the store with form data",
"description": "",
"operationId": "updatePetWithForm",
"consumes": ["application/x-www-form-urlencoded"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "petId",
"in": "path",
"description": "ID of pet that needs to be updated",
"required": true,
"type": "integer",
"format": "int64"
},
{
"name": "name",
"in": "formData",
"description": "Updated name of the pet",
"required": false,
"type": "string"
},
{
"name": "status",
"in": "formData",
"description": "Updated status of the pet",
"required": false,
"type": "string"
}
],
"responses": { "405": { "description": "Invalid input" } },
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
},
"delete": {
"tags": ["pet"],
"summary": "Deletes a pet",
"description": "",
"operationId": "deletePet",
"produces": ["application/json", "application/xml"],
"parameters": [
{ "name": "api_key", "in": "header", "required": false, "type": "string" },
{
"name": "petId",
"in": "path",
"description": "Pet id to delete",
"required": true,
"type": "integer",
"format": "int64"
}
],
"responses": {
"400": { "description": "Invalid ID supplied" },
"404": { "description": "Pet not found" }
},
"security": [{ "petstore_auth": ["write:pets", "read:pets"] }]
}
},
"/store/inventory": {
"get": {
"tags": ["store"],
"summary": "Returns pet inventories by status",
"description": "Returns a map of status codes to quantities",
"operationId": "getInventory",
"produces": ["application/json"],
"parameters": [],
"responses": {
"200": {
"description": "successful operation",
"schema": {
"type": "object",
"additionalProperties": { "type": "integer", "format": "int32" }
}
}
},
"security": [{ "api_key": [] }]
}
},
"/store/order": {
"post": {
"tags": ["store"],
"summary": "Place an order for a pet",
"description": "",
"operationId": "placeOrder",
"consumes": ["application/json"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"in": "body",
"name": "body",
"description": "order placed for purchasing the pet",
"required": true,
"schema": { "$ref": "#/definitions/Order" }
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "$ref": "#/definitions/Order" }
},
"400": { "description": "Invalid Order" }
}
}
},
"/store/order/{orderId}": {
"get": {
"tags": ["store"],
"summary": "Find purchase order by ID",
"description": "For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions",
"operationId": "getOrderById",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "orderId",
"in": "path",
"description": "ID of pet that needs to be fetched",
"required": true,
"type": "integer",
"maximum": 10,
"minimum": 1,
"format": "int64"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "$ref": "#/definitions/Order" }
},
"400": { "description": "Invalid ID supplied" },
"404": { "description": "Order not found" }
}
},
"delete": {
"tags": ["store"],
"summary": "Delete purchase order by ID",
"description": "For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors",
"operationId": "deleteOrder",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "orderId",
"in": "path",
"description": "ID of the order that needs to be deleted",
"required": true,
"type": "integer",
"minimum": 1,
"format": "int64"
}
],
"responses": {
"400": { "description": "Invalid ID supplied" },
"404": { "description": "Order not found" }
}
}
},
"/user/createWithList": {
"post": {
"tags": ["user"],
"summary": "Creates list of users with given input array",
"description": "",
"operationId": "createUsersWithListInput",
"consumes": ["application/json"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"in": "body",
"name": "body",
"description": "List of user object",
"required": true,
"schema": { "type": "array", "items": { "$ref": "#/definitions/User" } }
}
],
"responses": { "default": { "description": "successful operation" } }
}
},
"/user/{username}": {
"get": {
"tags": ["user"],
"summary": "Get user by user name",
"description": "",
"operationId": "getUserByName",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "username",
"in": "path",
"description": "The name that needs to be fetched. Use user1 for testing. ",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "successful operation",
"schema": { "$ref": "#/definitions/User" }
},
"400": { "description": "Invalid username supplied" },
"404": { "description": "User not found" }
}
},
"put": {
"tags": ["user"],
"summary": "Updated user",
"description": "This can only be done by the logged in user.",
"operationId": "updateUser",
"consumes": ["application/json"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "username",
"in": "path",
"description": "name that need to be updated",
"required": true,
"type": "string"
},
{
"in": "body",
"name": "body",
"description": "Updated user object",
"required": true,
"schema": { "$ref": "#/definitions/User" }
}
],
"responses": {
"400": { "description": "Invalid user supplied" },
"404": { "description": "User not found" }
}
},
"delete": {
"tags": ["user"],
"summary": "Delete user",
"description": "This can only be done by the logged in user.",
"operationId": "deleteUser",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "username",
"in": "path",
"description": "The name that needs to be deleted",
"required": true,
"type": "string"
}
],
"responses": {
"400": { "description": "Invalid username supplied" },
"404": { "description": "User not found" }
}
}
},
"/user/login": {
"get": {
"tags": ["user"],
"summary": "Logs user into the system",
"description": "",
"operationId": "loginUser",
"produces": ["application/json", "application/xml"],
"parameters": [
{
"name": "username",
"in": "query",
"description": "The user name for login",
"required": true,
"type": "string"
},
{
"name": "password",
"in": "query",
"description": "The password for login in clear text",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "successful operation",
"headers": {
"X-Expires-After": {
"type": "string",
"format": "date-time",
"description": "date in UTC when token expires"
},
"X-Rate-Limit": {
"type": "integer",
"format": "int32",
"description": "calls per hour allowed by the user"
}
},
"schema": { "type": "string" }
},
"400": { "description": "Invalid username/password supplied" }
}
}
},
"/user/logout": {
"get": {
"tags": ["user"],
"summary": "Logs out current logged in user session",
"description": "",
"operationId": "logoutUser",
"produces": ["application/json", "application/xml"],
"parameters": [],
"responses": { "default": { "description": "successful operation" } }
}
},
"/user/createWithArray": {
"post": {
"tags": ["user"],
"summary": "Creates list of users with given input array",
"description": "",
"operationId": "createUsersWithArrayInput",
"consumes": ["application/json"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"in": "body",
"name": "body",
"description": "List of user object",
"required": true,
"schema": { "type": "array", "items": { "$ref": "#/definitions/User" } }
}
],
"responses": { "default": { "description": "successful operation" } }
}
},
"/user": {
"post": {
"tags": ["user"],
"summary": "Create user",
"description": "This can only be done by the logged in user.",
"operationId": "createUser",
"consumes": ["application/json"],
"produces": ["application/json", "application/xml"],
"parameters": [
{
"in": "body",
"name": "body",
"description": "Created user object",
"required": true,
"schema": { "$ref": "#/definitions/User" }
}
],
"responses": { "default": { "description": "successful operation" } }
}
}
},
"securityDefinitions": {
"api_key": { "type": "apiKey", "name": "api_key", "in": "header" },
"petstore_auth": {
"type": "oauth2",
"authorizationUrl": "https://petstore.swagger.io/oauth/authorize",
"flow": "implicit",
"scopes": { "read:pets": "read your pets", "write:pets": "modify pets in your account" }
}
},
"definitions": {
"ApiResponse": {
"type": "object",
"properties": {
"code": { "type": "integer", "format": "int32" },
"type": { "type": "string" },
"message": { "type": "string" }
}
},
"Category": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"name": { "type": "string" }
},
"xml": { "name": "Category" }
},
"Pet": {
"type": "object",
"required": ["name", "photoUrls"],
"properties": {
"id": { "type": "integer", "format": "int64" },
"category": { "$ref": "#/definitions/Category" },
"name": { "type": "string", "example": "doggie" },
"photoUrls": {
"type": "array",
"xml": { "wrapped": true },
"items": { "type": "string", "xml": { "name": "photoUrl" } }
},
"tags": {
"type": "array",
"xml": { "wrapped": true },
"items": { "xml": { "name": "tag" }, "$ref": "#/definitions/Tag" }
},
"status": {
"type": "string",
"description": "pet status in the store",
"enum": ["available", "pending", "sold"]
}
},
"xml": { "name": "Pet" }
},
"Tag": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"name": { "type": "string" }
},
"xml": { "name": "Tag" }
},
"Order": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"petId": { "type": "integer", "format": "int64" },
"quantity": { "type": "integer", "format": "int32" },
"shipDate": { "type": "string", "format": "date-time" },
"status": {
"type": "string",
"description": "Order Status",
"enum": ["placed", "approved", "delivered"]
},
"complete": { "type": "boolean" }
},
"xml": { "name": "Order" }
},
"User": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int64" },
"username": { "type": "string" },
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"email": { "type": "string" },
"password": { "type": "string" },
"phone": { "type": "string" },
"userStatus": { "type": "integer", "format": "int32", "description": "User Status" }
},
"xml": { "name": "User" }
}
},
"externalDocs": { "description": "Find out more about Swagger", "url": "http://swagger.io" }
}
+8
View File
@@ -0,0 +1,8 @@
export { parseOpenAPI } from './parse';
export type * from '@scalar/openapi-types';
export type * from './types';
export * from './error';
export * from './traverse';
export { dereference } from '@scalar/openapi-parser';
export type { AnyObject } from '@scalar/openapi-parser';
+15
View File
@@ -0,0 +1,15 @@
import { describe, it } from 'bun:test';
import { parseOpenAPI } from './parse';
const spec = await Bun.file(new URL('./fixtures/recursive-spec.json', import.meta.url)).text();
describe('#parseOpenAPI', () => {
it('parses an OpenAPI document', async () => {
const schema = await parseOpenAPI({
value: spec,
url: 'https://example.com',
});
// Ensure the structure returned is not recursive (not dereferenced).
JSON.stringify(schema);
});
});
@@ -7,11 +7,7 @@ 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;
parseMarkdown: (input: string) => Promise<string>;
}) {
export async function parseOpenAPI(input: { value: string; url: string }) {
try {
return await parseOpenAPIV3(input);
} catch (error) {
@@ -21,6 +17,3 @@ export async function parseOpenAPI(input: {
throw error;
}
}
export type * from './types';
export * from './error';
@@ -0,0 +1,85 @@
import { LoadPlugin } from '@scalar/openapi-parser';
export const fetchUrlsDefaultConfiguration = {
limit: 40,
};
export const fetchUrls: (customConfiguration: {
/**
* Base URL to use for relative paths.
*/
baseUrl: string;
/**
* Limit the number of requests. Set to `false` to disable the limit.
*/
limit?: number | false;
}) => LoadPlugin = (customConfiguration) => {
// State
let numberOfRequests = 0;
// Configuration
const configuration = {
...fetchUrlsDefaultConfiguration,
...customConfiguration,
};
return {
check(value?: any) {
// Not a string
if (typeof value !== 'string') {
return false;
}
// Not http/https or relative path
if (
!value.startsWith('http://') &&
!value.startsWith('https://') &&
!isRelativePath(value)
) {
return false;
}
return true;
},
async get(value?: any) {
// Limit ht enumber of requests
if (configuration?.limit !== false && numberOfRequests >= configuration?.limit) {
console.warn(
`[fetchUrls] Maximum number of requests reeached (${configuration?.limit}), skipping request`,
);
return undefined;
}
try {
numberOfRequests++;
const url = getReferenceUrl(value, configuration.baseUrl);
const response = await fetch(url);
return await response.text();
} catch (error: any) {
console.error('[fetchUrls]', error.message, `(${value})`);
return undefined;
}
},
};
};
/**
* Check if a path is relative.
*/
function isRelativePath(path: string): boolean {
// Exclude external URLs
const externalUrlPattern = /^(https?:\/\/|www\.|data:|#\/)/;
return !externalUrlPattern.test(path);
}
/**
* Get the reference URL.
*/
function getReferenceUrl(value: string, baseUrl: string) {
if (isRelativePath(value)) {
return new URL(value, baseUrl).href;
}
return value;
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'bun:test';
import { dereference } from '@scalar/openapi-parser';
import { traverse } from './traverse';
import { createFileSystem } from './filesystem';
const recursiveSpec = await Bun.file(
new URL('./fixtures/recursive-spec.json', import.meta.url),
).text();
describe('#traverse', () => {
it('traverses a recursive structure asynchronously', async () => {
expect.assertions(2);
const result = await dereference(recursiveSpec);
// Confirm that it is a recursive structure
try {
JSON.stringify(result);
} catch (error) {
expect((error as Error).message).toBe(
'JSON.stringify cannot serialize cyclic structures.',
);
}
const specification = await traverse(result.specification!, async (node) => {
if ('description' in node && node['description']) {
node['description'] = 'Hello, world!';
return node;
}
return node;
});
expect(specification.info!.description).toBe('Hello, world!');
});
it('traverses a complete filesystem', async () => {
const filesystem = await createFileSystem({
value: JSON.parse(recursiveSpec),
baseUrl: 'https://example.com',
});
const transformedFilesystem = await traverse(filesystem, async (node) => {
if ('description' in node && node['description']) {
node['description'] = 'Hello, world!';
return node;
}
return node;
});
expect(transformedFilesystem[0]!.specification['info']!.description).toBe('Hello, world!');
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { 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 async function traverse<T extends AnyObject | AnyObject[]>(
specification: T,
transform: (specification: AnyObject, path?: string[]) => AnyObject | Promise<AnyObject>,
path: string[] = [],
seen = new WeakSet(),
): Promise<T> {
const result: AnyObject = {};
if (typeof specification !== 'object' || specification === null) {
return specification as Promise<T>;
}
if (seen.has(specification)) {
return specification as Promise<T>;
}
if (Array.isArray(specification)) {
return Promise.all(
specification.map((item, index) =>
traverse(item, transform, [...path, index.toString()], seen),
),
) as Promise<T>;
}
const keys = Object.keys(specification);
await Promise.all(
keys.map(async (key) => {
const value = specification[key];
result[key] = await traverse(value, transform, [...path, key], seen);
}),
);
return transform(result, path) as Promise<T>;
}
@@ -1,4 +1,5 @@
export type IconComponent = React.ComponentType<{ className?: string }>;
import type { AnyObject } from '@scalar/openapi-parser';
import type { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
/**
* Custom properties that can be defined at the entire spec level.
@@ -15,6 +16,11 @@ export interface OpenAPICustomSpecProperties {
* This option can be used to hide code samples for the entire spec.
*/
'x-hideTryItPanel'?: boolean;
/**
* Description in HTML format.
*/
'x-description-html'?: string;
}
/**
@@ -31,6 +37,11 @@ export interface OpenAPICustomOperationProperties {
* https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/
*/
'x-hideTryItPanel'?: boolean;
/**
* Description in HTML format.
*/
'x-description-html'?: string;
}
/**
@@ -43,3 +54,23 @@ export interface OpenAPICustomCodeSample {
label: string;
source: string;
}
export type OpenAPIV3xDocument =
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
| OpenAPIV3.Document<OpenAPICustomSpecProperties>;
/**
* Not literally a filesystem, but a list of files with their content.
* This is an abstraction layer to handle multiple files in the browser (without access to the hard disk).
*/
export type Filesystem<T extends AnyObject = AnyObject> = FilesystemEntry<T>[];
/**
* Holds all information about a single file (doesnt have to be a literal file, see Filesystem).
*/
export type FilesystemEntry<T extends AnyObject> = {
dir: string;
isEntrypoint: boolean;
references: string[];
filename: string;
specification: T;
};
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'bun:test';
import { convertOpenAPIV2ToOpenAPIV3 } from './v2';
const specV2 = await Bun.file(new URL('./fixtures/spec-v2.json', import.meta.url)).text();
describe('#convertOpenAPIV2ToOpenAPIV3', () => {
it('converts an OpenAPIV2 in V3', async () => {
const schema = await convertOpenAPIV2ToOpenAPIV3({
value: specV2,
url: 'https://example.com',
});
// Ensure the structure returned is not recursive (not dereferenced).
JSON.stringify(schema);
expect(schema[0]!.specification.openapi).toBe('3.0.0');
});
});
@@ -1,11 +1,10 @@
import YAML from 'yaml';
import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi';
import swagger2openapi, { type 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';
import type { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
import type { Filesystem, OpenAPIV3xDocument } from './types';
/**
* Convert a Swagger 2.0 schema to an OpenAPI 3.0 schema.
@@ -13,12 +12,8 @@ import { AnyApiDefinitionFormat } from '@scalar/openapi-parser';
export async function convertOpenAPIV2ToOpenAPIV3(input: {
value: AnyApiDefinitionFormat;
url: string;
parseMarkdown: (input: string) => Promise<string>;
}): Promise<
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
> {
const { value, url, parseMarkdown } = input;
}): Promise<Filesystem<OpenAPIV3xDocument>> {
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 {
@@ -34,7 +29,7 @@ export async function convertOpenAPIV2ToOpenAPIV3(input: {
patch: true,
})) as ConvertOutputOptions;
return parseOpenAPIV3({ url, value: convertResult.openapi, parseMarkdown });
return parseOpenAPIV3({ url, value: convertResult.openapi });
} catch (error) {
if (error instanceof Error && error.name === 'S2OError') {
throw new OpenAPIParseError(
+30
View File
@@ -0,0 +1,30 @@
import { type AnyApiDefinitionFormat, validate } from '@scalar/openapi-parser';
import { OpenAPIParseError } from './error';
import { createFileSystem } from './filesystem';
import type { Filesystem, OpenAPIV3xDocument } from './types';
/**
* 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<Filesystem<OpenAPIV3xDocument>> {
const { value, url } = input;
const result = await validate(value);
// Spec is invalid, we stop here.
if (!result.specification) {
throw new OpenAPIParseError('Invalid OpenAPI document', url, 'invalid-spec');
}
if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', url, 'v2-spec');
}
const filesystem = await createFileSystem({ value: result.specification, baseUrl: url });
return filesystem;
}
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["./tsconfig.json"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"declaration": true,
"noEmit": false,
"outDir": "dist"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node20/tsconfig.json"],
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"isolatedModules": true,
"incremental": true,
"noEmit": true,
"types": [
"bun-types" // add Bun global
]
},
"include": ["src/**/*.ts"]
}
+6 -9
View File
@@ -9,21 +9,18 @@
}
},
"version": "0.7.1",
"sideEffects": false,
"dependencies": {
"@gitbook/openapi-parser": "workspace:*",
"@scalar/api-client-react": "1.0.87",
"@scalar/openapi-parser": "^0.10.4",
"@scalar/openapi-types": "^0.1.6",
"@scalar/oas-utils": "^0.2.101",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"flatted": "^3.2.9",
"swagger2openapi": "^7.0.8",
"react-aria-components": "^1.6.0",
"react-aria": "^3.37.0",
"usehooks-ts": "^3.1.0",
"yaml": "1.10.2"
"usehooks-ts": "^3.1.0"
},
"devDependencies": {
"@types/swagger2openapi": "^7.0.4",
"bun-types": "^1.1.20",
"typescript": "^5.5.3"
},
@@ -31,10 +28,10 @@
"react": "*"
},
"scripts": {
"build": "tsc",
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"unit": "bun test",
"dev": "tsc -w",
"dev": "bun run build -- --watch",
"clean": "rm -rf ./dist"
},
"files": [
@@ -1,7 +1,7 @@
'use client';
import classNames from 'classnames';
import React, { useCallback } from 'react';
import clsx from 'clsx';
import { useCallback, useRef, useState, useSyncExternalStore } from 'react';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately';
@@ -30,7 +30,7 @@ function useSyncedTabsGlobalState() {
[],
);
const tabs = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const tabs = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return [tabs, setSyncedTabs] as const;
}
@@ -80,15 +80,15 @@ export function InteractiveSection(props: {
stateKey && stateKey in syncedTabs
? tabs.find((tab) => tab.key === syncedTabs[stateKey])
: undefined;
const [selectedTabKey, setSelectedTab] = React.useState(tabFromState?.key ?? defaultTab);
const [selectedTabKey, setSelectedTab] = useState(tabFromState?.key ?? defaultTab);
const selectedTab: InteractiveSectionTab | undefined =
tabFromState ?? tabs.find((tab) => tab.key === selectedTabKey) ?? tabs[0];
const state = useDisclosureState({
defaultExpanded: defaultOpened,
});
const panelRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const { buttonProps: triggerProps, panelProps } = useDisclosure({}, state, panelRef);
const { buttonProps } = useButton(triggerProps, triggerRef);
const { isFocusVisible, focusProps } = useFocusRing();
@@ -96,7 +96,7 @@ export function InteractiveSection(props: {
return (
<div
id={id}
className={classNames(
className={clsx(
'openapi-section',
toggeable ? 'openapi-section-toggeable' : null,
className,
@@ -110,10 +110,10 @@ export function InteractiveSection(props: {
state.toggle();
}
}}
className={classNames('openapi-section-header', `${className}-header`)}
className={clsx('openapi-section-header', `${className}-header`)}
>
<div
className={classNames(
className={clsx(
'openapi-section-header-content',
`${className}-header-content`,
)}
@@ -122,10 +122,7 @@ export function InteractiveSection(props: {
<button
{...mergeProps(buttonProps, focusProps)}
ref={triggerRef}
className={classNames(
'openapi-section-toggle',
`${className}-toggle`,
)}
className={clsx('openapi-section-toggle', `${className}-toggle`)}
style={{
outline: isFocusVisible
? '2px solid rgb(var(--primary-color-500) / 0.4)'
@@ -138,7 +135,7 @@ export function InteractiveSection(props: {
{header}
</div>
<div
className={classNames(
className={clsx(
'openapi-section-header-controls',
`${className}-header-controls`,
)}
@@ -148,12 +145,12 @@ export function InteractiveSection(props: {
>
{tabs.length > 1 ? (
<select
className={classNames(
className={clsx(
'openapi-section-select',
'openapi-select',
`${className}-tabs-select`,
)}
value={selectedTab.key}
value={selectedTab?.key ?? ''}
onChange={(event) => {
setSelectedTab(event.target.value);
if (stateKey) {
@@ -179,14 +176,14 @@ export function InteractiveSection(props: {
<div
ref={panelRef}
{...panelProps}
className={classNames('openapi-section-body', `${className}-body`)}
className={clsx('openapi-section-body', `${className}-body`)}
>
{children}
{selectedTab?.body}
</div>
) : null}
{overlay ? (
<div className={classNames('openapi-section-overlay', `${className}-overlay`)}>
<div className={clsx('openapi-section-overlay', `${className}-overlay`)}>
{overlay}
</div>
) : null}
+2 -2
View File
@@ -1,11 +1,11 @@
import classNames from 'classnames';
import clsx from 'clsx';
export function Markdown(props: { source: string; className?: string }) {
const { source, className } = props;
return (
<div
className={classNames('openapi-markdown', className)}
className={clsx('openapi-markdown', className)}
dangerouslySetInnerHTML={{ __html: source }}
/>
);
@@ -1,5 +1,3 @@
import React from 'react';
import { CodeSampleInput, codeSampleGenerators } from './code-samples';
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
@@ -1,5 +1,5 @@
import React from 'react';
import { OpenAPIClientContext } from './types';
import { useRef } from 'react';
import type { OpenAPIClientContext } from './types';
import { mergeProps, useButton, useDisclosure, useFocusRing } from 'react-aria';
import { useDisclosureState } from 'react-stately';
@@ -15,8 +15,8 @@ interface Props {
*/
export function OpenAPIDisclosure({ context, children, label }: Props): JSX.Element {
const state = useDisclosureState({});
const panelRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const { buttonProps: triggerProps, panelProps } = useDisclosure({}, state, panelRef);
const { buttonProps } = useButton(triggerProps, triggerRef);
const { isFocusVisible, focusProps } = useFocusRing();
@@ -1,5 +1,3 @@
import React from 'react';
interface Props {
groups: TDisclosureGroup[];
icon?: React.ReactNode;
@@ -22,9 +20,9 @@ import {
useDisclosureGroupState,
useDisclosureState,
} from 'react-stately';
import classNames from 'classnames';
import { createContext, useContext, useRef, useState } from 'react';
const DisclosureGroupStateContext = React.createContext<DisclosureGroupState | null>(null);
const DisclosureGroupStateContext = createContext<DisclosureGroupState | null>(null);
/**
* Display an interactive OpenAPI disclosure group.
@@ -48,7 +46,7 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode
const defaultId = useId();
const id = group.id || defaultId;
const groupState = React.useContext(DisclosureGroupStateContext);
const groupState = useContext(DisclosureGroupStateContext);
const isExpanded = groupState?.expandedKeys.has(id) || false;
const state = useDisclosureState({
isExpanded,
@@ -59,8 +57,8 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode
},
});
const panelRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const isDisabled = groupState?.isDisabled || !group.tabs?.length || false;
const { buttonProps: triggerProps, panelProps } = useDisclosure(
{
@@ -75,7 +73,7 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode
const { isFocusVisible, focusProps } = useFocusRing();
const defaultTab = group.tabs?.[0]?.id || '';
const [selectedTabKey, setSelectedTabKey] = React.useState(defaultTab);
const [selectedTabKey, setSelectedTabKey] = useState(defaultTab);
const selectedTab = group.tabs?.find((tab) => tab.id === selectedTabKey);
return (
@@ -107,11 +105,7 @@ function DisclosureItem(props: { group: TDisclosureGroup; icon?: React.ReactNode
<div className="openapi-disclosure-group-mediatype">
{group.tabs?.length > 1 ? (
<select
className={classNames(
'openapi-section-select',
'openapi-select',
`openapi-disclosure-group-tabs-select`,
)}
className="openapi-section-select openapi-select openapi-disclosure-group-tabs-select"
onClick={(event) => event.stopPropagation()}
value={selectedTab?.id}
onChange={(event) => {
+12 -12
View File
@@ -1,13 +1,13 @@
import * as React from 'react';
import classNames from 'classnames';
import clsx from 'clsx';
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { Markdown } from './Markdown';
import { OpenAPICodeSample } from './OpenAPICodeSample';
import { OpenAPIResponseExample } from './OpenAPIResponseExample';
import { OpenAPISpec } from './OpenAPISpec';
import { OpenAPIClientContext, OpenAPIContextProps } from './types';
import { OpenAPIClientContext, type OpenAPIContextProps } from './types';
import { OpenAPIPath } from './OpenAPIPath';
import { resolveDescription } from './utils';
/**
* Display an interactive OpenAPI operation.
@@ -26,18 +26,18 @@ export function OpenAPIOperation(props: {
blockKey: context.blockKey,
};
const trimmedDescription = operation.description?.trim();
const description = resolveDescription(operation)?.trim();
return (
<div className={classNames('openapi-operation', className)}>
<div className={clsx('openapi-operation', className)}>
<div className="openapi-summary" id={context.id}>
<h2 className="openapi-summary-title" data-deprecated={operation.deprecated}>
{operation.summary}
</h2>
{operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
</div>
<div className={classNames('openapi-columns')}>
<div className={classNames('openapi-column-spec')}>
<div className="openapi-columns">
<div className="openapi-column-spec">
{operation['x-deprecated-sunset'] ? (
<div className="openapi-deprecated-sunset openapi-description openapi-markdown">
This operation is deprecated and will be sunset on{' '}
@@ -47,16 +47,16 @@ export function OpenAPIOperation(props: {
{`.`}
</div>
) : null}
{trimmedDescription ? (
{description ? (
<div className="openapi-intro">
<Markdown className="openapi-description" source={trimmedDescription} />
<Markdown className="openapi-description" source={description} />
</div>
) : null}
<OpenAPIPath data={data} context={context} />
<OpenAPISpec data={data} context={clientContext} />
</div>
<div className={classNames('openapi-column-preview')}>
<div className={classNames('openapi-column-preview-body')}>
<div className="openapi-column-preview">
<div className="openapi-column-preview-body">
<OpenAPICodeSample {...props} />
<OpenAPIResponseExample {...props} />
</div>
@@ -1,5 +1,6 @@
'use client';
import * as React from 'react';
import { createContext, useContext, useMemo } from 'react';
import { useEventCallback } from 'usehooks-ts';
interface OpenAPIOperationPointer {
@@ -11,7 +12,7 @@ interface OpenAPIOperationContextValue {
onOpenClient: (pointer: OpenAPIOperationPointer) => void;
}
const OpenAPIOperationContext = React.createContext<OpenAPIOperationContextValue>({
const OpenAPIOperationContext = createContext<OpenAPIOperationContextValue>({
onOpenClient: () => {},
});
@@ -27,7 +28,7 @@ export function OpenAPIOperationContextProvider(
props.onOpenClient?.(pointer);
});
const value = React.useMemo(() => ({ onOpenClient }), [onOpenClient]);
const value = useMemo(() => ({ onOpenClient }), [onOpenClient]);
return (
<OpenAPIOperationContext.Provider value={value}>
@@ -40,5 +41,5 @@ export function OpenAPIOperationContextProvider(
* Hook to access the OpenAPIOperationContext.
*/
export function useOpenAPIOperationContext() {
return React.useContext(OpenAPIOperationContext);
return useContext(OpenAPIOperationContext);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { ScalarApiButton } from './ScalarApiButton';
import { OpenAPIContextProps } from './types';
import type { OpenAPIContextProps } from './types';
/**
* Display the path of an operation.
@@ -1,9 +1,7 @@
import * as React from 'react';
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPIRootSchema } from './OpenAPISchema';
import { noReference } from './utils';
import { OpenAPIClientContext } from './types';
import type { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection';
/**
@@ -1,8 +1,7 @@
import classNames from 'classnames';
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPISchemaProperties } from './OpenAPISchema';
import { checkIsReference, noReference } from './utils';
import { OpenAPIClientContext } from './types';
import { checkIsReference, noReference, resolveDescription } from './utils';
import type { OpenAPIClientContext } from './types';
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
/**
@@ -19,7 +18,9 @@ export function OpenAPIResponse(props: {
);
const content = Object.entries(mediaType.schema ?? {});
if (content.length === 0 && !response.description && headers.length === 0) {
const description = resolveDescription(response);
if (content.length === 0 && !description && headers.length === 0) {
return null;
}
@@ -37,7 +38,7 @@ export function OpenAPIResponse(props: {
/>
</OpenAPIDisclosure>
) : null}
<div className={classNames('openapi-responsebody')}>
<div className="openapi-responsebody">
<OpenAPISchemaProperties
id={`response-${context.blockKey}`}
properties={[
@@ -1,9 +1,9 @@
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import type { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { generateSchemaExample } from './generateSchemaExample';
import { OpenAPIContextProps } from './types';
import { checkIsReference, noReference } from './utils';
import type { OpenAPIContextProps } from './types';
import { checkIsReference, noReference, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPIV3 } from '@scalar/openapi-types';
import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
import { InteractiveSection } from './InteractiveSection';
@@ -46,9 +46,10 @@ export function OpenAPIResponseExample(props: {
if (!responseObject.content) {
return null;
}
const key = Object.keys(responseObject.content)[0];
return (
responseObject.content['application/json'] ??
responseObject.content[Object.keys(responseObject.content)[0]]
(key ? responseObject.content[key] : null)
);
})();
@@ -56,7 +57,7 @@ export function OpenAPIResponseExample(props: {
return {
key: key,
label: key,
description: responseObject.description,
description: resolveDescription(responseObject),
body: <OpenAPIEmptyResponseExample />,
};
}
@@ -65,11 +66,13 @@ export function OpenAPIResponseExample(props: {
(() => {
const { examples, example } = mediaTypeObject;
if (examples) {
const firstKey = Object.keys(examples)[0];
// @TODO handle multiple examples
const firstExample = noReference(examples[firstKey]);
if (firstExample) {
return firstExample;
const key = Object.keys(examples)[0];
if (key) {
// @TODO handle multiple examples
const firstExample = noReference(examples[key]);
if (firstExample) {
return firstExample;
}
}
}
@@ -89,7 +92,7 @@ export function OpenAPIResponseExample(props: {
return {
key: key,
label: key,
description: responseObject.description,
description: resolveDescription(responseObject),
body: example?.value ? (
<context.CodeBlock
code={
@@ -1,14 +1,10 @@
import * as React from 'react';
import classNames from 'classnames';
import { createStateKey, noReference } from './utils';
import type { OpenAPIV3, OpenAPIV3_1 } from '@gitbook/openapi-parser';
import { createStateKey, resolveDescription } from './utils';
import { OpenAPIResponse } from './OpenAPIResponse';
import { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIDisclosureGroup } from './OpenAPIDisclosureGroup';
import { Markdown } from './Markdown';
import { OpenAPIRootSchema, OpenAPISchemaProperties, OpenAPISchemaProperty } from './OpenAPISchema';
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
/**
* Display an interactive response body.
@@ -23,7 +19,7 @@ export function OpenAPIResponses(props: {
<InteractiveSection
stateKey={createStateKey('response', context.blockKey)}
header="Responses"
className={classNames('openapi-responses')}
className="openapi-responses"
>
<OpenAPIDisclosureGroup
allowsMultipleExpanded
@@ -31,6 +27,7 @@ export function OpenAPIResponses(props: {
groups={Object.entries(responses).map(
([statusCode, response]: [string, OpenAPIV3.ResponseObject]) => {
const content = Object.entries(response.content ?? {});
const description = resolveDescription(response);
return {
id: statusCode,
@@ -42,9 +39,9 @@ export function OpenAPIResponses(props: {
<span className="openapi-response-statuscode">
{statusCode}
</span>
{response.description ? (
{description ? (
<Markdown
source={response.description}
source={description}
className="openapi-response-description"
/>
) : null}
@@ -1,6 +1,6 @@
import { it, describe, expect } from 'bun:test';
import { getSchemaAlternatives } from './OpenAPISchema';
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
describe('getSchemaAlternatives', () => {
it('should flatten oneOf', () => {
+15 -12
View File
@@ -1,11 +1,11 @@
import classNames from 'classnames';
import { OpenAPIV3 } from '@scalar/openapi-types';
import React, { useId } from 'react';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import clsx from 'clsx';
import { useId } from 'react';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
import { OpenAPIClientContext } from './types';
import { checkIsReference, noReference } from './utils';
import type { OpenAPIClientContext } from './types';
import { checkIsReference, noReference, resolveDescription } from './utils';
import { stringifyOpenAPI } from './stringifyOpenAPI';
import { OpenAPISchemaName } from './OpenAPISchemaName';
import { OpenAPIDisclosure } from './OpenAPIDisclosure';
@@ -49,7 +49,7 @@ export function OpenAPISchemaProperty(
if ((properties && !!properties.length) || schema.type === 'object') {
return (
<InteractiveSection id={id} className={classNames('openapi-schema', className)}>
<InteractiveSection id={id} className={clsx('openapi-schema', className)}>
<OpenAPISchemaPresentation {...props} />
<OpenAPIDisclosure context={context}>
{properties && properties.length > 0 ? (
@@ -66,10 +66,11 @@ export function OpenAPISchemaProperty(
if (alternatives?.[0]?.length) {
return (
<InteractiveSection id={id} className={classNames('openapi-schema', className)}>
<InteractiveSection id={id} className={clsx('openapi-schema', className)}>
<OpenAPISchemaPresentation {...props} />
{alternatives[0].map((alternative, index) => (
<OpenAPISchemaAlternative
key={index}
schema={alternative}
circularRefs={circularRefs}
context={context}
@@ -80,7 +81,7 @@ export function OpenAPISchemaProperty(
}
return (
<InteractiveSection id={id} className={classNames('openapi-schema', className)}>
<InteractiveSection id={id} className={clsx('openapi-schema', className)}>
<OpenAPISchemaPresentation {...props} />
{(properties && properties.length > 0) ||
(schema.enum && schema.enum.length > 0) ||
@@ -118,7 +119,7 @@ export function OpenAPISchemaProperties(props: {
}
return (
<div id={id} className={classNames('openapi-schema-properties')}>
<div id={id} className="openapi-schema-properties">
{properties.map((property) => (
<OpenAPISchemaProperty
key={property.propertyName}
@@ -228,8 +229,10 @@ export function OpenAPISchemaPresentation(props: OpenAPISchemaPropertyEntry) {
);
};
const description = resolveDescription(schema);
return (
<div className={classNames('openapi-schema-presentation')}>
<div className="openapi-schema-presentation">
<OpenAPISchemaName
type={getSchemaTitle(schema)}
propertyName={propertyName}
@@ -244,8 +247,8 @@ export function OpenAPISchemaPresentation(props: OpenAPISchemaPropertyEntry) {
</span>
</div>
) : null}
{schema.description ? (
<Markdown source={schema.description} className="openapi-schema-description" />
{description ? (
<Markdown source={description} className="openapi-schema-description" />
) : null}
{shouldDisplayExample(schema) ? (
<div className="openapi-schema-example">
@@ -1,6 +1,3 @@
import React from 'react';
import classNames from 'classnames';
interface OpenAPISchemaNameProps {
propertyName?: string | JSX.Element;
required?: boolean;
@@ -16,22 +13,15 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps): JSX.Element {
const { type, propertyName, required, deprecated } = props;
return (
<div className={classNames('openapi-schema-name')}>
<div className="openapi-schema-name">
{propertyName ? (
<span
data-deprecated={deprecated}
className={classNames('openapi-schema-propertyname')}
>
<span data-deprecated={deprecated} className="openapi-schema-propertyname">
{propertyName}
</span>
) : null}
{type ? <span className={classNames('openapi-schema-type')}>{type}</span> : null}
{required ? (
<span className={classNames('openapi-schema-required')}>required</span>
) : null}
{deprecated ? (
<span className={classNames('openapi-deprecated')}>Deprecated</span>
) : null}
{type ? <span className="openapi-schema-type">{type}</span> : null}
{required ? <span className="openapi-schema-required">required</span> : null}
{deprecated ? <span className="openapi-deprecated">Deprecated</span> : null}
</div>
);
}
@@ -1,10 +1,10 @@
import * as React from 'react';
import { OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIClientContext } from './types';
import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
import type { OpenAPIClientContext } from './types';
import { InteractiveSection } from './InteractiveSection';
import { Markdown } from './Markdown';
import { OpenAPIOperationData } from './fetchOpenAPIOperation';
import { OpenAPISchemaName } from './OpenAPISchemaName';
import { resolveDescription } from './utils';
/**
* Present securities authorization that can be used for this operation.
@@ -27,6 +27,7 @@ export function OpenAPISecurities(props: {
toggleIcon={context.icons.chevronRight}
className="openapi-securities"
tabs={securities.map(([key, security]) => {
const description = resolveDescription(security);
return {
key: key,
label: key,
@@ -35,9 +36,9 @@ export function OpenAPISecurities(props: {
<div className="openapi-schema-presentation">
{getLabelForType(security)}
{security.description ? (
{description ? (
<Markdown
source={security.description}
source={description}
className="openapi-securities-description"
/>
) : null}
@@ -66,11 +67,12 @@ function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject) {
}
if (security.scheme == 'bearer') {
const description = resolveDescription(security);
return (
<>
<OpenAPISchemaName propertyName="Authorization" type="string" required />
{/** Show a default description if none is provided */}
{!security.description ? (
{!description ? (
<Markdown
source={`Bearer authentication header of the form Bearer ${`&lt;token&gt;`}.`}
className="openapi-securities-description"
+17 -10
View File
@@ -1,5 +1,4 @@
import * as React from 'react';
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { OpenAPIServerURLVariable } from './OpenAPIServerURLVariable';
/**
@@ -9,6 +8,10 @@ export function OpenAPIServerURL(props: { servers: OpenAPIV3.ServerObject[] }) {
const { servers } = props;
const server = servers[0];
if (!server) {
return null;
}
const parts = parseServerURL(server?.url ?? '');
return (
@@ -17,16 +20,13 @@ export function OpenAPIServerURL(props: { servers: OpenAPIV3.ServerObject[] }) {
if (part.kind === 'text') {
return <span key={i}>{part.text}</span>;
} else {
if (!server.variables?.[part.name]) {
const variable = server.variables?.[part.name];
if (!variable) {
return <span key={i}>{`{${part.name}}`}</span>;
}
return (
<OpenAPIServerURLVariable
key={i}
name={part.name}
variable={server.variables[part.name]}
/>
<OpenAPIServerURLVariable key={i} name={part.name} variable={variable} />
);
}
})}
@@ -39,6 +39,9 @@ export function OpenAPIServerURL(props: { servers: OpenAPIV3.ServerObject[] }) {
*/
export function getServersURL(servers: OpenAPIV3.ServerObject[]): string {
const server = servers[0];
if (!server) {
return '';
}
const parts = parseServerURL(server?.url ?? '');
return parts
@@ -56,10 +59,14 @@ function parseServerURL(url: string) {
const parts = url.split(/{([^}]+)}/g);
const result: Array<{ kind: 'variable'; name: string } | { kind: 'text'; text: string }> = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) {
continue;
}
if (i % 2 === 0) {
result.push({ kind: 'text', text: parts[i] });
result.push({ kind: 'text', text: part });
} else {
result.push({ kind: 'variable', name: parts[i] });
result.push({ kind: 'variable', name: part });
}
}
return result;
@@ -1,8 +1,6 @@
'use client';
import * as React from 'react';
import classNames from 'classnames';
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
/**
* Interactive component to show the value of a server variable and let the user change it.
@@ -12,5 +10,5 @@ export function OpenAPIServerURLVariable(props: {
variable: OpenAPIV3.ServerVariableObject;
}) {
const { variable } = props;
return <span className={classNames('openapi-url-var')}>{variable.default}</span>;
return <span className="openapi-url-var">{variable.default}</span>;
}
+32 -27
View File
@@ -1,15 +1,15 @@
'use client';
import { OpenAPI } from '@scalar/openapi-types';
import type { OpenAPI } from '@gitbook/openapi-parser';
import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
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 { noReference } from './utils';
import { noReference, resolveDescription } from './utils';
/**
* Client component to render the spec for the request and response.
@@ -31,30 +31,35 @@ export function OpenAPISpec(props: { data: OpenAPIOperationData; context: OpenAP
<OpenAPISecurities securities={securities} context={context} />
) : null}
{parameterGroups.map((group) => (
<InteractiveSection
key={group.key}
className="openapi-parameters"
header={group.label}
>
<OpenAPISchemaProperties
properties={group.parameters.map((parameter) => ({
propertyName: parameter.name,
schema: {
// Description of the parameter is defined at the parameter level
// we use display it if the schema doesn't override it
description: parameter.description,
example: parameter.example,
// Deprecated can be defined at the parameter level
deprecated: parameter.deprecated,
...(noReference(parameter.schema) ?? {}),
},
required: parameter.required,
}))}
context={context}
/>
</InteractiveSection>
))}
{parameterGroups.map((group) => {
return (
<InteractiveSection
key={group.key}
className="openapi-parameters"
header={group.label}
>
<OpenAPISchemaProperties
properties={group.parameters.map((parameter) => {
const description = resolveDescription(parameter);
return {
propertyName: parameter.name,
schema: {
// Description of the parameter is defined at the parameter level
// we use display it if the schema doesn't override it
description: description,
example: parameter.example,
// Deprecated can be defined at the parameter level
deprecated: parameter.deprecated,
...(noReference(parameter.schema) ?? {}),
},
required: parameter.required,
};
})}
context={context}
/>
</InteractiveSection>
);
})}
{operation.requestBody ? (
<OpenAPIRequestBody
+11 -4
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useMemo } from 'react';
import { createContext, useContext, useMemo, useState } from 'react';
import { Key, Tab, TabList, TabPanel, Tabs, TabsProps } from 'react-aria-components';
import { Markdown } from './Markdown';
@@ -17,10 +17,10 @@ type OpenAPITabsContextData = {
setSelectedKey: (key: Key) => void;
};
const OpenAPITabsContext = React.createContext<OpenAPITabsContextData | null>(null);
const OpenAPITabsContext = createContext<OpenAPITabsContextData | null>(null);
function useOpenAPITabsContext() {
const context = React.useContext(OpenAPITabsContext);
const context = useContext(OpenAPITabsContext);
if (!context) {
throw new Error('OpenAPITabsContext is missing');
}
@@ -32,7 +32,14 @@ function useOpenAPITabsContext() {
*/
export function OpenAPITabs(props: React.PropsWithChildren<TabsProps & { items: Tab[] }>) {
const { children, items } = props;
const [selectedKey, setSelectedKey] = React.useState(items[0].key);
const [selectedKey, setSelectedKey] = useState(() => {
const firstItem = items[0];
if (!firstItem) {
throw new Error('OpenAPITabs: at least one tab is required');
}
return firstItem.key;
});
const contextValue = { items, selectedKey, setSelectedKey };
@@ -1,7 +1,7 @@
'use client';
import { ApiClientModalProvider, useApiClientModal } from '@scalar/api-client-react';
import React, { useImperativeHandle, useRef } from 'react';
import { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useOpenAPIOperationContext } from './OpenAPIOperationContext';
@@ -19,7 +19,7 @@ export function ScalarApiButton({
path: string;
specUrl: string;
}) {
const [isOpen, setIsOpen] = React.useState(false);
const [isOpen, setIsOpen] = useState(false);
const controllerRef = useRef<ScalarModalControllerRef>(null);
return (
<div className="scalar scalar-activate">
@@ -96,7 +96,7 @@ function ScalarModalController(props: {
const trackOpening = useEventCallback(() => {
onOpenClient({ method: props.method, path: props.path });
});
React.useEffect(() => {
useEffect(() => {
if (openClient) {
openClient();
trackOpening();
+2 -1
View File
@@ -144,7 +144,8 @@ export function parseHostAndPath(url: string) {
} catch (e) {
// If the URL was invalid do our best to parse the URL.
// Check for the protocol part and pull it off to grab the host
const fullUrl = url.match(/\/\//) ? url.split('//')[1] : url;
const splitted = url.split('//');
const fullUrl = splitted[1] ? splitted[1] : url;
// separate paths from the first element (host)
const parts = fullUrl.split('/');
@@ -1,12 +1,20 @@
import { it, expect } from 'bun:test';
import { fetchOpenAPIOperation, OpenAPIFetcher } from './fetchOpenAPIOperation';
import { parseOpenAPI } from './parser';
import { fetchOpenAPIOperation, type OpenAPIFetcher } from './fetchOpenAPIOperation';
import { parseOpenAPI, traverse } from '@gitbook/openapi-parser';
const fetcher: OpenAPIFetcher = {
fetch: async (url) => {
const response = await fetch(url);
return parseOpenAPI({ value: await response.text(), url, parseMarkdown: async (v) => v });
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;
},
};
@@ -1,20 +1,22 @@
import { toJSON, fromJSON } from 'flatted';
import { OpenAPICustomSpecProperties, OpenAPIParseError } from './parser';
import { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import {
type OpenAPICustomOperationProperties,
type OpenAPICustomSpecProperties,
type OpenAPIV3xDocument,
type Filesystem,
type OpenAPIV3,
type OpenAPIV3_1,
OpenAPIParseError,
dereference,
} from '@gitbook/openapi-parser';
import { noReference } from './utils';
import { dereference } 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>
>;
fetch: (url: string) => Promise<Filesystem<OpenAPIV3xDocument>>;
}
export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
@@ -25,7 +27,7 @@ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
servers: OpenAPIV3.ServerObject[];
/** Spec of the operation */
operation: OpenAPIV3.OperationObject;
operation: OpenAPIV3.OperationObject<OpenAPICustomOperationProperties>;
/** Securities that should be used for this operation */
securities: [string, OpenAPIV3.SecuritySchemeObject][];
@@ -44,9 +46,8 @@ export async function fetchOpenAPIOperation(
},
fetcher: OpenAPIFetcher,
): Promise<OpenAPIOperationData | null> {
const refSchema = await fetcher.fetch(input.url);
const schema = await memoDereferenceSchema(refSchema, input.url);
const filesystem = await fetcher.fetch(input.url);
const schema = await memoDereferenceFilesystem(filesystem, input.url);
let operation = getOperationByPathAndMethod(schema, input.path, input.method);
if (!operation) {
@@ -69,9 +70,11 @@ export async function fetchOpenAPIOperation(
const securities: OpenAPIOperationData['securities'] = [];
for (const entry of security) {
const securityKey = Object.keys(entry)[0];
const securityScheme = schema.components?.securitySchemes?.[securityKey];
if (securityScheme) {
securities.push([securityKey, noReference(securityScheme)]);
if (securityKey) {
const securityScheme = schema.components?.securitySchemes?.[securityKey];
if (securityScheme) {
securities.push([securityKey, noReference(securityScheme)]);
}
}
}
@@ -90,28 +93,34 @@ export async function fetchOpenAPIOperation(
};
}
const dereferenceSchemaCache = new WeakMap<OpenAPI.Document, Promise<OpenAPI.Document>>();
const dereferenceCache = new WeakMap<Filesystem, Promise<OpenAPIV3xDocument>>();
/**
* Memoized version of `dereferenceSchema`.
*/
function memoDereferenceSchema<T extends OpenAPI.Document>(schema: T, url: string): Promise<T> {
if (dereferenceSchemaCache.has(schema)) {
return dereferenceSchemaCache.get(schema) as Promise<T>;
function memoDereferenceFilesystem(
filesystem: Filesystem,
url: string,
): Promise<OpenAPIV3xDocument> {
if (dereferenceCache.has(filesystem)) {
return dereferenceCache.get(filesystem) as Promise<OpenAPIV3xDocument>;
}
const promise = dereferenceSchema(schema, url);
dereferenceSchemaCache.set(schema, promise);
const promise = dereferenceFilesystem(filesystem, url);
dereferenceCache.set(filesystem, promise);
return promise;
}
/**
* Dereference an OpenAPI schema.
*/
async function dereferenceSchema<T extends OpenAPI.Document>(schema: T, url: string): Promise<T> {
const derefResult = await dereference(schema);
async function dereferenceFilesystem(
filesystem: Filesystem,
url: string,
): Promise<OpenAPIV3xDocument> {
const result = await dereference(filesystem);
if (!derefResult.schema) {
if (!result.schema) {
throw new OpenAPIParseError(
'Failed to dereference OpenAPI document',
url,
@@ -119,7 +128,7 @@ async function dereferenceSchema<T extends OpenAPI.Document>(schema: T, url: str
);
}
return derefResult.schema as T;
return result.schema as OpenAPIV3xDocument;
}
/**
@@ -1,4 +1,4 @@
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { OpenAPIV3 } from '@gitbook/openapi-parser';
import { noReference } from './utils';
import { getExampleFromSchema } from '@scalar/oas-utils/spec-getters';
@@ -46,9 +46,12 @@ export function generateMediaTypeExample(
}
if (mediaType.examples) {
const example = mediaType.examples[Object.keys(mediaType.examples)[0]];
if (example) {
return noReference(example).value;
const key = Object.keys(mediaType.examples)[0];
if (key) {
const example = mediaType.examples[key];
if (example) {
return noReference(example).value;
}
}
}
-1
View File
@@ -1,4 +1,3 @@
export * from './fetchOpenAPIOperation';
export * from './OpenAPIOperation';
export * from './OpenAPIOperationContext';
export * from './parser';
@@ -1,14 +0,0 @@
import { expect, it } from 'bun:test';
import { readFile } from 'node:fs/promises';
import { parseOpenAPI } from '.';
it('should parse and not give a recursive structure', async () => {
const schema = await parseOpenAPI({
value: await readFile(new URL('./fixtures/spec-example.json', import.meta.url), 'utf-8'),
url: 'https://example.com',
parseMarkdown: async (input) => input,
});
JSON.stringify(schema);
expect(schema.openapi).toBe('3.0.0');
});
@@ -1,50 +0,0 @@
import { AnyObject } from '@scalar/openapi-parser';
import { traverse } from './traverse';
/**
* Parse descriptions in the spec to markdown.
*/
export async function parseDescriptions<T extends AnyObject>(input: {
specification: T;
parseMarkdown: (input: string) => Promise<string>;
}): Promise<T> {
const { specification, parseMarkdown } = input;
const promises: Record<string, Promise<string>> = {};
const results: Record<string, string> = {};
traverse(specification, (obj, path) => {
if (checkHasDescription(obj) && path) {
promises[hashPath(path)] = parseMarkdown(obj.description);
}
return obj;
});
await Promise.all(
Object.entries(promises).map(async ([key, promise]) => {
results[key] = await promise;
}),
);
return traverse(specification, (obj, path) => {
if (
checkHasDescription(obj) &&
typeof obj.description === 'string' &&
path &&
results[hashPath(path)]
) {
obj.description = results[hashPath(path)];
}
return obj;
}) as T;
}
/**
* Check if the object contains a description.
*/
function checkHasDescription(obj: AnyObject): obj is { description: string } {
return 'description' in obj && typeof obj.description === 'string';
}
/**
* Hash a path.
*/
function hashPath(path: string[]): string {
return path.join('/');
}
@@ -1,36 +0,0 @@
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;
}
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);
}
-44
View File
@@ -1,44 +0,0 @@
import { OpenAPICustomSpecProperties } from './types';
import { AnyApiDefinitionFormat, validate } from '@scalar/openapi-parser';
import { OpenAPIV3, OpenAPIV3_1 } from '@scalar/openapi-types';
import { OpenAPIParseError } from './error';
import { parseDescriptions } from './markdown';
/**
* 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;
parseMarkdown: (input: string) => Promise<string>;
}): Promise<
| OpenAPIV3.Document<OpenAPICustomSpecProperties>
| OpenAPIV3_1.Document<OpenAPICustomSpecProperties>
> {
const { value, url } = input;
const result = await validate(value);
// Spec is invalid, we stop here.
if (!result.specification) {
throw new OpenAPIParseError('Invalid OpenAPI document', url, 'invalid-spec');
}
if (result.version === '2.0') {
throw new OpenAPIParseError('Only OpenAPI v3 is supported', url, 'v2-spec');
}
const specification = await parseDescriptions({
specification: result.specification,
parseMarkdown: input.parseMarkdown,
});
switch (result.version) {
case '3.0':
return specification as OpenAPIV3.Document<OpenAPICustomSpecProperties>;
case '3.1':
default:
return specification as OpenAPIV3_1.Document<OpenAPICustomSpecProperties>;
}
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { OpenAPIV3 } from '@scalar/openapi-types';
import type { AnyObject, OpenAPIV3 } from '@gitbook/openapi-parser';
export function noReference<T>(input: T | OpenAPIV3.ReferenceObject): T {
if (checkIsReference(input)) {
@@ -15,3 +15,14 @@ export function checkIsReference(input: unknown): input is OpenAPIV3.ReferenceOb
export function createStateKey(key: string, scope?: string) {
return scope ? `${scope}_${key}` : key;
}
/**
* Resolve the description of an object.
*/
export function resolveDescription(object: AnyObject) {
return 'x-description-html' in object && typeof object['x-description-html'] === 'string'
? object['x-description-html']
: typeof object.description === 'string'
? object.description
: undefined;
}
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": ["./tsconfig.json"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"declaration": true,
"noEmit": false,
"outDir": "dist"
}
}
+20 -11
View File
@@ -1,24 +1,33 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": false,
"declaration": true,
"outDir": "dist",
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"checkJs": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"jsx": "preserve",
"types": [
"bun-types" // add Bun global
]
],
"outDir": "dist"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"ui": "stream",
"globalEnv": ["NODE_ENV", "CI", "ARGOS_*", "GITHUB_*", "GITBOOK_*", "SENTRY_*"],
"tasks": {
// Prepare the package for all other tasks