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