UNPKG

opinionated-machine

Version:

Very opinionated DI framework for fastify, built on top of awilix

153 lines (152 loc) 6.9 kB
import type { ApiContract, ContractNoBody, ContractResponseMode, InferSseSuccessResponses, PayloadApiContract } from '@lokalise/api-contracts'; import type { FastifyRequest, RouteOptions } from 'fastify'; import type { z } from 'zod/v4'; import type { DualModeType } from '../dualmode/dualModeTypes.ts'; import type { GatewayMetadata } from '../gateway/gatewayTypes.ts'; import type { FastifySSERouteOptions, SSEContext, SSEHandlerResult, SyncModeReply } from '../routes/fastifyRouteTypes.ts'; type NonSseBodyEntry<T> = T extends undefined ? never : T extends { _tag: 'SseResponse'; } ? never : T extends { _tag: 'BlobResponse'; } ? Blob : T extends { _tag: 'TextResponse'; } ? string : T extends { _tag: 'AnyOfResponses'; responses: Array<infer R>; } ? NonSseBodyEntry<R> : T extends z.ZodType ? z.output<T> : undefined; /** * Discriminated union of `{ status, body }` pairs for all non-SSE responses in a contract. * * Allows non-SSE handlers to return a specific status code and body together without * calling `reply.code()` separately. * * @example * ```typescript * async (request) => { * if (!valid) return { status: 400, body: { error: 'Bad Request' } } * return { id: request.params.id } * } * ``` */ export type InferApiStatusResponse<Contract extends ApiContract> = { [K in keyof Contract['responsesByStatusCode']]: NonSseBodyEntry<Contract['responsesByStatusCode'][K]> extends never ? never : { status: K; body: NonSseBodyEntry<Contract['responsesByStatusCode'][K]>; }; }[keyof Contract['responsesByStatusCode']]; type InferOptSchema<T, Fallback = unknown> = NonNullable<T> extends z.ZodType ? z.output<NonNullable<T>> : Fallback; type InferApiBodyType<Contract extends ApiContract> = Contract extends PayloadApiContract ? Contract['requestBodySchema'] extends typeof ContractNoBody ? undefined : NonNullable<Contract['requestBodySchema']> extends z.ZodType ? z.output<NonNullable<Contract['requestBodySchema']>> : undefined : undefined; /** * Infer the FastifyRequest type from an ApiContract. * * Provides properly typed params, querystring, headers, and body. * * @example * ```typescript * const handler = async (request: InferApiRequest<typeof myContract>) => { * request.params.userId // typed * request.body.name // typed * } * ``` */ export type InferApiRequest<Contract extends ApiContract> = FastifyRequest<{ Params: InferOptSchema<Contract['requestPathParamsSchema']>; Querystring: InferOptSchema<Contract['requestQuerySchema']>; Headers: InferOptSchema<Contract['requestHeaderSchema']>; Body: InferApiBodyType<Contract>; }>; /** * Handler for non-SSE responses from an ApiContract. * * Always return `{ status, body }` — the framework validates the body against the * contract's schema for that status code and sends it. * * Use `reply.header()` to set response headers when needed. * * @example * ```typescript * async (request) => ({ status: 200, body: { id: request.params.userId } }) * ``` * * @example With multiple status codes * ```typescript * async (request) => { * if (!valid) return { status: 400, body: { error: 'Bad Request' } } * return { status: 200, body: { id: request.params.userId } } * } * ``` */ export type ApiNonSseHandler<Contract extends ApiContract> = (request: InferApiRequest<Contract>, reply: SyncModeReply) => InferApiStatusResponse<Contract> | Promise<InferApiStatusResponse<Contract>>; /** * Handler for SSE responses from an ApiContract. * * Call `sse.start(mode)` to begin streaming or `sse.respond(code, body)` for * early HTTP returns before streaming starts. */ export type ApiSseHandler<Contract extends ApiContract> = (request: InferApiRequest<Contract>, sse: SSEContext<InferSseSuccessResponses<Contract['responsesByStatusCode']>>) => SSEHandlerResult | Promise<SSEHandlerResult>; /** * Infer the handler shape based on the contract's response mode: * - `'non-sse'` — bare `ApiNonSseHandler` function * - `'sse'` — bare `ApiSseHandler` function * - `'dual'` — `{ nonSse, sse }` object, branched by `Accept` header */ export type InferApiHandler<Contract extends ApiContract> = [ ContractResponseMode<Contract['responsesByStatusCode']> ] extends ['dual'] ? { nonSse: ApiNonSseHandler<Contract>; sse: ApiSseHandler<Contract>; } : [ContractResponseMode<Contract['responsesByStatusCode']>] extends ['sse'] ? ApiSseHandler<Contract> : ApiNonSseHandler<Contract>; /** * Options for configuring an ApiContract route. * * Extends Fastify's `RouteOptions` minus the fields the contract provides * (`method`, `url`, `schema`, `handler`, `sse`), so any Fastify hook or config * (`preHandler`, `onRequest`, `config`, `bodyLimit`, etc.) can be passed directly. * * SSE lifecycle options (`onConnect`, `onClose`, `onReconnect`) are only * relevant for SSE and dual-mode contracts and are ignored for non-SSE routes. * * Generic in `Contract` so `gatewayMetadata.match.headers` / `match.query` * keys are narrowed to the contract's request schemas. The generic is always * inferred from the contract argument at the `buildApiRoute` call site, so * direct references should write `ApiRouteOptions<typeof myContract>` when * gateway metadata typing is needed. */ export type ApiRouteOptions<Contract extends ApiContract> = Omit<RouteOptions, 'method' | 'url' | 'schema' | 'handler' | 'sse'> & Omit<FastifySSERouteOptions, 'preHandler'> & { /** * Default response mode for dual-mode routes when the `Accept` header * does not express a preference. * @default 'json' */ defaultMode?: DualModeType; /** * Per-route gateway metadata. `match.headers` / `match.query` keys are * narrowed to the contract's request schemas; `customHeaders` / * `customQuery` remain the escape hatch for headers and params not * declared on the contract. Validated at runtime against the same Zod * schema used by `withGatewayMetadata` and stamped on the route via the * shared `GATEWAY_METADATA_SYMBOL`. * * Equivalent to wrapping the result with `withGatewayMetadata` — keep * to one form per route. If both are used on the same route, the later * call (typically `withGatewayMetadata`) overwrites the inline value; * there is no merge. * * @example * ```ts * buildApiRoute(MyController.contracts.getItem, this.getItem, { * gatewayMetadata: { * cache: { ttl: '60s' }, * match: { * // narrowed to keys of the contract's requestHeaderSchema: * headers: { 'x-trace-id': { regex: '^[a-f0-9]+$' } }, * // escape hatch for headers not declared on the contract: * customHeaders: { 'x-tenant-id': { regex: '^t_' } }, * }, * }, * }) * ``` */ gatewayMetadata?: GatewayMetadata<Contract>; }; export {};