@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
454 lines • 20 kB
JavaScript
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
import { getHeaderFieldName, getHttpOperation, isHeader, } from "@typespec/http";
import { createOrGetModuleForNamespace, emitNamespaceInterfaceReference, } from "../../common/namespace.js";
import { emitTypeReference } from "../../common/reference.js";
import { createModule } from "../../ctx.js";
import { parseCase } from "../../util/case.js";
import { bifilter, indent } from "../../util/iter.js";
import { keywordSafe } from "../../util/keywords.js";
import { module as headerHelpers } from "../../../generated-defs/helpers/header.js";
import { module as routerHelper } from "../../../generated-defs/helpers/router.js";
import { differentiateModelTypes, writeCodeTree } from "../../util/differentiate.js";
import { canonicalizeHttpOperation } from "../operation.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, service, serverRawModule) {
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, service, routeTree, module) {
const routerName = parseCase(service.namespace.name).pascalCase + "Router";
const uniqueContainers = new Set(service.operations.map((operation) => operation.container));
const backends = new Map();
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 ` ${keywordSafe(param.camelCase)}: ${param.pascalCase},`;
}
yield ` options: RouterOptions<{`;
for (const [param] of backends.values()) {
yield ` ${keywordSafe(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, routeHandlers, routeTree, backends, module) {
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, module));
}
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, routeHandlers, operations, backends, module) {
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 = keywordSafe(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
yield ` case ${JSON.stringify(verb.toUpperCase())}:`;
yield* indent(indent(emitRouteOperationDispatchMultiple(ctx, routeHandlers, operationList, backends, module)));
}
}
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, routeHandlers, operations, backends, module) {
const differentiated = differentiateModelTypes(ctx, module, new Set(operations.map((op) => op.operation.parameters)), {
renderPropertyName(prop) {
return getHeaderFieldName(ctx.program, prop);
},
filter(prop) {
return isHeader(ctx.program, prop);
},
else: {
kind: "verbatim",
body: [`return ctx.errorHandlers.onRequestNotFound(ctx);`],
},
});
yield* writeCodeTree(ctx, differentiated, {
referenceModelProperty(p) {
const headerName = getHeaderFieldName(ctx.program, p);
return `request.headers["${headerName}"]`;
},
*renderResult(type) {
const operation = operations.find((op) => op.operation.parameters === type);
const [backend] = backends.get(operation.container);
const operationName = keywordSafe(backend.snakeCase + "_" + parseCase(operation.operation.name).snakeCase);
const backendMemberName = keywordSafe(backend.camelCase);
const parameters = operation.parameters.length > 0
? ", " + operation.parameters.map((param) => parseCase(param.name).camelCase).join(", ")
: "";
yield `return ${routeHandlers}.${operationName}(ctx, ${backendMemberName}${parameters});`;
},
subject: "(request.headers)",
});
}
/**
* Create a route tree for a given service.
*/
function createRouteTree(ctx, service) {
// First get the Route for each operation in the service.
const routes = service.operations.map(function (operation) {
const canonicalOperation = canonicalizeHttpOperation(ctx, operation.operation);
const [httpOperation] = getHttpOperation(ctx.program, canonicalOperation);
const segments = getRouteSegments(ctx, httpOperation);
return {
operation: canonicalOperation,
container: operation.container,
verb: httpOperation.verb,
parameters: segments.filter((segment) => typeof segment !== "string"),
segments,
};
});
// 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) {
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();
// Group the routes by common prefix
outer: for (const literalRoute of literal) {
const segment = literalRoute.segments[0];
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;
if (prefix === edge) {
return { ...route, segments: rest };
}
else {
return {
...route,
segments: [prefix.substring(edge.length), ...rest],
};
}
})),
]);
let bind;
if (parameterized.length > 0) {
const parameters = new Set();
const nextRoutes = [];
for (const parameterizedRoute of parameterized) {
const [{ name }, ...rest] = parameterizedRoute.segments;
parameters.add(name);
nextRoutes.push({ ...parameterizedRoute, segments: rest });
}
bind = [parameters, intoRouteTree(nextRoutes)];
}
const operationMap = new Map();
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, b) {
let i = 0;
while (i < a.length && i < b.length && a[i] === b[i]) {
i++;
}
return a.substring(0, i);
}
}
function getRouteSegments(ctx, operation) {
// 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 = [];
const parameterTypeMap = new Map([...operation.parameters.parameters.values()].map((p) => [
p.param.name,
p.param.type.kind === "ModelProperty" ? p.param.type.type : p.param.type,
]));
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,
});
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;
}
//# sourceMappingURL=router.js.map