opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
90 lines • 4.18 kB
JavaScript
import { merge } from 'ts-deepmerge';
import { readGatewayMetadata } from "../withGatewayMetadata.js";
import { gatewayManifestSchema, } from "./manifestSchema.js";
import { normalizePath } from "./pathNormalize.js";
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
function normalizeMethod(method) {
if (Array.isArray(method)) {
throw new Error(`Gateway manifest does not support multi-method routes (got [${method.join(', ')}]). Declare one route per method.`);
}
const upper = String(method).toUpperCase();
if (!HTTP_METHODS.includes(upper)) {
throw new Error(`Unsupported HTTP method "${method}" in gateway manifest`);
}
return upper;
}
function mergeMetadata(layers) {
const present = layers.filter((m) => m !== undefined);
if (present.length === 0)
return {};
if (present.length === 1)
return present[0];
// ts-deepmerge replaces arrays in later layers (documented merge semantics).
// biome-ignore lint/suspicious/noExplicitAny: ts-deepmerge generic doesn't cleanly express this
return merge.withOptions({ mergeArrays: false }, ...present);
}
function collectRouteEntries(collected) {
if (collected.kind === 'rest') {
const built = collected.controller.buildRoutes();
return Object.entries(built).map(([routeKey, route]) => ({ routeKey, route }));
}
// AbstractApiController: routes is a Record — key becomes the routeKey.
return Object.entries(collected.controller.routes).map(([routeKey, route]) => ({
routeKey,
route,
}));
}
/**
* Pure manifest builder. Takes already-resolved controllers; performs no DI.
*
* Used by `DIContext.buildGatewayManifest()` after it resolves controllers from
* the container. Exposed separately for unit testing without spinning up a DI
* context.
*/
export function buildGatewayManifestFrom(controllers, options) {
const routes = [];
// Track route ids back to their origin so we can produce a useful error
// message if two declarations end up with the same explicit metadata.id.
const idOrigin = new Map();
for (const collected of controllers) {
const controllerDefaults = collected.controller.gatewayDefaults;
for (const { routeKey, route } of collectRouteEntries(collected)) {
const routeMetadata = readGatewayMetadata(route);
const merged = mergeMetadata([options.defaults, controllerDefaults, routeMetadata]);
if (route.url === undefined) {
throw new Error(`Route "${collected.name}.${routeKey}" is missing a URL — gateway manifest cannot be generated.`);
}
const path = normalizePath(route.url);
const method = normalizeMethod(route.method);
const origin = `${collected.name}.${routeKey}`;
const id = merged.id ?? origin;
const previousOrigin = idOrigin.get(id);
if (previousOrigin) {
throw new Error(`Duplicate gateway route id "${id}": declared by both ${previousOrigin} and ${origin}. Set a distinct metadata.id on one of them.`);
}
idOrigin.set(id, origin);
routes.push({
id,
method,
path,
controller: collected.name,
routeKey,
metadata: merged,
});
}
}
// Sort for stable output across runs (gateways like deterministic configs).
routes.sort((a, b) => a.path === b.path ? a.method.localeCompare(b.method) : a.path.localeCompare(b.path));
const manifest = {
manifestVersion: '1',
service: options.service,
...(options.version !== undefined ? { version: options.version } : {}),
generatedAt: new Date().toISOString(),
routes,
};
// Validate the per-route merged metadata too. The route-level types narrow
// header/query keys, but service- and controller-level defaults are
// contract-unbound, so a runtime check at the boundary protects generators.
return gatewayManifestSchema.parse(manifest);
}
//# sourceMappingURL=buildManifest.js.map