From 001a4ee4fca6f610255b94eab72490c3c661e5d6 Mon Sep 17 00:00:00 2001 From: Tom Brennan Date: Tue, 11 Mar 2025 08:16:29 -0400 Subject: [PATCH] initial project setup --- .gitignore | 2 + .nvmrc | 1 + .vscode/settings.json | 12 + LICENSE | 21 + build-helpers.ts | 170 ++ package-lock.json | 4207 +++++++++++++++++++++++++++++ package.json | 71 + src/common/declaration.ts | 52 + src/common/documentation.ts | 26 + src/common/enum.ts | 28 + src/common/interface.ts | 264 ++ src/common/model.ts | 160 ++ src/common/namespace.ts | 243 ++ src/common/reference.ts | 319 +++ src/common/scalar.ts | 173 ++ src/common/serialization/index.ts | 124 + src/common/serialization/json.ts | 444 +++ src/common/union.ts | 76 + src/ctx.ts | 527 ++++ src/helpers/header.ts | 55 + src/helpers/http.ts | 113 + src/helpers/multipart.ts | 228 ++ src/helpers/router.ts | 238 ++ src/http/index.ts | 81 + src/http/server/index.ts | 548 ++++ src/http/server/multipart.ts | 272 ++ src/http/server/router.ts | 686 +++++ src/index.ts | 62 + src/lib.ts | 141 + src/scripts/scaffold/bin.mts | 781 ++++++ src/testing/index.ts | 10 + src/util/case.ts | 182 ++ src/util/differentiate.ts | 957 +++++++ src/util/error.ts | 28 + src/util/extends.ts | 43 + src/util/iter.ts | 85 + src/util/keywords.ts | 90 + src/util/name.ts | 33 + src/util/once-queue.ts | 55 + src/util/openapi3.ts | 53 + src/util/pluralism.ts | 37 + src/util/scope.ts | 211 ++ src/write.ts | 88 + test/header.test.ts | 26 + test/multipart.test.ts | 169 ++ tsconfig.base.json | 29 + tsconfig.json | 14 + vitest.config.ts | 25 + 48 files changed, 12260 insertions(+) create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 build-helpers.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/common/declaration.ts create mode 100644 src/common/documentation.ts create mode 100644 src/common/enum.ts create mode 100644 src/common/interface.ts create mode 100644 src/common/model.ts create mode 100644 src/common/namespace.ts create mode 100644 src/common/reference.ts create mode 100644 src/common/scalar.ts create mode 100644 src/common/serialization/index.ts create mode 100644 src/common/serialization/json.ts create mode 100644 src/common/union.ts create mode 100644 src/ctx.ts create mode 100644 src/helpers/header.ts create mode 100644 src/helpers/http.ts create mode 100644 src/helpers/multipart.ts create mode 100644 src/helpers/router.ts create mode 100644 src/http/index.ts create mode 100644 src/http/server/index.ts create mode 100644 src/http/server/multipart.ts create mode 100644 src/http/server/router.ts create mode 100644 src/index.ts create mode 100644 src/lib.ts create mode 100644 src/scripts/scaffold/bin.mts create mode 100644 src/testing/index.ts create mode 100644 src/util/case.ts create mode 100644 src/util/differentiate.ts create mode 100644 src/util/error.ts create mode 100644 src/util/extends.ts create mode 100644 src/util/iter.ts create mode 100644 src/util/keywords.ts create mode 100644 src/util/name.ts create mode 100644 src/util/once-queue.ts create mode 100644 src/util/openapi3.ts create mode 100644 src/util/pluralism.ts create mode 100644 src/util/scope.ts create mode 100644 src/write.ts create mode 100644 test/header.test.ts create mode 100644 test/multipart.test.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00e9e2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +generated-* \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca8f932 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +23.9.0 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6cbbc66 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "prettier.tabWidth": 4 +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2107107 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/build-helpers.ts b/build-helpers.ts new file mode 100644 index 0000000..2f8c618 --- /dev/null +++ b/build-helpers.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/* eslint no-console: "off" */ + +import fs from "node:fs/promises"; +import path from "node:path"; + +const HELPER_DECLARATION_PATH = path.resolve("generated-defs", "helpers"); +const HELPER_SRC_PATH = path.resolve("src", "helpers"); + +console.log("Building JS server generator helpers."); + +async function* visitAllFiles(base: string): AsyncIterable { + const contents = await fs.readdir(base, { withFileTypes: true }); + + for (const entry of contents) { + if (entry.isDirectory()) { + yield* visitAllFiles(path.join(base, entry.name)); + } else if (entry.isFile()) { + yield path.join(base, entry.name); + } + } +} + +async function main() { + const allFiles: string[] = []; + const indices = new Map(); + + const ctxPath = path.resolve("src", "ctx.js"); + + await fs.rm(HELPER_DECLARATION_PATH, { recursive: true, force: true }); + + function addIndex(dir: string, file: string) { + const index = indices.get(dir); + + if (index) { + index.push(file); + } else { + indices.set(dir, [file]); + } + } + + for await (const file of visitAllFiles(HELPER_SRC_PATH)) { + allFiles.push(file); + addIndex(path.dirname(file), file); + } + + for (const file of allFiles) { + if (!file.endsWith(".ts")) { + continue; + } + + const relativePath = path.relative(HELPER_SRC_PATH, file); + + console.log("Building helper:", relativePath); + + const targetPath = path.resolve(HELPER_DECLARATION_PATH, relativePath); + + const targetDir = path.dirname(targetPath); + const targetFileBase = path.basename(targetPath, ".ts"); + const isIndex = targetFileBase === "index"; + const targetBase = isIndex ? path.basename(targetDir) : targetFileBase; + await fs.mkdir(targetDir, { recursive: true }); + + const childModules = isIndex ? indices.get(path.dirname(file)) : []; + + if (isIndex) { + indices.delete(path.dirname(file)); + } + + const contents = await fs.readFile(file, "utf-8"); + + let childModuleLines = + childModules + ?.filter((m) => path.basename(m, ".ts") !== "index") + .map((child) => { + const childBase = path.basename(child, ".ts"); + return ` await import("./${childBase}.js").then((m) => m.createModule(module));`; + }) ?? []; + + if (childModuleLines.length > 0) { + childModuleLines = [" // Child modules", ...childModuleLines, ""]; + } + + const transformed = [ + "// Copyright (c) Microsoft Corporation", + "// Licensed under the MIT license.", + "", + `import { Module } from "${path.relative(targetDir, ctxPath).replace(/\\/g, "/")}";`, + "", + "export let module: Module = undefined as any;", + "", + "// prettier-ignore", + "const lines = [", + ...contents.split(/\r?\n/).map((line) => " " + JSON.stringify(line) + ","), + "];", + "", + "export async function createModule(parent: Module): Promise {", + " if (module) return module;", + "", + " module = {", + ` name: ${JSON.stringify(targetBase)},`, + ` cursor: parent.cursor.enter(${JSON.stringify(targetBase)}),`, + " imports: [],", + " declarations: [],", + " };", + "", + ...childModuleLines, + " module.declarations.push(lines);", + "", + " parent.declarations.push(module);", + "", + " return module;", + "}", + "", + ].join("\n"); + + await fs.writeFile(targetPath, transformed); + } + + console.log("Building index files."); + + for (const [dir, files] of indices.entries()) { + console.log("Building index:", dir); + + const relativePath = path.relative(HELPER_SRC_PATH, dir); + + const targetPath = path.resolve(HELPER_DECLARATION_PATH, relativePath, "index.ts"); + + const children = files.map((file) => { + return ` await import("./${path.basename(file, ".ts")}.js").then((m) => m.createModule(module));`; + }); + + const transformed = [ + "// Copyright (c) Microsoft Corporation", + "// Licensed under the MIT license.", + "", + `import { Module } from "${path.relative(path.dirname(targetPath), ctxPath).replace(/\\/g, "/")}";`, + "", + "export let module: Module = undefined as any;", + "", + "export async function createModule(parent: Module): Promise {", + " if (module) return module;", + "", + " module = {", + ` name: ${JSON.stringify(path.basename(dir))},`, + ` cursor: parent.cursor.enter(${JSON.stringify(path.basename(dir))}),`, + " imports: [],", + " declarations: [],", + " };", + "", + " // Child modules", + ...children, + "", + " parent.declarations.push(module);", + "", + " return module;", + "}", + "", + ].join("\n"); + + await fs.writeFile(targetPath, transformed); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6ab01b4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4207 @@ +{ + "name": "@pojagi/http-server-drf", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pojagi/http-server-drf", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "prettier": "~3.4.2", + "yaml": "~2.7.0" + }, + "bin": { + "hsj-scaffold": "dist/src/scripts/scaffold/bin.mjs" + }, + "devDependencies": { + "@types/node": "~22.10.10", + "@typespec/compiler": "latest", + "@typespec/http": "latest", + "@typespec/openapi3": "latest", + "@vitest/coverage-v8": "^3.0.4", + "@vitest/ui": "^3.0.3", + "tsx": "^4.19.2", + "typescript": "~5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "@typespec/compiler": "latest", + "@typespec/http": "latest", + "@typespec/openapi3": "latest" + }, + "peerDependenciesMeta": { + "@typespec/openapi3": { + "optional": true + } + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.7.2", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.11", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.3", + "@inquirer/confirm": "^5.1.7", + "@inquirer/editor": "^4.2.8", + "@inquirer/expand": "^4.0.10", + "@inquirer/input": "^4.1.7", + "@inquirer/number": "^3.0.10", + "@inquirer/password": "^4.0.10", + "@inquirer/rawlist": "^4.0.10", + "@inquirer/search": "^3.0.10", + "@inquirer/select": "^4.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.8", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "8.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^8.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^19.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^3.0.1" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git": { + "version": "6.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^20.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/pacote": { + "version": "20.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar": { + "version": "6.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/@npmcli/metavuln-calculator/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "6.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/query": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "9.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.35.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sigstore/bundle": { + "version": "3.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/core": { + "version": "2.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.4.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "3.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "3.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.10", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@typespec/compiler": { + "version": "0.66.0", + "resolved": "https://registry.npmjs.org/@typespec/compiler/-/compiler-0.66.0.tgz", + "integrity": "sha512-JoaHQCc1Va48xuiws7UHFa9ix1bxxJrjTud69mrbu67HerqLN5meLwNkqVGrOlEfUnvMcnaXNQlHbMbixmshQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.26.2", + "@inquirer/prompts": "^7.3.1", + "@npmcli/arborist": "^8.0.0", + "ajv": "~8.17.1", + "change-case": "~5.4.4", + "globby": "~14.0.2", + "is-unicode-supported": "^2.1.0", + "mustache": "~4.2.0", + "picocolors": "~1.1.1", + "prettier": "~3.4.2", + "semver": "^7.6.3", + "temporal-polyfill": "^0.2.5", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.12", + "yaml": "~2.7.0", + "yargs": "~17.7.2" + }, + "bin": { + "tsp": "cmd/tsp.js", + "tsp-server": "cmd/tsp-server.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@typespec/http": { + "version": "0.66.0", + "resolved": "https://registry.npmjs.org/@typespec/http/-/http-0.66.0.tgz", + "integrity": "sha512-IKD5FMvnjbZ5cdQ3wTXXAnnQgDcqFuPtVphpTdnYER87gGEa8YNLocgK44CLFB+GvVkTecDltG0CNKPSPQ0RMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@typespec/compiler": "~0.66.0", + "@typespec/streams": "~0.66.0" + }, + "peerDependenciesMeta": { + "@typespec/streams": { + "optional": true + } + } + }, + "node_modules/@typespec/openapi": { + "version": "0.66.0", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@typespec/compiler": "~0.66.0", + "@typespec/http": "~0.66.0" + } + }, + "node_modules/@typespec/openapi3": { + "version": "0.66.0", + "resolved": "https://registry.npmjs.org/@typespec/openapi3/-/openapi3-0.66.0.tgz", + "integrity": "sha512-tHZVF7pfHCiHVkNlnfcuh8xkXCJ9j1LDpwYAQCzuMa2uH1dg1G6UiuDDySFgqDCgBp6z1kkcgQ4G9FQEN4Iz8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "~10.1.1", + "openapi-types": "~12.1.3", + "yaml": "~2.7.0" + }, + "bin": { + "tsp-openapi3": "cmd/tsp-openapi3.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@typespec/compiler": "~0.66.0", + "@typespec/http": "~0.66.0", + "@typespec/json-schema": "~0.66.0", + "@typespec/openapi": "~0.66.0", + "@typespec/versioning": "~0.66.0" + }, + "peerDependenciesMeta": { + "@typespec/json-schema": { + "optional": true + }, + "@typespec/xml": { + "optional": true + } + } + }, + "node_modules/@typespec/versioning": { + "version": "0.66.0", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@typespec/compiler": "~0.66.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.0.8", + "vitest": "3.0.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.8", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.8", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.12", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.0.8" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.8", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abbrev": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/bin-links": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "dev": true, + "license": "MIT" + }, + "node_modules/chardet": { + "version": "0.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/common-ancestor-path": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/external-editor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "14.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hosted-git-info": { + "version": "8.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "7.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.9", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp": { + "version": "11.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-bundled": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-install-checks": { + "version": "7.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-package-arg": { + "version": "12.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-packlist": { + "version": "9.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "10.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "18.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "19.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/pacote/node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar": { + "version": "6.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/pacote/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/parse-conflict-json": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/proggy": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/read-package-json-fast": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.35.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.35.0", + "@rollup/rollup-android-arm64": "4.35.0", + "@rollup/rollup-darwin-arm64": "4.35.0", + "@rollup/rollup-darwin-x64": "4.35.0", + "@rollup/rollup-freebsd-arm64": "4.35.0", + "@rollup/rollup-freebsd-x64": "4.35.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.35.0", + "@rollup/rollup-linux-arm-musleabihf": "4.35.0", + "@rollup/rollup-linux-arm64-gnu": "4.35.0", + "@rollup/rollup-linux-arm64-musl": "4.35.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.35.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.35.0", + "@rollup/rollup-linux-riscv64-gnu": "4.35.0", + "@rollup/rollup-linux-s390x-gnu": "4.35.0", + "@rollup/rollup-linux-x64-gnu": "4.35.0", + "@rollup/rollup-linux-x64-musl": "4.35.0", + "@rollup/rollup-win32-arm64-msvc": "4.35.0", + "@rollup/rollup-win32-ia32-msvc": "4.35.0", + "@rollup/rollup-win32-x64-msvc": "4.35.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "3.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/sirv": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "12.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.8.1", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/temporal-polyfill": { + "version": "0.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "temporal-spec": "^0.2.4" + } + }, + "node_modules/temporal-spec": { + "version": "0.2.4", + "dev": true, + "license": "ISC" + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/tsx": { + "version": "4.19.3", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tuf-js": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/vite": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.8", + "@vitest/mocker": "3.0.8", + "@vitest/pretty-format": "^3.0.8", + "@vitest/runner": "3.0.8", + "@vitest/snapshot": "3.0.8", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.8", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.8", + "@vitest/ui": "3.0.8", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "dev": true, + "license": "MIT" + }, + "node_modules/walk-up-path": { + "version": "3.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/which": { + "version": "5.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yaml": { + "version": "2.7.0", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3613bbb --- /dev/null +++ b/package.json @@ -0,0 +1,71 @@ +{ + "name": "@pojagi/http-server-drf", + "version": "0.1.0", + "author": "Tom Brennan", + "description": "TypeSpec HTTP server code generator for Django Rest Framework", + "homepage": "https://git.pojagi.org/tjb1982/http-server-drf", + "readme": "https://git.pojagi.org/tjb1982/http-server-drf/src/branch/main/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://git.pojagi.org/tjb1982/http-server-drf" + }, + "bugs": { + "url": "https://git.pojagi.org/tjb1982/http-server-drf/issues" + }, + "keywords": [ + "typespec", + "http", + "server", + "javascript", + "typescript" + ], + "type": "module", + "main": "dist/src/index.js", + "exports": { + ".": "./dist/src/index.js", + "./testing": "./dist/src/testing/index.js" + }, + "bin": { + "hsj-scaffold": "./dist/src/scripts/scaffold/bin.mjs" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "npm run build:helpers && npm run build:src", + "build:src": "tsc -p ./tsconfig.json", + "build:helpers": "tsx ./build-helpers.ts", + "watch": "tsc -p . --watch", + "test": "vitest run", + "test:watch": "vitest -w", + "test:ui": "vitest --ui", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix", + "regen-docs": "echo Doc generation disabled for this package." + }, + "peerDependencies": { + "@typespec/compiler": "latest", + "@typespec/http": "latest", + "@typespec/openapi3": "latest" + }, + "peerDependenciesMeta": { + "@typespec/openapi3": { + "optional": true + } + }, + "dependencies": { + "prettier": "~3.4.2", + "yaml": "~2.7.0" + }, + "devDependencies": { + "@types/node": "~22.10.10", + "@typespec/compiler": "latest", + "@typespec/http": "latest", + "@typespec/openapi3": "latest", + "@vitest/coverage-v8": "^3.0.4", + "@vitest/ui": "^3.0.3", + "tsx": "^4.19.2", + "typescript": "~5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/src/common/declaration.ts b/src/common/declaration.ts new file mode 100644 index 0000000..cff3520 --- /dev/null +++ b/src/common/declaration.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { DeclarationType, JsContext, Module } from "../ctx.js"; +import { emitEnum } from "./enum.js"; +import { emitInterface } from "./interface.js"; +import { emitModel } from "./model.js"; +import { emitScalar } from "./scalar.js"; +import { emitUnion } from "./union.js"; + +/** + * Emit a declaration for a module based on its type. + * + * The altName is optional and is only used for unnamed models and unions. + * + * @param ctx - The emitter context. + * @param type - The type to emit. + * @param module - The module that this declaration is written into. + * @param altName - An alternative name to use for the declaration if it is not named. + */ +export function* emitDeclaration( + ctx: JsContext, + type: DeclarationType, + module: Module, + altName?: string, +): Iterable { + switch (type.kind) { + case "Model": { + yield* emitModel(ctx, type, module, altName); + break; + } + case "Enum": { + yield* emitEnum(ctx, type); + break; + } + case "Union": { + yield* emitUnion(ctx, type, module, altName); + break; + } + case "Interface": { + yield* emitInterface(ctx, type, module); + break; + } + case "Scalar": { + yield emitScalar(ctx, type); + break; + } + default: { + throw new Error(`UNREACHABLE: Unhandled type kind: ${(type satisfies never as any).kind}`); + } + } +} diff --git a/src/common/documentation.ts b/src/common/documentation.ts new file mode 100644 index 0000000..ee1bf08 --- /dev/null +++ b/src/common/documentation.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Type, getDoc } from "@typespec/compiler"; +import { JsContext } from "../ctx.js"; +import { indent } from "../util/iter.js"; + +/** + * Emit the documentation for a type in JSDoc format. + * + * This assumes that the documentation may include Markdown formatting. + * + * @param ctx - The emitter context. + * @param type - The type to emit documentation for. + */ +export function* emitDocumentation(ctx: JsContext, type: Type): Iterable { + const doc = getDoc(ctx.program, type); + + if (doc === undefined) return; + + yield `/**`; + + yield* indent(doc.trim().split(/\r?\n/g), " * "); + + yield ` */`; +} diff --git a/src/common/enum.ts b/src/common/enum.ts new file mode 100644 index 0000000..606bb77 --- /dev/null +++ b/src/common/enum.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Enum } from "@typespec/compiler"; +import { JsContext } from "../ctx.js"; +import { parseCase } from "../util/case.js"; +import { emitDocumentation } from "./documentation.js"; + +/** + * Emit an enum declaration. + * + * @param ctx - The emitter context. + * @param enum_ - The enum to emit. + */ +export function* emitEnum(ctx: JsContext, enum_: Enum): Iterable { + yield* emitDocumentation(ctx, enum_); + + const name = parseCase(enum_.name); + + yield `export enum ${name.pascalCase} {`; + + for (const member of enum_.members.values()) { + const nameCase = parseCase(member.name); + yield ` ${nameCase.pascalCase} = ${JSON.stringify(member.value)},`; + } + + yield `}`; +} diff --git a/src/common/interface.ts b/src/common/interface.ts new file mode 100644 index 0000000..b2a6a9c --- /dev/null +++ b/src/common/interface.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Interface, Operation, Type, UnionVariant, isErrorModel } from "@typespec/compiler"; +import { JsContext, Module, PathCursor } from "../ctx.js"; +import { parseCase } from "../util/case.js"; +import { getAllProperties } from "../util/extends.js"; +import { bifilter, indent } from "../util/iter.js"; +import { emitDocumentation } from "./documentation.js"; +import { emitTypeReference, isValueLiteralType } from "./reference.js"; +import { emitUnionType } from "./union.js"; + +/** + * Emit an interface declaration. + * + * @param ctx - The emitter context. + * @param iface - The interface to emit. + * @param module - The module that this interface is written into. + */ +export function* emitInterface(ctx: JsContext, iface: Interface, module: Module): Iterable { + const name = parseCase(iface.name).pascalCase; + + yield* emitDocumentation(ctx, iface); + yield `export interface ${name} {`; + yield* indent(emitOperationGroup(ctx, iface.operations.values(), module)); + yield "}"; + yield ""; +} + +/** + * Emit a list of operation signatures. + * + * @param ctx - The emitter context. + * @param operations - The operations to emit. + * @param module - The module that the operations are written into. + */ +export function* emitOperationGroup( + ctx: JsContext, + operations: Iterable, + module: Module, +): Iterable { + for (const op of operations) { + yield* emitOperation(ctx, op, module); + yield ""; + } +} + +/** + * Emit a single operation signature. + * + * @param ctx - The emitter context. + * @param op - The operation to emit. + * @param module - The module that the operation is written into. + */ +export function* emitOperation(ctx: JsContext, op: Operation, module: Module): Iterable { + const opNameCase = parseCase(op.name); + + const opName = opNameCase.camelCase; + + const allParameters = getAllProperties(op.parameters); + + const hasOptions = allParameters.some((p) => p.optional); + + const returnTypeReference = emitTypeReference(ctx, op.returnType, op, module, { + altName: opNameCase.pascalCase + "Result", + }); + + const returnType = `Promise<${returnTypeReference}>`; + + const params: string[] = []; + + for (const param of allParameters) { + // If the type is a value literal, then we consider it a _setting_ and not a parameter. + // This allows us to exclude metadata parameters (such as contentType) from the generated interface. + if (param.optional || isValueLiteralType(param.type)) continue; + + const paramNameCase = parseCase(param.name); + const paramName = paramNameCase.camelCase; + + const outputTypeReference = emitTypeReference(ctx, param.type, param, module, { + altName: opNameCase.pascalCase + paramNameCase.pascalCase, + }); + + params.push(`${paramName}: ${outputTypeReference}`); + } + + const paramsDeclarationLine = params.join(", "); + + yield* emitDocumentation(ctx, op); + + if (hasOptions) { + const optionsTypeName = opNameCase.pascalCase + "Options"; + + emitOptionsType(ctx, op, module, optionsTypeName); + + const paramsFragment = params.length > 0 ? `${paramsDeclarationLine}, ` : ""; + + // prettier-ignore + yield `${opName}(ctx: Context, ${paramsFragment}options?: ${optionsTypeName}): ${returnType};`; + yield ""; + } else { + // prettier-ignore + yield `${opName}(ctx: Context, ${paramsDeclarationLine}): ${returnType};`; + yield ""; + } +} + +/** + * Emit a declaration for an options type including the optional parameters of an operation. + * + * @param ctx - The emitter context. + * @param operation - The operation to emit the options type for. + * @param module - The module that the options type is written into. + * @param optionsTypeName - The name of the options type. + */ +export function emitOptionsType( + ctx: JsContext, + operation: Operation, + module: Module, + optionsTypeName: string, +) { + module.imports.push({ + binder: [optionsTypeName], + from: ctx.syntheticModule, + }); + + const options = [...operation.parameters.properties.values()].filter((p) => p.optional); + + ctx.syntheticModule.declarations.push([ + `export interface ${optionsTypeName} {`, + ...options.flatMap((p) => [ + ` ${parseCase(p.name).camelCase}?: ${emitTypeReference(ctx, p.type, p, module, { + altName: optionsTypeName + parseCase(p.name).pascalCase, + })};`, + ]), + "}", + "", + ]); +} + +export interface SplitReturnTypeCommon { + typeReference: string; + target: Type | [PathCursor, string] | undefined; +} + +export interface OrdinarySplitReturnType extends SplitReturnTypeCommon { + kind: "ordinary"; +} + +export interface UnionSplitReturnType extends SplitReturnTypeCommon { + kind: "union"; + variants: UnionVariant[]; +} + +export type SplitReturnType = OrdinarySplitReturnType | UnionSplitReturnType; + +const DEFAULT_NO_VARIANT_RETURN_TYPE = "never"; +const DEFAULT_NO_VARIANT_SPLIT: SplitReturnType = { + kind: "ordinary", + typeReference: DEFAULT_NO_VARIANT_RETURN_TYPE, + target: undefined, +}; + +export function isInfallible(split: SplitReturnType): boolean { + return ( + (split.kind === "ordinary" && split.typeReference === "never") || + (split.kind === "union" && split.variants.length === 0) + ); +} + +export function splitReturnType( + ctx: JsContext, + type: Type, + module: Module, + altBaseName: string, +): [SplitReturnType, SplitReturnType] { + const successAltName = altBaseName + "Response"; + const errorAltName = altBaseName + "ErrorResponse"; + + if (type.kind === "Union") { + const [successVariants, errorVariants] = bifilter( + type.variants.values(), + (v) => !isErrorModel(ctx.program, v.type), + ); + + const successTypeReference = + successVariants.length === 0 + ? DEFAULT_NO_VARIANT_RETURN_TYPE + : successVariants.length === 1 + ? emitTypeReference(ctx, successVariants[0].type, successVariants[0], module, { + altName: successAltName, + }) + : emitUnionType(ctx, successVariants, module); + + const errorTypeReference = + errorVariants.length === 0 + ? DEFAULT_NO_VARIANT_RETURN_TYPE + : errorVariants.length === 1 + ? emitTypeReference(ctx, errorVariants[0].type, errorVariants[0], module, { + altName: errorAltName, + }) + : emitUnionType(ctx, errorVariants, module); + + const successSplit: SplitReturnType = + successVariants.length > 1 + ? { + kind: "union", + variants: successVariants, + typeReference: successTypeReference, + target: undefined, + } + : { + kind: "ordinary", + typeReference: successTypeReference, + target: successVariants[0]?.type, + }; + + const errorSplit: SplitReturnType = + errorVariants.length > 1 + ? { + kind: "union", + variants: errorVariants, + typeReference: errorTypeReference, + // target: module.cursor.resolveRelativeItemPath(errorTypeReference), + target: undefined, + } + : { + kind: "ordinary", + typeReference: errorTypeReference, + target: errorVariants[0]?.type, + }; + + return [successSplit, errorSplit]; + } else { + // No splitting, just figure out if the type is an error type or not and make the other infallible. + + if (isErrorModel(ctx.program, type)) { + const typeReference = emitTypeReference(ctx, type, type, module, { + altName: altBaseName + "ErrorResponse", + }); + + return [ + DEFAULT_NO_VARIANT_SPLIT, + { + kind: "ordinary", + typeReference, + target: type, + }, + ]; + } else { + const typeReference = emitTypeReference(ctx, type, type, module, { + altName: altBaseName + "SuccessResponse", + }); + return [ + { + kind: "ordinary", + typeReference, + target: type, + }, + DEFAULT_NO_VARIANT_SPLIT, + ]; + } + } +} diff --git a/src/common/model.ts b/src/common/model.ts new file mode 100644 index 0000000..3b0ac93 --- /dev/null +++ b/src/common/model.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { + Model, + getFriendlyName, + isTemplateDeclaration, + isTemplateInstance, +} from "@typespec/compiler"; +import { JsContext, Module } from "../ctx.js"; +import { isUnspeakable, parseCase } from "../util/case.js"; +import { indent } from "../util/iter.js"; +import { KEYWORDS } from "../util/keywords.js"; +import { getFullyQualifiedTypeName } from "../util/name.js"; +import { asArrayType, getArrayElementName, getRecordValueName } from "../util/pluralism.js"; +import { emitDocumentation } from "./documentation.js"; +import { emitTypeReference } from "./reference.js"; + +/** + * Emit a model declaration. + * + * @param ctx - The emitter context. + * @param model - The model to emit. + * @param module - The module that this model is written into. + * @param altName - An alternative name to use for the model if it is not named. + */ +export function* emitModel( + ctx: JsContext, + model: Model, + module: Module, + altName?: string, +): Iterable { + const isTemplate = isTemplateInstance(model); + const friendlyName = getFriendlyName(ctx.program, model); + + if (isTemplateDeclaration(model)) { + return; + } + + const modelNameCase = parseCase( + friendlyName + ? friendlyName + : isTemplate + ? model.templateMapper!.args.map((a) => ("name" in a ? String(a.name) : "")).join("_") + + model.name + : model.name, + ); + + if (model.name === "" && !altName) { + throw new Error("UNREACHABLE: Anonymous model with no altName"); + } + + yield* emitDocumentation(ctx, model); + + const ifaceName = model.name === "" ? altName! : modelNameCase.pascalCase; + + const extendsClause = model.baseModel + ? `extends ${emitTypeReference(ctx, model.baseModel, model, module)} ` + : ""; + + yield `export interface ${ifaceName} ${extendsClause}{`; + + for (const field of model.properties.values()) { + // Skip properties with unspeakable names. + if (isUnspeakable(field.name)) { + continue; + } + + const nameCase = parseCase(field.name); + const basicName = nameCase.camelCase; + + const typeReference = emitTypeReference(ctx, field.type, field, module, { + altName: modelNameCase.pascalCase + nameCase.pascalCase, + }); + + const name = KEYWORDS.has(basicName) ? `_${basicName}` : basicName; + + yield* indent(emitDocumentation(ctx, field)); + + const questionMark = field.optional ? "?" : ""; + + yield ` ${name}${questionMark}: ${typeReference};`; + yield ""; + } + + yield "}"; + yield ""; +} + +export function emitModelLiteral(ctx: JsContext, model: Model, module: Module): string { + const properties = [...model.properties.values()] + .map((prop) => { + if (isUnspeakable(prop.name)) { + return undefined; + } + + const nameCase = parseCase(prop.name); + const questionMark = prop.optional ? "?" : ""; + + const name = KEYWORDS.has(nameCase.camelCase) ? `_${nameCase.camelCase}` : nameCase.camelCase; + + return `${name}${questionMark}: ${emitTypeReference(ctx, prop.type, prop, module)}`; + }) + .filter((p) => !!p); + + return `{ ${properties.join("; ")} }`; +} + +/** + * Determines whether a model is an instance of a well-known model, such as TypeSpec.Record or TypeSpec.Array. + */ +export function isWellKnownModel(ctx: JsContext, type: Model): boolean { + const fullName = getFullyQualifiedTypeName(type); + return ["TypeSpec.Record", "TypeSpec.Array", "TypeSpec.Http.HttpPart"].includes(fullName); +} + +/** + * Emits a well-known model, such as TypeSpec.Record or TypeSpec.Array. + * + * @param ctx - The emitter context. + * @param type - The model to emit. + * @param module - The module that this model is written into. + * @param preferredAlternativeName - An alternative name to use for the model if it is not named. + */ +export function emitWellKnownModel( + ctx: JsContext, + type: Model, + module: Module, + preferredAlternativeName?: string, +): string { + switch (type.name) { + case "Record": { + const arg = type.indexer!.value; + return `{ [k: string]: ${emitTypeReference(ctx, arg, type, module, { + altName: preferredAlternativeName && getRecordValueName(preferredAlternativeName), + })} }`; + } + case "Array": { + const arg = type.indexer!.value; + return asArrayType( + emitTypeReference(ctx, arg, type, module, { + altName: preferredAlternativeName && getArrayElementName(preferredAlternativeName), + }), + ); + } + case "HttpPart": { + const argument = type.templateMapper!.args[0]; + + if (!(argument.entityKind === "Type" && argument.kind === "Model")) { + throw new Error("UNREACHABLE: HttpPart must have a Model argument"); + } + + return emitTypeReference(ctx, argument, type, module, { + altName: preferredAlternativeName && `${preferredAlternativeName}HttpPart`, + }); + } + default: + throw new Error(`UNREACHABLE: ${type.name}`); + } +} diff --git a/src/common/namespace.ts b/src/common/namespace.ts new file mode 100644 index 0000000..939fac7 --- /dev/null +++ b/src/common/namespace.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Namespace, getNamespaceFullName } from "@typespec/compiler"; +import { + DeclarationType, + JsContext, + Module, + ModuleBodyDeclaration, + createModule, + isModule, +} from "../ctx.js"; +import { parseCase } from "../util/case.js"; +import { UnimplementedError } from "../util/error.js"; +import { cat, indent, isIterable } from "../util/iter.js"; +import { OnceQueue } from "../util/once-queue.js"; +import { emitOperationGroup } from "./interface.js"; + +/** + * Enqueue all declarations in the namespace to be included in the emit, recursively. + * + * @param ctx - The emitter context. + * @param namespace - The root namespace to begin traversing. + */ +export function visitAllTypes(ctx: JsContext, namespace: Namespace) { + const { enums, interfaces, models, unions, namespaces, scalars, operations } = namespace; + + for (const type of cat( + enums.values(), + interfaces.values(), + models.values(), + unions.values(), + scalars.values(), + )) { + ctx.typeQueue.add(type); + } + + for (const ns of namespaces.values()) { + visitAllTypes(ctx, ns); + } + + if (operations.size > 0) { + // If the operation has any floating operations in it, we will synthesize an interface for them in the parent module. + // This requires some special handling by other parts of the emitter to ensure that the interface for a namespace's + // own operations is properly imported. + if (!namespace.namespace) { + throw new UnimplementedError("no parent namespace in visitAllTypes"); + } + + const parentModule = createOrGetModuleForNamespace(ctx, namespace.namespace); + + parentModule.declarations.push([ + // prettier-ignore + `/** An interface representing the operations defined in the '${getNamespaceFullName(namespace)}' namespace. */`, + `export interface ${parseCase(namespace.name).pascalCase} {`, + ...indent(emitOperationGroup(ctx, operations.values(), parentModule)), + "}", + ]); + } +} + +/** + * Create a module for a namespace, or get an existing module if one has already been created. + * + * @param ctx - The emitter context. + * @param namespace - The namespace to create a module for. + * @returns the module for the namespace. + */ +export function createOrGetModuleForNamespace( + ctx: JsContext, + namespace: Namespace, + root: Module = ctx.globalNamespaceModule, +): Module { + if (ctx.namespaceModules.has(namespace)) { + return ctx.namespaceModules.get(namespace)!; + } + + if (!namespace.namespace) { + throw new Error("UNREACHABLE: no parent namespace in createOrGetModuleForNamespace"); + } + + const parent = + namespace.namespace === ctx.globalNamespace + ? root + : createOrGetModuleForNamespace(ctx, namespace.namespace); + const name = namespace.name === "TypeSpec" ? "typespec" : parseCase(namespace.name).kebabCase; + + const module: Module = createModule(name, parent, namespace); + + ctx.namespaceModules.set(namespace, module); + + return module; +} + +/** + * Get a reference to the interface representing the namespace's floating operations. + * + * This does not check that such an interface actually exists, so it should only be called in situations where it is + * known to exist (for example, if an operation comes from the namespace). + * + * @param ctx - The emitter context. + * @param namespace - The namespace to get the interface reference for. + * @param module - The module the the reference will be written to. + */ +export function emitNamespaceInterfaceReference( + ctx: JsContext, + namespace: Namespace, + module: Module, +): string { + if (!namespace.namespace) { + throw new Error("UNREACHABLE: no parent namespace in emitNamespaceInterfaceReference"); + } + + const namespaceName = parseCase(namespace.name).pascalCase; + + module.imports.push({ + binder: [namespaceName], + from: createOrGetModuleForNamespace(ctx, namespace.namespace), + }); + + return namespaceName; +} + +/** + * Emits a single declaration within a module. If the declaration is a module, it is enqueued for later processing. + * + * @param ctx - The emitter context. + * @param decl - The declaration to emit. + * @param queue - The queue to add the declaration to if it is a module. + */ +function* emitModuleBodyDeclaration( + ctx: JsContext, + decl: ModuleBodyDeclaration, + queue: OnceQueue, +): Iterable { + if (isIterable(decl)) { + yield* decl; + } else if (typeof decl === "string") { + yield* decl.split(/\r?\n/); + } else { + if (decl.declarations.length > 0) { + queue.add(decl); + } + } +} + +/** + * Gets a file path from a given module to another module. + */ +function computeRelativeFilePath(from: Module, to: Module): string { + const fromIsIndex = from.declarations.some((d) => isModule(d)); + const toIsIndex = to.declarations.some((d) => isModule(d)); + + const relativePath = (fromIsIndex ? from.cursor : from.cursor.parent!).relativePath(to.cursor); + + if (relativePath.length === 0 && !toIsIndex) + throw new Error("UNREACHABLE: relativePath returned no fragments"); + + if (relativePath.length === 0) return "./index.js"; + + const prefix = relativePath[0] === ".." ? "" : "./"; + + const suffix = toIsIndex ? "/index.js" : ".js"; + + return prefix + relativePath.join("/") + suffix; +} + +/** + * Deduplicates, consolidates, and writes the import statements for a module. + */ +function* writeImportsNormalized(ctx: JsContext, module: Module): Iterable { + const allTargets = new Set(); + const importMap = new Map>(); + const starAsMap = new Map(); + const extraStarAs: [string, string][] = []; + + for (const _import of module.imports) { + // check for same module and continue + if (_import.from === module) continue; + + const target = + typeof _import.from === "string" + ? _import.from + : computeRelativeFilePath(module, _import.from); + + allTargets.add(target); + + if (typeof _import.binder === "string") { + if (starAsMap.has(target)) { + extraStarAs.push([_import.binder, target]); + } else { + starAsMap.set(target, _import.binder); + } + } else { + const binders = importMap.get(target) ?? new Set(); + for (const binder of _import.binder) { + binders.add(binder); + } + importMap.set(target, binders); + } + } + + for (const target of allTargets) { + const binders = importMap.get(target); + const starAs = starAsMap.get(target); + + if (binders && starAs) { + yield `import ${starAs}, { ${[...binders].join(", ")} } from "${target}";`; + } else if (binders) { + yield `import { ${[...binders].join(", ")} } from "${target}";`; + } else if (starAs) { + yield `import ${starAs} from "${target}";`; + } + + yield ""; + } + + for (const [binder, target] of extraStarAs) { + yield `import ${binder} from "${target}";`; + } +} + +/** + * Emits the body of a module file. + * + * @param ctx - The emitter context. + * @param module - The module to emit. + * @param queue - The queue to add any submodules to for later processing. + */ +export function* emitModuleBody( + ctx: JsContext, + module: Module, + queue: OnceQueue, +): Iterable { + yield* writeImportsNormalized(ctx, module); + + if (module.imports.length > 0) yield ""; + + for (const decl of module.declarations) { + yield* emitModuleBodyDeclaration(ctx, decl, queue); + yield ""; + } +} diff --git a/src/common/reference.ts b/src/common/reference.ts new file mode 100644 index 0000000..2652985 --- /dev/null +++ b/src/common/reference.ts @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { + DiagnosticTarget, + IntrinsicType, + LiteralType, + Namespace, + NoTarget, + Type, + compilerAssert, + getEffectiveModelType, + getFriendlyName, + isArrayModelType, +} from "@typespec/compiler"; +import { JsContext, Module, isImportableType } from "../ctx.js"; +import { reportDiagnostic } from "../lib.js"; +import { parseCase } from "../util/case.js"; +import { asArrayType, getArrayElementName } from "../util/pluralism.js"; +import { emitModelLiteral, emitWellKnownModel, isWellKnownModel } from "./model.js"; +import { createOrGetModuleForNamespace } from "./namespace.js"; +import { getJsScalar } from "./scalar.js"; +import { emitUnionType } from "./union.js"; + +export type NamespacedType = Extract; + +/** + * Options for emitting a type reference. + */ +export interface EmitTypeReferenceOptions { + /** + * An optional alternative name to use for the type if it is not named. + */ + altName?: string; + + /** + * Require a declaration for types that may be represented anonymously. + */ + requireDeclaration?: boolean; +} + +/** + * Emits a reference to a host type. + * + * This function will automatically ensure that the referenced type is included in the emit graph, and will import the + * type into the current module if necessary. + * + * Optionally, a `preferredAlternativeName` may be supplied. This alternative name will be used if a declaration is + * required, but the type is anonymous. The alternative name can only be set once. If two callers provide different + * alternative names for the same anonymous type, the first one is used in all cases. If a declaration _is_ required, + * and no alternative name is supplied (or has been supplied in a prior call to `emitTypeReference`), this function will + * throw an error. Callers must be sure to provide an alternative name if the type _may_ have an unknown name. However, + * callers may know that they have previously emitted a reference to the type and provided an alternative name in that + * call, in which case the alternative name may be safely omitted. + * + * @param ctx - The emitter context. + * @param type - The type to emit a reference to. + * @param position - The syntactic position of the reference, for diagnostics. + * @param module - The module that the reference is being emitted into. + * @param preferredAlternativeName - An optional alternative name to use for the type if it is not named. + * @returns a string containing a reference to the TypeScript type that represents the given TypeSpec type. + */ +export function emitTypeReference( + ctx: JsContext, + type: Type, + position: DiagnosticTarget | typeof NoTarget, + module: Module, + options: EmitTypeReferenceOptions = {}, +): string { + switch (type.kind) { + case "Scalar": + // Get the scalar and return it directly, as it is a primitive. + return getJsScalar(ctx.program, type, position); + case "Model": { + // First handle arrays. + if (isArrayModelType(ctx.program, type)) { + const argumentType = type.indexer.value; + + const argTypeReference = emitTypeReference(ctx, argumentType, position, module, { + altName: options.altName && getArrayElementName(options.altName), + }); + + if (isImportableType(ctx, argumentType) && argumentType.namespace) { + module.imports.push({ + binder: [argTypeReference], + from: createOrGetModuleForNamespace(ctx, argumentType.namespace), + }); + } + + return asArrayType(argTypeReference); + } + + // Now other well-known models. + if (isWellKnownModel(ctx, type)) { + return emitWellKnownModel(ctx, type, module, options.altName); + } + + // Try to reduce the model to an effective model if possible. + const effectiveModel = getEffectiveModelType(ctx.program, type); + + if (effectiveModel.name === "") { + // We might have seen the model before and synthesized a declaration for it already. + if (ctx.syntheticNames.has(effectiveModel)) { + const name = ctx.syntheticNames.get(effectiveModel)!; + module.imports.push({ + binder: [name], + from: ctx.syntheticModule, + }); + return name; + } + + // Require preferredAlternativeName at this point, as we have an anonymous model that we have not visited. + if (!options.altName) { + return emitModelLiteral(ctx, effectiveModel, module); + } + + // Anonymous model, synthesize a new model with the preferredName + ctx.synthetics.push({ + kind: "anonymous", + name: options.altName, + underlying: effectiveModel, + }); + + module.imports.push({ + binder: [options.altName], + from: ctx.syntheticModule, + }); + + ctx.syntheticNames.set(effectiveModel, options.altName); + + return options.altName; + } else { + // The effective model is good for a declaration, so enqueue it. + ctx.typeQueue.add(effectiveModel); + } + + const friendlyName = getFriendlyName(ctx.program, effectiveModel); + + // The model may be a template instance, so we generate a name for it. + const templatedName = parseCase( + friendlyName + ? friendlyName + : effectiveModel.templateMapper + ? effectiveModel + .templateMapper!.args.map((a) => ("name" in a ? String(a.name) : "")) + .join("_") + effectiveModel.name + : effectiveModel.name, + ); + + if (!effectiveModel.namespace) { + throw new Error("UNREACHABLE: no parent namespace of named model in emitTypeReference"); + } + + const parentModule = createOrGetModuleForNamespace(ctx, effectiveModel.namespace); + + module.imports.push({ + binder: [templatedName.pascalCase], + from: parentModule, + }); + + return templatedName.pascalCase; + } + case "Union": { + if (type.variants.size === 0) return "never"; + else if (type.variants.size === 1) + return emitTypeReference(ctx, [...type.variants.values()][0], position, module, options); + + if (options.requireDeclaration) { + if (type.name) { + const nameCase = parseCase(type.name); + + ctx.typeQueue.add(type); + + module.imports.push({ + binder: [nameCase.pascalCase], + from: createOrGetModuleForNamespace(ctx, type.namespace!), + }); + + return type.name; + } else { + const existingSyntheticName = ctx.syntheticNames.get(type); + + if (existingSyntheticName) { + module.imports.push({ + binder: [existingSyntheticName], + from: ctx.syntheticModule, + }); + + return existingSyntheticName; + } else { + const altName = options.altName; + + if (!altName) { + throw new Error("UNREACHABLE: anonymous union without preferredAlternativeName"); + } + + ctx.synthetics.push({ + kind: "anonymous", + name: altName, + underlying: type, + }); + + module.imports.push({ + binder: [altName], + from: ctx.syntheticModule, + }); + + ctx.syntheticNames.set(type, altName); + + return altName; + } + } + } else { + return emitUnionType(ctx, [...type.variants.values()], module); + } + } + case "Enum": { + ctx.typeQueue.add(type); + + const name = parseCase(type.name).pascalCase; + + module.imports.push({ + binder: [name], + from: createOrGetModuleForNamespace(ctx, type.namespace!), + }); + + return name; + } + case "String": + return escapeUnsafeChars(JSON.stringify(type.value)); + case "Number": + case "Boolean": + return String(type.value); + case "Intrinsic": + switch (type.name) { + case "never": + return "never"; + case "null": + return "null"; + case "void": + // It's a bit strange to have a void property, but it's possible, and TypeScript allows it. Void is simply + // only assignable from undefined or void itself. + return "void"; + case "ErrorType": + compilerAssert( + false, + "ErrorType should not be encountered in emitTypeReference", + position === NoTarget ? type : position, + ); + return "unknown"; + case "unknown": + return "unknown"; + default: + reportDiagnostic(ctx.program, { + code: "unrecognized-intrinsic", + format: { intrinsic: (type satisfies never as IntrinsicType).name }, + target: position, + }); + return "unknown"; + } + case "Interface": { + if (type.namespace === undefined) { + throw new Error("UNREACHABLE: unparented interface"); + } + + const typeName = parseCase(type.name).pascalCase; + + ctx.typeQueue.add(type); + + const parentModule = createOrGetModuleForNamespace(ctx, type.namespace); + + module.imports.push({ + binder: [typeName], + from: parentModule, + }); + + return typeName; + } + case "ModelProperty": { + // Forward to underlying type. + return emitTypeReference(ctx, type.type, position, module, options); + } + default: + throw new Error(`UNREACHABLE: ${type.kind}`); + } +} +const UNSAFE_CHAR_MAP: { [k: string]: string } = { + "<": "\\u003C", + ">": "\\u003E", + "/": "\\u002F", + "\\": "\\\\", + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\0": "\\0", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +export function escapeUnsafeChars(s: string) { + return s.replace(/[<>/\\\b\f\n\r\t\0\u2028\u2029]/g, (x) => UNSAFE_CHAR_MAP[x]); +} + +export type JsTypeSpecLiteralType = LiteralType | (IntrinsicType & { name: "null" }); + +export function isValueLiteralType(t: Type): t is JsTypeSpecLiteralType { + switch (t.kind) { + case "String": + case "Number": + case "Boolean": + return true; + case "Intrinsic": + return t.name === "null"; + default: + return false; + } +} diff --git a/src/common/scalar.ts b/src/common/scalar.ts new file mode 100644 index 0000000..868b178 --- /dev/null +++ b/src/common/scalar.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { DiagnosticTarget, NoTarget, Program, Scalar, formatDiagnostic } from "@typespec/compiler"; +import { JsContext } from "../ctx.js"; +import { reportDiagnostic } from "../lib.js"; +import { parseCase } from "../util/case.js"; +import { UnimplementedError } from "../util/error.js"; +import { getFullyQualifiedTypeName } from "../util/name.js"; + +/** + * Emits a declaration for a scalar type. + * + * This is rare in TypeScript, as the scalar will ordinarily be used inline, but may be desirable in some cases. + * + * @param ctx - The emitter context. + * @param scalar - The scalar to emit. + * @returns a string that declares an alias to the scalar type in TypeScript. + */ +export function emitScalar(ctx: JsContext, scalar: Scalar): string { + const jsScalar = getJsScalar(ctx.program, scalar, scalar.node.id); + + const name = parseCase(scalar.name).pascalCase; + + return `type ${name} = ${jsScalar};`; +} + +/** + * Get the string parsing template for a given scalar. + * + * It is common that a scalar type is encoded as a string. For example, in HTTP path parameters or query parameters + * where the value may be an integer, but the APIs expose it as a string. In such cases the parse template may be + * used to coerce the string value to the correct scalar type. + * + * The result of this function contains the string "{}" exactly once, which should be replaced with the text of an + * expression evaluating to the string representation of the scalar. + * + * For example, scalars that are represented by JS `number` are parsed with the template `Number({})`, which will + * convert the string to a number. + * + * @param ctx - The emitter context. + * @param scalar - The scalar to parse from a string + * @returns a template expression string that can be used to parse a string into the scalar type. + */ +export function parseTemplateForScalar(ctx: JsContext, scalar: Scalar): string { + const jsScalar = getJsScalar(ctx.program, scalar, scalar); + + switch (jsScalar) { + case "string": + return "{}"; + case "number": + return "Number({})"; + case "bigint": + return "BigInt({})"; + case "Uint8Array": + return "Buffer.from({}, 'base64')"; + default: + throw new UnimplementedError(`parse template for scalar '${jsScalar}'`); + } +} + +/** + * Get the string encoding template for a given scalar. + * @param ctx + * @param scalar + */ +export function encodeTemplateForScalar(ctx: JsContext, scalar: Scalar): string { + const jsScalar = getJsScalar(ctx.program, scalar, scalar); + + switch (jsScalar) { + case "string": + return "{}"; + case "number": + return "String({})"; + case "bigint": + return "String({})"; + case "Uint8Array": + return "{}.toString('base64')"; + default: + throw new UnimplementedError(`encode template for scalar '${jsScalar}'`); + } +} + +const __JS_SCALARS_MAP = new Map>(); + +function getScalarsMap(program: Program): Map { + let scalars = __JS_SCALARS_MAP.get(program); + + if (scalars === undefined) { + scalars = createScalarsMap(program); + __JS_SCALARS_MAP.set(program, scalars); + } + + return scalars; +} + +function createScalarsMap(program: Program): Map { + const entries = [ + [program.resolveTypeReference("TypeSpec.bytes"), "Uint8Array"], + [program.resolveTypeReference("TypeSpec.boolean"), "boolean"], + [program.resolveTypeReference("TypeSpec.string"), "string"], + [program.resolveTypeReference("TypeSpec.float32"), "number"], + [program.resolveTypeReference("TypeSpec.float64"), "number"], + + [program.resolveTypeReference("TypeSpec.uint32"), "number"], + [program.resolveTypeReference("TypeSpec.uint16"), "number"], + [program.resolveTypeReference("TypeSpec.uint8"), "number"], + [program.resolveTypeReference("TypeSpec.int32"), "number"], + [program.resolveTypeReference("TypeSpec.int16"), "number"], + [program.resolveTypeReference("TypeSpec.int8"), "number"], + + [program.resolveTypeReference("TypeSpec.safeint"), "number"], + [program.resolveTypeReference("TypeSpec.integer"), "bigint"], + [program.resolveTypeReference("TypeSpec.plainDate"), "Date"], + [program.resolveTypeReference("TypeSpec.plainTime"), "Date"], + [program.resolveTypeReference("TypeSpec.utcDateTime"), "Date"], + ] as const; + + for (const [[type, diagnostics]] of entries) { + if (!type) { + const diagnosticString = diagnostics.map((x) => formatDiagnostic(x)).join("\n"); + throw new Error(`failed to construct TypeSpec -> JavaScript scalar map: ${diagnosticString}`); + } else if (type.kind !== "Scalar") { + throw new Error( + `type ${(type as any).name ?? ""} is a '${type.kind}', expected 'scalar'`, + ); + } + } + + return new Map(entries.map(([[type], scalar]) => [type! as Scalar, scalar])); +} + +/** + * Gets a TypeScript type that can represent a given TypeSpec scalar. + * + * Scalar recognition is recursive. If a scalar is not recognized, we will treat it as its parent scalar and try again. + * + * If no scalar in the chain is recognized, it will be treated as `unknown` and a warning will be issued. + * + * @param program - The program that contains the scalar + * @param scalar - The scalar to get the TypeScript type for + * @param diagnosticTarget - Where to report a diagnostic if the scalar is not recognized. + * @returns a string containing a TypeScript type that can represent the scalar + */ +export function getJsScalar( + program: Program, + scalar: Scalar, + diagnosticTarget: DiagnosticTarget | typeof NoTarget, +): string { + const scalars = getScalarsMap(program); + + let _scalar: Scalar | undefined = scalar; + + while (_scalar !== undefined) { + const jsScalar = scalars.get(_scalar); + + if (jsScalar !== undefined) { + return jsScalar; + } + + _scalar = _scalar.baseScalar; + } + + reportDiagnostic(program, { + code: "unrecognized-scalar", + target: diagnosticTarget, + format: { + scalar: getFullyQualifiedTypeName(scalar), + }, + }); + + return "unknown"; +} diff --git a/src/common/serialization/index.ts b/src/common/serialization/index.ts new file mode 100644 index 0000000..73ee763 --- /dev/null +++ b/src/common/serialization/index.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Model, NoTarget, Scalar, Type, Union } from "@typespec/compiler"; +import { JsContext, Module, completePendingDeclarations } from "../../ctx.js"; +import { UnimplementedError } from "../../util/error.js"; +import { indent } from "../../util/iter.js"; +import { createOrGetModuleForNamespace } from "../namespace.js"; +import { emitTypeReference } from "../reference.js"; +import { emitJsonSerialization, requiresJsonSerialization } from "./json.js"; + +export type SerializableType = Model | Scalar | Union; + +export function isSerializableType(t: Type): t is SerializableType { + return t.kind === "Model" || t.kind === "Scalar" || t.kind === "Union"; +} + +export type SerializationContentType = "application/json"; + +const _SERIALIZATIONS_MAP = new WeakMap>(); + +export function requireSerialization( + ctx: JsContext, + type: Type, + contentType: SerializationContentType, +): void { + if (!isSerializableType(type)) { + throw new UnimplementedError(`no implementation of JSON serialization for type '${type.kind}'`); + } + + let serializationsForType = _SERIALIZATIONS_MAP.get(type); + + if (!serializationsForType) { + serializationsForType = new Set(); + _SERIALIZATIONS_MAP.set(type, serializationsForType); + } + + serializationsForType.add(contentType); + + ctx.serializations.add(type); +} + +export interface SerializationContext extends JsContext {} + +export function emitSerialization(ctx: JsContext): void { + completePendingDeclarations(ctx); + + const serializationContext: SerializationContext = { + ...ctx, + }; + + while (!ctx.serializations.isEmpty()) { + const type = ctx.serializations.take()!; + + const serializations = _SERIALIZATIONS_MAP.get(type)!; + + const requiredSerializations = new Set( + [...serializations].filter((serialization) => + isSerializationRequired(ctx, type, serialization), + ), + ); + + if (requiredSerializations.size > 0) { + emitSerializationsForType(serializationContext, type, serializations); + } + } +} + +export function isSerializationRequired( + ctx: JsContext, + type: Type, + serialization: SerializationContentType, +): boolean { + switch (serialization) { + case "application/json": { + return requiresJsonSerialization(ctx, type); + } + default: + throw new Error(`Unreachable: serialization content type ${serialization satisfies never}`); + } +} + +function emitSerializationsForType( + ctx: SerializationContext, + type: SerializableType, + serializations: Set, +): void { + const isSynthetic = ctx.syntheticNames.has(type) || !type.namespace; + + const module = isSynthetic + ? ctx.syntheticModule + : createOrGetModuleForNamespace(ctx, type.namespace!); + + const typeName = emitTypeReference(ctx, type, NoTarget, module); + + const serializationCode = [`export const ${typeName} = {`]; + + for (const serialization of serializations) { + serializationCode.push( + ...indent(emitSerializationForType(ctx, type, serialization, module, typeName)), + ); + } + + serializationCode.push("} as const;"); + + module.declarations.push(serializationCode); +} + +function* emitSerializationForType( + ctx: SerializationContext, + type: SerializableType, + contentType: SerializationContentType, + module: Module, + typeName: string, +): Iterable { + switch (contentType) { + case "application/json": { + yield* emitJsonSerialization(ctx, type, module, typeName); + break; + } + default: + throw new Error(`Unreachable: serialization content type ${contentType satisfies never}`); + } +} diff --git a/src/common/serialization/json.ts b/src/common/serialization/json.ts new file mode 100644 index 0000000..ec1fa43 --- /dev/null +++ b/src/common/serialization/json.ts @@ -0,0 +1,444 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { + BooleanLiteral, + IntrinsicType, + ModelProperty, + NoTarget, + NumericLiteral, + StringLiteral, + Type, + compilerAssert, + getEncode, + getProjectedName, + isArrayModelType, + isRecordModelType, + resolveEncodedName, +} from "@typespec/compiler"; +import { getHeaderFieldOptions, getPathParamOptions, getQueryParamOptions } from "@typespec/http"; +import { JsContext, Module } from "../../ctx.js"; +import { parseCase } from "../../util/case.js"; +import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js"; +import { UnimplementedError } from "../../util/error.js"; +import { indent } from "../../util/iter.js"; +import { emitTypeReference, escapeUnsafeChars } from "../reference.js"; +import { getJsScalar } from "../scalar.js"; +import { SerializableType, SerializationContext, requireSerialization } from "./index.js"; + +/** + * Memoization cache for requiresJsonSerialization. + */ +const _REQUIRES_JSON_SERIALIZATION = new WeakMap(); + +export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean { + if (!isSerializable(type)) return false; + + if (_REQUIRES_JSON_SERIALIZATION.has(type)) { + return _REQUIRES_JSON_SERIALIZATION.get(type)!; + } + + // Assume the type is serializable until proven otherwise, in case this model is encountered recursively. + // This isn't an exactly correct algorithm, but in the recursive case it will at least produce something that + // is correct. + _REQUIRES_JSON_SERIALIZATION.set(type, true); + + let requiresSerialization: boolean; + + switch (type.kind) { + case "Model": { + if (isArrayModelType(ctx.program, type)) { + const argumentType = type.indexer.value; + requiresSerialization = requiresJsonSerialization(ctx, argumentType); + break; + } + + requiresSerialization = [...type.properties.values()].some((property) => + propertyRequiresJsonSerialization(ctx, property), + ); + break; + } + case "Scalar": { + const scalar = getJsScalar(ctx.program, type, type); + requiresSerialization = scalar === "Uint8Array" || getEncode(ctx.program, type) !== undefined; + break; + } + case "Union": { + requiresSerialization = [...type.variants.values()].some((variant) => + requiresJsonSerialization(ctx, variant), + ); + break; + } + case "ModelProperty": + requiresSerialization = requiresJsonSerialization(ctx, type.type); + break; + } + + _REQUIRES_JSON_SERIALIZATION.set(type, requiresSerialization); + + return requiresSerialization; +} + +function propertyRequiresJsonSerialization(ctx: JsContext, property: ModelProperty): boolean { + return !!( + isHttpMetadata(ctx, property) || + getEncode(ctx.program, property) || + resolveEncodedName(ctx.program, property, "application/json") !== property.name || + getProjectedName(ctx.program, property, "json") || + (isSerializable(property.type) && requiresJsonSerialization(ctx, property.type)) + ); +} + +function isHttpMetadata(ctx: JsContext, property: ModelProperty): boolean { + return ( + getQueryParamOptions(ctx.program, property) !== undefined || + getHeaderFieldOptions(ctx.program, property) !== undefined || + getPathParamOptions(ctx.program, property) !== undefined + ); +} + +function isSerializable(type: Type): type is SerializableType | ModelProperty { + return ( + type.kind === "Model" || + type.kind === "Scalar" || + type.kind === "Union" || + type.kind === "ModelProperty" + ); +} + +export function* emitJsonSerialization( + ctx: SerializationContext, + type: SerializableType, + module: Module, + typeName: string, +): Iterable { + yield `toJsonObject(input: ${typeName}): any {`; + yield* indent(emitToJson(ctx, type, module)); + yield `},`; + + yield `fromJsonObject(input: any): ${typeName} {`; + yield* indent(emitFromJson(ctx, type, module)); + yield `},`; +} + +function* emitToJson( + ctx: SerializationContext, + type: SerializableType, + module: Module, +): Iterable { + switch (type.kind) { + case "Model": { + yield `return {`; + + for (const property of type.properties.values()) { + const encodedName = + getProjectedName(ctx.program, property, "json") ?? + resolveEncodedName(ctx.program, property, "application/json") ?? + property.name; + + const expr = transposeExpressionToJson( + ctx, + property.type, + `input.${property.name}`, + module, + ); + + yield ` ${encodedName}: ${expr},`; + } + + yield `};`; + + return; + } + case "Scalar": { + yield `throw new Error("Unimplemented: scalar JSON serialization");`; + return; + } + case "Union": { + const codeTree = differentiateUnion(ctx, type); + + yield* writeCodeTree(ctx, codeTree, { + subject: "input", + referenceModelProperty(p) { + return "input." + parseCase(p.name).camelCase; + }, + renderResult(type) { + return [`return ${transposeExpressionToJson(ctx, type, "input", module)};`]; + }, + }); + + return; + } + } +} + +function transposeExpressionToJson( + ctx: SerializationContext, + type: Type, + expr: string, + module: Module, +): string { + switch (type.kind) { + case "Model": { + if (isArrayModelType(ctx.program, type)) { + const argumentType = type.indexer.value; + + if (requiresJsonSerialization(ctx, argumentType)) { + return `${expr}?.map((item) => ${transposeExpressionToJson(ctx, argumentType, "item", module)})`; + } else { + return expr; + } + } else if (isRecordModelType(ctx.program, type)) { + const argumentType = type.indexer.value; + + if (requiresJsonSerialization(ctx, argumentType)) { + return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [String(key), ${transposeExpressionToJson( + ctx, + argumentType, + "value", + module, + )}]))`; + } else { + return expr; + } + } else if (!requiresJsonSerialization(ctx, type)) { + return expr; + } else { + requireSerialization(ctx, type, "application/json"); + const typeReference = emitTypeReference(ctx, type, NoTarget, module); + + return `${typeReference}.toJsonObject(${expr})`; + } + } + case "Scalar": + const scalar = getJsScalar(ctx.program, type, type); + + switch (scalar) { + case "Uint8Array": + // Coerce to Buffer if we aren't given a buffer. This avoids having to do unholy things to + // convert through an intermediate and use globalThis.btoa. v8 does not support Uint8Array.toBase64 + return `((${expr} instanceof Buffer) ? ${expr} : Buffer.from(${expr})).toString('base64')`; + default: + return expr; + } + case "Union": + if (!requiresJsonSerialization(ctx, type)) { + return expr; + } else { + requireSerialization(ctx, type, "application/json"); + const typeReference = emitTypeReference(ctx, type, NoTarget, module, { + altName: "WeirdUnion", + requireDeclaration: true, + }); + + return `${typeReference}.toJsonObject(${expr})`; + } + case "ModelProperty": + return transposeExpressionToJson(ctx, type.type, expr, module); + case "Intrinsic": + switch (type.name) { + case "void": + return "undefined"; + case "null": + return "null"; + case "ErrorType": + compilerAssert(false, "Encountered ErrorType in JSON serialization", type); + return expr; + case "never": + case "unknown": + default: + // Unhandled intrinsics will have been caught during type construction. We'll ignore this and + // just return the expr as-is. + return expr; + } + case "String": + case "Number": + case "Boolean": + return literalToExpr(type); + case "Interface": + case "Enum": + case "EnumMember": + case "TemplateParameter": + case "Namespace": + case "Operation": + case "StringTemplate": + case "StringTemplateSpan": + case "Tuple": + case "UnionVariant": + case "Function": + case "Decorator": + case "FunctionParameter": + case "Object": + case "Projection": + case "ScalarConstructor": + default: + throw new UnimplementedError(`transformJsonExprForType: ${type.kind}`); + } +} + +function literalToExpr(type: StringLiteral | BooleanLiteral | NumericLiteral): string { + switch (type.kind) { + case "String": + return escapeUnsafeChars(JSON.stringify(type.value)); + case "Number": + case "Boolean": + return String(type.value); + } +} + +function* emitFromJson( + ctx: SerializationContext, + type: SerializableType, + module: Module, +): Iterable { + switch (type.kind) { + case "Model": { + yield `return {`; + + for (const property of type.properties.values()) { + const encodedName = + getProjectedName(ctx.program, property, "json") ?? + resolveEncodedName(ctx.program, property, "application/json") ?? + property.name; + + const expr = transposeExpressionFromJson( + ctx, + property.type, + `input["${encodedName}"]`, + module, + ); + + yield ` ${property.name}: ${expr},`; + } + + yield "};"; + + return; + } + case "Scalar": { + yield `throw new Error("Unimplemented: scalar JSON serialization");`; + return; + } + case "Union": { + const codeTree = differentiateUnion(ctx, type); + + yield* writeCodeTree(ctx, codeTree, { + subject: "input", + referenceModelProperty(p) { + const jsonName = + getProjectedName(ctx.program, p, "json") ?? + resolveEncodedName(ctx.program, p, "application/json") ?? + p.name; + return "input[" + JSON.stringify(jsonName) + "]"; + }, + renderResult(type) { + return [`return ${transposeExpressionFromJson(ctx, type, "input", module)};`]; + }, + }); + + return; + } + } +} + +function transposeExpressionFromJson( + ctx: SerializationContext, + type: Type, + expr: string, + module: Module, +): string { + switch (type.kind) { + case "Model": { + if (isArrayModelType(ctx.program, type)) { + const argumentType = type.indexer.value; + + if (requiresJsonSerialization(ctx, argumentType)) { + return `${expr}?.map((item: any) => ${transposeExpressionFromJson(ctx, argumentType, "item", module)})`; + } else { + return expr; + } + } else if (isRecordModelType(ctx.program, type)) { + const argumentType = type.indexer.value; + + if (requiresJsonSerialization(ctx, argumentType)) { + return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [key, ${transposeExpressionFromJson( + ctx, + argumentType, + "value", + module, + )}]))`; + } else { + return expr; + } + } else if (!requiresJsonSerialization(ctx, type)) { + return `${expr} as ${emitTypeReference(ctx, type, NoTarget, module)}`; + } else { + requireSerialization(ctx, type, "application/json"); + const typeReference = emitTypeReference(ctx, type, NoTarget, module); + + return `${typeReference}.fromJsonObject(${expr})`; + } + } + case "Scalar": + const scalar = getJsScalar(ctx.program, type, type); + + switch (scalar) { + case "Uint8Array": + return `Buffer.from(${expr}, 'base64')`; + default: + return expr; + } + case "Union": + if (!requiresJsonSerialization(ctx, type)) { + return expr; + } else { + requireSerialization(ctx, type, "application/json"); + const typeReference = emitTypeReference(ctx, type, NoTarget, module, { + altName: "WeirdUnion", + requireDeclaration: true, + }); + + return `${typeReference}.fromJsonObject(${expr})`; + } + case "ModelProperty": + return transposeExpressionFromJson(ctx, type.type, expr, module); + case "Intrinsic": + switch (type.name) { + case "ErrorType": + throw new Error("UNREACHABLE: ErrorType in JSON deserialization"); + case "void": + return "undefined"; + case "null": + return "null"; + case "never": + case "unknown": + return expr; + default: + throw new Error( + `Unreachable: intrinsic type ${(type satisfies never as IntrinsicType).name}`, + ); + } + case "String": + case "Number": + case "Boolean": + return literalToExpr(type); + case "Interface": + case "Enum": + case "EnumMember": + case "TemplateParameter": + case "Namespace": + case "Operation": + case "StringTemplate": + case "StringTemplateSpan": + case "Tuple": + case "UnionVariant": + case "Function": + case "Decorator": + case "FunctionParameter": + case "Object": + case "Projection": + case "ScalarConstructor": + default: + throw new UnimplementedError(`transformJsonExprForType: ${type.kind}`); + } +} diff --git a/src/common/union.ts b/src/common/union.ts new file mode 100644 index 0000000..38e5786 --- /dev/null +++ b/src/common/union.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Union, UnionVariant } from "@typespec/compiler"; +import { JsContext, Module, PartialUnionSynthetic } from "../ctx.js"; +import { parseCase } from "../util/case.js"; +import { emitDocumentation } from "./documentation.js"; +import { emitTypeReference } from "./reference.js"; + +/** + * Emit an inline union type. This will automatically import any referenced types that are part of the union. + * + * @param ctx - The emitter context. + * @param variants - The variants of the union. + * @param module - The module that this union is written into. + * @returns a string that can be used as a type reference + */ +export function emitUnionType(ctx: JsContext, variants: UnionVariant[], module: Module): string { + // Treat empty unions as never so that we always return a good type reference here. + if (variants.length === 0) return "never"; + + const variantTypes: string[] = []; + + for (const [_, v] of variants.entries()) { + const name = emitTypeReference(ctx, v.type, v, module); + + variantTypes.push(name); + + // if (isImportableType(ctx, v.type)) { + // module.imports.push({ + // binder: [name], + // from: createOrGetModuleForNamespace(ctx, v.type.namespace!), + // }); + // } + } + + return variantTypes.join(" | "); +} + +/** + * Emits a union type declaration as an alias. + * + * This is rare in TypeScript, but may occur in some niche cases where an alias is desirable. + * + * @param ctx - The emitter context. + * @param union - The union to emit. + * @param module - The module that this union declaration is written into. + * @param altName - An alternative name to use for the union if it is not named. + */ +export function* emitUnion( + ctx: JsContext, + union: Union | PartialUnionSynthetic, + module: Module, + altName?: string, +): Iterable { + const name = union.name ? parseCase(union.name).pascalCase : altName; + const isPartialSynthetic = union.kind === "partialUnion"; + + if (name === undefined) { + throw new Error("Internal Error: Union name is undefined"); + } + + if (!isPartialSynthetic) yield* emitDocumentation(ctx, union); + + const variants = isPartialSynthetic + ? union.variants.map((v) => [v.name, v] as const) + : union.variants.entries(); + + const variantTypes = [...variants].map(([_, v]) => + emitTypeReference(ctx, v.type, v, module, { + altName: name + parseCase(String(v.name)).pascalCase, + }), + ); + + yield `export type ${name} = ${variantTypes.join(" | ")};`; +} diff --git a/src/ctx.ts b/src/ctx.ts new file mode 100644 index 0000000..6df6184 --- /dev/null +++ b/src/ctx.ts @@ -0,0 +1,527 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { + compilerAssert, + Enum, + Interface, + isArrayModelType, + isRecordModelType, + listServices, + Model, + Namespace, + NoTarget, + Program, + Scalar, + Service, + Type, + Union, + UnionVariant, +} from "@typespec/compiler"; +import { emitDeclaration } from "./common/declaration.js"; +import { createOrGetModuleForNamespace } from "./common/namespace.js"; +import { SerializableType } from "./common/serialization/index.js"; +import { emitUnion } from "./common/union.js"; +import { JsEmitterOptions, reportDiagnostic } from "./lib.js"; +import { parseCase } from "./util/case.js"; +import { UnimplementedError } from "./util/error.js"; +import { createOnceQueue, OnceQueue } from "./util/once-queue.js"; + +import { createModule as initializeHelperModule } from "../generated-defs/helpers/index.js"; + +export type DeclarationType = Model | Enum | Union | Interface | Scalar; + +/** + * Determines whether or not a type is importable into a JavaScript module. + * + * i.e. whether or not it is declared as a named symbol within the module. + * + * In TypeScript, unions are rendered inline, so they are not ordinarily + * considered importable. + * + * @param ctx - The JS emitter context. + * @param t - the type to test + * @returns `true` if the type is an importable declaration, `false` otherwise. + */ +export function isImportableType( + ctx: JsContext, + t: Type +): t is DeclarationType { + return ( + (t.kind === "Model" && + !isArrayModelType(ctx.program, t) && + !isRecordModelType(ctx.program, t)) || + t.kind === "Enum" || + t.kind === "Interface" + ); +} + +/** + * Stores stateful information consumed and modified by the JavaScript server + * emitter. + */ +export interface JsContext { + /** + * The TypeSpec Program that this emitter instance operates over. + */ + program: Program; + + /** + * The emitter options. + */ + options: JsEmitterOptions; + + /** + * The global (root) namespace of the program. + */ + globalNamespace: Namespace; + + /** + * The service definition to use for emit. + */ + service: Service; + + /** + * A queue of all types to be included in the emit tree. This queue + * automatically deduplicates types, so if a type is added multiple times it + * will only be visited once. + */ + typeQueue: OnceQueue; + /** + * A list of synthetic types (anonymous types that are given names) that are + * included in the emit tree. + */ + synthetics: Synthetic[]; + /** + * A cache of names given to synthetic types. These names may be used to avoid + * emitting the same synthetic type multiple times. + */ + syntheticNames: Map; + + /** + * The root module for the emit tree. + */ + rootModule: Module; + + /** + * The parent of the generated module. + */ + srcModule: Module; + + /** + * The module that contains all generated code. + */ + generatedModule: Module; + + /** + * A map relating each namespace to the module that contains its declarations. + * + * @see createOrGetModuleForNamespace + */ + namespaceModules: Map; + /** + * The module that contains all synthetic types. + */ + syntheticModule: Module; + /** + * The root module for all named declarations of types referenced by the program. + */ + modelsModule: Module; + /** + * The module within `models` that maps to the global namespace. + */ + globalNamespaceModule: Module; + + /** + * A map of all types that require serialization code to the formats they require. + */ + serializations: OnceQueue; + + gensym: (name: string) => string; +} + +export async function createInitialContext( + program: Program, + options: JsEmitterOptions +): Promise { + const services = listServices(program); + + if (services.length === 0) { + reportDiagnostic(program, { + code: "no-services-in-program", + target: NoTarget, + messageId: "default", + }); + + return; + } else if (services.length > 1) { + throw new UnimplementedError( + "multiple service definitions per program." + ); + } + + const [service] = services; + + const serviceModuleName = parseCase(service.type.name).snakeCase; + + const rootCursor = createPathCursor(); + + const globalNamespace = program.getGlobalNamespaceType(); + + // Root module for emit. + const rootModule: Module = { + name: serviceModuleName, + cursor: rootCursor, + + imports: [], + declarations: [], + }; + + const srcModule = createModule("src", rootModule); + + const generatedModule = createModule("generated", srcModule); + + // This has the side effect of setting the `module` property of all helpers. + // Don't do anything with the emitter code before this is called. + await initializeHelperModule(generatedModule); + + // Module for all models, including synthetic and all. + const modelsModule: Module = createModule("models", generatedModule); + + // Module for all types in all namespaces. + const allModule: Module = createModule( + "all", + modelsModule, + globalNamespace + ); + + // Module for all synthetic (named ad-hoc) types. + const syntheticModule: Module = createModule("synthetic", modelsModule); + + const jsCtx: JsContext = { + program, + options, + globalNamespace, + service, + + typeQueue: createOnceQueue(), + synthetics: [], + syntheticNames: new Map(), + + rootModule, + srcModule, + generatedModule, + namespaceModules: new Map([[globalNamespace, allModule]]), + syntheticModule, + modelsModule, + globalNamespaceModule: allModule, + + serializations: createOnceQueue(), + + gensym: (name) => { + return gensym(jsCtx, name); + }, + }; + + return jsCtx; +} + +/** + * A synthetic type that is not directly represented with a name in the TypeSpec program. + */ +export type Synthetic = AnonymousSynthetic | PartialUnionSynthetic; + +/** + * An ordinary, anonymous type that is given a name. + */ +export interface AnonymousSynthetic { + kind: "anonymous"; + name: string; + underlying: DeclarationType; +} + +/** + * A partial union with a name for the given variants. + */ +export interface PartialUnionSynthetic { + kind: "partialUnion"; + name: string; + variants: UnionVariant[]; +} + +/** + * Adds all pending declarations from the type queue to the module tree. + * + * The JavaScript emitter is lazy, and sometimes emitter components may visit + * types that are not yet declared. This function ensures that all types + * reachable from existing declarations are complete. + * + * @param ctx - The JavaScript emitter context. + */ +export function completePendingDeclarations(ctx: JsContext): void { + // Add all pending declarations to the module tree. + while (!ctx.typeQueue.isEmpty() || ctx.synthetics.length > 0) { + while (!ctx.typeQueue.isEmpty()) { + const type = ctx.typeQueue.take()!; + + compilerAssert( + type.namespace !== undefined, + "no namespace for declaration type", + type + ); + + const module = createOrGetModuleForNamespace(ctx, type.namespace); + + module.declarations.push([...emitDeclaration(ctx, type, module)]); + } + + while (ctx.synthetics.length > 0) { + const synthetic = ctx.synthetics.shift()!; + + switch (synthetic.kind) { + case "anonymous": { + ctx.syntheticModule.declarations.push([ + ...emitDeclaration( + ctx, + synthetic.underlying, + ctx.syntheticModule, + synthetic.name + ), + ]); + break; + } + case "partialUnion": { + ctx.syntheticModule.declarations.push([ + ...emitUnion( + ctx, + synthetic, + ctx.syntheticModule, + synthetic.name + ), + ]); + break; + } + } + } + } +} + +// #region Module + +/** + * A declaration within a module. This may be a string (i.e. a line), an array of + * strings (emitted as multiple lines), or another module (emitted as a nested module). + */ +export type ModuleBodyDeclaration = string[] | string | Module; + +/** + * A type-guard that checks whether or not a given value is a module. + * @returns `true` if the value is a module, `false` otherwise. + */ +export function isModule(value: unknown): value is Module { + return ( + typeof value === "object" && + value !== null && + "declarations" in value && + Array.isArray(value.declarations) + ); +} + +/** + * Creates a new module with the given name and attaches it to the parent module. + * + * Optionally, a namespace may be associated with the module. This namespace is + * _NOT_ stored in the context (this function does not use the JsContext), and + * is only stored as metadata within the module. To associate a module with a + * namespace inside the context, use `createOrGetModuleForNamespace`. + * + * The module is automatically declared as a declaration within its parent + * module. + * + * @param name - The name of the module. + * @param parent - The parent module to attach the new module to. + * @param namespace - an optional TypeSpec Namespace to associate with the module + * @returns the newly created module + */ +export function createModule( + name: string, + parent: Module, + namespace?: Namespace +): Module { + const self = { + name, + cursor: parent.cursor.enter(name), + namespace, + + imports: [], + declarations: [], + }; + + parent.declarations.push(self); + + return self; +} + +/** + * The type of a binding for an import statement. Either: + * + * - A string beginning with `* as` followed by the name of the binding, which + * imports all exports from the module as a single object. + * - A binding name, which imports the default export of the module. + * - An array of strings, each of which is a named import from the module. + */ +export type ImportBinder = string | string[]; + +/** + * An object representing a ECMAScript module import declaration. + */ +export interface Import { + /** + * The binder to define the import as. + */ + binder: ImportBinder; + /** + * Where to import from. This is either a literal string (which will be used verbatim), or Module object, which will + * be resolved to a relative file path. + */ + from: Module | string; +} + +/** + * An output module within the module tree. + */ +export interface Module { + /** + * The name of the module, which should be suitable for use as the basename of + * a file and as an identifier. + */ + name: string; + /** + * The cursor for the module, which assists navigation and relative path + * computation between modules. + */ + readonly cursor: PathCursor; + + /** + * An optional namespace for the module. This is not used by the code writer, + * but is used to track dependencies between TypeSpec namespaces and create + * imports between them. + */ + namespace?: Namespace; + + /** + * A list of imports that the module requires. + */ + imports: Import[]; + + /** + * A list of declarations within the module. + */ + declarations: ModuleBodyDeclaration[]; +} + +// #endregion + +/** + * A cursor that assists in navigating the module tree and computing relative + * paths between modules. + */ +export interface PathCursor { + /** + * The path to this cursor. This is an array of strings that represents the + * path from the root module to another module. + */ + readonly path: string[]; + + /** + * The parent cursor of this cursor (equivalent to moving up one level in the + * module tree). If this cursor is the root cursor, this property is `undefined`. + */ + readonly parent: PathCursor | undefined; + + /** + * Returns a new cursor that includes the given path components appended to + * this cursor's path. + * + * @param path - the path to append to this cursor + */ + enter(...path: string[]): PathCursor; + + /** + * Computes a relative path from this cursor to another cursor, using the string `up` + * to navigate upwards one level in the path. This is similar to `path.relative` when + * working with file paths, but operates over PathCursor objects. + * + * @param to - the cursor to compute the path to + * @param up - the string to use to move up a level in the path (defaults to "..") + */ + relativePath(to: PathCursor, up?: string): string[]; +} + +/** + * Create a new cursor with the given path. + * + * @param base - the base path of this cursor + * @returns + */ +export function createPathCursor(...base: string[]): PathCursor { + const self: PathCursor = { + path: base, + + get parent() { + return self.path.length === 0 + ? undefined + : createPathCursor(...self.path.slice(0, -1)); + }, + + enter(...path: string[]) { + return createPathCursor(...self.path, ...path); + }, + + relativePath(to: PathCursor, up: string = ".."): string[] { + const commonPrefix = getCommonPrefix(self.path, to.path); + + const outputPath = []; + + for (let i = 0; i < self.path.length - commonPrefix.length; i++) { + outputPath.push(up); + } + + outputPath.push(...to.path.slice(commonPrefix.length)); + + return outputPath; + }, + }; + + return self; +} + +/** + * Compute the common prefix of two paths. + */ +function getCommonPrefix(a: string[], b: string[]): string[] { + const prefix = []; + + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) { + break; + } + + prefix.push(a[i]); + } + + return prefix; +} + +const SYM_TAB = new WeakMap(); + +export function gensym(ctx: JsContext, name: string): string { + let symTab = SYM_TAB.get(ctx.program); + + if (symTab === undefined) { + symTab = { idx: 0 }; + SYM_TAB.set(ctx.program, symTab); + } + + return `__${name}_${symTab.idx++}`; +} diff --git a/src/helpers/header.ts b/src/helpers/header.ts new file mode 100644 index 0000000..b177eb5 --- /dev/null +++ b/src/helpers/header.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +export interface HeaderValueParameters { + value: string; + verbatim: string; + params: { [k: string]: string }; +} + +/** + * Parses a header value that may contain additional parameters (e.g. `text/html; charset=utf-8`). + * @param headerValueText - the text of the header value to parse + * @returns an object containing the value and a map of parameters + */ +export function parseHeaderValueParameters
( + headerValueText: Header, +): undefined extends Header ? HeaderValueParameters | undefined : HeaderValueParameters { + if (headerValueText === undefined) { + return undefined as any; + } + + const idx = headerValueText.indexOf(";"); + const [value, _paramsText] = + idx === -1 + ? [headerValueText, ""] + : [headerValueText.slice(0, idx), headerValueText.slice(idx + 1)]; + + let paramsText = _paramsText; + + // Parameters are a sequence of key=value pairs separated by semicolons, but the value may be quoted in which case it + // may contain semicolons. We use a regular expression to iteratively split the parameters into key=value pairs. + const params: { [k: string]: string } = {}; + + let match; + + // TODO: may need to support ext-parameter (e.g. "filename*=UTF-8''%e2%82%ac%20rates" => { filename: "€ rates" }). + // By default we decoded everything as UTF-8, and non-UTF-8 agents are a dying breed, but we may need to support + // this for completeness. If we do support it, we'll prefer an ext-parameter over a regular parameter. Currently, we'll + // just treat them as separate keys and put the raw value in the parameter. + // + // https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1 + while ((match = paramsText.match(/\s*([^=]+)=(?:"([^"]+)"|([^;]+));?/))) { + const [, key, quotedValue, unquotedValue] = match; + + params[key.trim()] = quotedValue ?? unquotedValue; + + paramsText = paramsText.slice(match[0].length); + } + + return { + value: value.trim(), + verbatim: headerValueText, + params, + }; +} diff --git a/src/helpers/http.ts b/src/helpers/http.ts new file mode 100644 index 0000000..b22b6a8 --- /dev/null +++ b/src/helpers/http.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { HttpContext } from "./router.js"; + +export const HTTP_RESPONDER = Symbol.for("@pojagi/http-server-drf.HttpResponder"); + +/** + * A type that can respond to an HTTP request. + */ +export interface HttpResponder { + /** + * A function that handles an HTTP request and response. + * + * @param context - The HTTP context. + */ + [HTTP_RESPONDER]: (context: HttpContext) => void; +} + +/** + * Determines if a value is an HttpResponder. + * @param value - The value to check. + * @returns `true` if the value is an HttpResponder, otherwise `false`. + */ +export function isHttpResponder(value: unknown): value is HttpResponder { + return ( + typeof value === "object" && + value !== null && + HTTP_RESPONDER in value && + typeof value[HTTP_RESPONDER] === "function" + ); +} + +/** + * An Error that can respond to an HTTP request if thrown from a route handler. + */ +export class HttpResponderError extends Error implements HttpResponder { + #statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.#statusCode = statusCode; + } + + [HTTP_RESPONDER](ctx: HttpContext): void { + ctx.response.statusCode = this.#statusCode; + ctx.response.setHeader("Content-Type", "text/plain"); + ctx.response.end(this.message); + } +} + +/** + * The requested resource was not found. + */ +export class NotFoundError extends HttpResponderError { + constructor() { + super(404, "Not Found"); + } +} + +/** + * The request was malformed. + */ +export class BadRequestError extends HttpResponderError { + constructor() { + super(400, "Bad Request"); + } +} + +/** + * The request is missing required authentication credentials. + */ +export class UnauthorizedError extends HttpResponderError { + constructor() { + super(401, "Unauthorized"); + } +} + +/** + * The request is missing required permissions. + */ +export class ForbiddenError extends HttpResponderError { + constructor() { + super(403, "Forbidden"); + } +} + +/** + * The request conflicts with the current state of the server. + */ +export class ConflictError extends HttpResponderError { + constructor() { + super(409, "Conflict"); + } +} + +/** + * The server encountered an unexpected condition that prevented it from fulfilling the request. + */ +export class InternalServerError extends HttpResponderError { + constructor() { + super(500, "Internal Server Error"); + } +} + +/** + * The server does not support the functionality required to fulfill the request. + */ +export class NotImplementedError extends HttpResponderError { + constructor() { + super(501, "Not Implemented"); + } +} diff --git a/src/helpers/multipart.ts b/src/helpers/multipart.ts new file mode 100644 index 0000000..53e308f --- /dev/null +++ b/src/helpers/multipart.ts @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import type * as http from "node:http"; + +export interface HttpPart { + headers: { [k: string]: string | undefined }; + body: ReadableStream; +} + +/** + * Consumes a stream of incoming data and splits it into individual streams for each part of a multipart request, using + * the provided `boundary` value. + */ +function MultipartBoundaryTransformStream( + boundary: string, +): ReadableWritablePair, Buffer> { + let buffer: Buffer = Buffer.alloc(0); + // Initialize subcontroller to an object that does nothing. Multipart bodies may contain a preamble before the first + // boundary, so this dummy controller will discard it. + let subController: { enqueue(chunk: Buffer): void; close(): void } | null = { + enqueue() {}, + close() {}, + }; + + let boundarySplit = Buffer.from(`--${boundary}`); + let initialized = false; + + // We need to keep at least the length of the boundary split plus room for CRLFCRLF in the buffer to detect the boundaries. + // We subtract one from this length because if the whole thing were in the buffer, we would detect it and move past it. + const bufferKeepLength = boundarySplit.length + BUF_CRLFCRLF.length - 1; + let _readableController: ReadableStreamDefaultController> = null as any; + + const readable = new ReadableStream>({ + start(controller) { + _readableController = controller; + }, + }); + + const readableController = _readableController; + + const writable = new WritableStream({ + write: async (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + let index: number; + + while ((index = buffer.indexOf(boundarySplit)) !== -1) { + // We found a boundary, emit everything before it and initialize a new stream for the next part. + + // We are initialized if we have found the boundary at least once. + // + // Cases + // 1. If the index is zero and we aren't initialized, there was no preamble. + // 2. If the index is zero and we are initialized, then we had to have found \r\n--boundary, nothing special to do. + // 3. If the index is not zero, and we are initialized, then we found \r\n--boundary somewhere in the middle, + // nothing special to do. + // 4. If the index is not zero and we aren't initialized, then we need to check that boundarySplit was preceded + // by \r\n for validity, because the preamble must end with \r\n. + + if (index > 0) { + if (!initialized) { + if (!buffer.subarray(index - 2, index).equals(Buffer.from("\r\n"))) { + readableController.error(new Error("Invalid preamble in multipart body.")); + } else { + await enqueueSub(buffer.subarray(0, index - 2)); + } + } else { + await enqueueSub(buffer.subarray(0, index)); + } + } + + // We enqueued everything before the boundary, so we clear the buffer past the boundary + buffer = buffer.subarray(index + boundarySplit.length); + + // We're done with the current part, so close the stream. If this is the opening boundary, there won't be a + // subcontroller yet. + subController?.close(); + subController = null; + + if (!initialized) { + initialized = true; + boundarySplit = Buffer.from(`\r\n${boundarySplit}`); + } + } + + if (buffer.length > bufferKeepLength) { + await enqueueSub(buffer.subarray(0, -bufferKeepLength)); + buffer = buffer.subarray(-bufferKeepLength); + } + }, + close() { + if (!/--(\r\n)?/.test(buffer.toString("utf-8"))) { + readableController.error(new Error("Unexpected characters after final boundary.")); + } + + subController?.close(); + + readableController.close(); + }, + }); + + async function enqueueSub(s: Buffer) { + subController ??= await new Promise((resolve) => { + readableController.enqueue( + new ReadableStream({ + start: (controller) => resolve(controller), + }), + ); + }); + + subController.enqueue(s); + } + + return { readable, writable }; +} + +const BUF_CRLFCRLF = Buffer.from("\r\n\r\n"); + +/** + * Consumes a stream of the contents of a single part of a multipart request and emits an `HttpPart` object for each part. + * This consumes just enough of the stream to read the headers, and then forwards the rest of the stream as the body. + */ +class HttpPartTransform extends TransformStream, HttpPart> { + constructor() { + super({ + transform: async (partRaw, controller) => { + const reader = partRaw.getReader(); + + let buf = Buffer.alloc(0); + let idx; + + while ((idx = buf.indexOf(BUF_CRLFCRLF)) === -1) { + const { done, value } = await reader.read(); + if (done) { + throw new Error("Unexpected end of part."); + } + buf = Buffer.concat([buf, value]); + } + + const headerText = buf.subarray(0, idx).toString("utf-8").trim(); + + const headers = Object.fromEntries( + headerText.split("\r\n").map((line) => { + const [name, value] = line.split(": ", 2); + + return [name.toLowerCase(), value]; + }), + ) as { [k: string]: string }; + + const body = new ReadableStream({ + start(controller) { + controller.enqueue(buf.subarray(idx + BUF_CRLFCRLF.length)); + }, + async pull(controller) { + const { done, value } = await reader.read(); + + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + }); + + controller.enqueue({ headers, body }); + }, + }); + } +} + +/** + * Processes a request as a multipart request, returning a stream of `HttpPart` objects, each representing an individual + * part in the multipart request. + * + * Only call this function if you have already validated the content type of the request and confirmed that it is a + * multipart request. + * + * @throws Error if the content-type header is missing or does not contain a boundary field. + * + * @param request - the incoming request to parse as multipart + * @returns a stream of HttpPart objects, each representing an individual part in the multipart request + */ +export function createMultipartReadable(request: http.IncomingMessage): ReadableStream { + const boundary = request.headers["content-type"] + ?.split(";") + .find((s) => s.includes("boundary=")) + ?.split("=", 2)[1]; + if (!boundary) { + throw new Error("Invalid request: missing boundary in content-type."); + } + + const bodyStream = new ReadableStream({ + start(controller) { + request.on("data", (chunk: Buffer) => { + controller.enqueue(chunk); + }); + request.on("end", () => controller.close()); + }, + }); + + return bodyStream + .pipeThrough(MultipartBoundaryTransformStream(boundary)) + .pipeThrough(new HttpPartTransform()); +} + +// Gross polyfill because Safari doesn't support this yet. +// +// https://bugs.webkit.org/show_bug.cgi?id=194379 +// https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#browser_compatibility +(ReadableStream.prototype as any)[Symbol.asyncIterator] ??= async function* () { + const reader = this.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return value; + yield value; + } + } finally { + reader.releaseLock(); + } +}; + +declare global { + interface ReadableStream { + [Symbol.asyncIterator](): AsyncIterableIterator; + } +} diff --git a/src/helpers/router.ts b/src/helpers/router.ts new file mode 100644 index 0000000..e07d765 --- /dev/null +++ b/src/helpers/router.ts @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import type * as http from "node:http"; + +/** A policy that can be applied to a route or a set of routes. */ +export interface Policy { + /** Optional policy name. */ + name?: string; + + /** + * Applies the policy to the request. + * + * Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate, + * and _MUST NOT_ do both. + * + * If the policy passes a `request` object to `next()`, that request object will be used instead of the original + * request object for the remainder of the policy chain. If the policy does _not_ pass a request object to `next()`, + * the same object that was passed to this policy will be forwarded to the next policy automatically. + * + * @param request - The incoming HTTP request. + * @param response - The outgoing HTTP response. + * @param next - Calls the next policy in the chain. + */ + (ctx: HttpContext, next: (request?: http.IncomingMessage) => void): void; +} + +/** + * Create a function from a chain of policies. + * + * This returns a single function that will apply the policy chain and eventually call the provided `next()` function. + * + * @param name - The name to give to the policy chain function. + * @param policies - The policies to apply to the request. + * @param out - The function to call after the policies have been applied. + */ +export function createPolicyChain void>( + name: string, + policies: Policy[], + out: Out, +): Out { + let outParams: any[]; + if (policies.length === 0) { + return out; + } + + function applyPolicy(ctx: HttpContext, index: number) { + if (index >= policies.length) { + return out(ctx, ...outParams); + } + + policies[index](ctx, function nextPolicy(nextRequest) { + applyPolicy( + { + ...ctx, + request: nextRequest ?? ctx.request, + }, + index + 1, + ); + }); + } + + return { + [name](ctx: HttpContext, ...params: any[]) { + outParams = params; + applyPolicy(ctx, 0); + }, + }[name] as Out; +} + +/** + * The type of an error encountered during request validation. + */ +export type ValidationError = string; + +/** + * An object specifying the policies for a given route configuration. + */ +export type RoutePolicies = { + [Interface in keyof RouteConfig]?: { + before?: Policy[]; + after?: Policy[]; + methodPolicies?: { + [Method in keyof RouteConfig[Interface]]?: Policy[]; + }; + }; +}; + +/** + * Create a policy chain for a given route. + * + * This function calls `createPolicyChain` internally and orders the policies based on the route configuration. + * + * Interface-level `before` policies run first, then method-level policies, then Interface-level `after` policies. + * + * @param name - The name to give to the policy chain function. + * @param routePolicies - The policies to apply to the routes (part of the route configuration). + * @param interfaceName - The name of the interface that the route belongs to. + * @param methodName - The name of the method that the route corresponds to. + * @param out - The function to call after the policies have been applied. + */ +export function createPolicyChainForRoute< + RouteConfig extends { [k: string]: object }, + InterfaceName extends keyof RouteConfig, + Out extends (ctx: HttpContext, ...rest: any[]) => void, +>( + name: string, + routePolicies: RoutePolicies, + interfaceName: InterfaceName, + methodName: keyof RouteConfig[InterfaceName], + out: Out, +): Out { + return createPolicyChain( + name, + [ + ...(routePolicies[interfaceName]?.before ?? []), + ...(routePolicies[interfaceName]?.methodPolicies?.[methodName] ?? []), + ...(routePolicies[interfaceName]?.after ?? []), + ], + out, + ); +} + +/** + * Options for configuring a router with additional functionality. + */ +export interface RouterOptions< + RouteConfig extends { [k: string]: object } = { [k: string]: object }, +> { + /** + * The base path of the router. + * + * This should include any leading slashes, but not a trailing slash, and should not include any component + * of the URL authority (e.g. the scheme, host, or port). + * + * Defaults to "". + */ + basePath?: string; + + /** + * A list of policies to apply to all routes _before_ routing. + * + * Policies are applied in the order they are listed. + * + * By default, the policy list is empty. + * + * Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate + * the response and _MUST NOT_ do both. + */ + policies?: Policy[]; + + /** + * A record of policies that apply to specific routes. + * + * The policies are provided as a nested record where the keys are the business-logic interface names, and the values + * are records of the method names in the given interface and the policies that apply to them. + * + * By default, no additional policies are applied to the routes. + * + * Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate + * the response and _MUST NOT_ do both. + */ + routePolicies?: RoutePolicies; + + /** + * A handler for requests where the resource is not found. + * + * The router will call this function when no route matches the incoming request. + * + * If this handler is not provided, a 404 Not Found response with a text body will be returned. + * + * You _MUST_ call `response.end()` to terminate the response. + * + * This handler is unreachable when using the Express middleware, as it will forward non-matching requests to the + * next middleware layer in the stack. + * + * @param ctx - The HTTP context for the request. + */ + onRequestNotFound?: (ctx: HttpContext) => void; + + /** + * A handler for requests that fail to validate inputs. + * + * If this handler is not provided, a 400 Bad Request response with a JSON body containing some basic information + * about the error will be returned to the client. + * + * You _MUST_ call `response.end()` to terminate the response. + * + * @param ctx - The HTTP context for the request. + * @param route - The route that was matched. + * @param error - The validation error that was thrown. + */ + onInvalidRequest?: (ctx: HttpContext, route: string, error: ValidationError) => void; + + /** + * A handler for requests that throw an error during processing. + * + * If this handler is not provided, a 500 Internal Server Error response with a text body and no error details will be + * returned to the client. + * + * You _MUST_ call `response.end()` to terminate the response. + * + * If this handler itself throws an Error, the router will respond with a 500 Internal Server Error + * + * @param ctx - The HTTP context for the request. + * @param error - The error that was thrown. + */ + onInternalError?(ctx: HttpContext, error: Error): void; +} + +/** Context information for operations carried over the HTTP protocol. */ +export interface HttpContext { + /** The incoming request to the server. */ + request: http.IncomingMessage; + /** The outgoing response object. */ + response: http.ServerResponse; + + /** + * Error handling functions provided by the HTTP router. Service implementations may call these methods in case a + * resource is not found, a request is invalid, or an internal error occurs. + * + * These methods will respond to the client with the appropriate status code and message. + */ + errorHandlers: { + /** + * Signals that the requested resource was not found. + */ + onRequestNotFound: Exclude; + /** + * Signals that the request was invalid. + */ + onInvalidRequest: Exclude; + /** + * Signals that an internal error occurred. + */ + onInternalError: Exclude; + }; +} diff --git a/src/http/index.ts b/src/http/index.ts new file mode 100644 index 0000000..51add8d --- /dev/null +++ b/src/http/index.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { NoTarget } from "@typespec/compiler"; +import { HttpServer, HttpService, getHttpService, getServers } from "@typespec/http"; +import { JsContext, Module, createModule } from "../ctx.js"; +import { reportDiagnostic } from "../lib.js"; +import { getOpenApi3Emitter, getOpenApi3ServiceRecord, tryGetOpenApi3 } from "../util/openapi3.js"; +import { emitRawServer } from "./server/index.js"; +import { emitRouter } from "./server/router.js"; + +/** + * Additional context items used by the HTTP emitter. + */ +export interface HttpContext extends JsContext { + /** + * The HTTP-level representation of the service. + */ + httpService: HttpService; + /** + * The root module for HTTP-specific code. + */ + httpModule: Module; + /** + * The server definitions of the service (\@server decorator) + */ + servers: HttpServer[]; +} + +/** + * Emits bindings for the service to be carried over the HTTP protocol. + */ +export async function emitHttp(ctx: JsContext) { + const [httpService, diagnostics] = getHttpService(ctx.program, ctx.service.type); + + const diagnosticsAreError = diagnostics.some((d) => d.severity === "error"); + + if (diagnosticsAreError) { + reportDiagnostic(ctx.program, { + code: "http-emit-disabled", + target: NoTarget, + messageId: "default", + }); + return; + } + + const servers = getServers(ctx.program, ctx.service.type) ?? []; + + const httpModule = createModule("http", ctx.generatedModule); + + const httpContext: HttpContext = { + ...ctx, + httpService, + httpModule, + servers, + }; + + const openapi3Emitter = await getOpenApi3Emitter(); + const openapi3 = await tryGetOpenApi3(ctx.program, ctx.service); + + if (openapi3) { + const openApiDocumentModule = createModule("openapi3", httpModule); + + openApiDocumentModule.declarations.push([ + `export const openApiDocument = ${JSON.stringify(openapi3)}`, + ]); + } else if (openapi3Emitter) { + const serviceRecord = await getOpenApi3ServiceRecord(ctx.program, ctx.service); + + reportDiagnostic(ctx.program, { + code: "openapi3-document-not-generated", + target: ctx.service.type, + messageId: serviceRecord?.versioned ? "versioned" : "unable", + }); + } + + const operationsModule = createModule("operations", httpModule); + + const serverRawModule = emitRawServer(httpContext, operationsModule); + emitRouter(httpContext, httpService, serverRawModule); +} diff --git a/src/http/server/index.ts b/src/http/server/index.ts new file mode 100644 index 0000000..03d8d12 --- /dev/null +++ b/src/http/server/index.ts @@ -0,0 +1,548 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { ModelProperty, NoTarget, Type, compilerAssert } from "@typespec/compiler"; +import { + HttpOperation, + HttpOperationParameter, + getHeaderFieldName, + isBody, + isHeader, + isStatusCode, +} from "@typespec/http"; +import { createOrGetModuleForNamespace } from "../../common/namespace.js"; +import { emitTypeReference, isValueLiteralType } from "../../common/reference.js"; +import { parseTemplateForScalar } from "../../common/scalar.js"; +import { + SerializableType, + isSerializationRequired, + requireSerialization, +} from "../../common/serialization/index.js"; +import { Module, completePendingDeclarations, createModule } from "../../ctx.js"; +import { isUnspeakable, parseCase } from "../../util/case.js"; +import { UnimplementedError } from "../../util/error.js"; +import { getAllProperties } from "../../util/extends.js"; +import { bifilter, indent } from "../../util/iter.js"; +import { keywordSafe } from "../../util/keywords.js"; +import { HttpContext } from "../index.js"; + +import { module as routerHelpers } from "../../../generated-defs/helpers/router.js"; +import { reportDiagnostic } from "../../lib.js"; +import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js"; +import { emitMultipart, emitMultipartLegacy } from "./multipart.js"; + +import { module as headerHelpers } from "../../../generated-defs/helpers/header.js"; +import { module as httpHelpers } from "../../../generated-defs/helpers/http.js"; +import { requiresJsonSerialization } from "../../common/serialization/json.js"; + +const DEFAULT_CONTENT_TYPE = "application/json"; + +/** + * Emits raw operations for handling incoming server requests. + * + * @param ctx - The HTTP emitter context. + * @param operationsModule - The module to emit the operations into. + * @returns the module containing the raw server operations. + */ +export function emitRawServer(ctx: HttpContext, operationsModule: Module): Module { + const serverRawModule = createModule("server-raw", operationsModule); + + serverRawModule.imports.push({ + binder: ["HttpContext"], + from: routerHelpers, + }); + + const isHttpResponder = ctx.gensym("isHttpResponder"); + const httpResponderSym = ctx.gensym("httpResponderSymbol"); + + serverRawModule.imports.push({ + binder: [`isHttpResponder as ${isHttpResponder}`, `HTTP_RESPONDER as ${httpResponderSym}`], + from: httpHelpers, + }); + + for (const operation of ctx.httpService.operations) { + serverRawModule.declarations.push([ + ...emitRawServerOperation(ctx, operation, serverRawModule, { + isHttpResponder, + httpResponderSym, + }), + ]); + } + + return serverRawModule; +} + +/** + * Emit a raw operation handler for a specific operation. + * @param ctx - The HTTP emitter context. + * @param operation - The operation to create a handler for. + * @param module - The module that the handler will be written to. + */ +function* emitRawServerOperation( + ctx: HttpContext, + operation: HttpOperation, + module: Module, + responderNames: Pick, +): Iterable { + const op = operation.operation; + const operationNameCase = parseCase(op.name); + + const container = op.interface ?? op.namespace!; + const containerNameCase = parseCase(container.name); + + module.imports.push({ + binder: [containerNameCase.pascalCase], + from: createOrGetModuleForNamespace(ctx, container.namespace!), + }); + + completePendingDeclarations(ctx); + + const pathParameters = operation.parameters.parameters.filter(function isPathParameter(param) { + return param.type === "path"; + }) as Extract[]; + + const functionName = keywordSafe(containerNameCase.snakeCase + "_" + operationNameCase.snakeCase); + + const names: Names = { + ctx: ctx.gensym("ctx"), + result: ctx.gensym("result"), + operations: ctx.gensym("operations"), + queryParams: ctx.gensym("queryParams"), + ...responderNames, + }; + + yield `export async function ${functionName}(`; + yield ` ${names.ctx}: HttpContext,`; + yield ` ${names.operations}: ${containerNameCase.pascalCase},`; + + for (const pathParam of pathParameters) { + yield ` ${parseCase(pathParam.param.name).camelCase}: string,`; + } + + yield "): Promise {"; + + const [_, parameters] = bifilter(op.parameters.properties.values(), (param) => + isValueLiteralType(param.type), + ); + + const queryParams: Extract[] = []; + + const parsedParams = new Set(); + + for (const parameter of operation.parameters.parameters) { + const resolvedParameter = + parameter.param.type.kind === "ModelProperty" ? parameter.param.type : parameter.param; + switch (parameter.type) { + case "header": + yield* indent(emitHeaderParamBinding(ctx, operation, names, parameter)); + break; + case "cookie": + throw new UnimplementedError("cookie parameters"); + case "query": + queryParams.push(parameter); + parsedParams.add(resolvedParameter); + break; + case "path": + // Already handled above. + parsedParams.add(resolvedParameter); + break; + default: + throw new Error( + `UNREACHABLE: parameter type ${ + (parameter satisfies never as HttpOperationParameter).type + }`, + ); + } + } + + if (queryParams.length > 0) { + yield ` const ${names.queryParams} = new URLSearchParams(${names.ctx}.request.url!.split("?", 2)[1] ?? "");`; + yield ""; + } + + for (const qp of queryParams) { + yield* indent(emitQueryParamBinding(ctx, operation, names, qp)); + } + + const bodyFields = new Map( + operation.parameters.body && operation.parameters.body.type.kind === "Model" + ? getAllProperties(operation.parameters.body.type).map((p) => [p.name, p.type] as const) + : [], + ); + + let bodyName: string | undefined = undefined; + + if (operation.parameters.body) { + const body = operation.parameters.body; + + if (body.contentTypes.length > 1) { + reportDiagnostic(ctx.program, { + code: "dynamic-request-content-type", + target: operation.operation, + }); + } + + const contentType = body.contentTypes[0] ?? DEFAULT_CONTENT_TYPE; + + const defaultBodyTypeName = operationNameCase.pascalCase + "RequestBody"; + + const bodyNameCase = parseCase(body.property?.name ?? defaultBodyTypeName); + + const bodyTypeName = emitTypeReference( + ctx, + body.type, + body.property?.type ?? operation.operation.node, + module, + { altName: defaultBodyTypeName }, + ); + + bodyName = ctx.gensym(bodyNameCase.camelCase); + + module.imports.push({ binder: ["parseHeaderValueParameters"], from: headerHelpers }); + + const contentTypeHeader = ctx.gensym("contentType"); + + yield ` const ${contentTypeHeader} = parseHeaderValueParameters(${names.ctx}.request.headers["content-type"] as string | undefined);`; + + yield ` if (${contentTypeHeader}?.value !== ${JSON.stringify(contentType)}) {`; + + yield ` return ${names.ctx}.errorHandlers.onInvalidRequest(`; + yield ` ${names.ctx},`; + yield ` ${JSON.stringify(operation.path)},`; + yield ` \`unexpected "content-type": '\${${contentTypeHeader}?.value}', expected '${JSON.stringify(contentType)}'\``; + yield ` );`; + + yield " }"; + yield ""; + + switch (contentType) { + case "application/merge-patch+json": + case "application/json": { + requireSerialization(ctx, body.type as SerializableType, "application/json"); + yield ` const ${bodyName} = await new Promise(function parse${bodyNameCase.pascalCase}(resolve, reject) {`; + yield ` const chunks: Array = [];`; + yield ` ${names.ctx}.request.on("data", function appendChunk(chunk) { chunks.push(chunk); });`; + yield ` ${names.ctx}.request.on("end", function finalize() {`; + yield ` try {`; + yield ` const body = Buffer.concat(chunks).toString();`; + + let value: string; + + if (requiresJsonSerialization(ctx, body.type)) { + value = `${bodyTypeName}.fromJsonObject(JSON.parse(body))`; + } else { + value = `JSON.parse(body)`; + } + + yield ` resolve(${value});`; + yield ` } catch {`; + yield ` ${names.ctx}.errorHandlers.onInvalidRequest(`; + yield ` ${names.ctx},`; + yield ` ${JSON.stringify(operation.path)},`; + yield ` "invalid JSON in request body",`; + yield ` );`; + yield ` reject();`; + yield ` }`; + yield ` });`; + yield ` ${names.ctx}.request.on("error", reject);`; + yield ` }) as ${bodyTypeName};`; + yield ""; + + break; + } + case "multipart/form-data": + if (body.bodyKind === "multipart") { + yield* indent( + emitMultipart(ctx, module, operation, body, names.ctx, bodyName, bodyTypeName), + ); + } else { + yield* indent(emitMultipartLegacy(names.ctx, bodyName, bodyTypeName)); + } + break; + default: + throw new UnimplementedError(`request deserialization for content-type: '${contentType}'`); + } + + yield ""; + } + + let hasOptions = false; + const optionalParams = new Map(); + + const requiredParams = []; + + for (const param of parameters) { + let paramBaseExpression; + const paramNameCase = parseCase(param.name); + const isBodyField = bodyFields.has(param.name) && bodyFields.get(param.name) === param.type; + const isBodyExact = operation.parameters.body?.property === param; + if (isBodyField) { + paramBaseExpression = `${bodyName}.${paramNameCase.camelCase}`; + } else if (isBodyExact) { + paramBaseExpression = bodyName!; + } else { + const resolvedParameter = param.type.kind === "ModelProperty" ? param.type : param; + + paramBaseExpression = + resolvedParameter.type.kind === "Scalar" && parsedParams.has(resolvedParameter) + ? parseTemplateForScalar(ctx, resolvedParameter.type).replace( + "{}", + paramNameCase.camelCase, + ) + : paramNameCase.camelCase; + } + + if (param.optional) { + hasOptions = true; + optionalParams.set(paramNameCase.camelCase, paramBaseExpression); + } else { + requiredParams.push(paramBaseExpression); + } + } + + const paramLines = requiredParams.map((p) => `${p},`); + + if (hasOptions) { + paramLines.push( + `{ ${[...optionalParams.entries()].map(([name, expr]) => (name === expr ? name : `${name}: ${expr}`)).join(", ")} }`, + ); + } + + const returnType = emitTypeReference(ctx, op.returnType, NoTarget, module, { + altName: operationNameCase.pascalCase + "Result", + }); + + yield ` let ${names.result}: ${returnType};`; + yield ""; + yield ` try {`; + yield ` ${names.result} = await ${names.operations}.${operationNameCase.camelCase}(${names.ctx}, `; + yield* indent(indent(indent(paramLines))); + yield ` );`; + yield " } catch(e) {"; + yield ` if (${names.isHttpResponder}(e)) {`; + yield ` return e[${names.httpResponderSym}](${names.ctx});`; + yield ` } else throw e;`; + yield ` }`; + yield ""; + + yield* indent(emitResultProcessing(ctx, names, op.returnType, module)); + + yield "}"; + + yield ""; +} + +interface Names { + ctx: string; + result: string; + operations: string; + queryParams: string; + isHttpResponder: string; + httpResponderSym: string; +} + +/** + * Emit the result-processing code for an operation. + * + * This code handles writing the result of calling the business logic layer to the HTTP response object. + * + * @param ctx - The HTTP emitter context. + * @param t - The return type of the operation. + * @param module - The module that the result processing code will be written to. + */ +function* emitResultProcessing( + ctx: HttpContext, + names: Names, + t: Type, + module: Module, +): Iterable { + if (t.kind !== "Union") { + // Single target type + yield* emitResultProcessingForType(ctx, names, t, module); + } else { + const codeTree = differentiateUnion(ctx, t); + + yield* writeCodeTree(ctx, codeTree, { + subject: names.result, + referenceModelProperty(p) { + return names.result + "." + parseCase(p.name).camelCase; + }, + // We mapped the output directly in the code tree input, so we can just return it. + renderResult: (t) => emitResultProcessingForType(ctx, names, t, module), + }); + } +} + +/** + * Emit the result-processing code for a single response type. + * + * @param ctx - The HTTP emitter context. + * @param target - The target type to emit processing code for. + * @param module - The module that the result processing code will be written to. + */ +function* emitResultProcessingForType( + ctx: HttpContext, + names: Names, + target: Type, + module: Module, +): Iterable { + if (target.kind === "Intrinsic") { + switch (target.name) { + case "void": + yield `${names.ctx}.response.statusCode = 204;`; + yield `${names.ctx}.response.end();`; + return; + case "null": + yield `${names.ctx}.response.statusCode = 200;`; + yield `${names.ctx}.response.setHeader("content-type", "application/json");`; + yield `${names.ctx}.response.end("null");`; + return; + case "unknown": + yield `${names.ctx}.response.statusCode = 200;`; + yield `${names.ctx}.response.setHeader("content-type", "application/json");`; + yield `${names.ctx}.response.end(JSON.stringify(${names.result}));`; + return; + case "never": + yield `return ${names.ctx}.errorHandlers.onInternalError(${names.ctx}, "Internal server error.");`; + return; + default: + throw new UnimplementedError(`result processing for intrinsic type '${target.name}'`); + } + } + + if (target.kind !== "Model") { + throw new UnimplementedError(`result processing for type kind '${target.kind}'`); + } + + const body = [...target.properties.values()].find((p) => isBody(ctx.program, p)); + + for (const property of target.properties.values()) { + if (isHeader(ctx.program, property)) { + const headerName = getHeaderFieldName(ctx.program, property); + yield `${names.ctx}.response.setHeader(${JSON.stringify(headerName.toLowerCase())}, ${names.result}.${parseCase(property.name).camelCase});`; + if (!body) yield `delete (${names.result} as any).${parseCase(property.name).camelCase};`; + } else if (isStatusCode(ctx.program, property)) { + if (isUnspeakable(property.name)) { + if (!isValueLiteralType(property.type)) { + reportDiagnostic(ctx.program, { + code: "unspeakable-status-code", + target: property, + format: { + name: property.name, + }, + }); + continue; + } + + compilerAssert(property.type.kind === "Number", "Status code must be a number."); + + yield `${names.ctx}.response.statusCode = ${property.type.valueAsString};`; + } else { + yield `${names.ctx}.response.statusCode = ${names.result}.${parseCase(property.name).camelCase};`; + if (!body) yield `delete (${names.result} as any).${parseCase(property.name).camelCase};`; + } + } + } + + const allMetadataIsRemoved = + !body && + [...target.properties.values()].every((p) => { + return isHeader(ctx.program, p) || isStatusCode(ctx.program, p); + }); + + if (body) { + const bodyCase = parseCase(body.name); + const serializationRequired = isSerializationRequired(ctx, body.type, "application/json"); + requireSerialization(ctx, body.type, "application/json"); + + yield `${names.ctx}.response.setHeader("content-type", "application/json");`; + + if (serializationRequired) { + const typeReference = emitTypeReference(ctx, body.type, body, module, { + requireDeclaration: true, + }); + yield `${names.ctx}.response.end(JSON.stringify(${typeReference}.toJsonObject(${names.result}.${bodyCase.camelCase})))`; + } else { + yield `${names.ctx}.response.end(JSON.stringify(${names.result}.${bodyCase.camelCase}));`; + } + } else { + if (allMetadataIsRemoved) { + yield `${names.ctx}.response.end();`; + } else { + const serializationRequired = isSerializationRequired(ctx, target, "application/json"); + requireSerialization(ctx, target, "application/json"); + + yield `${names.ctx}.response.setHeader("content-type", "application/json");`; + + if (serializationRequired) { + const typeReference = emitTypeReference(ctx, target, target, module, { + requireDeclaration: true, + }); + yield `${names.ctx}.response.end(JSON.stringify(${typeReference}.toJsonObject(${names.result} as ${typeReference})));`; + } else { + yield `${names.ctx}.response.end(JSON.stringify(${names.result}));`; + } + } + } +} + +/** + * Emit code that binds a given header parameter to a variable. + * + * If the parameter is not optional, this will also emit a test to ensure that the parameter is present. + * + * @param ctx - The HTTP emitter context. + * @param parameter - The header parameter to bind. + */ +function* emitHeaderParamBinding( + ctx: HttpContext, + operation: HttpOperation, + names: Names, + parameter: Extract, +): Iterable { + const nameCase = parseCase(parameter.param.name); + const headerName = parameter.name.toLowerCase(); + + // See https://nodejs.org/api/http.html#messageheaders + // Apparently, only set-cookie can be an array. + const canBeArrayType = parameter.name === "set-cookie"; + + const assertion = canBeArrayType ? "" : " as string | undefined"; + + yield `const ${nameCase.camelCase} = ${names.ctx}.request.headers[${JSON.stringify(headerName)}]${assertion};`; + + if (!parameter.param.optional) { + yield `if (${nameCase.camelCase} === undefined) {`; + // prettier-ignore + yield ` return ${names.ctx}.errorHandlers.onInvalidRequest(${names.ctx}, ${JSON.stringify(operation.path)}, "missing required header '${headerName}'");`; + yield "}"; + yield ""; + } +} + +/** + * Emit code that binds a given query parameter to a variable. + * + * If the parameter is not optional, this will also emit a test to ensure that the parameter is present. + * + * @param ctx - The HTTP emitter context + * @param parameter - The query parameter to bind + */ +function* emitQueryParamBinding( + ctx: HttpContext, + operation: HttpOperation, + names: Names, + parameter: Extract, +): Iterable { + const nameCase = parseCase(parameter.param.name); + + // UrlSearchParams annoyingly returns null for missing parameters instead of undefined. + yield `const ${nameCase.camelCase} = ${names.queryParams}.get(${JSON.stringify(parameter.name)}) ?? undefined;`; + + if (!parameter.param.optional) { + yield `if (!${nameCase.camelCase}) {`; + yield ` return ${names.ctx}.errorHandlers.onInvalidRequest(${names.ctx}, ${JSON.stringify(operation.path)}, "missing required query parameter '${parameter.name}'");`; + yield "}"; + yield ""; + } +} diff --git a/src/http/server/multipart.ts b/src/http/server/multipart.ts new file mode 100644 index 0000000..6ec51fb --- /dev/null +++ b/src/http/server/multipart.ts @@ -0,0 +1,272 @@ +import { HttpOperation, HttpOperationMultipartBody, isHttpFile } from "@typespec/http"; +import { Module } from "../../ctx.js"; +import { HttpContext } from "../index.js"; + +import { module as headerHelpers } from "../../../generated-defs/helpers/header.js"; +import { module as multipartHelpers } from "../../../generated-defs/helpers/multipart.js"; +import { emitTypeReference } from "../../common/reference.js"; +import { requireSerialization } from "../../common/serialization/index.js"; +import { requiresJsonSerialization } from "../../common/serialization/json.js"; +import { parseCase } from "../../util/case.js"; +import { UnimplementedError } from "../../util/error.js"; + +/** + * Parse a multipart request body according to the given body spec. + * + * @param ctx - The emitter context. + * @param module - The module that this parser is written into. + * @param operation - The HTTP operation this body is being parsed for. + * @param body - The multipart body spec + * @param bodyName - The name of the variable to store the parsed body in. + * @param bodyTypeName - The name of the type of the parsed body. + */ +export function* emitMultipart( + ctx: HttpContext, + module: Module, + operation: HttpOperation, + body: HttpOperationMultipartBody, + ctxName: string, + bodyName: string, + bodyTypeName: string, +): Iterable { + module.imports.push( + { binder: ["parseHeaderValueParameters"], from: headerHelpers }, + { binder: ["createMultipartReadable"], from: multipartHelpers }, + ); + + yield `const ${bodyName} = await new Promise<${bodyTypeName}>(`; + yield `// eslint-disable-next-line no-async-promise-executor`; + yield `async function parse${bodyTypeName}MultipartRequest(resolve, reject) {`; + + // Wrap this whole thing in a try/catch because the executor is async. If anything in here throws, we want to reject the promise instead of + // just letting the executor die and the promise never settle. + yield ` try {`; + + const stream = ctx.gensym("stream"); + + yield ` const ${stream} = createMultipartReadable(${ctxName}.request);`; + yield ""; + + const contentDisposition = ctx.gensym("contentDisposition"); + const contentType = ctx.gensym("contentType"); + const name = ctx.gensym("name"); + const fields = ctx.gensym("fields"); + + yield ` const ${fields}: { [k: string]: any } = {};`; + yield ""; + + const partsWithMulti = body.parts.filter((part) => part.name && part.multi); + const anonymousParts = body.parts.filter((part) => !part.name); + const anonymousPartsAreMulti = anonymousParts.some((part) => part.multi); + + if (anonymousParts.length > 0) { + throw new UnimplementedError("Anonymous parts are not yet supported in multipart parsing."); + } + + let hadMulti = false; + + for (const partWithMulti of partsWithMulti) { + if (!partWithMulti.optional) { + hadMulti = true; + const name = partWithMulti.name!; + + const propName = parseCase(name).camelCase; + + yield ` ${fields}.${propName} = [];`; + } + } + + if (anonymousPartsAreMulti) { + hadMulti = true; + yield ` const ${fields}.__anonymous = [];`; + } + + if (hadMulti) yield ""; + + const partName = ctx.gensym("part"); + + yield ` for await (const ${partName} of ${stream}) {`; + yield ` const ${contentDisposition} = parseHeaderValueParameters(${partName}.headers["content-disposition"]);`; + yield ` if (!${contentDisposition}) {`; + yield ` return reject("Invalid request: missing content-disposition in part.");`; + yield ` }`; + yield ""; + yield ` const ${contentType} = parseHeaderValueParameters(${partName}.headers["content-type"]);`; + yield ""; + yield ` const ${name} = ${contentDisposition}.params.name ?? "";`; + yield ""; + yield ` switch (${name}) {`; + + for (const namedPart of body.parts.filter((part) => part.name)) { + // TODO: this is wrong. The name of the part is not necessarily the name of the property in the body. + // The HTTP library does not provide the property that maps to this part if it's explicitly named. + const propName = parseCase(namedPart.name!).camelCase; + + let value = ctx.gensym("value"); + + yield ` case ${JSON.stringify(namedPart.name)}: {`; + // HTTP API is doing too much work for us. I need to know whether I'm looking at an HTTP file, and the only way to do that is to + // look at the model that the body is a property of. This is more than a bit of a hack, but it will work for now. + if ( + namedPart.body.contentTypeProperty?.model && + isHttpFile(ctx.program, namedPart.body.contentTypeProperty.model) + ) { + // We have an http file, so we will buffer the body and then optionally get the filename and content type. + // TODO: support models that inherit from File and have other optional metadata. The Http.File structure + // doesn't make this easy to do, since it doesn't describe where the fields of the file come from in the + // multipart request. However, we could recognize models that extend File and handle the special fields + // of Http.File specially. + // TODO: find a way to avoid buffering the entire file in memory. I have to do this to return an object that + // has the keys described in the TypeSpec model and because the underlying multipart stream has to be + // drained sequentially. Server authors could stall the stream by trying to read part bodies out of order if + // I represented the file contents as a stream. We will need some way to identify the whole multipart + // envelope and represent it as a stream of named parts. The backend for multipart streaming supports this, + // and it's how we receive the part data in this handler, but we don't have a way to represent it to the + // implementor yet. + + yield ` const __chunks = [];`; + yield ""; + yield ` for await (const __chunk of ${partName}.body) {`; + yield ` __chunks.push(__chunk);`; + yield ` }`; + yield ""; + + yield ` const ${value}: { filename?: string; contentType?: string; contents: Buffer; } = { contents: Buffer.concat(__chunks) };`; + yield ""; + + yield ` if (${contentType}) {`; + yield ` ${value}.contentType = ${contentType}.verbatim;`; + yield ` }`; + yield ""; + + yield ` const __filename = ${contentDisposition}.params.filename;`; + yield ` if (__filename) {`; + yield ` ${value}.filename = __filename;`; + yield ` }`; + } else { + // Not a file. We just use the given content-type to determine how to parse the body. + + yield ` if (${contentType}?.value && ${contentType}.value !== "application/json") {`; + yield ` throw new Error("Unsupported content-type for part: " + ${contentType}.value);`; + yield ` }`; + yield ""; + + if (namedPart.headers.length > 0) { + // TODO: support reconstruction of mixed objects with headers and bodies. + throw new UnimplementedError( + "Named parts with headers are not yet supported in multipart parsing.", + ); + } + + yield ` const __chunks = [];`; + yield ""; + yield ` for await (const __chunk of ${partName}.body) {`; + yield ` __chunks.push(__chunk);`; + yield ` }`; + + yield ` const __object = JSON.parse(Buffer.concat(__chunks).toString("utf-8"));`; + yield ""; + + if (requiresJsonSerialization(ctx, namedPart.body.type)) { + const bodyTypeReference = emitTypeReference( + ctx, + namedPart.body.type, + namedPart.body.property ?? namedPart.body.type, + module, + { altName: bodyTypeName + "Body", requireDeclaration: true }, + ); + + requireSerialization(ctx, namedPart.body.type, "application/json"); + + value = `${bodyTypeReference}.fromJsonObject(__object)`; + } else { + value = "__object"; + } + } + if (namedPart.multi) { + if (namedPart.optional) { + yield ` (${fields}.${propName} ??= []).push(${value});`; + } else { + yield ` ${fields}.${propName}.push(${value});`; + } + } else { + yield ` ${fields}.${propName} = ${value};`; + } + yield ` break;`; + yield ` }`; + } + + if (anonymousParts.length > 0) { + yield ` "": {`; + if (anonymousPartsAreMulti) { + yield ` ${fields}.__anonymous.push({`; + yield ` headers: ${partName}.headers,`; + yield ` body: ${partName}.body,`; + yield ` });`; + yield ` break;`; + } else { + yield ` ${fields}.__anonymous = {}`; + yield ` break;`; + } + yield ` }`; + } + + yield ` default: {`; + yield ` reject("Invalid request: unknown part name.");`; + yield ` return;`; + yield ` }`; + yield ` }`; + yield ` }`; + yield ""; + + yield ` resolve(${fields} as ${bodyTypeName});`; + + yield ` } catch (err) { reject(err); }`; + + yield "});"; +} + +// This function is old and broken. I'm not likely to fix it unless we decide to continue supporting legacy multipart +// parsing after 1.0. +export function* emitMultipartLegacy( + ctxName: string, + bodyName: string, + bodyTypeName: string, +): Iterable { + yield `const ${bodyName} = await new Promise(function parse${bodyTypeName}MultipartRequest(resolve, reject) {`; + yield ` const boundary = ${ctxName}.request.headers["content-type"]?.split(";").find((s) => s.includes("boundary="))?.split("=", 2)[1];`; + yield ` if (!boundary) {`; + yield ` return reject("Invalid request: missing boundary in content-type.");`; + yield ` }`; + yield ""; + yield ` const chunks: Array = [];`; + yield ` ${ctxName}.request.on("data", function appendChunk(chunk) { chunks.push(chunk); });`; + yield ` ${ctxName}.request.on("end", function finalize() {`; + yield ` const text = Buffer.concat(chunks).toString();`; + yield ` const parts = text.split(boundary).slice(1, -1);`; + yield ` const fields: { [k: string]: any } = {};`; + yield ""; + yield ` for (const part of parts) {`; + yield ` const [headerText, body] = part.split("\\r\\n\\r\\n", 2);`; + yield " const headers = Object.fromEntries("; + yield ` headerText.split("\\r\\n").map((line) => line.split(": ", 2))`; + yield " ) as { [k: string]: string };"; + yield ` const name = headers["Content-Disposition"].split("name=\\"")[1].split("\\"")[0];`; + yield ` const contentType = headers["Content-Type"] ?? "text/plain";`; + yield ""; + yield ` switch (contentType) {`; + yield ` case "application/json":`; + yield ` fields[name] = JSON.parse(body);`; + yield ` break;`; + yield ` case "application/octet-stream":`; + yield ` fields[name] = Buffer.from(body, "utf-8");`; + yield ` break;`; + yield ` default:`; + yield ` fields[name] = body;`; + yield ` }`; + yield ` }`; + yield ""; + yield ` resolve(fields as ${bodyTypeName});`; + yield ` });`; + yield `}) as ${bodyTypeName};`; +} diff --git a/src/http/server/router.ts b/src/http/server/router.ts new file mode 100644 index 0000000..3d57d02 --- /dev/null +++ b/src/http/server/router.ts @@ -0,0 +1,686 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Operation, Type } from "@typespec/compiler"; +import { + HttpOperation, + HttpService, + HttpVerb, + OperationContainer, + getHttpOperation, +} from "@typespec/http"; +import { + createOrGetModuleForNamespace, + emitNamespaceInterfaceReference, +} from "../../common/namespace.js"; +import { emitTypeReference } from "../../common/reference.js"; +import { Module, createModule } from "../../ctx.js"; +import { ReCase, parseCase } from "../../util/case.js"; +import { bifilter, indent } from "../../util/iter.js"; +import { keywordSafe } from "../../util/keywords.js"; +import { HttpContext } from "../index.js"; + +import { module as headerHelpers } from "../../../generated-defs/helpers/header.js"; +import { module as routerHelper } from "../../../generated-defs/helpers/router.js"; +import { parseHeaderValueParameters } from "../../helpers/header.js"; +import { reportDiagnostic } from "../../lib.js"; +import { UnimplementedError } from "../../util/error.js"; + +/** + * Emit a router for the HTTP operations defined in a given service. + * + * The generated router will use optimal prefix matching to dispatch requests to the appropriate underlying + * implementation using the raw server. + * + * @param ctx - The emitter context. + * @param service - The HTTP service to emit a router for. + * @param serverRawModule - The module that contains the raw server implementation. + */ +export function emitRouter(ctx: HttpContext, service: HttpService, serverRawModule: Module) { + const routerModule = createModule("router", ctx.httpModule); + + const routeTree = createRouteTree(ctx, service); + + routerModule.imports.push({ + binder: "* as http", + from: "node:http", + }); + + routerModule.imports.push({ + binder: "* as serverRaw", + from: serverRawModule, + }); + + routerModule.imports.push({ + binder: ["parseHeaderValueParameters"], + from: headerHelpers, + }); + + routerModule.declarations.push([...emitRouterDefinition(ctx, service, routeTree, routerModule)]); +} + +/** + * Writes the code for a router of a given service. + * + * @param ctx - The emitter context. + * @param service - The HTTP service to emit a router for. + * @param routeTree - The service's route tree. + * @param module - The module we're writing to. + */ +function* emitRouterDefinition( + ctx: HttpContext, + service: HttpService, + routeTree: RouteTree, + module: Module, +): Iterable { + const routerName = parseCase(service.namespace.name).pascalCase + "Router"; + + const uniqueContainers = new Set(service.operations.map((operation) => operation.container)); + + const backends = new Map(); + + for (const container of uniqueContainers) { + const param = parseCase(container.name); + + const traitConstraint = + container.kind === "Namespace" + ? emitNamespaceInterfaceReference(ctx, container, module) + : emitTypeReference(ctx, container, container, module); + + module.imports.push({ + binder: [param.pascalCase], + from: createOrGetModuleForNamespace(ctx, container.namespace!), + }); + + backends.set(container, [param, traitConstraint]); + } + + module.imports.push({ + binder: ["RouterOptions", "createPolicyChain", "createPolicyChainForRoute", "HttpContext"], + from: routerHelper, + }); + + yield `export interface ${routerName} {`; + yield ` /**`; + yield ` * Dispatches the request to the appropriate service based on the request path.`; + yield ` *`; + yield ` * This member function may be used directly as a handler for a Node HTTP server.`; + yield ` *`; + yield ` * @param request - The incoming HTTP request.`; + yield ` * @param response - The outgoing HTTP response.`; + yield ` */`; + yield ` dispatch(request: http.IncomingMessage, response: http.ServerResponse): void;`; + + if (ctx.options.express) { + yield ""; + yield ` /**`; + yield ` * An Express middleware function that dispatches the request to the appropriate service based on the request path.`; + yield ` *`; + yield ` * This member function may be used directly as an application-level middleware function in an Express app.`; + yield ` *`; + yield ` * If the router does not match a route, it will call the \`next\` middleware registered with the application,`; + yield ` * so it is sensible to insert this middleware at the beginning of the middleware stack.`; + yield ` *`; + yield ` * @param req - The incoming HTTP request.`; + yield ` * @param res - The outgoing HTTP response.`; + yield ` * @param next - The next middleware function in the stack.`; + yield ` */`; + yield ` expressMiddleware(req: http.IncomingMessage, res: http.ServerResponse, next: () => void): void;`; + } + + yield "}"; + yield ""; + + yield `export function create${routerName}(`; + + for (const [param] of backends.values()) { + yield ` ${param.camelCase}: ${param.pascalCase},`; + } + + yield ` options: RouterOptions<{`; + for (const [param] of backends.values()) { + yield ` ${param.camelCase}: ${param.pascalCase},`; + } + yield ` }> = {}`; + yield `): ${routerName} {`; + + const [onRequestNotFound, onInvalidRequest, onInternalError] = [ + "onRequestNotFound", + "onInvalidRequest", + "onInternalError", + ].map(ctx.gensym); + + // Router error case handlers + yield ` const ${onRequestNotFound} = options.onRequestNotFound ?? ((ctx) => {`; + yield ` ctx.response.statusCode = 404;`; + yield ` ctx.response.setHeader("Content-Type", "text/plain");`; + yield ` ctx.response.end("Not Found");`; + yield ` });`; + yield ""; + yield ` const ${onInvalidRequest} = options.onInvalidRequest ?? ((ctx, route, error) => {`; + yield ` ctx.response.statusCode = 400;`; + yield ` ctx.response.setHeader("Content-Type", "application/json");`; + yield ` ctx.response.end(JSON.stringify({ error }));`; + yield ` });`; + yield ""; + yield ` const ${onInternalError} = options.onInternalError ?? ((ctx, error) => {`; + yield ` ctx.response.statusCode = 500;`; + yield ` ctx.response.setHeader("Content-Type", "text/plain");`; + yield ` ctx.response.end("Internal server error.");`; + yield ` });`; + yield ""; + + const routePolicies = ctx.gensym("routePolicies"); + const routeHandlers = ctx.gensym("routeHandlers"); + + yield ` const ${routePolicies} = options.routePolicies ?? {};`; + yield ""; + yield ` const ${routeHandlers} = {`; + + // Policy chains for each operation + for (const operation of service.operations) { + const operationName = parseCase(operation.operation.name); + const containerName = parseCase(operation.container.name); + + yield ` ${containerName.snakeCase}_${operationName.snakeCase}: createPolicyChainForRoute(`; + yield ` "${containerName.camelCase + operationName.pascalCase + "Dispatch"}",`; + yield ` ${routePolicies},`; + yield ` "${containerName.camelCase}",`; + yield ` "${operationName.camelCase}",`; + yield ` serverRaw.${containerName.snakeCase}_${operationName.snakeCase},`; + yield ` ),`; + } + + yield ` } as const;`; + yield ""; + + // Core routing function definition + yield ` const dispatch = createPolicyChain("${routerName}Dispatch", options.policies ?? [], async function(ctx, request, response) {`; + yield ` const url = new URL(request.url!, \`http://\${request.headers.host}\`);`; + yield ` let path = url.pathname;`; + yield ""; + + yield* indent(indent(emitRouteHandler(ctx, routeHandlers, routeTree, backends, module))); + + yield ""; + + yield ` return ctx.errorHandlers.onRequestNotFound(ctx);`; + yield ` });`; + yield ""; + + const errorHandlers = ctx.gensym("errorHandlers"); + + yield ` const ${errorHandlers} = {`; + yield ` onRequestNotFound: ${onRequestNotFound},`; + yield ` onInvalidRequest: ${onInvalidRequest},`; + yield ` onInternalError: ${onInternalError},`; + yield ` };`; + + yield ` return {`; + yield ` dispatch(request, response) {`; + yield ` const ctx = { request, response, errorHandlers: ${errorHandlers} };`; + yield ` return dispatch(ctx, request, response).catch((e) => ${onInternalError}(ctx, e));`; + yield ` },`; + + if (ctx.options.express) { + yield ` expressMiddleware: function (request, response, next) {`; + yield ` const ctx = { request, response, errorHandlers: ${errorHandlers} };`; + yield ` void dispatch(`; + yield ` { request, response, errorHandlers: {`; + yield ` ...${errorHandlers},`; + yield ` onRequestNotFound: function () { next() }`; + yield ` }},`; + yield ` request,`; + yield ` response`; + yield ` ).catch((e) => ${onInternalError}(ctx, e));`; + yield ` },`; + } + + yield " }"; + yield "}"; +} + +/** + * Writes handling code for a single route tree node. + * + * @param ctx - The emitter context. + * @param routeTree - The route tree node to write handling code for. + * @param backends - The map of backends for operations. + * @param module - The module we're writing to. + */ +function* emitRouteHandler( + ctx: HttpContext, + routeHandlers: string, + routeTree: RouteTree, + backends: Map, + module: Module, +): Iterable { + const mustTerminate = routeTree.edges.length === 0 && !routeTree.bind; + + const onRouteNotFound = "ctx.errorHandlers.onRequestNotFound"; + + yield `if (path.length === 0) {`; + if (routeTree.operations.size > 0) { + yield* indent(emitRouteOperationDispatch(ctx, routeHandlers, routeTree.operations, backends)); + } else { + // Not found + yield ` return ${onRouteNotFound}(ctx);`; + } + yield `}`; + + if (mustTerminate) { + // Not found + yield "else {"; + yield ` return ${onRouteNotFound}(ctx);`; + yield `}`; + return; + } + + for (const [edge, nextTree] of routeTree.edges) { + const edgePattern = edge.length === 1 ? `'${edge}'` : JSON.stringify(edge); + yield `else if (path.startsWith(${edgePattern})) {`; + yield ` path = path.slice(${edge.length});`; + yield* indent(emitRouteHandler(ctx, routeHandlers, nextTree, backends, module)); + yield "}"; + } + + if (routeTree.bind) { + const [parameterSet, nextTree] = routeTree.bind; + const parameters = [...parameterSet]; + + yield `else {`; + const paramName = parameters.length === 1 ? parameters[0] : "param"; + const idxName = `__${parseCase(paramName).snakeCase}_idx`; + yield ` let ${idxName} = path.indexOf("/");`; + yield ` ${idxName} = ${idxName} === -1 ? path.length : ${idxName};`; + yield ` const ${paramName} = path.slice(0, ${idxName});`; + yield ` path = path.slice(${idxName});`; + if (parameters.length !== 1) { + for (const p of parameters) { + yield ` const ${parseCase(p).camelCase} = param;`; + } + } + yield* indent(emitRouteHandler(ctx, routeHandlers, nextTree, backends, module)); + + yield `}`; + } +} + +/** + * Writes the dispatch code for a specific set of operations mapped to the same route. + * + * @param ctx - The emitter context. + * @param operations - The operations mapped to the route. + * @param backends - The map of backends for operations. + */ +function* emitRouteOperationDispatch( + ctx: HttpContext, + routeHandlers: string, + operations: Map, + backends: Map, +): Iterable { + yield `switch (request.method) {`; + for (const [verb, operationList] of operations.entries()) { + if (operationList.length === 1) { + const operation = operationList[0]; + const [backend] = backends.get(operation.container)!; + const operationName = keywordSafe( + backend.snakeCase + "_" + parseCase(operation.operation.name).snakeCase, + ); + + const backendMemberName = backend.camelCase; + + const parameters = + operation.parameters.length > 0 + ? ", " + operation.parameters.map((param) => parseCase(param.name).camelCase).join(", ") + : ""; + + yield ` case ${JSON.stringify(verb.toUpperCase())}:`; + yield ` return ${routeHandlers}.${operationName}(ctx, ${backendMemberName}${parameters});`; + } else { + // Shared route + const route = getHttpOperation(ctx.program, operationList[0].operation)[0].path; + yield ` case ${JSON.stringify(verb.toUpperCase())}:`; + yield* indent( + indent( + emitRouteOperationDispatchMultiple(ctx, routeHandlers, operationList, route, backends), + ), + ); + } + } + + yield ` default:`; + yield ` return ctx.errorHandlers.onRequestNotFound(ctx);`; + + yield "}"; +} + +/** + * Writes the dispatch code for a specific set of operations mapped to the same route. + * + * @param ctx - The emitter context. + * @param operations - The operations mapped to the route. + * @param backends - The map of backends for operations. + */ +function* emitRouteOperationDispatchMultiple( + ctx: HttpContext, + routeHandlers: string, + operations: RouteOperation[], + route: string, + backends: Map, +): Iterable { + const usedContentTypes = new Set(); + const contentTypeMap = new Map(); + + for (const operation of operations) { + const [httpOperation] = getHttpOperation(ctx.program, operation.operation); + const operationContentType = httpOperation.parameters.parameters.find( + (param) => param.type === "header" && param.name.toLowerCase() === "content-type", + )?.param.type; + + if (!operationContentType || operationContentType.kind !== "String") { + throw new UnimplementedError( + "Only string content-types are supported for route differentiation.", + ); + } + + if (usedContentTypes.has(operationContentType.value)) { + reportDiagnostic(ctx.program, { + code: "undifferentiable-route", + target: httpOperation.operation, + }); + } + + usedContentTypes.add(operationContentType.value); + + contentTypeMap.set(operation, operationContentType.value); + } + + const contentTypeName = ctx.gensym("contentType"); + + yield `const ${contentTypeName} = parseHeaderValueParameters(request.headers["content-type"])?.value;`; + + yield `switch (${contentTypeName}) {`; + + for (const [operation, contentType] of contentTypeMap.entries()) { + const [backend] = backends.get(operation.container)!; + const operationName = keywordSafe( + backend.snakeCase + "_" + parseCase(operation.operation.name).snakeCase, + ); + + const backendMemberName = backend.camelCase; + + const parameters = + operation.parameters.length > 0 + ? ", " + operation.parameters.map((param) => parseCase(param.name).camelCase).join(", ") + : ""; + + const contentTypeValue = parseHeaderValueParameters(contentType).value; + + yield ` case ${JSON.stringify(contentTypeValue)}:`; + yield ` return ${routeHandlers}.${operationName}(ctx, ${backendMemberName}${parameters});`; + } + + yield ` default:`; + yield ` return ctx.errorHandlers.onInvalidRequest(ctx, ${JSON.stringify(route)}, \`No operation in route '${route}' matched content-type "\${${contentTypeName}}"\`);`; + yield "}"; +} + +/** + * A tree of routes in an HTTP router domain. + */ +interface RouteTree { + /** + * A list of operations that can be dispatched at this node. + */ + operations: Map; + /** + * A set of parameters that are bound in this position before proceeding along the subsequent tree. + */ + bind?: [Set, RouteTree]; + /** + * A list of static edges that can be taken from this node. + */ + edges: RouteTreeEdge[]; +} + +/** + * An edge in the route tree. The edge contains a literal string prefix that must match before the next node is visited. + */ +type RouteTreeEdge = readonly [string, RouteTree]; + +/** + * An operation that may be dispatched at a given tree node. + */ +interface RouteOperation { + /** + * The HTTP operation corresponding to this route operation. + */ + operation: Operation; + /** + * The operation's container. + */ + container: OperationContainer; + /** + * The path parameters that the route template for this operation binds. + */ + parameters: RouteParameter[]; + /** + * The HTTP verb (GET, PUT, etc.) that this operation requires. + */ + verb: HttpVerb; +} + +/** + * A single route split into segments of strings and parameters. + */ +interface Route extends RouteOperation { + segments: RouteSegment[]; +} + +/** + * A segment of a single route. + */ +type RouteSegment = string | RouteParameter; + +/** + * A parameter in the route segment with its expected type. + */ +interface RouteParameter { + name: string; + type: Type; +} + +/** + * Create a route tree for a given service. + */ +function createRouteTree(ctx: HttpContext, service: HttpService): RouteTree { + // First get the Route for each operation in the service. + const routes = service.operations.map(function (operation) { + const segments = getRouteSegments(ctx, operation); + return { + operation: operation.operation, + container: operation.container, + verb: operation.verb, + parameters: segments.filter((segment) => typeof segment !== "string"), + segments, + } as Route; + }); + + // Build the tree by iteratively removing common prefixes from the text segments. + + const tree = intoRouteTree(routes); + + return tree; +} + +/** + * Build a route tree from a list of routes. + * + * This iteratively removes common segments from the routes and then for all routes matching a given common prefix, + * builds a nested tree from their subsequent segments. + * + * @param routes - the routes to build the tree from + */ +function intoRouteTree(routes: Route[]): RouteTree { + const [operations, rest] = bifilter(routes, (route) => route.segments.length === 0); + const [literal, parameterized] = bifilter( + rest, + (route) => typeof route.segments[0]! === "string", + ); + + const edgeMap = new Map(); + + // Group the routes by common prefix + + outer: for (const literalRoute of literal) { + const segment = literalRoute.segments[0] as string; + + for (const edge of [...edgeMap.keys()]) { + const prefix = commonPrefix(segment, edge); + + if (prefix.length > 0) { + const existing = edgeMap.get(edge)!; + edgeMap.delete(edge); + edgeMap.set(prefix, [...existing, literalRoute]); + continue outer; + } + } + + edgeMap.set(segment, [literalRoute]); + } + + const edges = [...edgeMap.entries()].map( + ([edge, routes]) => + [ + edge, + intoRouteTree( + routes.map(function removePrefix(route) { + const [prefix, ...rest] = route.segments as [string, ...RouteSegment[]]; + + if (prefix === edge) { + return { ...route, segments: rest }; + } else { + return { + ...route, + segments: [prefix.substring(edge.length), ...rest], + }; + } + }), + ), + ] as const, + ); + + let bind: [Set, RouteTree] | undefined; + + if (parameterized.length > 0) { + const parameters = new Set(); + const nextRoutes: Route[] = []; + for (const parameterizedRoute of parameterized) { + const [{ name }, ...rest] = parameterizedRoute.segments as [ + RouteParameter, + ...RouteSegment[], + ]; + + parameters.add(name); + nextRoutes.push({ ...parameterizedRoute, segments: rest }); + } + + bind = [parameters, intoRouteTree(nextRoutes)]; + } + + const operationMap = new Map(); + + for (const operation of operations) { + let operations = operationMap.get(operation.verb); + if (!operations) { + operations = []; + operationMap.set(operation.verb, operations); + } + + operations.push(operation); + } + + return { + operations: operationMap, + bind, + edges, + }; + + function commonPrefix(a: string, b: string): string { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) { + i++; + } + return a.substring(0, i); + } +} + +function getRouteSegments(ctx: HttpContext, operation: HttpOperation): RouteSegment[] { + // Parse the route template into segments of "prefixes" (i.e. literal strings) + // and parameters (names enclosed in curly braces). The "/" character does not + // actually matter for this. We just want to know what the segments of the route + // are. + // + // Examples: + // "" => [] + // "/users" => ["/users"] + // "/users/{userId}" => ["/users/", {name: "userId"}] + // "/users/{userId}/posts/{postId}" => ["/users/", {name: "userId"}, "/posts/", {name: "postId"}] + + const segments: RouteSegment[] = []; + + const parameterTypeMap = new Map( + [...operation.parameters.parameters.values()].map( + (p) => + [ + p.param.name, + p.param.type.kind === "ModelProperty" ? p.param.type.type : p.param.type, + ] as const, + ), + ); + + let remainingTemplate = operation.path; + + while (remainingTemplate.length > 0) { + // Scan for next `{` character + const openBraceIndex = remainingTemplate.indexOf("{"); + + if (openBraceIndex === -1) { + // No more parameters, just add the remaining string as a segment + segments.push(remainingTemplate); + break; + } + + // Add the prefix before the parameter, if there is one + if (openBraceIndex > 0) { + segments.push(remainingTemplate.substring(0, openBraceIndex)); + } + + // Scan for next `}` character + const closeBraceIndex = remainingTemplate.indexOf("}", openBraceIndex); + + if (closeBraceIndex === -1) { + // This is an error in the HTTP layer, so we'll just treat it as if the parameter ends here + // and captures the rest of the string as its name. + segments.push({ + name: remainingTemplate.substring(openBraceIndex + 1), + type: undefined as any, + }); + break; + } + + // Extract the parameter name + const parameterName = remainingTemplate.substring(openBraceIndex + 1, closeBraceIndex); + + segments.push({ + name: parameterName, + type: parameterTypeMap.get(parameterName)!, + }); + + // Move to the next segment + remainingTemplate = remainingTemplate.substring(closeBraceIndex + 1); + } + + return segments; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..32c55c3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { EmitContext } from "@typespec/compiler"; +import { visitAllTypes } from "./common/namespace.js"; +import { createInitialContext } from "./ctx.js"; +import { JsEmitterOptions } from "./lib.js"; +import { writeModuleTree } from "./write.js"; + +// #region features + +import path from "node:path"; +import { emitSerialization } from "./common/serialization/index.js"; +import { emitHttp } from "./http/index.js"; + +// #endregion + +export { $lib } from "./lib.js"; + +export async function $onEmit(context: EmitContext) { + const jsCtx = await createInitialContext(context.program, context.options); + + if (!jsCtx) { + return; + } + + await emitHttp(jsCtx); + + if (!context.options["omit-unreachable-types"]) { + // Visit everything in the service namespace to ensure we emit a full `models` module and not just the subparts that + // are reachable from the service impl. + + visitAllTypes(jsCtx, jsCtx.service.type); + } + + // Emit serialization code for all required types. + emitSerialization(jsCtx); + + const srcGeneratedPath = path.join( + context.emitterOutputDir, + "src", + "generated" + ); + + if (!context.program.compilerOptions.noEmit) { + try { + const stat = await context.program.host.stat(srcGeneratedPath); + if (stat.isDirectory()) { + await context.program.host.rm(srcGeneratedPath, { + recursive: true, + }); + } + } catch {} + + await writeModuleTree( + jsCtx, + context.emitterOutputDir, + jsCtx.rootModule, + !context.options["no-format"] + ); + } +} diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 0000000..84ca417 --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { + JSONSchemaType, + createTypeSpecLibrary, + paramMessage, +} from "@typespec/compiler"; + +export interface JsEmitterOptions { + express?: boolean; + "omit-unreachable-types": boolean; + "no-format": boolean; +} + +const EmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + express: { + type: "boolean", + nullable: true, + default: false, + }, + "omit-unreachable-types": { + type: "boolean", + default: false, + }, + "no-format": { + type: "boolean", + default: false, + }, + }, + required: [], +}; + +export const $lib = createTypeSpecLibrary({ + name: "@pojagi/http-server-drf", + requireImports: [], + emitter: { + options: EmitterOptionsSchema, + }, + diagnostics: { + "unrecognized-intrinsic": { + severity: "warning", + messages: { + default: paramMessage`unrecognized intrinsic '${"intrinsic"}' is treated as 'unknown'`, + }, + }, + "unrecognized-scalar": { + severity: "warning", + messages: { + default: paramMessage`unrecognized scalar '${"scalar"}' is treated as 'unknown'`, + }, + }, + "unrecognized-encoding": { + severity: "error", + messages: { + default: paramMessage`unrecognized encoding '${"encoding"}' for type '${"type"}'`, + }, + }, + "http-emit-disabled": { + severity: "warning", + messages: { + default: + "HTTP emit is disabled because the HTTP library returned errors.", + }, + }, + "no-services-in-program": { + severity: "warning", + messages: { + default: "No services found in program.", + }, + }, + "undifferentiable-route": { + severity: "error", + messages: { + default: + "Shared route cannot be differentiated from other routes.", + }, + }, + "undifferentiable-scalar": { + severity: "error", + messages: { + default: paramMessage`Scalar type cannot be differentiated from other scalar type '${"competitor"}'.`, + }, + }, + "undifferentiable-model": { + severity: "error", + messages: { + default: + "Model type does not have enough unique properties to be differentiated from other models in some contexts.", + }, + }, + "unrepresentable-numeric-constant": { + severity: "error", + messages: { + default: + "JavaScript cannot accurately represent this numeric constant.", + }, + }, + "undifferentiable-union-variant": { + severity: "error", + messages: { + default: + "Union variant cannot be differentiated from other variants of the union an an ambiguous context.", + }, + }, + "unspeakable-status-code": { + severity: "error", + messages: { + default: paramMessage`Status code property '${"name"}' is unspeakable and does not have an exact value. Provide an exact status code value or rename the property.`, + }, + }, + "name-conflict": { + severity: "error", + messages: { + default: paramMessage`Name ${"name"} conflicts with a prior declaration and must be unique.`, + }, + }, + "dynamic-request-content-type": { + severity: "error", + messages: { + default: + "Operation has multiple possible content-type values and cannot be emitted.", + }, + }, + "openapi3-document-not-generated": { + severity: "warning", + messages: { + unable: "@typespec/openapi3 is installed, but the OpenAPI 3 document could not be generated.", + versioned: + "An OpenAPI3 document could not be generated for this service because versioned services are not yet supported by the HTTP server emitter for JavaScript.", + }, + }, + }, +}); + +const { reportDiagnostic } = $lib; + +export { reportDiagnostic }; diff --git a/src/scripts/scaffold/bin.mts b/src/scripts/scaffold/bin.mts new file mode 100644 index 0000000..8f3294a --- /dev/null +++ b/src/scripts/scaffold/bin.mts @@ -0,0 +1,781 @@ +#!/usr/bin/env node + +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { compile, formatDiagnostic, NodeHost, OperationContainer } from "@typespec/compiler"; + +import YAML from "yaml"; + +import { getHttpService, HttpOperation, HttpService } from "@typespec/http"; +import { spawn as _spawn, SpawnOptions } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline/promises"; +import { createOrGetModuleForNamespace } from "../../common/namespace.js"; +import { createInitialContext, createModule, isModule, JsContext, Module } from "../../ctx.js"; +import { parseCase } from "../../util/case.js"; + +import { SupportedOpenAPIDocuments } from "@typespec/openapi3"; +import { module as httpHelperModule } from "../../../generated-defs/helpers/http.js"; +import { module as routerModule } from "../../../generated-defs/helpers/router.js"; +import { emitOptionsType } from "../../common/interface.js"; +import { emitTypeReference, isValueLiteralType } from "../../common/reference.js"; +import { getAllProperties } from "../../util/extends.js"; +import { bifilter, indent } from "../../util/iter.js"; +import { createOnceQueue } from "../../util/once-queue.js"; +import { tryGetOpenApi3 } from "../../util/openapi3.js"; +import { writeModuleFile } from "../../write.js"; + +function spawn(command: string, args: string[], options: SpawnOptions): Promise { + return new Promise((resolve, reject) => { + const proc = _spawn(command, args, options); + + proc.on("error", reject); + proc.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Exit code: ${code}`)))); + }); +} + +/* eslint-disable no-console */ + +const COMMON_PATHS = { + mainTsp: "./main.tsp", + projectYaml: "./tspconfig.yaml", + packageJson: "./package.json", + tsConfigJson: "./tsconfig.json", + vsCodeLaunchJson: "./.vscode/launch.json", + vsCodeTasksJson: "./.vscode/tasks.json", +} as const; + +function getDefaultTsConfig(standalone: boolean) { + return { + compilerOptions: { + target: "es2020", + module: "Node16", + moduleResolution: "node16", + rootDir: "./", + outDir: "./dist/", + esModuleInterop: true, + forceConsistentCasingInFileNames: true, + strict: true, + skipLibCheck: true, + declaration: true, + sourceMap: true, + }, + include: standalone + ? ["src/**/*.ts"] + : ["src/**/*.ts", "tsp-output/@pojagi/http-server-drf/**/*.ts"], + } as const; +} + +const VSCODE_LAUNCH_JSON = { + configurations: [ + { + type: "node", + request: "launch", + name: "Launch Program", + program: "${workspaceFolder}/dist/src/index.js", + preLaunchTask: "npm: build", + internalConsoleOptions: "neverOpen", + }, + ], +}; + +const VSCODE_TASKS_JSON = { + version: "2.0.0", + tasks: [ + { + type: "npm", + script: "build", + group: "build", + problemMatcher: [], + label: "npm: build", + presentation: { + reveal: "silent", + }, + }, + ], +}; + +interface ScaffoldingOptions { + /** + * If true, the project will be generated in the current directory instead of the output directory. + */ + "no-standalone": boolean; + /** + * If true, writes will be forced even if the file or setting already exists. Use with caution. + */ + force: boolean; +} + +const DEFAULT_SCAFFOLDING_OPTIONS: ScaffoldingOptions = { + "no-standalone": false, + force: false, +}; + +function parseScaffoldArguments(args: string[]): ScaffoldingOptions { + let cursor = 2; + const options: Partial = {}; + + while (cursor < args.length) { + const arg = args[cursor]; + + if (arg === "--no-standalone") { + options["no-standalone"] = true; + } else if (arg === "--force") { + options.force = true; + } else { + console.error(`[hsj] Unrecognized scaffolding argument: '${arg}'`); + process.exit(1); + } + + cursor++; + } + + return { ...DEFAULT_SCAFFOLDING_OPTIONS, ...options }; +} + +async function confirmYesNo(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const response = await rl.question(`${message} [y/N] `); + + if (response.trim().toLowerCase() !== "y") { + console.error("[hsj] Operation cancelled."); + process.exit(0); + } + } finally { + rl.close(); + } +} + +export async function scaffold(options: ScaffoldingOptions) { + if (options.force) { + await confirmYesNo( + "[hsj] The `--force` flag is set and will overwrite existing files and settings that may have been modified. Continue?", + ); + } + + const cwd = process.cwd(); + + const projectYamlPath = path.resolve(cwd, COMMON_PATHS.projectYaml); + const mainTspPath = path.resolve(cwd, COMMON_PATHS.mainTsp); + + console.info("[hsj] Scaffolding TypeScript project..."); + console.info( + `[hsj] Using project file '${path.relative(cwd, projectYamlPath)}' and main file '${path.relative(cwd, mainTspPath)}'`, + ); + + let config: any; + + try { + const configText = await fs.readFile(projectYamlPath); + + config = YAML.parse(configText.toString("utf-8")); + } catch { + console.error( + "[hsj] Failed to read project configuration file. Is the project initialized using `tsp init`?", + ); + process.exit(1); + } + + // TODO: all of this path handling is awful. We need a good API from the compiler to interpolate the paths for us and + // resolve the options from the config using the schema. + + const emitterOutputDirTemplate = + config.options?.["@pojagi/http-server-drf"]?.["emitter-output-dir"]; + const defaultOutputDir = path.resolve(path.dirname(projectYamlPath), "tsp-output"); + + const emitterOutputDir = emitterOutputDirTemplate.replace("{output-dir}", defaultOutputDir); + + const baseOutputDir = options["no-standalone"] ? cwd : path.resolve(cwd, emitterOutputDir); + const tsConfigOutputPath = path.resolve(baseOutputDir, COMMON_PATHS.tsConfigJson); + + const expressOptions: PackageJsonExpressOptions = { + isExpress: !!config.options?.["@pojagi/http-server-drf"]?.express, + openApi3: undefined, + }; + + console.info( + `[hsj] Emitter options have 'express: ${expressOptions.isExpress}'. Generating server model: '${expressOptions.isExpress ? "Express" : "Node"}'.`, + ); + + if (options["no-standalone"]) { + console.info("[hsj] Standalone mode disabled, generating project in current directory."); + } else { + console.info("[hsj] Generating standalone project in output directory."); + } + + console.info("[hsj] Compiling TypeSpec project..."); + + const program = await compile(NodeHost, mainTspPath, { + noEmit: true, + config: projectYamlPath, + emit: [], + }); + + const jsCtx = await createInitialContext(program, { + express: expressOptions.isExpress, + "no-format": false, + "omit-unreachable-types": true, + }); + + if (!jsCtx) { + console.error("[hsj] No services were found in the program. Exiting."); + process.exit(1); + } + + expressOptions.openApi3 = await tryGetOpenApi3(program, jsCtx.service); + + const [httpService, httpDiagnostics] = getHttpService(program, jsCtx.service.type); + + let hadError = false; + + for (const diagnostic of [...program.diagnostics, ...httpDiagnostics]) { + hadError = hadError || diagnostic.severity === "error"; + console.error(formatDiagnostic(diagnostic, { pathRelativeTo: cwd, pretty: true })); + } + + if (program.hasError() || hadError) { + console.error("[hsj] TypeScript compilation failed. See above error output."); + process.exit(1); + } + + console.info("[hsj] TypeSpec compiled successfully. Scaffolding implementation..."); + + const indexModule = jsCtx.srcModule; + + const routeControllers = await createRouteControllers(jsCtx, httpService, indexModule); + + console.info("[hsj] Generating server entry point..."); + + const controllerModules = new Set(); + + for (const { name, module } of routeControllers) { + controllerModules.add(module); + indexModule.imports.push({ binder: [name], from: module }); + } + + const routerName = parseCase(httpService.namespace.name).pascalCase + "Router"; + + indexModule.imports.push({ + binder: ["create" + routerName], + from: options["no-standalone"] + ? "../tsp-output/@pojagi/http-server-drf/src/generated/http/router.js" + : "./generated/http/router.js", + }); + + indexModule.declarations.push([ + `const router = create${routerName}(`, + ...routeControllers.map((controller) => ` new ${controller.name}(),`), + `);`, + "", + "const PORT = process.env.PORT || 3000;", + ]); + + if (expressOptions.isExpress) { + indexModule.imports.push( + { + binder: "express", + from: "express", + }, + { + binder: "morgan", + from: "morgan", + }, + ); + + if (expressOptions.openApi3) { + const swaggerUiModule = createModule("swagger-ui", indexModule); + + indexModule.imports.push({ + from: swaggerUiModule, + binder: ["addSwaggerUi"], + }); + + swaggerUiModule.imports.push( + { + binder: "swaggerUi", + from: "swagger-ui-express", + }, + { + binder: ["openApiDocument"], + from: "./generated/http/openapi3.js", + }, + { + binder: "type express", + from: "express", + }, + ); + + swaggerUiModule.declarations.push([ + "export function addSwaggerUi(path: string, app: express.Application) {", + " app.use(path, swaggerUi.serve, swaggerUi.setup(openApiDocument));", + "}", + ]); + + writeModuleFile( + jsCtx, + baseOutputDir, + swaggerUiModule, + createOnceQueue(), + true, + tryWrite, + ); + } + + indexModule.declarations.push([ + "const app = express();", + "", + "app.use(morgan('dev'));", + ...(expressOptions.openApi3 + ? [ + "", + 'const SWAGGER_UI_PATH = process.env.SWAGGER_UI_PATH || "/.api-docs";', + "", + "addSwaggerUi(SWAGGER_UI_PATH, app);", + ] + : []), + "", + "app.use(router.expressMiddleware);", + "", + "app.listen(PORT, () => {", + ` console.log(\`Server is running at http://localhost:\${PORT}\`);`, + ...(expressOptions.openApi3 + ? [ + " console.log(`API documentation is available at http://localhost:${PORT}${SWAGGER_UI_PATH}`);", + ] + : []), + "});", + ]); + } else { + indexModule.imports.push({ + binder: ["createServer"], + from: "node:http", + }); + + indexModule.declarations.push([ + "const server = createServer(router.dispatch);", + "", + "server.listen(PORT, () => {", + ` console.log(\`Server is running at http://localhost:\${PORT}\`);`, + "});", + ]); + } + + console.info("[hsj] Writing files..."); + + const queue = createOnceQueue(); + + await writeModuleFile(jsCtx, baseOutputDir, indexModule, queue, /* format */ true, tryWrite); + + for (const module of controllerModules) { + module.imports = module.imports.map((_import) => { + if ( + options["no-standalone"] && + typeof _import.from !== "string" && + !controllerModules.has(_import.from) + ) { + const backout = module.cursor.path.map(() => ".."); + + const [declaredModules] = bifilter(_import.from.declarations, isModule); + + const targetIsIndex = _import.from.cursor.path.length === 0 || declaredModules.length > 0; + + const modulePrincipalName = _import.from.cursor.path.slice(-1)[0]; + + const targetPath = [ + ...backout.slice(1), + "tsp-output", + "@typespec", + "http-server-js", + ..._import.from.cursor.path.slice(0, -1), + ...(targetIsIndex ? [modulePrincipalName, "index.js"] : [`${modulePrincipalName}.js`]), + ].join("/"); + + _import.from = targetPath; + } + + return _import; + }); + + await writeModuleFile(jsCtx, baseOutputDir, module, queue, /* format */ true, tryWrite); + } + + // Force writing of http helper module + await writeModuleFile(jsCtx, baseOutputDir, httpHelperModule, queue, /* format */ true, tryWrite); + + await tryWrite( + tsConfigOutputPath, + JSON.stringify(getDefaultTsConfig(!options["no-standalone"]), null, 2) + "\n", + ); + + const vsCodeLaunchJsonPath = path.resolve(baseOutputDir, COMMON_PATHS.vsCodeLaunchJson); + const vsCodeTasksJsonPath = path.resolve(baseOutputDir, COMMON_PATHS.vsCodeTasksJson); + + await tryWrite(vsCodeLaunchJsonPath, JSON.stringify(VSCODE_LAUNCH_JSON, null, 2) + "\n"); + await tryWrite(vsCodeTasksJsonPath, JSON.stringify(VSCODE_TASKS_JSON, null, 2) + "\n"); + + const ownPackageJsonPath = path.resolve(cwd, COMMON_PATHS.packageJson); + + let ownPackageJson; + + try { + ownPackageJson = JSON.parse((await fs.readFile(ownPackageJsonPath)).toString("utf-8")); + } catch { + console.error("[hsj] Failed to read package.json of TypeSpec project. Exiting."); + process.exit(1); + } + + let packageJsonChanged = true; + + if (options["no-standalone"]) { + console.info("[hsj] Checking package.json for changes..."); + + packageJsonChanged = updatePackageJson(ownPackageJson, expressOptions, options.force); + + if (packageJsonChanged) { + console.info("[hsj] Writing updated package.json..."); + + try { + await fs.writeFile(ownPackageJsonPath, JSON.stringify(ownPackageJson, null, 2) + "\n"); + } catch { + console.error("[hsj] Failed to write package.json."); + process.exit(1); + } + } else { + console.info("[hsj] No changes to package.json suggested."); + } + } else { + // Standalone mode, need to generate package.json from scratch + const relativePathToSpec = path.relative(baseOutputDir, cwd); + const packageJson = getPackageJsonForStandaloneProject( + ownPackageJson, + expressOptions, + relativePathToSpec, + ); + + const packageJsonPath = path.resolve(baseOutputDir, COMMON_PATHS.packageJson); + + await tryWrite(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n"); + } + + if (packageJsonChanged) { + // Run npm install to ensure dependencies are installed. + console.info("[hsj] Running npm install..."); + + try { + await spawn("npm", ["install"], { + stdio: "inherit", + cwd: options["no-standalone"] ? cwd : baseOutputDir, + shell: process.platform === "win32", + }); + } catch { + console.warn( + "[hsj] Failed to run npm install. Check the output above for errors and install dependencies manually.", + ); + } + } + + console.info("[hsj] Project scaffolding complete. Building project..."); + + try { + await spawn("npm", ["run", "build"], { + stdio: "inherit", + cwd: options["no-standalone"] ? cwd : baseOutputDir, + shell: process.platform === "win32", + }); + } catch { + console.error("[hsj] Failed to build project. Check the output above for errors."); + process.exit(1); + } + + const codeDirectory = path.relative(cwd, options["no-standalone"] ? cwd : baseOutputDir); + + console.info("[hsj] Project is ready to run. Use `npm start` to launch the server."); + console.info("[hsj] A debug configuration has been created for Visual Studio Code."); + console.info( + `[hsj] Try \`code ${codeDirectory}\` to open the project and press F5 to start debugging.`, + ); + console.info( + `[hsj] The newly-generated route controllers in '${path.join(codeDirectory, "src", "controllers")}' are ready to be implemented.`, + ); + console.info("[hsj] Done."); + + async function tryWrite(file: string, contents: string): Promise { + try { + const relative = path.relative(cwd, file); + + const exists = await fs + .stat(file) + .then(() => true) + .catch(() => false); + + if (exists && !options.force) { + console.warn(`[hsj] File '${relative}' already exists and will not be overwritten.`); + console.warn(`[hsj] Manually update the file or delete it and run scaffolding again.`); + + return; + } else if (exists) { + console.warn(`[hsj] Overwriting file '${relative}'...`); + } else { + console.info(`[hsj] Writing file '${relative}'...`); + } + + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, contents); + } catch (e: unknown) { + console.error(`[hsj] Failed to write file: '${(e as Error).message}'`); + } + } +} + +interface RouteController { + name: string; + module: Module; +} + +async function createRouteControllers( + ctx: JsContext, + httpService: HttpService, + srcModule: Module, +): Promise { + const controllers: RouteController[] = []; + + const operationsByContainer = new Map>(); + + for (const operation of httpService.operations) { + let byContainer = operationsByContainer.get(operation.container); + + if (!byContainer) { + byContainer = new Set(); + operationsByContainer.set(operation.container, byContainer); + } + + byContainer.add(operation); + } + + const controllersModule = createModule("controllers", srcModule); + + for (const [container, operations] of operationsByContainer) { + controllers.push(await createRouteController(ctx, container, operations, controllersModule)); + } + + return controllers; +} + +async function createRouteController( + ctx: JsContext, + container: OperationContainer, + operations: Set, + controllersModule: Module, +): Promise { + const nameCase = parseCase(container.name); + const module = createModule(nameCase.kebabCase, controllersModule); + + const containerNameCase = parseCase(container.name); + + module.imports.push( + { + binder: [containerNameCase.pascalCase], + from: createOrGetModuleForNamespace(ctx, container.namespace!), + }, + { + binder: ["HttpContext"], + from: routerModule, + }, + ); + + const controllerName = containerNameCase.pascalCase + "Impl"; + + console.info(`[hsj] Generating controller '${controllerName}'...`); + + module.declarations.push([ + `export class ${controllerName} implements ${containerNameCase.pascalCase} {`, + ...indent(emitControllerOperationHandlers(ctx, container, operations, module)), + `}`, + ]); + + return { name: controllerName, module }; +} + +function* emitControllerOperationHandlers( + ctx: JsContext, + container: OperationContainer, + httpOperations: Set, + module: Module, +): Iterable { + module.imports.push({ + binder: ["NotImplementedError"], + from: httpHelperModule, + }); + for (const httpOperation of httpOperations) { + // TODO: unify construction of signature with emitOperation in common/interface.ts + const op = httpOperation.operation; + + const opNameCase = parseCase(op.name); + + const opName = opNameCase.camelCase; + + const allParameters = getAllProperties(op.parameters); + + const hasOptions = allParameters.some((p) => p.optional); + + const returnTypeReference = emitTypeReference(ctx, op.returnType, op, module, { + altName: opNameCase.pascalCase + "Result", + }); + + const returnType = `Promise<${returnTypeReference}>`; + + const params: string[] = []; + + for (const param of allParameters) { + // If the type is a value literal, then we consider it a _setting_ and not a parameter. + // This allows us to exclude metadata parameters (such as contentType) from the generated interface. + if (param.optional || isValueLiteralType(param.type)) continue; + + const paramNameCase = parseCase(param.name); + const paramName = paramNameCase.camelCase; + + const outputTypeReference = emitTypeReference(ctx, param.type, param, module, { + altName: opNameCase.pascalCase + paramNameCase.pascalCase, + }); + + params.push(`${paramName}: ${outputTypeReference}`); + } + + const paramsDeclarationLine = params.join(", "); + + if (hasOptions) { + const optionsTypeName = opNameCase.pascalCase + "Options"; + + emitOptionsType(ctx, op, module, optionsTypeName); + + const paramsFragment = params.length > 0 ? `${paramsDeclarationLine}, ` : ""; + + // prettier-ignore + yield `async ${opName}(ctx: HttpContext, ${paramsFragment}options?: ${optionsTypeName}): ${returnType} {`; + } else { + // prettier-ignore + yield `async ${opName}(ctx: HttpContext, ${paramsDeclarationLine}): ${returnType} {`; + } + + yield " throw new NotImplementedError();"; + yield "}"; + yield ""; + } +} + +function getPackageJsonForStandaloneProject( + ownPackageJson: any, + express: PackageJsonExpressOptions, + relativePathToSpec: string, +): any { + const packageJson = { + name: (ownPackageJson.name ?? path.basename(process.cwd())) + "-server", + version: ownPackageJson.version ?? "0.1.0", + type: "module", + description: "Generated TypeSpec server project.", + } as any; + + if (ownPackageJson.private) { + packageJson.private = true; + } + + updatePackageJson(packageJson, express, true, () => {}); + + delete packageJson.scripts["build:scaffold"]; + packageJson.scripts["build:typespec"] = 'tsp compile --output-dir=".." ' + relativePathToSpec; + + return packageJson; +} + +const JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + +interface PackageJsonExpressOptions { + isExpress: boolean; + openApi3: SupportedOpenAPIDocuments | undefined; +} + +function updatePackageJson( + packageJson: any, + express: PackageJsonExpressOptions, + force: boolean, + info: (...args: any[]) => void = console.info, +): boolean { + let changed = false; + + updateObjectPath(["scripts", "start"], "node dist/src/index.js"); + updateObjectPath(["scripts", "build"], "npm run build:typespec && tsc"); + updateObjectPath(["scripts", "build:typespec"], "tsp compile ."); + updateObjectPath(["scripts", "build:scaffold"], "hsj-scaffold"); + + updateObjectPath(["devDependencies", "typescript"], "^5.7.3"); + updateObjectPath(["devDependencies", "@types/node"], "^22.13.1"); + + if (express.isExpress) { + updateObjectPath(["dependencies", "express"], "^5.0.1"); + updateObjectPath(["devDependencies", "@types/express"], "^5.0.0"); + + if (express.openApi3) { + updateObjectPath(["dependencies", "swagger-ui-express"], "^5.0.1"); + updateObjectPath(["devDependencies", "@types/swagger-ui-express"], "^4.1.7"); + } + + updateObjectPath(["dependencies", "morgan"], "^1.10.0"); + updateObjectPath(["devDependencies", "@types/morgan"], "^1.9.9"); + } + + return changed; + + function updateObjectPath(path: string[], value: string) { + let current = packageJson; + + for (const fragment of path.slice(0, -1)) { + current = current[fragment] ??= {}; + } + + const existingValue = current[path[path.length - 1]]; + + let property = ""; + + for (const fragment of path) { + if (!JS_IDENTIFIER_RE.test(fragment)) { + property += `["${fragment}"]`; + } else { + property += property === "" ? fragment : `.${fragment}`; + } + } + + if (!existingValue || force) { + if (!existingValue) { + info(`[hsj] - Setting package.json property '${property}' to "${value}".`); + } else if (force) { + info(`[hsj] - Overwriting package.json property '${property}' to "${value}".`); + } + + current[path[path.length - 1]] = value; + + changed ||= true; + + return; + } + + if (current[path[path.length - 1]] !== value) { + info(`[hsj] - Skipping package.json property '${property}'.`); + info(`[hsj] Scaffolding prefers "${value}", but it is already set to "${existingValue}".`); + info( + "[hsj] Manually update the property or remove it and run scaffolding again if needed.", + ); + } + } +} + +scaffold(parseScaffoldArguments(process.argv)).catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 0000000..45c08fc --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1,10 @@ +import { + TypeSpecTestLibrary, + createTestLibrary, + findTestPackageRoot, +} from "@typespec/compiler/testing"; + +export const HttpServerJavaScriptTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@pojagi/http-server-drf", + packageRoot: await findTestPackageRoot(import.meta.url), +}); diff --git a/src/util/case.ts b/src/util/case.ts new file mode 100644 index 0000000..318712c --- /dev/null +++ b/src/util/case.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +/** + * Separators recognized by the case parser. + */ +const SEPARATORS = /[\s:_\-./\\]/; + +/** + * Returns true if a name cannot be spoken. A name is unspeakable if: + * + * - It contains only separators and whitespace. + * + * OR + * + * - The first non-separator, non-whitespace character is a digit. + * + * @param name - a name in any case + * @returns true if the name is unspeakable + */ +export function isUnspeakable(name: string): boolean { + for (const c of name) { + if (!SEPARATORS.test(c)) { + return /[0-9]/.test(c); + } + } + + return true; +} + +/** + * Destructures a name into its components. + * + * The following case conventions are supported: + * - PascalCase (["pascal", "case"]) + * - camelCase (["camel", "case"]) + * - snake_case (["snake", "case"]) + * - kebab-case (["kebab", "case"]) + * - dot.separated (["dot", "separated"]) + * - path/separated (["path", "separated"]) + * - double::colon::separated (["double", "colon", "separated"]) + * - space separated (["space", "separated"]) + * + * - AND any combination of the above, or any other separators or combination of separators. + * + * @param name - a name in any case + */ +export function parseCase(name: string): ReCase { + const components: string[] = []; + + let currentComponent = ""; + let inAcronym = false; + + for (let i = 0; i < name.length; i++) { + const char = name[i]; + + // cSpell:ignore presponse + // Special case acronym handling. We want to treat acronyms as a single component, + // but we also want the last capitalized letter in an all caps sequence to start a new + // component if the next letter is lower case. + // For example : "HTTPResponse" => ["http", "response"] + // : "OpenAIContext" => ["open", "ai", "context"] + // but : "HTTPresponse" (wrong) => ["htt", "presponse"] + // however : "HTTP_response" (okay I guess) => ["http", "response"] + + // If the character is a separator or an upper case character, we push the current component and start a new one. + if (char === char.toUpperCase() && !/[0-9]/.test(char)) { + // If we're in an acronym, we need to check if the next character is lower case. + // If it is, then this is the start of a new component. + const acronymRestart = + inAcronym && /[A-Z]/.test(char) && i + 1 < name.length && /[^A-Z]/.test(name[i + 1]); + + if (currentComponent.length > 0 && (acronymRestart || !inAcronym)) { + components.push(currentComponent.trim()); + currentComponent = ""; + } + } + + if (!SEPARATORS.test(char)) { + currentComponent += char.toLowerCase(); + } + + inAcronym = /[A-Z]/.test(char); + } + + if (currentComponent.length > 0) { + components.push(currentComponent); + } + + return recase(components); +} + +/** + * An object allowing a name to be converted into various case conventions. + */ +export interface ReCase extends ReCaseUpper { + /** + * The components of the name with the first letter of each component capitalized and joined by an empty string. + */ + readonly pascalCase: string; + /** + * The components of the name with the first letter of the second and all subsequent components capitalized and joined + * by an empty string. + */ + readonly camelCase: string; + + /** + * Convert the components of the name into all uppercase letters. + */ + readonly upper: ReCaseUpper; +} + +interface ReCaseUpper { + /** + * The components of the name. + */ + readonly components: readonly string[]; + + /** + * The components of the name joined by underscores. + */ + readonly snakeCase: string; + /** + * The components of the name joined by hyphens. + */ + readonly kebabCase: string; + /** + * The components of the name joined by periods. + */ + readonly dotCase: string; + /** + * The components of the name joined by slashes. + * + * This uses forward slashes in the unix convention. + */ + readonly pathCase: string; + + /** + * Join the components with any given string. + * + * @param separator - the separator to join the components with + */ + join(separator: string): string; +} + +function recase(components: readonly string[]): ReCase { + return Object.freeze({ + components, + get pascalCase() { + return components + .map((component) => component[0].toUpperCase() + component.slice(1)) + .join(""); + }, + get camelCase() { + return components + .map((component, index) => + index === 0 ? component : component[0].toUpperCase() + component.slice(1), + ) + .join(""); + }, + get snakeCase() { + return components.join("_"); + }, + get kebabCase() { + return components.join("-"); + }, + get dotCase() { + return components.join("."); + }, + get pathCase() { + return components.join("/"); + }, + + get upper() { + return recase(components.map((component) => component.toUpperCase())); + }, + + join(separator: string) { + return components.join(separator); + }, + }); +} diff --git a/src/util/differentiate.ts b/src/util/differentiate.ts new file mode 100644 index 0000000..27ebc0c --- /dev/null +++ b/src/util/differentiate.ts @@ -0,0 +1,957 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { + BooleanLiteral, + EnumMember, + Model, + ModelProperty, + NullType, + NumericLiteral, + Scalar, + StringLiteral, + Type, + Union, + UnknownType, + VoidType, + getDiscriminator, + getMaxValue, + getMinValue, + isNeverType, + isUnknownType, +} from "@typespec/compiler"; +import { getJsScalar } from "../common/scalar.js"; +import { JsContext } from "../ctx.js"; +import { reportDiagnostic } from "../lib.js"; +import { isUnspeakable, parseCase } from "./case.js"; +import { UnimplementedError, UnreachableError } from "./error.js"; +import { getAllProperties } from "./extends.js"; +import { categorize, indent } from "./iter.js"; + +/** + * A tree structure representing a body of TypeScript code. + */ +export type CodeTree = Result | IfChain | Switch | Verbatim; + +export type JsLiteralType = StringLiteral | BooleanLiteral | NumericLiteral | EnumMember; + +/** + * A TypeSpec type that is precise, i.e. the type of a single value. + */ +export type PreciseType = Scalar | Model | JsLiteralType | VoidType | NullType; + +/** + * Determines if `t` is a precise type. + * @param t - the type to test + * @returns true if `t` is precise, false otherwise. + */ +export function isPreciseType(t: Type): t is PreciseType { + return ( + t.kind === "Scalar" || + t.kind === "Model" || + t.kind === "Boolean" || + t.kind === "Number" || + t.kind === "String" || + (t.kind === "Intrinsic" && (t.name === "void" || t.name === "null")) + ); +} + +/** + * An if-chain structure in the CodeTree DSL. This represents a cascading series of if-else-if statements with an optional + * final `else` branch. + */ +export interface IfChain { + kind: "if-chain"; + branches: IfBranch[]; + else?: CodeTree; +} + +/** + * A branch in an if-chain. + */ +export interface IfBranch { + /** + * A condition to test for this branch. + */ + condition: Expression; + /** + * The body of this branch, to be executed if the condition is true. + */ + body: CodeTree; +} + +/** + * A node in the code tree indicating that a precise type has been determined. + */ +export interface Result { + kind: "result"; + type: PreciseType | UnknownType; +} + +/** + * A switch structure in the CodeTree DSL. + */ +export interface Switch { + kind: "switch"; + /** + * The expression to switch on. + */ + condition: Expression; + /** + * The cases to test for. + */ + cases: SwitchCase[]; + /** + * The default case, if any. + */ + default?: CodeTree; +} + +/** + * A verbatim code block. + */ +export interface Verbatim { + kind: "verbatim"; + body: Iterable; +} + +/** + * A case in a switch statement. + */ +export interface SwitchCase { + /** + * The value to test for in this case. + */ + value: Expression; + /** + * The body of this case. + */ + body: CodeTree; +} + +/** + * An expression in the CodeTree DSL. + */ +export type Expression = + | BinaryOp + | UnaryOp + | TypeOf + | Literal + | VerbatimExpression + | SubjectReference + | ModelPropertyReference + | InRange; + +/** + * A binary operation. + */ +export interface BinaryOp { + kind: "binary-op"; + /** + * The operator to apply. This operation may be sensitive to the order of the left and right expressions. + */ + operator: + | "===" + | "!==" + | "<" + | "<=" + | ">" + | ">=" + | "+" + | "-" + | "*" + | "/" + | "%" + | "&&" + | "||" + | "instanceof" + | "in"; + /** + * The left-hand-side operand. + */ + left: Expression; + /** + * The right-hand-side operand. + */ + right: Expression; +} + +/** + * A unary operation. + */ +export interface UnaryOp { + kind: "unary-op"; + /** + * The operator to apply. + */ + operator: "!" | "-"; + /** + * The operand to apply the operator to. + */ + operand: Expression; +} + +/** + * A type-of operation. + */ +export interface TypeOf { + kind: "typeof"; + /** + * The operand to apply the `typeof` operator to. + */ + operand: Expression; +} + +/** + * A literal JavaScript value. The value will be converted to the text of an expression that will yield the same value. + */ +export interface Literal { + kind: "literal"; + /** + * The value of the literal. + */ + value: LiteralValue; +} + +/** + * A verbatim expression, written as-is with no modification. + */ +export interface VerbatimExpression { + kind: "verbatim"; + /** + * The exact text of the expression. + */ + text: string; +} + +/** + * A reference to the "subject" of the code tree. + * + * The "subject" is a special expression denoting an input value. + */ +export interface SubjectReference { + kind: "subject"; +} + +const SUBJECT = { kind: "subject" } as SubjectReference; + +/** + * A reference to a model property. Model property references are rendered by the `referenceModelProperty` function in the + * options given to `writeCodeTree`, allowing the caller to define how model properties are stored. + */ +export interface ModelPropertyReference { + kind: "model-property"; + property: ModelProperty; +} + +/** + * A check to see if a value is in an integer range. + */ +export interface InRange { + kind: "in-range"; + /** + * The expression to check. + */ + expr: Expression; + /** + * The range to check against. + */ + range: IntegerRange; +} + +/** + * A literal value that can be used in a JavaScript expression. + */ +export type LiteralValue = string | number | boolean | bigint; + +function isLiteralValueType(type: Type): type is JsLiteralType { + return ( + type.kind === "Boolean" || + type.kind === "Number" || + type.kind === "String" || + type.kind === "EnumMember" + ); +} + +const PROPERTY_ID = (prop: ModelProperty) => parseCase(prop.name).camelCase; + +/** + * Differentiates the variants of a union type. This function returns a CodeTree that will test an input "subject" and + * determine which of the cases it matches. + * + * Compared to `differentiateTypes`, this function is specialized for union types, and will consider union + * discriminators first, then delegate to `differentiateTypes` for the remaining cases. + * + * @param ctx + * @param type + */ +export function differentiateUnion( + ctx: JsContext, + union: Union, + renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID, +): CodeTree { + const discriminator = getDiscriminator(ctx.program, union)?.propertyName; + // Exclude `never` from the union variants. + const variants = [...union.variants.values()].filter((v) => !isNeverType(v.type)); + + if (variants.some((v) => isUnknownType(v.type))) { + // Collapse the whole union to `unknown`. + return { kind: "result", type: ctx.program.checker.anyType }; + } + + if (!discriminator) { + const cases = new Set(); + + for (const variant of variants) { + if (!isPreciseType(variant.type)) { + reportDiagnostic(ctx.program, { + code: "undifferentiable-union-variant", + target: variant, + }); + } else { + cases.add(variant.type); + } + } + + return differentiateTypes(ctx, cases, renderPropertyName); + } else { + const property = (variants[0].type as Model).properties.get(discriminator)!; + + return { + kind: "switch", + condition: { + kind: "model-property", + property, + }, + cases: variants.map((v) => { + const discriminatorPropertyType = (v.type as Model).properties.get(discriminator)!.type as + | JsLiteralType + | EnumMember; + + return { + value: { kind: "literal", value: getJsValue(ctx, discriminatorPropertyType) }, + body: { kind: "result", type: v.type }, + } as SwitchCase; + }), + default: { + kind: "verbatim", + body: [ + 'throw new Error("Unreachable: discriminator did not match any known value or was not present.");', + ], + }, + }; + } +} + +/** + * Differentiates a set of input types. This function returns a CodeTree that will test an input "subject" and determine + * which of the cases it matches, executing the corresponding code block. + * + * @param ctx - The emitter context. + * @param cases - A map of cases to differentiate to their respective code blocks. + * @returns a CodeTree to use with `writeCodeTree` + */ +export function differentiateTypes( + ctx: JsContext, + cases: Set, + renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID, +): CodeTree { + if (cases.size === 0) { + return { + kind: "verbatim", + body: [ + 'throw new Error("Unreachable: encountered a value in differentiation where no variants exist.");', + ], + }; + } + + const categories = categorize(cases.keys(), (type) => type.kind); + + const literals = [ + ...(categories.Boolean ?? []), + ...(categories.Number ?? []), + ...(categories.String ?? []), + ] as JsLiteralType[]; + const models = (categories.Model as Model[]) ?? []; + const scalars = (categories.Scalar as Scalar[]) ?? []; + + const intrinsics = (categories.Intrinsic as (VoidType | NullType)[]) ?? []; + + if (literals.length + scalars.length + intrinsics.length === 0) { + return differentiateModelTypes(ctx, select(models, cases), renderPropertyName); + } else { + const branches: IfBranch[] = []; + + for (const intrinsic of intrinsics) { + const intrinsicValue = intrinsic.name === "void" ? "undefined" : "null"; + branches.push({ + condition: { + kind: "binary-op", + operator: "===", + left: SUBJECT, + right: { + kind: "verbatim", + text: intrinsicValue, + }, + }, + body: { + kind: "result", + type: intrinsic, + }, + }); + } + + for (const literal of literals) { + branches.push({ + condition: { + kind: "binary-op", + operator: "===", + left: SUBJECT, + right: { kind: "literal", value: getJsValue(ctx, literal) }, + }, + body: { + kind: "result", + type: literal, + }, + }); + } + + const scalarRepresentations = new Map(); + + for (const scalar of scalars) { + const jsScalar = getJsScalar(ctx.program, scalar, scalar); + + if (scalarRepresentations.has(jsScalar)) { + reportDiagnostic(ctx.program, { + code: "undifferentiable-scalar", + target: scalar, + format: { + competitor: scalarRepresentations.get(jsScalar)!.name, + }, + }); + continue; + } + + let test: Expression; + + switch (jsScalar) { + case "Uint8Array": + test = { + kind: "binary-op", + operator: "instanceof", + left: SUBJECT, + right: { kind: "verbatim", text: "Uint8Array" }, + }; + break; + case "number": + test = { + kind: "binary-op", + operator: "===", + left: { kind: "typeof", operand: SUBJECT }, + right: { kind: "literal", value: "number" }, + }; + break; + case "bigint": + test = { + kind: "binary-op", + operator: "===", + left: { kind: "typeof", operand: SUBJECT }, + right: { kind: "literal", value: "bigint" }, + }; + break; + case "string": + test = { + kind: "binary-op", + operator: "===", + left: { kind: "typeof", operand: SUBJECT }, + right: { kind: "literal", value: "string" }, + }; + break; + case "boolean": + test = { + kind: "binary-op", + operator: "===", + left: { kind: "typeof", operand: SUBJECT }, + right: { kind: "literal", value: "boolean" }, + }; + break; + case "Date": + test = { + kind: "binary-op", + operator: "instanceof", + left: SUBJECT, + right: { kind: "verbatim", text: "Date" }, + }; + break; + default: + throw new UnimplementedError( + `scalar differentiation for unknown JS Scalar '${jsScalar}'.`, + ); + } + + branches.push({ + condition: test, + body: { + kind: "result", + type: scalar, + }, + }); + } + + return { + kind: "if-chain", + branches, + else: + models.length > 0 + ? differentiateModelTypes(ctx, select(models, cases), renderPropertyName) + : undefined, + }; + } + + /** + * Select a subset of keys from a map. + * + * @param keys - The keys to select. + * @param map - The map to select from. + * @returns a map containing only those keys of the original map that were also in the `keys` iterable. + */ + function select(keys: Iterable, set: Set): Set { + const result = new Set(); + for (const key of keys) { + if (set.has(key)) result.add(key); + } + return result; + } +} + +/** + * Gets a JavaScript literal value for a given LiteralType. + */ +function getJsValue(ctx: JsContext, literal: JsLiteralType | EnumMember): LiteralValue { + switch (literal.kind) { + case "Boolean": + return literal.value; + case "Number": { + const asNumber = literal.numericValue.asNumber(); + + if (asNumber) return asNumber; + + const asBigInt = literal.numericValue.asBigInt(); + + if (asBigInt) return asBigInt; + + reportDiagnostic(ctx.program, { + code: "unrepresentable-numeric-constant", + target: literal, + }); + return 0; + } + case "String": + return literal.value; + case "EnumMember": + return literal.value ?? literal.name; + default: + throw new UnreachableError( + "getJsValue for " + (literal satisfies never as JsLiteralType).kind, + { literal }, + ); + } +} + +/** + * An integer range, inclusive. + */ +type IntegerRange = [number, number]; + +function getIntegerRange(ctx: JsContext, property: ModelProperty): IntegerRange | false { + if ( + property.type.kind === "Scalar" && + getJsScalar(ctx.program, property.type, property) === "number" + ) { + const minValue = getMinValue(ctx.program, property); + const maxValue = getMaxValue(ctx.program, property); + + if (minValue !== undefined && maxValue !== undefined) { + return [minValue, maxValue]; + } + } + + return false; +} + +function overlaps(range: IntegerRange, other: IntegerRange): boolean { + return range[0] <= other[1] && range[1] >= other[0]; +} + +/** + * Differentiate a set of model types based on their properties. This function returns a CodeTree that will test an input + * "subject" and determine which of the cases it matches, executing the corresponding code block. + * + * @param ctx - The emitter context. + * @param models - A map of models to differentiate to their respective code blocks. + * @param renderPropertyName - A function that converts a model property reference over the subject to a string. + * @returns a CodeTree to use with `writeCodeTree` + */ +export function differentiateModelTypes( + ctx: JsContext, + models: Set, + renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID, +): CodeTree { + // Horrible n^2 operation to get the unique properties of all models in the map, but hopefully n is small, so it should + // be okay until you have a lot of models to differentiate. + + type PropertyName = string; + type RenderedPropertyName = string & { __brand: "RenderedPropertyName" }; + + const uniqueProps = new Map>(); + + // Map of property names to maps of literal values that identify a model. + const propertyLiterals = new Map>(); + // Map of models to properties with values that can uniquely identify it + const uniqueLiterals = new Map>(); + + const propertyRanges = new Map>(); + const uniqueRanges = new Map>(); + + for (const model of models) { + const props = new Set(); + + for (const prop of getAllProperties(model)) { + // Don't consider optional properties for differentiation. + if (prop.optional) continue; + + // Ignore properties that have no parseable name. + if (isUnspeakable(prop.name)) continue; + + const renderedPropName = renderPropertyName(prop) as RenderedPropertyName; + + // CASE - literal value + + if (isLiteralValueType(prop.type)) { + let literals = propertyLiterals.get(renderedPropName); + if (!literals) { + literals = new Map(); + propertyLiterals.set(renderedPropName, literals); + } + + const value = getJsValue(ctx, prop.type); + + const other = literals.get(value); + + if (other) { + // Literal already used. Leave the literal in the propertyLiterals map to prevent future collisions, + // but remove the model from the uniqueLiterals map. + uniqueLiterals.get(other)?.delete(renderedPropName); + } else { + // Literal is available. Add the model to the uniqueLiterals map and set this value. + literals.set(value, model); + let modelsUniqueLiterals = uniqueLiterals.get(model); + if (!modelsUniqueLiterals) { + modelsUniqueLiterals = new Set(); + uniqueLiterals.set(model, modelsUniqueLiterals); + } + modelsUniqueLiterals.add(renderedPropName); + } + } + + // CASE - unique range + + const range = getIntegerRange(ctx, prop); + if (range) { + let ranges = propertyRanges.get(renderedPropName); + if (!ranges) { + ranges = new Map(); + propertyRanges.set(renderedPropName, ranges); + } + + const overlappingRanges = [...ranges.entries()].filter(([r]) => overlaps(r, range)); + + if (overlappingRanges.length > 0) { + // Overlapping range found. Remove the model from the uniqueRanges map. + for (const [, other] of overlappingRanges) { + uniqueRanges.get(other)?.delete(renderedPropName); + } + } else { + // No overlapping range found. Add the model to the uniqueRanges map and set this range. + ranges.set(range, model); + let modelsUniqueRanges = uniqueRanges.get(model); + if (!modelsUniqueRanges) { + modelsUniqueRanges = new Set(); + uniqueRanges.set(model, modelsUniqueRanges); + } + modelsUniqueRanges.add(renderedPropName); + } + } + + // CASE - unique property + + let valid = true; + for (const [, other] of uniqueProps) { + if ( + other.has(prop.name) || + (isLiteralValueType(prop.type) && + propertyLiterals + .get(renderedPropName) + ?.has(getJsValue(ctx, prop.type as JsLiteralType))) + ) { + valid = false; + other.delete(prop.name); + } + } + + if (valid) { + props.add(prop.name); + } + } + + uniqueProps.set(model, props); + } + + const branches: IfBranch[] = []; + + let defaultCase: Model | undefined = undefined; + + for (const [model, unique] of uniqueProps) { + const literals = uniqueLiterals.get(model); + const ranges = uniqueRanges.get(model); + if (unique.size === 0 && (!literals || literals.size === 0) && (!ranges || ranges.size === 0)) { + if (defaultCase) { + reportDiagnostic(ctx.program, { + code: "undifferentiable-model", + target: model, + }); + return { + kind: "result", + type: defaultCase, + }; + } else { + // Allow a single default case. This covers more APIs that have a single model that is not differentiated by a + // unique property, in which case we can make it the `else` case. + defaultCase = model; + continue; + } + } + + if (literals && literals.size > 0) { + // A literal property value exists that can differentiate this model. + const firstUniqueLiteral = literals.values().next().value as RenderedPropertyName; + + const property = [...model.properties.values()].find( + (p) => (renderPropertyName(p) as RenderedPropertyName) === firstUniqueLiteral, + )!; + + branches.push({ + condition: { + kind: "binary-op", + left: { + kind: "binary-op", + left: { kind: "literal", value: renderPropertyName(property) }, + operator: "in", + right: SUBJECT, + }, + operator: "&&", + right: { + kind: "binary-op", + left: { kind: "model-property", property }, + operator: "===", + right: { + kind: "literal", + value: getJsValue(ctx, property.type as JsLiteralType), + }, + }, + }, + body: { kind: "result", type: model }, + }); + } else if (ranges && ranges.size > 0) { + // A range property value exists that can differentiate this model. + const firstUniqueRange = ranges.values().next().value as RenderedPropertyName; + + const property = [...model.properties.values()].find( + (p) => renderPropertyName(p) === firstUniqueRange, + )!; + + const range = [...propertyRanges.get(firstUniqueRange)!.entries()].find( + ([range, candidate]) => candidate === model, + )![0]; + + branches.push({ + condition: { + kind: "binary-op", + left: { + kind: "binary-op", + left: { kind: "literal", value: renderPropertyName(property) }, + operator: "in", + right: SUBJECT, + }, + operator: "&&", + right: { + kind: "in-range", + expr: { kind: "model-property", property }, + range, + }, + }, + body: { kind: "result", type: model }, + }); + } else { + const firstUniqueProp = unique.values().next().value as PropertyName; + + branches.push({ + condition: { + kind: "binary-op", + left: { kind: "literal", value: firstUniqueProp }, + operator: "in", + right: SUBJECT, + }, + body: { kind: "result", type: model }, + }); + } + } + + return { + kind: "if-chain", + branches, + else: defaultCase + ? { + kind: "result", + type: defaultCase, + } + : undefined, + }; +} + +/** + * Options for the `writeCodeTree` function. + */ +export interface CodeTreeOptions { + /** + * The subject expression to use in the code tree. + * + * This text is used whenever a `SubjectReference` is encountered in the code tree, allowing the caller to specify + * how the subject is stored and referenced. + */ + subject: string; + + /** + * A function that converts a model property to a string reference. + * + * This function is used whenever a `ModelPropertyReference` is encountered in the code tree, allowing the caller to + * specify how model properties are stored and referenced. + */ + referenceModelProperty: (p: ModelProperty) => string; + + /** + * Renders a result when encountered in the code tree. + */ + renderResult: (type: PreciseType | UnknownType) => Iterable; +} + +/** + * Writes a code tree to text, given a set of options. + * + * @param ctx - The emitter context. + * @param tree - The code tree to write. + * @param options - The options to use when writing the code tree. + */ +export function* writeCodeTree( + ctx: JsContext, + tree: CodeTree, + options: CodeTreeOptions, +): Iterable { + switch (tree.kind) { + case "result": + yield* options.renderResult(tree.type); + break; + case "if-chain": { + let first = true; + for (const branch of tree.branches) { + const condition = writeExpression(ctx, branch.condition, options); + if (first) { + first = false; + yield `if (${condition}) {`; + } else { + yield `} else if (${condition}) {`; + } + yield* indent(writeCodeTree(ctx, branch.body, options)); + } + if (tree.else) { + yield "} else {"; + yield* indent(writeCodeTree(ctx, tree.else, options)); + } + yield "}"; + break; + } + case "switch": { + yield `switch (${writeExpression(ctx, tree.condition, options)}) {`; + for (const _case of tree.cases) { + yield ` case ${writeExpression(ctx, _case.value, options)}: {`; + yield* indent(indent(writeCodeTree(ctx, _case.body, options))); + yield " }"; + } + if (tree.default) { + yield " default: {"; + yield* indent(indent(writeCodeTree(ctx, tree.default, options))); + yield " }"; + } + yield "}"; + break; + } + case "verbatim": + yield* tree.body; + break; + default: + throw new UnreachableError("writeCodeTree for " + (tree satisfies never as CodeTree).kind, { + tree, + }); + } +} + +function writeExpression(ctx: JsContext, expression: Expression, options: CodeTreeOptions): string { + switch (expression.kind) { + case "binary-op": + return `(${writeExpression(ctx, expression.left, options)}) ${expression.operator} (${writeExpression( + ctx, + expression.right, + options, + )})`; + case "unary-op": + return `${expression.operator}(${writeExpression(ctx, expression.operand, options)})`; + case "typeof": + return `typeof (${writeExpression(ctx, expression.operand, options)})`; + case "literal": + switch (typeof expression.value) { + case "string": + return JSON.stringify(expression.value); + case "number": + case "bigint": + return String(expression.value); + case "boolean": + return expression.value ? "true" : "false"; + default: + throw new UnreachableError( + `writeExpression for literal value type '${typeof expression.value}'`, + ); + } + case "in-range": { + const { + expr, + range: [min, max], + } = expression; + const exprText = writeExpression(ctx, expr, options); + + return `(${exprText} >= ${min} && ${exprText} <= ${max})`; + } + case "verbatim": + return expression.text; + case "subject": + return options.subject; + case "model-property": + return options.referenceModelProperty(expression.property); + default: + throw new UnreachableError( + "writeExpression for " + (expression satisfies never as Expression).kind, + { + expression, + }, + ); + } +} diff --git a/src/util/error.ts b/src/util/error.ts new file mode 100644 index 0000000..89997fc --- /dev/null +++ b/src/util/error.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +/** + * A utility error for unimplemented functionality. + */ +export class UnimplementedError extends Error { + constructor(message: string) { + super(`Unimplemented: ${message}`); + } +} + +/** + * A utility error for unreachable code paths. + */ +export class UnreachableError extends Error { + constructor(message: string, values?: Record) { + let fullMessage = `Unreachable: ${message}`; + + if (values) { + fullMessage += `\nObserved values: ${Object.entries(values) + .map(([k, v]) => ` ${k}: ${String(v)}`) + .join(",\n")}`; + } + + super(fullMessage); + } +} diff --git a/src/util/extends.ts b/src/util/extends.ts new file mode 100644 index 0000000..d5a62d9 --- /dev/null +++ b/src/util/extends.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Interface, Model, ModelProperty, Operation } from "@typespec/compiler"; + +/** + * Recursively collects all properties of a model, including inherited properties. + */ +export function getAllProperties(model: Model, visited: Set = new Set()): ModelProperty[] { + if (visited.has(model)) return []; + + visited.add(model); + + const properties = [...model.properties.values()]; + + if (model.baseModel) { + properties.push(...getAllProperties(model.baseModel, visited)); + } + + return properties; +} + +/** + * Recursively collects all operations in an interface, including those inherited from source interfaces. + */ +export function getAllOperations( + iface: Interface, + visited: Set = new Set(), +): Operation[] { + if (visited.has(iface)) return []; + + visited.add(iface); + + const operations = [...iface.operations.values()]; + + if (iface.sourceInterfaces) { + for (const source of iface.sourceInterfaces) { + operations.push(...getAllOperations(source, visited)); + } + } + + return operations; +} diff --git a/src/util/iter.ts b/src/util/iter.ts new file mode 100644 index 0000000..d408e09 --- /dev/null +++ b/src/util/iter.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +/** + * Returns true if a value implements the ECMAScript Iterable interface. + */ +export function isIterable(value: unknown): value is object & Iterable { + return ( + typeof value === "object" && + value !== null && + Symbol.iterator in value && + typeof (value as Iterable)[Symbol.iterator] === "function" + ); +} + +/** + * Concatenate multiple iterables into a single iterable. + */ +export function* cat(...iterables: Iterable[]): Iterable { + for (const iterable of iterables) { + yield* iterable; + } +} + +/** + * Filter and collect an iterable into multiple groups based on a categorization function. + * + * The categorization function returns a string key for each value, and the values are returned in an object where each + * key is a category returned by the categorization function and the value is an array of values in that category. + * + * @param values - an iterable of values to categorize + * @param categorize - a categorization function that returns a string key for each value + * @returns an object where each key is a category and the value is an array of values in that category + */ + +export function categorize( + values: Iterable, + categorize: (o: T) => K, +): Partial> { + const result: Record = {} as any; + + for (const value of values) { + (result[categorize(value)] ??= []).push(value); + } + + return result; +} + +/** + * Filter and collect an iterable into two categorizations based on a predicate function. + * + * Items for which the predicate returns true will be returned in the first array. + * Items for which the predicate returns false will be returned in the second array. + * + * @param values - an iterable of values to filter + * @param predicate - a predicate function that decides whether a value should be included in the first or second array + * + * @returns a tuple of two arrays of values filtered by the predicate + */ +export function bifilter(values: Iterable, predicate: (o: T) => boolean): [T[], T[]] { + const pass: T[] = []; + const fail: T[] = []; + + for (const value of values) { + if (predicate(value)) { + pass.push(value); + } else { + fail.push(value); + } + } + + return [pass, fail]; +} + +/** + * Prepends a string `indentation` to each value in `values`. + * + * @param values - an iterable of strings to indent + * @param indentation - the string to prepend to the beginning of each value + */ +export function* indent(values: Iterable, indentation: string = " "): Iterable { + for (const value of values) { + yield indentation + value; + } +} diff --git a/src/util/keywords.ts b/src/util/keywords.ts new file mode 100644 index 0000000..5030e89 --- /dev/null +++ b/src/util/keywords.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +const KEYWORDS_CONTEXTUAL = [ + "any", + "boolean", + "constructor", + "declare", + "get", + "module", + "require", + "number", + "set", + "string", +]; + +const KEYWORDS_STRICT = [ + "as", + "implements", + "interface", + "let", + "package", + "private", + "protected", + "public", + "static", + "yield", + "symbol", + "type", + "from", + "of", +]; + +const KEYWORDS_RESERVED = [ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "new", + "null", + "return", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + + "namespace", + "async", + "await", + "module", + "delete", +]; + +/** + * A set of reserved keywords that should not be used as identifiers. + */ +export const KEYWORDS = new Set([...KEYWORDS_STRICT, ...KEYWORDS_RESERVED, ...KEYWORDS_CONTEXTUAL]); + +/** + * Makes a name safe to use as an identifier by prefixing it with an underscore + * if it would conflict with a keyword. + */ +export function keywordSafe(name: string): string { + return KEYWORDS.has(name) ? `_${name}` : name; +} diff --git a/src/util/name.ts b/src/util/name.ts new file mode 100644 index 0000000..e259cdf --- /dev/null +++ b/src/util/name.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { Namespace, Type } from "@typespec/compiler"; + +/** + * A TypeSpec type that may be attached to a namespace. + */ +export type NamespacedType = Extract; + +/** + * Computes the fully-qualified name of a TypeSpec type, i.e. `TypeSpec.boolean` for the built-in `boolean` scalar. + */ +export function getFullyQualifiedTypeName(type: NamespacedType): string { + const name = type.name ?? ""; + if (type.namespace) { + const nsPath = getFullyQualifiedNamespacePath(type.namespace); + + return (nsPath[0] === "" ? nsPath.slice(1) : nsPath).join(".") + "." + name; + } else { + return name; + } +} + +function getFullyQualifiedNamespacePath(ns: Namespace): string[] { + if (ns.namespace) { + const innerPath = getFullyQualifiedNamespacePath(ns.namespace); + innerPath.push(ns.name); + return innerPath; + } else { + return [ns.name]; + } +} diff --git a/src/util/once-queue.ts b/src/util/once-queue.ts new file mode 100644 index 0000000..b29ac79 --- /dev/null +++ b/src/util/once-queue.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +/** + * A deduplicating queue that only allows elements to be enqueued once. + * + * This uses a Set to track visited elements. + */ +export interface OnceQueue { + /** + * Enqueue a value if it has not been enqueued before. + */ + add(value: T): void; + /** + * Dequeue the next value. + */ + take(): T | undefined; + /** + * Check if the queue is empty. + */ + isEmpty(): boolean; +} + +/** + * Creates a new OnceQueue with the given initial values. + */ +export function createOnceQueue(...initialValues: T[]): OnceQueue { + const visited = new Set(); + const queue = [] as T[]; + let idx = 0; + const oncequeue: OnceQueue = { + add(value: T): void { + if (!visited.has(value)) { + visited.add(value); + queue.push(value); + } + }, + take(): T | undefined { + if (idx < queue.length) { + return queue[idx++]; + } else { + return undefined; + } + }, + isEmpty(): boolean { + return idx >= queue.length; + }, + }; + + for (const value of initialValues) { + oncequeue.add(value); + } + + return oncequeue; +} diff --git a/src/util/openapi3.ts b/src/util/openapi3.ts new file mode 100644 index 0000000..268ec62 --- /dev/null +++ b/src/util/openapi3.ts @@ -0,0 +1,53 @@ +import { Program, Service } from "@typespec/compiler"; +import type { OpenAPI3ServiceRecord, SupportedOpenAPIDocuments } from "@typespec/openapi3"; + +/** + * Attempts to import the OpenAPI 3 emitter if it is installed. + * + * @returns the OpenAPI 3 emitter module or undefined + */ +export function getOpenApi3Emitter(): Promise { + return import("@typespec/openapi3").catch(() => undefined); +} + +/** + * Gets the OpenAPI 3 service record for a given service. + * + * @param program - the program in which the service occurs + * @param service - the service to check + */ +export async function getOpenApi3ServiceRecord( + program: Program, + service: Service, +): Promise { + const openapi3 = await getOpenApi3Emitter(); + + if (!openapi3) return undefined; + + const serviceRecords = await openapi3.getOpenAPI3(program, { + "include-x-typespec-name": "never", + "omit-unreachable-types": true, + "safeint-strategy": "int64", + }); + + return serviceRecords.find((r) => r.service === service); +} + +/** + * Determines if an OpenAPI3 document can be generated for the given service. + * + * @param program - the program in which the service occurs + * @param service - the service to check + */ +export async function tryGetOpenApi3( + program: Program, + service: Service, +): Promise { + const serviceRecord = await getOpenApi3ServiceRecord(program, service); + + if (!serviceRecord) return undefined; + + if (serviceRecord.versioned) return undefined; + + return serviceRecord.document; +} diff --git a/src/util/pluralism.ts b/src/util/pluralism.ts new file mode 100644 index 0000000..aa8662f --- /dev/null +++ b/src/util/pluralism.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +/** + * Provides an alternative name for anonymous TypeSpec.Array elements. + * @param typeName + * @returns + */ +export function getArrayElementName(typeName: string): string { + return typeName + "Element"; +} + +/** + * Provides an alternative name for anonymous TypeSpec.Record values. + * @param typeName + * @returns + */ +export function getRecordValueName(typeName: string): string { + return typeName + "Value"; +} + +/** + * Produces the name of an array type for a given base type. + * + * If the type name is a simple identifier, this will use the `[]` syntax, + * otherwise it will use the `Array<>` type constructor. + * + * @param typeName - the base type to make an array of + * @returns a good representation of an array of the base type + */ +export function asArrayType(typeName: string): string { + if (/^[a-zA-Z_]+$/.test(typeName)) { + return typeName + "[]"; + } else { + return `Array<${typeName}>`; + } +} diff --git a/src/util/scope.ts b/src/util/scope.ts new file mode 100644 index 0000000..8443930 --- /dev/null +++ b/src/util/scope.ts @@ -0,0 +1,211 @@ +import { DiagnosticTarget, NoTarget } from "@typespec/compiler"; +import { JsContext } from "../ctx.js"; +import { reportDiagnostic } from "../lib.js"; +import { UnreachableError } from "./error.js"; + +/** + * A conceptual lexical scope. + */ +export interface Scope { + /** + * Declare a name in the scope, applying the appropriate resolution strategy if necessary. + * + * @param primaryName - the primary name we want to declare in this scope + * @param options - options for the declaration + * @returns the name that was finally declared in the scope + */ + declare(primaryName: string, options?: DeclarationOptions): string; + + /** + * Determines whether or not a given name is declared in the scope. + * + * @param name - the name to check for declaration + */ + isDeclared(name: string): boolean; +} + +export interface DeclarationOptions { + /** + * The source of the declaration, to be used when raising diagnostics. + * + * Default: NoTarget + */ + source?: DiagnosticTarget | typeof NoTarget; + /** + * The resolution strategy to use if the declared name conflicts with an already declared name. + * + * Default: "shadow" + */ + resolutionStrategy?: ResolutionStrategy; +} + +const DEFAULT_DECLARATION_OPTIONS: Required = { + source: NoTarget, + resolutionStrategy: "shadow", +}; + +/** + * A strategy to use when attempting to resolve naming conflicts. This can be one of the following types: + * + * - `none`: no attempt will be made to resolve the naming conflict. + * - `shadow`: if the scope does not directly declare the name, this declaration will shadow it. + * - `prefix`: if the name is already declared, a prefix will be added to the name to resolve the conflict. + * - `alt-name`: if the name is already declared, an alternative name will be used to resolve the conflict. + */ +export type ResolutionStrategy = PrefixResolution | AltNameResolution | "shadow" | "none"; + +/** + * A resolution strategy that prepends a prefix. + */ +export interface PrefixResolution { + kind: "prefix"; + /** + * The prefix to append to the name. + * + * Default: "_". + */ + prefix?: string; + /** + * Whether or not to repeat the prefix until the conflict is resolved. + */ + repeated?: boolean; + /** + * Whether or not the name should shadow existing declarations. + * + * This setting applies to the primary name as well, so if the primary name is not own-declared in the scope, no + * prefix will be added. + */ + shadow?: boolean; +} + +/** + * A resolution strategy that attempts to use an alternative name to resolve conflicts. + */ +export interface AltNameResolution { + kind: "alt-name"; + /** + * The alternative name for this declaration. + */ + altName: string; +} + +const NO_PARENT: Scope = { + declare() { + throw new UnreachableError("Cannot declare in the no-parent scope"); + }, + isDeclared() { + return false; + }, +}; + +/** + * Create a new scope. + * + * @param ctx - the JS emitter context. + * @param parent - an optional parent scope for this scope. It will consider declarations in the parent scope for some conflicts. + */ +export function createScope(ctx: JsContext, parent: Scope = NO_PARENT): Scope { + const ownDeclarations: Set = new Set(); + const self: Scope = { + declare(primaryName, options = {}) { + const { source: target, resolutionStrategy } = { ...DEFAULT_DECLARATION_OPTIONS, ...options }; + + if (!self.isDeclared(primaryName)) { + ownDeclarations.add(primaryName); + return primaryName; + } + + // Apply resolution strategy + const resolutionStrategyName = + typeof resolutionStrategy === "string" ? resolutionStrategy : resolutionStrategy.kind; + + switch (resolutionStrategyName) { + case "none": + // Report diagnostic and return the name as is. + reportDiagnostic(ctx.program, { + code: "name-conflict", + format: { + name: primaryName, + }, + target, + }); + return primaryName; + case "shadow": + // Check to make sure this name isn't an own-declaration, and if not allow it, otherwise raise a diagnostic. + if (!ownDeclarations.has(primaryName)) { + ownDeclarations.add(primaryName); + return primaryName; + } else { + reportDiagnostic(ctx.program, { + code: "name-conflict", + format: { + name: primaryName, + }, + target, + }); + return primaryName; + } + case "prefix": { + const { + prefix = "_", + repeated = false, + shadow = true, + } = resolutionStrategy as PrefixResolution; + let name = primaryName; + + const isDeclared = shadow ? (name: string) => ownDeclarations.has(name) : self.isDeclared; + + while (isDeclared(name)) { + name = prefix + name; + + if (!repeated) break; + } + + if (isDeclared(name)) { + // We were not able to resolve the conflict with this strategy, so raise a diagnostic. + reportDiagnostic(ctx.program, { + code: "name-conflict", + format: { + name: name, + }, + target, + }); + + return name; + } + + ownDeclarations.add(name); + return name; + } + case "alt-name": { + const { altName } = resolutionStrategy as AltNameResolution; + + if (!self.isDeclared(altName)) { + ownDeclarations.add(altName); + return altName; + } + + // We were not able to resolve the conflict with this strategy, so raise a diagnostic. + reportDiagnostic(ctx.program, { + code: "name-conflict", + format: { + name: altName, + }, + target, + }); + + return altName; + } + default: + throw new UnreachableError(`Unknown resolution strategy: ${resolutionStrategy}`, { + resolutionStrategyName, + }); + } + }, + isDeclared(name) { + return ownDeclarations.has(name) || parent.isDeclared(name); + }, + }; + + return self; +} diff --git a/src/write.ts b/src/write.ts new file mode 100644 index 0000000..c1f51c0 --- /dev/null +++ b/src/write.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation +// Licensed under the MIT license. + +import { resolvePath } from "@typespec/compiler"; +import { JsContext, Module, isModule } from "./ctx.js"; + +import { emitModuleBody } from "./common/namespace.js"; +import { OnceQueue, createOnceQueue } from "./util/once-queue.js"; + +import * as prettier from "prettier"; + +import { EOL } from "os"; +import path from "path"; +import { bifilter } from "./util/iter.js"; + +/** + * Writes the tree of modules to the output directory. + * + * @param ctx - The emitter context. + * @param baseOutputPath - The base output directory to write the module tree to. + * @param rootModule - The root module to begin emitting from. + * @param format - Whether to format the output using Prettier. + */ +export async function writeModuleTree( + ctx: JsContext, + baseOutputPath: string, + rootModule: Module, + format: boolean, +): Promise { + const queue = createOnceQueue(rootModule); + + while (!queue.isEmpty()) { + const module = queue.take()!; + await writeModuleFile(ctx, baseOutputPath, module, queue, format); + } +} + +/** + * Write a single module file to the output directory. + * + * @param ctx - The emitter context. + * @param baseOutputPath - The base output directory to write the module tree to. + * @param module - The module to write. + * @param queue - The queue of modules to write. + * @param format - Whether to format the output using Prettier. + * @param spit - The function used to write the file to disk (default: `ctx.program.host.writeFile`). + */ +export async function writeModuleFile( + ctx: JsContext, + baseOutputPath: string, + module: Module, + queue: OnceQueue, + format: boolean, + spit: (path: string, contents: string) => Promise = async (name, contents) => { + await ctx.program.host.mkdirp(path.dirname(name)); + await ctx.program.host.writeFile(name, contents); + }, +): Promise { + const moduleText = [ + "// Generated by Microsoft TypeSpec", + "", + ...emitModuleBody(ctx, module, queue), + ]; + + const [declaredModules, declaredText] = bifilter(module.declarations, isModule); + + if (declaredText.length === 0) { + // Early exit to avoid writing an empty module. + return; + } + + const isIndex = module.cursor.path.length === 0 || declaredModules.length > 0; + + const moduleRelativePath = + module.cursor.path.length > 0 + ? module.cursor.path.join("/") + (isIndex ? "/index.ts" : ".ts") + : "index.ts"; + + const modulePath = resolvePath(baseOutputPath, moduleRelativePath); + + const text = format + ? await prettier.format(moduleText.join(EOL), { + parser: "typescript", + }) + : moduleText.join(EOL); + + await spit(modulePath, text); +} diff --git a/test/header.test.ts b/test/header.test.ts new file mode 100644 index 0000000..4227752 --- /dev/null +++ b/test/header.test.ts @@ -0,0 +1,26 @@ +import { assert, describe, it } from "vitest"; +import { parseHeaderValueParameters } from "../src/helpers/header.js"; + +describe("headers", () => { + it("parses header values with parameters", () => { + const { value, params } = parseHeaderValueParameters("text/html; charset=utf-8"); + + assert.equal(value, "text/html"); + assert.equal(params.charset, "utf-8"); + }); + + it("parses a header value with a quoted parameter", () => { + const { value, params } = parseHeaderValueParameters('text/html; charset="utf-8"'); + + assert.equal(value, "text/html"); + assert.equal(params.charset, "utf-8"); + }); + + it("parses a header value with multiple parameters", () => { + const { value, params } = parseHeaderValueParameters('text/html; charset="utf-8"; foo=bar'); + + assert.equal(value, "text/html"); + assert.equal(params.charset, "utf-8"); + assert.equal(params.foo, "bar"); + }); +}); diff --git a/test/multipart.test.ts b/test/multipart.test.ts new file mode 100644 index 0000000..d62a7ac --- /dev/null +++ b/test/multipart.test.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from "stream"; +import { assert, describe, it } from "vitest"; +import { createMultipartReadable } from "../src/helpers/multipart.js"; + +import type * as http from "node:http"; + +interface StringChunkOptions { + sizeConstraint: [number, number]; + timeConstraintMs: [number, number]; +} + +function chunkString(s: string, options: StringChunkOptions): EventEmitter { + const [min, max] = options.sizeConstraint; + const [minTime, maxTime] = options.timeConstraintMs; + const emitter = new EventEmitter(); + let i = 0; + + function emitChunk() { + const chunkSize = Math.floor(Math.random() * (max - min + 1) + min); + emitter.emit("data", Buffer.from(s.slice(i, i + chunkSize))); + i += chunkSize; + } + + setTimeout( + function tick() { + emitChunk(); + + if (i < s.length) { + setTimeout(tick, Math.floor(Math.random() * (maxTime - minTime + 1) + minTime)); + } else { + emitter.emit("end"); + } + }, + Math.floor(Math.random() * (maxTime - minTime + 1) + minTime), + ); + + return emitter; +} + +const exampleMultipart = [ + "This is the preamble text. It should be ignored.", + "--boundary", + 'Content-Disposition: form-data; name="field1"', + "Content-Type: application/json", + "", + '"value1"', + "--boundary", + 'Content-Disposition: form-data; name="field2"', + "", + "value2", + "--boundary--", +].join("\r\n"); + +function createMultipartRequestLike( + text: string, + boundary: string = "boundary", +): http.IncomingMessage { + return Object.assign( + chunkString(text, { sizeConstraint: [40, 90], timeConstraintMs: [20, 30] }), + { + headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, + }, + ) as any; +} + +describe("multipart", () => { + it("correctly chunks multipart data", async () => { + const request = createMultipartRequestLike(exampleMultipart); + + const stream = createMultipartReadable(request); + + const parts: Array<{ headers: { [k: string]: string | undefined }; body: string }> = []; + + for await (const part of stream) { + parts.push({ + headers: part.headers, + body: await (async () => { + const chunks = []; + for await (const chunk of part.body) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString(); + })(), + }); + } + + assert.deepStrictEqual(parts, [ + { + headers: { + "content-disposition": 'form-data; name="field1"', + "content-type": "application/json", + }, + body: '"value1"', + }, + { + headers: { "content-disposition": 'form-data; name="field2"' }, + body: "value2", + }, + ]); + }); + + it("detects missing boundary", () => { + assert.throws(() => { + createMultipartReadable({ headers: {} } as any); + }, "missing boundary"); + + assert.throws(() => { + createMultipartReadable({ + headers: { "content-type": "multipart/form-data" }, + } as any); + }, "missing boundary"); + }); + + it("detects unexpected termination", async () => { + const request = createMultipartRequestLike( + [ + "--boundary", + 'Content-Disposition: form-data; name="field1"', + "Content-Type: application/json", + "", + '"value1"', + "--boundary asdf asdf", + ].join("\r\n"), + ); + + const stream = createMultipartReadable(request); + + try { + for await (const part of stream) { + for await (const _ of part.body) { + // Do nothing + } + } + assert.fail(); + } catch (e) { + assert.equal((e as Error).message, "Unexpected characters after final boundary."); + } + }); + + it("detects invalid preamble text", async () => { + const request = createMultipartRequestLike( + [ + "This is the preamble text. It should be ignored.--boundary", + 'Content-Disposition: form-data; name="field1"', + "Content-Type: application/json", + "", + '"value1"', + "--boundary", + 'Content-Disposition: form-data; name="field2"', + "", + "value2", + "--boundary--", + ].join("\r\n"), + ); + + const stream = createMultipartReadable(request); + + try { + for await (const part of stream) { + for await (const _ of part.body) { + // Do nothing + } + } + assert.fail(); + } catch (e) { + assert.equal((e as Error).message, "Invalid preamble in multipart body."); + } + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..14f4cbd --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "composite": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true, + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "sourceMap": true, + "declarationMap": true, + "strict": true, + "declaration": true, + "stripInternal": true, + "noEmitHelpers": false, + "target": "ES2022", + "types": [ + "node" + ], + "lib": [ + "es2022", + "DOM" + ], + "experimentalDecorators": true, + "newLine": "LF" + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d018dde --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.base.json", + "references": [], + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*.ts", + "generated-defs/**/*.ts", + "src/scripts/scaffold/bin.mts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..572bf33 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; + +/** + * Default Config For all TypeSpec projects using vitest. + */ +const defaultTypeSpecVitestConfig = defineConfig({ + test: { + environment: "node", + isolate: false, + coverage: { + reporter: ["cobertura", "json", "text"], + }, + outputFile: { + junit: "./test-results.xml", + }, + exclude: ["node_modules", "dist/test"], + }, + server: { + watch: { + ignored: [], + }, + }, +}); + +export default defaultTypeSpecVitestConfig;