UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

683 lines (624 loc) 19.8 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { HttpContractConfig, InferOutput, Success2xxKeys, } from "../contracts/index.js"; import { inferSoleSuccessStatus } from "../contracts/index.js"; import type { ContractLike, ResolveContract } from "./contract-like.js"; import type { AddedCtxFromHooks, Handler, InferBody, InferHeaders, InferPath, InferQuery, RouteHook, } from "./http.js"; /** * Structural shape of a finalized use case accepted by the route binder. * * This intentionally mirrors `UseCaseDef` from `@beignet/core/application` * without importing it, so the server runtime stays decoupled from the * application builder at runtime. */ export type AnyUseCaseLike = { /** * Stable use-case name, used in binder diagnostics. */ name: string; /** * Input schema declared with `.input(...)`. */ inputSchema: StandardSchemaV1; /** * Output schema declared with `.output(...)`. */ outputSchema: StandardSchemaV1; /** * Execute the use case with application context and typed input. */ run: (args: never) => Promise<unknown>; }; type UseCaseRouteCtx<UC> = UC extends { run: (args: { ctx: infer Ctx; input: infer _Input }) => Promise<infer _Out>; } ? Ctx : never; /** * Input type accepted by a bound use case's `run(...)`. */ export type UseCaseRouteInput<UC> = UC extends { run: (args: { ctx: infer _Ctx; input: infer Input }) => Promise<infer _Out>; } ? Input : never; type UseCaseRouteOutput<UC> = UC extends { run: (args: { ctx: infer _Ctx; input: infer _Input }) => Promise<infer Out>; } ? Out : never; type ResponseBodyForSchema<S> = S extends null ? // biome-ignore lint/suspicious/noConfusingVoidType: void accepts z.void() use case outputs for null response schemas void | undefined : S extends StandardSchemaV1 ? InferOutput<S> : unknown; type ResponseForStatus< TResponses, TStatus extends number, > = TStatus extends keyof TResponses ? TResponses[TStatus] : `${TStatus}` extends keyof TResponses ? TResponses[`${TStatus}`] : never; type SuccessBodyFromKeys<TResponses, K> = [K] extends [never] ? unknown : K extends number ? ResponseBodyForSchema<ResponseForStatus<TResponses, K>> : unknown; type UnionToIntersection<T> = ( T extends unknown ? (value: T) => void : never ) extends (value: infer I) => void ? I : never; type BinderStatusFromKeys<K> = [K] extends [never] ? { /** * Success status for the use case result. Required because the contract * does not declare exactly one 2xx response. */ status: number; } : [K] extends [UnionToIntersection<K>] ? { /** * Success status for the use case result. Optional because the * contract declares exactly one 2xx response. */ status?: K; } : { /** * Success status for the use case result. Required because the * contract declares multiple 2xx responses. */ status: K; }; /** * `status` option for a binder route. * * Optional and typed to the sole declared 2xx status when the contract * declares exactly one, required (typed to the union of declared 2xx * statuses) otherwise. */ export type BinderStatusOption<C extends HttpContractConfig> = BinderStatusFromKeys<Success2xxKeys<C["responses"]>>; /** * Parsed request parts passed to a binder route's `input` mapper. */ export type UseCaseRouteInputParts<C extends HttpContractConfig> = { /** * Parsed path parameters. */ path: InferPath<C>; /** * Parsed query parameters. */ query: InferQuery<C>; /** * Parsed request headers. */ headers: InferHeaders<C>; /** * Parsed request body. */ body: InferBody<C>; }; type SegmentPathParam<Segment extends string> = Segment extends `:${infer Name}` ? Name : Segment extends `[${infer Name}]` ? Name : never; type PathParamNames<Path extends string> = string extends Path ? never : Path extends `${infer Segment}/${infer Rest}` ? SegmentPathParam<Segment> | PathParamNames<Rest> : SegmentPathParam<Path>; type EmptyBinderInput = Record<never, never>; declare const UNMERGEABLE_BINDER_INPUT: unique symbol; type UnmergeableBinderInput = { readonly [UNMERGEABLE_BINDER_INPUT]: true; }; type IsAny<T> = 0 extends 1 & T ? true : false; type BinderObject<T> = IsAny<T> extends true ? UnmergeableBinderInput : [T] extends [object] ? [Extract<T, readonly unknown[]>] extends [never] ? T : UnmergeableBinderInput : UnmergeableBinderInput; type MergeBinderObjects<LowerPrecedence, HigherPrecedence> = [ BinderObject<LowerPrecedence>, ] extends [UnmergeableBinderInput] ? UnmergeableBinderInput : [BinderObject<HigherPrecedence>] extends [UnmergeableBinderInput] ? UnmergeableBinderInput : Omit< BinderObject<LowerPrecedence>, keyof BinderObject<HigherPrecedence> > & BinderObject<HigherPrecedence>; type InferredPathInput<C extends HttpContractConfig> = string extends C["path"] ? UnmergeableBinderInput : [PathParamNames<C["path"]>] extends [never] ? EmptyBinderInput : { [K in PathParamNames<C["path"]>]: string }; type BinderPathInput<C extends HttpContractConfig> = C["pathParams"] extends StandardSchemaV1 ? InferOutput<C["pathParams"]> : InferredPathInput<C>; type BinderQueryInput<C extends HttpContractConfig> = C["query"] extends StandardSchemaV1 ? InferOutput<C["query"]> : EmptyBinderInput; type BinderBodyInput<C extends HttpContractConfig> = C["body"] extends StandardSchemaV1 ? InferOutput<C["body"]> : EmptyBinderInput; type HasPathSchema<C extends HttpContractConfig> = C["pathParams"] extends StandardSchemaV1 ? true : false; type HasQuerySchema<C extends HttpContractConfig> = C["query"] extends StandardSchemaV1 ? true : false; type HasBodySchema<C extends HttpContractConfig> = C["body"] extends StandardSchemaV1 ? true : false; type HasInferredPathInput<C extends HttpContractConfig> = string extends C["path"] ? true : [PathParamNames<C["path"]>] extends [never] ? false : true; type MergedBinderInput<C extends HttpContractConfig> = MergeBinderObjects< MergeBinderObjects<BinderQueryInput<C>, BinderBodyInput<C>>, BinderPathInput<C> >; /** * Input produced when a binder route omits an explicit `input` mapper. * * A sole declared request schema passes through unchanged when the literal * path has no additional inferred parameters. Every other supported default * binding merges object inputs with path over body over query precedence. */ type DefaultBinderRouteInput<C extends HttpContractConfig> = HasPathSchema<C> extends true ? HasQuerySchema<C> extends true ? MergedBinderInput<C> : HasBodySchema<C> extends true ? MergedBinderInput<C> : BinderPathInput<C> : HasQuerySchema<C> extends true ? HasBodySchema<C> extends true ? MergedBinderInput<C> : HasInferredPathInput<C> extends true ? MergedBinderInput<C> : BinderQueryInput<C> : HasBodySchema<C> extends true ? HasInferredPathInput<C> extends true ? MergedBinderInput<C> : BinderBodyInput<C> : BinderPathInput<C>; type UseCaseRouteInputMode = "default" | "mapped"; /** * Constraint that checks a use case against the route that binds it. * * Produces a readable branded mismatch object on the `useCase` property when * the use case requires a context the server does not provide, when its * output does not match the contract's declared success response schema, or * when the default binder input does not satisfy the use case input. */ export type UseCaseFitsRoute< Ctx, C extends HttpContractConfig, UC, InputMode extends UseCaseRouteInputMode = "default", > = [Ctx] extends [UseCaseRouteCtx<UC>] ? [UseCaseRouteOutput<UC>] extends [ SuccessBodyFromKeys<C["responses"], Success2xxKeys<C["responses"]>>, ] ? InputMode extends "mapped" ? unknown : [DefaultBinderRouteInput<C>] extends [UseCaseRouteInput<UC>] ? unknown : { "~beignetError": "default binder input does not match the use case input; add an input mapper"; } : { "~beignetError": "useCase output does not match the contract's success response schema"; } : { "~beignetError": "useCase requires a context this server does not provide"; }; type UseCaseRouteShape< HandlerCtx, CLike extends ContractLike, C extends HttpContractConfig, UC extends AnyUseCaseLike, Hooks, > = { /** * Contract builder or plain contract config for this route. */ contract: CLike; /** * Route-scoped hooks that run after group hooks and before the use case. */ hooks?: Hooks; handle?: never; } & ( | { /** * Use case bound directly to the contract. The default binder input * must satisfy the use case input type. */ useCase: UC & UseCaseFitsRoute<HandlerCtx, C, UC>; input?: never; } | { /** * Use case bound directly to the contract through an explicit input * mapper. */ useCase: UC & UseCaseFitsRoute<HandlerCtx, C, UC, "mapped">; /** * Map parsed request parts to the use case input. * * A sole declared path, query, or body schema is passed through * unchanged when no additional path, query, or object body values are * present. Otherwise `defaultBinderInput` merges query, body, and path * objects (path wins collisions) and never merges headers. */ input: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>; } ) & BinderStatusOption<C>; /** * Route registration that binds a contract directly to a use case. * * The server synthesizes the handler: it maps parsed request parts to the use * case input, runs the use case, and returns its output as the success * response body. Use a full `handle` route for headers, streaming, native * `Response` values, or multi-status handling. */ export type UseCaseRouteDef< Ctx, CLike extends ContractLike, UC extends AnyUseCaseLike, Hooks extends readonly RouteHook<Ctx, object>[] = readonly [], > = UseCaseRouteShape< Ctx & AddedCtxFromHooks<Hooks>, CLike, ResolveContract<CLike>, UC, Hooks >; /** * Structural check that a use case accepts the context this route provides. * * Enforced through `run` parameter contravariance so it applies even at loose * collection boundaries where contract types are erased. */ export type UseCaseAcceptsCtx<Ctx> = { run: (args: { ctx: Ctx; input: never }) => Promise<unknown>; }; /** * Loosely typed binder route used at collection boundaries where contract and * use case types are erased. The use case's context requirement is still * checked against the server context. */ export type AnyUseCaseRouteDef< Ctx, CLike extends ContractLike = ContractLike, Hooks extends readonly RouteHook<Ctx, object>[] = readonly RouteHook< Ctx, object >[], > = { contract: CLike; hooks?: Hooks; useCase: AnyUseCaseLike & UseCaseAcceptsCtx<Ctx & AddedCtxFromHooks<Hooks>>; // biome-ignore lint/suspicious/noExplicitAny: request part types are erased at collection boundaries input?: (parts: any) => unknown; status?: number; handle?: never; }; type HooksOf<E> = E extends { hooks: infer H extends readonly unknown[] } ? H : readonly []; /** * Per-element binder validation applied where route tuples are inferred, such * as an app-bound `defineRouteGroup({ ... })`, so contract/use-case mismatches are * reported on the individual route literal. */ export type ValidatedRouteInput<Ctx, E> = E extends { contract: infer CL extends ContractLike; useCase: infer UC extends AnyUseCaseLike; } ? ResolveContract<CL> extends infer C extends HttpContractConfig ? { contract: CL; hooks?: HooksOf<E>; handle?: never; } & ( | { useCase: UC & UseCaseFitsRoute<Ctx & AddedCtxFromHooks<HooksOf<E>>, C, UC>; input?: never; } | { useCase: UC & UseCaseFitsRoute< Ctx & AddedCtxFromHooks<HooksOf<E>>, C, UC, "mapped" >; input: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>; } ) & BinderStatusOption<C> : unknown : unknown; /** * Element-wise binder validation for a route input list. */ export type ValidatedRouteInputs<Ctx, R extends readonly unknown[]> = { [K in keyof R]: ValidatedRouteInput<Ctx, R[K]>; }; /** * 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: unique symbol = Symbol.for( "beignet.useCase.trustedRun", ); const USE_CASE_OUTPUT_VALIDATED_KEY: unique symbol = Symbol.for( "beignet.useCase.outputValidated", ); type RuntimeUseCase = AnyUseCaseLike & { run: (args: { ctx: unknown; input: unknown }) => Promise<unknown>; [USE_CASE_TRUSTED_RUN_KEY]?: (args: { ctx: unknown; input: unknown; }) => Promise<unknown>; [USE_CASE_OUTPUT_VALIDATED_KEY]?: boolean; }; /** * Loosely typed binder route definition consumed by route registration. */ export type RuntimeUseCaseRouteDef = { useCase: RuntimeUseCase; input?: (parts: { path: unknown; query: unknown; headers: unknown; body: unknown; }) => unknown; status?: number; }; type UseCaseInputValidationFailure = Error & { name: "UseCaseValidationError"; phase: "input"; useCaseName: string; }; function isUseCaseInputValidationFailure( error: unknown, useCaseName: string, ): error is UseCaseInputValidationFailure { if (!(error instanceof Error)) return false; const candidate = error as Partial<UseCaseInputValidationFailure>; 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 { readonly code = "USE_CASE_INPUT_VALIDATION_ERROR"; readonly contractName: string; readonly useCaseName: string; constructor(args: { contractName: string; useCaseName: string; cause: UseCaseInputValidationFailure; }) { 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: unknown): value is Record<string, unknown> { 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: { path: unknown; query: unknown; body: unknown; }): Record<string, unknown> { 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: { handle?: unknown; useCase?: unknown; }): route is RuntimeUseCaseRouteDef { return route.useCase !== undefined && route.useCase !== null; } function computeSingleInput( contract: HttpContractConfig, def: RuntimeUseCaseRouteDef, ): { source: "path" | "query" | "body"; schema: unknown } | undefined { if (def.input) return undefined; const sources = [ { source: "path", schema: contract.pathParams }, { source: "query", schema: contract.query, }, { source: "body", schema: contract.body, }, ] as const; 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: { path: unknown; query: unknown; body: unknown }, source: "path" | "query" | "body", ): boolean { return (["path", "query", "body"] as const).every( (candidate) => candidate === source || !isPlainObject(parts[candidate]) || Object.keys(parts[candidate]).length === 0, ); } function computeResponseExemption( contract: HttpContractConfig, def: RuntimeUseCaseRouteDef, status: number, ): number | undefined { 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<Ctx, C extends HttpContractConfig>( contract: C, def: RuntimeUseCaseRouteDef, ): { handler: Handler<Ctx, C>; responseValidationExemptStatus?: number; } { 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: Handler<Ctx, C> = 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, ), }; }