OpenAPI workflow
api/openapi.yaml is the single source of truth for the HTTP API. make gen
regenerates four things from that one file: the Go server interface, the
TypeScript types, the @tanstack/react-query hooks, and the spec copy served
to external consumers at /openapi.yaml. CI fails the build if any generated
file drifts from what the spec describes.
This is the same shape Stripe, Twilio, GitHub and Kubernetes use to keep their SDKs aligned with their HTTP surface.
Add or change an endpoint
Section titled “Add or change an endpoint”- Edit
api/openapi.yaml. Add the path, theoperationId, the request body schema (undercomponents.schemasif reusable, inline otherwise), and the response shape. Include error responses such as404, so the generated TS types narrow on status and callers get the right body type per status code. APOST,PUTorPATCHunder/v1also lists$ref: "#/components/parameters/IdempotencyKey"in itsparameters. The exceptions are/v1/auth/*,/v1/public/*, the inbound webhooks,/v1/tenantsand/v1/bootstrap: the server ignores the header there. - Run
make genfrom the repo root. It runs both Go and TS codegen, refreshesweb-v2/public/openapi.yaml, and rewrites the generated files in place. - Implement the handler. On the Go side, add a method on the relevant
handler that matches the generated
ServerInterfacesignature, for examplefunc (h *Handler) GetInvoice(w, r, id string). Avar _ <interface> = (*Handler)(nil)assertion (a compile-time check that the handler implements the generated interface) turns spec-vs-code drift into a build error rather than a runtime 404. - Move the dashboard to the generated hook. Replace ad-hoc
useQuery({ queryFn: () => api.foo() })calls with the generated hook, for exampleuseGetInvoice(id). The hook handles the URL, the query keys and the typed response shape. The mutator (orvalClient, the function every generated request goes through) sends the request throughapiRequest, so cookies, error-envelope parsing andHardcount-Request-Idcapture keep working unchanged. - Run the local gates:
go build ./...,go vet ./...,go test ./... -short,cd web-v2 && npx tsc -b. Usetsc -b:--noEmitis a no-op under this repo’s composite project references. - Commit the spec change and the regenerated files together. See CI gate.
Files at a glance
Section titled “Files at a glance”| Path | What it is | Hand-edit? |
|---|---|---|
api/openapi.yaml |
OpenAPI 3.0.3 source of truth | Yes — this is the contract |
api/cfg.yaml |
oapi-codegen config (Go) |
Rarely |
tools/tools.go |
Build-tagged import that pins oapi-codegen to go.mod |
When bumping codegen |
internal/api/generated/api.gen.go |
Generated Go types + chi-compat ServerInterface |
No — regenerate |
web-v2/orval.config.ts |
orval config (TS hooks) |
Rarely |
web-v2/scripts/copy-openapi-spec.mjs |
Copies api/openapi.yaml into web-v2/public/openapi.yaml, so the served spec and codegen read the same file |
No |
web-v2/src/lib/api.gen.ts |
Generated raw TS types from openapi-typescript |
No — regenerate |
web-v2/src/lib/gen/queries.gen.ts |
Generated react-query hooks from orval |
No — regenerate |
web-v2/src/lib/gen/schemas/ |
Generated TS schema types (one file per components.schemas entry) |
No — regenerate |
web-v2/src/lib/orvalClient.ts |
Hand-written orval mutator that delegates to the existing apiRequest fetch wrapper. See Why a custom orval mutator |
Yes |
CI gate
Section titled “CI gate”.github/workflows/ci.yml runs make gen, then git diff --exit-code. The
first command regenerates everything from the spec. The second fails if that
changed anything in the working tree. So both of these fail CI:
- a spec edit without the regenerated files;
- a hand edit to a generated file, such as
api.gen.go.
The rule: generated files must equal what make gen produces from the
committed spec.
A unit test also ties the spec to the router. TestOpenAPISpec_MatchesRouter
in internal/api/openapi_routes_test.go walks the chi router and fails when:
- a route the server answers is not in the spec with the same method and path parameter names;
- an operation in the spec matches no route.
A route that no caller should use can go in routesNotInSpec in that test
instead, with the reason. Today that is only the methods other than GET on
/metrics, which exist because /metrics is registered for every method.
TestOpenAPISpec_IdempotencyKeyHeader in the same file checks the
Idempotency-Key header. It fails when:
- a
POST,PUTorPATCHroute behind the idempotency middleware does not list theIdempotencyKeyparameter in the spec; - an operation lists it but the middleware does not run on that route, or
skips it (the usage routes,
isIngestPath), so the server would ignore the header.
TestOpenAPISpec_RequestBodies in openapi_bodies_test.go checks request
bodies. requestBodies maps each operation that has a JSON body to the Go
type its handler decodes. The test compares the type’s JSON tags with the
spec’s properties, nested objects included, and fails when:
- the type reads a field the spec does not list, so generated clients cannot send it;
- the spec lists a field the type does not have, so the server drops it, or refuses it on a strict route;
- one side is an array or an object with fields and the other is not, at any depth;
- an operation with a JSON body is in neither
requestBodiesnorbodiesNotChecked.
It compares field names and shape, not value types: a spec string against
a Go int64 passes.
A field left out of the spec on purpose, such as a read-only id on a type
the handler decodes whole, goes in the entry’s undocumented with the
reason. The test starts from the spec, so it cannot see a handler that
decodes a body the spec does not declare. When you add one, add its
requestBody too.
Why it is built this way
Section titled “Why it is built this way”Why a build-time copy of the spec into web-v2/public/
Section titled “Why a build-time copy of the spec into web-v2/public/”The spec is served to external consumers at a stable same-origin URL
(/openapi.yaml). The embedded Scalar viewer that rendered it at
/docs/api was cut 2026-04-29. web-v2/scripts/copy-openapi-spec.mjs
copies api/openapi.yaml into web-v2/public/openapi.yaml, and runs as a
predev / prebuild / gen hook in web-v2/package.json.
A symlink would break on hosts that flatten symlinks during checkout. An explicit copy is portable, and it shows in the package.json scripts as a clear dependency.
Why a custom orval mutator
Section titled “Why a custom orval mutator”orval builds its own Axios client by default. We delegate to apiRequest
(in web-v2/src/lib/api.ts) instead, because it already carries:
- session-cookie auth;
- parsing of the Stripe-style error envelope (the
{ error: {…} }JSON wrapper on failure responses) intoApiError; - capture of the
Hardcount-Request-Idheader intosetLastRequestId, for “Report an issue” trace handles.
Reusing the wrapper keeps generated hooks on the same auth posture as every hand-written caller in the dashboard. Stripe-node makes the same trade-off: the SDK is generated from the spec, and the transport is hand-written and reused.
Why two TS generators
Section titled “Why two TS generators”They regenerate from the same spec on make gen, and they do different jobs:
openapi-typescriptwritesweb-v2/src/lib/api.gen.ts: one flat type tree indexed by URL path and method. A page that has not moved to the typed hooks can still take a precise payload type frompaths['/v1/foo']['get'].orvalwrites the typed react-query hooks.
Trade-offs and known sharp edges
Section titled “Trade-offs and known sharp edges”- Optional fields are plain types, not pointers.
prefer-skip-optional-pointer: trueinapi/cfg.yamlrenders optional fields withomitemptyJSON tags rather than*string. A*""non-nil pointer marshals as"", which would break wire-shape parity with the existingdomain.InvoiceJSON output. The cost: callers cannot tell “field absent” from “field present and empty” at the type level. For Hardcount’s domain types, every empty string means the same as “absent”, so that is the right call. - Generated hooks cannot send
Idempotency-Keyyet. The spec declares the header, but orval leaves header parameters out of the hooks it generates, andorvalClientpasses only the method, path and body toapiRequest. A dashboard call that must send the header stays onapiRequestuntil the mutator forwards headers. - Hooks return the bare body.
output.override.fetch.includeHttpResponseReturnType: falsekeeps the orval hook return types as the body (Promise<InvoiceWithLineItems>), not the{ data, status, headers }envelope. The mutator throwsApiErroron non-2xx and resolves with the body on 2xx. The bare-body type matches what it returns, and removes a.datastep at every call site. - Generated files live in
web-v2/src/lib/gen/, not beside the hand-written files inweb-v2/src/lib/. orval’sclean: truewould delete the parent oftargetrecursively. Pointed atsrc/lib/queries.gen.ts, it would delete every hand-written file insrc/lib/on the next regen. Thegen/subdirectory limits the damage even ifcleanis ever turned back on.npm run gendeletessrc/lib/gen/before orval runs, because orval only rewrites the files it generates: a schema whose route left the spec would otherwise stay behind.