@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
766 lines (663 loc) • 27.1 kB
text/typescript
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
import {
ModelProperty,
NoTarget,
Type,
compilerAssert,
isArrayModelType,
isRecordModelType,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import {
HttpOperation,
HttpOperationParameter,
getHeaderFieldName,
getHttpOperation,
isBody,
isHeader,
isStatusCode,
} from "@typespec/http";
import { createOrGetModuleForNamespace } from "../../common/namespace.js";
import { emitTypeReference, isValueLiteralType } from "../../common/reference.js";
import {
SerializableType,
isSerializationRequired,
requireSerialization,
} from "../../common/serialization/index.js";
import { Module, completePendingDeclarations, createModule } from "../../ctx.js";
import { ReCase, 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 { getJsScalar } from "../../common/scalar.js";
import {
requiresJsonSerialization,
transposeExpressionFromJson,
transposeExpressionToJson,
} from "../../common/serialization/json.js";
import { getFullyQualifiedTypeName } from "../../util/name.js";
import { canonicalizeHttpOperation } from "../operation.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> {
let op = operation.operation;
const operationNameCase = parseCase(op.name);
const container = op.interface ?? op.namespace!;
const containerNameCase = parseCase(container.name);
op = canonicalizeHttpOperation(ctx, op);
[operation] = getHttpOperation(ctx.program, op);
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 Map<ModelProperty, HttpOperationParameter>();
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.set(resolvedParameter, parameter);
break;
case "path":
// Already handled above.
parsedParams.set(resolvedParameter, parameter);
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,
module,
{
altName: defaultBodyTypeName,
requireDeclaration: requiresJsonSerialization(ctx, module, body.type),
},
);
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, module, body.type)) {
if (body.type.kind === "Model" && isArrayModelType(ctx.program, body.type)) {
yield ` const __arrayBody = JSON.parse(body);`;
yield ` if (!Array.isArray(__arrayBody)) {`;
yield ` ${names.ctx}.errorHandlers.onInvalidRequest(`;
yield ` ${names.ctx},`;
yield ` ${JSON.stringify(operation.path)},`;
yield ` "expected JSON array in request body",`;
yield ` );`;
yield ` return reject();`;
yield ` }`;
value = transposeExpressionFromJson(ctx, body.type, `__arrayBody`, module);
} else if (body.type.kind === "Model" && isRecordModelType(ctx.program, body.type)) {
yield ` const __recordBody = JSON.parse(body);`;
yield ` if (typeof __recordBody !== "object" || __recordBody === null) {`;
yield ` ${names.ctx}.errorHandlers.onInvalidRequest(`;
yield ` ${names.ctx},`;
yield ` ${JSON.stringify(operation.path)},`;
yield ` "expected JSON object in request body",`;
yield ` );`;
yield ` return reject();`;
yield ` }`;
value = transposeExpressionFromJson(ctx, body.type, `__recordBody`, module);
} else if (body.type.kind === "Scalar") {
value = transposeExpressionFromJson(ctx, body.type, `JSON.parse(body)`, module);
} else {
value = `${bodyTypeName}.fromJsonObject(globalThis.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;
}
case "text/plain": {
const string = ctx.program.checker.getStdType("string");
const assignable = $(ctx.program).type.isAssignableTo(
body.type,
string,
body.property ?? body.type,
);
if (!assignable) {
const name =
("namespace" in body.type &&
body.type.namespace &&
getFullyQualifiedTypeName(body.type)) ||
("name" in body.type && typeof body.type.name === "string" && body.type.name) ||
"<unknown>";
reportDiagnostic(ctx.program, {
code: "unrecognized-media-type",
target: body.property ?? body.type,
format: {
mediaType: contentType,
type: name,
},
});
}
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();`;
yield ` resolve(body);`;
yield ` } catch (e) {`;
yield ` ${names.ctx}.errorHandlers.onInvalidRequest(`;
yield ` ${names.ctx},`;
yield ` ${JSON.stringify(operation.path)},`;
yield ` "invalid text in request body",`;
yield ` );`;
yield ` reject(e);`;
yield ` }`;
yield ` });`;
yield ` ${names.ctx}.request.on("error", reject);`;
yield ` }) as string;`;
yield "";
break;
}
case "application/octet-stream":
default:
{
if (!ctx.program.checker.isStdType(body.type, "bytes")) {
const name =
("namespace" in body.type &&
body.type.namespace &&
getFullyQualifiedTypeName(body.type)) ||
("name" in body.type && typeof body.type.name === "string" && body.type.name) ||
"<unknown>";
reportDiagnostic(ctx.program, {
code: "unrecognized-media-type",
target: body.property ?? body.type,
format: {
mediaType: contentType,
type: name,
},
});
}
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);`;
yield ` resolve(body);`;
yield ` } catch (e) {`;
yield ` reject(e);`;
yield ` }`;
yield ` });`;
yield ` ${names.ctx}.request.on("error", reject);`;
yield ` }) as Buffer;`;
yield "";
break;
}
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 paramNameSafe = keywordSafe(paramNameCase.camelCase);
const isBodyField = bodyFields.has(param.name) && bodyFields.get(param.name) === param.type;
const isBodyExact = operation.parameters.body?.property === param;
const isPathParameter = operation.parameters.parameters.some(
(p) => p.type === "path" && p.param === param,
);
if (isPathParameter) {
paramBaseExpression = `${paramNameSafe}`;
} else if (isBodyField) {
paramBaseExpression = `${bodyName}.${paramNameCase.camelCase}`;
} else if (isBodyExact) {
paramBaseExpression = bodyName!;
} else {
const resolvedParameter = param.type.kind === "ModelProperty" ? param.type : param;
const httpOperationParam = parsedParams.get(resolvedParameter);
if (resolvedParameter.type.kind === "Scalar" && httpOperationParam) {
const jsScalar = getJsScalar(ctx, module, resolvedParameter.type, resolvedParameter);
const encoder = jsScalar.http[httpOperationParam.type];
paramBaseExpression = encoder.decode(paramNameSafe);
} else {
paramBaseExpression = paramNameSafe;
}
}
if (param.optional) {
hasOptions = true;
optionalParams.set(paramNameCase.camelCase, paramBaseExpression);
} else {
requiredParams.push(paramBaseExpression);
}
}
const paramLines = requiredParams.map((p) => `${keywordSafe(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, createNamer(operationNameCase), names, op.returnType, module),
);
yield "}";
yield "";
}
interface Names {
ctx: string;
result: string;
operations: string;
queryParams: string;
isHttpResponder: string;
httpResponderSym: string;
}
interface Namer {
opName: ReCase;
names: Record<string, number>;
getAltName(name: string): string;
}
function createNamer(opName: ReCase): Namer {
const names: Record<string, number> = {};
return {
opName,
names,
getAltName(name: string): string {
names[name] ??= 1;
const idx = names[name]++;
return this.opName.pascalCase + (idx === 1 ? name : `${name}_${idx}`);
},
};
}
/**
* 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,
namer: Namer,
names: Names,
t: Type,
module: Module,
): Iterable<string> {
if (t.kind !== "Union") {
// Single target type
yield* emitResultProcessingForType(ctx, namer, names, t, module);
} else {
const codeTree = differentiateUnion(ctx, module, 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, namer, 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,
namer: Namer,
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(globalThis.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,
module,
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, {
altName: namer.getAltName("Body"),
requireDeclaration: true,
});
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${typeReference}.toJsonObject(${names.result}.${bodyCase.camelCase})))`;
} else {
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${names.result}.${bodyCase.camelCase}));`;
}
} else if (isArrayModelType(ctx.program, target)) {
const itemType = target.indexer.value;
const serializationRequired = isSerializationRequired(
ctx,
module,
itemType,
"application/json",
);
requireSerialization(ctx, itemType, "application/json");
yield `${names.ctx}.response.setHeader("content-type", "application/json");`;
if (serializationRequired) {
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${transposeExpressionToJson(ctx, target, names.result, module)}));`;
} else {
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${names.result}));`;
}
} else if (isRecordModelType(ctx.program, target)) {
const itemType = target.indexer.value;
const serializationRequired = isSerializationRequired(
ctx,
module,
itemType,
"application/json",
);
requireSerialization(ctx, itemType, "application/json");
yield `${names.ctx}.response.setHeader("content-type", "application/json");`;
if (serializationRequired) {
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${transposeExpressionToJson(ctx, target, names.result, module)}));`;
} else {
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${names.result}));`;
}
} else {
if (allMetadataIsRemoved) {
yield `${names.ctx}.response.end();`;
} else {
const serializationRequired = isSerializationRequired(
ctx,
module,
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, {
altName: namer.getAltName("Result"),
requireDeclaration: true,
});
yield `${names.ctx}.response.end(globalThis.JSON.stringify(${typeReference}.toJsonObject(${names.result} as ${typeReference})));`;
} else {
yield `${names.ctx}.response.end(globalThis.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 name = keywordSafe(nameCase.camelCase);
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 ${name} = ${names.ctx}.request.headers[${JSON.stringify(headerName)}]${assertion};`;
if (!parameter.param.optional) {
yield `if (${name} === 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);
const name = keywordSafe(nameCase.camelCase);
// UrlSearchParams annoyingly returns null for missing parameters instead of undefined.
yield `const ${name} = ${names.queryParams}.get(${JSON.stringify(parameter.name)}) ?? undefined;`;
if (!parameter.param.optional) {
yield `if (!${name}) {`;
yield ` return ${names.ctx}.errorHandlers.onInvalidRequest(${names.ctx}, ${JSON.stringify(operation.path)}, "missing required query parameter '${parameter.name}'");`;
yield "}";
yield "";
}
}