@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
1,001 lines (901 loc) • 27.4 kB
text/typescript
// 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 { $ } from "@typespec/compiler/typekit";
import { getJsScalar } from "../common/scalar.js";
import { JsContext, Module } 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> | (() => 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,
module: Module,
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).intrinsic.any };
}
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, module, 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,
module: Module,
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, module, 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, module, scalar, scalar).type;
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, module, 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,
module: Module,
property: ModelProperty,
): IntegerRange | false {
if (
property.type.kind === "Scalar" &&
getJsScalar(ctx, module, property.type, property).type === "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];
}
/**
* Optional paramters for model differentiation.
*/
interface DifferentiateModelOptions {
/**
* A function that converts a model property reference over the subject to a string.
*
* Default: `(prop) => prop.name`
*/
renderPropertyName?: (prop: ModelProperty) => string;
/**
* A filter function that determines which properties to consider for differentiation.
*
* Default: `() => true`
*/
filter?: (prop: ModelProperty) => boolean;
/**
* The default case to use if no other cases match.
*
* Default: undefined.
*/
else?: CodeTree | undefined;
}
const DEFAULT_DIFFERENTIATE_OPTIONS = {
renderPropertyName: PROPERTY_ID,
filter: () => true,
else: undefined,
} as const;
/**
* 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,
module: Module,
models: Set<Model>,
options?: DifferentiateModelOptions,
): CodeTree;
export function differentiateModelTypes(
ctx: JsContext,
module: Module,
models: Set<Model>,
_options: DifferentiateModelOptions = {},
): CodeTree {
const options = { ...DEFAULT_DIFFERENTIATE_OPTIONS, ..._options };
// 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).filter(options.filter)) {
// 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 = options.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, module, 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: CodeTree | undefined = options.else;
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 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 = { kind: "result", type: 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) => (options.renderPropertyName(p) as RenderedPropertyName) === firstUniqueLiteral,
)!;
branches.push({
condition: {
kind: "binary-op",
left: {
kind: "binary-op",
left: { kind: "literal", value: options.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) => options.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: options.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,
};
}
/**
* 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":
if (typeof tree.body === "function") {
yield* tree.body();
} else {
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,
},
);
}
}