UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

142 lines 5.71 kB
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