@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
589 lines • 23.2 kB
JavaScript
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
import { getDiscriminator, getMaxValue, getMinValue, isArrayModelType, isNeverType, isUnknownType, } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import { getJsScalar } from "../common/scalar.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";
/**
* 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) {
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")));
}
const SUBJECT = { kind: "subject" };
function isLiteralValueType(type) {
return (type.kind === "Boolean" ||
type.kind === "Number" ||
type.kind === "String" ||
type.kind === "EnumMember");
}
const PROPERTY_ID = (prop) => 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, module, union, renderPropertyName = PROPERTY_ID) {
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();
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.properties.get(discriminator);
return {
kind: "switch",
condition: {
kind: "model-property",
property,
},
cases: variants.map((v) => {
const discriminatorPropertyType = v.type.properties.get(discriminator).type;
return {
value: { kind: "literal", value: getJsValue(ctx, discriminatorPropertyType) },
body: { kind: "result", type: v.type },
};
}),
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, module, cases, renderPropertyName = PROPERTY_ID) {
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 ?? []),
];
const models = categories.Model ?? [];
const scalars = categories.Scalar ?? [];
const intrinsics = categories.Intrinsic ?? [];
if (literals.length + scalars.length + intrinsics.length === 0) {
return differentiateModelTypes(ctx, module, select(models, cases), { renderPropertyName });
}
else {
const branches = [];
for (const intrinsic of intrinsics) {
const intrinsicValue = intrinsic.name === "void" ? "undefined" : "null";
branches.push({
condition: {
kind: "binary-op",
operator: "===",
left: SUBJECT,
right: {
kind: "verbatim",
text: intrinsicValue,
},
},
body: {
kind: "result",
type: intrinsic,
},
});
}
for (const literal of literals) {
branches.push({
condition: {
kind: "binary-op",
operator: "===",
left: SUBJECT,
right: { kind: "literal", value: getJsValue(ctx, literal) },
},
body: {
kind: "result",
type: literal,
},
});
}
const scalarRepresentations = new Map();
for (const scalar of scalars) {
const jsScalar = getJsScalar(ctx, 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;
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(keys, set) {
const result = new Set();
for (const key of keys) {
if (set.has(key))
result.add(key);
}
return result;
}
}
/**
* Gets a JavaScript literal value for a given LiteralType.
*/
function getJsValue(ctx, literal) {
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.kind, { literal });
}
}
function getIntegerRange(ctx, module, property) {
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, other) {
return range[0] <= other[1] && range[1] >= other[0];
}
const DEFAULT_DIFFERENTIATE_OPTIONS = {
renderPropertyName: PROPERTY_ID,
filter: () => true,
else: undefined,
};
export function differentiateModelTypes(ctx, module, models, _options = {}) {
const options = { ...DEFAULT_DIFFERENTIATE_OPTIONS, ..._options };
const uniqueProps = new Map();
// Map of property names to maps of literal values that identify a model.
const propertyLiterals = new Map();
// Map of models to properties with values that can uniquely identify it
const uniqueLiterals = new Map();
const propertyRanges = new Map();
const uniqueRanges = new Map();
let arrayVariant = undefined;
for (const model of models) {
if (isArrayModelType(ctx.program, model) && model.properties.size === 0 && !arrayVariant) {
arrayVariant = model;
continue;
}
const props = new Set();
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);
// 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)))) {
valid = false;
other.delete(prop.name);
}
}
if (valid) {
props.add(prop.name);
}
}
uniqueProps.set(model, props);
}
const branches = [];
let defaultCase = options.else;
if (arrayVariant) {
branches.push({
condition: {
kind: "is-array",
expr: SUBJECT,
},
body: { kind: "result", type: arrayVariant },
});
}
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;
const property = [...model.properties.values()].find((p) => options.renderPropertyName(p) === 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),
},
},
},
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;
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;
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,
};
}
/**
* 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, tree, options) {
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.kind, {
tree,
});
}
}
function writeExpression(ctx, expression, options) {
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 "is-array":
return `globalThis.Array.isArray(${writeExpression(ctx, expression.expr, 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.kind, {
expression,
});
}
}
//# sourceMappingURL=differentiate.js.map