Files
asmhatreandClaude Sonnet 5 8d0efb0d4b Add openapi-versioning: springdoc-openapi 3.1.1 vs Spring Framework 7 API versioning
Companion module for "springdoc-openapi with Spring Boot 4.1: Generating,
Customising and Versioning Your API Spec". Reuses the ApiVersionConfigurer
setup from the versioning-mechanics companion project and tests what
springdoc-openapi 3.1.1 actually generates for a path with multiple
version-scoped handlers: a default oneOf-merged operation with an
arbitrary operationId, a working GroupedOpenApi + OpenApiCustomizer fix
that collapses it to one clean schema per version, and a check of the
officially-versioning-supported functional-endpoint path (springdoc
v3.0.2's "Add support for Spring Framework API Versioning with Functional
Endpoints"), which turns out to document only one of two registered
versions rather than either version separately.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M1uZDXWkY1MnTfQgt4HMeQ
2026-09-17 19:31:42 +00:00

3.3 KiB

The fix: one GroupedOpenApi per version

Full transcript: docs/output/05-grouped-openapi-fix.txt, from OpenApiVersioningTest#c_groupedOpenApiCustomizerCollapsesToOneCleanSchemaPerVersion.

VersionedOpenApiConfig registers three GroupedOpenApi beans — one per supported version — each scoped to /accounts/** and each carrying an OpenApiCustomizer that collapses the merged oneOf response from the previous chapter down to the one schema that version's clients actually receive, fixes operationId and summary to match, and drops the now-meaningless API-Version header:

@Bean
public GroupedOpenApi accountsV1Group() {
    return groupFor("accounts-v1", "1");
}

private GroupedOpenApi groupFor(String groupName, String version) {
    VersionSpec spec = SPECS.get(version);
    return GroupedOpenApi.builder()
            .group(groupName)
            .pathsToMatch("/accounts/**")
            .addOpenApiCustomizer(collapseToSingleVersion(spec))
            .build();
}

Each group is served at /v3/api-docs/{group} (springdoc's own convention — no extra wiring needed). Result, all three fetched from the same running instance:

GET /v3/api-docs/accounts-v1   -> operationId=getAccountV1,         schema=AccountV1,         params=[id]
GET /v3/api-docs/accounts-v1.1 -> operationId=getAccountV1Point1Plus, schema=AccountV1Point1,  params=[id]
GET /v3/api-docs/accounts-v2   -> operationId=getAccountV2,         schema=AccountV2,          params=[id]

Each is a clean, single-schema operation — feed any one of these three documents into openapi-generator and you get one method whose declared response type is actually what that version's clients receive, not a oneOf of three.

The default, ungrouped /v3/api-docs is untouched — confirmed by re-fetching it inside the same test, immediately after fetching all three groups, and it still shows the exact oneOf merge from the previous chapter. GroupedOpenApi's customizer runs against a document scoped to that group's own build, not the shared default document, so adding version-specific groups is additive: existing tooling pointed at the plain /v3/api-docs (or Swagger UI's default view) keeps working exactly as it did before, while /v3/api-docs/accounts-v2 becomes a new, additional, clean-per-version resource for anyone who wants one — a generated TypeScript client for your v2 mobile app, say, without also generating types for the v1 shape it will never receive.

See the previous chapter for why the version→schema mapping this customizer applies is a small hand-written Map, not something derived automatically from the running handler mappings — that's a real limitation of the public springdoc customizer API, not a shortcut taken for the sake of the demo.

Swagger UI

With three additional groups registered, /swagger-ui.html grows a group selector (default, accounts-v1, accounts-v1.1, accounts-v2) — switching between them re-fetches the corresponding /v3/api-docs/{group} document, so the same collapsed, single-schema view is what a human reading the UI sees too, not just API clients hitting the JSON directly.