SaaS Starter

Agregar un endpoint

Agregar una route + schema Zod + registración en apiRoute + permission gate + regen.

Estás agregando un único endpoint a un módulo existente (ej. GET /api/users/me/preferences). Para un módulo nuevo, ver agregar una nueva feature.

1. Definir el contract

packages/contracts/src/user.ts:

export const PreferencesSchema = z.object({
  locale: z.enum(['en', 'es', 'pt']),
  emailDigest: z.boolean(),
});
export type Preferences = z.infer<typeof PreferencesSchema>;

Re-exportá desde el barrel de contracts.

2. Agregar el use case (o extender una query)

// apps/server/src/modules/iam/application/use-cases/get-preferences.ts
export class GetPreferences implements UseCase<{ userId: UserId }, Preferences> {
  constructor(private readonly users: UserRepository) {}

  async execute({ userId }: { userId: UserId }): Promise<Result<Preferences, DomainError>> {
    const user = await this.users.findById(userId);
    if (!user) return err(new NotFoundError('User'));
    return ok({ locale: user.locale, emailDigest: user.emailDigest });
  }
}

Registralo en bootstrap/container.ts.

3. Cablear la route

// apps/server/src/modules/iam/interfaces/http/users.routes.ts
import { apiRoute } from '@/infrastructure/http/openapi.js';
import { PreferencesSchema } from '@app/contracts';

apiRoute({
  method: 'get',
  path: '/users/me/preferences',
  tags: ['users'],
  responses: {
    200: { description: 'OK', schema: PreferencesSchema },
  },
  security: 'session',
});

router.get(
  '/me/preferences',
  authMiddleware,
  readLimiter,
  // Sin requirePermission — leer tus propias preferencias siempre está permitido.
  async (req, res) => {
    const result = await getPreferences.execute({ userId: req.session.userId });
    if (result.isErr()) return sendError(res, result.error);
    res.json(result.value);
  },
);

Montá los middlewares correctos

Caso de usoCadena de middleware
Lectura públicasólo readLimiter
Lectura autenticadaauthMiddlewarereadLimiter
Operación adminauthMiddlewarewriteLimiterrequirePermission('resource:action')
Self-or-admin (ej. PATCH /users/:id)authMiddlewarewriteLimiterrequirePermissionOrSelf('users:update', (r) => r.params.id)
Valida un bodysumar validateRequest({ body: ContractSchema })
Feature premiumsumar requireActiveSubscription

Los cuatro tier limiters (readLimiter, writeLimiter, authIpLimiter, authEmailLimiter) están memoizados — todo router obtiene el mismo handler, así que el modo memoria y el modo Redis comparten buckets entre routers.

4. Regenerar tipos

bun run generate:api

Chequeá que el diff esté acotado:

git diff apps/server/openapi.json apps/client/lib/api/openapi-types.ts

Commiteá ambos archivos en el mismo cambio que la route.

5. Usalo desde el client

import { api } from '@/lib/api/client';

const { data, error } = await api.GET('/api/users/me/preferences');

El path, el método, y el tipo de response están todos chequeados contra el openapi-types.ts regenerado. Si renombraste el path entre generar la spec y escribir la llamada, la llamada no compila.

6. Test

Un test challenging ejercita el path que regresaría. Para un endpoint que gateá por permissions, eso es "user sin grant obtiene 403, user con grant obtiene 200". Para un endpoint que valida body, "campo faltante devuelve 422 con los issues correctos". Evitá tests que sólo chequean status codes en el happy path — esos no fallan cuando la lógica de negocio regresa.

En esta página