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-docs Spring profile must be active — the default application config has springdoc.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’s form/ package (FormController, EventFormController, MembershipFormController, TestFormController) — those are internal domain wrappers, not HTTP endpoints; their HTTP surface is FormResource.

  • The spec includes /auth/** endpoints — but annotations there are sparse. Part of WS3 scope: add @Operation + @Tag to ExternalAuthResource methods 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-spec branch 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 (@ems/admin-service-client)

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).

5.4. X-Forwarded-Prefix

SpringDocConfiguration customises the server base URL based on X-Forwarded-Prefix. This works transparently when admin-service is proxied through a gateway; the spec’s servers entry reflects the prefix. No portal-side action needed.

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.

therapi-runtime-javadoc is not in the build. Springdoc can only read Javadoc when that annotation processor bakes it into the jar at compile time. Without it springdoc sees annotations only, and Javadoc is invisible to the spec.

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 403

6

Handlers carrying @PreAuthorize (so 403 is reachable)

85

The six are exactly the operations carrying @ApiResponsesEventParticipantResource (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. @ApiResponses does not merge with springdoc’s default, so omitting 200/201/204 publishes 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 500 is 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 — -Pdev,api-docs. Maven deactivates activeByDefault profiles as soon as any profile is named with -P, and admin-service declares failsafe’s version, includes and executions inside the dev profile’s pluginManagement. mvn verify -Papi-docs therefore binds no failsafe executions, runs zero integration tests, and still reports BUILD SUCCESS.

7. Generated Client Model Closure

Both generated clients restrict what they emit with an explicit allow-list in pom.xmlapisToGenerate 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:

modelsToGenerate must be closed under $ref. Every model reachable by reference from a listed model has to be listed as well.

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 @param/@return docblock types, and would cover the wider "references a class that does not exist" family rather than this one shape. It cannot see the reference in the generator’s type-map string, so the two checks complement rather than replace one another.

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.version in 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-diff against 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

admin-service/src/main/java/…​/config/SpringDocConfiguration.java

Spec customisation — server URL, contact, description

admin-service/src/main/java/…​/config/apidoc/ApplicationOpenApiCustomizer.java

Security scheme attachment

admin-service/src/main/resources/config/application.yml (springdoc section)

Profile gating, path config

admin-service/src/main/resources/config/application-api-docs.yml

api-docs profile activation

admin-service/pom.xml

springdoc-openapi-starter-webmvc-ui dependency

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: therapi-runtime-javadoc is absent, so Javadoc never reaches the spec and only springdoc annotations do; measured coverage across the 516 published operations; the @ApiResponses convention; how to verify a contract shipped; and the -P profile trap that makes mvn verify -Papi-docs run zero integration tests. Corrected two claims in Generated Client Model Closure — the closure script is only partly generator-agnostic, and the TypeScript regeneration workflow has no typecheck, so a broken client does reach main before the build catches it.

2026-08-12

Added Generated Client Model Closure. Documents the modelsToGenerate closure contract, why the same omission is a compile error in the TypeScript client but a silent production failure in the PHP one, and the static check that enforces it. Written up from the tourdeworcester.co.za order-sync incident.