Files
suaveplan/packages/api-stack/api/api-openapi/src/route-factory/route-factory.md
2026-05-27 23:29:53 +01:00

4.5 KiB

route-factory

(no submodules)

Purpose

Typed route construction utilities for @hono/zod-openapi. The RouteBuilder is a fluent generic that threads TParams, TQuery, TBody, and TResponse from .params/.query/.body/.response calls through to the final .build() output. The resulting TypedRouteConfig is assignable to RouteConfig, so app.openapi(route, handler) infers c.req.valid("params" | "query" | "json") to the exact Zod type without unknown widening.

Also exports the wire-shape ErrorResponseSchema, the wider StandardResponses (covering every status code a SuaveplanError can emit — 400/401/403/404/409/422/429/500/503/504), and the successResponse / paginatedResponse / errorResponse envelope helpers.

Features

  • RouteBuilder<TParams, TQuery, TBody, TResponse> fluent generic.
  • route(method, path) factory + GET/POST/PUT/PATCH/DELETE shorthands.
  • StandardResponses widened to cover the full SuaveplanError status range.
  • ErrorResponseSchema carrying the cause field from SuaveplanError.toJSON().
  • successResponse(schema), paginatedResponse(itemSchema), errorResponse(description) helpers.

Usage: Basic

import z from "zod";
import { GET, successResponse } from "@suaveplan/api-openapi/route-factory";

const ListUsers = GET("/users")
    .tags("users")
    .operationId("listUsers")
    .response(successResponse(z.array(z.object({ id: z.string() }))))
    .build();

Usage: Advanced

import { OpenAPIHono } from "@hono/zod-openapi";
import z from "zod";
import {
    POST,
    paginatedResponse,
    successResponse,
} from "@suaveplan/api-openapi/route-factory";

const UserParam = z.object({ id: z.string().uuid() });
const UserCreate = z.object({ email: z.string().email() });
const User = z.object({ id: z.string(), email: z.string() });

const createUser = POST("/users/:id")
    .tags("users")
    .operationId("createUser")
    .deprecated()
    .security([{ Bearer: [] }])
    .params(UserParam)
    .body(UserCreate)
    .response(successResponse(User))
    .build();

const listUsers = POST("/users/list")
    .tags("users")
    .response(paginatedResponse(User))
    .build();

const app = new OpenAPIHono();
app.openapi(createUser, async (c) => {
    const params = c.req.valid("param"); // typed as z.infer<typeof UserParam>
    const body = c.req.valid("json"); // typed as z.infer<typeof UserCreate>
    return c.json({ success: true, data: { id: params.id, email: body.email } });
});

API Reference

  • type RouteMethod = "get"|"post"|"put"|"patch"|"delete"
  • interface RouteOptions<TParams, TQuery, TBody, TResponse>
  • const ErrorResponseSchema + type ErrorResponse
  • function successResponse<T>(dataSchema: T)
  • function errorResponse(description: string)
  • function paginatedResponse<T>(itemSchema: T)
  • const StandardResponses — 400/401/403/404/409/422/429/500/503/504 each pointing at ErrorResponseSchema
  • type TypedRouteConfig<TParams, TQuery, TBody, TResponse>
  • class RouteBuilder<TParams, TQuery, TBody, TResponse>
  • function route(method, path): RouteBuilder
  • const GET/POST/PUT/PATCH/DELETE

Implementation Notes

TypedRouteConfig uses Omit<RouteConfig, "request"> & { request: … } so the request property is non-optional. Hono's InputType* conditional types only match when request is present, so making it optional silently downgrades inference to unknown.

The builder records state in a private object rather than directly on this so the four .params/.query/.body/.response overloads can re-cast the receiver to a new generic instantiation without TypeScript losing track of the actual stored values.

StandardResponses is widened to cover every status SuaveplanError emits. Upstream omitted 422 (validation), 429 (rate limit), 503 (service unavailable), and 504 (gateway timeout), forcing every plugin that wanted to document those responses to redefine them locally — a common source of doc drift. The widened set is now the single source.

ErrorResponseSchema adds the cause field on top of upstream's shape. SuaveplanError supports a cause chain, and serialising it gives clients enough context to diagnose without requesting logs. cause is typed z.unknown() because the chain may be heterogeneous — SuaveplanError descendants, native Errors, or serialised payloads.

responses always merges 200: Success first and then spreads StandardResponses — so consumers can override 200 by passing .response(…) but cannot accidentally drop one of the standard error slots.