Skip to content

[Feature]: Generate an OpenAPI spec from our Zod schemas #2958

Description

@cqnykamp

Before submitting

  • I searched existing issues and did not find a duplicate request.

Affected area

Developer experience / tooling

Layer

  • Frontend (apps/app)
  • API (apps/api)
  • Infrastructure / CI
  • Docs

Problem

~106 endpoints across apps/api/src/routes/ (and newer per-system folders like media/), but no machine-readable description of the API. Consequences:

  • apps/app calls the API via raw axios.get("/api/...") — paths and payloads are stringly-typed; renames silently break the client.
  • apps/app/src/types.ts and apps/api/src/types.ts are kept "intentionally identical" by hand (root AGENTS.md) because we have no shared contract.
  • Reviewers can't easily see "this PR changes the API surface" — they have to read every route handler and Zod schema and infer it.

We already write a precise request contract for every wrapped route (the Zod schema passed to queryLoggedIn). The information is there; we're just not extracting it.

Proposed solution

Generate openapi.yaml from existing Zod schemas, commit it, enforce it in CI. Three phases so each merged PR is independently useful:

Phase 1 — Generated request spec + CI enforcement. Adopt @asteasolutions/zod-to-openapi (Zod 4 compatible). Extend queryLoggedIn / queryOptionalLoggedIn in apps/api/src/middleware/queryMiddleware.ts to register each route + schema with a shared OpenAPIRegistry as a side effect of route construction — this is the chokepoint, so registration is automatic for existing and future endpoints. Add npm run openapi:generate that writes apps/api/openapi.yaml (YAML, not JSON — diffs better in review). Commit the file. Add a CI check that regenerates and runs git diff --exit-code on it, the same shape as a Prisma migrate-check — fails with a clear "run npm run openapi:generate" message. Passport auth callbacks, /api/health, and a couple of multipart routes that don't use the wrappers are out of scope for Phase 1; document the exclusion list in apps/api/openapi-excluded.md.

Phase 2 — Response schemas, system-by-system (opt-in). The hard gap: response shapes are inferred from query function returns, not declared in Zod. Don't try to fix everywhere at once. Add an optional responseSchema parameter to the wrapper (no-op when absent). Migrate one system at a time, starting with media/ (smallest, already follows the per-system pattern). Track progress as a checklist on this issue.

Phase 3 — Typed frontend client. Once Phase 2 has coverage for a system, generate a typed client via @hey-api/openapi-ts into packages/shared. Migrate axios call sites incrementally. As each system's call sites land, the duplicated types.ts entries for it can be deleted in favor of the generated types.

Alternatives considered

  • Zod-to-TS types in packages/shared, skip OpenAPI. Solves the duplicated types.ts problem but no reviewable artifact, no docs, no generated client paths/methods. OpenAPI is a superset of what we'd build by hand.
  • tRPC. End-to-end type safety, but a rewrite of all 106 endpoints and gives up REST affordances (curl-able, cacheable GETs). Disproportionate.
  • Hand-written Swagger/JSDoc annotations. Drifts; doubles the source of truth. Defeats the point of already having Zod.

Additional context

  • Single registration chokepoint: apps/api/src/middleware/queryMiddleware.ts.
  • Drift-detection precedent: project already enforces "regenerate and commit" for Prisma — same CI shape.
  • Optional later add-on: oasdiff CI step that PR-comments breaking-vs-non-breaking changes; pairs well with expand-migrate-contract.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    Priority

    Medium

    Effort

    High

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions