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. Response Contracts
Everything above concerns the shape of the payload. The other half of the contract — which HTTP statuses an endpoint can return, and why — is documented far less reliably, because of one build fact that is easy to miss.
|
|
This matters because admin-service is full of Javadoc that reads like a contract and publishes nothing:
/**
* @return the {@link ResponseEntity} with status {@code 200 (OK)} ...,
* or with status {@code 400 (Bad Request)} if the eventDTO is not valid, ...
* or with status {@code 403 (Forbidden)} if the user lacks access to update the event,
* or with status {@code 500 (Internal Server Error)} if the eventDTO couldn't be updated.
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_USER')")
public ResponseEntity<EventDTO> updateEvent(...)
The published spec for that operation carries 200 alone. The 400, 403 and 500 never leave the source file.
6.1. Measured state
Against the published spec on the openapi-spec branch (2026-08-25):
| Metric | Count |
|---|---|
Operations in the spec |
516 |
Operations with a single success-only response |
509 |
Operations documenting a |
6 |
Handlers carrying |
85 |
The six are exactly the operations carrying @ApiResponses — EventParticipantResource (POST, PUT), ProgressiveMatchResource (three), and PersonResource (one). The correspondence is one-to-one: annotation reaches the spec, Javadoc does not.
Springdoc’s defaults are not merely incomplete, they can be wrong. Every DELETE operation in the spec publishes 200; none publishes 204. 54 delete handlers return ResponseEntity.noContent(), which is a 204. The Javadoc on DELETE /api/events/{id} correctly says 204 (NO_CONTENT) and the spec still says 200 OK, because Javadoc cannot correct a default it cannot reach.
6.2. The convention
Document response contracts with @ApiResponses, keeping the Javadoc for maintainers:
@PostMapping("")
@PreAuthorize("hasAnyAuthority('ROLE_USER', 'ROLE_API_KEY')")
@ApiResponses(
value = {
@ApiResponse(responseCode = "201", description = "Created."),
@ApiResponse(
responseCode = "403",
description = "The caller may not write in the event's organisation, or may not act for the "
+ "named person. Access is composite: both dimensions must permit the write. The "
+ "organisation is derived server-side from the event and is never read from the payload."
),
}
)
Rules:
-
Restate the success code.
@ApiResponsesdoes not merge with springdoc’s default, so omitting200/201/204publishes a contract of errors only. This is also how a wrong default gets corrected. -
Describe the cause, not the status name.
description = "Forbidden"adds nothing. Say which condition failed and what the server derived on its own — facts a consumer cannot obtain anywhere else. -
Only document reachable codes. A generic
500is a maintainer’s note, not a contract. -
Summary and description belong in
@Operation, for the same reason. -
Changing a security path is a contract change. Annotate in the same commit.
6.3. Verifying a contract shipped
A green OpenAPI Spec Extraction run proves the spec was extracted and pushed, not that a given response code reached it. The workflow’s own guard only checks the file is non-empty and warns below 30 paths. Read the JSON instead:
gh api -H "Accept: application/vnd.github.raw" \
"repos/christhonie/event-admin-service/contents/openapi.json?ref=openapi-spec" \
> /tmp/live-openapi.json
Extraction can also be run locally — it takes about three minutes and writes target/openapi.json:
EXTRACT_OPENAPI_SPEC=true ./mvnw -B -Pdev,api-docs \
-Dtest=OpenApiSpecExtractTest -DfailIfNoTests=true test
|
Name the profiles additively — |
7. Generated Client Model Closure
Both generated clients restrict what they emit with an explicit allow-list in pom.xml — apisToGenerate and modelsToGenerate. The spec describes admin-service’s entire surface (178 schemas); a client that consumes eight resources has no use for all of it.
That allow-list carries a contract that is easy to violate and hard to notice:
|
|
openapi-generator honours the list literally. When a generated model `$ref`s a model that is not listed, the generator still writes the reference — into the type map, the docblocks and the setter hints — and simply never writes the class. Nothing downstream objects: generation succeeds, dependency install succeeds, and a syntax check passes.
7.1. Why the PHP client fails silently and the TypeScript one does not
The two clients carry very different risk from the same mistake, which is worth understanding before assuming a green build means anything.
registration-api-ts |
event-registration-api-php |
|
|---|---|---|
Missing model surfaces as |
A missing import, at compile time |
A lazy runtime class lookup |
When you find out |
The next build |
The first time a payload populates that field, in production |
Blast radius |
Build fails, nothing ships |
A succeeding API call is reported as a failure |
The PHP client’s serializer resolves the class only when deserialising a value for that property:
$instance->$propertySetter(self::deserialize($propertyValue, $type, null));
So a dangling reference sits dormant until a response happens to carry the field. On 2026-08-12 that surfaced on tourdeworcester.co.za: ParticipantOrderDTO.buyer referenced a PersonNameDTO that was never generated. Every participant order sync PATCHed successfully and admin-service returned 200; the client then threw Class "…\Model\PersonNameDTO" not found while reading the reply. The plugin recorded a sync failure and told the operator the admin order "may not exist" — for a call that had already applied. It had been broken for months.
PersonNameDTO was one of 23 models missing from the PHP list, six of them reachable from ParticipantOrderDTO, so fixing only the reported field would have moved the failure to whichever field the next payload populated.
7.2. Detection
Closure is a property of the spec plus the allow-list, so it can be checked statically, before anything is published:
python3 scripts/check-model-closure.py [--spec openapi.json] [--pom pom.xml] [--fix]
The script originated in event-registration-api-php. Its first two assertions are generator-agnostic — they read only openapi.json and the pom — but the third is not: it looks for lib/Model/<Name>.php. Pointing the PHP copy at registration-api-ts therefore needs --model-dir overridden to a non-existent path, or it compares the TS allow-list against the PHP repo’s classes and reports every model as missing. A TypeScript-native port lives in registration-api-ts/scripts/, which maps SchemaName to schemaName<modelFileSuffix>.ts and ignores the generated models.ts barrel. It asserts the same three things:
-
the allow-list is closed under
$ref; -
no listed model is absent from the spec (catches typos and models removed upstream);
-
every listed model produced a class on disk.
It is wired into both PHP generation workflows ahead of the commit step, so an unclosed list cannot reach main — which is the publish point for dev-main consumers.
The TypeScript side needed the same wiring for a subtler reason. The compile error is real — dropping CourseDTO from the allow-list and regenerating leaves raceDTO.model.ts importing a ./courseDTO.model that was never written, and npm run build fails with TS2307: Cannot find module. But regenerate-on-spec-update.yaml regenerates with mvn clean compile and never runs npm run build, so it commits the broken client to main regardless; the error only surfaces afterwards in generate-api-client.yaml, reported as a publish failure naming one module at a time with no hint that the allow-list caused it. The check now gates the commit in the regeneration workflow and the build in the publish workflow.
One failure mode has no other cover in either client: a listed model that is not a component schema generates nothing, and no compiler can see it because nothing imports it. That is how PersonContractDTO and PersonIdentityDTO sat in the TypeScript allow-list until a manual audit removed them.
|
A static analyser (PHPStan) would also catch this, from the |
7.3. Adding a resource to a client
When extending apisToGenerate, do not hand-pick the models it needs. Add the obvious ones, then run the closure check with --fix and regenerate; it computes the transitive set. Listing a model that is not a component schema is harmless but dead — an inline enum, for example, generates as mixed in PHP, so names like OrderStatus in the list produce nothing.
8. 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.
9. 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 |
11. Change History
| Date | Change |
|---|---|
2026-04-24 |
Initial draft. Captures WS3 design for admin-portal greenfield; grounded in config scan of admin-service springdoc config. |
2026-08-25 |
Added Response Contracts: |
2026-08-12 |
Added Generated Client Model Closure. Documents the |