diff --git a/.changeset/rotten-cups-judge.md b/.changeset/rotten-cups-judge.md new file mode 100644 index 000000000..30f70322c --- /dev/null +++ b/.changeset/rotten-cups-judge.md @@ -0,0 +1,7 @@ +--- +'@gitbook/openapi-parser': major +'@gitbook/react-openapi': major +'gitbook': patch +--- + +Improve and split OpenAPI parser into its own package diff --git a/bun.lock b/bun.lock index 76568c221..d6494ab8b 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 6e6114a80..7f70cd3fa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index b48462d20..a9bf0e372 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -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:*", diff --git a/packages/gitbook/src/lib/openapi.ts b/packages/gitbook/src/lib/openapi.ts index b22796cc8..4ae19e9bc 100644 --- a/packages/gitbook/src/lib/openapi.ts +++ b/packages/gitbook/src/lib/openapi.ts @@ -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> = 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, }; }, }), diff --git a/packages/gitbook/tsconfig.json b/packages/gitbook/tsconfig.json index 4374d4750..b65719fb5 100644 --- a/packages/gitbook/tsconfig.json +++ b/packages/gitbook/tsconfig.json @@ -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" + ] } diff --git a/packages/openapi-parser/.gitignore b/packages/openapi-parser/.gitignore new file mode 100644 index 000000000..53c37a166 --- /dev/null +++ b/packages/openapi-parser/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/packages/openapi-parser/README.md b/packages/openapi-parser/README.md new file mode 100644 index 000000000..1434c32fb --- /dev/null +++ b/packages/openapi-parser/README.md @@ -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. \ No newline at end of file diff --git a/packages/openapi-parser/package.json b/packages/openapi-parser/package.json new file mode 100644 index 000000000..d2061cb20 --- /dev/null +++ b/packages/openapi-parser/package.json @@ -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" + ] +} diff --git a/packages/react-openapi/src/parser/error.ts b/packages/openapi-parser/src/error.ts similarity index 69% rename from packages/react-openapi/src/parser/error.ts rename to packages/openapi-parser/src/error.ts index cf7cded4d..3bcb109d4 100644 --- a/packages/react-openapi/src/parser/error.ts +++ b/packages/openapi-parser/src/error.ts @@ -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, diff --git a/packages/openapi-parser/src/filesystem.test.ts b/packages/openapi-parser/src/filesystem.test.ts new file mode 100644 index 000000000..32560f7f8 --- /dev/null +++ b/packages/openapi-parser/src/filesystem.test.ts @@ -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'); + }); +}); diff --git a/packages/openapi-parser/src/filesystem.ts b/packages/openapi-parser/src/filesystem.ts new file mode 100644 index 000000000..a651f33df --- /dev/null +++ b/packages/openapi-parser/src/filesystem.ts @@ -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 { + const { filesystem } = await load(input.value, { + plugins: [fetchUrls({ baseUrl: input.baseUrl })], + }); + return filesystem; +} diff --git a/packages/react-openapi/src/parser/fixtures/spec-example.json b/packages/openapi-parser/src/fixtures/recursive-spec.json similarity index 100% rename from packages/react-openapi/src/parser/fixtures/spec-example.json rename to packages/openapi-parser/src/fixtures/recursive-spec.json diff --git a/packages/openapi-parser/src/fixtures/remote-ref/root/pet.yaml b/packages/openapi-parser/src/fixtures/remote-ref/root/pet.yaml new file mode 100644 index 000000000..e4f09ae06 --- /dev/null +++ b/packages/openapi-parser/src/fixtures/remote-ref/root/pet.yaml @@ -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 diff --git a/packages/openapi-parser/src/fixtures/remote-ref/root/spec.yaml b/packages/openapi-parser/src/fixtures/remote-ref/root/spec.yaml new file mode 100644 index 000000000..a5cc9eabd --- /dev/null +++ b/packages/openapi-parser/src/fixtures/remote-ref/root/spec.yaml @@ -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 diff --git a/packages/openapi-parser/src/fixtures/remote-ref/root/user.yaml b/packages/openapi-parser/src/fixtures/remote-ref/root/user.yaml new file mode 100644 index 000000000..ac7db95c3 --- /dev/null +++ b/packages/openapi-parser/src/fixtures/remote-ref/root/user.yaml @@ -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 diff --git a/packages/openapi-parser/src/fixtures/remote-ref/tag.yaml b/packages/openapi-parser/src/fixtures/remote-ref/tag.yaml new file mode 100644 index 000000000..7d4b2fb2c --- /dev/null +++ b/packages/openapi-parser/src/fixtures/remote-ref/tag.yaml @@ -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 diff --git a/packages/openapi-parser/src/fixtures/spec-v2.json b/packages/openapi-parser/src/fixtures/spec-v2.json new file mode 100644 index 000000000..96c047068 --- /dev/null +++ b/packages/openapi-parser/src/fixtures/spec-v2.json @@ -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" } +} diff --git a/packages/openapi-parser/src/index.ts b/packages/openapi-parser/src/index.ts new file mode 100644 index 000000000..8e289deb2 --- /dev/null +++ b/packages/openapi-parser/src/index.ts @@ -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'; diff --git a/packages/openapi-parser/src/parse.test.ts b/packages/openapi-parser/src/parse.test.ts new file mode 100644 index 000000000..32dc98302 --- /dev/null +++ b/packages/openapi-parser/src/parse.test.ts @@ -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); + }); +}); diff --git a/packages/react-openapi/src/parser/index.ts b/packages/openapi-parser/src/parse.ts similarity index 74% rename from packages/react-openapi/src/parser/index.ts rename to packages/openapi-parser/src/parse.ts index 9bc94472b..bd73a19b3 100644 --- a/packages/react-openapi/src/parser/index.ts +++ b/packages/openapi-parser/src/parse.ts @@ -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; -}) { +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'; diff --git a/packages/openapi-parser/src/scalar-plugins/fetchURLs.ts b/packages/openapi-parser/src/scalar-plugins/fetchURLs.ts new file mode 100644 index 000000000..92edf725e --- /dev/null +++ b/packages/openapi-parser/src/scalar-plugins/fetchURLs.ts @@ -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; +} diff --git a/packages/openapi-parser/src/traverse.test.ts b/packages/openapi-parser/src/traverse.test.ts new file mode 100644 index 000000000..77e2a843d --- /dev/null +++ b/packages/openapi-parser/src/traverse.test.ts @@ -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!'); + }); +}); diff --git a/packages/openapi-parser/src/traverse.ts b/packages/openapi-parser/src/traverse.ts new file mode 100644 index 000000000..6d418749e --- /dev/null +++ b/packages/openapi-parser/src/traverse.ts @@ -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( + specification: T, + transform: (specification: AnyObject, path?: string[]) => AnyObject | Promise, + path: string[] = [], + seen = new WeakSet(), +): Promise { + const result: AnyObject = {}; + + if (typeof specification !== 'object' || specification === null) { + return specification as Promise; + } + + if (seen.has(specification)) { + return specification as Promise; + } + + if (Array.isArray(specification)) { + return Promise.all( + specification.map((item, index) => + traverse(item, transform, [...path, index.toString()], seen), + ), + ) as Promise; + } + + 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; +} diff --git a/packages/react-openapi/src/parser/types.ts b/packages/openapi-parser/src/types.ts similarity index 57% rename from packages/react-openapi/src/parser/types.ts rename to packages/openapi-parser/src/types.ts index 9edbe2596..218540511 100644 --- a/packages/react-openapi/src/parser/types.ts +++ b/packages/openapi-parser/src/types.ts @@ -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 + | OpenAPIV3.Document; + +/** + * 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 = FilesystemEntry[]; +/** + * Holds all information about a single file (doesn’t have to be a literal file, see Filesystem). + */ +export type FilesystemEntry = { + dir: string; + isEntrypoint: boolean; + references: string[]; + filename: string; + specification: T; +}; diff --git a/packages/openapi-parser/src/v2.test.ts b/packages/openapi-parser/src/v2.test.ts new file mode 100644 index 000000000..5588f1e52 --- /dev/null +++ b/packages/openapi-parser/src/v2.test.ts @@ -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'); + }); +}); diff --git a/packages/react-openapi/src/parser/v2.ts b/packages/openapi-parser/src/v2.ts similarity index 78% rename from packages/react-openapi/src/parser/v2.ts rename to packages/openapi-parser/src/v2.ts index f44064596..8bfe69483 100644 --- a/packages/react-openapi/src/parser/v2.ts +++ b/packages/openapi-parser/src/v2.ts @@ -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; -}): Promise< - | OpenAPIV3_1.Document - | OpenAPIV3.Document -> { - const { value, url, parseMarkdown } = input; +}): Promise> { + 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( diff --git a/packages/openapi-parser/src/v3.ts b/packages/openapi-parser/src/v3.ts new file mode 100644 index 000000000..08be7f40a --- /dev/null +++ b/packages/openapi-parser/src/v3.ts @@ -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> { + 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; +} diff --git a/packages/openapi-parser/tsconfig.build.json b/packages/openapi-parser/tsconfig.build.json new file mode 100644 index 000000000..ee97ea0d0 --- /dev/null +++ b/packages/openapi-parser/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["./tsconfig.json"], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "dist" + } +} diff --git a/packages/openapi-parser/tsconfig.json b/packages/openapi-parser/tsconfig.json new file mode 100644 index 000000000..c9b10d1b8 --- /dev/null +++ b/packages/openapi-parser/tsconfig.json @@ -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"] +} diff --git a/packages/react-openapi/package.json b/packages/react-openapi/package.json index b7b931242..a728afe75 100644 --- a/packages/react-openapi/package.json +++ b/packages/react-openapi/package.json @@ -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": [ diff --git a/packages/react-openapi/src/InteractiveSection.tsx b/packages/react-openapi/src/InteractiveSection.tsx index e8bfa91bb..adcd1f8b2 100644 --- a/packages/react-openapi/src/InteractiveSection.tsx +++ b/packages/react-openapi/src/InteractiveSection.tsx @@ -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(null); - const triggerRef = React.useRef(null); + const panelRef = useRef(null); + const triggerRef = useRef(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 (
{tabs.length > 1 ? ( event.stopPropagation()} value={selectedTab?.id} onChange={(event) => { diff --git a/packages/react-openapi/src/OpenAPIOperation.tsx b/packages/react-openapi/src/OpenAPIOperation.tsx index 2b348ce51..40a2bf0cd 100644 --- a/packages/react-openapi/src/OpenAPIOperation.tsx +++ b/packages/react-openapi/src/OpenAPIOperation.tsx @@ -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 ( -
+

{operation.summary}

{operation.deprecated &&
Deprecated
}
-
-
+
+
{operation['x-deprecated-sunset'] ? (
This operation is deprecated and will be sunset on{' '} @@ -47,16 +47,16 @@ export function OpenAPIOperation(props: { {`.`}
) : null} - {trimmedDescription ? ( + {description ? (
- +
) : null}
-
-
+
+
diff --git a/packages/react-openapi/src/OpenAPIOperationContext.tsx b/packages/react-openapi/src/OpenAPIOperationContext.tsx index b89b20b3b..2dcf77d10 100644 --- a/packages/react-openapi/src/OpenAPIOperationContext.tsx +++ b/packages/react-openapi/src/OpenAPIOperationContext.tsx @@ -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({ +const OpenAPIOperationContext = createContext({ onOpenClient: () => {}, }); @@ -27,7 +28,7 @@ export function OpenAPIOperationContextProvider( props.onOpenClient?.(pointer); }); - const value = React.useMemo(() => ({ onOpenClient }), [onOpenClient]); + const value = useMemo(() => ({ onOpenClient }), [onOpenClient]); return ( @@ -40,5 +41,5 @@ export function OpenAPIOperationContextProvider( * Hook to access the OpenAPIOperationContext. */ export function useOpenAPIOperationContext() { - return React.useContext(OpenAPIOperationContext); + return useContext(OpenAPIOperationContext); } diff --git a/packages/react-openapi/src/OpenAPIPath.tsx b/packages/react-openapi/src/OpenAPIPath.tsx index 954162625..ef200782c 100644 --- a/packages/react-openapi/src/OpenAPIPath.tsx +++ b/packages/react-openapi/src/OpenAPIPath.tsx @@ -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. diff --git a/packages/react-openapi/src/OpenAPIRequestBody.tsx b/packages/react-openapi/src/OpenAPIRequestBody.tsx index cc58a3eea..e03e36bc1 100644 --- a/packages/react-openapi/src/OpenAPIRequestBody.tsx +++ b/packages/react-openapi/src/OpenAPIRequestBody.tsx @@ -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'; /** diff --git a/packages/react-openapi/src/OpenAPIResponse.tsx b/packages/react-openapi/src/OpenAPIResponse.tsx index a2f0d3701..448abbc66 100644 --- a/packages/react-openapi/src/OpenAPIResponse.tsx +++ b/packages/react-openapi/src/OpenAPIResponse.tsx @@ -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: { /> ) : null} -
+
, }; } @@ -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 ? ( { const content = Object.entries(response.content ?? {}); + const description = resolveDescription(response); return { id: statusCode, @@ -42,9 +39,9 @@ export function OpenAPIResponses(props: { {statusCode} - {response.description ? ( + {description ? ( ) : null} diff --git a/packages/react-openapi/src/OpenAPISchema.test.ts b/packages/react-openapi/src/OpenAPISchema.test.ts index 954a9eefb..915e4bb88 100644 --- a/packages/react-openapi/src/OpenAPISchema.test.ts +++ b/packages/react-openapi/src/OpenAPISchema.test.ts @@ -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', () => { diff --git a/packages/react-openapi/src/OpenAPISchema.tsx b/packages/react-openapi/src/OpenAPISchema.tsx index f4f98681c..efb2e2c23 100644 --- a/packages/react-openapi/src/OpenAPISchema.tsx +++ b/packages/react-openapi/src/OpenAPISchema.tsx @@ -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 ( - + {properties && properties.length > 0 ? ( @@ -66,10 +66,11 @@ export function OpenAPISchemaProperty( if (alternatives?.[0]?.length) { return ( - + {alternatives[0].map((alternative, index) => ( + {(properties && properties.length > 0) || (schema.enum && schema.enum.length > 0) || @@ -118,7 +119,7 @@ export function OpenAPISchemaProperties(props: { } return ( -
+
{properties.map((property) => ( +
) : null} - {schema.description ? ( - + {description ? ( + ) : null} {shouldDisplayExample(schema) ? (
diff --git a/packages/react-openapi/src/OpenAPISchemaName.tsx b/packages/react-openapi/src/OpenAPISchemaName.tsx index 36d9a0479..c3ff40e26 100644 --- a/packages/react-openapi/src/OpenAPISchemaName.tsx +++ b/packages/react-openapi/src/OpenAPISchemaName.tsx @@ -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 ( -
+
{propertyName ? ( - + {propertyName} ) : null} - {type ? {type} : null} - {required ? ( - required - ) : null} - {deprecated ? ( - Deprecated - ) : null} + {type ? {type} : null} + {required ? required : null} + {deprecated ? Deprecated : null}
); } diff --git a/packages/react-openapi/src/OpenAPISecurities.tsx b/packages/react-openapi/src/OpenAPISecurities.tsx index 493d64d98..b766560a7 100644 --- a/packages/react-openapi/src/OpenAPISecurities.tsx +++ b/packages/react-openapi/src/OpenAPISecurities.tsx @@ -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: {
{getLabelForType(security)} - {security.description ? ( + {description ? ( ) : null} @@ -66,11 +67,12 @@ function getLabelForType(security: OpenAPIV3_1.SecuritySchemeObject) { } if (security.scheme == 'bearer') { + const description = resolveDescription(security); return ( <> {/** Show a default description if none is provided */} - {!security.description ? ( + {!description ? ( {part.text}; } else { - if (!server.variables?.[part.name]) { + const variable = server.variables?.[part.name]; + if (!variable) { return {`{${part.name}}`}; } return ( - + ); } })} @@ -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; diff --git a/packages/react-openapi/src/OpenAPIServerURLVariable.tsx b/packages/react-openapi/src/OpenAPIServerURLVariable.tsx index c777432a7..8269d6083 100644 --- a/packages/react-openapi/src/OpenAPIServerURLVariable.tsx +++ b/packages/react-openapi/src/OpenAPIServerURLVariable.tsx @@ -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 {variable.default}; + return {variable.default}; } diff --git a/packages/react-openapi/src/OpenAPISpec.tsx b/packages/react-openapi/src/OpenAPISpec.tsx index 1b83500f0..2a93c19af 100644 --- a/packages/react-openapi/src/OpenAPISpec.tsx +++ b/packages/react-openapi/src/OpenAPISpec.tsx @@ -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 ) : null} - {parameterGroups.map((group) => ( - - ({ - 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} - /> - - ))} + {parameterGroups.map((group) => { + return ( + + { + 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} + /> + + ); + })} {operation.requestBody ? ( void; }; -const OpenAPITabsContext = React.createContext(null); +const OpenAPITabsContext = createContext(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) { 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 }; diff --git a/packages/react-openapi/src/ScalarApiButton.tsx b/packages/react-openapi/src/ScalarApiButton.tsx index 8c16086c7..f9ab2f050 100644 --- a/packages/react-openapi/src/ScalarApiButton.tsx +++ b/packages/react-openapi/src/ScalarApiButton.tsx @@ -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(null); return (
@@ -96,7 +96,7 @@ function ScalarModalController(props: { const trackOpening = useEventCallback(() => { onOpenClient({ method: props.method, path: props.path }); }); - React.useEffect(() => { + useEffect(() => { if (openClient) { openClient(); trackOpening(); diff --git a/packages/react-openapi/src/code-samples.ts b/packages/react-openapi/src/code-samples.ts index a1dbdb602..4f5339e13 100644 --- a/packages/react-openapi/src/code-samples.ts +++ b/packages/react-openapi/src/code-samples.ts @@ -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('/'); diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts index c801e0d5f..6d7d6987c 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.test.ts @@ -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; }, }; diff --git a/packages/react-openapi/src/fetchOpenAPIOperation.ts b/packages/react-openapi/src/fetchOpenAPIOperation.ts index eeb71e643..6ecc4df42 100644 --- a/packages/react-openapi/src/fetchOpenAPIOperation.ts +++ b/packages/react-openapi/src/fetchOpenAPIOperation.ts @@ -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 - | OpenAPIV3.Document - >; + fetch: (url: string) => Promise>; } 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; /** Securities that should be used for this operation */ securities: [string, OpenAPIV3.SecuritySchemeObject][]; @@ -44,9 +46,8 @@ export async function fetchOpenAPIOperation( }, fetcher: OpenAPIFetcher, ): Promise { - 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>(); +const dereferenceCache = new WeakMap>(); /** * Memoized version of `dereferenceSchema`. */ -function memoDereferenceSchema(schema: T, url: string): Promise { - if (dereferenceSchemaCache.has(schema)) { - return dereferenceSchemaCache.get(schema) as Promise; +function memoDereferenceFilesystem( + filesystem: Filesystem, + url: string, +): Promise { + if (dereferenceCache.has(filesystem)) { + return dereferenceCache.get(filesystem) as Promise; } - 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(schema: T, url: string): Promise { - const derefResult = await dereference(schema); +async function dereferenceFilesystem( + filesystem: Filesystem, + url: string, +): Promise { + 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(schema: T, url: str ); } - return derefResult.schema as T; + return result.schema as OpenAPIV3xDocument; } /** diff --git a/packages/react-openapi/src/generateSchemaExample.ts b/packages/react-openapi/src/generateSchemaExample.ts index b725437ff..9c0642e92 100644 --- a/packages/react-openapi/src/generateSchemaExample.ts +++ b/packages/react-openapi/src/generateSchemaExample.ts @@ -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; + } } } diff --git a/packages/react-openapi/src/index.ts b/packages/react-openapi/src/index.ts index bc0a33fe5..b78fa3aed 100644 --- a/packages/react-openapi/src/index.ts +++ b/packages/react-openapi/src/index.ts @@ -1,4 +1,3 @@ export * from './fetchOpenAPIOperation'; export * from './OpenAPIOperation'; export * from './OpenAPIOperationContext'; -export * from './parser'; diff --git a/packages/react-openapi/src/parser/index.test.ts b/packages/react-openapi/src/parser/index.test.ts deleted file mode 100644 index 4973caa96..000000000 --- a/packages/react-openapi/src/parser/index.test.ts +++ /dev/null @@ -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'); -}); diff --git a/packages/react-openapi/src/parser/markdown.ts b/packages/react-openapi/src/parser/markdown.ts deleted file mode 100644 index 1291f1acc..000000000 --- a/packages/react-openapi/src/parser/markdown.ts +++ /dev/null @@ -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(input: { - specification: T; - parseMarkdown: (input: string) => Promise; -}): Promise { - const { specification, parseMarkdown } = input; - const promises: Record> = {}; - const results: Record = {}; - 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('/'); -} diff --git a/packages/react-openapi/src/parser/traverse.ts b/packages/react-openapi/src/parser/traverse.ts deleted file mode 100644 index 3c0f9a61c..000000000 --- a/packages/react-openapi/src/parser/traverse.ts +++ /dev/null @@ -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); -} diff --git a/packages/react-openapi/src/parser/v3.ts b/packages/react-openapi/src/parser/v3.ts deleted file mode 100644 index c719ae898..000000000 --- a/packages/react-openapi/src/parser/v3.ts +++ /dev/null @@ -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; -}): Promise< - | OpenAPIV3.Document - | OpenAPIV3_1.Document -> { - 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; - case '3.1': - default: - return specification as OpenAPIV3_1.Document; - } -} diff --git a/packages/react-openapi/src/utils.ts b/packages/react-openapi/src/utils.ts index 49e7ef3ca..bf4de4f1f 100644 --- a/packages/react-openapi/src/utils.ts +++ b/packages/react-openapi/src/utils.ts @@ -1,4 +1,4 @@ -import { OpenAPIV3 } from '@scalar/openapi-types'; +import type { AnyObject, OpenAPIV3 } from '@gitbook/openapi-parser'; export function noReference(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; +} diff --git a/packages/react-openapi/tsconfig.build.json b/packages/react-openapi/tsconfig.build.json new file mode 100644 index 000000000..ee97ea0d0 --- /dev/null +++ b/packages/react-openapi/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": ["./tsconfig.json"], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "dist" + } +} diff --git a/packages/react-openapi/tsconfig.json b/packages/react-openapi/tsconfig.json index 92db2d902..d6062bf04 100644 --- a/packages/react-openapi/tsconfig.json +++ b/packages/react-openapi/tsconfig.json @@ -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"] } diff --git a/turbo.json b/turbo.json index 6a427ad3d..e3b62ffa8 100644 --- a/turbo.json +++ b/turbo.json @@ -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