Add an endpoint
Add a route + Zod schema + apiRoute registration + permission gate + regen.
You're adding a single endpoint to an existing module (e.g. GET /api/users/me/preferences). For a brand-new module, see add a new feature.
1. Define the 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 from the contracts barrel.
2. Add the use case (or extend a 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 });
}
}Register it in bootstrap/container.ts.
3. Wire the 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,
// No requirePermission — reading your own preferences is always allowed.
async (req, res) => {
const result = await getPreferences.execute({ userId: req.session.userId });
if (result.isErr()) return sendError(res, result.error);
res.json(result.value);
},
);Mount the right middleware
| Use case | Middleware chain |
|---|---|
| Public read | readLimiter only |
| Authenticated read | authMiddleware → readLimiter |
| Admin operation | authMiddleware → writeLimiter → requirePermission('resource:action') |
| Self-or-admin (e.g. PATCH /users/:id) | authMiddleware → writeLimiter → requirePermissionOrSelf('users:update', (r) => r.params.id) |
| Validates a body | append validateRequest({ body: ContractSchema }) |
| Premium feature | append requireActiveSubscription |
The four tier limiters (readLimiter, writeLimiter, authIpLimiter, authEmailLimiter) are memoized — every router gets the same handler back, so memory mode and Redis mode share buckets across routers.
4. Regenerate types
bun run generate:apiCheck the diff is scoped:
git diff apps/server/openapi.json apps/client/lib/api/openapi-types.tsCommit both files in the same change as the route.
5. Use it from the client
import { api } from '@/lib/api/client';
const { data, error } = await api.GET('/api/users/me/preferences');The path, method, and response type are all checked against the regenerated openapi-types.ts. If you renamed the path between generating the spec and writing the call, the call won't compile.
6. Test
A challenging test exercises the path that would regress. For an endpoint that gates on permissions, that's "user without grant gets 403, user with grant gets 200". For a body-validating endpoint, "missing field returns 422 with the right issues". Avoid tests that only check status codes on the happy path — those don't fail when business logic regresses.