@beignet/core
Version:
Core framework primitives for Beignet
437 lines (403 loc) • 12.3 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>;
};
/**
* 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, or when its
* output does not match the contract's declared success response schema.
*/
export type UseCaseFitsRoute<Ctx, C extends HttpContractConfig, UC> = [
Ctx,
] extends [UseCaseRouteCtx<UC>]
? [UseCaseRouteOutput<UC>] extends [
SuccessBodyFromKeys<C["responses"], Success2xxKeys<C["responses"]>>,
]
? unknown
: {
"~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;
/**
* Use case bound directly to the contract.
*/
useCase: UC & UseCaseFitsRoute<HandlerCtx, C, UC>;
/**
* Map parsed request parts to the use case input.
*
* Defaults to `defaultBinderInput`, which merges query, body, and path
* objects (path wins collisions) and never merges headers.
*/
input?: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
handle?: never;
} & 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>;
useCase: UC &
UseCaseFitsRoute<Ctx & AddedCtxFromHooks<HooksOf<E>>, C, UC>;
input?: (parts: UseCaseRouteInputParts<C>) => UseCaseRouteInput<UC>;
handle?: never;
} & 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;
};
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. Non-object bodies (text, arrays, scalars)
* are excluded. Routes that need headers or non-object bodies 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 computeTrustedInput(
contract: HttpContractConfig,
def: RuntimeUseCaseRouteDef,
): boolean {
if (def.input) return false;
const sources = [contract.pathParams, contract.query, contract.body].filter(
(schema) => schema !== null && schema !== undefined,
);
return sources.length === 1 && sources[0] === def.useCase.inputSchema;
}
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 mapInput = def.input ?? defaultBinderInput;
const trustedRun = computeTrustedInput(contract, def)
? def.useCase[USE_CASE_TRUSTED_RUN_KEY]
: undefined;
const run = trustedRun ?? def.useCase.run;
const handler: Handler<Ctx, C> = async ({
ctx,
path,
query,
headers,
body,
}) => ({
status,
body: await run.call(def.useCase, {
ctx,
input: mapInput({ path, query, headers, body }),
}),
});
return {
handler,
responseValidationExemptStatus: computeResponseExemption(
contract,
def,
status,
),
};
}