From 8354a758b92eeae0cd05d4d3f4246e3d83557e08 Mon Sep 17 00:00:00 2001 From: Christopher Radek <14189820+chrisradek@users.noreply.github.com> Date: Tue, 2 Jul 2024 15:43:49 -0700 Subject: [PATCH] Add support for converting OpenAPI3 specs to TypeSpec (#3663) fix #3038 This PR updates the `@typespec/openapi3` package to support converting OpenAPI3 specs to TypeSpec. ## Example usage: 1. `npm install @typespec/openapi3` 2. `npx openapi3-to-tsp compile --output-dir ./tsp-output /path/to/openapi-yaml-or-json` ## What's supported - Parse OpenAPI3 specs in yml/json formats (via 3rd party package) - Generates file namespace based on OpenAPI3 service name - Populates `@info` decorator with OpenAPI3 service info - Converts `#/components/schemas` into TypeSpec models/scalars. - Converts `#/components/parameters` into TypeSpec models/model properties as appropriate. - Generates a response model for every operation/statusCode/contentType combination. - Operation tags - Generates TypeSpec operations with routes/Http Method decorators - Generates docs/extension decorators - Most schema decorators - Model inheritance via `allOf` - Discriminators ## What's not supported (yet) - auth - deprecated directive - combining multiple versions of an OpenAPI3-defined service into a single TypeSpec project - converting `#/components/requestBodies` and `#/components/responses` into models - TypeSpec doesn't seem to generate these and I didn't find examples in the wild where they were defined _and_ actually used so deprioritized. - emitting warnings/FIXMEs for unexpected/unsupported scenarios - Probably a lot more that I'm still discovering ## Notes When going through the TypeSpec -> OpenAPI3 -> TypeSpec loop, the generated TypeSpec is going to be larger than the original. The biggest contribution towards this is because I'm currently generating a model for every possible response on every operation. I can definitely pare this down with some simple heuristics that take into account what default statusCode/contentTypes are, and extract the referenced body type directly in the operation's return signature. I can also eliminate the `@get` decorators, `@route("/")` routes, and likely use some of the response models provided by TypeSpec.Http. However - if I'm using this tool to convert from OpenAPI3 to TypeSpec - I thought it might be preferable to be more explicit in the generated output so there's no mystery on how things actually get defined. Will be interested in feedback on this. ## Testing For tests, I generate TypeSpec files for a number of OpenAPI3 specs. Most of the OpenAPI3 specs I generated from our TypeSpec samples packages. Then I'm able to compare the generated TypeSpec to the corresponding original TypeSpec file. I've also been diffing the OpenAPI3 specs generated from the original and generated TypeSpec files <- these are what typically show no changes outside of known unsupported conversions (e.g. auth). --------- Co-authored-by: Christopher Radek --- .../changes/oa3-to-ts-2024-5-26-13-27-36.md | 7 + docs/emitters/openapi3/cli.md | 224 +++++ docs/emitters/openapi3/reference/index.mdx | 2 +- packages/openapi3/README.md | 2 +- packages/openapi3/cmd/tsp-openapi3.js | 9 + packages/openapi3/package.json | 14 +- packages/openapi3/scripts/generate-version.js | 27 + .../openapi3/src/cli/actions/convert/args.ts | 4 + .../src/cli/actions/convert/convert.ts | 30 + .../convert/generators/generate-decorators.ts | 13 + .../convert/generators/generate-main.ts | 27 + .../convert/generators/generate-model.ts | 69 ++ .../convert/generators/generate-operation.ts | 124 +++ .../generators/generate-service-info.ts | 27 + .../convert/generators/generate-types.ts | 190 +++++ .../src/cli/actions/convert/interfaces.ts | 88 ++ .../transform-component-parameters.ts | 56 ++ .../transforms/transform-component-schemas.ts | 100 +++ .../transform-operation-responses.ts | 228 +++++ .../convert/transforms/transform-paths.ts | 86 ++ .../transforms/transform-service-info.ts | 14 + .../actions/convert/transforms/transforms.ts | 31 + .../convert/utils/convert-header-name.ts | 6 + .../cli/actions/convert/utils/decorators.ts | 199 +++++ .../src/cli/actions/convert/utils/docs.ts | 69 ++ .../convert/utils/supported-http-methods.ts | 10 + packages/openapi3/src/cli/cli.ts | 89 ++ packages/openapi3/src/cli/types.ts | 50 ++ packages/openapi3/src/cli/utils.ts | 50 ++ packages/openapi3/src/types.ts | 7 +- packages/openapi3/src/version.ts | 11 + .../test/tsp-openapi3/generate-type.test.ts | 161 ++++ .../tsp-openapi3/output/one-any-all/main.tsp | 54 ++ .../output/openapi-extensions/main.tsp | 59 ++ .../output/param-decorators/main.tsp | 62 ++ .../output/petstore-sample/main.tsp | 107 +++ .../output/petstore-swagger/main.tsp | 773 +++++++++++++++++ .../output/playground-http-service/main.tsp | 172 ++++ .../tsp-openapi3/output/polymorphism/main.tsp | 45 + .../output/status-code-changes/main.tsp | 51 ++ .../openapi3/test/tsp-openapi3/specs.test.ts | 15 + .../specs/one-any-all/service.yml | 81 ++ .../specs/openapi-extensions/service.yml | 66 ++ .../specs/param-decorators/service.yml | 72 ++ .../specs/petstore-sample/service.json | 165 ++++ .../specs/petstore-swagger/service.yml | 798 ++++++++++++++++++ .../specs/playground-http-service/service.yml | 206 +++++ .../specs/polymorphism/service.yml | 66 ++ .../specs/status-code-changes/service.yml | 30 + .../tsp-openapi3/utils/generate-typespec.ts | 56 ++ .../utils/spec-snapshot-testing.ts | 189 +++++ packages/website/sidebars.ts | 1 + pnpm-lock.yaml | 228 +++-- 53 files changed, 5229 insertions(+), 91 deletions(-) create mode 100644 .chronus/changes/oa3-to-ts-2024-5-26-13-27-36.md create mode 100644 docs/emitters/openapi3/cli.md create mode 100755 packages/openapi3/cmd/tsp-openapi3.js create mode 100644 packages/openapi3/scripts/generate-version.js create mode 100644 packages/openapi3/src/cli/actions/convert/args.ts create mode 100644 packages/openapi3/src/cli/actions/convert/convert.ts create mode 100644 packages/openapi3/src/cli/actions/convert/generators/generate-decorators.ts create mode 100644 packages/openapi3/src/cli/actions/convert/generators/generate-main.ts create mode 100644 packages/openapi3/src/cli/actions/convert/generators/generate-model.ts create mode 100644 packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts create mode 100644 packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts create mode 100644 packages/openapi3/src/cli/actions/convert/generators/generate-types.ts create mode 100644 packages/openapi3/src/cli/actions/convert/interfaces.ts create mode 100644 packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts create mode 100644 packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts create mode 100644 packages/openapi3/src/cli/actions/convert/transforms/transform-operation-responses.ts create mode 100644 packages/openapi3/src/cli/actions/convert/transforms/transform-paths.ts create mode 100644 packages/openapi3/src/cli/actions/convert/transforms/transform-service-info.ts create mode 100644 packages/openapi3/src/cli/actions/convert/transforms/transforms.ts create mode 100644 packages/openapi3/src/cli/actions/convert/utils/convert-header-name.ts create mode 100644 packages/openapi3/src/cli/actions/convert/utils/decorators.ts create mode 100644 packages/openapi3/src/cli/actions/convert/utils/docs.ts create mode 100644 packages/openapi3/src/cli/actions/convert/utils/supported-http-methods.ts create mode 100644 packages/openapi3/src/cli/cli.ts create mode 100644 packages/openapi3/src/cli/types.ts create mode 100644 packages/openapi3/src/cli/utils.ts create mode 100644 packages/openapi3/src/version.ts create mode 100644 packages/openapi3/test/tsp-openapi3/generate-type.test.ts create mode 100644 packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/openapi-extensions/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/petstore-sample/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/petstore-swagger/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/polymorphism/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/output/status-code-changes/main.tsp create mode 100644 packages/openapi3/test/tsp-openapi3/specs.test.ts create mode 100644 packages/openapi3/test/tsp-openapi3/specs/one-any-all/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/specs/openapi-extensions/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/specs/param-decorators/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/specs/petstore-sample/service.json create mode 100644 packages/openapi3/test/tsp-openapi3/specs/petstore-swagger/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/specs/playground-http-service/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/specs/polymorphism/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/specs/status-code-changes/service.yml create mode 100644 packages/openapi3/test/tsp-openapi3/utils/generate-typespec.ts create mode 100644 packages/openapi3/test/tsp-openapi3/utils/spec-snapshot-testing.ts diff --git a/.chronus/changes/oa3-to-ts-2024-5-26-13-27-36.md b/.chronus/changes/oa3-to-ts-2024-5-26-13-27-36.md new file mode 100644 index 0000000000..2f593379b8 --- /dev/null +++ b/.chronus/changes/oa3-to-ts-2024-5-26-13-27-36.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Adds support for converting OpenAPI3 specs to TypeSpec via the new tsp-openapi3 CLI included in the `@typespec/openapi3` package. diff --git a/docs/emitters/openapi3/cli.md b/docs/emitters/openapi3/cli.md new file mode 100644 index 0000000000..cd063d5d8d --- /dev/null +++ b/docs/emitters/openapi3/cli.md @@ -0,0 +1,224 @@ +--- +title: OpenAPI3 to TypeSpec +--- + +# tsp-openapi3 CLI + +## Converting OpenAPI 3 into TypeSpec + +This package includes the `tsp-openapi3` CLI for converting OpenAPI 3 specs into TypeSpec. +The generated TypeSpec depends on the `@typespec/http`, `@typespec/openapi` and `@typespec/openapi3` libraries. + +### Usage + +1. via the command line + +```bash +tsp-openapi3 ./openapi3spec.yml --output-dir ./tsp-output +``` + +### tsp-openapi3 arguments + +The path to the OpenAPI3 yaml or json file **must** be passed as a position argument. + +The named arguments are: + +| Name | Type | Required | Description | +| ---------- | ------- | -------- | ---------------------------------------------------------------------------------------- | +| output-dir | string | required | The output directory for generated TypeSpec files. Will be created if it does not exist. | +| help | boolean | optional | Show help. | + +## Examples + +### 1. Convert component schemas into models + +All schemas present at `#/components/schemas` will be converted into a model or scalar as appropriate. + + + + + + + + + + + +
OpenAPI3TypeSpec
+ +```yml +components: + schemas: + Widget: + type: object + required: + - id + - weight + - color + properties: + id: + type: string + weight: + type: integer + format: int32 + color: + type: string + enum: + - red + - blue + uuid: + type: string + format: uuid +``` + + + +```tsp +model Widget { + id: string; + weight: int32; + color: "red" | "blue"; +} + +@format("uuid") +scalar uuid extends string; +``` + +
+ +### 2. Convert component parameters into models or fields + +All parameters present at `#/components/parameters` will be converted to a field in a model. If the model doesn't exist in `#/components/schemas`, then it will be created. + + + + + + + + + + + + + + + + +
OpenAPI3TypeSpec
+ +```yml +components: + parameters: + Widget.id: + name: id + in: path + required: true + schema: + type: string + schemas: + Widget: + type: object + required: + - id + - weight + - color + properties: + id: + type: string + weight: + type: integer + format: int32 + color: + type: string + enum: + - red + - blue +``` + + + +```tsp +model Widget { + @path id: string; + weight: int32; + color: "red" | "blue"; +} +``` + +
+ +```yml +components: + parameters: + Foo.id: + name: id + in: path + required: true + schema: + type: string +``` + + + +```tsp +model Foo { + @path id: string; +} +``` + +
+ +### 3. Convert path routes to operations + +All routes using one of the HTTP methods supported by `@typespec/http` will be converted into operations at the file namespace level. A model is also generated for each operation response. + +At this time, no automatic operation grouping under interfaces is performed. + + + + + + + + + + + +
OpenAPI3TypeSpec
+ +```yml +paths: + /{id}: + get: + operationId: readWidget + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" +``` + + + +```tsp +/** + * The request has succeeded. + */ +model readWidget200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Widget; +} + +@route("/{id}") @get op readWidget(@path id: string): readWidget200ApplicationJsonResponse; +``` + +
diff --git a/docs/emitters/openapi3/reference/index.mdx b/docs/emitters/openapi3/reference/index.mdx index 712b79d863..63b200c638 100644 --- a/docs/emitters/openapi3/reference/index.mdx +++ b/docs/emitters/openapi3/reference/index.mdx @@ -10,7 +10,7 @@ import TabItem from '@theme/TabItem'; # Overview -TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding +TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec ## Install diff --git a/packages/openapi3/README.md b/packages/openapi3/README.md index d148beb0c2..768c679e1c 100644 --- a/packages/openapi3/README.md +++ b/packages/openapi3/README.md @@ -1,6 +1,6 @@ # @typespec/openapi3 -TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding +TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec ## Install diff --git a/packages/openapi3/cmd/tsp-openapi3.js b/packages/openapi3/cmd/tsp-openapi3.js new file mode 100755 index 0000000000..33a4ea2098 --- /dev/null +++ b/packages/openapi3/cmd/tsp-openapi3.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { main } from "../dist/src/cli/cli.js"; + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + + process.exit(1); +}); diff --git a/packages/openapi3/package.json b/packages/openapi3/package.json index 8fc996327c..397097ef03 100644 --- a/packages/openapi3/package.json +++ b/packages/openapi3/package.json @@ -2,7 +2,7 @@ "name": "@typespec/openapi3", "version": "0.57.0", "author": "Microsoft Corporation", - "description": "TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding", + "description": "TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec", "homepage": "https://typespec.io", "readme": "https://github.com/microsoft/typespec/blob/main/README.md", "license": "MIT", @@ -16,6 +16,9 @@ "keywords": [ "typespec" ], + "bin": { + "tsp-openapi3": "cmd/tsp-openapi3.js" + }, "type": "module", "main": "dist/src/index.js", "tspMain": "lib/main.tsp", @@ -34,7 +37,7 @@ }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "npm run gen-extern-signature && tsc -p . && npm run lint-typespec-library", + "build": "npm run gen-version && npm run gen-extern-signature && tsc -p . && npm run lint-typespec-library", "watch": "tsc -p . --watch", "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", @@ -44,7 +47,9 @@ "test:ci": "vitest run --coverage --reporter=junit --reporter=default", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix", - "regen-docs": "tspd doc . --enable-experimental --output-dir ../../docs/emitters/openapi3/reference" + "regen-docs": "tspd doc . --enable-experimental --output-dir ../../docs/emitters/openapi3/reference", + "regen-specs": "cross-env RECORD=true vitest run", + "gen-version": "node scripts/generate-version.js" }, "files": [ "lib/*.tsp", @@ -52,6 +57,7 @@ "!dist/test/**" ], "dependencies": { + "@readme/openapi-parser": "~2.6.0", "yaml": "~2.4.5" }, "peerDependencies": { @@ -62,6 +68,7 @@ }, "devDependencies": { "@types/node": "~18.11.19", + "@types/yargs": "~17.0.32", "@typespec/compiler": "workspace:~", "@typespec/http": "workspace:~", "@typespec/library-linter": "workspace:~", @@ -72,6 +79,7 @@ "@vitest/coverage-v8": "^1.6.0", "@vitest/ui": "^1.6.0", "c8": "^10.1.2", + "cross-env": "~7.0.3", "rimraf": "~5.0.7", "typescript": "~5.5.3", "vitest": "^1.6.0" diff --git a/packages/openapi3/scripts/generate-version.js b/packages/openapi3/scripts/generate-version.js new file mode 100644 index 0000000000..3d9530c7e6 --- /dev/null +++ b/packages/openapi3/scripts/generate-version.js @@ -0,0 +1,27 @@ +// @ts-check +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { fileURLToPath } from "url"; +const root = fileURLToPath(new URL("..", import.meta.url).href); +const distDir = join(root, "dist"); +const versionTarget = join(distDir, "version.js"); + +function loadPackageJson() { + const packageJsonPath = join(root, "package.json"); + return JSON.parse(readFileSync(packageJsonPath, "utf-8")); +} + +function main() { + const pkg = loadPackageJson(); + + const version = pkg.version; + + if (!existsSync(distDir)) { + mkdirSync(distDir, { recursive: true }); + } + + const versionJs = `export const version = "${version}";`; + writeFileSync(versionTarget, versionJs); +} + +main(); diff --git a/packages/openapi3/src/cli/actions/convert/args.ts b/packages/openapi3/src/cli/actions/convert/args.ts new file mode 100644 index 0000000000..df40cca791 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/args.ts @@ -0,0 +1,4 @@ +export interface ConvertCliArgs { + "output-dir": string; + path: string; +} diff --git a/packages/openapi3/src/cli/actions/convert/convert.ts b/packages/openapi3/src/cli/actions/convert/convert.ts new file mode 100644 index 0000000000..ca25b67b6d --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/convert.ts @@ -0,0 +1,30 @@ +import oaParser from "@readme/openapi-parser"; +import { resolvePath } from "@typespec/compiler"; +import { OpenAPI3Document } from "../../../types.js"; +import { CliHost } from "../../types.js"; +import { handleInternalCompilerError } from "../../utils.js"; +import { ConvertCliArgs } from "./args.js"; +import { generateMain } from "./generators/generate-main.js"; +import { transform } from "./transforms/transforms.js"; + +export async function convertAction(host: CliHost, args: ConvertCliArgs) { + // attempt to read the file + const fullPath = resolvePath(process.cwd(), args.path); + const model = await parseOpenApiFile(fullPath); + const program = transform(model); + let mainTsp: string; + try { + mainTsp = await generateMain(program); + } catch (err) { + handleInternalCompilerError(err); + } + + if (args["output-dir"]) { + await host.mkdirp(args["output-dir"]); + await host.writeFile(resolvePath(args["output-dir"], "main.tsp"), mainTsp); + } +} + +function parseOpenApiFile(path: string): Promise { + return oaParser.bundle(path) as Promise; +} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-decorators.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-decorators.ts new file mode 100644 index 0000000000..08407cb28b --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-decorators.ts @@ -0,0 +1,13 @@ +import { TypeSpecDecorator } from "../interfaces.js"; + +function generateDecorator({ name, args }: TypeSpecDecorator): string { + const hasArgs = args.length; + const stringifiedArguments = hasArgs ? `(${args.map((a) => JSON.stringify(a)).join(", ")})` : ""; + + return `@${name}${stringifiedArguments}`; +} + +export function generateDecorators(decorators: TypeSpecDecorator[]): string[] { + const uniqueDecorators = new Set(decorators.map(generateDecorator)); + return Array.from(uniqueDecorators); +} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts new file mode 100644 index 0000000000..87df95a28a --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-main.ts @@ -0,0 +1,27 @@ +import { formatTypeSpec } from "@typespec/compiler"; +import { TypeSpecProgram } from "../interfaces.js"; +import { generateModel } from "./generate-model.js"; +import { generateOperation } from "./generate-operation.js"; +import { generateServiceInformation } from "./generate-service-info.js"; + +export async function generateMain(program: TypeSpecProgram): Promise { + const content = ` + import "@typespec/http"; + import "@typespec/openapi"; + import "@typespec/openapi3"; + + using Http; + using OpenAPI; + + ${generateServiceInformation(program.serviceInfo)} + + ${program.models.map(generateModel).join("\n\n")} + + ${program.operations.map(generateOperation).join("\n\n")} + `; + + return formatTypeSpec(content, { + printWidth: 100, + tabWidth: 2, + }); +} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts new file mode 100644 index 0000000000..edf0c1c2f3 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-model.ts @@ -0,0 +1,69 @@ +import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { getDecoratorsForSchema } from "../utils/decorators.js"; +import { generateDocs } from "../utils/docs.js"; +import { generateDecorators } from "./generate-decorators.js"; +import { generateTypeFromSchema } from "./generate-types.js"; + +export function generateModel(model: TypeSpecModel): string { + const definitions: string[] = []; + const modelDeclaration = generateModelDeclaration(model); + + if (model.doc) { + definitions.push(generateDocs(model.doc)); + } + + definitions.push(...generateDecorators(model.decorators)); + definitions.push(modelDeclaration.open); + + definitions.push(...model.properties.map(generateModelProperty)); + + if (model.additionalProperties) { + definitions.push(`...${generateTypeFromSchema(model.additionalProperties)};`); + } + + if (modelDeclaration.close) definitions.push(modelDeclaration.close); + + return definitions.join("\n"); +} + +type ModelDeclarationOutput = { open: string; close?: string }; + +function generateModelDeclaration(model: TypeSpecModel): ModelDeclarationOutput { + const modelName = model.name; + const modelType = model.type ?? "object"; + + if (model.is) { + return { open: `model ${modelName} is ${model.is};` }; + } + + if (!model.extends) { + return { open: `model ${modelName} {`, close: "}" }; + } + + if (modelType === "object") { + return { open: `model ${modelName} extends ${model.extends} {`, close: "}" }; + } + + switch (modelType) { + case "boolean": + case "integer": + case "number": + case "string": + return { open: `scalar ${modelName} extends ${model.extends};` }; + } + + return { open: `model ${modelName} {`, close: "}" }; +} + +function generateModelProperty(property: TypeSpecModelProperty): string { + // Decorators will be a combination of top-level (parameters) and + // schema-level decorators. + const decorators = generateDecorators([ + ...property.decorators, + ...getDecoratorsForSchema(property.schema), + ]).join(" "); + + const doc = property.doc ? generateDocs(property.doc) : ""; + + return `${doc}${decorators} ${property.name}${property.isOptional ? "?" : ""}: ${generateTypeFromSchema(property.schema)};`; +} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts new file mode 100644 index 0000000000..0adf8b4030 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-operation.ts @@ -0,0 +1,124 @@ +import { OpenAPI3Response, Refable } from "../../../../types.js"; +import { + TypeSpecOperation, + TypeSpecOperationParameter, + TypeSpecRequestBody, +} from "../interfaces.js"; +import { generateResponseModelName } from "../transforms/transform-operation-responses.js"; +import { generateDocs } from "../utils/docs.js"; +import { generateDecorators } from "./generate-decorators.js"; +import { generateTypeFromSchema, getRefName } from "./generate-types.js"; + +export function generateOperation(operation: TypeSpecOperation): string { + const definitions: string[] = []; + + if (operation.doc) { + definitions.push(generateDocs(operation.doc)); + } + + definitions.push(...operation.tags.map((t) => `@tag("${t}")`)); + + definitions.push(generateDecorators(operation.decorators).join(" ")); + + // generate parameters + const parameters: string[] = [ + ...operation.parameters.map(generateOperationParameter), + ...generateRequestBodyParameters(operation.requestBodies), + ]; + + const responseTypes = generateResponses(operation.operationId!, operation.responses); + + definitions.push(`op ${operation.name}(${parameters.join(", ")}): ${responseTypes.join(" | ")};`); + + return definitions.join(" "); +} + +function generateOperationParameter(parameter: Refable) { + if ("$ref" in parameter) { + // check if referencing a model or a property + const refName = getRefName(parameter.$ref); + const paramName = refName.indexOf(".") >= 0 ? refName.split(".").pop() : refName; + // when refName and paramName match, we're referencing a model and can spread + // TODO: Handle optionality + return refName === paramName ? `...${refName}` : `${paramName}: ${refName}`; + } + + const definitions: string[] = []; + + if (parameter.doc) { + definitions.push(generateDocs(parameter.doc)); + } + + definitions.push(...generateDecorators(parameter.decorators)); + + definitions.push( + `${parameter.name}${parameter.isOptional ? "?" : ""}: ${generateTypeFromSchema(parameter.schema)}` + ); + + return definitions.join(" "); +} + +function generateRequestBodyParameters(requestBodies: TypeSpecRequestBody[]): string[] { + if (!requestBodies.length) { + return []; + } + + const definitions: string[] = []; + + // Generate the content-type header if defined content-types is not just 'application/json' + const contentTypes = requestBodies.map((r) => r.contentType); + if (!supportsOnlyJson(contentTypes)) { + definitions.push(`@header contentType: ${contentTypes.map((c) => `"${c}"`).join(" | ")}`); + } + + // Get the set of referenced types + const body = Array.from( + new Set(requestBodies.filter((r) => !!r.schema).map((r) => generateTypeFromSchema(r.schema!))) + ).join(" | "); + + if (body) { + definitions.push(`@bodyRoot body: ${body}`); + } + + return definitions; +} + +function supportsOnlyJson(contentTypes: string[]) { + return contentTypes.length === 1 && contentTypes[0] === "application/json"; +} + +function generateResponses( + operationId: string, + responses: TypeSpecOperation["responses"] +): string[] { + if (!responses) { + return ["void"]; + } + + const definitions: string[] = []; + + for (const statusCode of Object.keys(responses)) { + const response = responses[statusCode]; + definitions.push(...generateResponseForStatus(operationId, statusCode, response)); + } + + return definitions; +} + +function generateResponseForStatus( + operationId: string, + statusCode: string, + response: Refable +): string[] { + if ("$ref" in response) { + return [getRefName(response.$ref)]; + } + + if (!response.content) { + return [generateResponseModelName(operationId, statusCode)]; + } + + return Object.keys(response.content).map((contentType) => + generateResponseModelName(operationId, statusCode, contentType) + ); +} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts new file mode 100644 index 0000000000..be55da1c6a --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-service-info.ts @@ -0,0 +1,27 @@ +import { TypeSpecServiceInfo } from "../interfaces.js"; +import { generateDocs } from "../utils/docs.js"; + +export function generateServiceInformation(serviceInfo: TypeSpecServiceInfo): string { + const definitions: string[] = []; + + const { name, doc, ...info } = serviceInfo; + + definitions.push(` + @service({ + title: "${name}" + }) + @info(${JSON.stringify(info)}) + `); + + if (doc) { + definitions.push(generateDocs(doc)); + } + + definitions.push(`namespace ${generateNamespaceName(name)};`); + + return definitions.join("\n"); +} + +function generateNamespaceName(name: string): string { + return name.replaceAll(/[^\w^\d_]+/g, ""); +} diff --git a/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts new file mode 100644 index 0000000000..bcbc8cfcc5 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/generators/generate-types.ts @@ -0,0 +1,190 @@ +import { OpenAPI3Schema, Refable } from "../../../../types.js"; +import { getDecoratorsForSchema } from "../utils/decorators.js"; +import { generateDecorators } from "./generate-decorators.js"; + +export function generateTypeFromSchema(schema: Refable): string { + return getTypeFromRefableSchema(schema); +} + +function getTypeFromRefableSchema(schema: Refable): string { + const hasRef = "$ref" in schema; + return hasRef ? getRefName(schema.$ref) : getTypeFromSchema(schema); +} + +function getTypeFromSchema(schema: OpenAPI3Schema): string { + let type = "unknown"; + + if (schema.enum) { + type = getEnum(schema.enum); + } else if (schema.anyOf) { + type = getAnyOfType(schema); + } else if (schema.type === "array") { + type = getArrayType(schema); + } else if (schema.type === "boolean") { + type = "boolean"; + } else if (schema.type === "integer") { + type = getIntegerType(schema); + } else if (schema.type === "number") { + type = getNumberType(schema); + } else if (schema.type === "object") { + type = getObjectType(schema); + } else if (schema.oneOf) { + type = getOneOfType(schema); + } else if (schema.type === "string") { + type = getStringType(schema); + } + + if (schema.nullable) { + type += ` | null`; + } + + if (schema.default) { + type += ` = ${JSON.stringify(schema.default)}`; + } + + return type; +} + +export function getRefName(ref: string): string { + const name = ref.split("/").pop() ?? ""; + // TODO: account for `.` in the name + return name; +} + +function getAnyOfType(schema: OpenAPI3Schema): string { + const definitions: string[] = []; + + for (const item of schema.anyOf ?? []) { + definitions.push(generateTypeFromSchema(item)); + } + + return definitions.join(" | "); +} + +function getOneOfType(schema: OpenAPI3Schema): string { + const definitions: string[] = []; + + for (const item of schema.oneOf ?? []) { + definitions.push(generateTypeFromSchema(item)); + } + + return definitions.join(" | "); +} + +function getObjectType(schema: OpenAPI3Schema): string { + // If we have `additionalProperties`, treat that as an 'indexer' and convert to a record. + const recordType = + typeof schema.additionalProperties === "object" + ? `Record<${getTypeFromRefableSchema(schema.additionalProperties)}>` + : ""; + + if (!schema.properties && recordType) { + return recordType; + } + + const requiredProps = schema.required ?? []; + + const props: string[] = []; + if (schema.properties) { + for (const name of Object.keys(schema.properties)) { + const decorators = generateDecorators(getDecoratorsForSchema(schema.properties[name])) + .map((d) => `${d}\n`) + .join(""); + const isOptional = !requiredProps.includes(name) ? "?" : ""; + props.push( + `${decorators}${name}${isOptional}: ${getTypeFromRefableSchema(schema.properties[name])}` + ); + } + } + + const propertyCount = Object.keys(props).length; + if (recordType && !propertyCount) { + return recordType; + } else if (recordType && propertyCount) { + props.push(`...${recordType}`); + } + + return `{${props.join("; ")}}`; +} + +export function getArrayType(schema: OpenAPI3Schema): string { + const items = schema.items; + if (!items) { + return "unknown[]"; + } + + if ("$ref" in items) { + return `${getRefName(items.$ref)}[]`; + } + + // Prettier will get rid of the extra parenthesis for us + return `(${getTypeFromSchema(items)})[]`; +} + +export function getIntegerType(schema: OpenAPI3Schema): string { + const format = schema.format ?? ""; + switch (format) { + case "int8": + case "int16": + case "int32": + case "int64": + case "uint8": + case "uint16": + case "uint32": + case "uint64": + return format; + case "double-int": + return "safeint"; + default: + return "integer"; + } +} + +export function getNumberType(schema: OpenAPI3Schema): string { + const format = schema.format ?? ""; + switch (format) { + case "decimal": + case "decimal128": + return format; + case "double": + return "float64"; + case "float": + return "float32"; + default: + // Could be either 'float' or 'numeric' - add FIXME? + return "numeric"; + } +} + +export function getStringType(schema: OpenAPI3Schema): string { + const format = schema.format ?? ""; + let type = "string"; + switch (format) { + case "binary": + case "byte": + type = "bytes"; + break; + case "date": + type = "plainDate"; + break; + case "date-time": + // Can be 'offsetDateTime' or 'utcDateTime' - needs FIXME or union? + type = "utcDateTime"; + break; + case "time": + type = "plainTime"; + break; + case "duration": + type = "duration"; + break; + case "uri": + type = "url"; + break; + } + + return type; +} + +function getEnum(schemaEnum: (string | number | boolean)[]): string { + return schemaEnum.map((e) => JSON.stringify(e)).join(" | "); +} diff --git a/packages/openapi3/src/cli/actions/convert/interfaces.ts b/packages/openapi3/src/cli/actions/convert/interfaces.ts new file mode 100644 index 0000000000..5afd6f0244 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/interfaces.ts @@ -0,0 +1,88 @@ +import { Contact, License } from "@typespec/openapi"; +import { OpenAPI3Encoding, OpenAPI3Responses, OpenAPI3Schema, Refable } from "../../../types.js"; + +export interface TypeSpecProgram { + serviceInfo: TypeSpecServiceInfo; + models: TypeSpecModel[]; + augmentations: TypeSpecAugmentation[]; + operations: TypeSpecOperation[]; +} + +export interface TypeSpecServiceInfo { + name: string; + doc?: string; + version: string; + termsOfService?: string; + contact?: Contact; + license?: License; + summary?: string; +} + +export interface TypeSpecDecorator { + name: string; + args: (object | number | string)[]; +} + +export interface TypeSpecAugmentation extends TypeSpecDecorator { + target: string; +} + +export interface TypeSpecModel { + name: string; + doc?: string; + decorators: TypeSpecDecorator[]; + properties: TypeSpecModelProperty[]; + additionalProperties?: Refable; + /** + * Note: Only one of `extends` or `is` should be specified. + */ + extends?: string; + /** + * Note: Only one of `extends` or `is` should be specified. + */ + is?: string; + /** + * Defaults to 'object' + */ + type?: OpenAPI3Schema["type"]; +} + +export interface TypeSpecModelProperty { + name: string; + isOptional: boolean; + doc?: string; + /** + * A partial list of decorators that can't be ascertained from + * the schema. + * Example: location decorators for parameters + */ + decorators: TypeSpecDecorator[]; + schema: Refable; +} + +export interface TypeSpecOperation { + name: string; + doc?: string; + decorators: TypeSpecDecorator[]; + operationId?: string; + parameters: Refable[]; + requestBodies: TypeSpecRequestBody[]; + responses: OpenAPI3Responses; + tags: string[]; +} + +export interface TypeSpecOperationParameter { + name: string; + doc?: string; + decorators: TypeSpecDecorator[]; + isOptional: boolean; + schema: Refable; +} + +export interface TypeSpecRequestBody { + contentType: string; + doc?: string; + isOptional: boolean; + encoding?: Record; + schema?: Refable; +} diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts new file mode 100644 index 0000000000..2b607ad3b3 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-parameters.ts @@ -0,0 +1,56 @@ +import { OpenAPI3Components, OpenAPI3Parameter } from "../../../../types.js"; +import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { getParameterDecorators } from "../utils/decorators.js"; + +/** + * Transforms #/components/parameters into TypeSpec models. + * Overwrites properties of existing models if an existing model already exists. + * Populates the provided `models` array in-place. + * @param models + * @param parameters + * @returns + */ +export function transformComponentParameters( + models: TypeSpecModel[], + parameters?: OpenAPI3Components["parameters"] +): void { + if (!parameters) return; + + for (const name of Object.keys(parameters)) { + // Determine what the name of the parameter's model is since name may point at + // a nested property. + const modelName = name.indexOf(".") < 0 ? name : name.split(".").shift()!; + + // Check if model already exists; if not, create it + let model = models.find((m) => m.name === modelName); + if (!model) { + model = { + name: modelName, + decorators: [], + properties: [], + }; + models.push(model); + } + + const parameter = parameters[name]; + const modelParameter = getModelPropertyFromParameter(parameter); + + // Check if the model already has a property of the matching name + const propIndex = model.properties.findIndex((p) => p.name === modelParameter.name); + if (propIndex >= 0) { + model.properties[propIndex] = modelParameter; + } else { + model.properties.push(modelParameter); + } + } +} + +function getModelPropertyFromParameter(parameter: OpenAPI3Parameter): TypeSpecModelProperty { + return { + name: parameter.name, + isOptional: !parameter.required, + doc: parameter.description ?? parameter.schema.description, + decorators: getParameterDecorators(parameter), + schema: parameter.schema, + }; +} diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts new file mode 100644 index 0000000000..cd249486ad --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-component-schemas.ts @@ -0,0 +1,100 @@ +import { OpenAPI3Components, OpenAPI3Schema } from "../../../../types.js"; +import { + getArrayType, + getIntegerType, + getNumberType, + getRefName, + getStringType, +} from "../generators/generate-types.js"; +import { TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { getDecoratorsForSchema } from "../utils/decorators.js"; + +/** + * Transforms #/components/schemas into TypeSpec models. + * Populates the provided `models` array in-place. + * @param models + * @param schemas + * @returns + */ +export function transformComponentSchemas( + models: TypeSpecModel[], + schemas?: OpenAPI3Components["schemas"] +): void { + if (!schemas) return; + + for (const name of Object.keys(schemas)) { + const schema = schemas[name]; + const extendsParent = getModelExtends(schema); + const isParent = getModelIs(schema); + models.push({ + name: name.replace(/-/g, "_"), + decorators: [...getDecoratorsForSchema(schema)], + doc: schema.description, + properties: getModelPropertiesFromObjectSchema(schema), + additionalProperties: + typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined, + extends: extendsParent, + is: isParent, + type: schema.type, + }); + } +} + +function getModelExtends(schema: OpenAPI3Schema): string | undefined { + switch (schema.type) { + case "boolean": + return "boolean"; + case "integer": + return getIntegerType(schema); + case "number": + return getNumberType(schema); + case "string": + return getStringType(schema); + } + + if (schema.type !== "object" || !schema.allOf) { + return; + } + + if (schema.allOf.length !== 1) { + // TODO: Emit warning - can't extend more than 1 model + return; + } + + const parent = schema.allOf[0]; + if (!parent || !("$ref" in parent)) { + // TODO: Error getting parent - must be a reference, not expression + return; + } + + return getRefName(parent.$ref); +} + +function getModelIs(schema: OpenAPI3Schema): string | undefined { + if (schema.type !== "array") { + return; + } + return getArrayType(schema); +} + +function getModelPropertiesFromObjectSchema({ + properties, + required = [], +}: OpenAPI3Schema): TypeSpecModelProperty[] { + if (!properties) return []; + + const modelProperties: TypeSpecModelProperty[] = []; + for (const name of Object.keys(properties)) { + const property = properties[name]; + + modelProperties.push({ + name, + doc: property.description, + schema: property, + isOptional: !required.includes(name), + decorators: [...getDecoratorsForSchema(property)], + }); + } + + return modelProperties; +} diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-operation-responses.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-operation-responses.ts new file mode 100644 index 0000000000..30944fbebc --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-operation-responses.ts @@ -0,0 +1,228 @@ +import { + OpenAPI3Document, + OpenAPI3Header, + OpenAPI3Responses, + OpenAPI3Schema, + OpenAPI3StatusCode, + Refable, +} from "../../../../types.js"; +import { TypeSpecDecorator, TypeSpecModel, TypeSpecModelProperty } from "../interfaces.js"; +import { convertHeaderName } from "../utils/convert-header-name.js"; +import { getDecoratorsForSchema, getExtensions } from "../utils/decorators.js"; +import { supportedHttpMethods } from "../utils/supported-http-methods.js"; + +type OperationResponseInfo = { + operationId: string; + operationResponses: OpenAPI3Responses; +}; + +/** + * Transforms #/paths/{route}/{httpMethod}/responses into TypeSpec models. + * Populates the provided `models` array in-place. + * @param models + * @param document + */ +export function transformAllOperationResponses( + models: TypeSpecModel[], + document: OpenAPI3Document +): void { + const allOperationResponses = collectOpenAPI3OperationResponses(document); + for (const { operationId, operationResponses } of allOperationResponses) { + transformOperationResponses(models, operationId, operationResponses); + } +} + +function collectOpenAPI3OperationResponses(document: OpenAPI3Document): OperationResponseInfo[] { + const allOperationResponses: OperationResponseInfo[] = []; + const paths = document.paths ?? {}; + for (const route of Object.keys(paths)) { + const path = paths[route]; + for (const verb of supportedHttpMethods) { + const operation = path[verb]; + if (!operation) continue; + + const operationResponses: OpenAPI3Responses = operation.responses ?? {}; + allOperationResponses.push({ + operationId: operation.operationId!, + operationResponses, + }); + } + } + + return allOperationResponses; +} + +function transformOperationResponses( + models: TypeSpecModel[], + operationId: string, + operationResponses: OpenAPI3Responses +) { + const rootDecorators: TypeSpecDecorator[] = getExtensions(operationResponses); + for (const statusCode of Object.keys(operationResponses)) { + const response = operationResponses[statusCode]; + const decorators: TypeSpecDecorator[] = [...rootDecorators]; + + if ("$ref" in response) { + //TODO: Support for referencing #/components/responseBodies + return; + } + + // These headers will be applied to all of the models for this operation/statusCode + const commonProperties: TypeSpecModelProperty[] = []; + for (const name of Object.keys(response.headers ?? {})) { + const property = convertHeaderToProperty(name, response.headers[name]); + if (property) commonProperties.push(property); + } + + decorators.push(...getExtensions(response)); + + // `default` status code is treated as the fallback for any status codes returned that aren't defined. + if (statusCode === "default") { + decorators.push({ name: "defaultResponse", args: [] }); + } else { + commonProperties.push(convertStatusCodeToProperty(statusCode)); + } + if (isErrorStatusCode(statusCode)) { + decorators.push({ name: "error", args: [] }); + } + + if (!response.content) { + // This is common when there is no actual request body, just a statusCode, e.g. for errors + models.push({ + name: generateResponseModelName(operationId, statusCode), + decorators, + properties: commonProperties, + doc: response.description, + }); + } else { + // An operation may produce multiple content types, so need a model for each one. + for (const contentType of Object.keys(response.content ?? {})) { + const properties: TypeSpecModelProperty[] = [...commonProperties]; + const contentBody = response.content[contentType]; + + // Wouldn't expect schema can be undefined since that implies contentType is not needed. + if (contentBody.schema) { + properties.push({ + name: "body", + isOptional: false, // TODO: use the real value + decorators: [{ name: "bodyRoot", args: [] }], + schema: contentBody.schema, + }); + } + + // Default is application/json, so it doesn't need to be specified + if (contentType !== "application/json") { + properties.push({ + name: "contentType", + decorators: [{ name: "header", args: [] }], + isOptional: false, + schema: { type: "string", enum: [contentType] }, + }); + } + + models.push({ + name: generateResponseModelName(operationId, statusCode, contentType), + decorators, + properties, + doc: response.description, + }); + } + } + } +} + +function convertHeaderToProperty( + name: string, + meta: Refable +): TypeSpecModelProperty | undefined { + const normalizedName = convertHeaderName(name); + // TODO: handle style + const headerDecorator: TypeSpecDecorator = { name: "header", args: [] }; + if (normalizedName !== name) { + headerDecorator.args.push(name); + } + + if ("$ref" in meta) { + // Unhandled right now + return; + } + + return { + name: normalizedName, + decorators: [headerDecorator, ...getDecoratorsForSchema(meta.schema)], + doc: meta.description ?? meta.schema.description, + isOptional: !meta.required, + schema: meta.schema, + }; +} + +function convertStatusCodeToProperty(statusCode: OpenAPI3StatusCode): TypeSpecModelProperty { + const schema: OpenAPI3Schema = { type: "integer", format: "int32" }; + if (statusCode === "1XX") { + schema.minimum = 100; + schema.maximum = 199; + } else if (statusCode === "2XX") { + schema.minimum = 200; + schema.maximum = 299; + } else if (statusCode === "3XX") { + schema.minimum = 300; + schema.maximum = 399; + } else if (statusCode === "4XX") { + schema.minimum = 400; + schema.maximum = 499; + } else if (statusCode === "5XX") { + schema.minimum = 500; + schema.maximum = 599; + } else { + const literalStatusCode = parseInt(statusCode, 10); + if (!isValidLiteralStatusCode(literalStatusCode)) { + // TODO: Emit warning or // FIXME + } else { + schema.enum = [literalStatusCode]; + } + } + return { + name: "statusCode", + schema, + decorators: [{ name: "statusCode", args: [] }], + isOptional: false, + }; +} + +function isValidLiteralStatusCode(statusCode: number): boolean { + return isFinite(statusCode) && statusCode >= 100 && statusCode <= 599; +} + +function isErrorStatusCode(statusCode: OpenAPI3StatusCode): boolean { + if (["1XX", "2XX", "3XX", "default"].includes(statusCode)) { + return false; + } else if (["4XX", "5XX"].includes(statusCode)) { + return true; + } + + const literalStatusCode = parseInt(statusCode, 10); + return isFinite(literalStatusCode) && literalStatusCode >= 400; +} + +export function generateResponseModelName( + operationId: string, + statusCode: string, + contentType?: string +): string { + if (statusCode === "default") { + statusCode = "Default"; + } + let modelName = `${operationId}${statusCode}`; + if (contentType) { + modelName += convertContentType(contentType); + } + return modelName + "Response"; +} + +function convertContentType(contentType: string): string { + return contentType + .replaceAll("*", "Star") + .split("/") + .map((s) => s.substring(0, 1).toUpperCase() + s.substring(1)) + .join(""); +} diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-paths.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-paths.ts new file mode 100644 index 0000000000..72375ae097 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-paths.ts @@ -0,0 +1,86 @@ +import { + OpenAPI3Parameter, + OpenAPI3PathItem, + OpenAPI3RequestBody, + Refable, +} from "../../../../types.js"; +import { + TypeSpecOperation, + TypeSpecOperationParameter, + TypeSpecRequestBody, +} from "../interfaces.js"; +import { getExtensions, getParameterDecorators } from "../utils/decorators.js"; +import { supportedHttpMethods } from "../utils/supported-http-methods.js"; + +/** + * Transforms each operation defined under #/paths/{route}/{httpMethod} into a TypeSpec operation. + * @param paths + * @returns + */ +export function transformPaths(paths: Record): TypeSpecOperation[] { + const operations: TypeSpecOperation[] = []; + + for (const route of Object.keys(paths)) { + const path = paths[route]; + for (const verb of supportedHttpMethods) { + const operation = path[verb]; + if (!operation) continue; + + const parameters = operation.parameters?.map(transformOperationParameter) ?? []; + const tags = operation.tags?.map((t) => t) ?? []; + + operations.push({ + name: operation.operationId!, + decorators: [ + ...getExtensions(operation), + { name: "route", args: [route] }, + { name: verb, args: [] }, + ], + parameters, + doc: operation.description, + operationId: operation.operationId, + requestBodies: transformRequestBodies(operation.requestBody), + responses: operation.responses ?? {}, + tags: tags, + }); + } + } + + return operations; +} + +function transformOperationParameter( + parameter: Refable +): Refable { + if ("$ref" in parameter) { + return { $ref: parameter.$ref }; + } + + return { + name: parameter.name, + doc: parameter.description, + decorators: getParameterDecorators(parameter), + isOptional: !parameter.required, + schema: parameter.schema, + }; +} + +function transformRequestBodies(requestBodies?: OpenAPI3RequestBody): TypeSpecRequestBody[] { + if (!requestBodies) { + return []; + } + + const typespecBodies: TypeSpecRequestBody[] = []; + for (const contentType of Object.keys(requestBodies.content)) { + const contentBody = requestBodies.content[contentType]; + typespecBodies.push({ + contentType, + isOptional: !requestBodies.required, + doc: requestBodies.description, + encoding: contentBody.encoding, + schema: contentBody.schema, + }); + } + + return typespecBodies; +} diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transform-service-info.ts b/packages/openapi3/src/cli/actions/convert/transforms/transform-service-info.ts new file mode 100644 index 0000000000..921351a5af --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/transforms/transform-service-info.ts @@ -0,0 +1,14 @@ +import { OpenAPI3Info } from "../../../../types.js"; +import { TypeSpecServiceInfo } from "../interfaces.js"; + +export function transformServiceInfo(info: OpenAPI3Info): TypeSpecServiceInfo { + return { + name: info.title, + doc: info.description, + version: info.version, + contact: info.contact, + license: info.license, + termsOfService: info.termsOfService, + summary: info.summary, + }; +} diff --git a/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts new file mode 100644 index 0000000000..2bbdda34cf --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/transforms/transforms.ts @@ -0,0 +1,31 @@ +import { OpenAPI3Document } from "../../../../types.js"; +import { TypeSpecModel, TypeSpecProgram } from "../interfaces.js"; +import { transformComponentParameters } from "./transform-component-parameters.js"; +import { transformComponentSchemas } from "./transform-component-schemas.js"; +import { transformAllOperationResponses } from "./transform-operation-responses.js"; +import { transformPaths } from "./transform-paths.js"; +import { transformServiceInfo } from "./transform-service-info.js"; + +export function transform(openapi: OpenAPI3Document): TypeSpecProgram { + const models = collectModels(openapi); + + return { + serviceInfo: transformServiceInfo(openapi.info), + models, + augmentations: [], + operations: transformPaths(openapi.paths), + }; +} + +function collectModels(document: OpenAPI3Document): TypeSpecModel[] { + const models: TypeSpecModel[] = []; + const components = document.components; + // get models from `#/components/schema + transformComponentSchemas(models, components?.schemas); + // get models from `#/components/parameters + transformComponentParameters(models, components?.parameters); + // get models from #/paths/{route}/{httpMethod}/responses + transformAllOperationResponses(models, document); + + return models; +} diff --git a/packages/openapi3/src/cli/actions/convert/utils/convert-header-name.ts b/packages/openapi3/src/cli/actions/convert/utils/convert-header-name.ts new file mode 100644 index 0000000000..13c01b7d41 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/convert-header-name.ts @@ -0,0 +1,6 @@ +export function convertHeaderName(s: string): string { + return s + .split("-") + .map((s, idx) => (idx === 0 ? s : s.substring(0, 1).toUpperCase() + s.substring(1))) + .join(""); +} diff --git a/packages/openapi3/src/cli/actions/convert/utils/decorators.ts b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts new file mode 100644 index 0000000000..447a32d703 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/decorators.ts @@ -0,0 +1,199 @@ +import { ExtensionKey } from "@typespec/openapi"; +import { Extensions, OpenAPI3Parameter, OpenAPI3Schema, Refable } from "../../../../types.js"; +import { TypeSpecDecorator } from "../interfaces.js"; + +const validLocations = ["header", "query", "path"]; +const extensionDecoratorName = "extension"; + +export function getExtensions(element: Extensions): TypeSpecDecorator[] { + const decorators: TypeSpecDecorator[] = []; + + for (const key of Object.keys(element)) { + if (isExtensionKey(key)) { + decorators.push({ + name: extensionDecoratorName, + args: [key, element[key]], + }); + } + } + + return decorators; +} + +function isExtensionKey(key: string): key is ExtensionKey { + return key.startsWith("x-"); +} + +export function getParameterDecorators(parameter: OpenAPI3Parameter) { + const decorators: TypeSpecDecorator[] = []; + + decorators.push(...getExtensions(parameter)); + decorators.push(...getDecoratorsForSchema(parameter.schema)); + + const locationDecorator = getLocationDecorator(parameter); + if (locationDecorator) decorators.push(locationDecorator); + + return decorators; +} + +function getLocationDecorator(parameter: OpenAPI3Parameter): TypeSpecDecorator | undefined { + if (!validLocations.includes(parameter.in)) return; + + const decorator: TypeSpecDecorator = { + name: parameter.in, + args: [], + }; + + let format: string | undefined; + switch (parameter.in) { + case "header": + format = getHeaderFormat(parameter.style); + break; + case "query": + format = getQueryFormat(parameter.explode, parameter.style); + break; + } + + if (format) { + decorator.args.push({ format }); + } + + return decorator; +} + +function getQueryFormat(explode?: boolean, style?: string): string | undefined { + if (explode) { + return "form"; + } else if (style === "form") { + return "simple"; + } else if (style === "spaceDelimited") { + return "ssv"; + } else if (style === "pipeDelimited") { + return "pipes"; + } + return; +} + +function getHeaderFormat(style?: string): string | undefined { + return style === "simple" ? "simple" : undefined; +} + +export function getDecoratorsForSchema(schema: Refable): TypeSpecDecorator[] { + const decorators: TypeSpecDecorator[] = []; + + if ("$ref" in schema) { + return decorators; + } + + decorators.push(...getExtensions(schema)); + + switch (schema.type) { + case "array": + decorators.push(...getArraySchemaDecorators(schema)); + break; + case "object": + decorators.push(...getObjectSchemaDecorators(schema)); + break; + case "integer": + case "number": + decorators.push(...getNumberSchemaDecorators(schema)); + break; + case "string": + decorators.push(...getStringSchemaDecorators(schema)); + break; + default: + break; + } + + if (schema.oneOf) { + decorators.push(...getOneOfSchemaDecorators(schema)); + } + + return decorators; +} + +function getOneOfSchemaDecorators(schema: OpenAPI3Schema): TypeSpecDecorator[] { + return [{ name: "oneOf", args: [] }]; +} + +function getArraySchemaDecorators(schema: OpenAPI3Schema) { + const decorators: TypeSpecDecorator[] = []; + + if (typeof schema.minItems === "number") { + decorators.push({ name: "minItems", args: [schema.minItems] }); + } + + if (typeof schema.maxItems === "number") { + decorators.push({ name: "maxItems", args: [schema.maxItems] }); + } + + return decorators; +} + +function getObjectSchemaDecorators(schema: OpenAPI3Schema) { + const decorators: TypeSpecDecorator[] = []; + + if (schema.discriminator) { + decorators.push({ name: "discriminator", args: [schema.discriminator.propertyName] }); + } + + return decorators; +} + +function getNumberSchemaDecorators(schema: OpenAPI3Schema) { + const decorators: TypeSpecDecorator[] = []; + + if (typeof schema.minimum === "number") { + if (schema.exclusiveMinimum) { + decorators.push({ name: "minValueExclusive", args: [schema.minimum] }); + } else { + decorators.push({ name: "minValue", args: [schema.minimum] }); + } + } + + if (typeof schema.maximum === "number") { + if (schema.exclusiveMaximum) { + decorators.push({ name: "maxValueExclusive", args: [schema.maximum] }); + } else { + decorators.push({ name: "maxValue", args: [schema.maximum] }); + } + } + + return decorators; +} + +const knownStringFormats = new Set([ + "binary", + "byte", + "date", + "date-time", + "time", + "duration", + "uri", +]); + +function getStringSchemaDecorators(schema: OpenAPI3Schema) { + const decorators: TypeSpecDecorator[] = []; + + if (typeof schema.minLength === "number") { + decorators.push({ name: "minLength", args: [schema.minLength] }); + } + + if (typeof schema.maxLength === "number") { + decorators.push({ name: "maxLength", args: [schema.maxLength] }); + } + + if (typeof schema.pattern === "string") { + decorators.push({ name: "pattern", args: [escapeRegex(schema.pattern)] }); + } + + if (typeof schema.format === "string" && !knownStringFormats.has(schema.format)) { + decorators.push({ name: "format", args: [schema.format] }); + } + + return decorators; +} + +function escapeRegex(str: string) { + return str.replace(/\\/g, "\\\\"); +} diff --git a/packages/openapi3/src/cli/actions/convert/utils/docs.ts b/packages/openapi3/src/cli/actions/convert/utils/docs.ts new file mode 100644 index 0000000000..de24dded68 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/docs.ts @@ -0,0 +1,69 @@ +export function generateDocs(doc: string | string[]): string { + if (isEmptyDoc(doc)) { + return ``; + } + + const wrapped = lineWrap(doc); + + for (let i = 0; i < wrapped.length; i++) { + if (wrapped[i].includes("@") || wrapped[i].includes("*/")) { + if (wrapped.length === 1) { + return `@doc("${wrapped[0].replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`; + } + return `@doc("""\n${wrapped.join("\n").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}\n""")`; + } + } + + return `/**\n* ${wrapped.join("\n* ")}\n*/`; +} + +export function generateDocsContent(doc: string | string[]): string { + if (isEmptyDoc(doc)) { + return ``; + } + + const wrapped = lineWrap(doc); + return wrapped.length === 1 ? `${wrapped[0]}` : `""\n${wrapped.join("\n")}\n""`; +} + +function lineWrap(doc: string | string[]): string[] { + const maxLength = 80; + + let docString = Array.isArray(doc) ? doc.join("\n") : doc; + docString = docString.replace(/\r\n/g, "\n"); + docString = docString.replace(/\r/g, "\n"); + + if (docString.length <= maxLength && !docString.includes("\n")) { + return [docString]; + } + + const oriLines = docString.split("\n"); + const lines: string[] = []; + for (const oriLine of oriLines) { + const words = oriLine.split(" "); + let line = ``; + for (const word of words) { + if (word.length + 1 > maxLength - line.length) { + lines.push(line.substring(0, line.length - 1)); + line = `${word} `; + } else { + line = `${line}${word} `; + } + } + lines.push(`${line.substring(0, line.length - 1)}`); + } + + return lines; +} + +function isEmptyDoc(doc?: string | string[]): doc is undefined { + if (!doc) { + return true; + } + + if (Array.isArray(doc) && !doc.length) { + return true; + } + + return false; +} diff --git a/packages/openapi3/src/cli/actions/convert/utils/supported-http-methods.ts b/packages/openapi3/src/cli/actions/convert/utils/supported-http-methods.ts new file mode 100644 index 0000000000..33ad70c7f7 --- /dev/null +++ b/packages/openapi3/src/cli/actions/convert/utils/supported-http-methods.ts @@ -0,0 +1,10 @@ +import { HttpVerb } from "@typespec/http"; + +export const supportedHttpMethods = new Set([ + "delete", + "get", + "head", + "patch", + "post", + "put", +]); diff --git a/packages/openapi3/src/cli/cli.ts b/packages/openapi3/src/cli/cli.ts new file mode 100644 index 0000000000..10dc2e81e4 --- /dev/null +++ b/packages/openapi3/src/cli/cli.ts @@ -0,0 +1,89 @@ +import { parseArgs } from "util"; +import { ConvertCliArgs } from "./actions/convert/args.js"; +import { convertAction } from "./actions/convert/convert.js"; +import { createCliHost } from "./utils.js"; + +export async function main() { + const cliArgs = parseCliArgs(); + const host = createCliHost(); + + return convertAction(host, cliArgs); +} + +const cliUsage = `tsp-openapi3 --output-dir `; + +function parseCliArgs(): ConvertCliArgs { + const options = parseArgs({ + args: process.argv.slice(2), + options: { + help: { + type: "boolean", + }, + "output-dir": { + type: "string", + }, + }, + allowPositionals: true, + }); + + // Show help first + if (options.values.help) { + displayHelp(); + process.exit(0); + } + + const diagnostics: string[] = []; + if (!options.values["output-dir"]) { + diagnostics.push("Missing required argument: --output-dir"); + } + if (!options.positionals.length) { + diagnostics.push("Missing required positional argument: "); + } else if (options.positionals.length !== 1) { + diagnostics.push( + `Incorrect number of positional arguments provided for path: got ${options.positionals.length}, need 1` + ); + } + + if (diagnostics.length > 0) { + // eslint-disable-next-line no-console + console.log(cliUsage); + // eslint-disable-next-line no-console + console.log(`\n${diagnostics.join("\n")}`); + process.exit(1); + } + + return { + "output-dir": options.values["output-dir"]!, + path: options.positionals[0], + }; +} + +function displayHelp() { + // eslint-disable-next-line no-console + const log = console.log; + log(cliUsage); + log(`\nConvert OpenAPI3 to TypeSpec`); + log(`\nPositionals:`); + log( + padArgumentUsage( + "path", + "The path to the OpenAPI3 file in JSON or YAML format.", + "[string] [required]" + ) + ); + log(`\nOptions:`); + log(padArgumentUsage("--help", "Show help.", "[boolean]")); + log( + padArgumentUsage( + "--output-dir", + "The output directory for generated TypeSpec files. Will be created if it does not exist.", + "[string] [required]" + ) + ); +} + +function padArgumentUsage(name: string, description: string, type: string) { + // Assume 80 col width + // 14 for name, 20 for type, leaves 40 (with spacing) for description + return ` ${name.padEnd(14)} ${description.padEnd(40)} ${type.padStart(20)}`; +} diff --git a/packages/openapi3/src/cli/types.ts b/packages/openapi3/src/cli/types.ts new file mode 100644 index 0000000000..aeabc0bf7b --- /dev/null +++ b/packages/openapi3/src/cli/types.ts @@ -0,0 +1,50 @@ +import { SourceFile } from "@typespec/compiler"; + +export interface CliHost { + logger: Logger; + + /** read a file at the given url. */ + readUrl(url: string): Promise; + /** + * Write the file. + * @param path Path to the file. + * @param content Content of the file. + */ + writeFile(path: string, content: string): Promise; + + /** + * Read directory. + * @param path Path to the directory. + * @returns list of file/directory in the given directory. Returns the name not the full path. + */ + readDir(path: string): Promise; + + /** + * Deletes a directory or file. + * @param path Path to the directory or file. + */ + rm(path: string, options?: RmOptions): Promise; + /** + * create directory recursively. + * @param path Path to the directory. + */ + mkdirp(path: string): Promise; +} + +export interface RmOptions { + /** + * If `true`, perform a recursive directory removal. In + * recursive mode, errors are not reported if `path` does not exist, and + * operations are retried on failure. + * @default false + */ + recursive?: boolean; +} + +export interface Logger { + trace(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +export interface CliHostArgs {} diff --git a/packages/openapi3/src/cli/utils.ts b/packages/openapi3/src/cli/utils.ts new file mode 100644 index 0000000000..efe081a6ca --- /dev/null +++ b/packages/openapi3/src/cli/utils.ts @@ -0,0 +1,50 @@ +import { NodeHost } from "@typespec/compiler"; +import { CliHost, CliHostArgs, Logger } from "./types.js"; + +export function withCliHost( + fn: (host: CliHost, args: T) => void | Promise +): (args: T) => void | Promise { + return (args: T) => { + const host = createCliHost(); + return fn(host, args); + }; +} + +export function createCliHost(): CliHost { + const logger = createConsoleLogger(); + return { + ...NodeHost, + logger, + }; +} + +export function createConsoleLogger(): Logger { + // eslint-disable-next-line no-console + const log = console.log; + return { + trace: (message) => log({ level: "trace", message }), + warn: (message) => log({ level: "warning", message }), + error: (message) => log({ level: "error", message }), + }; +} + +/** + * Handle an internal compiler error. + * + * NOTE: An expected error, like one thrown for bad input, shouldn't reach + * here, but be handled somewhere else. If we reach here, it should be + * considered a bug and therefore we should not suppress the stack trace as + * that risks losing it in the case of a bug that does not repro easily. + * + * @param error error thrown + */ +export function handleInternalCompilerError(error: unknown): never { + /* eslint-disable no-console */ + console.error("Internal compiler error!"); + console.error("File issue at https://github.com/microsoft/typespec"); + console.error(); + console.error(error); + /* eslint-enable no-console */ + + process.exit(1); +} diff --git a/packages/openapi3/src/types.ts b/packages/openapi3/src/types.ts index c554638db6..19e3e12228 100644 --- a/packages/openapi3/src/types.ts +++ b/packages/openapi3/src/types.ts @@ -1,5 +1,5 @@ import { Diagnostic, Service } from "@typespec/compiler"; -import { ExtensionKey } from "@typespec/openapi"; +import { Contact, ExtensionKey, License } from "@typespec/openapi"; export type Extensions = { [key in ExtensionKey]?: any; @@ -96,6 +96,9 @@ export interface OpenAPI3Info extends Extensions { description?: string; termsOfService?: string; version: string; + contact?: Contact; + license?: License; + summary?: string; } export interface OpenAPI3Server { @@ -668,6 +671,8 @@ export type OpenAPI3Operation = Extensions & { // eslint-disable-next-line @typescript-eslint/no-unused-vars export interface Ref { $ref: string; + description?: string; + summary?: string; } export type Refable = Ref | T; diff --git a/packages/openapi3/src/version.ts b/packages/openapi3/src/version.ts new file mode 100644 index 0000000000..f3790e341c --- /dev/null +++ b/packages/openapi3/src/version.ts @@ -0,0 +1,11 @@ +let version; +try { + // eslint-disable-next-line + // @ts-ignore + version = (await import("../version.js")).version; +} catch { + const name = "../dist/version.js"; + version = (await import(/* @vite-ignore */ /* webpackIgnore: true */ name)).default; +} + +export const packageVersion = version; diff --git a/packages/openapi3/test/tsp-openapi3/generate-type.test.ts b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts new file mode 100644 index 0000000000..85e5f42788 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/generate-type.test.ts @@ -0,0 +1,161 @@ +import { formatTypeSpec } from "@typespec/compiler"; +import { strictEqual } from "node:assert"; +import { describe, it } from "vitest"; +import { generateTypeFromSchema } from "../../src/cli/actions/convert/generators/generate-types.js"; +import { OpenAPI3Schema, Refable } from "../../src/types.js"; + +interface TestScenario { + schema: Refable; + expected: string; +} + +function generateScenarioName(scenario: TestScenario): string { + return `${JSON.stringify(scenario.schema)} => ${scenario.expected}`; +} + +const testScenarios: TestScenario[] = [ + // boolean + { schema: { type: "boolean" }, expected: "boolean" }, + { schema: { type: "boolean", nullable: true }, expected: "boolean | null" }, + // integers + { schema: { type: "integer" }, expected: "integer" }, + { schema: { type: "integer", format: "int8" }, expected: "int8" }, + { schema: { type: "integer", format: "int16" }, expected: "int16" }, + { schema: { type: "integer", format: "int32" }, expected: "int32" }, + { schema: { type: "integer", format: "int64" }, expected: "int64" }, + { schema: { type: "integer", format: "uint8" }, expected: "uint8" }, + { schema: { type: "integer", format: "uint16" }, expected: "uint16" }, + { schema: { type: "integer", format: "uint32" }, expected: "uint32" }, + { schema: { type: "integer", format: "uint64" }, expected: "uint64" }, + { schema: { type: "integer", format: "double-int" }, expected: "safeint" }, + { schema: { type: "integer", enum: [1, 3, 5, 8, 13] }, expected: "1 | 3 | 5 | 8 | 13" }, + { + schema: { type: "integer", default: 3, enum: [1, 3, 5, 8, 13] }, + expected: "1 | 3 | 5 | 8 | 13 = 3", + }, + { + schema: { type: "integer", default: 3, enum: [1, 3, 5, 8, 13], nullable: true }, + expected: "1 | 3 | 5 | 8 | 13 | null = 3", + }, + // numerics + { schema: { type: "number" }, expected: "numeric" }, + { schema: { type: "number", default: 123 }, expected: "numeric = 123" }, + { schema: { type: "number", default: 123, nullable: true }, expected: "numeric | null = 123" }, + { schema: { type: "number", format: "decimal" }, expected: "decimal" }, + { schema: { type: "number", format: "decimal128" }, expected: "decimal128" }, + { schema: { type: "number", format: "double" }, expected: "float64" }, + { schema: { type: "number", format: "float" }, expected: "float32" }, + { schema: { type: "number", enum: [3.14, 6.28, 42] }, expected: "3.14 | 6.28 | 42" }, + // strings + { schema: { type: "string" }, expected: "string" }, + { schema: { type: "string", format: "binary" }, expected: "bytes" }, + { schema: { type: "string", format: "byte" }, expected: "bytes" }, + { schema: { type: "string", format: "date" }, expected: "plainDate" }, + { schema: { type: "string", format: "date-time" }, expected: "utcDateTime" }, + { schema: { type: "string", format: "duration" }, expected: "duration" }, + { schema: { type: "string", format: "time" }, expected: "plainTime" }, + { schema: { type: "string", format: "uri" }, expected: "url" }, + { schema: { type: "string", enum: ["foo", "bar"] }, expected: `"foo" | "bar"` }, + { + schema: { type: "string", default: "foo", enum: ["foo", "bar"] }, + expected: `"foo" | "bar" = "foo"`, + }, + // refs + { schema: { $ref: "#/Path/To/Some/Model" }, expected: "Model" }, + { schema: { $ref: "#/Path/To/Some/Model.Prop" }, expected: "Model.Prop" }, + // arrays + { schema: { type: "array", items: { type: "string" } }, expected: "string[]" }, + { + schema: { type: "array", items: { type: "array", items: { type: "string" } } }, + expected: "string[][]", + }, + { + schema: { type: "array", items: { type: "string", enum: ["foo", "bar"] } }, + expected: `("foo" | "bar")[]`, + }, + { schema: { type: "array", items: { $ref: "#/Path/To/Some/Model" } }, expected: "Model[]" }, + { + schema: { type: "array", items: { anyOf: [{ type: "string" }, { $ref: "#/Path/To/Model" }] } }, + expected: "(string | Model)[]", + }, + // objects + { + schema: { type: "object", properties: { foo: { type: "string" } } }, + expected: "{foo?: string}", + }, + { + schema: { + type: "object", + required: ["foo"], + properties: { foo: { type: "string" }, bar: { type: "boolean" } }, + }, + expected: "{foo: string; bar?: boolean}", + }, + { + schema: { type: "object", additionalProperties: { type: "string" } }, + expected: "Record", + }, + { + schema: { + type: "object", + additionalProperties: { type: "string" }, + properties: { bar: { type: "boolean" } }, + }, + expected: "{bar?: boolean; ...Record}", + }, + { + schema: { + type: "object", + required: ["foo"], + properties: { foo: { type: "object", properties: { foo: { type: "string" } } } }, + }, + expected: "{foo: {foo?: string}}", + }, + { + schema: { + type: "object", + required: ["foo"], + properties: { foo: { type: "string" }, bar: { $ref: "#/Path/To/Model" } }, + }, + expected: "{foo: string; bar?: Model}", + }, + // anyOf/oneOf + { + schema: { + anyOf: [ + { $ref: "#/Path/To/Model" }, + { type: "boolean" }, + { type: "string", enum: ["foo", "bar"] }, + ], + }, + expected: `Model | boolean | "foo" | "bar"`, + }, + { + schema: { + oneOf: [ + { $ref: "#/Path/To/Model" }, + { type: "boolean" }, + { type: "string", enum: ["foo", "bar"] }, + ], + }, + expected: `Model | boolean | "foo" | "bar"`, + }, + // fallthrough + { schema: {}, expected: "unknown" }, +]; + +describe("tsp-openapi: generate-type", () => { + testScenarios.forEach((t) => + it(`${generateScenarioName(t)}`, async () => { + const type = generateTypeFromSchema(t.schema); + const wrappedType = await formatWrappedType(type); + const wrappedExpected = await formatWrappedType(t.expected); + strictEqual(wrappedType, wrappedExpected); + }) + ); +}); + +// Wrap the expected and actual types in this model to get formatted types. +function formatWrappedType(type: string): Promise { + return formatTypeSpec(`model Test { test: ${type}; }`); +} diff --git a/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp b/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp new file mode 100644 index 0000000000..db4352b089 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/one-any-all/main.tsp @@ -0,0 +1,54 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "OneAnyAll Service", +}) +@info({ + version: "0.0.0", +}) +namespace OneAnyAllService; + +model Cat extends Pet { + hunts: boolean; +} + +model Dog extends Pet { + bark: boolean; + breed: "Husky" | "Corgi" | "Terrier"; +} + +model Pet { + age: int32; +} + +/** + * There is no content to send for this request, but the headers may be useful. + */ +model putAny204Response { + @statusCode statusCode: 204; +} + +/** + * There is no content to send for this request, but the headers may be useful. + */ +model putOne204Response { + @statusCode statusCode: 204; +} + +@route("/any") @post op putAny( + @bodyRoot body: { + pet: Dog | Cat; + }, +): putAny204Response; + +@route("/one") @post op putOne( + @bodyRoot body: { + @oneOf + pet: Dog | Cat; + }, +): putOne204Response; diff --git a/packages/openapi3/test/tsp-openapi3/output/openapi-extensions/main.tsp b/packages/openapi3/test/tsp-openapi3/output/openapi-extensions/main.tsp new file mode 100644 index 0000000000..945db6c92e --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/openapi-extensions/main.tsp @@ -0,0 +1,59 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "(title)", +}) +@info({ + version: "0.0.0", +}) +namespace title; + +model Foo { + @extension( + "x-model", + { + name: "Foo", + age: 12, + other: { + id: "some", + }, + } + ) + @extension( + "x-obj", + { + foo: 123, + bar: "string", + } + ) + @extension("x-array", ["one", 2]) + @extension("x-bool", true) + @extension("x-number", 123) + @extension("x-string", "string") + id: string; +} + +model MyConfig { + name: "Foo"; + age: 12; + other: NestedConfig; +} + +model NestedConfig { + id: "some"; +} + +/** + * The request has succeeded. + */ +model foo200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Foo; +} + +@route("/") @get op foo(): foo200ApplicationJsonResponse; diff --git a/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp b/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp new file mode 100644 index 0000000000..6ae3ee52d8 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/param-decorators/main.tsp @@ -0,0 +1,62 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "Parameter Decorators", +}) +@info({ + version: "0.0.0", +}) +namespace ParameterDecorators; + +model Thing { + name: string; + @format("UUID") id: string; +} + +model NameParameter { + /** + * Name parameter + */ + @pattern("^[a-zA-Z0-9-]{3,24}$") + @format("UUID") + @path + name: string; +} + +/** + * The request has succeeded. + */ +model Operations_getThing200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Thing; +} + +/** + * The request has succeeded. + */ +model Operations_putThing200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Thing; +} + +@route("/thing/{name}") @get op Operations_getThing( + @pattern("^[a-zA-Z0-9-]{3,24}$") + @format("UUID") + @path + name: string, + + @minValue(0) + @maxValue(10) + @query + count: int32, +): Operations_getThing200ApplicationJsonResponse; + +@route("/thing/{name}") @put op Operations_putThing( + ...NameParameter, + @bodyRoot body: Thing, +): Operations_putThing200ApplicationJsonResponse; diff --git a/packages/openapi3/test/tsp-openapi3/output/petstore-sample/main.tsp b/packages/openapi3/test/tsp-openapi3/output/petstore-sample/main.tsp new file mode 100644 index 0000000000..0fbaa45b2b --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/petstore-sample/main.tsp @@ -0,0 +1,107 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "Swagger Petstore", +}) +@info({ + version: "1.0.0", + license: { + name: "MIT", + }, +}) +namespace SwaggerPetstore; + +model Pet { + id: int64; + name: string; + tag?: string; +} + +model Pets is Pet[]; + +model Error { + code: int32; + message: string; +} + +/** + * A paged array of pets + */ +model listPets200ApplicationJsonResponse { + /** + * A link to the next page of responses + */ + @header("x-next") xNext?: string; + + @statusCode statusCode: 200; + @bodyRoot body: Pets; +} + +/** + * unexpected error + */ +@defaultResponse +model listPetsDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * Null response + */ +model createPets201Response { + @statusCode statusCode: 201; +} + +/** + * unexpected error + */ +@defaultResponse +model createPetsDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * Expected response to a valid request + */ +model showPetById200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; +} + +/** + * unexpected error + */ +@defaultResponse +model showPetByIdDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +@tag("pets") +@route("/pets") +@get +op listPets( + /** + * How many items to return at one time (max 100) + */ + @query limit?: int32, +): listPets200ApplicationJsonResponse | listPetsDefaultApplicationJsonResponse; + +@tag("pets") +@route("/pets") +@post +op createPets(): createPets201Response | createPetsDefaultApplicationJsonResponse; + +@tag("pets") +@route("/pets/{petId}") +@get +op showPetById( + /** + * The id of the pet to retrieve + */ + @path petId: string, +): showPetById200ApplicationJsonResponse | showPetByIdDefaultApplicationJsonResponse; diff --git a/packages/openapi3/test/tsp-openapi3/output/petstore-swagger/main.tsp b/packages/openapi3/test/tsp-openapi3/output/petstore-swagger/main.tsp new file mode 100644 index 0000000000..614782fbdd --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/petstore-swagger/main.tsp @@ -0,0 +1,773 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +/** + * This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You + * can find out more about + * Swagger at [http://swagger.io](http://swagger.io). In the third iteration of + * the pet store, we've switched to the design first approach! + * You can now help us improve the API whether it's by making changes to the + * definition itself or to the code. + * That way, with time, we can improve the API in general, and expose some of the + * new features in OAS3. + * + * Some useful links: + * - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + * - [The source API definition for the Pet + * Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + */ +@service({ + title: "Swagger Petstore - OpenAPI 3.0", +}) +@info({ + version: "1.0.20-SNAPSHOT", + contact: { + email: "apiteam@swagger.io", + }, + license: { + name: "Apache 2.0", + url: "http://www.apache.org/licenses/LICENSE-2.0.html", + }, + termsOfService: "http://swagger.io/terms/", +}) +namespace SwaggerPetstoreOpenAPI30; + +@extension("x-swagger-router-model", "io.swagger.petstore.model.Order") +model Order { + id?: int64; + petId?: int64; + @maxValue(10) quantity?: int32; + shipDate?: utcDateTime; + + /** + * Order Status + */ + status?: "placed" | "approved" | "delivered"; + + complete?: boolean; +} + +model Customer { + id?: int64; + username?: string; + address?: Address[]; +} + +model Address { + street?: string; + city?: string; + state?: string; + zip?: string; +} + +@extension("x-swagger-router-model", "io.swagger.petstore.model.Category") +model Category { + id?: int64; + name?: string; +} + +@extension("x-swagger-router-model", "io.swagger.petstore.model.User") +model User { + id?: int64; + username?: string; + firstName?: string; + lastName?: string; + email?: string; + password?: string; + phone?: string; + + /** + * User Status + */ + userStatus?: int32; +} + +@extension("x-swagger-router-model", "io.swagger.petstore.model.Tag") +model Tag { + id?: int64; + name?: string; +} + +@extension("x-swagger-router-model", "io.swagger.petstore.model.Pet") +model Pet { + id?: int64; + name: string; + category?: Category; + photoUrls: string[]; + tags?: Tag[]; + + /** + * pet status in the store + */ + status?: "available" | "pending" | "sold"; +} + +model ApiResponse { + code?: int32; + type?: string; + message?: string; +} + +/** + * Successful operation + */ +model addPet200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; + @header contentType: "application/xml"; +} + +/** + * Successful operation + */ +model addPet200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; +} + +/** + * Invalid input + */ +@error +model addPet405Response { + @statusCode statusCode: 405; +} + +/** + * Successful operation + */ +model updatePet200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; + @header contentType: "application/xml"; +} + +/** + * Successful operation + */ +model updatePet200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; +} + +/** + * Invalid ID supplied + */ +@error +model updatePet400Response { + @statusCode statusCode: 400; +} + +/** + * Pet not found + */ +@error +model updatePet404Response { + @statusCode statusCode: 404; +} + +/** + * Validation exception + */ +@error +model updatePet405Response { + @statusCode statusCode: 405; +} + +/** + * successful operation + */ +model findPetsByStatus200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet[]; + @header contentType: "application/xml"; +} + +/** + * successful operation + */ +model findPetsByStatus200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet[]; +} + +/** + * Invalid status value + */ +@error +model findPetsByStatus400Response { + @statusCode statusCode: 400; +} + +/** + * successful operation + */ +model findPetsByTags200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet[]; + @header contentType: "application/xml"; +} + +/** + * successful operation + */ +model findPetsByTags200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet[]; +} + +/** + * Invalid tag value + */ +@error +model findPetsByTags400Response { + @statusCode statusCode: 400; +} + +/** + * Invalid pet value + */ +@error +model deletePet400Response { + @statusCode statusCode: 400; +} + +/** + * successful operation + */ +model getPetById200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; + @header contentType: "application/xml"; +} + +/** + * successful operation + */ +model getPetById200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; +} + +/** + * Invalid ID supplied + */ +@error +model getPetById400Response { + @statusCode statusCode: 400; +} + +/** + * Pet not found + */ +@error +model getPetById404Response { + @statusCode statusCode: 404; +} + +/** + * Invalid input + */ +@error +model updatePetWithForm405Response { + @statusCode statusCode: 405; +} + +/** + * successful operation + */ +model uploadFile200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: ApiResponse; +} + +/** + * successful operation + */ +model getInventory200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Record; +} + +/** + * successful operation + */ +model placeOrder200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Order; +} + +/** + * Invalid input + */ +@error +model placeOrder405Response { + @statusCode statusCode: 405; +} + +/** + * Invalid ID supplied + */ +@error +model deleteOrder400Response { + @statusCode statusCode: 400; +} + +/** + * Order not found + */ +@error +model deleteOrder404Response { + @statusCode statusCode: 404; +} + +/** + * successful operation + */ +model getOrderById200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: Order; + @header contentType: "application/xml"; +} + +/** + * successful operation + */ +model getOrderById200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Order; +} + +/** + * Invalid ID supplied + */ +@error +model getOrderById400Response { + @statusCode statusCode: 400; +} + +/** + * Order not found + */ +@error +model getOrderById404Response { + @statusCode statusCode: 404; +} + +/** + * successful operation + */ +@defaultResponse +model createUserDefaultApplicationJsonResponse { + @bodyRoot body: User; +} + +/** + * successful operation + */ +@defaultResponse +model createUserDefaultApplicationXmlResponse { + @bodyRoot body: User; + @header contentType: "application/xml"; +} + +/** + * Successful operation + */ +model createUsersWithListInput200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: User; + @header contentType: "application/xml"; +} + +/** + * Successful operation + */ +model createUsersWithListInput200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: User; +} + +/** + * successful operation + */ +@defaultResponse +model createUsersWithListInputDefaultResponse {} + +/** + * successful operation + */ +model loginUser200ApplicationXmlResponse { + /** + * calls per hour allowed by the user + */ + @header("X-Rate-Limit") XRateLimit?: int32; + + /** + * date in UTC when token expires + */ + @header("X-Expires-After") XExpiresAfter?: utcDateTime; + + @statusCode statusCode: 200; + @bodyRoot body: string; + @header contentType: "application/xml"; +} + +/** + * successful operation + */ +model loginUser200ApplicationJsonResponse { + /** + * calls per hour allowed by the user + */ + @header("X-Rate-Limit") XRateLimit?: int32; + + /** + * date in UTC when token expires + */ + @header("X-Expires-After") XExpiresAfter?: utcDateTime; + + @statusCode statusCode: 200; + @bodyRoot body: string; +} + +/** + * Invalid username/password supplied + */ +@error +model loginUser400Response { + @statusCode statusCode: 400; +} + +/** + * successful operation + */ +@defaultResponse +model logoutUserDefaultResponse {} + +/** + * Invalid username supplied + */ +@error +model deleteUser400Response { + @statusCode statusCode: 400; +} + +/** + * User not found + */ +@error +model deleteUser404Response { + @statusCode statusCode: 404; +} + +/** + * successful operation + */ +model getUserByName200ApplicationXmlResponse { + @statusCode statusCode: 200; + @bodyRoot body: User; + @header contentType: "application/xml"; +} + +/** + * successful operation + */ +model getUserByName200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: User; +} + +/** + * Invalid username supplied + */ +@error +model getUserByName400Response { + @statusCode statusCode: 400; +} + +/** + * User not found + */ +@error +model getUserByName404Response { + @statusCode statusCode: 404; +} + +/** + * successful operation + */ +@defaultResponse +model updateUserDefaultResponse {} + +/** + * Add a new pet to the store + */ +@tag("pet") +@route("/pet") +@post +op addPet( + @header contentType: "application/json" | "application/xml" | "application/x-www-form-urlencoded", + @bodyRoot body: Pet, +): addPet200ApplicationXmlResponse | addPet200ApplicationJsonResponse | addPet405Response; + +/** + * Update an existing pet by Id + */ +@tag("pet") +@route("/pet") +@put +op updatePet( + @header contentType: "application/json" | "application/xml" | "application/x-www-form-urlencoded", + @bodyRoot body: Pet, +): + | updatePet200ApplicationXmlResponse + | updatePet200ApplicationJsonResponse + | updatePet400Response + | updatePet404Response + | updatePet405Response; + +/** + * Multiple status values can be provided with comma separated strings + */ +@tag("pet") +@route("/pet/findByStatus") +@get +op findPetsByStatus( + /** + * Status values that need to be considered for filter + */ + @query({ + format: "form", + }) + status?: "available" | "pending" | "sold" = "available", +): findPetsByStatus200ApplicationXmlResponse | findPetsByStatus200ApplicationJsonResponse | findPetsByStatus400Response; + +/** + * Multiple tags can be provided with comma separated strings. Use tag1, tag2, + * tag3 for testing. + */ +@tag("pet") +@route("/pet/findByTags") +@get +op findPetsByTags( + /** + * Tags to filter by + */ + @query({ + format: "form", + }) + tags?: string[], +): findPetsByTags200ApplicationXmlResponse | findPetsByTags200ApplicationJsonResponse | findPetsByTags400Response; + +@tag("pet") +@route("/pet/{petId}") +@delete +op deletePet( + @header api_key?: string, + + /** + * Pet id to delete + */ + @path petId: int64, +): deletePet400Response; + +/** + * Returns a single pet + */ +@tag("pet") +@route("/pet/{petId}") +@get +op getPetById( + /** + * ID of pet to return + */ + @path petId: int64, +): + | getPetById200ApplicationXmlResponse + | getPetById200ApplicationJsonResponse + | getPetById400Response + | getPetById404Response; + +@tag("pet") +@route("/pet/{petId}") +@post +op updatePetWithForm( + /** + * ID of pet that needs to be updated + */ + @path petId: int64, + + /** + * Name of pet that needs to be updated + */ + @query name?: string, + + /** + * Status of pet that needs to be updated + */ + @query status?: string, +): updatePetWithForm405Response; + +@tag("pet") +@route("/pet/{petId}/uploadImage") +@post +op uploadFile( + /** + * ID of pet to update + */ + @path petId: int64, + + /** + * Additional Metadata + */ + @query additionalMetadata?: string, + + @header contentType: "application/octet-stream", + @bodyRoot body: bytes, +): uploadFile200ApplicationJsonResponse; + +/** + * Returns a map of status codes to quantities + */ +@tag("store") +@extension("x-swagger-router-controller", "OrderController") +@route("/store/inventory") +@get +op getInventory(): getInventory200ApplicationJsonResponse; + +/** + * Place a new order in the store + */ +@tag("store") +@extension("x-swagger-router-controller", "OrderController") +@route("/store/order") +@post +op placeOrder( + @header contentType: "application/json" | "application/xml" | "application/x-www-form-urlencoded", + @bodyRoot body: Order, +): placeOrder200ApplicationJsonResponse | placeOrder405Response; + +/** + * For valid response try integer IDs with value < 1000. Anything above 1000 or + * nonintegers will generate API errors + */ +@tag("store") +@extension("x-swagger-router-controller", "OrderController") +@route("/store/order/{orderId}") +@delete +op deleteOrder( + /** + * ID of the order that needs to be deleted + */ + @path orderId: int64, +): deleteOrder400Response | deleteOrder404Response; + +/** + * For valid response try integer IDs with value <= 5 or > 10. Other values will + * generate exceptions. + */ +@tag("store") +@extension("x-swagger-router-controller", "OrderController") +@route("/store/order/{orderId}") +@get +op getOrderById( + /** + * ID of order that needs to be fetched + */ + @path orderId: int64, +): + | getOrderById200ApplicationXmlResponse + | getOrderById200ApplicationJsonResponse + | getOrderById400Response + | getOrderById404Response; + +/** + * This can only be done by the logged in user. + */ +@tag("user") +@route("/user") +@post +op createUser( + @header contentType: "application/json" | "application/xml" | "application/x-www-form-urlencoded", + @bodyRoot body: User, +): createUserDefaultApplicationJsonResponse | createUserDefaultApplicationXmlResponse; + +/** + * Creates list of users with given input array + */ +@tag("user") +@extension("x-swagger-router-controller", "UserController") +@route("/user/createWithList") +@post +op createUsersWithListInput( + @bodyRoot body: User[], +): createUsersWithListInput200ApplicationXmlResponse | createUsersWithListInput200ApplicationJsonResponse | createUsersWithListInputDefaultResponse; + +@tag("user") +@route("/user/login") +@get +op loginUser( + /** + * The user name for login + */ + @query username?: string, + + /** + * The password for login in clear text + */ + @query password?: string, +): loginUser200ApplicationXmlResponse | loginUser200ApplicationJsonResponse | loginUser400Response; + +@tag("user") +@route("/user/logout") +@get +op logoutUser(): logoutUserDefaultResponse; + +/** + * This can only be done by the logged in user. + */ +@tag("user") +@route("/user/{username}") +@delete +op deleteUser( + /** + * The name that needs to be deleted + */ + @path username: string, +): deleteUser400Response | deleteUser404Response; + +@tag("user") +@route("/user/{username}") +@get +op getUserByName( + /** + * The name that needs to be fetched. Use user1 for testing. + */ + @path username: string, +): + | getUserByName200ApplicationXmlResponse + | getUserByName200ApplicationJsonResponse + | getUserByName400Response + | getUserByName404Response; + +/** + * This can only be done by the logged in user. + */ +@tag("user") +@extension("x-swagger-router-controller", "UserController") +@route("/user/{username}") +@put +op updateUser( + /** + * name that needs to be updated + */ + @path username: string, + + @header contentType: "application/json" | "application/xml" | "application/x-www-form-urlencoded", + @bodyRoot body: User, +): updateUserDefaultResponse; diff --git a/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp b/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp new file mode 100644 index 0000000000..009f580477 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/playground-http-service/main.tsp @@ -0,0 +1,172 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "Widget Service", +}) +@info({ + version: "0.0.0", +}) +namespace WidgetService; + +model Error { + code: int32; + message: string; +} + +model Widget { + @path id: string; + weight: int32; + color: "red" | "blue"; +} + +model WidgetCreate { + weight: int32; + color: "red" | "blue"; +} + +model WidgetUpdate { + weight?: int32; + color?: "red" | "blue"; +} + +/** + * The request has succeeded. + */ +model Widgets_list200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Widget[]; +} + +/** + * An unexpected error response. + */ +@defaultResponse +model Widgets_listDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * The request has succeeded. + */ +model Widgets_create200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Widget; +} + +/** + * An unexpected error response. + */ +@defaultResponse +model Widgets_createDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * There is no content to send for this request, but the headers may be useful. + */ +model Widgets_delete204Response { + @statusCode statusCode: 204; +} + +/** + * An unexpected error response. + */ +@defaultResponse +model Widgets_deleteDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * The request has succeeded. + */ +model Widgets_read200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Widget; +} + +/** + * An unexpected error response. + */ +@defaultResponse +model Widgets_readDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * The request has succeeded. + */ +model Widgets_update200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Widget; +} + +/** + * An unexpected error response. + */ +@defaultResponse +model Widgets_updateDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +/** + * The request has succeeded. + */ +model Widgets_analyze200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: string; +} + +/** + * An unexpected error response. + */ +@defaultResponse +model Widgets_analyzeDefaultApplicationJsonResponse { + @bodyRoot body: Error; +} + +@tag("Widgets") +@route("/widgets") +@get +op Widgets_list( +): Widgets_list200ApplicationJsonResponse | Widgets_listDefaultApplicationJsonResponse; + +@tag("Widgets") +@route("/widgets") +@post +op Widgets_create( + @bodyRoot body: WidgetCreate, +): Widgets_create200ApplicationJsonResponse | Widgets_createDefaultApplicationJsonResponse; + +@tag("Widgets") +@route("/widgets/{id}") +@delete +op Widgets_delete( + @path id: string, +): Widgets_delete204Response | Widgets_deleteDefaultApplicationJsonResponse; + +@tag("Widgets") +@route("/widgets/{id}") +@get +op Widgets_read( + @path id: string, +): Widgets_read200ApplicationJsonResponse | Widgets_readDefaultApplicationJsonResponse; + +@tag("Widgets") +@route("/widgets/{id}") +@patch +op Widgets_update( + id: Widget.id, + @bodyRoot body: WidgetUpdate, +): Widgets_update200ApplicationJsonResponse | Widgets_updateDefaultApplicationJsonResponse; + +@tag("Widgets") +@route("/widgets/{id}/analyze") +@post +op Widgets_analyze( + @path id: string, +): Widgets_analyze200ApplicationJsonResponse | Widgets_analyzeDefaultApplicationJsonResponse; diff --git a/packages/openapi3/test/tsp-openapi3/output/polymorphism/main.tsp b/packages/openapi3/test/tsp-openapi3/output/polymorphism/main.tsp new file mode 100644 index 0000000000..d585239549 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/polymorphism/main.tsp @@ -0,0 +1,45 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "Polymorphism sample", +}) +@info({ + version: "0.0.0", +}) +namespace Polymorphismsample; + +model Cat extends Pet { + kind: "cat"; + meow: int32; +} + +model Dog extends Pet { + kind: "dog"; + bark: string; +} + +@discriminator("kind") +model Pet { + name: string; + weight?: float32; + + /** + * Discriminator property for Pet. + */ + kind?: string; +} + +/** + * The request has succeeded. + */ +model root_read200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; +} + +@route("/Pets") @get op root_read(): root_read200ApplicationJsonResponse; diff --git a/packages/openapi3/test/tsp-openapi3/output/status-code-changes/main.tsp b/packages/openapi3/test/tsp-openapi3/output/status-code-changes/main.tsp new file mode 100644 index 0000000000..3d78422fd5 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/output/status-code-changes/main.tsp @@ -0,0 +1,51 @@ +import "@typespec/http"; +import "@typespec/openapi"; +import "@typespec/openapi3"; + +using Http; +using OpenAPI; + +@service({ + title: "(title)", +}) +@info({ + version: "0.0.0", +}) +namespace title; + +model Pet { + name: string; +} + +/** + * The request has succeeded. + */ +model extensive200ApplicationJsonResponse { + @statusCode statusCode: 200; + @bodyRoot body: Pet; +} + +/** + * Client error + */ +@error +model extensive4XXResponse { + @statusCode + @minValue(400) + @maxValue(499) + statusCode: int32; +} + +/** + * Server error + */ +@error +model extensive5XXResponse { + @statusCode + @minValue(500) + @maxValue(599) + statusCode: int32; +} + +@route("/") @get op extensive( +): extensive200ApplicationJsonResponse | extensive4XXResponse | extensive5XXResponse; diff --git a/packages/openapi3/test/tsp-openapi3/specs.test.ts b/packages/openapi3/test/tsp-openapi3/specs.test.ts new file mode 100644 index 0000000000..80cb7e8d09 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs.test.ts @@ -0,0 +1,15 @@ +import { resolvePath } from "@typespec/compiler"; +import { findTestPackageRoot } from "@typespec/compiler/testing"; +import { describe } from "vitest"; +import { defineSpecSnaphotTests } from "./utils/spec-snapshot-testing.js"; + +const pkgRoot = await findTestPackageRoot(import.meta.url); +const specsRoot = resolvePath(pkgRoot, "test", "tsp-openapi3", "specs"); +const rootOutputDir = resolvePath(pkgRoot, "test", "tsp-openapi3", "output"); + +describe("tsp-openapi3 convert", () => { + defineSpecSnaphotTests({ + specDir: specsRoot, + outputDir: rootOutputDir, + }); +}); diff --git a/packages/openapi3/test/tsp-openapi3/specs/one-any-all/service.yml b/packages/openapi3/test/tsp-openapi3/specs/one-any-all/service.yml new file mode 100644 index 0000000000..835bb39098 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/one-any-all/service.yml @@ -0,0 +1,81 @@ +openapi: 3.0.0 +info: + title: OneAnyAll Service + version: 0.0.0 +tags: [] +paths: + /any: + post: + operationId: putAny + parameters: [] + responses: + "204": + description: "There is no content to send for this request, but the headers may be useful. " + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pet: + anyOf: + - $ref: "#/components/schemas/Dog" + - $ref: "#/components/schemas/Cat" + required: + - pet + /one: + post: + operationId: putOne + parameters: [] + responses: + "204": + description: "There is no content to send for this request, but the headers may be useful. " + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pet: + oneOf: + - $ref: "#/components/schemas/Dog" + - $ref: "#/components/schemas/Cat" + required: + - pet +components: + schemas: + Cat: + type: object + required: + - hunts + properties: + hunts: + type: boolean + allOf: + - $ref: "#/components/schemas/Pet" + Dog: + type: object + required: + - bark + - breed + properties: + bark: + type: boolean + breed: + type: string + enum: + - Husky + - Corgi + - Terrier + allOf: + - $ref: "#/components/schemas/Pet" + Pet: + type: object + required: + - age + properties: + age: + type: integer + format: int32 diff --git a/packages/openapi3/test/tsp-openapi3/specs/openapi-extensions/service.yml b/packages/openapi3/test/tsp-openapi3/specs/openapi-extensions/service.yml new file mode 100644 index 0000000000..d25fc1e4fd --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/openapi-extensions/service.yml @@ -0,0 +1,66 @@ +openapi: 3.0.0 +info: + title: (title) + version: 0.0.0 +tags: [] +paths: + /: + get: + operationId: foo + parameters: [] + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Foo" +components: + schemas: + Foo: + type: object + required: + - id + properties: + id: + type: string + x-model: + name: Foo + age: 12 + other: + id: some + x-obj: + foo: 123 + bar: string + x-array: + - one + - 2 + x-bool: true + x-number: 123 + x-string: string + MyConfig: + type: object + required: + - name + - age + - other + properties: + name: + type: string + enum: + - Foo + age: + type: number + enum: + - 12 + other: + $ref: "#/components/schemas/NestedConfig" + NestedConfig: + type: object + required: + - id + properties: + id: + type: string + enum: + - some diff --git a/packages/openapi3/test/tsp-openapi3/specs/param-decorators/service.yml b/packages/openapi3/test/tsp-openapi3/specs/param-decorators/service.yml new file mode 100644 index 0000000000..8af612b179 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/param-decorators/service.yml @@ -0,0 +1,72 @@ +openapi: 3.0.0 +info: + title: Parameter Decorators + version: 0.0.0 +tags: [] +paths: + /thing/{name}: + get: + operationId: Operations_getThing + parameters: + - name: name + in: path + required: true + schema: + type: string + format: UUID + pattern: ^[a-zA-Z0-9-]{3,24}$ + - name: count + in: query + required: true + schema: + type: integer + format: int32 + minimum: 0 + maximum: 10 + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" + put: + operationId: Operations_putThing + parameters: + - $ref: "#/components/parameters/NameParameter" + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Thing" +components: + parameters: + NameParameter: + name: name + in: path + required: true + description: Name parameter + schema: + type: string + format: UUID + pattern: ^[a-zA-Z0-9-]{3,24}$ + schemas: + Thing: + type: object + required: + - name + - id + properties: + name: + type: string + id: + type: string + format: UUID diff --git a/packages/openapi3/test/tsp-openapi3/specs/petstore-sample/service.json b/packages/openapi3/test/tsp-openapi3/specs/petstore-sample/service.json new file mode 100644 index 0000000000..ece4f7be65 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/petstore-sample/service.json @@ -0,0 +1,165 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": ["pets"], + "responses": { + "201": { + "description": "Null response" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Pets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/openapi3/test/tsp-openapi3/specs/petstore-swagger/service.yml b/packages/openapi3/test/tsp-openapi3/specs/petstore-swagger/service.yml new file mode 100644 index 0000000000..387ac4c2ea --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/petstore-swagger/service.yml @@ -0,0 +1,798 @@ +openapi: 3.0.2 +servers: + - url: /v3 +info: + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + version: 1.0.20-SNAPSHOT + title: Swagger Petstore - OpenAPI 3.0 + termsOfService: "http://swagger.io/terms/" + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: "http://www.apache.org/licenses/LICENSE-2.0.html" +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: "http://swagger.io" + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: "http://swagger.io" + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + security: + - petstore_auth: + - "write:pets" + - "read:pets" + requestBody: + description: Create a new pet in the store + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Pet" + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - "write:pets" + - "read:pets" + requestBody: + description: Update an existent pet in the store + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Pet" + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + enum: + - available + - pending + - sold + default: available + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid status value + security: + - petstore_auth: + - "write:pets" + - "read:pets" + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: false + explode: true + schema: + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid tag value + security: + - petstore_auth: + - "write:pets" + - "read:pets" + "/pet/{petId}": + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - "write:pets" + - "read:pets" + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: "" + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + "405": + description: Invalid input + security: + - petstore_auth: + - "write:pets" + - "read:pets" + delete: + tags: + - pet + summary: Deletes a pet + description: "" + operationId: deletePet + parameters: + - name: api_key + in: header + description: "" + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid pet value + security: + - petstore_auth: + - "write:pets" + - "read:pets" + "/pet/{petId}/uploadImage": + post: + tags: + - pet + summary: uploads an image + description: "" + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + security: + - petstore_auth: + - "write:pets" + - "read:pets" + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + x-swagger-router-controller: OrderController + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: Place a new order in the store + operationId: placeOrder + x-swagger-router-controller: OrderController + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "405": + description: Invalid input + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + application/xml: + schema: + $ref: "#/components/schemas/Order" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Order" + "/store/order/{orderId}": + get: + tags: + - store + summary: Find purchase order by ID + x-swagger-router-controller: OrderController + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Order" + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + x-swagger-router-controller: OrderController + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + application/xml: + schema: + $ref: "#/components/schemas/User" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + application/xml: + schema: + $ref: "#/components/schemas/User" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/User" + description: Created user object + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: "Creates list of users with given input array" + x-swagger-router-controller: UserController + operationId: createUsersWithListInput + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/User" + application/json: + schema: + $ref: "#/components/schemas/User" + default: + description: successful operation + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: "" + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + "200": + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: "" + operationId: logoutUser + parameters: [] + responses: + default: + description: successful operation + "/user/{username}": + get: + tags: + - user + summary: Get user by user name + description: "" + operationId: getUserByName + parameters: + - name: username + in: path + description: "The name that needs to be fetched. Use user1 for testing. " + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/User" + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Update user + x-swagger-router-controller: UserController + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that needs to be updated + required: true + schema: + type: string + responses: + default: + description: successful operation + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: "#/components/schemas/User" + application/xml: + schema: + $ref: "#/components/schemas/User" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/User" + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + "400": + description: Invalid username supplied + "404": + description: User not found +externalDocs: + description: Find out more about Swagger + url: "http://swagger.io" +components: + schemas: + Order: + x-swagger-router-model: io.swagger.petstore.model.Order + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + maximum: 10 + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + type: object + Customer: + properties: + id: + type: integer + format: int64 + username: + type: string + address: + type: array + items: + $ref: "#/components/schemas/Address" + xml: + wrapped: true + name: addresses + xml: + name: customer + type: object + Address: + properties: + street: + type: string + city: + type: string + state: + type: string + zip: + type: string + xml: + name: address + type: object + Category: + x-swagger-router-model: io.swagger.petstore.model.Category + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: category + type: object + User: + x-swagger-router-model: io.swagger.petstore.model.User + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: user + type: object + Tag: + x-swagger-router-model: io.swagger.petstore.model.Tag + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + type: object + Pet: + x-swagger-router-model: io.swagger.petstore.model.Pet + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + name: + type: string + category: + $ref: "#/components/schemas/Category" + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: "#/components/schemas/Tag" + xml: + name: tag + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + type: object + ApiResponse: + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: "##default" + type: object + requestBodies: + Pet: + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + description: Pet object that needs to be added to the store + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + description: List of user object + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: "https://petstore.swagger.io/oauth/authorize" + scopes: + "write:pets": modify pets in your account + "read:pets": read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/packages/openapi3/test/tsp-openapi3/specs/playground-http-service/service.yml b/packages/openapi3/test/tsp-openapi3/specs/playground-http-service/service.yml new file mode 100644 index 0000000000..f64ce61d5d --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/playground-http-service/service.yml @@ -0,0 +1,206 @@ +openapi: 3.0.0 +info: + title: Widget Service + version: 0.0.0 +tags: + - name: Widgets +paths: + /widgets: + get: + tags: + - Widgets + operationId: Widgets_list + parameters: [] + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Widget" + default: + description: An unexpected error response. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + tags: + - Widgets + operationId: Widgets_create + parameters: [] + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + default: + description: An unexpected error response. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WidgetCreate" + /widgets/{id}: + get: + tags: + - Widgets + operationId: Widgets_read + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + default: + description: An unexpected error response. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + tags: + - Widgets + operationId: Widgets_update + parameters: + - $ref: "#/components/parameters/Widget.id" + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + default: + description: An unexpected error response. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WidgetUpdate" + delete: + tags: + - Widgets + operationId: Widgets_delete + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "204": + description: "There is no content to send for this request, but the headers may be useful. " + default: + description: An unexpected error response. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /widgets/{id}/analyze: + post: + tags: + - Widgets + operationId: Widgets_analyze + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + type: string + default: + description: An unexpected error response. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + parameters: + Widget.id: + name: id + in: path + required: true + schema: + type: string + schemas: + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + Widget: + type: object + required: + - id + - weight + - color + properties: + id: + type: string + weight: + type: integer + format: int32 + color: + type: string + enum: + - red + - blue + WidgetCreate: + type: object + required: + - weight + - color + properties: + weight: + type: integer + format: int32 + color: + type: string + enum: + - red + - blue + WidgetUpdate: + type: object + properties: + weight: + type: integer + format: int32 + color: + type: string + enum: + - red + - blue diff --git a/packages/openapi3/test/tsp-openapi3/specs/polymorphism/service.yml b/packages/openapi3/test/tsp-openapi3/specs/polymorphism/service.yml new file mode 100644 index 0000000000..920492e1a2 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/polymorphism/service.yml @@ -0,0 +1,66 @@ +openapi: 3.0.0 +info: + title: Polymorphism sample + version: 0.0.0 +tags: [] +paths: + /Pets: + get: + operationId: root_read + parameters: [] + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" +components: + schemas: + Cat: + type: object + required: + - kind + - meow + properties: + kind: + type: string + enum: + - cat + meow: + type: integer + format: int32 + allOf: + - $ref: "#/components/schemas/Pet" + Dog: + type: object + required: + - kind + - bark + properties: + kind: + type: string + enum: + - dog + bark: + type: string + allOf: + - $ref: "#/components/schemas/Pet" + Pet: + type: object + required: + - name + properties: + name: + type: string + weight: + type: number + format: float + kind: + type: string + description: Discriminator property for Pet. + discriminator: + propertyName: kind + mapping: + cat: "#/components/schemas/Cat" + dog: "#/components/schemas/Dog" diff --git a/packages/openapi3/test/tsp-openapi3/specs/status-code-changes/service.yml b/packages/openapi3/test/tsp-openapi3/specs/status-code-changes/service.yml new file mode 100644 index 0000000000..fd36544fe8 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/specs/status-code-changes/service.yml @@ -0,0 +1,30 @@ +openapi: 3.0.0 +info: + title: (title) + version: 0.0.0 +tags: [] +paths: + /: + get: + operationId: extensive + parameters: [] + responses: + "200": + description: The request has succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + 4XX: + description: Client error + 5XX: + description: Server error +components: + schemas: + Pet: + type: object + required: + - name + properties: + name: + type: string diff --git a/packages/openapi3/test/tsp-openapi3/utils/generate-typespec.ts b/packages/openapi3/test/tsp-openapi3/utils/generate-typespec.ts new file mode 100644 index 0000000000..396c6a0b80 --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/utils/generate-typespec.ts @@ -0,0 +1,56 @@ +import { joinPaths } from "@typespec/compiler"; +import { spawnSync } from "child_process"; +import { readdir } from "fs/promises"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export async function generateTypeSpec(folder: string) { + const testRoot = joinPaths(__dirname, "..", folder); + + const testDir = await readdir(testRoot); + if (!testDir.length) { + throw new Error(`No files found in ${testRoot}`); + } + + // Check for yml or json file for the service + const serviceEntry = testDir.includes("service.yml") + ? "service.yml" + : testDir.includes("service.json") + ? "service.json" + : ""; + + if (!serviceEntry) { + throw new Error(`Could not find "service.(yml|json)" in ${testRoot}`); + } + + const args = [ + resolve(__dirname, "..", "..", "..", "cmd", "openapi3-to-tsp.js"), + "compile", + "--output-dir", + joinPaths(testRoot, "tsp"), + joinPaths(testRoot, serviceEntry), + ]; + + const spawn = spawnSync("node", args); + if (spawn.status !== 0) { + throw new Error( + `Generation failed, command:\n openapi3-to-tsp ${args.join(" ")}\nStdout:\n${spawn.stdout}\nStderr:\n${spawn.stderr}` + ); + } +} + +async function main() { + const root = joinPaths(__dirname, ".."); + const folders = (await readdir(root)).filter((d) => d !== "utils"); + + for (const folder of folders) { + await generateTypeSpec(folder); + } +} + +main().catch((e) => { + // eslint-disable-next-line no-console + console.error(e); +}); diff --git a/packages/openapi3/test/tsp-openapi3/utils/spec-snapshot-testing.ts b/packages/openapi3/test/tsp-openapi3/utils/spec-snapshot-testing.ts new file mode 100644 index 0000000000..8de68eab9f --- /dev/null +++ b/packages/openapi3/test/tsp-openapi3/utils/spec-snapshot-testing.ts @@ -0,0 +1,189 @@ +import { + CompilerHost, + NodeHost, + getDirectoryPath, + getRelativePathFromDirectory, + joinPaths, + resolvePath, +} from "@typespec/compiler"; +import { fail, ok, strictEqual } from "assert"; +import { readdirSync } from "fs"; +import { mkdir, readFile, readdir, rm, writeFile } from "fs/promises"; +import { File, Suite, afterAll, beforeAll, it } from "vitest"; +import { convertAction } from "../../../src/cli/actions/convert/convert.js"; + +const shouldUpdateSnapshots = process.env.RECORD === "true"; + +export interface SpecSnapshotTestOptions { + /** Spec root directory. */ + specDir: string; + + /** Output directory for snapshots. */ + outputDir: string; +} + +export interface TestContext { + runCount: number; + registerSnapshot(filename: string): void; +} +export function defineSpecSnaphotTests(config: SpecSnapshotTestOptions) { + const specs = resolveSpecs(config); + let existingSnapshots: string[]; + const writtenSnapshots: string[] = []; + const context = { + runCount: 0, + registerSnapshot(filename: string) { + writtenSnapshots.push(filename); + }, + }; + beforeAll(async () => { + existingSnapshots = await readFilesInDirRecursively(config.outputDir); + }); + + afterAll(async function (context: Readonly) { + if (context.tasks.some((x) => x.mode === "skip")) { + return; // Not running the full test suite, so don't bother checking snapshots. + } + + const missingSnapshots = new Set(existingSnapshots); + for (const writtenSnapshot of writtenSnapshots) { + missingSnapshots.delete(writtenSnapshot); + } + if (missingSnapshots.size > 0) { + if (shouldUpdateSnapshots) { + for (const file of [...missingSnapshots].map((x) => joinPaths(config.outputDir, x))) { + await rm(file); + } + } else { + const snapshotList = [...missingSnapshots].map((x) => ` ${x}`).join("\n"); + fail( + `The following snapshot are still present in the output dir but were not generated:\n${snapshotList}\n Run with RECORD=true to regenerate them.` + ); + } + } + }); + specs.forEach((specs) => defineSpecSnaphotTest(context, config, specs)); +} + +function defineSpecSnaphotTest(context: TestContext, config: SpecSnapshotTestOptions, spec: Spec) { + it(spec.name, async () => { + context.runCount++; + const host = createSpecSnapshotTestHost(config); + + const outputDir = resolvePath(config.outputDir, spec.name); + + await convertAction(host as any, { "output-dir": outputDir, path: spec.fullPath }); + + if (shouldUpdateSnapshots) { + try { + await host.rm(outputDir, { recursive: true }); + } catch (e) {} + await mkdir(outputDir, { recursive: true }); + + for (const [snapshotPath, content] of host.outputs.entries()) { + const relativePath = getRelativePathFromDirectory(outputDir, snapshotPath, false); + + try { + await mkdir(getDirectoryPath(snapshotPath), { recursive: true }); + await writeFile(snapshotPath, content); + context.registerSnapshot(resolvePath(spec.name, relativePath)); + } catch (e) { + throw new Error(`Failure to write snapshot: "${snapshotPath}"\n Error: ${e}`); + } + } + } else { + for (const [snapshotPath, content] of host.outputs.entries()) { + const relativePath = getRelativePathFromDirectory(outputDir, snapshotPath, false); + let existingContent; + try { + existingContent = await readFile(snapshotPath); + } catch (e: unknown) { + if (isEnoentError(e)) { + fail(`Snapshot "${snapshotPath}" is missing. Run with RECORD=true to regenerate it.`); + } + throw e; + } + context.registerSnapshot(resolvePath(spec.name, relativePath)); + strictEqual(content, existingContent.toString()); + } + + for (const filename of await readFilesInDirRecursively(outputDir)) { + const snapshotPath = resolvePath(outputDir, filename); + ok( + host.outputs.has(snapshotPath), + `Snapshot for "${snapshotPath}" was not emitted. Run with RECORD=true to remove it.` + ); + } + } + }); +} + +interface SpecSnapshotTestHost extends CompilerHost { + outputs: Map; +} + +function createSpecSnapshotTestHost(config: SpecSnapshotTestOptions): SpecSnapshotTestHost { + const outputs = new Map(); + return { + ...NodeHost, + outputs, + mkdirp: (path: string) => Promise.resolve(path), + rm: (path: string) => Promise.resolve(), + writeFile: async (path: string, content: string) => { + outputs.set(path, content); + }, + }; +} +async function readFilesInDirRecursively(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (e) { + if (isEnoentError(e)) { + return []; + } else { + throw new Error(`Failed to read dir "${dir}"\n Error: ${e}`); + } + } + const files: string[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + for (const file of await readFilesInDirRecursively(resolvePath(dir, entry.name))) { + files.push(resolvePath(entry.name, file)); + } + } else { + files.push(entry.name); + } + } + return files; +} + +interface Spec { + name: string; + /** Spec folder */ + fullPath: string; +} + +function resolveSpecs(config: SpecSnapshotTestOptions): Spec[] { + const specs: Spec[] = []; + walk(""); + return specs; + + function walk(relativeDir: string) { + const fullDir = joinPaths(config.specDir, relativeDir); + for (const entry of readdirSync(fullDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + walk(joinPaths(relativeDir, entry.name)); + } else if (relativeDir && (entry.name === "service.yml" || entry.name === "service.json")) { + specs.push({ + name: relativeDir, + fullPath: joinPaths(config.specDir, relativeDir, entry.name), + }); + } + } + } +} + +function isEnoentError(e: unknown): e is { code: "ENOENT" } { + return typeof e === "object" && e !== null && "code" in e; +} diff --git a/packages/website/sidebars.ts b/packages/website/sidebars.ts index 28eb23916b..5565017104 100644 --- a/packages/website/sidebars.ts +++ b/packages/website/sidebars.ts @@ -143,6 +143,7 @@ const sidebars: SidebarsConfig = { createLibraryReferenceStructure("emitters/json-schema", "JSON Schema", false, []), createLibraryReferenceStructure("emitters/openapi3", "OpenAPI3", false, [ "emitters/openapi3/openapi", + "emitters/openapi3/cli", "emitters/openapi3/diagnostics", ]), createLibraryReferenceStructure("emitters/protobuf", "Protobuf", false, [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6446cc165f..ea764a6457 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,7 +123,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/bundle-uploader: dependencies: @@ -172,7 +172,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/bundler: dependencies: @@ -233,7 +233,7 @@ importers: version: 5.3.2(@types/node@18.11.19) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/compiler: dependencies: @@ -327,7 +327,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) vscode-oniguruma: specifier: ~2.0.1 version: 2.0.1 @@ -373,7 +373,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/html-program-viewer: dependencies: @@ -425,7 +425,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/http: devDependencies: @@ -458,7 +458,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/internal-build-utils: dependencies: @@ -507,7 +507,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/json-schema: dependencies: @@ -553,7 +553,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/library-linter: devDependencies: @@ -580,7 +580,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/monarch: dependencies: @@ -650,10 +650,13 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/openapi3: dependencies: + '@readme/openapi-parser': + specifier: ~2.6.0 + version: 2.6.0(openapi-types@12.1.3) yaml: specifier: ~2.4.5 version: 2.4.5 @@ -661,6 +664,9 @@ importers: '@types/node': specifier: ~18.11.19 version: 18.11.19 + '@types/yargs': + specifier: ~17.0.32 + version: 17.0.32 '@typespec/compiler': specifier: workspace:~ version: link:../compiler @@ -691,6 +697,9 @@ importers: c8: specifier: ^10.1.2 version: 10.1.2 + cross-env: + specifier: ~7.0.3 + version: 7.0.3 rimraf: specifier: ~5.0.7 version: 5.0.7 @@ -699,7 +708,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/playground: dependencies: @@ -926,7 +935,7 @@ importers: version: 5.3.2(@types/node@18.11.19) vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/prettier-plugin-typespec: dependencies: @@ -993,7 +1002,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/rest: devDependencies: @@ -1029,7 +1038,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/samples: dependencies: @@ -1087,7 +1096,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/spec: devDependencies: @@ -1176,7 +1185,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/typespec-vs: devDependencies: @@ -1233,7 +1242,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) vscode-languageclient: specifier: ~9.0.1 version: 9.0.1 @@ -1269,7 +1278,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages/website: dependencies: @@ -1441,7 +1450,7 @@ importers: version: 5.5.3 vitest: specifier: ^1.6.0 - version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + version: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) packages: @@ -1614,6 +1623,10 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + /@apidevtools/swagger-methods@3.0.2: + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + dev: false + /@aw-web-design/x-default-browser@1.4.126: resolution: {integrity: sha512-Xk1sIhyNC/esHGGVjL/niHLowM0csl/kFO5uawBy4IrWwy0o1G8LGt3jP6nmWGz+USxeeqbihAmp/oVZju6wug==} hasBin: true @@ -6553,6 +6566,11 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} + /@humanwhocodes/momoa@2.0.4: + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + dev: false + /@humanwhocodes/object-schema@2.0.3: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} @@ -6645,6 +6663,10 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@jsdevtools/ono@7.1.3: + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + dev: false + /@leichtgewicht/ip-codec@2.0.5: resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} dev: false @@ -7535,6 +7557,53 @@ packages: /@polka/url@1.0.0-next.25: resolution: {integrity: sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==} + /@readme/better-ajv-errors@1.6.0(ajv@8.16.0): + resolution: {integrity: sha512-9gO9rld84Jgu13kcbKRU+WHseNhaVt76wYMeRDGsUGYxwJtI3RmEJ9LY9dZCYQGI8eUZLuxb5qDja0nqklpFjQ==} + engines: {node: '>=14'} + peerDependencies: + ajv: 4.11.8 - 8 + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/runtime': 7.24.1 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.16.0 + chalk: 4.1.2 + json-to-ast: 2.1.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + dev: false + + /@readme/json-schema-ref-parser@1.2.0: + resolution: {integrity: sha512-Bt3QVovFSua4QmHa65EHUmh2xS0XJ3rgTEUPH998f4OW4VVJke3BuS16f+kM0ZLOGdvIrzrPRqwihuv5BAjtrA==} + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.1.0 + dev: false + + /@readme/openapi-parser@2.6.0(openapi-types@12.1.3): + resolution: {integrity: sha512-pyFJXezWj9WI1O+gdp95CoxfY+i+Uq3kKk4zXIFuRAZi9YnHpHOpjumWWr67wkmRTw19Hskh9spyY0Iyikf3fA==} + engines: {node: '>=18'} + peerDependencies: + openapi-types: '>=7' + dependencies: + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + '@readme/better-ajv-errors': 1.6.0(ajv@8.16.0) + '@readme/json-schema-ref-parser': 1.2.0 + '@readme/openapi-schemas': 3.1.0 + ajv: 8.16.0 + ajv-draft-04: 1.0.0(ajv@8.16.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + dev: false + + /@readme/openapi-schemas@3.1.0: + resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==} + engines: {node: '>=18'} + dev: false + /@rollup/plugin-alias@5.1.0(rollup@4.18.0): resolution: {integrity: sha512-lpA3RZ9PdIG7qqhEfv79tBffNaoDuukFDrmhLqg9ifv99u/ehn+lOg30x2zmhf8AQqQUZaMk/B9fZraQ6/acDQ==} engines: {node: '>=14.0.0'} @@ -8031,7 +8100,7 @@ packages: jscodeshift: 0.15.2(@babel/preset-env@7.24.5) leven: 3.1.0 ora: 5.4.1 - prettier: 3.3.2 + prettier: 3.2.5 prompts: 2.4.2 read-pkg-up: 7.0.1 semver: 7.6.2 @@ -8070,7 +8139,7 @@ packages: globby: 14.0.2 jscodeshift: 0.15.2(@babel/preset-env@7.24.5) lodash: 4.17.21 - prettier: 3.3.2 + prettier: 3.2.5 recast: 0.23.7 tiny-invariant: 1.3.3 transitivePeerDependencies: @@ -9465,7 +9534,7 @@ packages: std-env: 3.7.0 strip-literal: 2.1.0 test-exclude: 6.0.0 - vitest: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + vitest: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) transitivePeerDependencies: - supports-color dev: true @@ -9512,7 +9581,7 @@ packages: pathe: 1.1.2 picocolors: 1.0.1 sirv: 2.0.4 - vitest: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0) + vitest: 1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3) dev: true /@vitest/utils@1.6.0: @@ -9968,6 +10037,17 @@ packages: indent-string: 5.0.0 dev: true + /ajv-draft-04@1.0.0(ajv@8.16.0): + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.16.0 + dev: false + /ajv-formats@2.1.1(ajv@8.16.0): resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -10694,6 +10774,10 @@ packages: get-intrinsic: 1.2.4 set-function-length: 1.2.2 + /call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + dev: false + /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -10991,6 +11075,11 @@ packages: engines: {node: '>=16'} dev: true + /code-error-fragment@0.0.230: + resolution: {integrity: sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==} + engines: {node: '>= 4'} + dev: false + /collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -13834,6 +13923,10 @@ packages: '@esfx/disposable': 1.0.0 dev: true + /grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + dev: false + /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -15083,6 +15176,14 @@ packages: /json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + /json-to-ast@2.1.0: + resolution: {integrity: sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==} + engines: {node: '>= 4'} + dependencies: + code-error-fragment: 0.0.230 + grapheme-splitter: 1.0.4 + dev: false + /json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -15117,6 +15218,11 @@ packages: engines: {'0': node >= 0.2.0} dev: true + /jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + dev: false + /jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -16837,7 +16943,7 @@ packages: dependencies: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 - npm-package-arg: 11.0.2 + npm-package-arg: 11.0.1 semver: 7.6.2 dev: true @@ -16851,7 +16957,7 @@ packages: minipass-fetch: 3.0.4 minipass-json-stream: 1.0.1 minizlib: 2.1.2 - npm-package-arg: 11.0.2 + npm-package-arg: 11.0.1 proc-log: 4.0.0 transitivePeerDependencies: - supports-color @@ -17017,6 +17123,10 @@ packages: is-docker: 2.2.1 is-wsl: 2.2.0 + /openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + dev: false + /opener@1.5.2: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true @@ -17942,6 +18052,12 @@ packages: typescript: 5.5.3 dev: true + /prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + dev: true + /prettier@3.3.2: resolution: {integrity: sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==} engines: {node: '>=14'} @@ -18855,7 +18971,6 @@ packages: /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -20837,63 +20952,6 @@ packages: fsevents: 2.3.3 dev: true - /vitest@1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0): - resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 1.6.0 - '@vitest/ui': 1.6.0 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - dependencies: - '@types/node': 18.11.19 - '@vitest/expect': 1.6.0 - '@vitest/runner': 1.6.0 - '@vitest/snapshot': 1.6.0 - '@vitest/spy': 1.6.0 - '@vitest/ui': 1.6.0(vitest@1.6.0) - '@vitest/utils': 1.6.0 - acorn-walk: 8.3.2 - chai: 4.4.1 - debug: 4.3.4 - execa: 8.0.1 - local-pkg: 0.5.0 - magic-string: 0.30.8 - pathe: 1.1.2 - picocolors: 1.0.1 - std-env: 3.7.0 - strip-literal: 2.1.0 - tinybench: 2.6.0 - tinypool: 0.8.3 - vite: 5.2.11(@types/node@18.11.19) - vite-node: 1.6.0(@types/node@18.11.19) - why-is-node-running: 2.2.2 - transitivePeerDependencies: - - less - - lightningcss - - sass - - stylus - - sugarss - - supports-color - - terser - dev: true - /vitest@1.6.0(@types/node@18.11.19)(@vitest/ui@1.6.0)(happy-dom@14.12.3): resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -20939,7 +20997,7 @@ packages: strip-literal: 2.1.0 tinybench: 2.6.0 tinypool: 0.8.3 - vite: 5.3.2(@types/node@18.11.19) + vite: 5.2.11(@types/node@18.11.19) vite-node: 1.6.0(@types/node@18.11.19) why-is-node-running: 2.2.2 transitivePeerDependencies: