├── .gitignore ├── .prettierignore ├── pnpm-workspace.yaml ├── src ├── graphql │ ├── index.ts │ ├── fetchQuery.ts │ └── parseGraphQl.ts ├── swagger │ ├── index.ts │ ├── parseSwaggerDocumentation.ts │ ├── handleJson.ts │ └── handleJson.test.ts ├── openapi3 │ ├── index.ts │ ├── parseOpenApi3Documentation.ts │ ├── dereferencedOpenApiv3.ts │ ├── handleJson.ts │ └── handleJson.test.ts ├── core │ ├── utils │ │ ├── removeTrailingSlash.ts │ │ ├── index.ts │ │ ├── getResourcePaths.ts │ │ ├── getResources.test.ts │ │ ├── parsedJsonReplacer.ts │ │ ├── getType.ts │ │ └── buildEnumObject.ts │ ├── index.ts │ ├── types.ts │ ├── Api.ts │ ├── Parameter.ts │ ├── Operation.ts │ ├── Resource.ts │ └── Field.ts ├── index.ts └── hydra │ ├── index.ts │ ├── fetchResource.ts │ ├── getParameters.ts │ ├── fetchJsonLd.ts │ ├── getType.ts │ ├── fetchJsonLd.test.ts │ ├── types.ts │ └── parseHydraDocumentation.ts ├── tsconfig.build.json ├── vitest.setup.ts ├── .editorconfig ├── vitest.config.ts ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── LICENSE ├── tsconfig.json ├── package.json ├── .oxlintrc.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | /node_modules/ 3 | coverage -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | pnpm-workspace.yaml -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - '.' 3 | 4 | onlyBuiltDependencies: 5 | - esbuild 6 | - msw 7 | - oxc-resolver 8 | -------------------------------------------------------------------------------- /src/graphql/index.ts: -------------------------------------------------------------------------------- 1 | export { default as fetchQuery } from "./fetchQuery.js"; 2 | export { default as parseGraphQl } from "./parseGraphQl.js"; 3 | -------------------------------------------------------------------------------- /src/swagger/index.ts: -------------------------------------------------------------------------------- 1 | export { default as parseSwaggerDocumentation } from "./parseSwaggerDocumentation.js"; 2 | export * from "./parseSwaggerDocumentation.js"; 3 | -------------------------------------------------------------------------------- /src/openapi3/index.ts: -------------------------------------------------------------------------------- 1 | export { default as parseOpenApi3Documentation } from "./parseOpenApi3Documentation.js"; 2 | export * from "./parseOpenApi3Documentation.js"; 3 | -------------------------------------------------------------------------------- /src/core/utils/removeTrailingSlash.ts: -------------------------------------------------------------------------------- 1 | export function removeTrailingSlash(url: string): string { 2 | if (url.endsWith("/")) { 3 | return url.slice(0, -1); 4 | } 5 | 6 | return url; 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core/index.js"; 2 | export * from "./graphql/index.js"; 3 | export * from "./hydra/index.js"; 4 | export * from "./openapi3/index.js"; 5 | export * from "./swagger/index.js"; 6 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Api.js"; 2 | export * from "./Field.js"; 3 | export * from "./Operation.js"; 4 | export * from "./Parameter.js"; 5 | export * from "./Resource.js"; 6 | export * from "./types.js"; 7 | -------------------------------------------------------------------------------- /src/hydra/index.ts: -------------------------------------------------------------------------------- 1 | export { default as fetchJsonLd } from "./fetchJsonLd.js"; 2 | export { 3 | default as parseHydraDocumentation, 4 | getDocumentationUrlFromHeaders, 5 | } from "./parseHydraDocumentation.js"; 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | /* Emit */ 5 | "rootDir": "src" 6 | }, 7 | "include": ["./src"], 8 | "exclude": ["./src/**/*.test.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | export type Nullable> = { 2 | [P in keyof T]: T[P] | null; 3 | }; 4 | 5 | export interface RequestInitExtended extends Omit { 6 | headers?: HeadersInit | (() => HeadersInit); 7 | } 8 | -------------------------------------------------------------------------------- /src/core/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { buildEnumObject } from "./buildEnumObject.js"; 2 | export { getResourcePaths } from "./getResourcePaths.js"; 3 | export { getType } from "./getType.js"; 4 | export { parsedJsonReplacer } from "./parsedJsonReplacer.js"; 5 | export { removeTrailingSlash } from "./removeTrailingSlash.js"; 6 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import type { SetupServerApi } from "msw/node"; 2 | import { setupServer } from "msw/node"; 3 | import { afterAll, afterEach, beforeAll } from "vitest"; 4 | 5 | export const server: SetupServerApi = setupServer(); 6 | 7 | beforeAll(() => server.listen()); 8 | afterEach(() => server.resetHandlers()); 9 | afterAll(() => server.close()); 10 | -------------------------------------------------------------------------------- /src/core/utils/getResourcePaths.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV2, OpenAPIV3 } from "openapi-types"; 2 | 3 | export function getResourcePaths( 4 | paths: OpenAPIV2.PathsObject | OpenAPIV3.PathsObject, 5 | ): string[] { 6 | return [ 7 | ...new Set( 8 | Object.keys(paths).filter((path) => 9 | new RegExp("^[^{}]+/{[^{}]+}/?$").test(path), 10 | ), 11 | ), 12 | ]; 13 | } 14 | -------------------------------------------------------------------------------- /src/core/utils/getResources.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import { getResourcePaths } from "./index.js"; 3 | 4 | test("should get resource paths", () => { 5 | const paths = { 6 | "/test": {}, 7 | "/test/{id}": {}, 8 | "/test/{id}/subpath": {}, 9 | "/foo": {}, 10 | "/test/bar": {}, 11 | }; 12 | 13 | const resources = getResourcePaths(paths); 14 | expect(resources).toEqual(["/test/{id}"]); 15 | }); 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /src/core/utils/parsedJsonReplacer.ts: -------------------------------------------------------------------------------- 1 | interface ResourceValue { 2 | id?: string; 3 | title: string; 4 | } 5 | 6 | type ParsedJsonReplacerResult = ResourceValue | string | null; 7 | 8 | export function parsedJsonReplacer( 9 | key: string, 10 | value: null | ResourceValue, 11 | ): ParsedJsonReplacerResult { 12 | if ( 13 | ["reference", "embedded"].includes(key) && 14 | typeof value === "object" && 15 | value !== null 16 | ) { 17 | return `Object ${value.id || value.title}`; 18 | } 19 | 20 | return value; 21 | } 22 | -------------------------------------------------------------------------------- /src/core/Api.ts: -------------------------------------------------------------------------------- 1 | import type { Resource } from "./Resource.js"; 2 | import type { Nullable } from "./types.js"; 3 | 4 | export interface ApiOptions 5 | extends Nullable<{ 6 | title?: string; 7 | resources?: Resource[]; 8 | }> {} 9 | 10 | export class Api implements ApiOptions { 11 | entrypoint: string; 12 | 13 | title?: string | null; 14 | resources?: Resource[] | null; 15 | 16 | constructor(entrypoint: string, options: ApiOptions = {}) { 17 | this.entrypoint = entrypoint; 18 | Object.assign(this, options); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/Parameter.ts: -------------------------------------------------------------------------------- 1 | export class Parameter { 2 | variable: string; 3 | range: string | null; 4 | required: boolean; 5 | description: string; 6 | deprecated?: boolean | undefined; 7 | 8 | constructor( 9 | variable: string, 10 | range: string | null, 11 | required: boolean, 12 | description: string, 13 | deprecated?: boolean, 14 | ) { 15 | this.variable = variable; 16 | this.range = range; 17 | this.required = required; 18 | this.description = description; 19 | this.deprecated = deprecated; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { coverageConfigDefaults, defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ["src/**/*.test.ts"], 7 | setupFiles: ["vitest.setup.ts"], 8 | coverage: { 9 | provider: "v8", 10 | reporter: ["text", "json-summary", "json"], 11 | include: ["src/**/*.ts"], 12 | exclude: ["src/**/{index,types}.ts", ...coverageConfigDefaults.exclude], 13 | experimentalAstAwareRemapping: true, 14 | ignoreEmptyLines: true, 15 | all: true, 16 | thresholds: { 17 | statements: 70, 18 | branches: 58, 19 | functions: 70, 20 | lines: 70, 21 | }, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/core/Operation.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "./types.js"; 2 | 3 | export type OperationType = "show" | "edit" | "delete" | "list" | "create"; 4 | 5 | export interface OperationOptions 6 | extends Nullable<{ 7 | method?: string; 8 | expects?: any; 9 | returns?: string; 10 | types?: string[]; 11 | deprecated?: boolean; 12 | }> {} 13 | 14 | export class Operation implements OperationOptions { 15 | name: string; 16 | type: OperationType; 17 | 18 | method?: string | null; 19 | expects?: any | null; 20 | returns?: string | null; 21 | types?: string[] | null; 22 | deprecated?: boolean | null; 23 | 24 | constructor( 25 | name: string, 26 | type: OperationType, 27 | options: OperationOptions = {}, 28 | ) { 29 | this.name = name; 30 | this.type = type; 31 | 32 | Object.assign(this, options); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/hydra/fetchResource.ts: -------------------------------------------------------------------------------- 1 | import type { RequestInitExtended } from "../core/types.js"; 2 | import fetchJsonLd from "./fetchJsonLd.js"; 3 | import type { IriTemplateMapping } from "./types.js"; 4 | 5 | export default async function fetchResource( 6 | resourceUrl: string, 7 | options: RequestInitExtended = {}, 8 | ): Promise<{ parameters: IriTemplateMapping[] }> { 9 | const response = await fetchJsonLd( 10 | resourceUrl, 11 | // oxlint-disable-next-line prefer-object-spread 12 | Object.assign({ itemsPerPage: 0 }, options), 13 | ); 14 | 15 | let hasPrefix = true; 16 | if ("body" in response) { 17 | hasPrefix = "hydra:search" in response.body; 18 | } 19 | return { 20 | parameters: (hasPrefix 21 | ? (response as any)?.body?.["hydra:search"]?.["hydra:mapping"] 22 | : (response as any)?.body?.search 23 | ?.mapping) as unknown as IriTemplateMapping[], 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/hydra/getParameters.ts: -------------------------------------------------------------------------------- 1 | import type { Resource } from "../core/index.js"; 2 | import { Parameter } from "../core/index.js"; 3 | import type { RequestInitExtended } from "../core/types.js"; 4 | import fetchResource from "./fetchResource.js"; 5 | 6 | export default async function getParameters( 7 | resource: Resource, 8 | options: RequestInitExtended = {}, 9 | ): Promise { 10 | const { parameters = [] } = await fetchResource(resource.url, options); 11 | const resourceParameters: Parameter[] = []; 12 | for (const { property = null, required, variable } of parameters) { 13 | if (property === null) { 14 | continue; 15 | } 16 | 17 | const { range = null } = 18 | resource.fields?.find(({ name }) => property === name) || {}; 19 | 20 | resourceParameters.push(new Parameter(variable, range, required, "")); 21 | } 22 | resource.parameters = resourceParameters; 23 | 24 | return resourceParameters; 25 | } 26 | -------------------------------------------------------------------------------- /src/core/utils/getType.ts: -------------------------------------------------------------------------------- 1 | import { camelize } from "inflection"; 2 | import type { FieldType } from "../Field.js"; 3 | 4 | /** 5 | * Returns the corresponding FieldType for a given OpenAPI type and optional format. 6 | * 7 | * If a format is provided, it will map certain formats (e.g., "int32", "int64") to "integer". 8 | * Otherwise, it will camelize the format string. If no format is provided, it returns the OpenAPI type. 9 | * 10 | * @param {string} openApiType - The OpenAPI type string. 11 | * @param {string} [format] - An optional format string. 12 | * @returns {FieldType} The mapped FieldType. 13 | */ 14 | export function getType(openApiType: string, format?: string): FieldType { 15 | if (format) { 16 | switch (format) { 17 | case "int32": 18 | case "int64": 19 | return "integer"; 20 | default: 21 | return camelize(format.replace("-", "_"), true); 22 | } 23 | } 24 | 25 | return openApiType; 26 | } 27 | -------------------------------------------------------------------------------- /src/core/utils/buildEnumObject.ts: -------------------------------------------------------------------------------- 1 | import { humanize } from "inflection"; 2 | 3 | /** 4 | * Builds an object from an array of enum values. 5 | * The keys of the object are the humanized versions of the enum values, 6 | * and the values are the original enum values. 7 | * 8 | * @param {any[] | undefined} enumArray - An array of enum values. 9 | * @returns {Record | null} An object mapping humanized enum names to their original values, or null if the input is empty. 10 | */ 11 | export function buildEnumObject( 12 | enumArray: any[] | undefined, 13 | ): Record | null { 14 | if (!enumArray || enumArray.length === 0) { 15 | return null; 16 | } 17 | 18 | return Object.fromEntries( 19 | // Object.values is used because the array is annotated: it contains the __meta symbol used by jsonref. 20 | Object.values(enumArray).map((enumValue: string | number) => [ 21 | typeof enumValue === "string" ? humanize(enumValue) : enumValue, 22 | enumValue, 23 | ]), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: ~ 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | release: 11 | name: Create and publish a release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup pnpm 18 | uses: pnpm/action-setup@v4 19 | with: 20 | run_install: false 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 22 26 | cache: "pnpm" 27 | 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | 31 | - name: Check formatting 32 | run: pnpm format:check 33 | 34 | - name: Lint 35 | run: pnpm lint 36 | 37 | - name: Run tests 38 | run: pnpm test 39 | 40 | - name: Build 41 | run: pnpm build 42 | 43 | - name: Publish to npm 44 | run: pnpm publish 45 | env: 46 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kévin Dunglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Type checking */ 4 | "exactOptionalPropertyTypes": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "noFallthroughCasesInSwitch": true, 7 | "noImplicitOverride": true, 8 | "noImplicitReturns": true, 9 | "noUncheckedIndexedAccess": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "strict": true, 14 | 15 | /* Modules */ 16 | "module": "NodeNext", 17 | "noUncheckedSideEffectImports": true, 18 | "types": [], 19 | 20 | /* Language and Environment */ 21 | "lib": ["ES2022", "DOM"], 22 | "moduleDetection": "force", 23 | "target": "ES2022", 24 | 25 | /* Output Formatting */ 26 | "noErrorTruncation": true, 27 | 28 | /* Emit */ 29 | "declaration": true, 30 | "declarationMap": true, 31 | "inlineSources": true, 32 | "outDir": "lib", 33 | "sourceMap": true, 34 | 35 | /* Interop Constraints */ 36 | "erasableSyntaxOnly": true, 37 | "isolatedDeclarations": true, 38 | "verbatimModuleSyntax": true 39 | }, 40 | "include": ["./src"] 41 | } 42 | -------------------------------------------------------------------------------- /src/graphql/fetchQuery.ts: -------------------------------------------------------------------------------- 1 | import type { ExecutionResult } from "graphql"; 2 | 3 | function setOptions(query: string, options: RequestInit): RequestInit { 4 | if (!options.method) { 5 | options.method = "POST"; 6 | } 7 | 8 | if (!(options.headers instanceof Headers)) { 9 | options.headers = new Headers(options.headers); 10 | } 11 | 12 | if (options.headers.get("Content-Type") === null) { 13 | options.headers.set("Content-Type", "application/json"); 14 | } 15 | 16 | if (options.method !== "GET" && !options.body) { 17 | options.body = JSON.stringify({ query }); 18 | } 19 | 20 | return options; 21 | } 22 | 23 | export default async function fetchQuery>( 24 | url: string, 25 | query: string, 26 | options: RequestInit = {}, 27 | ): Promise<{ 28 | response: Response; 29 | body: ExecutionResult; 30 | }> { 31 | const response = await fetch(url, setOptions(query, options)); 32 | const body = (await response.json()) as ExecutionResult; 33 | 34 | if (body?.errors) { 35 | // oxlint-disable-next-line no-throw-literal 36 | throw { response, body }; 37 | } 38 | 39 | return { response, body }; 40 | } 41 | -------------------------------------------------------------------------------- /src/core/Resource.ts: -------------------------------------------------------------------------------- 1 | import type { Field } from "./Field.js"; 2 | import type { Operation } from "./Operation.js"; 3 | import type { Parameter } from "./Parameter.js"; 4 | import type { Nullable } from "./types.js"; 5 | 6 | export interface ResourceOptions 7 | extends Nullable<{ 8 | id?: string; 9 | title?: string; 10 | description?: string; 11 | fields?: Field[]; 12 | readableFields?: Field[]; 13 | writableFields?: Field[]; 14 | getParameters?: () => Promise; 15 | operations?: Operation[]; 16 | deprecated?: boolean; 17 | parameters?: Parameter[]; 18 | }> {} 19 | 20 | export class Resource implements ResourceOptions { 21 | name: string; 22 | url: string; 23 | 24 | id?: string | null; 25 | title?: string | null; 26 | description?: string | null; 27 | fields?: Field[] | null; 28 | readableFields?: Field[] | null; 29 | writableFields?: Field[] | null; 30 | getParameters?: (() => Promise) | null; 31 | operations?: Operation[] | null; 32 | deprecated?: boolean | null; 33 | parameters?: Parameter[] | null; 34 | 35 | constructor(name: string, url: string, options: ResourceOptions = {}) { 36 | this.name = name; 37 | this.url = url; 38 | Object.assign(this, options); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: ["**"] 6 | push: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | ci: 15 | name: Continuous integration 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest] 20 | node: [20, 22, 24] 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup pnpm 27 | uses: pnpm/action-setup@v4 28 | with: 29 | run_install: false 30 | 31 | - name: Setup Node 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ matrix.node }} 35 | cache: "pnpm" 36 | 37 | - name: Install dependencies 38 | run: pnpm install --frozen-lockfile 39 | 40 | - name: Check formatting 41 | run: pnpm format:check 42 | 43 | - name: Lint 44 | run: pnpm lint 45 | 46 | - name: Check types 47 | run: pnpm typecheck 48 | 49 | - name: Run tests with coverage 50 | run: pnpm test:coverage 51 | 52 | - name: Report test coverage 53 | uses: davelosert/vitest-coverage-report-action@v2 54 | 55 | - name: Check unused files and dependencies with knip 56 | run: pnpm run knip 57 | -------------------------------------------------------------------------------- /src/swagger/parseSwaggerDocumentation.ts: -------------------------------------------------------------------------------- 1 | // oxlint-disable prefer-await-to-then 2 | import type { OpenAPIV2 } from "openapi-types"; 3 | import { Api } from "../core/Api.js"; 4 | import { removeTrailingSlash } from "../core/utils/index.js"; 5 | import handleJson from "./handleJson.js"; 6 | 7 | export interface ParsedSwaggerDocumentation { 8 | api: Api; 9 | response: OpenAPIV2.Document; 10 | status: number; 11 | } 12 | 13 | export default function parseSwaggerDocumentation( 14 | entrypointUrl: string, 15 | ): Promise { 16 | entrypointUrl = removeTrailingSlash(entrypointUrl); 17 | return fetch(entrypointUrl) 18 | .then((res) => Promise.all([res, res.json()])) 19 | .then( 20 | ([res, response]: [res: Response, response: OpenAPIV2.Document]) => { 21 | const title = response.info.title; 22 | const resources = handleJson(response, entrypointUrl); 23 | 24 | return { 25 | api: new Api(entrypointUrl, { title, resources }), 26 | response, 27 | status: res.status, 28 | }; 29 | }, 30 | ([res, response]: [res: Response, response: OpenAPIV2.Document]) => { 31 | // oxlint-disable-next-line no-throw-literal 32 | throw { 33 | api: new Api(entrypointUrl, { resources: [] }), 34 | response, 35 | status: res.status, 36 | }; 37 | }, 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/core/Field.ts: -------------------------------------------------------------------------------- 1 | import type { Resource } from "./Resource.js"; 2 | import type { Nullable } from "./types.js"; 3 | 4 | export type FieldType = 5 | | "string" 6 | | "integer" 7 | | "negativeInteger" 8 | | "nonNegativeInteger" 9 | | "positiveInteger" 10 | | "nonPositiveInteger" 11 | | "number" 12 | | "decimal" 13 | | "double" 14 | | "float" 15 | | "boolean" 16 | | "date" 17 | | "dateTime" 18 | | "duration" 19 | | "time" 20 | | "byte" 21 | | "binary" 22 | | "hexBinary" 23 | | "base64Binary" 24 | | "array" 25 | | "object" 26 | | "email" 27 | | "url" 28 | | "uuid" 29 | | "password" 30 | // oxlint-disable-next-line ban-types 31 | | ({} & string); // Allow any other string type 32 | 33 | export interface FieldOptions 34 | extends Nullable<{ 35 | id?: string; 36 | range?: string; 37 | type?: FieldType; 38 | arrayType?: FieldType; 39 | enum?: { [key: string | number]: string | number }; 40 | reference?: string | Resource; 41 | embedded?: Resource; 42 | nullable?: boolean; 43 | required?: boolean; 44 | description?: string; 45 | maxCardinality?: number; 46 | deprecated?: boolean; 47 | }> {} 48 | 49 | export class Field implements FieldOptions { 50 | name: string; 51 | 52 | id?: string | null; 53 | range?: string | null; 54 | type?: FieldType | null; 55 | arrayType?: FieldType | null; 56 | enum?: { [key: string | number]: string | number } | null; 57 | reference?: string | Resource | null; 58 | embedded?: Resource | null; 59 | nullable?: boolean | null; 60 | required?: boolean | null; 61 | description?: string | null; 62 | maxCardinality?: number | null; 63 | deprecated?: boolean | null; 64 | 65 | constructor(name: string, options: FieldOptions = {}) { 66 | this.name = name; 67 | Object.assign(this, options); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/openapi3/parseOpenApi3Documentation.ts: -------------------------------------------------------------------------------- 1 | // oxlint-disable prefer-await-to-then 2 | import type { OpenAPIV3 } from "openapi-types"; 3 | import { Api } from "../core/index.js"; 4 | import type { RequestInitExtended } from "../core/types.js"; 5 | import { removeTrailingSlash } from "../core/utils/index.js"; 6 | import handleJson from "./handleJson.js"; 7 | 8 | export interface ParsedOpenApi3Documentation { 9 | api: Api; 10 | response: OpenAPIV3.Document; 11 | status: number; 12 | } 13 | 14 | export default function parseOpenApi3Documentation( 15 | entrypointUrl: string, 16 | options: RequestInitExtended = {}, 17 | ): Promise { 18 | entrypointUrl = removeTrailingSlash(entrypointUrl); 19 | const headersObject = 20 | typeof options.headers === "function" ? options.headers() : options.headers; 21 | const headers = new Headers(headersObject); 22 | if (!headers.get("Accept")?.includes("application/vnd.openapi+json")) { 23 | headers.append("Accept", "application/vnd.openapi+json"); 24 | } 25 | 26 | return fetch(entrypointUrl, { ...options, headers: headers }) 27 | .then((res) => Promise.all([res, res.json()])) 28 | .then( 29 | ([res, response]: [res: Response, response: OpenAPIV3.Document]) => { 30 | const title = response.info.title; 31 | return handleJson(response, entrypointUrl).then((resources) => ({ 32 | api: new Api(entrypointUrl, { title, resources }), 33 | response, 34 | status: res.status, 35 | })); 36 | }, 37 | ([res, response]: [res: Response, response: OpenAPIV3.Document]) => { 38 | // oxlint-disable-next-line no-throw-literal 39 | throw { 40 | api: new Api(entrypointUrl, { resources: [] }), 41 | response, 42 | status: res.status, 43 | }; 44 | }, 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/swagger/handleJson.ts: -------------------------------------------------------------------------------- 1 | import { classify, pluralize } from "inflection"; 2 | import type { OpenAPIV2 } from "openapi-types"; 3 | import { Field, Resource } from "../core/index.js"; 4 | import { 5 | buildEnumObject, 6 | getResourcePaths, 7 | getType, 8 | removeTrailingSlash, 9 | } from "../core/utils/index.js"; 10 | 11 | export default function handleJson( 12 | response: OpenAPIV2.Document, 13 | entrypointUrl: string, 14 | ): Resource[] { 15 | const paths = getResourcePaths(response.paths); 16 | 17 | return paths.map((path) => { 18 | const splittedPath = removeTrailingSlash(path).split("/"); 19 | const baseName = splittedPath[splittedPath.length - 2]; 20 | if (!baseName) { 21 | throw new Error("Invalid path: " + path); 22 | } 23 | 24 | const name = pluralize(baseName); 25 | const url = `${removeTrailingSlash(entrypointUrl)}/${name}`; 26 | 27 | const title = classify(baseName); 28 | 29 | if (!response.definitions) { 30 | throw new Error(); // @TODO 31 | } 32 | 33 | const definition = response.definitions[title]; 34 | 35 | if (!definition) { 36 | throw new Error(); // @TODO 37 | } 38 | 39 | const { description = "", properties } = definition; 40 | 41 | if (!properties) { 42 | throw new Error(); // @TODO 43 | } 44 | 45 | const requiredFields = response.definitions?.[title]?.required ?? []; 46 | 47 | const fields = Object.entries(properties).map( 48 | ([fieldName, property]) => 49 | new Field(fieldName, { 50 | id: null, 51 | range: null, 52 | type: getType( 53 | typeof property?.type === "string" ? property.type : "", 54 | property?.["format"] ?? "", 55 | ), 56 | enum: buildEnumObject(property.enum), 57 | reference: null, 58 | embedded: null, 59 | required: requiredFields.some((value) => value === fieldName), 60 | description: property.description || "", 61 | }), 62 | ); 63 | 64 | return new Resource(name, url, { 65 | id: null, 66 | title, 67 | description, 68 | fields, 69 | readableFields: fields, 70 | writableFields: fields, 71 | }); 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /src/hydra/fetchJsonLd.ts: -------------------------------------------------------------------------------- 1 | import type { Document, JsonLd, RemoteDocument } from "jsonld/jsonld-spec.js"; 2 | import type { RequestInitExtended } from "../core/types.js"; 3 | 4 | const jsonLdMimeType = "application/ld+json"; 5 | const jsonProblemMimeType = "application/problem+json"; 6 | 7 | interface RejectedResponseDocument { 8 | response: Response; 9 | } 10 | 11 | interface EmptyResponseDocument { 12 | response: Response; 13 | } 14 | 15 | interface ResponseDocument extends RemoteDocument { 16 | response: Response; 17 | body: Document; 18 | } 19 | 20 | /** 21 | * Sends a JSON-LD request to the API. 22 | * @param {string} url The URL to request. 23 | * @param {RequestInitExtended} [options] Optional fetch options. 24 | * @returns {Promise} The response document or an empty response document. 25 | */ 26 | export default async function fetchJsonLd( 27 | url: string, 28 | options: RequestInitExtended = {}, 29 | ): Promise { 30 | const response = await fetch(url, setHeaders(options)); 31 | const { headers, status } = response; 32 | const contentType = headers.get("Content-Type"); 33 | 34 | if (status === 204) { 35 | return { response }; 36 | } 37 | 38 | if ( 39 | status >= 500 || 40 | !contentType || 41 | (!contentType.includes(jsonLdMimeType) && 42 | !contentType.includes(jsonProblemMimeType)) 43 | ) { 44 | const reason: RejectedResponseDocument = { response }; 45 | // oxlint-disable-next-line no-throw-literal 46 | throw reason; 47 | } 48 | 49 | const body = (await response.json()) as JsonLd; 50 | return { 51 | response, 52 | body, 53 | document: body, 54 | documentUrl: url, 55 | }; 56 | } 57 | 58 | function setHeaders(options: RequestInitExtended): RequestInit { 59 | if (!options.headers) { 60 | return { ...options, headers: {} }; 61 | } 62 | 63 | let headers = 64 | typeof options.headers === "function" ? options.headers() : options.headers; 65 | 66 | headers = new Headers(headers); 67 | 68 | if (headers.get("Accept") === null) { 69 | headers.set("Accept", jsonLdMimeType); 70 | } 71 | 72 | const result = { ...options, headers }; 73 | 74 | if ( 75 | result.body !== "undefined" && 76 | !(typeof FormData !== "undefined" && result.body instanceof FormData) && 77 | result.headers.get("Content-Type") === null 78 | ) { 79 | result.headers.set("Content-Type", jsonLdMimeType); 80 | } 81 | 82 | return result; 83 | } 84 | -------------------------------------------------------------------------------- /src/hydra/getType.ts: -------------------------------------------------------------------------------- 1 | import type { FieldType } from "../core/index.js"; 2 | 3 | function getType(id: string, range: string): FieldType { 4 | switch (id) { 5 | case "http://schema.org/email": 6 | case "https://schema.org/email": 7 | return "email"; 8 | case "http://schema.org/url": 9 | case "https://schema.org/url": 10 | return "url"; 11 | default: 12 | } 13 | 14 | switch (range) { 15 | case "http://www.w3.org/2001/XMLSchema#array": 16 | return "array"; 17 | case "http://www.w3.org/2001/XMLSchema#integer": 18 | case "http://www.w3.org/2001/XMLSchema#int": 19 | case "http://www.w3.org/2001/XMLSchema#long": 20 | case "http://www.w3.org/2001/XMLSchema#short": 21 | case "https://schema.org/Integer": 22 | return "integer"; 23 | case "http://www.w3.org/2001/XMLSchema#negativeInteger": 24 | return "negativeInteger"; 25 | case "http://www.w3.org/2001/XMLSchema#nonNegativeInteger": 26 | case "http://www.w3.org/2001/XMLSchema#unsignedInt": 27 | case "http://www.w3.org/2001/XMLSchema#unsignedLong": 28 | case "http://www.w3.org/2001/XMLSchema#unsignedShort": 29 | return "nonNegativeInteger"; 30 | case "http://www.w3.org/2001/XMLSchema#positiveInteger": 31 | return "positiveInteger"; 32 | case "http://www.w3.org/2001/XMLSchema#nonPositiveInteger": 33 | return "nonPositiveInteger"; 34 | case "http://www.w3.org/2001/XMLSchema#decimal": 35 | return "decimal"; 36 | case "http://www.w3.org/2001/XMLSchema#double": 37 | return "double"; 38 | case "http://www.w3.org/2001/XMLSchema#float": 39 | case "https://schema.org/Float": 40 | return "float"; 41 | case "http://www.w3.org/2001/XMLSchema#boolean": 42 | case "https://schema.org/Boolean": 43 | return "boolean"; 44 | case "http://www.w3.org/2001/XMLSchema#date": 45 | case "http://www.w3.org/2001/XMLSchema#gYear": 46 | case "http://www.w3.org/2001/XMLSchema#gYearMonth": 47 | case "http://www.w3.org/2001/XMLSchema#gMonth": 48 | case "http://www.w3.org/2001/XMLSchema#gMonthDay": 49 | case "http://www.w3.org/2001/XMLSchema#gDay": 50 | case "https://schema.org/Date": 51 | return "date"; 52 | case "http://www.w3.org/2001/XMLSchema#dateTime": 53 | case "https://schema.org/DateTime": 54 | return "dateTime"; 55 | case "http://www.w3.org/2001/XMLSchema#duration": 56 | return "duration"; 57 | case "http://www.w3.org/2001/XMLSchema#time": 58 | case "https://schema.org/Time": 59 | return "time"; 60 | case "http://www.w3.org/2001/XMLSchema#byte": 61 | case "http://www.w3.org/2001/XMLSchema#unsignedByte": 62 | return "byte"; 63 | case "http://www.w3.org/2001/XMLSchema#hexBinary": 64 | return "hexBinary"; 65 | case "http://www.w3.org/2001/XMLSchema#base64Binary": 66 | return "base64Binary"; 67 | default: 68 | return "string"; 69 | } 70 | } 71 | 72 | export default getType; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api-platform/api-doc-parser", 3 | "version": "0.16.8", 4 | "description": "Transform an API documentation (Hydra, OpenAPI, GraphQL) in an intermediate representation that can be used for various tasks such as creating smart API clients, scaffolding code or building administration interfaces.", 5 | "keywords": [ 6 | "api", 7 | "api-platform", 8 | "documentation", 9 | "hydra", 10 | "openapi", 11 | "graphql", 12 | "jsonld", 13 | "json-schema", 14 | "typescript", 15 | "client" 16 | ], 17 | "homepage": "https://github.com/api-platform/api-doc-parser", 18 | "bugs": "https://github.com/api-platform/api-doc-parser/issues", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/api-platform/api-doc-parser.git" 22 | }, 23 | "license": "MIT", 24 | "author": "Kévin Dunglas", 25 | "sideEffects": false, 26 | "type": "module", 27 | "exports": { 28 | ".": { 29 | "types": "./lib/index.d.ts", 30 | "import": "./lib/index.js", 31 | "default": "./lib/index.js" 32 | }, 33 | "./core": { 34 | "types": "./lib/core/index.d.ts", 35 | "import": "./lib/core/index.js", 36 | "default": "./lib/core/index.js" 37 | }, 38 | "./package.json": "./package.json" 39 | }, 40 | "main": "./lib/index.js", 41 | "types": "./lib/index.d.ts", 42 | "files": [ 43 | "lib", 44 | "LICENSE", 45 | "package.json", 46 | "README.md" 47 | ], 48 | "scripts": { 49 | "build": "rm -rf lib && tsc --project tsconfig.build.json", 50 | "format": "prettier . --write --experimental-cli", 51 | "format:check": "prettier . --check --experimental-cli", 52 | "knip": "knip", 53 | "knip:fix": "knip --fix", 54 | "lint": "oxlint", 55 | "lint:fix": "oxlint --fix --fix-suggestions", 56 | "test": "vitest run", 57 | "test:coverage": "vitest --coverage", 58 | "test:watch": "vitest --watch", 59 | "typecheck": "tsc --noEmit" 60 | }, 61 | "dependencies": { 62 | "graphql": "^16.11.0", 63 | "inflection": "^3.0.2", 64 | "jsonld": "^8.3.3", 65 | "jsonref": "^9.0.0" 66 | }, 67 | "devDependencies": { 68 | "@types/jsonld": "^1.5.15", 69 | "@types/node": "^22.16.5", 70 | "@vitest/coverage-v8": "3.2.4", 71 | "knip": "^5.62.0", 72 | "msw": "^2.10.4", 73 | "openapi-types": "^12.1.3", 74 | "oxlint": "^1.8.0", 75 | "prettier": "^3.6.2", 76 | "typescript": "^5.8.3", 77 | "vitest": "^3.2.4" 78 | }, 79 | "packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad", 80 | "engines": { 81 | "node": ">= 20" 82 | }, 83 | "publishConfig": { 84 | "access": "public" 85 | }, 86 | "devEngines": { 87 | "packageManager": { 88 | "name": "pnpm", 89 | "version": ">= 10.0.0", 90 | "onFail": "download" 91 | }, 92 | "runtime": { 93 | "name": "node", 94 | "version": ">= 20" 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/oxlint/configuration_schema.json", 3 | "categories": { 4 | "correctness": "error", 5 | "nursery": "error", 6 | "pedantic": "error", 7 | "perf": "error", 8 | "restriction": "error", 9 | "style": "error", 10 | "suspicious": "error" 11 | }, 12 | "env": { 13 | "es2022": true, 14 | "shared-node-browser": true 15 | }, 16 | "plugins": [ 17 | "typescript", 18 | "unicorn", 19 | "vitest", 20 | "import", 21 | "eslint", 22 | "oxc", 23 | "promise", 24 | "jsdoc" 25 | ], 26 | "rules": { 27 | "@typescript-eslint/no-empty-object-type": [ 28 | "error", 29 | { 30 | "allowInterfaces": "with-single-extends" 31 | } 32 | ], 33 | "@typescript-eslint/no-explicit-any": "off", 34 | "@typescript-eslint/no-unsafe-declaration-merging": "error", 35 | "eslint/arrow-body-style": ["error", "as-needed"], 36 | "eslint/curly": "error", 37 | "eslint/id-length": "off", 38 | "eslint/max-depth": "off", 39 | "eslint/max-lines": "off", 40 | "eslint/max-lines-per-function": "off", 41 | "eslint/no-duplicate-imports": "off", 42 | "func-style": [ 43 | "error", 44 | "declaration", 45 | { 46 | "overrides": { 47 | "namedExports": "declaration" 48 | } 49 | } 50 | ], 51 | "import/exports-last": "off", 52 | "import/extensions": "off", 53 | "import/group-exports": "off", 54 | "import/max-dependencies": "off", 55 | "import/no-anonymous-default-export": [ 56 | "error", 57 | { 58 | "allowCallExpression": true 59 | } 60 | ], 61 | "import/no-default-export": "off", 62 | "import/prefer-default-export": "off", 63 | "max-params": "off", 64 | "no-async-await": "off", 65 | "no-console": "off", 66 | "no-continue": "off", 67 | "no-magic-numbers": "off", 68 | "no-nested-ternary": "off", 69 | "no-optional-chaining": "off", 70 | "no-ternary": "off", 71 | "no-undefined": "off", 72 | "oxc/no-barrel-file": "off", 73 | "oxc/no-rest-spread-properties": "off", 74 | "promise/catch-or-return": "off", 75 | "sort-imports": "off", 76 | "sort-keys": "off", 77 | "sort-vars": "off", 78 | "typescript/consistent-indexed-object-style": "error", 79 | "typescript/explicit-function-return-type": "off", 80 | "typescript/no-empty-interface": "off", 81 | "unicorn/error-message": "off", 82 | "unicorn/filename-case": [ 83 | "error", 84 | { 85 | "cases": { 86 | "camelCase": true, 87 | "kebabCase": false, 88 | "pascalCase": true, 89 | "snakeCase": false 90 | } 91 | } 92 | ], 93 | "unicorn/no-null": "off", 94 | "vitest/consistent-test-it": [ 95 | "error", 96 | { 97 | "fn": "test", 98 | "withinDescribe": "test" 99 | } 100 | ], 101 | "vitest/max-expects": "off", 102 | "vitest/no-hooks": "off", 103 | "vitest/prefer-lowercase-title": "off", 104 | "vitest/prefer-strict-equal": "off", 105 | "vitest/prefer-to-be-falsy": "off", 106 | "vitest/prefer-to-be-truthy": "off", 107 | "vitest/require-top-level-describe": "off", 108 | "yoda": "error" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/graphql/parseGraphQl.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IntrospectionObjectType, 3 | IntrospectionOutputTypeRef, 4 | IntrospectionQuery, 5 | } from "graphql/utilities"; 6 | import { getIntrospectionQuery } from "graphql/utilities/index.js"; 7 | import { Api, Field, Resource } from "../core/index.js"; 8 | import fetchQuery from "./fetchQuery.js"; 9 | 10 | function getRangeFromGraphQlType(type: IntrospectionOutputTypeRef): string { 11 | if (type.kind === "NON_NULL") { 12 | if (type.ofType.kind === "LIST") { 13 | return `Array<${getRangeFromGraphQlType(type.ofType.ofType)}>`; 14 | } 15 | 16 | return type.ofType.name; 17 | } 18 | 19 | if (type.kind === "LIST") { 20 | return `Array<${getRangeFromGraphQlType(type.ofType)}>`; 21 | } 22 | 23 | return type.name; 24 | } 25 | 26 | function getReferenceFromGraphQlType( 27 | type: IntrospectionOutputTypeRef, 28 | ): null | string { 29 | if (type.kind === "OBJECT" && type.name.endsWith("Connection")) { 30 | return type.name.slice(0, type.name.lastIndexOf("Connection")); 31 | } 32 | 33 | return null; 34 | } 35 | 36 | export default async function parseGraphQl( 37 | entrypointUrl: string, 38 | options: RequestInit = {}, 39 | ): Promise<{ 40 | api: Api; 41 | response: Response; 42 | }> { 43 | const introspectionQuery = getIntrospectionQuery(); 44 | 45 | const { 46 | response, 47 | body: { data }, 48 | } = await fetchQuery( 49 | entrypointUrl, 50 | introspectionQuery, 51 | options, 52 | ); 53 | if (!data?.__schema) { 54 | throw new Error( 55 | "Schema has not been retrieved from the introspection query.", 56 | ); 57 | } 58 | const schema = data?.__schema; 59 | 60 | const typeResources = schema.types.filter( 61 | (type) => 62 | type.kind === "OBJECT" && 63 | type.name !== schema.queryType.name && 64 | type.name !== schema.mutationType?.name && 65 | type.name !== schema.subscriptionType?.name && 66 | !type.name.startsWith("__") && 67 | // mutation 68 | (!type.name[0] || !type.name.startsWith(type.name[0].toLowerCase())) && 69 | !type.name.endsWith("Connection") && 70 | !type.name.endsWith("Edge"), 71 | ) as IntrospectionObjectType[]; 72 | 73 | const resources: Resource[] = []; 74 | for (const typeResource of typeResources) { 75 | const fields: Field[] = []; 76 | const readableFields: Field[] = []; 77 | const writableFields: Field[] = []; 78 | 79 | for (const resourceFieldType of typeResource.fields) { 80 | const field = new Field(resourceFieldType.name, { 81 | range: getRangeFromGraphQlType(resourceFieldType.type), 82 | reference: getReferenceFromGraphQlType(resourceFieldType.type), 83 | required: resourceFieldType.type.kind === "NON_NULL", 84 | description: resourceFieldType.description || "", 85 | deprecated: resourceFieldType.isDeprecated, 86 | }); 87 | 88 | fields.push(field); 89 | readableFields.push(field); 90 | writableFields.push(field); 91 | } 92 | 93 | resources.push( 94 | new Resource(typeResource.name, "", { 95 | fields, 96 | readableFields, 97 | writableFields, 98 | }), 99 | ); 100 | } 101 | 102 | for (const resource of resources) { 103 | for (const field of resource.fields ?? []) { 104 | if (field.reference !== null) { 105 | field.reference = 106 | resources.find((resource) => resource.name === field.reference) || 107 | null; 108 | } else if (field.range !== null) { 109 | field.reference = 110 | resources.find((resource) => resource.name === field.range) || null; 111 | } 112 | } 113 | } 114 | 115 | return { 116 | api: new Api(entrypointUrl, { resources }), 117 | response, 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /src/hydra/fetchJsonLd.test.ts: -------------------------------------------------------------------------------- 1 | import { http } from "msw/core/http"; 2 | import { assert, expect, test } from "vitest"; 3 | import { server } from "../../vitest.setup.js"; 4 | import fetchJsonLd from "./fetchJsonLd.js"; 5 | 6 | const httpResponse = { 7 | "@context": "http://json-ld.org/contexts/person.jsonld", 8 | "@id": "http://dbpedia.org/resource/John_Lennon", 9 | name: "John Lennon", 10 | born: "1940-10-09", 11 | spouse: "http://dbpedia.org/resource/Cynthia_Lennon", 12 | }; 13 | 14 | test("fetch a JSON-LD document", async () => { 15 | server.use( 16 | http.get("http://localhost/foo.jsonld", () => 17 | Response.json(httpResponse, { 18 | headers: { "Content-Type": "application/ld+json" }, 19 | status: 200, 20 | statusText: "OK", 21 | }), 22 | ), 23 | ); 24 | 25 | const data = await fetchJsonLd("http://localhost/foo.jsonld"); 26 | expect(data.response.ok).toBe(true); 27 | 28 | assert("body" in data, "Response should have a body property"); 29 | assert(data.body !== null, "Body should not be null"); 30 | assert("name" in data.body, "Body should have a name property"); 31 | expect(data.body["name"]).toBe("John Lennon"); 32 | }); 33 | 34 | test("fetch a non JSON-LD document", async () => { 35 | server.use( 36 | http.get( 37 | "http://localhost/foo.jsonld", 38 | () => 39 | new Response(`Hello`, { 40 | headers: { "Content-Type": "text/html" }, 41 | status: 200, 42 | statusText: "OK", 43 | }), 44 | ), 45 | ); 46 | 47 | const promise = fetchJsonLd("http://localhost/foo.jsonld"); 48 | 49 | await expect(promise).rejects.toHaveProperty("response.ok", true); 50 | await expect(promise).rejects.not.toHaveProperty("body"); 51 | }); 52 | 53 | test("fetch an error with Content-Type application/ld+json", async () => { 54 | server.use( 55 | http.get("http://localhost/foo.jsonld", () => 56 | Response.json(httpResponse, { 57 | status: 500, 58 | statusText: "Internal Server Error", 59 | headers: { "Content-Type": "application/ld+json" }, 60 | }), 61 | ), 62 | ); 63 | 64 | const rejectedResponse = await fetchJsonLd( 65 | "http://localhost/foo.jsonld", 66 | ).catch((error) => error as { response: Response }); 67 | 68 | await expect(rejectedResponse).toHaveProperty("response.ok", false); 69 | await expect(rejectedResponse.response.json()).resolves.toHaveProperty( 70 | "born", 71 | "1940-10-09", 72 | ); 73 | }); 74 | 75 | test("fetch an error with Content-Type application/error+json", async () => { 76 | server.use( 77 | http.get("http://localhost/foo.jsonld", () => 78 | Response.json(httpResponse, { 79 | status: 400, 80 | statusText: "Bad Request", 81 | headers: { "Content-Type": "application/error+json" }, 82 | }), 83 | ), 84 | ); 85 | 86 | const rejectedResponse = await fetchJsonLd( 87 | "http://localhost/foo.jsonld", 88 | ).catch((error) => error as { response: Response }); 89 | 90 | await expect(rejectedResponse).toHaveProperty("response.ok", false); 91 | await expect(rejectedResponse.response.json()).resolves.toHaveProperty( 92 | "born", 93 | "1940-10-09", 94 | ); 95 | }); 96 | 97 | test("fetch an empty document", async () => { 98 | server.use( 99 | http.get( 100 | "http://localhost/foo.jsonld", 101 | () => 102 | new Response(null, { 103 | status: 204, 104 | statusText: "No Content", 105 | headers: { "Content-Type": "text/html" }, 106 | }), 107 | ), 108 | ); 109 | 110 | const dataPromise = fetchJsonLd("http://localhost/foo.jsonld"); 111 | 112 | await expect(dataPromise).resolves.toHaveProperty("response.ok", true); 113 | await expect(dataPromise).resolves.not.toHaveProperty("body"); 114 | }); 115 | -------------------------------------------------------------------------------- /src/openapi3/dereferencedOpenApiv3.ts: -------------------------------------------------------------------------------- 1 | // oxlint-disable consistent-indexed-object-style 2 | import type { OpenAPIV3 } from "openapi-types"; 3 | 4 | interface ArraySchemaObjectDereferenced extends BaseSchemaObjectDereferenced { 5 | type: OpenAPIV3.ArraySchemaObjectType; 6 | items: SchemaObjectDereferenced; 7 | } 8 | 9 | interface NonArraySchemaObjectDereferenced 10 | extends BaseSchemaObjectDereferenced { 11 | type?: OpenAPIV3.NonArraySchemaObjectType; 12 | } 13 | 14 | export type SchemaObjectDereferenced = 15 | | ArraySchemaObjectDereferenced 16 | | NonArraySchemaObjectDereferenced; 17 | 18 | type BaseSchemaObjectDereferenced = Omit< 19 | OpenAPIV3.BaseSchemaObject, 20 | "additionalProperties" | "properties" | "allOf" | "oneOf" | "anyOf" | "not" 21 | > & { 22 | additionalProperties?: boolean | SchemaObjectDereferenced; 23 | properties?: { 24 | [name: string]: SchemaObjectDereferenced; 25 | }; 26 | allOf?: SchemaObjectDereferenced[]; 27 | oneOf?: SchemaObjectDereferenced[]; 28 | anyOf?: SchemaObjectDereferenced[]; 29 | not?: SchemaObjectDereferenced; 30 | }; 31 | 32 | type EncodingObjectDereferenced = Omit & { 33 | headers?: { 34 | [header: string]: HeaderObjectDereferenced; 35 | }; 36 | }; 37 | 38 | type MediaTypeObjectDereferenced = Omit< 39 | OpenAPIV3.MediaTypeObject, 40 | "schema" | "encoding" 41 | > & { 42 | schema?: SchemaObjectDereferenced; 43 | encoding?: { 44 | [media: string]: EncodingObjectDereferenced; 45 | }; 46 | }; 47 | 48 | type ParameterBaseObjectDereferenced = Omit< 49 | OpenAPIV3.ParameterObject, 50 | "schema" | "content" 51 | > & { 52 | schema?: SchemaObjectDereferenced; 53 | content?: { 54 | [media: string]: MediaTypeObjectDereferenced; 55 | }; 56 | }; 57 | 58 | interface HeaderObjectDereferenced extends ParameterBaseObjectDereferenced {} 59 | 60 | type RequestBodyObjectDereferenced = Omit< 61 | OpenAPIV3.RequestBodyObject, 62 | "content" 63 | > & { 64 | content: { 65 | [media: string]: MediaTypeObjectDereferenced; 66 | }; 67 | }; 68 | 69 | type ResponseObjectDereferenced = Omit< 70 | OpenAPIV3.ResponseObject, 71 | "headers" | "content" 72 | > & { 73 | headers?: { 74 | [header: string]: HeaderObjectDereferenced; 75 | }; 76 | content?: { 77 | [media: string]: MediaTypeObjectDereferenced; 78 | }; 79 | }; 80 | 81 | interface ResponsesObjectDereferenced { 82 | [code: string]: ResponseObjectDereferenced; 83 | } 84 | 85 | interface ParameterObjectDereferenced extends ParameterBaseObjectDereferenced { 86 | name: string; 87 | in: string; 88 | } 89 | 90 | type PathItemObjectDereferenced = Omit< 91 | OpenAPIV3.PathItemObject, 92 | "parameters" | `${OpenAPIV3.HttpMethods}` 93 | > & { 94 | parameters?: ParameterObjectDereferenced[]; 95 | } & { 96 | [method in OpenAPIV3.HttpMethods]?: OperationObjectDereferenced; 97 | }; 98 | interface CallbackObjectDereferenced { 99 | [url: string]: PathItemObjectDereferenced; 100 | } 101 | 102 | export type OperationObjectDereferenced = Omit< 103 | OpenAPIV3.OperationObject, 104 | "parameters" | "requestBody" | "responses" | "callbacks" 105 | > & { 106 | parameters?: ParameterObjectDereferenced[]; 107 | requestBody?: RequestBodyObjectDereferenced; 108 | responses: ResponsesObjectDereferenced; 109 | callbacks?: { 110 | [callback: string]: CallbackObjectDereferenced; 111 | }; 112 | } & T; 113 | 114 | interface PathsObjectDereferenced< 115 | T extends object = object, 116 | P extends object = object, 117 | > { 118 | [pattern: string]: (PathItemObjectDereferenced & P) | undefined; 119 | } 120 | 121 | type ComponentsObjectDereferenced = Omit< 122 | OpenAPIV3.ComponentsObject, 123 | | "schemas" 124 | | "responses" 125 | | "parameters" 126 | | "requestBodies" 127 | | "headers" 128 | | "callbacks" 129 | > & { 130 | schemas?: { 131 | [key: string]: SchemaObjectDereferenced; 132 | }; 133 | responses?: { 134 | [key: string]: ResponseObjectDereferenced; 135 | }; 136 | parameters?: { 137 | [key: string]: ParameterObjectDereferenced; 138 | }; 139 | requestBodies?: { 140 | [key: string]: RequestBodyObjectDereferenced; 141 | }; 142 | headers?: { 143 | [key: string]: HeaderObjectDereferenced; 144 | }; 145 | callbacks?: { 146 | [key: string]: CallbackObjectDereferenced; 147 | }; 148 | }; 149 | 150 | export type OpenAPIV3DocumentDereferenced = Omit< 151 | OpenAPIV3.Document, 152 | "paths" | "components" 153 | > & { 154 | paths: PathsObjectDereferenced; 155 | components?: ComponentsObjectDereferenced; 156 | }; 157 | -------------------------------------------------------------------------------- /src/hydra/types.ts: -------------------------------------------------------------------------------- 1 | export interface IriTemplateMapping { 2 | "@type": "IriTemplateMapping"; 3 | variable: "string"; 4 | property: string | null; 5 | required: boolean; 6 | } 7 | 8 | export interface ExpandedOperation { 9 | "@type": ["http://www.w3.org/ns/hydra/core#Operation"]; 10 | "http://www.w3.org/2000/01/rdf-schema#label": [ 11 | { 12 | "@value": string; 13 | }, 14 | ]; 15 | "http://www.w3.org/ns/hydra/core#title": [ 16 | { 17 | "@value": string; 18 | }, 19 | ]; 20 | "http://www.w3.org/ns/hydra/core#expects"?: [ 21 | { 22 | "@id": string; 23 | }, 24 | ]; 25 | "http://www.w3.org/ns/hydra/core#method": [ 26 | { 27 | "@value": string; 28 | }, 29 | ]; 30 | "http://www.w3.org/ns/hydra/core#returns"?: [ 31 | { 32 | "@id": string; 33 | }, 34 | ]; 35 | "http://www.w3.org/2002/07/owl#deprecated"?: [ 36 | { 37 | "@value": boolean; 38 | }, 39 | ]; 40 | } 41 | 42 | export interface ExpandedRdfProperty { 43 | "@id": string; 44 | "@type": [ 45 | | "http://www.w3.org/1999/02/22-rdf-syntax-ns#Property" 46 | | "http://www.w3.org/ns/hydra/core#Link", 47 | ]; 48 | "http://www.w3.org/2000/01/rdf-schema#label": [ 49 | { 50 | "@value": string; 51 | }, 52 | ]; 53 | "http://www.w3.org/2000/01/rdf-schema#domain": [ 54 | { 55 | "@id": string; 56 | }, 57 | ]; 58 | "http://www.w3.org/2000/01/rdf-schema#range": 59 | | [ 60 | { 61 | "@id": string; 62 | }, 63 | ] 64 | | [ 65 | { 66 | "@id": string; 67 | }, 68 | { 69 | "http://www.w3.org/2002/07/owl#equivalentClass": [ 70 | { 71 | "http://www.w3.org/2002/07/owl#allValuesFrom": [ 72 | { 73 | "@id": string; 74 | }, 75 | ]; 76 | "http://www.w3.org/2002/07/owl#onProperty": [ 77 | { 78 | "@id": string; 79 | }, 80 | ]; 81 | }, 82 | ]; 83 | }, 84 | ]; 85 | "http://www.w3.org/ns/hydra/core#supportedOperation"?: ExpandedOperation[]; 86 | "http://www.w3.org/2002/07/owl#maxCardinality": [ 87 | { 88 | "@value": number; 89 | }, 90 | ]; 91 | } 92 | 93 | interface ExpandedSupportedProperty { 94 | "@type": ["http://www.w3.org/ns/hydra/core#SupportedProperty"]; 95 | "http://www.w3.org/ns/hydra/core#title": [ 96 | { 97 | "@value": string; 98 | }, 99 | ]; 100 | "http://www.w3.org/ns/hydra/core#description": [ 101 | { 102 | "@value": string; 103 | }, 104 | ]; 105 | "http://www.w3.org/ns/hydra/core#required"?: [ 106 | { 107 | "@value": boolean; 108 | }, 109 | ]; 110 | "http://www.w3.org/ns/hydra/core#readable": [ 111 | { 112 | "@value": boolean; 113 | }, 114 | ]; 115 | /** 116 | * @deprecated 117 | */ 118 | "http://www.w3.org/ns/hydra/core#writeable": [ 119 | { 120 | "@value": boolean; 121 | }, 122 | ]; 123 | "http://www.w3.org/ns/hydra/core#writable": [ 124 | { 125 | "@value": boolean; 126 | }, 127 | ]; 128 | "http://www.w3.org/ns/hydra/core#property": [ExpandedRdfProperty]; 129 | "http://www.w3.org/2002/07/owl#deprecated"?: [ 130 | { 131 | "@value": boolean; 132 | }, 133 | ]; 134 | } 135 | 136 | export interface ExpandedClass { 137 | "@id": string; 138 | "@type": ["http://www.w3.org/ns/hydra/core#Class"]; 139 | "http://www.w3.org/2000/01/rdf-schema#label"?: [ 140 | { 141 | "@value": string; 142 | }, 143 | ]; 144 | "http://www.w3.org/2000/01/rdf-schema#subClassOf"?: [ 145 | { 146 | "@id": string; 147 | }, 148 | ]; 149 | "http://www.w3.org/ns/hydra/core#title": [ 150 | { 151 | "@value": string; 152 | }, 153 | ]; 154 | "http://www.w3.org/ns/hydra/core#description"?: [ 155 | { 156 | "@value": string; 157 | }, 158 | ]; 159 | "http://www.w3.org/ns/hydra/core#supportedProperty": ExpandedSupportedProperty[]; 160 | "http://www.w3.org/ns/hydra/core#supportedOperation"?: ExpandedOperation[]; 161 | "http://www.w3.org/2002/07/owl#deprecated"?: [ 162 | { 163 | "@value": boolean; 164 | }, 165 | ]; 166 | } 167 | 168 | export interface ExpandedDoc { 169 | "@id": string; 170 | "@type": ["http://www.w3.org/ns/hydra/core#ApiDocumentation"]; 171 | "http://www.w3.org/ns/hydra/core#title": [ 172 | { 173 | "@value": string; 174 | }, 175 | ]; 176 | "http://www.w3.org/ns/hydra/core#description": [ 177 | { 178 | "@value": string; 179 | }, 180 | ]; 181 | "http://www.w3.org/ns/hydra/core#entrypoint": [ 182 | { 183 | "@value": string; 184 | }, 185 | ]; 186 | "http://www.w3.org/ns/hydra/core#supportedClass": ExpandedClass[]; 187 | } 188 | 189 | export interface Entrypoint { 190 | "@id": string; 191 | "@type": [string]; 192 | [key: string]: 193 | | [ 194 | { 195 | "@id": string; 196 | }, 197 | ] 198 | | string 199 | | [string]; 200 | } 201 | -------------------------------------------------------------------------------- /src/openapi3/handleJson.ts: -------------------------------------------------------------------------------- 1 | import { camelize, classify, pluralize } from "inflection"; 2 | import type { ParseOptions } from "jsonref"; 3 | import { parse } from "jsonref"; 4 | import type { OpenAPIV3 } from "openapi-types"; 5 | import type { OperationType } from "../core/index.js"; 6 | import { Field, Operation, Parameter, Resource } from "../core/index.js"; 7 | import { 8 | buildEnumObject, 9 | getResourcePaths, 10 | getType, 11 | removeTrailingSlash, 12 | } from "../core/utils/index.js"; 13 | import type { 14 | OpenAPIV3DocumentDereferenced, 15 | OperationObjectDereferenced, 16 | SchemaObjectDereferenced, 17 | } from "./dereferencedOpenApiv3.js"; 18 | 19 | /** 20 | * Assigns relationships between resources based on their fields. 21 | * Sets the field's `embedded` or `reference` property depending on its type. 22 | * 23 | * @param {Resource[]} resources - Array of Resource objects to process. 24 | * @returns {Resource[]} The same array of resources with relationships assigned. 25 | */ 26 | function assignResourceRelationships(resources: Resource[]): Resource[] { 27 | for (const resource of resources) { 28 | for (const field of resource.fields ?? []) { 29 | const name = camelize(field.name).replace(/Ids?$/, ""); 30 | 31 | const guessedResource = resources.find( 32 | (res) => res.title === classify(name), 33 | ); 34 | if (!guessedResource) { 35 | continue; 36 | } 37 | field.maxCardinality = field.type === "array" ? null : 1; 38 | if (field.type === "object" || field.arrayType === "object") { 39 | field.embedded = guessedResource; 40 | } else { 41 | field.reference = guessedResource; 42 | } 43 | } 44 | } 45 | return resources; 46 | } 47 | 48 | function mergeResources(resourceA: Resource, resourceB: Resource) { 49 | for (const fieldB of resourceB.fields ?? []) { 50 | if (!resourceA.fields?.some((fieldA) => fieldA.name === fieldB.name)) { 51 | resourceA.fields?.push(fieldB); 52 | } 53 | } 54 | for (const fieldB of resourceB.readableFields ?? []) { 55 | if ( 56 | !resourceA.readableFields?.some((fieldA) => fieldA.name === fieldB.name) 57 | ) { 58 | resourceA.readableFields?.push(fieldB); 59 | } 60 | } 61 | for (const fieldB of resourceB.writableFields ?? []) { 62 | if ( 63 | !resourceA.writableFields?.some((fieldA) => fieldA.name === fieldB.name) 64 | ) { 65 | resourceA.writableFields?.push(fieldB); 66 | } 67 | } 68 | 69 | return resourceA; 70 | } 71 | 72 | function getArrayType(property: SchemaObjectDereferenced) { 73 | if (property.type !== "array") { 74 | return null; 75 | } 76 | return getType(property.items.type || "string", property.items.format); 77 | } 78 | 79 | function buildResourceFromSchema( 80 | schema: SchemaObjectDereferenced, 81 | name: string, 82 | title: string, 83 | url: string, 84 | ) { 85 | const { description = "", properties = {} } = schema; 86 | const requiredFields = schema.required || []; 87 | const fields: Field[] = []; 88 | const readableFields: Field[] = []; 89 | const writableFields: Field[] = []; 90 | 91 | for (const [fieldName, property] of Object.entries(properties)) { 92 | const field = new Field(fieldName, { 93 | id: null, 94 | range: null, 95 | type: getType(property.type || "string", property.format), 96 | arrayType: getArrayType(property), 97 | enum: buildEnumObject(property.enum), 98 | reference: null, 99 | embedded: null, 100 | nullable: property.nullable || false, 101 | required: requiredFields.some((value) => value === fieldName), 102 | description: property.description || "", 103 | }); 104 | 105 | if (!property.writeOnly) { 106 | readableFields.push(field); 107 | } 108 | if (!property.readOnly) { 109 | writableFields.push(field); 110 | } 111 | fields.push(field); 112 | } 113 | 114 | return new Resource(name, url, { 115 | id: null, 116 | title, 117 | description, 118 | fields, 119 | readableFields, 120 | writableFields, 121 | parameters: [], 122 | // oxlint-disable-next-line prefer-await-to-then 123 | getParameters: () => Promise.resolve([]), 124 | }); 125 | } 126 | 127 | function buildOperationFromPathItem( 128 | httpMethod: `${OpenAPIV3.HttpMethods}`, 129 | operationType: OperationType, 130 | pathItem: OperationObjectDereferenced, 131 | ): Operation { 132 | return new Operation(pathItem.summary || operationType, operationType, { 133 | method: httpMethod.toUpperCase(), 134 | deprecated: !!pathItem.deprecated, 135 | }); 136 | } 137 | 138 | function dereferenceOpenAPIV3( 139 | response: OpenAPIV3.Document, 140 | options: ParseOptions, 141 | ): Promise { 142 | return parse(response, options); 143 | } 144 | 145 | /* 146 | Assumptions: 147 | RESTful APIs typically have two paths per resources: a `/noun` path and a 148 | `/noun/{id}` path. `getResources` strips out the former, allowing us to focus 149 | on the latter. 150 | 151 | In OpenAPI 3, the `/noun/{id}` path will typically have a `get` action, that 152 | probably accepts parameters and would respond with an object. 153 | */ 154 | 155 | export default async function handleJson( 156 | response: OpenAPIV3.Document, 157 | entrypointUrl: string, 158 | ): Promise { 159 | const document = await dereferenceOpenAPIV3(response, { 160 | scope: entrypointUrl, 161 | }); 162 | 163 | const paths = getResourcePaths(document.paths); 164 | 165 | const serverUrlOrRelative = document.servers?.[0]?.url || "/"; 166 | const serverUrl = new URL(serverUrlOrRelative, entrypointUrl).href; 167 | 168 | const resources: Resource[] = []; 169 | 170 | for (const path of paths) { 171 | const splittedPath = removeTrailingSlash(path).split("/"); 172 | const baseName = splittedPath[splittedPath.length - 2]; 173 | if (!baseName) { 174 | throw new Error("Invalid path: " + path); 175 | } 176 | 177 | const name = pluralize(baseName); 178 | const url = `${removeTrailingSlash(serverUrl)}/${name}`; 179 | const pathItem = document.paths[path]; 180 | if (!pathItem) { 181 | throw new Error(); 182 | } 183 | 184 | const title = classify(baseName); 185 | 186 | const { 187 | get: showOperation, 188 | put: putOperation, 189 | patch: patchOperation, 190 | delete: deleteOperation, 191 | } = pathItem; 192 | 193 | const editOperation = putOperation || patchOperation; 194 | if (!showOperation && !editOperation) { 195 | continue; 196 | } 197 | const showSchema = 198 | showOperation?.responses?.["200"]?.content?.["application/json"] 199 | ?.schema || document.components?.schemas?.[title]; 200 | 201 | const editSchema = 202 | editOperation?.requestBody?.content?.["application/json"]?.schema || null; 203 | 204 | if (!showSchema && !editSchema) { 205 | continue; 206 | } 207 | 208 | const showResource = showSchema 209 | ? buildResourceFromSchema(showSchema, name, title, url) 210 | : null; 211 | const editResource = editSchema 212 | ? buildResourceFromSchema(editSchema, name, title, url) 213 | : null; 214 | let resource = showResource ?? editResource; 215 | if (!resource) { 216 | continue; 217 | } 218 | if (showResource && editResource) { 219 | resource = mergeResources(showResource, editResource); 220 | } 221 | 222 | const pathCollection = document.paths[`/${name}`]; 223 | const { get: listOperation, post: createOperation } = pathCollection ?? {}; 224 | 225 | resource.operations = [ 226 | ...(showOperation 227 | ? [buildOperationFromPathItem("get", "show", showOperation)] 228 | : []), 229 | ...(putOperation 230 | ? [buildOperationFromPathItem("put", "edit", putOperation)] 231 | : []), 232 | ...(patchOperation 233 | ? [buildOperationFromPathItem("patch", "edit", patchOperation)] 234 | : []), 235 | ...(deleteOperation 236 | ? [buildOperationFromPathItem("delete", "delete", deleteOperation)] 237 | : []), 238 | ...(listOperation 239 | ? [buildOperationFromPathItem("get", "list", listOperation)] 240 | : []), 241 | ...(createOperation 242 | ? [buildOperationFromPathItem("post", "create", createOperation)] 243 | : []), 244 | ]; 245 | 246 | if (listOperation?.parameters) { 247 | resource.parameters = listOperation.parameters.map( 248 | (parameter) => 249 | new Parameter( 250 | parameter.name, 251 | parameter.schema?.type ? getType(parameter.schema.type) : null, 252 | parameter.required || false, 253 | parameter.description || "", 254 | parameter.deprecated, 255 | ), 256 | ); 257 | } 258 | 259 | resources.push(resource); 260 | } 261 | 262 | return assignResourceRelationships(resources); 263 | } 264 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | API doc parser 7 |

8 | 9 | # API Doc Parser 10 | 11 | **Effortlessly turn Hydra, Swagger/OpenAPI, and GraphQL specs into actionable data for your tools and apps.** 12 | 13 | [![CI](https://github.com/api-platform/api-doc-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/api-platform/api-doc-parser/actions/workflows/ci.yml) 14 | [![GitHub License](https://img.shields.io/github/license/api-platform/api-doc-parser)](https://github.com/api-platform/api-doc-parser/blob/main/LICENSE) 15 | [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@api-platform/api-doc-parser)](https://bundlephobia.com/package/@api-platform/api-doc-parser) 16 | [![npm version](https://badge.fury.io/js/%40api-platform%2Fapi-doc-parser.svg)](https://badge.fury.io/js/%40api-platform%2Fapi-doc-parser) 17 | [![NPM Downloads](https://img.shields.io/npm/dw/%40api-platform%2Fapi-doc-parser)](https://img.shields.io/npm/dw/%40api-platform%2Fapi-doc-parser) 18 | 19 | --- 20 | 21 | **`api-doc-parser` is a standalone TypeScript library that parses [Hydra](https://www.hydra-cg.com/), [Swagger](https://swagger.io/specification/v2/), [OpenAPI](https://github.com/OAI/OpenAPI-Specification#the-openapi-specification), and [GraphQL](https://graphql.org/) documentation into a unified, intermediate representation.** 22 | This normalized structure enables smart API clients, code generators, admin interfaces, and more. 23 | It integrates seamlessly with the [API Platform](https://api-platform.com/) framework. 24 | 25 | ## ✨ Key Features 26 | 27 | - **Unified output** – one normalized `Api` object covering resources, fields, operations, parameters, and relations 28 | - **TypeScript-first** – strict typings for every parsed element 29 | - **Embedded & referenced resources** resolved automatically 30 | - **Framework integration** – easily integrates with the API Platform ecosystem 31 | - **Supports all major API formats** – Hydra, Swagger/OpenAPI v2, OpenAPI v3, and GraphQL 32 | 33 | ## 📦 Installation 34 | 35 | Using [NPM](https://www.npmjs.com/): 36 | 37 | npm install @api-platform/api-doc-parser 38 | 39 | Using [Pnpm](https://pnpm.io/): 40 | 41 | pnpm add @api-platform/api-doc-parser 42 | 43 | With [Yarn](https://yarnpkg.com/): 44 | 45 | yarn add @api-platform/api-doc-parser 46 | 47 | Using [Bun](https://bun.sh/): 48 | 49 | bun add @api-platform/api-doc-parser 50 | 51 | ## 🚀 Usage 52 | 53 | **Hydra** 54 | 55 | ```javascript 56 | import { parseHydraDocumentation } from "@api-platform/api-doc-parser"; 57 | 58 | const { api, response, status } = await parseHydraDocumentation( 59 | "https://demo.api-platform.com", 60 | ); 61 | ``` 62 | 63 | **OpenAPI v2 (formerly known as Swagger)** 64 | 65 | ```javascript 66 | import { parseSwaggerDocumentation } from "@api-platform/api-doc-parser"; 67 | 68 | const { api, response, status } = await parseSwaggerDocumentation( 69 | "https://demo.api-platform.com/docs.json", 70 | ); 71 | ``` 72 | 73 | **OpenAPI v3** 74 | 75 | ```javascript 76 | import { parseOpenApi3Documentation } from "@api-platform/api-doc-parser"; 77 | 78 | const { api, response, status } = await parseOpenApi3Documentation( 79 | "https://demo.api-platform.com/docs.jsonopenapi?spec_version=3.0.0", 80 | ); 81 | ``` 82 | 83 | **GraphQL** 84 | 85 | ```javascript 86 | import { parseGraphQl } from "@api-platform/api-doc-parser"; 87 | 88 | const { api, response } = await parseGraphQl( 89 | "https://demo.api-platform.com/graphql", 90 | ); 91 | ``` 92 | 93 | ## ![TypeScript](https://api.iconify.design/vscode-icons:file-type-typescript-official.svg?color=%23888888&width=26&height=26) Type definitions 94 | 95 | Each parse function returns a Promise that resolves to an object containing the normalized API structure, the raw documentation, and the HTTP status code: 96 | 97 | #### OpenAPI 3 98 | 99 | ```typescript 100 | function parseOpenApi3Documentation( 101 | entrypointUrl: string, 102 | options?: RequestInitExtended, 103 | ): Promise<{ 104 | api: Api; 105 | response: OpenAPIV3.Document; 106 | status: number; 107 | }>; 108 | ``` 109 | 110 | #### Swagger 111 | 112 | ```typescript 113 | function parseSwaggerDocumentation(entrypointUrl: string): Promise<{ 114 | api: Api; 115 | response: OpenAPIV2.Document; 116 | status: number; 117 | }>; 118 | ``` 119 | 120 | #### Hydra 121 | 122 | ```typescript 123 | function parseHydraDocumentation( 124 | entrypointUrl: string, 125 | options?: RequestInitExtended, 126 | ): Promise<{ 127 | api: Api; 128 | response: Response; 129 | status: number; 130 | }>; 131 | ``` 132 | 133 | #### GraphQL 134 | 135 | ```typescript 136 | function parseGraphQl( 137 | entrypointUrl: string, 138 | options?: RequestInit, 139 | ): Promise<{ 140 | api: Api; 141 | response: Response; 142 | }>; 143 | ``` 144 | 145 | ### Api 146 | 147 | Represents the root of the parsed API, containing the entrypoint URL, an optional title, and a list of resources. 148 | 149 | ```typescript 150 | interface Api { 151 | entrypoint: string; 152 | title?: string; 153 | resources?: Resource[]; 154 | } 155 | ``` 156 | 157 | ### Resource 158 | 159 | Describes an API resource (such as an entity or collection), including its fields, operations, and metadata. 160 | 161 | ```typescript 162 | interface Resource { 163 | name: string | null; 164 | url: string | null; 165 | id?: string | null; 166 | title?: string | null; 167 | description?: string | null; 168 | deprecated?: boolean | null; 169 | fields?: Field[] | null; 170 | readableFields?: Field[] | null; 171 | writableFields?: Field[] | null; 172 | parameters?: Parameter[] | null; 173 | getParameters?: () => Promise | null; 174 | operations?: Operation[] | null; 175 | } 176 | ``` 177 | 178 | ### Field 179 | 180 | Represents a property of a resource, including its type, constraints, and metadata. 181 | 182 | ```typescript 183 | interface Field { 184 | name: string | null; 185 | id?: string | null; 186 | range?: string | null; 187 | type?: FieldType | null; 188 | arrayType?: FieldType | null; 189 | enum?: { [key: string | number]: string | number } | null; 190 | reference?: string | Resource | null; 191 | embedded?: Resource | null; 192 | required?: boolean | null; 193 | nullable?: boolean | null; 194 | description?: string | null; 195 | maxCardinality?: number | null; 196 | deprecated?: boolean | null; 197 | } 198 | ``` 199 | 200 | ### Parameter 201 | 202 | Represents a query parameter for a collection/list operation, such as a filter or pagination variable. 203 | 204 | ```typescript 205 | interface Parameter { 206 | variable: string; 207 | range: string | null; 208 | required: boolean; 209 | description: string; 210 | deprecated?: boolean; 211 | } 212 | ``` 213 | 214 | ### FieldType 215 | 216 | Enumerates the possible types for a field, such as string, integer, date, etc. 217 | 218 | ```typescript 219 | type FieldType = 220 | | "string" 221 | | "integer" 222 | | "negativeInteger" 223 | | "nonNegativeInteger" 224 | | "positiveInteger" 225 | | "nonPositiveInteger" 226 | | "number" 227 | | "decimal" 228 | | "double" 229 | | "float" 230 | | "boolean" 231 | | "date" 232 | | "dateTime" 233 | | "duration" 234 | | "time" 235 | | "byte" 236 | | "binary" 237 | | "hexBinary" 238 | | "base64Binary" 239 | | "array" 240 | | "object" 241 | | "email" 242 | | "url" 243 | | "uuid" 244 | | "password" 245 | | string; 246 | ``` 247 | 248 | ### Operation 249 | 250 | Represents an operation (such as GET, POST, PUT, PATCH, DELETE) that can be performed on a resource. 251 | 252 | ```typescript 253 | interface Operation { 254 | name: string | null; 255 | type: "show" | "edit" | "delete" | "list" | "create" | null; 256 | method?: string | null; 257 | expects?: any | null; 258 | returns?: string | null; 259 | types?: string[] | null; 260 | deprecated?: boolean | null; 261 | } 262 | ``` 263 | 264 | ## 📖 OpenAPI Support 265 | 266 | `api-doc-parser` applies a predictable set of rules when interpreting an OpenAPI document. 267 | If a rule is not met, the resource concerned is silently skipped. 268 | | Rule | Details | 269 | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 270 | | **Single-item path pattern** | A `GET` (read) or **`PUT`/`PATCH`** (update) endpoint **must** match:
`/books/{id}` (regex `^[^{}]+/{[^{}]+}/?$`).
`books` may be singular (`/book/{id}`). | 271 | | **Schema discovery** | **GET** → first searches `responses → 200 → content → application/json`; if missing, falls back to `components` (component name must be singular, e.g. `Book`).
**PUT/PATCH** → only `requestBody → content → application/json` is considered.
If both GET & PUT/PATCH schemas exist, their fields are **merged**. | 272 | | **Collection paths** | A create (`POST`) or list (`GET`) endpoint **must** be plural:
`/books`. | 273 | | **Deletion path** | `DELETE` must live under the single-item GET path (`/books/{id}`). | 274 | | **Relations & Embeddeds** | Links between resources are inferred from property names and their JSON schema:
• **Plural object/array properties** (e.g. `reviews`, `authors`) become **embedded** resources when their item schema matches an existing resource (`Review`, `Author`).
• **ID-like properties** (e.g. `review_id`, `reviewId`, `review_ids`, `reviewIds`, `authorId`) are treated as **references** to that resource.
• As a result, fields such as **`reviews`** (object/array) and **`review_ids`** (scalar/array of IDs) each point to the **same** `Review` resource, one flagged _embedded_, the other _reference_. | 275 | | **Parameter extraction** | Parameters are read **only** from the list path (`/books`). | 276 | 277 | ## 🧩 Support for other formats (JSON:API, AsyncAPI...) 278 | 279 | API Doc Parser is designed to parse any API documentation format and convert it in the same intermediate representation. 280 | If you develop a parser for another format, please [open a Pull Request](https://github.com/api-platform/api-doc-parser/pulls) 281 | to include it in the library. 282 | 283 | ## 🤝 Contributing 284 | 285 | Contributions are welcome! To contribute: 286 | 287 | 1. **Read our [Code of Conduct](https://github.com/api-platform/api-doc-parser?tab=coc-ov-file#contributor-code-of-conduct).** 288 | 289 | 2. **Fork the repository and create a feature branch.** 290 | 291 | 3. **Ensure you have the _latest_ version of [pnpm](https://pnpm.io/installation) installed.** 292 | 293 | 4. **Install dependencies** 294 | 295 | ```bash 296 | pnpm install 297 | ``` 298 | 299 | 5. **Adhere to the code style and lint rules** 300 | 301 | ```bash 302 | pnpm lint:fix 303 | ``` 304 | 305 | ```bash 306 | pnpm format 307 | ``` 308 | 309 | 6. **Run tests** 310 | 311 | ```bash 312 | pnpm test 313 | ``` 314 | 315 | 7. **Ensure type correctness** 316 | 317 | ```bash 318 | pnpm typecheck 319 | ``` 320 | 321 | 8. **Submit a pull request with a clear description of your changes.** 322 | 323 | ## 👥 Contributors 324 | 325 | [![Contributors](https://contrib.rocks/image?repo=api-platform/api-doc-parser)](https://github.com/api-platform/api-doc-parser/graphs/contributors) 326 | 327 | ## 🌟 Star History 328 | 329 | 330 | 331 | 335 | 339 | Star History Chart 343 | 344 | 345 | 346 | ## 🙌 Credits 347 | 348 | Created by [Kévin Dunglas](https://dunglas.fr). Sponsored by [Les-Tilleuls.coop](https://les-tilleuls.coop). 349 | 350 | ## 🔒 License 351 | 352 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 353 | -------------------------------------------------------------------------------- /src/hydra/parseHydraDocumentation.ts: -------------------------------------------------------------------------------- 1 | import { expand as jsonldExpand } from "jsonld"; 2 | import type { OperationType, Parameter } from "../core/index.js"; 3 | import { Api, Field, Operation, Resource } from "../core/index.js"; 4 | import type { RequestInitExtended } from "../core/types.js"; 5 | import { removeTrailingSlash } from "../core/utils/index.js"; 6 | import fetchJsonLd from "./fetchJsonLd.js"; 7 | import getParameters from "./getParameters.js"; 8 | import getType from "./getType.js"; 9 | import type { 10 | Entrypoint, 11 | ExpandedClass, 12 | ExpandedDoc, 13 | ExpandedOperation, 14 | ExpandedRdfProperty, 15 | } from "./types.js"; 16 | 17 | /** 18 | * Extracts the short name of a resource. 19 | * @param {string} url The resource URL. 20 | * @param {string} entrypointUrl The API entrypoint URL. 21 | * @returns {string} The short name of the resource. 22 | */ 23 | function guessNameFromUrl(url: string, entrypointUrl: string): string { 24 | return url.slice(entrypointUrl.length + 1); 25 | } 26 | 27 | /** 28 | * Gets the title or label from an ExpandedOperation object. 29 | * @param {ExpandedOperation} obj The operation object. 30 | * @returns {string} The title or label. 31 | */ 32 | function getTitleOrLabel(obj: ExpandedOperation): string { 33 | const a = 34 | obj["http://www.w3.org/2000/01/rdf-schema#label"] ?? 35 | obj["http://www.w3.org/ns/hydra/core#title"] ?? 36 | null; 37 | 38 | if (a === null) { 39 | throw new Error("No title nor label defined on this operation."); 40 | } 41 | 42 | return a[0]["@value"]; 43 | } 44 | 45 | /** 46 | * Finds the description of the class with the given id. 47 | * @param {ExpandedDoc[]} docs The expanded documentation array. 48 | * @param {string} classToFind The class ID to find. 49 | * @returns {ExpandedClass} The matching expanded class. 50 | */ 51 | function findSupportedClass( 52 | docs: ExpandedDoc[], 53 | classToFind: string, 54 | ): ExpandedClass { 55 | const supportedClasses = 56 | docs?.[0]?.["http://www.w3.org/ns/hydra/core#supportedClass"]; 57 | if (!Array.isArray(supportedClasses)) { 58 | throw new TypeError( 59 | 'The API documentation has no "http://www.w3.org/ns/hydra/core#supportedClass" key or its value is not an array.', 60 | ); 61 | } 62 | 63 | for (const supportedClass of supportedClasses) { 64 | if (supportedClass["@id"] === classToFind) { 65 | return supportedClass; 66 | } 67 | } 68 | 69 | throw new Error( 70 | `The class "${classToFind}" is not defined in the API documentation.`, 71 | ); 72 | } 73 | 74 | export function getDocumentationUrlFromHeaders(headers: Headers): string { 75 | const linkHeader = headers.get("Link"); 76 | if (!linkHeader) { 77 | throw new Error('The response has no "Link" HTTP header.'); 78 | } 79 | 80 | const matches = 81 | /<([^<]+)>; rel="http:\/\/www.w3.org\/ns\/hydra\/core#apiDocumentation"/.exec( 82 | linkHeader, 83 | ); 84 | if (matches === null) { 85 | throw new Error( 86 | 'The "Link" HTTP header is not of the type "http://www.w3.org/ns/hydra/core#apiDocumentation".', 87 | ); 88 | } 89 | 90 | if (typeof matches[1] !== "string") { 91 | throw new TypeError( 92 | 'The "Link" HTTP header does not contain a documentation URL.', 93 | ); 94 | } 95 | 96 | return matches[1]; 97 | } 98 | 99 | /** 100 | * Retrieves Hydra's entrypoint and API docs. 101 | * @param {string} entrypointUrl The URL of the API entrypoint. 102 | * @param {RequestInitExtended} [options] Optional fetch options. 103 | * @returns {Promise<{ entrypointUrl: string; docsUrl: string; response: Response; entrypoint: Entrypoint[]; docs: ExpandedDoc[]; }>} An object containing entrypointUrl, docsUrl, response, entrypoint, and docs. 104 | */ 105 | async function fetchEntrypointAndDocs( 106 | entrypointUrl: string, 107 | options: RequestInitExtended = {}, 108 | ): Promise<{ 109 | entrypointUrl: string; 110 | docsUrl: string; 111 | response: Response; 112 | entrypoint: Entrypoint[]; 113 | docs: ExpandedDoc[]; 114 | }> { 115 | /** 116 | * Loads a JSON-LD document from the given input. 117 | * @param {string} input The URL or IRI to load. 118 | * @returns {Promise} The fetched JSON-LD response. 119 | */ 120 | async function documentLoader(input: string) { 121 | const response = await fetchJsonLd(input, options); 122 | if (!("body" in response)) { 123 | throw new Error( 124 | "An empty response was received when expanding documentation or entrypoint JSON-LD documents.", 125 | ); 126 | } 127 | return response; 128 | } 129 | 130 | try { 131 | const d = await fetchJsonLd(entrypointUrl, options); 132 | if (!("body" in d)) { 133 | throw new Error("An empty response was received for the entrypoint URL."); 134 | } 135 | const entrypointJsonLd = d.body; 136 | const docsUrl = getDocumentationUrlFromHeaders(d.response.headers); 137 | 138 | const docsResponse = await fetchJsonLd(docsUrl, options); 139 | if (!("body" in docsResponse)) { 140 | throw new Error( 141 | "An empty response was received for the documentation URL.", 142 | ); 143 | } 144 | const docsJsonLd = docsResponse.body; 145 | 146 | const [docs, entrypoint] = (await Promise.all([ 147 | jsonldExpand(docsJsonLd, { 148 | base: docsUrl, 149 | documentLoader, 150 | }), 151 | jsonldExpand(entrypointJsonLd, { 152 | base: entrypointUrl, 153 | documentLoader, 154 | }), 155 | ])) as unknown as [ExpandedDoc[], Entrypoint[]]; 156 | 157 | return { 158 | entrypointUrl, 159 | docsUrl, 160 | entrypoint, 161 | response: d.response, 162 | docs, 163 | }; 164 | } catch (error) { 165 | const { response } = error as { response: Response }; 166 | // oxlint-disable-next-line no-throw-literal 167 | throw { 168 | api: new Api(entrypointUrl, { resources: [] }), 169 | error, 170 | response, 171 | status: response?.status, 172 | }; 173 | } 174 | } 175 | 176 | /** 177 | * Finds the related class for a property. 178 | * @param {ExpandedDoc[]} docs The expanded documentation array. 179 | * @param {ExpandedRdfProperty} property The property to find the related class for. 180 | * @returns {ExpandedClass} The related expanded class. 181 | */ 182 | function findRelatedClass( 183 | docs: ExpandedDoc[], 184 | property: ExpandedRdfProperty, 185 | ): ExpandedClass { 186 | // Use the entrypoint property's owl:equivalentClass if available 187 | 188 | for (const range of property["http://www.w3.org/2000/01/rdf-schema#range"] ?? 189 | []) { 190 | const equivalentClass = 191 | "http://www.w3.org/2002/07/owl#equivalentClass" in range 192 | ? range?.["http://www.w3.org/2002/07/owl#equivalentClass"]?.[0] 193 | : undefined; 194 | 195 | if (!equivalentClass) { 196 | continue; 197 | } 198 | 199 | const onProperty = 200 | equivalentClass["http://www.w3.org/2002/07/owl#onProperty"]?.[0]?.["@id"]; 201 | const allValuesFrom = 202 | equivalentClass["http://www.w3.org/2002/07/owl#allValuesFrom"]?.[0]?.[ 203 | "@id" 204 | ]; 205 | 206 | if ( 207 | allValuesFrom && 208 | onProperty === "http://www.w3.org/ns/hydra/core#member" 209 | ) { 210 | return findSupportedClass(docs, allValuesFrom); 211 | } 212 | } 213 | 214 | // As a fallback, find an operation available on the property of the entrypoint returning the searched type (usually POST) 215 | for (const entrypointSupportedOperation of property[ 216 | "http://www.w3.org/ns/hydra/core#supportedOperation" 217 | ] || []) { 218 | if ( 219 | !entrypointSupportedOperation["http://www.w3.org/ns/hydra/core#returns"] 220 | ) { 221 | continue; 222 | } 223 | 224 | const returns = 225 | entrypointSupportedOperation?.[ 226 | "http://www.w3.org/ns/hydra/core#returns" 227 | ]?.[0]?.["@id"]; 228 | if ( 229 | typeof returns === "string" && 230 | returns.indexOf("http://www.w3.org/ns/hydra/core") !== 0 231 | ) { 232 | return findSupportedClass(docs, returns); 233 | } 234 | } 235 | 236 | throw new Error(`Cannot find the class related to ${property["@id"]}.`); 237 | } 238 | 239 | /** 240 | * Parses Hydra documentation and converts it to an intermediate representation. 241 | * @param {string} entrypointUrl The API entrypoint URL. 242 | * @param {RequestInitExtended} [options] Optional fetch options. 243 | * @returns {Promise<{ api: Api; response: Response; status: number; }>} The parsed API, response, and status. 244 | */ 245 | export default async function parseHydraDocumentation( 246 | entrypointUrl: string, 247 | options: RequestInitExtended = {}, 248 | ): Promise<{ 249 | api: Api; 250 | response: Response; 251 | status: number; 252 | }> { 253 | entrypointUrl = removeTrailingSlash(entrypointUrl); 254 | 255 | const { entrypoint, docs, response } = await fetchEntrypointAndDocs( 256 | entrypointUrl, 257 | options, 258 | ); 259 | 260 | const resources = [], 261 | fields = [], 262 | operations = []; 263 | const title = 264 | docs?.[0]?.["http://www.w3.org/ns/hydra/core#title"]?.[0]?.["@value"] ?? 265 | "API Platform"; 266 | 267 | const entrypointType = entrypoint?.[0]?.["@type"]?.[0]; 268 | if (!entrypointType) { 269 | throw new Error('The API entrypoint has no "@type" key.'); 270 | } 271 | 272 | const entrypointClass = findSupportedClass(docs, entrypointType); 273 | if ( 274 | !Array.isArray( 275 | entrypointClass["http://www.w3.org/ns/hydra/core#supportedProperty"], 276 | ) 277 | ) { 278 | throw new TypeError( 279 | 'The entrypoint definition has no "http://www.w3.org/ns/hydra/core#supportedProperty" key or it is not an array.', 280 | ); 281 | } 282 | 283 | // Add resources 284 | for (const properties of entrypointClass[ 285 | "http://www.w3.org/ns/hydra/core#supportedProperty" 286 | ]) { 287 | const readableFields = [], 288 | resourceFields = [], 289 | writableFields = [], 290 | resourceOperations = []; 291 | 292 | const property = 293 | properties?.["http://www.w3.org/ns/hydra/core#property"]?.[0]; 294 | const propertyIri = property?.["@id"]; 295 | 296 | if (!property || !propertyIri) { 297 | continue; 298 | } 299 | 300 | const resourceProperty = entrypoint?.[0]?.[propertyIri]?.[0]; 301 | 302 | const url = 303 | typeof resourceProperty === "object" 304 | ? resourceProperty["@id"] 305 | : undefined; 306 | 307 | if (!url) { 308 | console.error( 309 | new Error( 310 | `Unable to find the URL for "${propertyIri}" in the entrypoint, make sure your API resource has at least one GET collection operation declared.`, 311 | ), 312 | ); 313 | continue; 314 | } 315 | 316 | // Add fields 317 | const relatedClass = findRelatedClass(docs, property); 318 | for (const supportedProperties of relatedClass?.[ 319 | "http://www.w3.org/ns/hydra/core#supportedProperty" 320 | ] ?? []) { 321 | const supportedProperty = 322 | supportedProperties?.["http://www.w3.org/ns/hydra/core#property"]?.[0]; 323 | const id = supportedProperty?.["@id"]; 324 | const range = 325 | supportedProperty?.[ 326 | "http://www.w3.org/2000/01/rdf-schema#range" 327 | ]?.[0]?.["@id"] ?? null; 328 | 329 | const field = new Field( 330 | supportedProperties?.["http://www.w3.org/ns/hydra/core#title"]?.[0]?.[ 331 | "@value" 332 | ] ?? 333 | supportedProperty?.[ 334 | "http://www.w3.org/2000/01/rdf-schema#label" 335 | ]?.[0]?.["@value"], 336 | { 337 | id, 338 | range, 339 | type: getType(id, range), 340 | reference: 341 | supportedProperty?.["@type"]?.[0] === 342 | "http://www.w3.org/ns/hydra/core#Link" 343 | ? range // Will be updated in a subsequent pass 344 | : null, 345 | embedded: 346 | supportedProperty?.["@type"]?.[0] === 347 | "http://www.w3.org/ns/hydra/core#Link" 348 | ? null 349 | : (range as unknown as Resource), // Will be updated in a subsequent pass 350 | required: 351 | supportedProperties?.[ 352 | "http://www.w3.org/ns/hydra/core#required" 353 | ]?.[0]?.["@value"] ?? false, 354 | description: 355 | supportedProperties?.[ 356 | "http://www.w3.org/ns/hydra/core#description" 357 | ]?.[0]?.["@value"] ?? "", 358 | maxCardinality: 359 | supportedProperty?.[ 360 | "http://www.w3.org/2002/07/owl#maxCardinality" 361 | ]?.[0]?.["@value"] ?? null, 362 | deprecated: 363 | supportedProperties?.[ 364 | "http://www.w3.org/2002/07/owl#deprecated" 365 | ]?.[0]?.["@value"] ?? false, 366 | }, 367 | ); 368 | 369 | fields.push(field); 370 | resourceFields.push(field); 371 | 372 | if ( 373 | supportedProperties?.[ 374 | "http://www.w3.org/ns/hydra/core#readable" 375 | ]?.[0]?.["@value"] 376 | ) { 377 | readableFields.push(field); 378 | } 379 | 380 | if ( 381 | supportedProperties?.[ 382 | "http://www.w3.org/ns/hydra/core#writeable" 383 | ]?.[0]?.["@value"] || 384 | supportedProperties?.[ 385 | "http://www.w3.org/ns/hydra/core#writable" 386 | ]?.[0]?.["@value"] 387 | ) { 388 | writableFields.push(field); 389 | } 390 | } 391 | 392 | // parse entrypoint's operations (a.k.a. collection operations) 393 | if (property["http://www.w3.org/ns/hydra/core#supportedOperation"]) { 394 | for (const entrypointOperation of property[ 395 | "http://www.w3.org/ns/hydra/core#supportedOperation" 396 | ]) { 397 | if (!entrypointOperation["http://www.w3.org/ns/hydra/core#returns"]) { 398 | continue; 399 | } 400 | 401 | const range = 402 | entrypointOperation["http://www.w3.org/ns/hydra/core#returns"]?.[0]?.[ 403 | "@id" 404 | ]; 405 | const method = 406 | entrypointOperation["http://www.w3.org/ns/hydra/core#method"]?.[0]?.[ 407 | "@value" 408 | ]; 409 | let type: OperationType = "list"; 410 | if (method === "POST") { 411 | type = "create"; 412 | } 413 | const operation = new Operation( 414 | getTitleOrLabel(entrypointOperation), 415 | type, 416 | { 417 | method, 418 | expects: 419 | entrypointOperation[ 420 | "http://www.w3.org/ns/hydra/core#expects" 421 | ]?.[0]?.["@id"], 422 | returns: range, 423 | types: entrypointOperation["@type"], 424 | deprecated: 425 | entrypointOperation?.[ 426 | "http://www.w3.org/2002/07/owl#deprecated" 427 | ]?.[0]?.["@value"] ?? false, 428 | }, 429 | ); 430 | 431 | resourceOperations.push(operation); 432 | operations.push(operation); 433 | } 434 | } 435 | 436 | // parse resource operations (a.k.a. item operations) 437 | for (const supportedOperation of relatedClass[ 438 | "http://www.w3.org/ns/hydra/core#supportedOperation" 439 | ] || []) { 440 | if (!supportedOperation["http://www.w3.org/ns/hydra/core#returns"]) { 441 | continue; 442 | } 443 | 444 | const range = 445 | supportedOperation["http://www.w3.org/ns/hydra/core#returns"]?.[0]?.[ 446 | "@id" 447 | ]; 448 | const method = 449 | supportedOperation["http://www.w3.org/ns/hydra/core#method"]?.[0]?.[ 450 | "@value" 451 | ]; 452 | let type: OperationType = "show"; 453 | if (method === "POST") { 454 | type = "create"; 455 | } 456 | if (method === "PUT" || method === "PATCH") { 457 | type = "edit"; 458 | } 459 | if (method === "DELETE") { 460 | type = "delete"; 461 | } 462 | const operation = new Operation( 463 | getTitleOrLabel(supportedOperation), 464 | type, 465 | { 466 | method, 467 | expects: 468 | supportedOperation[ 469 | "http://www.w3.org/ns/hydra/core#expects" 470 | ]?.[0]?.["@id"], 471 | returns: range, 472 | types: supportedOperation["@type"], 473 | deprecated: 474 | supportedOperation?.[ 475 | "http://www.w3.org/2002/07/owl#deprecated" 476 | ]?.[0]?.["@value"] ?? false, 477 | }, 478 | ); 479 | 480 | resourceOperations.push(operation); 481 | operations.push(operation); 482 | } 483 | 484 | const resource = new Resource(guessNameFromUrl(url, entrypointUrl), url, { 485 | id: relatedClass["@id"], 486 | title: 487 | relatedClass?.["http://www.w3.org/ns/hydra/core#title"]?.[0]?.[ 488 | "@value" 489 | ] ?? "", 490 | fields: resourceFields, 491 | readableFields, 492 | writableFields, 493 | operations: resourceOperations, 494 | deprecated: 495 | relatedClass?.["http://www.w3.org/2002/07/owl#deprecated"]?.[0]?.[ 496 | "@value" 497 | ] ?? false, 498 | }); 499 | 500 | resource.parameters = []; 501 | resource.getParameters = 502 | /** 503 | * Gets the parameters for the resource. 504 | * @returns {Promise} The parameters for the resource. 505 | */ 506 | (): Promise => getParameters(resource, options); 507 | 508 | resources.push(resource); 509 | } 510 | 511 | // Resolve references and embedded 512 | for (const field of fields) { 513 | if (field.reference !== null) { 514 | field.reference = 515 | resources.find((resource) => resource.id === field.reference) || null; 516 | } 517 | if (field.embedded !== null) { 518 | field.embedded = 519 | resources.find((resource) => resource.id === field.embedded) || null; 520 | } 521 | } 522 | 523 | return { 524 | api: new Api(entrypointUrl, { title, resources }), 525 | response, 526 | status: response.status, 527 | }; 528 | } 529 | -------------------------------------------------------------------------------- /src/openapi3/handleJson.test.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIV3 } from "openapi-types"; 2 | import { expect, test } from "vitest"; 3 | import { parsedJsonReplacer } from "../core/utils/index.js"; 4 | import handleJson from "./handleJson.js"; 5 | 6 | const openApi3Definition: OpenAPIV3.Document = { 7 | openapi: "3.0.2", 8 | info: { 9 | title: "", 10 | version: "0.0.0", 11 | }, 12 | paths: { 13 | "/books": { 14 | get: { 15 | tags: ["Book"], 16 | operationId: "getBookCollection", 17 | summary: "Retrieves the collection of Book resources.", 18 | responses: { 19 | "200": { 20 | description: "Book collection response", 21 | content: { 22 | "application/ld+json": { 23 | schema: { 24 | type: "array", 25 | items: { 26 | $ref: "#/components/schemas/Book-book.read", 27 | }, 28 | }, 29 | }, 30 | "application/json": { 31 | schema: { 32 | type: "array", 33 | items: { 34 | $ref: "#/components/schemas/Book-book.read", 35 | }, 36 | }, 37 | }, 38 | "text/html": { 39 | schema: { 40 | type: "array", 41 | items: { 42 | $ref: "#/components/schemas/Book-book.read", 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }, 49 | parameters: [ 50 | { 51 | name: "page", 52 | in: "query", 53 | required: false, 54 | description: "The collection page number", 55 | schema: { 56 | type: "integer", 57 | }, 58 | }, 59 | ], 60 | }, 61 | post: { 62 | tags: ["Book"], 63 | operationId: "postBookCollection", 64 | summary: "Creates a Book resource.", 65 | responses: { 66 | "201": { 67 | description: "Book resource created", 68 | content: { 69 | "application/ld+json": { 70 | schema: { 71 | $ref: "#/components/schemas/Book-book.read", 72 | }, 73 | }, 74 | "application/json": { 75 | schema: { 76 | $ref: "#/components/schemas/Book-book.read", 77 | }, 78 | }, 79 | "text/html": { 80 | schema: { 81 | $ref: "#/components/schemas/Book-book.read", 82 | }, 83 | }, 84 | }, 85 | links: { 86 | GetBookItem: { 87 | parameters: { 88 | id: "$response.body#/id", 89 | }, 90 | operationId: "getBookItem", 91 | description: 92 | "The `id` value returned in the response can be used as the `id` parameter in `GET /books/{id}`.", 93 | }, 94 | }, 95 | }, 96 | "400": { 97 | description: "Invalid input", 98 | }, 99 | "404": { 100 | description: "Resource not found", 101 | }, 102 | }, 103 | requestBody: { 104 | content: { 105 | "application/ld+json": { 106 | schema: { 107 | $ref: "#/components/schemas/Book", 108 | }, 109 | }, 110 | "application/json": { 111 | schema: { 112 | $ref: "#/components/schemas/Book", 113 | }, 114 | }, 115 | "text/html": { 116 | schema: { 117 | $ref: "#/components/schemas/Book", 118 | }, 119 | }, 120 | }, 121 | description: "The new Book resource", 122 | }, 123 | }, 124 | }, 125 | "/books/{id}": { 126 | get: { 127 | tags: ["Book"], 128 | operationId: "getBookItem", 129 | summary: "Retrieves a Book resource.", 130 | parameters: [ 131 | { 132 | name: "id", 133 | in: "path", 134 | required: true, 135 | schema: { 136 | type: "string", 137 | }, 138 | }, 139 | ], 140 | responses: { 141 | "200": { 142 | description: "Book resource response", 143 | content: { 144 | "application/ld+json": { 145 | schema: { 146 | $ref: "#/components/schemas/Book-book.read", 147 | }, 148 | }, 149 | "application/json": { 150 | schema: { 151 | $ref: "#/components/schemas/Book-book.read", 152 | }, 153 | }, 154 | "text/html": { 155 | schema: { 156 | $ref: "#/components/schemas/Book-book.read", 157 | }, 158 | }, 159 | }, 160 | }, 161 | "404": { 162 | description: "Resource not found", 163 | }, 164 | }, 165 | }, 166 | delete: { 167 | tags: ["Book"], 168 | operationId: "deleteBookItem", 169 | summary: "Removes the Book resource.", 170 | responses: { 171 | "204": { 172 | description: "Book resource deleted", 173 | }, 174 | "404": { 175 | description: "Resource not found", 176 | }, 177 | }, 178 | parameters: [ 179 | { 180 | name: "id", 181 | in: "path", 182 | required: true, 183 | schema: { 184 | type: "string", 185 | }, 186 | }, 187 | ], 188 | }, 189 | put: { 190 | tags: ["Book"], 191 | operationId: "putBookItem", 192 | summary: "Replaces the Book resource.", 193 | parameters: [ 194 | { 195 | name: "id", 196 | in: "path", 197 | required: true, 198 | schema: { 199 | type: "string", 200 | }, 201 | }, 202 | ], 203 | responses: { 204 | "200": { 205 | description: "Book resource updated", 206 | content: { 207 | "application/ld+json": { 208 | schema: { 209 | $ref: "#/components/schemas/Book-book.read", 210 | }, 211 | }, 212 | "application/json": { 213 | schema: { 214 | $ref: "#/components/schemas/Book-book.read", 215 | }, 216 | }, 217 | "text/html": { 218 | schema: { 219 | $ref: "#/components/schemas/Book-book.read", 220 | }, 221 | }, 222 | }, 223 | }, 224 | "400": { 225 | description: "Invalid input", 226 | }, 227 | "404": { 228 | description: "Resource not found", 229 | }, 230 | }, 231 | requestBody: { 232 | content: { 233 | "application/ld+json": { 234 | schema: { 235 | $ref: "#/components/schemas/Book", 236 | }, 237 | }, 238 | "application/json": { 239 | schema: { 240 | $ref: "#/components/schemas/Book", 241 | }, 242 | }, 243 | "text/html": { 244 | schema: { 245 | $ref: "#/components/schemas/Book", 246 | }, 247 | }, 248 | }, 249 | description: "The updated Book resource", 250 | }, 251 | }, 252 | }, 253 | "/reviews": { 254 | get: { 255 | tags: ["Review"], 256 | operationId: "getReviewCollection", 257 | summary: "Retrieves the collection of Review resources.", 258 | responses: { 259 | "200": { 260 | description: "Review collection response", 261 | content: { 262 | "application/ld+json": { 263 | schema: { 264 | type: "array", 265 | items: { 266 | $ref: "#/components/schemas/Review", 267 | }, 268 | }, 269 | }, 270 | "application/json": { 271 | schema: { 272 | type: "array", 273 | items: { 274 | $ref: "#/components/schemas/Review", 275 | }, 276 | }, 277 | }, 278 | "text/html": { 279 | schema: { 280 | type: "array", 281 | items: { 282 | $ref: "#/components/schemas/Review", 283 | }, 284 | }, 285 | }, 286 | }, 287 | }, 288 | }, 289 | parameters: [ 290 | { 291 | name: "page", 292 | in: "query", 293 | required: false, 294 | description: "The collection page number", 295 | schema: { 296 | type: "integer", 297 | }, 298 | }, 299 | ], 300 | }, 301 | post: { 302 | tags: ["Review"], 303 | operationId: "postReviewCollection", 304 | summary: "Creates a Review resource.", 305 | responses: { 306 | "201": { 307 | description: "Review resource created", 308 | content: { 309 | "application/ld+json": { 310 | schema: { 311 | $ref: "#/components/schemas/Review", 312 | }, 313 | }, 314 | "application/json": { 315 | schema: { 316 | $ref: "#/components/schemas/Review", 317 | }, 318 | }, 319 | "text/html": { 320 | schema: { 321 | $ref: "#/components/schemas/Review", 322 | }, 323 | }, 324 | }, 325 | links: { 326 | GetReviewItem: { 327 | parameters: { 328 | id: "$response.body#/id", 329 | }, 330 | operationId: "getReviewItem", 331 | description: 332 | "The `id` value returned in the response can be used as the `id` parameter in `GET /reviews/{id}`.", 333 | }, 334 | }, 335 | }, 336 | "400": { 337 | description: "Invalid input", 338 | }, 339 | "404": { 340 | description: "Resource not found", 341 | }, 342 | }, 343 | requestBody: { 344 | content: { 345 | "application/ld+json": { 346 | schema: { 347 | $ref: "#/components/schemas/Review", 348 | }, 349 | }, 350 | "application/json": { 351 | schema: { 352 | $ref: "#/components/schemas/Review", 353 | }, 354 | }, 355 | "text/html": { 356 | schema: { 357 | $ref: "#/components/schemas/Review", 358 | }, 359 | }, 360 | }, 361 | description: "The new Review resource", 362 | }, 363 | }, 364 | }, 365 | "/reviews/{id}": { 366 | get: { 367 | tags: ["Review"], 368 | operationId: "getReviewItem", 369 | summary: "Retrieves a Review resource.", 370 | parameters: [ 371 | { 372 | name: "id", 373 | in: "path", 374 | required: true, 375 | schema: { 376 | type: "string", 377 | }, 378 | }, 379 | ], 380 | responses: { 381 | "200": { 382 | description: "Review resource response", 383 | content: { 384 | "application/ld+json": { 385 | schema: { 386 | $ref: "#/components/schemas/Review", 387 | }, 388 | }, 389 | "application/json": { 390 | schema: { 391 | $ref: "#/components/schemas/Review", 392 | }, 393 | }, 394 | "text/html": { 395 | schema: { 396 | $ref: "#/components/schemas/Review", 397 | }, 398 | }, 399 | }, 400 | }, 401 | "404": { 402 | description: "Resource not found", 403 | }, 404 | }, 405 | }, 406 | delete: { 407 | tags: ["Review"], 408 | operationId: "deleteReviewItem", 409 | summary: "Removes the Review resource.", 410 | responses: { 411 | "204": { 412 | description: "Review resource deleted", 413 | }, 414 | "404": { 415 | description: "Resource not found", 416 | }, 417 | }, 418 | parameters: [ 419 | { 420 | name: "id", 421 | in: "path", 422 | required: true, 423 | schema: { 424 | type: "string", 425 | }, 426 | }, 427 | ], 428 | }, 429 | put: { 430 | tags: ["Review"], 431 | operationId: "putReviewItem", 432 | summary: "Replaces the Review resource.", 433 | parameters: [ 434 | { 435 | name: "id", 436 | in: "path", 437 | required: true, 438 | schema: { 439 | type: "string", 440 | }, 441 | }, 442 | ], 443 | responses: { 444 | "200": { 445 | description: "Review resource updated", 446 | content: { 447 | "application/ld+json": { 448 | schema: { 449 | $ref: "#/components/schemas/Review", 450 | }, 451 | }, 452 | "application/json": { 453 | schema: { 454 | $ref: "#/components/schemas/Review", 455 | }, 456 | }, 457 | "text/html": { 458 | schema: { 459 | $ref: "#/components/schemas/Review", 460 | }, 461 | }, 462 | }, 463 | }, 464 | "400": { 465 | description: "Invalid input", 466 | }, 467 | "404": { 468 | description: "Resource not found", 469 | }, 470 | }, 471 | requestBody: { 472 | content: { 473 | "application/ld+json": { 474 | schema: { 475 | $ref: "#/components/schemas/Review", 476 | }, 477 | }, 478 | "application/json": { 479 | schema: { 480 | $ref: "#/components/schemas/Review", 481 | }, 482 | }, 483 | "text/html": { 484 | schema: { 485 | $ref: "#/components/schemas/Review", 486 | }, 487 | }, 488 | }, 489 | description: "The updated Review resource", 490 | }, 491 | }, 492 | }, 493 | }, 494 | components: { 495 | schemas: { 496 | Book: { 497 | type: "object", 498 | description: "", 499 | properties: { 500 | isbn: { 501 | type: "string", 502 | description: "The ISBN of the book", 503 | }, 504 | description: { 505 | type: "string", 506 | description: "A description of the item", 507 | }, 508 | author: { 509 | type: "string", 510 | description: 511 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 512 | }, 513 | title: { 514 | type: "string", 515 | description: "The title of the book", 516 | }, 517 | bookFormat: { 518 | type: "string", 519 | description: "The publication format of the book.", 520 | enum: ["AUDIOBOOK_FORMAT", "E_BOOK", "PAPERBACK", "HARDCOVER"], 521 | }, 522 | publicationDate: { 523 | type: "string", 524 | format: "date-time", 525 | description: 526 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 527 | }, 528 | reviews: { 529 | type: "array", 530 | items: { 531 | type: "string", 532 | }, 533 | }, 534 | archivedAt: { 535 | writeOnly: true, 536 | type: "string", 537 | format: "date-time", 538 | nullable: true, 539 | }, 540 | }, 541 | required: [ 542 | "description", 543 | "author", 544 | "title", 545 | "bookFormat", 546 | "publicationDate", 547 | ], 548 | }, 549 | "Book-book.read": { 550 | type: "object", 551 | description: "", 552 | properties: { 553 | id: { 554 | readOnly: true, 555 | type: "integer", 556 | }, 557 | isbn: { 558 | type: "string", 559 | description: "The ISBN of the book", 560 | }, 561 | description: { 562 | type: "string", 563 | description: "A description of the item", 564 | }, 565 | author: { 566 | type: "string", 567 | description: 568 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 569 | }, 570 | title: { 571 | type: "string", 572 | description: "The title of the book", 573 | }, 574 | bookFormat: { 575 | type: "string", 576 | description: "The publication format of the book.", 577 | enum: ["AUDIOBOOK_FORMAT", "E_BOOK", "PAPERBACK", "HARDCOVER"], 578 | }, 579 | publicationDate: { 580 | type: "string", 581 | format: "date-time", 582 | description: 583 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 584 | }, 585 | reviews: { 586 | type: "array", 587 | items: { 588 | type: "string", 589 | }, 590 | }, 591 | }, 592 | required: [ 593 | "description", 594 | "author", 595 | "title", 596 | "bookFormat", 597 | "publicationDate", 598 | ], 599 | }, 600 | Review: { 601 | type: "object", 602 | description: "", 603 | properties: { 604 | id: { 605 | readOnly: true, 606 | type: "integer", 607 | }, 608 | rating: { 609 | type: "integer", 610 | }, 611 | body: { 612 | type: "string", 613 | }, 614 | book: { 615 | type: "string", 616 | }, 617 | author: { 618 | type: "string", 619 | }, 620 | publicationDate: { 621 | type: "string", 622 | format: "date-time", 623 | }, 624 | }, 625 | }, 626 | }, 627 | }, 628 | }; 629 | const parsed = [ 630 | { 631 | name: "books", 632 | url: "https://demo.api-platform.com/books", 633 | id: null, 634 | title: "Book", 635 | description: "", 636 | fields: [ 637 | { 638 | name: "id", 639 | id: null, 640 | range: null, 641 | type: "integer", 642 | arrayType: null, 643 | enum: null, 644 | reference: null, 645 | embedded: null, 646 | nullable: false, 647 | required: false, 648 | description: "", 649 | }, 650 | { 651 | name: "isbn", 652 | id: null, 653 | range: null, 654 | type: "string", 655 | arrayType: null, 656 | enum: null, 657 | reference: null, 658 | embedded: null, 659 | nullable: false, 660 | required: false, 661 | description: "The ISBN of the book", 662 | }, 663 | { 664 | name: "description", 665 | id: null, 666 | range: null, 667 | type: "string", 668 | arrayType: null, 669 | enum: null, 670 | reference: null, 671 | embedded: null, 672 | nullable: false, 673 | required: true, 674 | description: "A description of the item", 675 | }, 676 | { 677 | name: "author", 678 | id: null, 679 | range: null, 680 | type: "string", 681 | arrayType: null, 682 | enum: null, 683 | reference: null, 684 | embedded: null, 685 | nullable: false, 686 | required: true, 687 | description: 688 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 689 | }, 690 | { 691 | name: "title", 692 | id: null, 693 | range: null, 694 | type: "string", 695 | arrayType: null, 696 | enum: null, 697 | reference: null, 698 | embedded: null, 699 | nullable: false, 700 | required: true, 701 | description: "The title of the book", 702 | }, 703 | { 704 | name: "bookFormat", 705 | id: null, 706 | range: null, 707 | type: "string", 708 | arrayType: null, 709 | enum: { 710 | "Audiobook format": "AUDIOBOOK_FORMAT", 711 | "E book": "E_BOOK", 712 | Paperback: "PAPERBACK", 713 | Hardcover: "HARDCOVER", 714 | }, 715 | reference: null, 716 | embedded: null, 717 | nullable: false, 718 | required: true, 719 | description: "The publication format of the book.", 720 | }, 721 | { 722 | name: "publicationDate", 723 | id: null, 724 | range: null, 725 | type: "dateTime", 726 | arrayType: null, 727 | enum: null, 728 | reference: null, 729 | embedded: null, 730 | nullable: false, 731 | required: true, 732 | description: 733 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 734 | }, 735 | { 736 | name: "reviews", 737 | id: null, 738 | range: null, 739 | type: "array", 740 | arrayType: "string", 741 | enum: null, 742 | reference: { 743 | title: "Review", 744 | }, 745 | embedded: null, 746 | nullable: false, 747 | required: false, 748 | description: "", 749 | maxCardinality: null, 750 | }, 751 | { 752 | name: "archivedAt", 753 | id: null, 754 | range: null, 755 | type: "dateTime", 756 | arrayType: null, 757 | enum: null, 758 | reference: null, 759 | embedded: null, 760 | nullable: true, 761 | required: false, 762 | description: "", 763 | }, 764 | ], 765 | readableFields: [ 766 | { 767 | name: "id", 768 | id: null, 769 | range: null, 770 | type: "integer", 771 | arrayType: null, 772 | enum: null, 773 | reference: null, 774 | embedded: null, 775 | nullable: false, 776 | required: false, 777 | description: "", 778 | }, 779 | { 780 | name: "isbn", 781 | id: null, 782 | range: null, 783 | type: "string", 784 | arrayType: null, 785 | enum: null, 786 | reference: null, 787 | embedded: null, 788 | nullable: false, 789 | required: false, 790 | description: "The ISBN of the book", 791 | }, 792 | { 793 | name: "description", 794 | id: null, 795 | range: null, 796 | type: "string", 797 | arrayType: null, 798 | enum: null, 799 | reference: null, 800 | embedded: null, 801 | nullable: false, 802 | required: true, 803 | description: "A description of the item", 804 | }, 805 | { 806 | name: "author", 807 | id: null, 808 | range: null, 809 | type: "string", 810 | arrayType: null, 811 | enum: null, 812 | reference: null, 813 | embedded: null, 814 | nullable: false, 815 | required: true, 816 | description: 817 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 818 | }, 819 | { 820 | name: "title", 821 | id: null, 822 | range: null, 823 | type: "string", 824 | arrayType: null, 825 | enum: null, 826 | reference: null, 827 | embedded: null, 828 | nullable: false, 829 | required: true, 830 | description: "The title of the book", 831 | }, 832 | { 833 | name: "bookFormat", 834 | id: null, 835 | range: null, 836 | type: "string", 837 | arrayType: null, 838 | enum: { 839 | "Audiobook format": "AUDIOBOOK_FORMAT", 840 | "E book": "E_BOOK", 841 | Paperback: "PAPERBACK", 842 | Hardcover: "HARDCOVER", 843 | }, 844 | reference: null, 845 | embedded: null, 846 | nullable: false, 847 | required: true, 848 | description: "The publication format of the book.", 849 | }, 850 | { 851 | name: "publicationDate", 852 | id: null, 853 | range: null, 854 | type: "dateTime", 855 | arrayType: null, 856 | enum: null, 857 | reference: null, 858 | embedded: null, 859 | nullable: false, 860 | required: true, 861 | description: 862 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 863 | }, 864 | { 865 | name: "reviews", 866 | id: null, 867 | range: null, 868 | type: "array", 869 | arrayType: "string", 870 | enum: null, 871 | reference: { 872 | title: "Review", 873 | }, 874 | embedded: null, 875 | nullable: false, 876 | required: false, 877 | description: "", 878 | maxCardinality: null, 879 | }, 880 | ], 881 | writableFields: [ 882 | { 883 | name: "isbn", 884 | id: null, 885 | range: null, 886 | type: "string", 887 | arrayType: null, 888 | enum: null, 889 | reference: null, 890 | embedded: null, 891 | nullable: false, 892 | required: false, 893 | description: "The ISBN of the book", 894 | }, 895 | { 896 | name: "description", 897 | id: null, 898 | range: null, 899 | type: "string", 900 | arrayType: null, 901 | enum: null, 902 | reference: null, 903 | embedded: null, 904 | nullable: false, 905 | required: true, 906 | description: "A description of the item", 907 | }, 908 | { 909 | name: "author", 910 | id: null, 911 | range: null, 912 | type: "string", 913 | arrayType: null, 914 | enum: null, 915 | reference: null, 916 | embedded: null, 917 | nullable: false, 918 | required: true, 919 | description: 920 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 921 | }, 922 | { 923 | name: "title", 924 | id: null, 925 | range: null, 926 | type: "string", 927 | arrayType: null, 928 | enum: null, 929 | reference: null, 930 | embedded: null, 931 | nullable: false, 932 | required: true, 933 | description: "The title of the book", 934 | }, 935 | { 936 | name: "bookFormat", 937 | id: null, 938 | range: null, 939 | type: "string", 940 | arrayType: null, 941 | enum: { 942 | "Audiobook format": "AUDIOBOOK_FORMAT", 943 | "E book": "E_BOOK", 944 | Paperback: "PAPERBACK", 945 | Hardcover: "HARDCOVER", 946 | }, 947 | reference: null, 948 | embedded: null, 949 | nullable: false, 950 | required: true, 951 | description: "The publication format of the book.", 952 | }, 953 | { 954 | name: "publicationDate", 955 | id: null, 956 | range: null, 957 | type: "dateTime", 958 | arrayType: null, 959 | enum: null, 960 | reference: null, 961 | embedded: null, 962 | nullable: false, 963 | required: true, 964 | description: 965 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 966 | }, 967 | { 968 | name: "reviews", 969 | id: null, 970 | range: null, 971 | type: "array", 972 | arrayType: "string", 973 | enum: null, 974 | reference: { 975 | title: "Review", 976 | }, 977 | embedded: null, 978 | nullable: false, 979 | required: false, 980 | description: "", 981 | maxCardinality: null, 982 | }, 983 | { 984 | name: "archivedAt", 985 | id: null, 986 | range: null, 987 | type: "dateTime", 988 | arrayType: null, 989 | enum: null, 990 | reference: null, 991 | embedded: null, 992 | nullable: true, 993 | required: false, 994 | description: "", 995 | }, 996 | ], 997 | 998 | operations: [ 999 | { 1000 | name: "Retrieves a Book resource.", 1001 | type: "show", 1002 | method: "GET", 1003 | deprecated: false, 1004 | }, 1005 | { 1006 | name: "Replaces the Book resource.", 1007 | type: "edit", 1008 | method: "PUT", 1009 | deprecated: false, 1010 | }, 1011 | { 1012 | name: "Removes the Book resource.", 1013 | type: "delete", 1014 | method: "DELETE", 1015 | deprecated: false, 1016 | }, 1017 | { 1018 | name: "Retrieves the collection of Book resources.", 1019 | type: "list", 1020 | method: "GET", 1021 | deprecated: false, 1022 | }, 1023 | { 1024 | name: "Creates a Book resource.", 1025 | type: "create", 1026 | method: "POST", 1027 | deprecated: false, 1028 | }, 1029 | ], 1030 | parameters: [ 1031 | { 1032 | variable: "page", 1033 | range: "integer", 1034 | required: false, 1035 | description: "The collection page number", 1036 | }, 1037 | ], 1038 | }, 1039 | { 1040 | name: "reviews", 1041 | url: "https://demo.api-platform.com/reviews", 1042 | id: null, 1043 | title: "Review", 1044 | description: "", 1045 | fields: [ 1046 | { 1047 | name: "id", 1048 | id: null, 1049 | range: null, 1050 | type: "integer", 1051 | arrayType: null, 1052 | enum: null, 1053 | reference: null, 1054 | embedded: null, 1055 | nullable: false, 1056 | required: false, 1057 | description: "", 1058 | }, 1059 | { 1060 | name: "rating", 1061 | id: null, 1062 | range: null, 1063 | type: "integer", 1064 | arrayType: null, 1065 | enum: null, 1066 | reference: null, 1067 | embedded: null, 1068 | nullable: false, 1069 | required: false, 1070 | description: "", 1071 | }, 1072 | { 1073 | name: "body", 1074 | id: null, 1075 | range: null, 1076 | type: "string", 1077 | arrayType: null, 1078 | enum: null, 1079 | reference: null, 1080 | embedded: null, 1081 | nullable: false, 1082 | required: false, 1083 | description: "", 1084 | }, 1085 | { 1086 | name: "book", 1087 | id: null, 1088 | range: null, 1089 | type: "string", 1090 | arrayType: null, 1091 | enum: null, 1092 | reference: { 1093 | title: "Book", 1094 | }, 1095 | embedded: null, 1096 | nullable: false, 1097 | required: false, 1098 | description: "", 1099 | maxCardinality: 1, 1100 | }, 1101 | { 1102 | name: "author", 1103 | id: null, 1104 | range: null, 1105 | type: "string", 1106 | arrayType: null, 1107 | enum: null, 1108 | reference: null, 1109 | embedded: null, 1110 | nullable: false, 1111 | required: false, 1112 | description: "", 1113 | }, 1114 | { 1115 | name: "publicationDate", 1116 | id: null, 1117 | range: null, 1118 | type: "dateTime", 1119 | arrayType: null, 1120 | enum: null, 1121 | reference: null, 1122 | embedded: null, 1123 | nullable: false, 1124 | required: false, 1125 | description: "", 1126 | }, 1127 | ], 1128 | readableFields: [ 1129 | { 1130 | name: "id", 1131 | id: null, 1132 | range: null, 1133 | type: "integer", 1134 | arrayType: null, 1135 | enum: null, 1136 | reference: null, 1137 | embedded: null, 1138 | nullable: false, 1139 | required: false, 1140 | description: "", 1141 | }, 1142 | { 1143 | name: "rating", 1144 | id: null, 1145 | range: null, 1146 | type: "integer", 1147 | arrayType: null, 1148 | enum: null, 1149 | reference: null, 1150 | embedded: null, 1151 | nullable: false, 1152 | required: false, 1153 | description: "", 1154 | }, 1155 | { 1156 | name: "body", 1157 | id: null, 1158 | range: null, 1159 | type: "string", 1160 | arrayType: null, 1161 | enum: null, 1162 | reference: null, 1163 | embedded: null, 1164 | nullable: false, 1165 | required: false, 1166 | description: "", 1167 | }, 1168 | { 1169 | name: "book", 1170 | id: null, 1171 | range: null, 1172 | type: "string", 1173 | arrayType: null, 1174 | enum: null, 1175 | reference: { 1176 | title: "Book", 1177 | }, 1178 | embedded: null, 1179 | nullable: false, 1180 | required: false, 1181 | description: "", 1182 | maxCardinality: 1, 1183 | }, 1184 | { 1185 | name: "author", 1186 | id: null, 1187 | range: null, 1188 | type: "string", 1189 | arrayType: null, 1190 | enum: null, 1191 | reference: null, 1192 | embedded: null, 1193 | nullable: false, 1194 | required: false, 1195 | description: "", 1196 | }, 1197 | { 1198 | name: "publicationDate", 1199 | id: null, 1200 | range: null, 1201 | type: "dateTime", 1202 | arrayType: null, 1203 | enum: null, 1204 | reference: null, 1205 | embedded: null, 1206 | nullable: false, 1207 | required: false, 1208 | description: "", 1209 | }, 1210 | ], 1211 | writableFields: [ 1212 | { 1213 | name: "rating", 1214 | id: null, 1215 | range: null, 1216 | type: "integer", 1217 | arrayType: null, 1218 | enum: null, 1219 | reference: null, 1220 | embedded: null, 1221 | nullable: false, 1222 | required: false, 1223 | description: "", 1224 | }, 1225 | { 1226 | name: "body", 1227 | id: null, 1228 | range: null, 1229 | type: "string", 1230 | arrayType: null, 1231 | enum: null, 1232 | reference: null, 1233 | embedded: null, 1234 | nullable: false, 1235 | required: false, 1236 | description: "", 1237 | }, 1238 | { 1239 | name: "book", 1240 | id: null, 1241 | range: null, 1242 | type: "string", 1243 | arrayType: null, 1244 | enum: null, 1245 | reference: { 1246 | title: "Book", 1247 | }, 1248 | embedded: null, 1249 | nullable: false, 1250 | required: false, 1251 | description: "", 1252 | maxCardinality: 1, 1253 | }, 1254 | { 1255 | name: "author", 1256 | id: null, 1257 | range: null, 1258 | type: "string", 1259 | arrayType: null, 1260 | enum: null, 1261 | reference: null, 1262 | embedded: null, 1263 | nullable: false, 1264 | required: false, 1265 | description: "", 1266 | }, 1267 | { 1268 | name: "publicationDate", 1269 | id: null, 1270 | range: null, 1271 | type: "dateTime", 1272 | arrayType: null, 1273 | enum: null, 1274 | reference: null, 1275 | embedded: null, 1276 | nullable: false, 1277 | required: false, 1278 | description: "", 1279 | }, 1280 | ], 1281 | 1282 | operations: [ 1283 | { 1284 | name: "Retrieves a Review resource.", 1285 | type: "show", 1286 | method: "GET", 1287 | deprecated: false, 1288 | }, 1289 | { 1290 | name: "Replaces the Review resource.", 1291 | type: "edit", 1292 | method: "PUT", 1293 | deprecated: false, 1294 | }, 1295 | { 1296 | name: "Removes the Review resource.", 1297 | type: "delete", 1298 | method: "DELETE", 1299 | deprecated: false, 1300 | }, 1301 | { 1302 | name: "Retrieves the collection of Review resources.", 1303 | type: "list", 1304 | method: "GET", 1305 | deprecated: false, 1306 | }, 1307 | { 1308 | name: "Creates a Review resource.", 1309 | type: "create", 1310 | method: "POST", 1311 | deprecated: false, 1312 | }, 1313 | ], 1314 | parameters: [ 1315 | { 1316 | variable: "page", 1317 | range: "integer", 1318 | required: false, 1319 | description: "The collection page number", 1320 | }, 1321 | ], 1322 | }, 1323 | ]; 1324 | 1325 | test(`Parse OpenApi v3 Documentation from Json`, async () => { 1326 | const toBeParsed = await handleJson( 1327 | openApi3Definition, 1328 | "https://demo.api-platform.com", 1329 | ); 1330 | 1331 | expect(JSON.stringify(toBeParsed, parsedJsonReplacer)).toEqual( 1332 | JSON.stringify(parsed, parsedJsonReplacer), 1333 | ); 1334 | }); 1335 | -------------------------------------------------------------------------------- /src/swagger/handleJson.test.ts: -------------------------------------------------------------------------------- 1 | import handleJson from "./handleJson.js"; 2 | import type { OpenAPIV2 } from "openapi-types"; 3 | import { assert, describe, expect, test } from "vitest"; 4 | 5 | const swaggerApiDefinition: OpenAPIV2.Document = { 6 | swagger: "2.0", 7 | basePath: "/", 8 | info: { 9 | title: "API Platform's demo", 10 | version: "0.0.0", 11 | description: 12 | "This is a demo application of the [API Platform](https://api-platform.com) framework.\n[Its source code](https://github.com/api-platform/demo) includes various examples, check it out!\n", 13 | }, 14 | paths: { 15 | "/books": { 16 | get: { 17 | tags: ["Book"], 18 | operationId: "getBookCollection", 19 | produces: [ 20 | "application/ld+json", 21 | "application/hal+json", 22 | "application/xml", 23 | "text/xml", 24 | "application/json", 25 | "application/x-yaml", 26 | "text/csv", 27 | "text/html", 28 | ], 29 | summary: "Retrieves the collection of Book resources.", 30 | responses: { 31 | "200": { 32 | description: "Book collection response", 33 | schema: { 34 | type: "array", 35 | items: { $ref: "#/definitions/Book" }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | post: { 41 | tags: ["Book"], 42 | operationId: "postBookCollection", 43 | consumes: [ 44 | "application/ld+json", 45 | "application/hal+json", 46 | "application/xml", 47 | "text/xml", 48 | "application/json", 49 | "application/x-yaml", 50 | "text/csv", 51 | "text/html", 52 | ], 53 | produces: [ 54 | "application/ld+json", 55 | "application/hal+json", 56 | "application/xml", 57 | "text/xml", 58 | "application/json", 59 | "application/x-yaml", 60 | "text/csv", 61 | "text/html", 62 | ], 63 | summary: "Creates a Book resource.", 64 | parameters: [ 65 | { 66 | name: "book", 67 | in: "body", 68 | description: "The new Book resource", 69 | schema: { $ref: "#/definitions/Book" }, 70 | }, 71 | ], 72 | responses: { 73 | "201": { 74 | description: "Book resource created", 75 | schema: { $ref: "#/definitions/Book" }, 76 | }, 77 | "400": { description: "Invalid input" }, 78 | "404": { description: "Resource not found" }, 79 | }, 80 | }, 81 | }, 82 | "/books/{id}": { 83 | get: { 84 | tags: ["Book"], 85 | operationId: "getBookItem", 86 | produces: [ 87 | "application/ld+json", 88 | "application/hal+json", 89 | "application/xml", 90 | "text/xml", 91 | "application/json", 92 | "application/x-yaml", 93 | "text/csv", 94 | "text/html", 95 | ], 96 | summary: "Retrieves a Book resource.", 97 | parameters: [ 98 | { name: "id", in: "path", required: true, type: "integer" }, 99 | ], 100 | responses: { 101 | "200": { 102 | description: "Book resource response", 103 | schema: { $ref: "#/definitions/Book" }, 104 | }, 105 | "404": { description: "Resource not found" }, 106 | }, 107 | }, 108 | put: { 109 | tags: ["Book"], 110 | operationId: "putBookItem", 111 | consumes: [ 112 | "application/ld+json", 113 | "application/hal+json", 114 | "application/xml", 115 | "text/xml", 116 | "application/json", 117 | "application/x-yaml", 118 | "text/csv", 119 | "text/html", 120 | ], 121 | produces: [ 122 | "application/ld+json", 123 | "application/hal+json", 124 | "application/xml", 125 | "text/xml", 126 | "application/json", 127 | "application/x-yaml", 128 | "text/csv", 129 | "text/html", 130 | ], 131 | summary: "Replaces the Book resource.", 132 | parameters: [ 133 | { name: "id", in: "path", type: "integer", required: true }, 134 | { 135 | name: "book", 136 | in: "body", 137 | description: "The updated Book resource", 138 | schema: { $ref: "#/definitions/Book" }, 139 | }, 140 | ], 141 | responses: { 142 | "200": { 143 | description: "Book resource updated", 144 | schema: { $ref: "#/definitions/Book" }, 145 | }, 146 | "400": { description: "Invalid input" }, 147 | "404": { description: "Resource not found" }, 148 | }, 149 | }, 150 | delete: { 151 | tags: ["Book"], 152 | operationId: "deleteBookItem", 153 | summary: "Removes the Book resource.", 154 | responses: { 155 | "204": { description: "Book resource deleted" }, 156 | "404": { description: "Resource not found" }, 157 | }, 158 | parameters: [ 159 | { name: "id", in: "path", type: "integer", required: true }, 160 | ], 161 | }, 162 | }, 163 | "/reviews": { 164 | get: { 165 | tags: ["Review"], 166 | operationId: "getReviewCollection", 167 | produces: [ 168 | "application/ld+json", 169 | "application/hal+json", 170 | "application/xml", 171 | "text/xml", 172 | "application/json", 173 | "application/x-yaml", 174 | "text/csv", 175 | "text/html", 176 | ], 177 | summary: "Retrieves the collection of Review resources.", 178 | responses: { 179 | "200": { 180 | description: "Review collection response", 181 | schema: { 182 | type: "array", 183 | items: { $ref: "#/definitions/Review" }, 184 | }, 185 | }, 186 | }, 187 | }, 188 | post: { 189 | tags: ["Review"], 190 | operationId: "postReviewCollection", 191 | consumes: [ 192 | "application/ld+json", 193 | "application/hal+json", 194 | "application/xml", 195 | "text/xml", 196 | "application/json", 197 | "application/x-yaml", 198 | "text/csv", 199 | "text/html", 200 | ], 201 | produces: [ 202 | "application/ld+json", 203 | "application/hal+json", 204 | "application/xml", 205 | "text/xml", 206 | "application/json", 207 | "application/x-yaml", 208 | "text/csv", 209 | "text/html", 210 | ], 211 | summary: "Creates a Review resource.", 212 | parameters: [ 213 | { 214 | name: "review", 215 | in: "body", 216 | description: "The new Review resource", 217 | schema: { $ref: "#/definitions/Review" }, 218 | }, 219 | ], 220 | responses: { 221 | "201": { 222 | description: "Review resource created", 223 | schema: { $ref: "#/definitions/Review" }, 224 | }, 225 | "400": { description: "Invalid input" }, 226 | "404": { description: "Resource not found" }, 227 | }, 228 | }, 229 | }, 230 | "/reviews/{id}": { 231 | get: { 232 | tags: ["Review"], 233 | operationId: "getReviewItem", 234 | produces: [ 235 | "application/ld+json", 236 | "application/hal+json", 237 | "application/xml", 238 | "text/xml", 239 | "application/json", 240 | "application/x-yaml", 241 | "text/csv", 242 | "text/html", 243 | ], 244 | summary: "Retrieves a Review resource.", 245 | parameters: [ 246 | { name: "id", in: "path", required: true, type: "integer" }, 247 | ], 248 | responses: { 249 | "200": { 250 | description: "Review resource response", 251 | schema: { $ref: "#/definitions/Review" }, 252 | }, 253 | "404": { description: "Resource not found" }, 254 | }, 255 | }, 256 | put: { 257 | tags: ["Review"], 258 | operationId: "putReviewItem", 259 | consumes: [ 260 | "application/ld+json", 261 | "application/hal+json", 262 | "application/xml", 263 | "text/xml", 264 | "application/json", 265 | "application/x-yaml", 266 | "text/csv", 267 | "text/html", 268 | ], 269 | produces: [ 270 | "application/ld+json", 271 | "application/hal+json", 272 | "application/xml", 273 | "text/xml", 274 | "application/json", 275 | "application/x-yaml", 276 | "text/csv", 277 | "text/html", 278 | ], 279 | summary: "Replaces the Review resource.", 280 | parameters: [ 281 | { name: "id", in: "path", type: "integer", required: true }, 282 | { 283 | name: "review", 284 | in: "body", 285 | description: "The updated Review resource", 286 | schema: { $ref: "#/definitions/Review" }, 287 | }, 288 | ], 289 | responses: { 290 | "200": { 291 | description: "Review resource updated", 292 | schema: { $ref: "#/definitions/Review" }, 293 | }, 294 | "400": { description: "Invalid input" }, 295 | "404": { description: "Resource not found" }, 296 | }, 297 | }, 298 | delete: { 299 | tags: ["Review"], 300 | operationId: "deleteReviewItem", 301 | summary: "Removes the Review resource.", 302 | responses: { 303 | "204": { description: "Review resource deleted" }, 304 | "404": { description: "Resource not found" }, 305 | }, 306 | parameters: [ 307 | { name: "id", in: "path", type: "integer", required: true }, 308 | ], 309 | }, 310 | }, 311 | }, 312 | definitions: { 313 | Book: { 314 | type: "object", 315 | externalDocs: { url: "http://schema.org/Book" }, 316 | properties: { 317 | id: { type: "integer" }, 318 | isbn: { description: "The ISBN of the book", type: "string" }, 319 | description: { 320 | description: "A description of the item", 321 | type: "string", 322 | }, 323 | author: { 324 | description: 325 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 326 | type: "string", 327 | }, 328 | title: { description: "The title of the book", type: "string" }, 329 | bookFormat: { 330 | type: "string", 331 | description: "The publication format of the book.", 332 | enum: ["AUDIOBOOK_FORMAT", "E_BOOK", "PAPERBACK", "HARDCOVER"], 333 | }, 334 | publicationDate: { 335 | description: 336 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 337 | type: "string", 338 | format: "date-time", 339 | }, 340 | }, 341 | required: [ 342 | "description", 343 | "author", 344 | "title", 345 | "bookFormat", 346 | "publicationDate", 347 | ], 348 | }, 349 | Review: { 350 | type: "object", 351 | externalDocs: { url: "http://schema.org/Review" }, 352 | properties: { 353 | id: { type: "integer" }, 354 | rating: { type: "integer" }, 355 | body: { 356 | description: "The actual body of the review", 357 | type: "string", 358 | }, 359 | book: { 360 | description: "The item that is being reviewed/rated", 361 | type: "string", 362 | }, 363 | author: { 364 | description: "Author the author of the review", 365 | type: "string", 366 | }, 367 | publicationDate: { 368 | description: "Author the author of the review", 369 | type: "string", 370 | format: "date-time", 371 | }, 372 | }, 373 | required: ["book"], 374 | }, 375 | }, 376 | }; 377 | 378 | const parsed = [ 379 | { 380 | name: "books", 381 | url: "https://demo.api-platform.com/books", 382 | id: null, 383 | title: "Book", 384 | fields: [ 385 | { 386 | name: "id", 387 | id: null, 388 | range: null, 389 | type: "integer", 390 | reference: null, 391 | required: false, 392 | embedded: null, 393 | enum: null, 394 | description: "", 395 | }, 396 | { 397 | name: "isbn", 398 | id: null, 399 | range: null, 400 | type: "string", 401 | reference: null, 402 | required: false, 403 | embedded: null, 404 | enum: null, 405 | description: "The ISBN of the book", 406 | }, 407 | { 408 | name: "description", 409 | id: null, 410 | range: null, 411 | type: "string", 412 | reference: null, 413 | required: true, 414 | embedded: null, 415 | enum: null, 416 | description: "A description of the item", 417 | }, 418 | { 419 | name: "author", 420 | id: null, 421 | range: null, 422 | type: "string", 423 | reference: null, 424 | required: true, 425 | embedded: null, 426 | enum: null, 427 | description: 428 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 429 | }, 430 | { 431 | name: "title", 432 | id: null, 433 | range: null, 434 | type: "string", 435 | reference: null, 436 | required: true, 437 | embedded: null, 438 | enum: null, 439 | description: "The title of the book", 440 | }, 441 | { 442 | name: "bookFormat", 443 | id: null, 444 | range: null, 445 | type: "string", 446 | reference: null, 447 | embedded: null, 448 | enum: { 449 | "Audiobook format": "AUDIOBOOK_FORMAT", 450 | "E book": "E_BOOK", 451 | Paperback: "PAPERBACK", 452 | Hardcover: "HARDCOVER", 453 | }, 454 | required: true, 455 | description: "The publication format of the book.", 456 | }, 457 | { 458 | name: "publicationDate", 459 | id: null, 460 | range: null, 461 | type: "dateTime", 462 | reference: null, 463 | required: true, 464 | embedded: null, 465 | enum: null, 466 | description: 467 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 468 | }, 469 | ], 470 | readableFields: [ 471 | { 472 | name: "id", 473 | id: null, 474 | range: null, 475 | reference: null, 476 | required: false, 477 | description: "", 478 | }, 479 | { 480 | name: "isbn", 481 | id: null, 482 | range: null, 483 | reference: null, 484 | required: false, 485 | description: "The ISBN of the book", 486 | }, 487 | { 488 | name: "description", 489 | id: null, 490 | range: null, 491 | reference: null, 492 | required: true, 493 | description: "A description of the item", 494 | }, 495 | { 496 | name: "author", 497 | id: null, 498 | range: null, 499 | reference: null, 500 | required: true, 501 | description: 502 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 503 | }, 504 | { 505 | name: "title", 506 | id: null, 507 | range: null, 508 | reference: null, 509 | required: true, 510 | description: "The title of the book", 511 | }, 512 | { 513 | name: "publicationDate", 514 | id: null, 515 | range: null, 516 | reference: null, 517 | required: true, 518 | description: 519 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 520 | }, 521 | ], 522 | writableFields: [ 523 | { 524 | name: "id", 525 | id: null, 526 | range: null, 527 | reference: null, 528 | required: false, 529 | description: "", 530 | }, 531 | { 532 | name: "isbn", 533 | id: null, 534 | range: null, 535 | reference: null, 536 | required: false, 537 | description: "The ISBN of the book", 538 | }, 539 | { 540 | name: "description", 541 | id: null, 542 | range: null, 543 | reference: null, 544 | required: true, 545 | description: "A description of the item", 546 | }, 547 | { 548 | name: "author", 549 | id: null, 550 | range: null, 551 | reference: null, 552 | required: true, 553 | description: 554 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 555 | }, 556 | { 557 | name: "title", 558 | id: null, 559 | range: null, 560 | reference: null, 561 | required: true, 562 | description: "The title of the book", 563 | }, 564 | { 565 | name: "publicationDate", 566 | id: null, 567 | range: null, 568 | reference: null, 569 | required: true, 570 | description: 571 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 572 | }, 573 | ], 574 | }, 575 | { 576 | name: "reviews", 577 | url: "https://demo.api-platform.com/reviews", 578 | id: null, 579 | title: "Review", 580 | fields: [ 581 | { 582 | name: "id", 583 | id: null, 584 | range: null, 585 | type: "integer", 586 | reference: null, 587 | required: false, 588 | embedded: null, 589 | enum: null, 590 | description: "", 591 | }, 592 | { 593 | name: "rating", 594 | id: null, 595 | range: null, 596 | type: "integer", 597 | reference: null, 598 | required: false, 599 | embedded: null, 600 | enum: null, 601 | description: "", 602 | }, 603 | { 604 | name: "body", 605 | id: null, 606 | range: null, 607 | type: "string", 608 | reference: null, 609 | required: false, 610 | embedded: null, 611 | enum: null, 612 | description: "The actual body of the review", 613 | }, 614 | { 615 | name: "book", 616 | id: null, 617 | range: null, 618 | reference: { 619 | name: "books", 620 | url: "https://demo.api-platform.com/books", 621 | id: null, 622 | title: "Book", 623 | fields: [ 624 | { 625 | name: "id", 626 | id: null, 627 | range: null, 628 | reference: null, 629 | required: false, 630 | description: "", 631 | }, 632 | { 633 | name: "isbn", 634 | id: null, 635 | range: null, 636 | reference: null, 637 | required: false, 638 | description: "The ISBN of the book", 639 | }, 640 | { 641 | name: "description", 642 | id: null, 643 | range: null, 644 | reference: null, 645 | required: true, 646 | description: "A description of the item", 647 | }, 648 | { 649 | name: "author", 650 | id: null, 651 | range: null, 652 | reference: null, 653 | required: true, 654 | description: 655 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 656 | }, 657 | { 658 | name: "title", 659 | id: null, 660 | range: null, 661 | reference: null, 662 | required: true, 663 | description: "The title of the book", 664 | }, 665 | { 666 | name: "publicationDate", 667 | id: null, 668 | range: null, 669 | reference: null, 670 | required: true, 671 | description: 672 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 673 | }, 674 | ], 675 | readableFields: [ 676 | { 677 | name: "id", 678 | id: null, 679 | range: null, 680 | reference: null, 681 | required: false, 682 | description: "", 683 | }, 684 | { 685 | name: "isbn", 686 | id: null, 687 | range: null, 688 | reference: null, 689 | required: false, 690 | description: "The ISBN of the book", 691 | }, 692 | { 693 | name: "description", 694 | id: null, 695 | range: null, 696 | reference: null, 697 | required: true, 698 | description: "A description of the item", 699 | }, 700 | { 701 | name: "author", 702 | id: null, 703 | range: null, 704 | reference: null, 705 | required: true, 706 | description: 707 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 708 | }, 709 | { 710 | name: "title", 711 | id: null, 712 | range: null, 713 | reference: null, 714 | required: true, 715 | description: "The title of the book", 716 | }, 717 | { 718 | name: "publicationDate", 719 | id: null, 720 | range: null, 721 | reference: null, 722 | required: true, 723 | description: 724 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 725 | }, 726 | ], 727 | writableFields: [ 728 | { 729 | name: "id", 730 | id: null, 731 | range: null, 732 | reference: null, 733 | required: false, 734 | description: "", 735 | }, 736 | { 737 | name: "isbn", 738 | id: null, 739 | range: null, 740 | reference: null, 741 | required: false, 742 | description: "The ISBN of the book", 743 | }, 744 | { 745 | name: "description", 746 | id: null, 747 | range: null, 748 | reference: null, 749 | required: true, 750 | description: "A description of the item", 751 | }, 752 | { 753 | name: "author", 754 | id: null, 755 | range: null, 756 | reference: null, 757 | required: true, 758 | description: 759 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 760 | }, 761 | { 762 | name: "title", 763 | id: null, 764 | range: null, 765 | reference: null, 766 | required: true, 767 | description: "The title of the book", 768 | }, 769 | { 770 | name: "publicationDate", 771 | id: null, 772 | range: null, 773 | reference: null, 774 | required: true, 775 | description: 776 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 777 | }, 778 | ], 779 | }, 780 | required: true, 781 | description: "The item that is being reviewed/rated", 782 | }, 783 | { 784 | name: "author", 785 | id: null, 786 | range: null, 787 | type: "string", 788 | reference: null, 789 | required: false, 790 | embedded: null, 791 | enum: null, 792 | description: "Author the author of the review", 793 | }, 794 | { 795 | name: "publicationDate", 796 | id: null, 797 | range: null, 798 | type: "string", 799 | reference: null, 800 | required: false, 801 | embedded: null, 802 | enum: null, 803 | description: "Author the author of the review", 804 | }, 805 | ], 806 | readableFields: [ 807 | { 808 | name: "id", 809 | id: null, 810 | range: null, 811 | reference: null, 812 | required: false, 813 | description: "", 814 | }, 815 | { 816 | name: "rating", 817 | id: null, 818 | range: null, 819 | reference: null, 820 | required: false, 821 | description: "", 822 | }, 823 | { 824 | name: "body", 825 | id: null, 826 | range: null, 827 | reference: null, 828 | required: false, 829 | description: "The actual body of the review", 830 | }, 831 | { 832 | name: "book", 833 | id: null, 834 | range: null, 835 | reference: { 836 | name: "books", 837 | url: "https://demo.api-platform.com/books", 838 | id: null, 839 | title: "Book", 840 | fields: [ 841 | { 842 | name: "id", 843 | id: null, 844 | range: null, 845 | reference: null, 846 | required: false, 847 | description: "", 848 | }, 849 | { 850 | name: "isbn", 851 | id: null, 852 | range: null, 853 | reference: null, 854 | required: false, 855 | description: "The ISBN of the book", 856 | }, 857 | { 858 | name: "description", 859 | id: null, 860 | range: null, 861 | reference: null, 862 | required: true, 863 | description: "A description of the item", 864 | }, 865 | { 866 | name: "author", 867 | id: null, 868 | range: null, 869 | reference: null, 870 | required: true, 871 | description: 872 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 873 | }, 874 | { 875 | name: "title", 876 | id: null, 877 | range: null, 878 | reference: null, 879 | required: true, 880 | description: "The title of the book", 881 | }, 882 | { 883 | name: "publicationDate", 884 | id: null, 885 | range: null, 886 | reference: null, 887 | required: true, 888 | description: 889 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 890 | }, 891 | ], 892 | readableFields: [ 893 | { 894 | name: "id", 895 | id: null, 896 | range: null, 897 | reference: null, 898 | required: false, 899 | description: "", 900 | }, 901 | { 902 | name: "isbn", 903 | id: null, 904 | range: null, 905 | reference: null, 906 | required: false, 907 | description: "The ISBN of the book", 908 | }, 909 | { 910 | name: "description", 911 | id: null, 912 | range: null, 913 | reference: null, 914 | required: true, 915 | description: "A description of the item", 916 | }, 917 | { 918 | name: "author", 919 | id: null, 920 | range: null, 921 | reference: null, 922 | required: true, 923 | description: 924 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 925 | }, 926 | { 927 | name: "title", 928 | id: null, 929 | range: null, 930 | reference: null, 931 | required: true, 932 | description: "The title of the book", 933 | }, 934 | { 935 | name: "publicationDate", 936 | id: null, 937 | range: null, 938 | reference: null, 939 | required: true, 940 | description: 941 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 942 | }, 943 | ], 944 | writableFields: [ 945 | { 946 | name: "id", 947 | id: null, 948 | range: null, 949 | reference: null, 950 | required: false, 951 | description: "", 952 | }, 953 | { 954 | name: "isbn", 955 | id: null, 956 | range: null, 957 | reference: null, 958 | required: false, 959 | description: "The ISBN of the book", 960 | }, 961 | { 962 | name: "description", 963 | id: null, 964 | range: null, 965 | reference: null, 966 | required: true, 967 | description: "A description of the item", 968 | }, 969 | { 970 | name: "author", 971 | id: null, 972 | range: null, 973 | reference: null, 974 | required: true, 975 | description: 976 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 977 | }, 978 | { 979 | name: "title", 980 | id: null, 981 | range: null, 982 | reference: null, 983 | required: true, 984 | description: "The title of the book", 985 | }, 986 | { 987 | name: "publicationDate", 988 | id: null, 989 | range: null, 990 | reference: null, 991 | required: true, 992 | description: 993 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 994 | }, 995 | ], 996 | }, 997 | required: true, 998 | description: "The item that is being reviewed/rated", 999 | }, 1000 | { 1001 | name: "author", 1002 | id: null, 1003 | range: null, 1004 | reference: null, 1005 | required: false, 1006 | description: "Author the author of the review", 1007 | }, 1008 | { 1009 | name: "publicationDate", 1010 | id: null, 1011 | range: null, 1012 | reference: null, 1013 | required: false, 1014 | description: "Author the author of the review", 1015 | }, 1016 | ], 1017 | writableFields: [ 1018 | { 1019 | name: "id", 1020 | id: null, 1021 | range: null, 1022 | reference: null, 1023 | required: false, 1024 | description: "", 1025 | }, 1026 | { 1027 | name: "rating", 1028 | id: null, 1029 | range: null, 1030 | reference: null, 1031 | required: false, 1032 | description: "", 1033 | }, 1034 | { 1035 | name: "body", 1036 | id: null, 1037 | range: null, 1038 | reference: null, 1039 | required: false, 1040 | description: "The actual body of the review", 1041 | }, 1042 | { 1043 | name: "book", 1044 | id: null, 1045 | range: null, 1046 | reference: { 1047 | name: "books", 1048 | url: "https://demo.api-platform.com/books", 1049 | id: null, 1050 | title: "Book", 1051 | fields: [ 1052 | { 1053 | name: "id", 1054 | id: null, 1055 | range: null, 1056 | reference: null, 1057 | required: false, 1058 | description: "", 1059 | }, 1060 | { 1061 | name: "isbn", 1062 | id: null, 1063 | range: null, 1064 | reference: null, 1065 | required: false, 1066 | description: "The ISBN of the book", 1067 | }, 1068 | { 1069 | name: "description", 1070 | id: null, 1071 | range: null, 1072 | reference: null, 1073 | required: true, 1074 | description: "A description of the item", 1075 | }, 1076 | { 1077 | name: "author", 1078 | id: null, 1079 | range: null, 1080 | reference: null, 1081 | required: true, 1082 | description: 1083 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 1084 | }, 1085 | { 1086 | name: "title", 1087 | id: null, 1088 | range: null, 1089 | reference: null, 1090 | required: true, 1091 | description: "The title of the book", 1092 | }, 1093 | { 1094 | name: "publicationDate", 1095 | id: null, 1096 | range: null, 1097 | reference: null, 1098 | required: true, 1099 | description: 1100 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 1101 | }, 1102 | ], 1103 | readableFields: [ 1104 | { 1105 | name: "id", 1106 | id: null, 1107 | range: null, 1108 | reference: null, 1109 | required: false, 1110 | description: "", 1111 | }, 1112 | { 1113 | name: "isbn", 1114 | id: null, 1115 | range: null, 1116 | reference: null, 1117 | required: false, 1118 | description: "The ISBN of the book", 1119 | }, 1120 | { 1121 | name: "description", 1122 | id: null, 1123 | range: null, 1124 | reference: null, 1125 | required: true, 1126 | description: "A description of the item", 1127 | }, 1128 | { 1129 | name: "author", 1130 | id: null, 1131 | range: null, 1132 | reference: null, 1133 | required: true, 1134 | description: 1135 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 1136 | }, 1137 | { 1138 | name: "title", 1139 | id: null, 1140 | range: null, 1141 | reference: null, 1142 | required: true, 1143 | description: "The title of the book", 1144 | }, 1145 | { 1146 | name: "publicationDate", 1147 | id: null, 1148 | range: null, 1149 | reference: null, 1150 | required: true, 1151 | description: 1152 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 1153 | }, 1154 | ], 1155 | writableFields: [ 1156 | { 1157 | name: "id", 1158 | id: null, 1159 | range: null, 1160 | reference: null, 1161 | required: false, 1162 | description: "", 1163 | }, 1164 | { 1165 | name: "isbn", 1166 | id: null, 1167 | range: null, 1168 | reference: null, 1169 | required: false, 1170 | description: "The ISBN of the book", 1171 | }, 1172 | { 1173 | name: "description", 1174 | id: null, 1175 | range: null, 1176 | reference: null, 1177 | required: true, 1178 | description: "A description of the item", 1179 | }, 1180 | { 1181 | name: "author", 1182 | id: null, 1183 | range: null, 1184 | reference: null, 1185 | required: true, 1186 | description: 1187 | "The author of this content or rating. Please note that author is special in that HTML 5 provides a special mechanism for indicating authorship via the rel tag. That is equivalent to this and may be used interchangeably", 1188 | }, 1189 | { 1190 | name: "title", 1191 | id: null, 1192 | range: null, 1193 | reference: null, 1194 | required: true, 1195 | description: "The title of the book", 1196 | }, 1197 | { 1198 | name: "publicationDate", 1199 | id: null, 1200 | range: null, 1201 | reference: null, 1202 | required: true, 1203 | description: 1204 | "The date on which the CreativeWork was created or the item was added to a DataFeed", 1205 | }, 1206 | ], 1207 | }, 1208 | required: true, 1209 | description: "The item that is being reviewed/rated", 1210 | }, 1211 | { 1212 | name: "author", 1213 | id: null, 1214 | range: null, 1215 | reference: null, 1216 | required: false, 1217 | description: "Author the author of the review", 1218 | }, 1219 | { 1220 | name: "publicationDate", 1221 | id: null, 1222 | range: null, 1223 | reference: null, 1224 | required: false, 1225 | description: "Author the author of the review", 1226 | }, 1227 | ], 1228 | }, 1229 | ]; 1230 | 1231 | describe(`Parse Swagger Documentation from Json`, () => { 1232 | const toBeParsed = handleJson( 1233 | swaggerApiDefinition, 1234 | "https://demo.api-platform.com", 1235 | ); 1236 | 1237 | test(`Properties to be equal`, () => { 1238 | expect(toBeParsed[0]).toMatchObject({ 1239 | name: parsed[0]?.name, 1240 | url: parsed[0]?.url, 1241 | id: parsed[0]?.id, 1242 | }); 1243 | 1244 | const toBeParsedFields = toBeParsed[0]?.fields; 1245 | 1246 | assert(!!toBeParsedFields, "Expected 'fields' to be defined"); 1247 | expect(toBeParsedFields).toEqual(parsed[0]?.fields); 1248 | 1249 | expect(toBeParsed[1]).toMatchObject({ 1250 | name: parsed[1]?.name, 1251 | url: parsed[1]?.url, 1252 | id: parsed[1]?.id, 1253 | }); 1254 | 1255 | assert( 1256 | !!toBeParsed[1]?.fields, 1257 | "Expected 'fields' property in the second resource", 1258 | ); 1259 | 1260 | expect(toBeParsed[1].fields?.[0]).toEqual(parsed[1]?.fields[0]); 1261 | }); 1262 | }); 1263 | --------------------------------------------------------------------------------