Generate the web API client from OpenAPI instead of declaring routes twice
Raised while settling the "Centralized API layer" ticket, and deliberately kept out of it.
The problem
Routes are declared twice, with nothing tying the two declarations together:
- 215 route decorators (
@Get/@Post/@Patch/@Put/@Delete) acrossapps/api/src/**/*.controller.ts - 210
request()calls with hardcoded path strings acrossapps/web/src/lib/api/*.ts
This is an asymmetry with how the monorepo already treats the rest of the contract: DTOs and enums live in packages/shared and are consumed by both sides, so a shape change breaks the typecheck. Paths get no such treatment — renaming an endpoint on the API compiles cleanly on both sides and fails at runtime, in production, as a 404.
What already exists
Most of the groundwork is in place:
@nestjs/swaggeris a production dependency (apps/api/package.json:45).- The nest-cli swagger plugin already injects metadata into every compiled controller.
- Swagger UI is already served on
/docs(apps/api/src/main.ts:88), currently dev-only.
So the OpenAPI document describing all 215 routes is already generated today — it is simply not consumed by anything.
Options
A. Shared route constants in packages/shared. Rejected on inspection:
packages/sharedis consumed from its builtdist/, so every route rename would force apnpm build:packagebefore either side compiles — a permanent tax on a common operation.- Nest wants segments (
@Controller("library")+@Get(":id/episodes")), the web wants interpolated URLs (/library/${id}/episodes). One shared representation serving both needs a builder layer. - It only fixes paths. Response shapes stay a separate manual exercise.
B. Generate a typed client from the OpenAPI document. Preferred: it fixes paths and response shapes in one move, using tooling that is already half-installed, and it is the standard answer to this problem.
Hard constraint
Whatever is generated must keep routing through request() (apps/web/src/lib/api/core.ts) rather than calling fetch itself. That function carries behaviour the app depends on and that no generator will produce:
- the auth header,
- the refresh-and-replay on 401 with the single-flight mutex that keeps concurrent 401s from consuming the rotating refresh token more than once (
core.ts:130-150), - wrapping every failure — including a rejected fetch (offline, VPS down) — into
ApiErrorwithcode/params/details/requestId/retryAfterSeconds.
That last point is the foundation of the whole error-code effort. A generated client that bypasses it would silently undo it. The generated layer should produce typed path + method + request/response types, and delegate the transport to request().
Open questions
- Which generator, and does it stay a dev dependency only (per the repo's "prefer no new runtime deps" rule)?
- Is the generated client committed to the repo, or produced at build time? Committed is friendlier for self-hosters building from source and makes diffs reviewable; generated keeps it impossible to desync.
- Does it replace
lib/api/*.tsentirely, or only supply paths and types while the hand-written domain functions stay as the ergonomic surface? - CI: a check that the committed client matches what the current API produces, so a route change that skips regeneration fails the build rather than production.
- Should the OpenAPI document be published rather than dev-only? Self-hosters and any third-party client would benefit, and it is the natural artifact for an AGPL self-hosted API. Separate call from the codegen itself.
Sequencing
After the "Centralized API layer" ticket. That one rewrites how call sites are consumed (components); this one rewrites how they are declared (lib/api/*.ts). They are largely orthogonal but both large, and running them together would make either impossible to review.
Explicitly not this ticket
Query keys were floated as a way to unify routes across both sides. They cannot: a route only yields a key's prefix (params carry the rest), and invalidation is a semantic graph rather than a URL tree — library.service.ts:186 emits activity to the feed and stats.service.ts:542 reads episodeWatch, so a single POST /library/... touches three domains and no route encodes that. Keys stay a web-side concern.
0 Comments
Sign in to comment
No comments yet. Be the first to share your thoughts!
