@beignet/core
Version:
Core framework primitives for Beignet
683 lines (624 loc) • 19.8 kB
text/typescript
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,
),
};
}