@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
577 lines (513 loc) • 18.2 kB
text/typescript
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
import {
BooleanLiteral,
DiagnosticTarget,
IntrinsicType,
ModelProperty,
NoTarget,
NumericLiteral,
Scalar,
StringLiteral,
Type,
compilerAssert,
getEncode,
isArrayModelType,
isRecordModelType,
resolveEncodedName,
} from "@typespec/compiler";
import { getHeaderFieldOptions, getPathParamOptions, getQueryParamOptions } from "@typespec/http";
import { JsContext, Module } from "../../ctx.js";
import { reportDiagnostic } from "../../lib.js";
import { access, objectLiteralProperty, 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 { keywordSafe } from "../../util/keywords.js";
import { getFullyQualifiedTypeName } from "../../util/name.js";
import { emitTypeReference, escapeUnsafeChars } from "../reference.js";
import { Encoder, JS_SCALAR_UNKNOWN, JsScalar, getJsScalar } from "../scalar.js";
import {
SerializableType,
SerializationContext,
isSerializableType,
requireSerialization,
} from "./index.js";
/**
* Memoization cache for requiresJsonSerialization.
*/
const _REQUIRES_JSON_SERIALIZATION = new WeakMap<
SerializableType | Scalar | ModelProperty,
boolean
>();
export function requiresJsonSerialization(
ctx: JsContext,
module: Module,
type: Type,
diagnosticTarget: DiagnosticTarget | typeof NoTarget = NoTarget,
): boolean {
if (!isJsonSerializable(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, module, argumentType);
break;
}
requiresSerialization = [...type.properties.values()].some((property) =>
propertyRequiresJsonSerialization(ctx, module, property),
);
break;
}
case "Scalar": {
const scalar = getJsScalar(ctx, module, type, diagnosticTarget);
requiresSerialization =
!scalar.isJsonCompatible ||
getEncode(ctx.program, type) !== undefined ||
scalar.getDefaultMimeEncoding("application/json") !== undefined;
break;
}
case "Union": {
requiresSerialization = [...type.variants.values()].some((variant) =>
requiresJsonSerialization(ctx, module, variant),
);
break;
}
case "ModelProperty":
requiresSerialization = requiresJsonSerialization(ctx, module, type.type);
break;
case "Enum":
requiresSerialization = false;
}
_REQUIRES_JSON_SERIALIZATION.set(type, requiresSerialization);
return requiresSerialization;
}
function propertyRequiresJsonSerialization(
ctx: JsContext,
module: Module,
property: ModelProperty,
): boolean {
const encodedName = resolveEncodedName(ctx.program, property, "application/json");
const jsPropertyName = keywordSafe(parseCase(property.name).camelCase);
return !!(
isHttpMetadata(ctx, property) ||
getEncode(ctx.program, property) ||
encodedName !== jsPropertyName ||
(isJsonSerializable(property.type) &&
requiresJsonSerialization(ctx, module, property.type, property))
);
}
function isHttpMetadata(ctx: JsContext, property: ModelProperty): boolean {
return (
getQueryParamOptions(ctx.program, property) !== undefined ||
getHeaderFieldOptions(ctx.program, property) !== undefined ||
getPathParamOptions(ctx.program, property) !== undefined
);
}
function isJsonSerializable(type: Type): type is SerializableType | Scalar | ModelProperty {
return type.kind === "ModelProperty" || type.kind === "Scalar" || isSerializableType(type);
}
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 =
resolveEncodedName(ctx.program, property, "application/json") ?? property.name;
const propertyName = keywordSafe(parseCase(property.name).camelCase);
let expr: string = access("input", propertyName);
const primitiveExpr = expr;
const encoding = getEncode(ctx.program, property);
if (property.type.kind === "Scalar" && encoding) {
const scalar = getJsScalar(ctx, module, property.type, property.type);
const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type);
if (scalarEncoder) {
expr = transposeExpressionToJson(
ctx,
// Assertion: scalarEncoder.target.scalar is defined because we resolved an encoder.
scalarEncoder.target.scalar as Scalar,
scalarEncoder.encode(expr),
module,
);
} else {
reportDiagnostic(ctx.program, {
code: "unknown-encoding",
target: NoTarget,
format: {
encoding: encoding.encoding ?? "<default>",
type: getFullyQualifiedTypeName(property.type),
target: getFullyQualifiedTypeName(encoding.type),
},
});
// We treat this as unknown from here on out. The encoding was not deciphered.
}
} else {
expr = transposeExpressionToJson(ctx, property.type, expr, module);
}
if (property.optional && requiresJsonSerialization(ctx, module, property.type)) {
expr = `(${primitiveExpr}) !== undefined ? ${expr} : undefined`;
}
yield ` ${objectLiteralProperty(encodedName)}: ${expr},`;
}
yield `};`;
return;
}
case "Union": {
const codeTree = differentiateUnion(ctx, module, type);
yield* writeCodeTree(ctx, codeTree, {
subject: "input",
referenceModelProperty(p) {
return access("input", parseCase(p.name).camelCase);
},
renderResult(type) {
return [`return ${transposeExpressionToJson(ctx, type, "input", module)};`];
},
});
return;
}
case "Enum": {
yield `return input;`;
return;
}
default: {
void (type satisfies never);
throw new UnimplementedError(`emitToJson: ${(type as Type).kind}`);
}
}
}
export 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, module, 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, module, argumentType)) {
return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [String(key), ${transposeExpressionToJson(
ctx,
argumentType,
"value",
module,
)}]))`;
} else {
return expr;
}
} else if (!requiresJsonSerialization(ctx, module, 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, module, type, NoTarget);
const encoder: Encoder = getScalarEncoder(ctx, type, scalar);
const encoded = encoder.encode(expr);
if (encoder.target.isJsonCompatible || !encoder.target.scalar) {
return encoded;
} else {
// Assertion: encoder.target.scalar is a scalar because "unknown" is JSON compatible.
return transposeExpressionToJson(ctx, encoder.target.scalar as Scalar, encoded, module);
}
case "Union":
if (!requiresJsonSerialization(ctx, module, 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 "EnumMember":
case "TemplateParameter":
case "Namespace":
case "Operation":
case "StringTemplate":
case "StringTemplateSpan":
case "Tuple":
case "UnionVariant":
case "Decorator":
case "FunctionParameter":
case "ScalarConstructor":
default:
throw new UnimplementedError(`transformJsonExprForType: ${type.kind}`);
}
}
function getScalarEncoder(ctx: SerializationContext, type: Scalar, scalar: JsScalar) {
const encoding = getEncode(ctx.program, type);
let encoder: Encoder;
if (encoding) {
const encodingName = encoding.encoding ?? "default";
const scalarEncoder = scalar.getEncoding(encodingName, encoding.type);
// TODO - we should detect this before realizing models and use a transform to represent
// the defective scalar as the encoding target type.
// See: https://github.com/microsoft/typespec/issues/6376
if (!scalarEncoder) {
reportDiagnostic(ctx.program, {
code: "unknown-encoding",
target: NoTarget,
format: {
encoding: encoding.encoding ?? "<default>",
type: getFullyQualifiedTypeName(type),
target: getFullyQualifiedTypeName(encoding.type),
},
});
encoder = {
target: JS_SCALAR_UNKNOWN,
encode: (expr) => expr,
decode: (expr) => expr,
};
} else {
encoder = scalarEncoder;
}
} else {
// No encoding specified, use the default content type encoding for json
encoder = scalar.getDefaultMimeEncoding("application/json") ?? {
target: JS_SCALAR_UNKNOWN,
encode: (expr) => expr,
decode: (expr) => expr,
};
}
return encoder;
}
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 =
resolveEncodedName(ctx.program, property, "application/json") ?? property.name;
let expr = access("input", encodedName);
const primitiveExpr = expr;
const encoding = getEncode(ctx.program, property);
if (property.type.kind === "Scalar" && encoding) {
const scalar = getJsScalar(ctx, module, property.type, property.type);
const scalarEncoder = scalar.getEncoding(encoding.encoding ?? "default", encoding.type);
if (scalarEncoder) {
expr = scalarEncoder.decode(
transposeExpressionFromJson(
ctx,
// Assertion: scalarEncoder.target.scalar is defined because we resolved an encoder.
scalarEncoder.target.scalar as Scalar,
expr,
module,
),
);
} else {
reportDiagnostic(ctx.program, {
code: "unknown-encoding",
target: NoTarget,
format: {
encoding: encoding.encoding ?? "<default>",
type: getFullyQualifiedTypeName(property.type),
target: getFullyQualifiedTypeName(encoding.type),
},
});
// We treat this as unknown from here on out. The encoding was not deciphered.
}
} else {
expr = transposeExpressionFromJson(ctx, property.type, expr, module);
}
const propertyName = keywordSafe(parseCase(property.name).camelCase);
if (property.optional && requiresJsonSerialization(ctx, module, property.type)) {
expr = `(${primitiveExpr}) !== undefined ? ${expr} : undefined`;
}
yield ` ${propertyName}: ${expr},`;
}
yield "};";
return;
}
case "Union": {
const codeTree = differentiateUnion(ctx, module, type);
yield* writeCodeTree(ctx, codeTree, {
subject: "input",
referenceModelProperty(p) {
const jsonName = resolveEncodedName(ctx.program, p, "application/json") ?? p.name;
return access("input", jsonName);
},
renderResult(type) {
return [`return ${transposeExpressionFromJson(ctx, type, "input", module)};`];
},
});
return;
}
case "Enum": {
yield `return input;`;
return;
}
default: {
void (type satisfies never);
throw new UnimplementedError(`emitFromJson: ${(type as Type).kind}`);
}
}
}
export 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, module, 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, module, argumentType)) {
return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [key, ${transposeExpressionFromJson(
ctx,
argumentType,
"value",
module,
)}]))`;
} else {
return expr;
}
} else if (!requiresJsonSerialization(ctx, module, 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, module, type, type);
const encoder = getScalarEncoder(ctx, type, scalar);
if (encoder.target.isJsonCompatible || !encoder.target.scalar) {
return encoder.decode(expr);
} else {
// Assertion: encoder.target.scalar is a scalar because "unknown" is JSON compatible.
return encoder.decode(
transposeExpressionFromJson(ctx, encoder.target.scalar as Scalar, expr, module),
);
}
case "Union":
if (!requiresJsonSerialization(ctx, module, 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 "Decorator":
case "FunctionParameter":
case "ScalarConstructor":
default:
throw new UnimplementedError(`transformJsonExprForType: ${type.kind}`);
}
}