initial project setup

This commit is contained in:
2025-03-11 08:16:29 -04:00
commit 001a4ee4fc
48 changed files with 12260 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
generated-*

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
23.9.0

12
.vscode/settings.json vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

71
package.json Normal file
View 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
View 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}`);
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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";
}

View 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}`);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 "";
}
}

View 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
View 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
View 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
View 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 };

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;