Skip to content

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.yaml from the services so you never have to remember to run make 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/Dockerfile installs ./generated when present (a bare local build without it still succeeds).
  • Locally, make generate produces it; install it into your venv with pip 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/json so responses are typed application/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