687 lines
22 KiB
TypeScript
687 lines
22 KiB
TypeScript
// Copyright (c) Microsoft Corporation
|
|
// Licensed under the MIT license.
|
|
|
|
import { Operation, Type } from "@typespec/compiler";
|
|
import {
|
|
HttpOperation,
|
|
HttpService,
|
|
HttpVerb,
|
|
OperationContainer,
|
|
getHttpOperation,
|
|
} from "@typespec/http";
|
|
import {
|
|
createOrGetModuleForNamespace,
|
|
emitNamespaceInterfaceReference,
|
|
} from "../../common/namespace.js";
|
|
import { emitTypeReference } from "../../common/reference.js";
|
|
import { Module, createModule } from "../../ctx.js";
|
|
import { ReCase, parseCase } from "../../util/case.js";
|
|
import { bifilter, indent } from "../../util/iter.js";
|
|
import { keywordSafe } from "../../util/keywords.js";
|
|
import { HttpContext } from "../index.js";
|
|
|
|
import { module as headerHelpers } from "../../../generated-defs/helpers/header.js";
|
|
import { module as routerHelper } from "../../../generated-defs/helpers/router.js";
|
|
import { parseHeaderValueParameters } from "../../helpers/header.js";
|
|
import { reportDiagnostic } from "../../lib.js";
|
|
import { UnimplementedError } from "../../util/error.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: HttpContext, service: HttpService, serverRawModule: Module) {
|
|
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: HttpContext,
|
|
service: HttpService,
|
|
routeTree: RouteTree,
|
|
module: Module,
|
|
): Iterable<string> {
|
|
const routerName = parseCase(service.namespace.name).pascalCase + "Router";
|
|
|
|
const uniqueContainers = new Set(service.operations.map((operation) => operation.container));
|
|
|
|
const backends = new Map<OperationContainer, [ReCase, string]>();
|
|
|
|
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 ` ${param.camelCase}: ${param.pascalCase},`;
|
|
}
|
|
|
|
yield ` options: RouterOptions<{`;
|
|
for (const [param] of backends.values()) {
|
|
yield ` ${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: HttpContext,
|
|
routeHandlers: string,
|
|
routeTree: RouteTree,
|
|
backends: Map<OperationContainer, [ReCase, string]>,
|
|
module: Module,
|
|
): Iterable<string> {
|
|
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));
|
|
} 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: HttpContext,
|
|
routeHandlers: string,
|
|
operations: Map<HttpVerb, RouteOperation[]>,
|
|
backends: Map<OperationContainer, [ReCase, string]>,
|
|
): Iterable<string> {
|
|
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 = 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
|
|
const route = getHttpOperation(ctx.program, operationList[0].operation)[0].path;
|
|
yield ` case ${JSON.stringify(verb.toUpperCase())}:`;
|
|
yield* indent(
|
|
indent(
|
|
emitRouteOperationDispatchMultiple(ctx, routeHandlers, operationList, route, backends),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: HttpContext,
|
|
routeHandlers: string,
|
|
operations: RouteOperation[],
|
|
route: string,
|
|
backends: Map<OperationContainer, [ReCase, string]>,
|
|
): Iterable<string> {
|
|
const usedContentTypes = new Set<string>();
|
|
const contentTypeMap = new Map<RouteOperation, string>();
|
|
|
|
for (const operation of operations) {
|
|
const [httpOperation] = getHttpOperation(ctx.program, operation.operation);
|
|
const operationContentType = httpOperation.parameters.parameters.find(
|
|
(param) => param.type === "header" && param.name.toLowerCase() === "content-type",
|
|
)?.param.type;
|
|
|
|
if (!operationContentType || operationContentType.kind !== "String") {
|
|
throw new UnimplementedError(
|
|
"Only string content-types are supported for route differentiation.",
|
|
);
|
|
}
|
|
|
|
if (usedContentTypes.has(operationContentType.value)) {
|
|
reportDiagnostic(ctx.program, {
|
|
code: "undifferentiable-route",
|
|
target: httpOperation.operation,
|
|
});
|
|
}
|
|
|
|
usedContentTypes.add(operationContentType.value);
|
|
|
|
contentTypeMap.set(operation, operationContentType.value);
|
|
}
|
|
|
|
const contentTypeName = ctx.gensym("contentType");
|
|
|
|
yield `const ${contentTypeName} = parseHeaderValueParameters(request.headers["content-type"])?.value;`;
|
|
|
|
yield `switch (${contentTypeName}) {`;
|
|
|
|
for (const [operation, contentType] of contentTypeMap.entries()) {
|
|
const [backend] = backends.get(operation.container)!;
|
|
const operationName = keywordSafe(
|
|
backend.snakeCase + "_" + parseCase(operation.operation.name).snakeCase,
|
|
);
|
|
|
|
const backendMemberName = backend.camelCase;
|
|
|
|
const parameters =
|
|
operation.parameters.length > 0
|
|
? ", " + operation.parameters.map((param) => parseCase(param.name).camelCase).join(", ")
|
|
: "";
|
|
|
|
const contentTypeValue = parseHeaderValueParameters(contentType).value;
|
|
|
|
yield ` case ${JSON.stringify(contentTypeValue)}:`;
|
|
yield ` return ${routeHandlers}.${operationName}(ctx, ${backendMemberName}${parameters});`;
|
|
}
|
|
|
|
yield ` default:`;
|
|
yield ` return ctx.errorHandlers.onInvalidRequest(ctx, ${JSON.stringify(route)}, \`No operation in route '${route}' matched content-type "\${${contentTypeName}}"\`);`;
|
|
yield "}";
|
|
}
|
|
|
|
/**
|
|
* A tree of routes in an HTTP router domain.
|
|
*/
|
|
interface RouteTree {
|
|
/**
|
|
* A list of operations that can be dispatched at this node.
|
|
*/
|
|
operations: Map<HttpVerb, RouteOperation[]>;
|
|
/**
|
|
* A set of parameters that are bound in this position before proceeding along the subsequent tree.
|
|
*/
|
|
bind?: [Set<string>, RouteTree];
|
|
/**
|
|
* A list of static edges that can be taken from this node.
|
|
*/
|
|
edges: RouteTreeEdge[];
|
|
}
|
|
|
|
/**
|
|
* An edge in the route tree. The edge contains a literal string prefix that must match before the next node is visited.
|
|
*/
|
|
type RouteTreeEdge = readonly [string, RouteTree];
|
|
|
|
/**
|
|
* An operation that may be dispatched at a given tree node.
|
|
*/
|
|
interface RouteOperation {
|
|
/**
|
|
* The HTTP operation corresponding to this route operation.
|
|
*/
|
|
operation: Operation;
|
|
/**
|
|
* The operation's container.
|
|
*/
|
|
container: OperationContainer;
|
|
/**
|
|
* The path parameters that the route template for this operation binds.
|
|
*/
|
|
parameters: RouteParameter[];
|
|
/**
|
|
* The HTTP verb (GET, PUT, etc.) that this operation requires.
|
|
*/
|
|
verb: HttpVerb;
|
|
}
|
|
|
|
/**
|
|
* A single route split into segments of strings and parameters.
|
|
*/
|
|
interface Route extends RouteOperation {
|
|
segments: RouteSegment[];
|
|
}
|
|
|
|
/**
|
|
* A segment of a single route.
|
|
*/
|
|
type RouteSegment = string | RouteParameter;
|
|
|
|
/**
|
|
* A parameter in the route segment with its expected type.
|
|
*/
|
|
interface RouteParameter {
|
|
name: string;
|
|
type: Type;
|
|
}
|
|
|
|
/**
|
|
* Create a route tree for a given service.
|
|
*/
|
|
function createRouteTree(ctx: HttpContext, service: HttpService): RouteTree {
|
|
// First get the Route for each operation in the service.
|
|
const routes = service.operations.map(function (operation) {
|
|
const segments = getRouteSegments(ctx, operation);
|
|
return {
|
|
operation: operation.operation,
|
|
container: operation.container,
|
|
verb: operation.verb,
|
|
parameters: segments.filter((segment) => typeof segment !== "string"),
|
|
segments,
|
|
} as Route;
|
|
});
|
|
|
|
// 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: Route[]): RouteTree {
|
|
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<string, Route[]>();
|
|
|
|
// Group the routes by common prefix
|
|
|
|
outer: for (const literalRoute of literal) {
|
|
const segment = literalRoute.segments[0] as string;
|
|
|
|
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 as [string, ...RouteSegment[]];
|
|
|
|
if (prefix === edge) {
|
|
return { ...route, segments: rest };
|
|
} else {
|
|
return {
|
|
...route,
|
|
segments: [prefix.substring(edge.length), ...rest],
|
|
};
|
|
}
|
|
}),
|
|
),
|
|
] as const,
|
|
);
|
|
|
|
let bind: [Set<string>, RouteTree] | undefined;
|
|
|
|
if (parameterized.length > 0) {
|
|
const parameters = new Set<string>();
|
|
const nextRoutes: Route[] = [];
|
|
for (const parameterizedRoute of parameterized) {
|
|
const [{ name }, ...rest] = parameterizedRoute.segments as [
|
|
RouteParameter,
|
|
...RouteSegment[],
|
|
];
|
|
|
|
parameters.add(name);
|
|
nextRoutes.push({ ...parameterizedRoute, segments: rest });
|
|
}
|
|
|
|
bind = [parameters, intoRouteTree(nextRoutes)];
|
|
}
|
|
|
|
const operationMap = new Map<HttpVerb, RouteOperation[]>();
|
|
|
|
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: string, b: string): string {
|
|
let i = 0;
|
|
while (i < a.length && i < b.length && a[i] === b[i]) {
|
|
i++;
|
|
}
|
|
return a.substring(0, i);
|
|
}
|
|
}
|
|
|
|
function getRouteSegments(ctx: HttpContext, operation: HttpOperation): RouteSegment[] {
|
|
// 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: RouteSegment[] = [];
|
|
|
|
const parameterTypeMap = new Map<string, Type>(
|
|
[...operation.parameters.parameters.values()].map(
|
|
(p) =>
|
|
[
|
|
p.param.name,
|
|
p.param.type.kind === "ModelProperty" ? p.param.type.type : p.param.type,
|
|
] as const,
|
|
),
|
|
);
|
|
|
|
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 as any,
|
|
});
|
|
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;
|
|
}
|