@beignet/core
Version:
Core framework primitives for Beignet
142 lines • 5.71 kB
JavaScript
import { inferSoleSuccessStatus } from "../contracts/index.js";
/**
* Trusted run key shared with `@beignet/core/application` via the global
* symbol registry, so the binder never imports the application builder at
* runtime.
*/
const USE_CASE_TRUSTED_RUN_KEY = Symbol.for("beignet.useCase.trustedRun");
const USE_CASE_OUTPUT_VALIDATED_KEY = Symbol.for("beignet.useCase.outputValidated");
function isUseCaseInputValidationFailure(error, useCaseName) {
if (!(error instanceof Error))
return false;
const candidate = error;
return (candidate.name === "UseCaseValidationError" &&
candidate.phase === "input" &&
candidate.useCaseName === useCaseName);
}
/**
* Internal framework error raised when a type-erased binder route produces an
* input that the bound use case rejects.
*/
export class UseCaseRouteInputValidationError extends Error {
code = "USE_CASE_INPUT_VALIDATION_ERROR";
contractName;
useCaseName;
constructor(args) {
super(`Default binder input for contract "${args.contractName}" does not satisfy use case "${args.useCaseName}". Add an explicit input mapper.`, { cause: args.cause });
this.name = "UseCaseRouteInputValidationError";
this.contractName = args.contractName;
this.useCaseName = args.useCaseName;
}
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Default input mapping for binder routes.
*
* Merges parsed query, body, and path objects into one input object. Path
* keys win all collisions, then body keys, then query keys. Headers are never
* merged: parsed headers include every raw request header, so merging them
* would poison the use case input. The route binder passes a sole declared
* input schema through unchanged when no other object input contains values;
* this merge handles every other default mapping. Routes that combine a
* non-object body with another source declare an explicit `input` mapper.
*/
export function defaultBinderInput(parts) {
return {
...(isPlainObject(parts.query) ? parts.query : {}),
...(isPlainObject(parts.body) ? parts.body : {}),
...(isPlainObject(parts.path) ? parts.path : {}),
};
}
/**
* Whether a route definition is a binder route.
*/
export function isUseCaseRouteDef(route) {
return route.useCase !== undefined && route.useCase !== null;
}
function computeSingleInput(contract, def) {
if (def.input)
return undefined;
const sources = [
{ source: "path", schema: contract.pathParams },
{
source: "query",
schema: contract.query,
},
{
source: "body",
schema: contract.body,
},
];
const present = sources.filter((candidate) => candidate.schema !== null && candidate.schema !== undefined);
const single = present[0];
return present.length === 1 && single
? { source: single.source, schema: single.schema }
: undefined;
}
function canPassSingleInput(parts, source) {
return ["path", "query", "body"].every((candidate) => candidate === source ||
!isPlainObject(parts[candidate]) ||
Object.keys(parts[candidate]).length === 0);
}
function computeResponseExemption(contract, def, status) {
return def.useCase[USE_CASE_OUTPUT_VALIDATED_KEY] === true &&
contract.responses[status] === def.useCase.outputSchema
? status
: undefined;
}
/**
* Synthesize the route handler for a binder route at registration time.
*
* Resolves the success status, decides whether the validated request parts can
* skip the use case's input parse, and computes whether server-side response
* validation is redundant for the success status.
*/
export function createUseCaseRouteHandler(contract, def) {
const status = def.status ?? inferSoleSuccessStatus(contract);
if (status === undefined) {
throw new Error(`Route binder for contract "${contract.name}" cannot infer a success ` +
`status: the contract declares ${Object.keys(contract.responses).length === 0
? "no responses"
: "zero or multiple 2xx responses"}. Declare exactly one 2xx response or pass an explicit status.`);
}
const singleInput = computeSingleInput(contract, def);
const trustedRun = singleInput?.schema === def.useCase.inputSchema
? def.useCase[USE_CASE_TRUSTED_RUN_KEY]
: undefined;
const handler = async ({ ctx, path, query, headers, body, }) => {
const parts = { path, query, headers, body };
const passSingle = singleInput !== undefined &&
canPassSingleInput(parts, singleInput.source);
const input = def.input
? def.input(parts)
: passSingle
? parts[singleInput.source]
: defaultBinderInput(parts);
const run = passSingle && trustedRun ? trustedRun : def.useCase.run;
try {
return {
status,
body: await run.call(def.useCase, { ctx, input }),
};
}
catch (error) {
if (!def.input &&
isUseCaseInputValidationFailure(error, def.useCase.name)) {
throw new UseCaseRouteInputValidationError({
contractName: contract.name,
useCaseName: def.useCase.name,
cause: error,
});
}
throw error;
}
};
return {
handler,
responseValidationExemptStatus: computeResponseExemption(contract, def, status),
};
}
//# sourceMappingURL=use-case-route.js.map