initial project setup
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
generated-*
|
||||||
12
.vscode/settings.json
vendored
Normal file
12
.vscode/settings.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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
|
||||||
170
build-helpers.ts
Normal file
170
build-helpers.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string, string[]>();
|
||||||
|
|
||||||
|
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<Module> {",
|
||||||
|
" 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<Module> {",
|
||||||
|
" 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);
|
||||||
|
});
|
||||||
4207
package-lock.json
generated
Normal file
4207
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
package.json
Normal file
71
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/common/declaration.ts
Normal file
52
src/common/declaration.ts
Normal file
@@ -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<string> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/common/documentation.ts
Normal file
26
src/common/documentation.ts
Normal file
@@ -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<string> {
|
||||||
|
const doc = getDoc(ctx.program, type);
|
||||||
|
|
||||||
|
if (doc === undefined) return;
|
||||||
|
|
||||||
|
yield `/**`;
|
||||||
|
|
||||||
|
yield* indent(doc.trim().split(/\r?\n/g), " * ");
|
||||||
|
|
||||||
|
yield ` */`;
|
||||||
|
}
|
||||||
28
src/common/enum.ts
Normal file
28
src/common/enum.ts
Normal file
@@ -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<string> {
|
||||||
|
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 `}`;
|
||||||
|
}
|
||||||
264
src/common/interface.ts
Normal file
264
src/common/interface.ts
Normal file
@@ -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<string> {
|
||||||
|
const name = parseCase(iface.name).pascalCase;
|
||||||
|
|
||||||
|
yield* emitDocumentation(ctx, iface);
|
||||||
|
yield `export interface ${name}<Context = unknown> {`;
|
||||||
|
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<Operation>,
|
||||||
|
module: Module,
|
||||||
|
): Iterable<string> {
|
||||||
|
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<string> {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/common/model.ts
Normal file
160
src/common/model.ts
Normal file
@@ -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<string> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/common/namespace.ts
Normal file
243
src/common/namespace.ts
Normal file
@@ -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<DeclarationType>(
|
||||||
|
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}<Context = unknown> {`,
|
||||||
|
...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<Module>,
|
||||||
|
): Iterable<string> {
|
||||||
|
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<string> {
|
||||||
|
const allTargets = new Set<string>();
|
||||||
|
const importMap = new Map<string, Set<string>>();
|
||||||
|
const starAsMap = new Map<string, string>();
|
||||||
|
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<string>();
|
||||||
|
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<Module>,
|
||||||
|
): Iterable<string> {
|
||||||
|
yield* writeImportsNormalized(ctx, module);
|
||||||
|
|
||||||
|
if (module.imports.length > 0) yield "";
|
||||||
|
|
||||||
|
for (const decl of module.declarations) {
|
||||||
|
yield* emitModuleBodyDeclaration(ctx, decl, queue);
|
||||||
|
yield "";
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/common/reference.ts
Normal file
319
src/common/reference.ts
Normal file
@@ -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<Type, { namespace?: Namespace }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/common/scalar.ts
Normal file
173
src/common/scalar.ts
Normal file
@@ -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<Program, Map<Scalar, string>>();
|
||||||
|
|
||||||
|
function getScalarsMap(program: Program): Map<Scalar, string> {
|
||||||
|
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<Scalar, string> {
|
||||||
|
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 ?? "<anonymous>"} is a '${type.kind}', expected 'scalar'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Map<Scalar, string>(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";
|
||||||
|
}
|
||||||
124
src/common/serialization/index.ts
Normal file
124
src/common/serialization/index.ts
Normal file
@@ -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<SerializableType, Set<SerializationContentType>>();
|
||||||
|
|
||||||
|
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<SerializationContentType>(
|
||||||
|
[...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<SerializationContentType>,
|
||||||
|
): 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<string> {
|
||||||
|
switch (contentType) {
|
||||||
|
case "application/json": {
|
||||||
|
yield* emitJsonSerialization(ctx, type, module, typeName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unreachable: serialization content type ${contentType satisfies never}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
444
src/common/serialization/json.ts
Normal file
444
src/common/serialization/json.ts
Normal file
@@ -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<SerializableType | ModelProperty, boolean>();
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/common/union.ts
Normal file
76
src/common/union.ts
Normal file
@@ -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<string> {
|
||||||
|
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(" | ")};`;
|
||||||
|
}
|
||||||
527
src/ctx.ts
Normal file
527
src/ctx.ts
Normal file
@@ -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<DeclarationType>;
|
||||||
|
/**
|
||||||
|
* 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<DeclarationType, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Namespace, Module>;
|
||||||
|
/**
|
||||||
|
* 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<SerializableType>;
|
||||||
|
|
||||||
|
gensym: (name: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInitialContext(
|
||||||
|
program: Program,
|
||||||
|
options: JsEmitterOptions
|
||||||
|
): Promise<JsContext | void> {
|
||||||
|
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<Program, { idx: number }>();
|
||||||
|
|
||||||
|
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++}`;
|
||||||
|
}
|
||||||
55
src/helpers/header.ts
Normal file
55
src/helpers/header.ts
Normal file
@@ -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<Header extends string | undefined>(
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
113
src/helpers/http.ts
Normal file
113
src/helpers/http.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/helpers/multipart.ts
Normal file
228
src/helpers/multipart.ts
Normal file
@@ -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<Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ReadableStream<Buffer>, 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<ReadableStream<Buffer>> = null as any;
|
||||||
|
|
||||||
|
const readable = new ReadableStream<ReadableStream<Buffer>>({
|
||||||
|
start(controller) {
|
||||||
|
_readableController = controller;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const readableController = _readableController;
|
||||||
|
|
||||||
|
const writable = new WritableStream<Buffer>({
|
||||||
|
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<ReadableStreamDefaultController>((resolve) => {
|
||||||
|
readableController.enqueue(
|
||||||
|
new ReadableStream<Buffer>({
|
||||||
|
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<ReadableStream<Buffer>, 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<Buffer>({
|
||||||
|
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<HttpPart> {
|
||||||
|
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<Uint8Array>({
|
||||||
|
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<R> {
|
||||||
|
[Symbol.asyncIterator](): AsyncIterableIterator<R>;
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/helpers/router.ts
Normal file
238
src/helpers/router.ts
Normal file
@@ -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<Out extends (ctx: HttpContext, ...rest: any[]) => 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<RouteConfig extends { [k: string]: object }> = {
|
||||||
|
[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<RouteConfig>,
|
||||||
|
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<RouteConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<RouterOptions["onRequestNotFound"], undefined>;
|
||||||
|
/**
|
||||||
|
* Signals that the request was invalid.
|
||||||
|
*/
|
||||||
|
onInvalidRequest: Exclude<RouterOptions["onInvalidRequest"], undefined>;
|
||||||
|
/**
|
||||||
|
* Signals that an internal error occurred.
|
||||||
|
*/
|
||||||
|
onInternalError: Exclude<RouterOptions["onInternalError"], undefined>;
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/http/index.ts
Normal file
81
src/http/index.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
548
src/http/server/index.ts
Normal file
548
src/http/server/index.ts
Normal file
@@ -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<Names, "isHttpResponder" | "httpResponderSym">,
|
||||||
|
): Iterable<string> {
|
||||||
|
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<HttpOperationParameter, { type: "path" }>[];
|
||||||
|
|
||||||
|
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<void> {";
|
||||||
|
|
||||||
|
const [_, parameters] = bifilter(op.parameters.properties.values(), (param) =>
|
||||||
|
isValueLiteralType(param.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParams: Extract<HttpOperationParameter, { type: "query" }>[] = [];
|
||||||
|
|
||||||
|
const parsedParams = new Set<ModelProperty>();
|
||||||
|
|
||||||
|
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<string, Type>(
|
||||||
|
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<Buffer> = [];`;
|
||||||
|
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<string, string>();
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<HttpOperationParameter, { type: "header" }>,
|
||||||
|
): Iterable<string> {
|
||||||
|
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<HttpOperationParameter, { type: "query" }>,
|
||||||
|
): Iterable<string> {
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
272
src/http/server/multipart.ts
Normal file
272
src/http/server/multipart.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<Buffer> = [];`;
|
||||||
|
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};`;
|
||||||
|
}
|
||||||
686
src/http/server/router.ts
Normal file
686
src/http/server/router.ts
Normal file
@@ -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<string> {
|
||||||
|
const routerName = parseCase(service.namespace.name).pascalCase + "Router";
|
||||||
|
|
||||||
|
const uniqueContainers = new Set(service.operations.map((operation) => operation.container));
|
||||||
|
|
||||||
|
const backends = new Map<OperationContainer, [ReCase, string]>();
|
||||||
|
|
||||||
|
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}<HttpContext>,`;
|
||||||
|
}
|
||||||
|
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<OperationContainer, [ReCase, string]>,
|
||||||
|
module: Module,
|
||||||
|
): Iterable<string> {
|
||||||
|
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<HttpVerb, RouteOperation[]>,
|
||||||
|
backends: Map<OperationContainer, [ReCase, string]>,
|
||||||
|
): Iterable<string> {
|
||||||
|
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<OperationContainer, [ReCase, string]>,
|
||||||
|
): Iterable<string> {
|
||||||
|
const usedContentTypes = new Set<string>();
|
||||||
|
const contentTypeMap = new Map<RouteOperation, string>();
|
||||||
|
|
||||||
|
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<HttpVerb, RouteOperation[]>;
|
||||||
|
/**
|
||||||
|
* A set of parameters that are bound in this position before proceeding along the subsequent tree.
|
||||||
|
*/
|
||||||
|
bind?: [Set<string>, 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<string, Route[]>();
|
||||||
|
|
||||||
|
// 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<string>, RouteTree] | undefined;
|
||||||
|
|
||||||
|
if (parameterized.length > 0) {
|
||||||
|
const parameters = new Set<string>();
|
||||||
|
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<HttpVerb, RouteOperation[]>();
|
||||||
|
|
||||||
|
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<string, Type>(
|
||||||
|
[...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;
|
||||||
|
}
|
||||||
62
src/index.ts
Normal file
62
src/index.ts
Normal file
@@ -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<JsEmitterOptions>) {
|
||||||
|
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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/lib.ts
Normal file
141
src/lib.ts
Normal file
@@ -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<JsEmitterOptions> = {
|
||||||
|
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 };
|
||||||
781
src/scripts/scaffold/bin.mts
Normal file
781
src/scripts/scaffold/bin.mts
Normal file
@@ -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<void> {
|
||||||
|
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<ScaffoldingOptions> = {};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<Module>();
|
||||||
|
|
||||||
|
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<Module>(),
|
||||||
|
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<Module>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<RouteController[]> {
|
||||||
|
const controllers: RouteController[] = [];
|
||||||
|
|
||||||
|
const operationsByContainer = new Map<OperationContainer, Set<HttpOperation>>();
|
||||||
|
|
||||||
|
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<HttpOperation>,
|
||||||
|
controllersModule: Module,
|
||||||
|
): Promise<RouteController> {
|
||||||
|
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}<HttpContext> {`,
|
||||||
|
...indent(emitControllerOperationHandlers(ctx, container, operations, module)),
|
||||||
|
`}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { name: controllerName, module };
|
||||||
|
}
|
||||||
|
|
||||||
|
function* emitControllerOperationHandlers(
|
||||||
|
ctx: JsContext,
|
||||||
|
container: OperationContainer,
|
||||||
|
httpOperations: Set<HttpOperation>,
|
||||||
|
module: Module,
|
||||||
|
): Iterable<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
10
src/testing/index.ts
Normal file
10
src/testing/index.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
182
src/util/case.ts
Normal file
182
src/util/case.ts
Normal file
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
957
src/util/differentiate.ts
Normal file
957
src/util/differentiate.ts
Normal file
@@ -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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<PreciseType>();
|
||||||
|
|
||||||
|
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<PreciseType>,
|
||||||
|
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<string, Scalar>();
|
||||||
|
|
||||||
|
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<V1, V2 extends V1>(keys: Iterable<V2>, set: Set<V1>): Set<V2> {
|
||||||
|
const result = new Set<V2>();
|
||||||
|
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<Model>,
|
||||||
|
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<Model, Set<PropertyName>>();
|
||||||
|
|
||||||
|
// Map of property names to maps of literal values that identify a model.
|
||||||
|
const propertyLiterals = new Map<RenderedPropertyName, Map<LiteralValue, Model>>();
|
||||||
|
// Map of models to properties with values that can uniquely identify it
|
||||||
|
const uniqueLiterals = new Map<Model, Set<RenderedPropertyName>>();
|
||||||
|
|
||||||
|
const propertyRanges = new Map<RenderedPropertyName, Map<IntegerRange, Model>>();
|
||||||
|
const uniqueRanges = new Map<Model, Set<RenderedPropertyName>>();
|
||||||
|
|
||||||
|
for (const model of models) {
|
||||||
|
const props = new Set<string>();
|
||||||
|
|
||||||
|
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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/util/error.ts
Normal file
28
src/util/error.ts
Normal file
@@ -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<string, never>) {
|
||||||
|
let fullMessage = `Unreachable: ${message}`;
|
||||||
|
|
||||||
|
if (values) {
|
||||||
|
fullMessage += `\nObserved values: ${Object.entries(values)
|
||||||
|
.map(([k, v]) => ` ${k}: ${String(v)}`)
|
||||||
|
.join(",\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
super(fullMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/util/extends.ts
Normal file
43
src/util/extends.ts
Normal file
@@ -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<Model> = 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<Interface> = 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;
|
||||||
|
}
|
||||||
85
src/util/iter.ts
Normal file
85
src/util/iter.ts
Normal file
@@ -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<unknown> {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
Symbol.iterator in value &&
|
||||||
|
typeof (value as Iterable<unknown>)[Symbol.iterator] === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate multiple iterables into a single iterable.
|
||||||
|
*/
|
||||||
|
export function* cat<T>(...iterables: Iterable<T>[]): Iterable<T> {
|
||||||
|
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<T, K extends string>(
|
||||||
|
values: Iterable<T>,
|
||||||
|
categorize: (o: T) => K,
|
||||||
|
): Partial<Record<K, T[]>> {
|
||||||
|
const result: Record<K, T[]> = {} 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<T>(values: Iterable<T>, 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<string>, indentation: string = " "): Iterable<string> {
|
||||||
|
for (const value of values) {
|
||||||
|
yield indentation + value;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/util/keywords.ts
Normal file
90
src/util/keywords.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
33
src/util/name.ts
Normal file
33
src/util/name.ts
Normal file
@@ -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<Type, { namespace?: Namespace | undefined }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ?? "<unknown>";
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/util/once-queue.ts
Normal file
55
src/util/once-queue.ts
Normal file
@@ -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<T> {
|
||||||
|
/**
|
||||||
|
* 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<T>(...initialValues: T[]): OnceQueue<T> {
|
||||||
|
const visited = new Set<T>();
|
||||||
|
const queue = [] as T[];
|
||||||
|
let idx = 0;
|
||||||
|
const oncequeue: OnceQueue<T> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
53
src/util/openapi3.ts
Normal file
53
src/util/openapi3.ts
Normal file
@@ -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<typeof import("@typespec/openapi3") | undefined> {
|
||||||
|
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<OpenAPI3ServiceRecord | undefined> {
|
||||||
|
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<SupportedOpenAPIDocuments | undefined> {
|
||||||
|
const serviceRecord = await getOpenApi3ServiceRecord(program, service);
|
||||||
|
|
||||||
|
if (!serviceRecord) return undefined;
|
||||||
|
|
||||||
|
if (serviceRecord.versioned) return undefined;
|
||||||
|
|
||||||
|
return serviceRecord.document;
|
||||||
|
}
|
||||||
37
src/util/pluralism.ts
Normal file
37
src/util/pluralism.ts
Normal file
@@ -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}>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/util/scope.ts
Normal file
211
src/util/scope.ts
Normal file
@@ -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<DeclarationOptions> = {
|
||||||
|
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<string> = 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;
|
||||||
|
}
|
||||||
88
src/write.ts
Normal file
88
src/write.ts
Normal file
@@ -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<void> {
|
||||||
|
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<Module>,
|
||||||
|
format: boolean,
|
||||||
|
spit: (path: string, contents: string) => Promise<void> = async (name, contents) => {
|
||||||
|
await ctx.program.host.mkdirp(path.dirname(name));
|
||||||
|
await ctx.program.host.writeFile(name, contents);
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
26
test/header.test.ts
Normal file
26
test/header.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
169
test/multipart.test.ts
Normal file
169
test/multipart.test.ts
Normal file
@@ -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.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
29
tsconfig.base.json
Normal file
29
tsconfig.base.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
vitest.config.ts
Normal file
25
vitest.config.ts
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user