OpenAPI Contract Flow
1. Overview
admin-service’s OpenAPI spec is the authoritative description of its HTTP contract. Portals do not hand-write API client services — they generate them from the spec and run them through a dayjs transformer to restore date-time ergonomics. The spec itself is extracted in CI during the admin-service test phase and published as a build artefact; portal builds consume that artefact.
This flow has one goal: keep the portal’s API layer in lockstep with admin-service without a manual sync step. When admin-service ships a new endpoint or changes a DTO, the next portal build picks up the change automatically.
See also: Portal Pattern.
2. The Three Moving Parts
2.1. 1. Spec extraction in admin-service CI
Springdoc generates /v3/api-docs at runtime using reflection on @RestController classes. The spec can only be produced once the Spring context is up. CI’s test phase already boots the context, so extraction attaches to that.
admin-service CI build
└─ mvn test -Papi-docs,test
├─ Spring context starts with `api-docs` profile
│ └─ springdoc.api-docs.enabled=true
├─ Spec-extraction test fires HTTP request to /v3/api-docs
├─ Response written to target/openapi.json
└─ Maven build-artefact step publishes target/openapi.json
Key points:
-
The
api-docsSpring profile must be active — the default application config hasspringdoc.api-docs.enabled: false. WS3 implementation wires this into a CI profile. -
The spec includes every class under
web/rest/annotated@RestController. It does not include admin-service’sform/package (FormController, EventFormController, MembershipFormController, TestFormController) — those are internal domain wrappers, not HTTP endpoints; their HTTP surface isFormResource. -
The spec includes
/auth/**endpoints — but annotations there are sparse. Part of WS3 scope: add@Operation+@TagtoExternalAuthResourcemethods so the generated TS client has meaningful names.
2.2. 2. Spec artefact publication
The extracted openapi.json is published to one of:
-
Branch-based: a dedicated
openapi-specbranch in admin-service with one commit per build (spec becomes versioned git history — easy diff tooling). -
GitHub Packages / artefact registry: timestamped artefacts, downloaded by build ID.
-
S3-style object storage: simplest, no git history.
Current operational environment already has the infrastructure for all three — pick per implementation convenience in WS3. Default recommendation: branch-based, because diff tooling and git blame remain free.
2.3. 3. Portal-side client generation
Each portal regenerates its TS client per build:
portal build (Maven frontend phase)
├─ download openapi.json artefact from admin-service latest
├─ openapi-generator-cli generate -i openapi.json -g typescript-angular \
│ -o src/app/api/generated \
│ -c openapi-generator-config.json
├─ Angular CLI build (tsc, esbuild) consumes generated/
└─ dayjs transformer (interceptor) wraps date-time types on response
src/app/api/generated/ is git-ignored. The generator’s output is reproducible from the spec + generator version; no value in committing it.
3. Library vs In-Portal Generation
Two ways to consume the spec:
| Approach | When it wins |
|---|---|
In-portal generation (default) |
One consumer (admin-portal) or each consumer has its own portal-specific post-gen transformer. No publishing ceremony. Spec version is implicit in the build timestamp. |
Shared library ( |
Multiple consumers need the same client. Version pinning matters (downstream wants to lock to spec v1.4.2). Transformer and helpers are identical across consumers. |
Default to in-portal. Promote to a library when membership-ui or registration-portal want the same generated client and the dayjs transformer is proven stable across consumers. This was the approved direction for admin-portal (design journal 2026-04-24).
4. Dayjs Transformer
The generator emits Date or string types for OpenAPI format: date / format: date-time fields. EMS uses dayjs everywhere. A single HTTP interceptor converts on the boundary:
// on response: strings matching ISO-8601 become dayjs
// on request: dayjs instances become ISO-8601 strings
const isoDateTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
function deepConvertToDayjs(obj: unknown): unknown { /* ... */ }
function deepConvertToIso(obj: unknown): unknown { /* ... */ }
@Injectable()
export class DayjsHttpInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<unknown>, next: HttpHandler) {
const modified = req.clone({ body: deepConvertToIso(req.body) });
return next.handle(modified).pipe(map(event => {
if (event instanceof HttpResponse) {
return event.clone({ body: deepConvertToDayjs(event.body) });
}
return event;
}));
}
}
Target size: ≤200 LOC. Keep it in one file. Add tests — a generated client that loses dayjs ergonomics is a regression that’s hard to attribute.
Alternative: a post-generation script that rewrites the generator output types from Date to Dayjs. More fragile (breaks if the generator template changes); avoid unless the interceptor approach hits a corner case.
5. OpenAPI Gotchas Specific to admin-service
5.1. Profile-gated endpoint
springdoc.api-docs.enabled: false by default. Without the api-docs profile, /v3/api-docs returns 404. CI spec-extraction must activate the profile:
# admin-service application-api-docs.yml (existing)
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
5.2. Sparse annotations on /auth/**
ExternalAuthResource methods have Javadoc but no @Operation or @Tag. The generator falls back to path-derived operation names (authExternalLoginPost, etc.) which are ugly in generated client code. Fix during WS3:
@Operation(summary = "Exchange OIDC claims for admin-service JWT")
@Tag(name = "Authentication")
@PostMapping("/token-exchange/oauth2")
public ResponseEntity<OAuth2TokenExchangeResponseDTO> oauth2TokenExchange(...) { ... }
5.3. Security scheme
ApplicationOpenApiCustomizer adds both JWT (Authorization: Bearer) and API key (X-API-KEY) to every operation. That’s correct — some admin-service endpoints accept one or the other — but the generated TS client will expect both configured. Portal-side: only the JWT interceptor is needed; API-key is never in browser code (see API-Key Injection).
6. Versioning
The spec has no semver built in — info.version is today derived from project.version in the Maven build. Downstream consumers should treat each spec release as potentially-breaking at the DTO level. Two mitigations:
-
Keep
project.versionin lockstep with meaningful API change — bump a minor when you add fields, bump a major when you break shape. -
Add a CI diff check:
openapi-diffagainst the previous spec version; fail the build on breaking changes unless explicitly acknowledged via a changelog entry.
Defer the CI diff gate until after the first portal is consuming the generated client end-to-end.
7. Reference
| File | Role |
|---|---|
|
Spec customisation — server URL, contact, description |
|
Security scheme attachment |
|
Profile gating, path config |
|
|
|
|
WS3 implementation — admin-portal |
CI extraction job + downstream client generation wiring |