opinionated-machine
Version:
Very opinionated DI framework for fastify, built on top of awilix
153 lines (152 loc) • 6.9 kB
TypeScript
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 {};