Appearance
OpenAPI Workflow
TT Time Tracker maintains end-to-end type safety between the NestJS API and the Vue frontend through an OpenAPI-driven code generation pipeline.
The pipeline
NestJS controllers + DTOs
│
│ (Swagger decorators)
▼
OpenAPI spec (JSON)
│
│ pnpm openapi
▼
src/api/sdk.gen.ts
(typed functions, one per endpoint)
│
│ import { Entries } from '@/api/sdk.gen'
▼
Vue componentsStep 1 — Annotate with Swagger
NestJS generates the OpenAPI spec from decorators in controllers and DTOs:
typescript
@ApiTags('Entries')
@Controller('tenants/:tenantId/entries')
export class EntryController {
@Get()
@ApiOkResponse({ type: [EntryDto] })
findAll(): Promise<EntryDto[]> { ... }
}The spec is served at GET /api/docs-json by the running API, but the codegen pipeline doesn't hit that URL — it exports the spec to a file first (see Step 2).
The plugin must run under the Nest CLI
DTO schemas are populated by the @nestjs/swagger transformer plugin (services/api/nest-cli.json), which only runs under the Nest CLI. The API's dev, start:debug, build, and openapi:export scripts therefore all go through nest. Exporting from a tsx- or plain-tsc-built API silently drops every request-body DTO (body?: never).
Step 2 — Export the spec
pnpm openapi runs this automatically, but you can run it on its own:
bash
pnpm --filter @tt/api openapi:export
# nest start with EXPORT_SWAGGER=openapi.json → writes services/api/openapi.json, then exitsservices/api/openapi.json is a gitignored build artifact, regenerated on demand — not committed. The export boots the app (so it needs the same DB/Redis a normal boot needs), builds the Swagger document, writes the file, and exits.
Step 3 — Generate the client
bash
pnpm openapi
# services/api openapi:export → then openapi-ts reads services/api/openapi.json → writes services/client/src/api/The generator (@hey-api/openapi-ts) produces one namespace per Swagger tag, with fully typed request and response shapes.
Why not tRPC or GraphQL?
tRPC requires TypeScript on both ends and tight coupling between the server and client through a shared type package. This works well for monorepos but adds complexity to the build pipeline and makes it harder to call the API from non-TypeScript clients.
GraphQL solves a different problem — flexible querying of complex graphs. TT Time Tracker's API is a straightforward REST CRUD surface. GraphQL's overhead (schema definition, resolvers, N+1 considerations) is not justified here.
OpenAPI gives us:
- Standard HTTP semantics (cacheable GETs, idempotent PUTs)
- A Swagger UI for easy exploration and manual testing
- The ability to call the API from any HTTP client (including external integrations via API keys)
- Type-safe generated clients without coupling the frontend and backend build pipelines
The main downside is that regeneration is a manual step: after changing the API you must run pnpm openapi (which exports the spec and regenerates in one go) to refresh the frontend SDK. This is a low friction cost in practice.