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

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

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