OpenAPI Workflow (code-first)
This project is code-first: the Spring controllers are the single source of truth for the API. The OpenAPI document is derived from the code (via springdoc), and the downstream consumer clients are generated from that document. Nothing generates server code from the spec, so the implementation and the contract cannot drift — the contract is a projection of the code.
Spring controllers (truth)
│ springdoc (OpenApiDocGenerationTest exports one spec per service)
▼
services/spring/<svc>/build/openapi/<svc>.json
│ api/scripts/merge-openapi.py (prefix with gateway routes, merge schemas)
▼
api/openapi.yaml (generated public contract)
│ openapi-python-client / openapi-typescript
▼
gen-ai Python client + web-client TypeScript types (consumers)
How the spec is produced
Each service has an OpenApiDocGenerationTest (a @SpringBootTest with MongoDB excluded/mocked,
so no live database is needed). It calls the service's springdoc endpoint and writes the spec to
build/openapi/<service>.json. Because it runs as a normal test, the spec is regenerated whenever
the controllers change — and if the export ever fails, the build fails.
api/scripts/merge-openapi.py then joins the per-service specs into a single public contract,
prefixing each service's paths with its gateway route (/api/users, /api/content) so the
generated clients call the same URLs the gateway exposes.
Regenerate everything
From the repository root:
./api/scripts/gen-all.sh
This runs the three steps end-to-end: export specs → merge into api/openapi.yaml → generate the
Python and TypeScript clients. It boots the Spring services (~30s), so it is not a git hook —
it runs in CI and on demand. The make generate target wraps it with dependency installation and a
final spec lint.
To export just the per-service specs without merging or client generation:
make spring-openapi-docs # writes services/spring/build/openapi/*.json
Outputs
| Output | Path | Generated by |
|---|---|---|
| Public contract | api/openapi.yaml |
springdoc export + merge-openapi.py |
| gen-ai Python client | services/gen-ai/generated/ |
openapi-python-client |
| web-client TS types | web-client/src/generated/api.ts |
openapi-typescript |
api/openapi.yaml is a generated artifact (kept in the repo so it is reviewable and consumable
without a build). Treat it as output: change the controllers, not the YAML. CI regenerates it and
fails if the committed copy is stale, which is the drift check at the spec level.
Security scheme
Both user-service and content-service declare a bearer-jwt security scheme via @SecurityScheme in
config/OpenApiConfig.java. Controllers annotated with @SecurityRequirement(name = "bearer-jwt")
reference this scheme; springdoc emits components.securitySchemes.bearer-jwt into each service's
spec, and merge-openapi.py carries it into api/openapi.yaml.
Lint the spec
The generated contract is linted (in make generate and as a pre-commit hook on api/openapi.yaml):
npx @redocly/cli@2.39.0 lint api/openapi.yaml
Lint rules are configured in redocly.yaml at the repo root. The recommended ruleset is extended
with relaxations for generated specs: security-defined is downgraded to a warning (public endpoints
have no security), and operation-4xx-response / info-license are disabled.
Git hooks
Install both hooks once after cloning:
pre-commit install # pre-commit: lints api/openapi.yaml when it changes, scans staged changes for secrets
make install-hooks # pre-push: scans pushed commits for secrets, regenerates the contract from the services
- pre-commit runs the cheap spec lint plus a gitleaks secret scan (see Security Scanning) — both fast enough for every commit. Client/contract generation is intentionally not a pre-commit hook — it boots Spring (~30s), which is too slow for every commit.
- pre-push first scans every commit being pushed for secrets (same gitleaks check, widened to
the whole commit range instead of just the staged diff — see
Security Scanning), then regenerates
api/openapi.yamlfrom the services so you never have to remember to runmake generate. If the contract changed, it commits the update and asks you to push again so the change is included. If no Spring source changed since the upstream branch, the contract-regen step skips (fast path); the secrets scan always runs.
CI enforcement
.github/workflows/ci.yml has an openapi-contract job that regenerates the contract from the
services, lints it, and fails if the committed api/openapi.yaml is stale. That is the
authoritative code-first drift check: a PR cannot merge with a spec that disagrees with the code.
The Spring services build self-contained (no generated server stubs). Consumers generate their
client from the committed api/openapi.yaml — a lightweight, no-Spring-boot step. The web client
uses the generated TypeScript types for its cross-service calls, so CI and the image build run
npx openapi-typescript api/openapi.yaml -o web-client/src/generated/api.ts before building it (the
generated client is gitignored, so it must be produced into the build context first).
The gen-ai Python client (openapi-python-client) is wired end-to-end and ready to use — no
further edits are needed when gen-ai starts importing it:
- CI generates and installs it, so it is continuously validated and importable by tests.
- The image workflow pre-generates it into the build context, and
services/gen-ai/Dockerfileinstalls./generatedwhen present (a bare local build without it still succeeds). - Locally,
make generateproduces it; install it into your venv withpip install services/gen-ai/generated.
Import it as personalised_news_aggregator_api_client (e.g.
from personalised_news_aggregator_api_client.api.users import me).
Keeping cross-service calls drift-safe
When a service consumes another service's API (today the live case is the gen-ai integration), call it through the generated client, never hand-rolled HTTP. The producer's API change then regenerates the consumer's client, and the consumer fails to build until it is addressed — the same no-drift guarantee, on the consumer side. (For behavioural changes, add a contract/integration test; generated clients only catch structural drift.)
Conventions
- Hand-write DTOs as records in the controllers; annotate endpoints with springdoc
(
@Operation,@Tag,@Schema) for a high-quality generated spec. - Each service sets
springdoc.default-produces-media-type=application/jsonso responses are typedapplication/json(not*/*), which lets the generated clients type request/response bodies. - Do not add
implementation project(':generated')or generate Spring server stubs — that is the contract-first direction and is not used here.
Example: consume the generated clients
TypeScript types in the web client:
import type { paths, components } from "@/generated/api";
type UserResponse = components["schemas"]["UserResponse"];
type LoginBody = paths["/api/users/auth/login"]["post"]["requestBody"]["content"]["application/json"];
Python client in gen-ai (package name follows the spec title):
from personalised_news_aggregator_api_client.client import AuthenticatedClient