SaaS Starter

Contract de API & OpenAPI

Cómo se declaran los endpoints, cómo se genera la spec OpenAPI, y cuándo debés regenerar.

Cómo se registra un endpoint

Toda ruta declara su forma OpenAPI vía apiRoute(...) desde apps/server/src/infrastructure/http/openapi.ts:

import { apiRoute } from '@/infrastructure/http/openapi.js';
import { user } from '@app/contracts';

apiRoute({
  method: 'post',
  path: '/users',
  tags: ['users'],
  summary: 'Create a user',
  body: user.CreateUserInput,
  responses: {
    201: { description: 'Created', schema: user.UserDTO },
    409: { description: 'Email already exists' },
  },
  security: 'session',
});

apiRoute registra el path con un registry Zod-to-OpenAPI. Una respuesta 422 de validation-failure se agrega por default a toda ruta, con la forma estándar { code, message, issues } — no la redeclares.

El mismo schema Zod valida la request vía middleware validateRequest({ body, params, query }). El drift de schema es imposible: hay un solo schema.

Generando la spec

bun run generate:api

Esto corre tres pasos:

  1. Arranca el container del server en modo spec-only y recorre el openApiRegistry para emitir apps/server/openapi.json.
  2. Corre openapi-typescript para producir apps/client/lib/api/openapi-types.ts.
  3. Formatea ambos con prettier.

Ambos archivos están en git. El job api-types-fresh de CI re-corre el generador y falla el PR si cualquiera de los dos cambiaría. Es un gate duro, no un warning.

Cuándo debés regenerar

La regla del CLAUDE.md:

  • Agregar, eliminar o renombrar una ruta.
  • Cambiar un schema Zod de request o response (incluyendo agregar/eliminar campos).
  • Modificar requisitos de permission/auth (la security scheme de OpenAPI los refleja).
  • Agregar/eliminar valores en enums que fluyen por la API surface — ej. strings de permission en packages/shared/src/permissions, enums de estado en aggregates expuestos vía DTOs.

Saltealo sólo si el cambio es pura infraestructura sin efecto visible en el controller (un index de Prisma, un helper privado). En la duda: regenerá. Es idempotente.

Workflow

  1. Hacé el cambio de endpoint o schema.
  2. Desde la raíz del repo: bun run generate:api.
  3. git diff apps/server/openapi.json apps/client/lib/api/openapi-types.ts — confirmá que los cambios estén acotados a tu edit. Drift sorpresa significa que estás regenerando contra un tree sucio; limpialo antes de commitear.
  4. Commiteá ambos archivos regenerados en el mismo commit (o como un commit chore(<scope>): regenerate OpenAPI adjunto al mismo PR).
  5. Push. CI re-corre el freshness check.

Consumiendo el client tipado

El frontend llama al server vía openapi-fetch:

import createClient from 'openapi-fetch';
import type { paths } from '@/lib/api/openapi-types';

export const api = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
  credentials: 'include',          // cookies — ver /docs/es/architecture/contracts
});

const { data, error } = await api.GET('/api/users/{id}', {
  params: { path: { id: userId } },
});

Si el server cambia el path o la forma del body, esta llamada deja de compilar.

Swagger UI

/docs (en el server) sirve Swagger UI contra la spec viva. Útil para tirar pruebas en endpoints durante el desarrollo; no reemplaza al client tipado en código de la app.

En esta página