Skip to main content

springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec

springdoc-openapi 3.1.1 silently merges same-path, different-version Framework 7 handlers into one oneOf operation with an arbitrary operationId. A working per-version fix with GroupedOpenApi and OpenApiCustomizer, and why the officially-versioning-supported functional-endpoint path documents fewer versions, not more.

A team I talked to this year did exactly what the Spring docs suggest: they added Framework 7’s built-in API versioning to a REST service, pointed springdoc-openapi at it because it was already in the project, and fed the resulting /v3/api-docs into openapi-generator to produce a TypeScript client for their web app. The generated client had one method for getAccount, typed as a union of every response shape the API had ever returned across every version β€” v1’s flat fields, v2’s nested contact object, all of it, undiscriminated. Nothing in the generated code said which shape you’d actually get back. It compiled. It shipped. It broke the first time someone’s code read .contact.email on a response that was actually shaped like v1. That’s not a springdoc bug in the crash-and-stack-trace sense β€” the spec it produced is valid OpenAPI 3.1. It’s a quieter problem: springdoc-openapi has no idea that Framework 7’s version attribute is a routing discriminant, so when several handlers share a path, it does the only thing it knows how to do with several handlers on one path β€” it merges them. I built a small Spring Boot 4.1 module to see exactly what that merge produces, whether it can be fixed without forking springdoc, and whether the “officially supported” functional-endpoint path springdoc’s own changelog advertises actually does better. Companion project: asmhatre/spring-boot-demo/openapi-versioning, where a 4-test suite hits a real running instance over real HTTP and every figure below is quoted from one of the 5 transcripts under docs/output/.
Versions. Spring Boot 4.1.1, Spring Framework 7.0.9, springdoc-openapi 3.1.1 β€” confirmed as the current release against Maven Central’s springdoc-openapi-starter-webmvc-ui/maven-metadata.xml (lastUpdated 2026‑09‑06), not copied from a docs page β€” JDK 25 (Temurin 25.0.4.1+1). The versioning setup reused below (header-based resolution, setDefaultVersion("1"), the three-handler AccountController shape) is identical to the one already verified in Spring Framework 7 API Versioning: The Complete Guide β€” this article takes those mechanics as given and asks a narrower question about what springdoc does on top of them.

What springdoc actually builds for a versioned path

Three handlers, one path, exactly the shape from the versioning-mechanics article:
@GetMapping(path = "/accounts/{id}", version = "1")
public AccountV1 getAccountV1(@PathVariable String id) { ... }

@GetMapping(path = "/accounts/{id}", version = "1.1+")
public AccountV1Point1 getAccountV1Point1Plus(@PathVariable String id) { ... }

@GetMapping(path = "/accounts/{id}", version = "2")
public AccountV2 getAccountV2(@PathVariable String id) { ... }
Full source: web/AccountController.java. Add nothing but the springdoc starter β€” no custom configuration β€” and hit the plain, ungrouped /v3/api-docs:
    "get" : {
        "tags" : [ "account-controller" ],
        "operationId" : "getAccountV2",
        "parameters" : [ {
          "name" : "id",
          "in" : "path",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "API-Version",
          "in" : "header",
          "schema" : {
            "type" : "string",
            "default" : "1.0.0",
            "enum" : [ "2", "1", "1.0.0", "1.1" ]
          }
        } ],
        "responses" : {
          "200" : {
            "description" : "OK",
            "content" : {
              "*/*" : {
                "schema" : {
                  "oneOf" : [ {
                    "$ref" : "#/components/schemas/AccountV1"
                  }, {
                    "$ref" : "#/components/schemas/AccountV2"
                  }, {
                    "$ref" : "#/components/schemas/AccountV1Point1"
                  } ]
                }
              }
            }
          }
        }
      }
From docs/output/02-what-springdoc-actually-generates.txt. Three handlers on one path, and springdoc does not know version is a routing discriminant the way it knows @PathVariable is β€” so it folds them into one Operation, the same folding it would do for any other overloaded mapping. The response schema becomes a oneOf across all three shapes, which is technically defensible β€” a client that sends no version header really could get any of the three back β€” but it’s unusable as version-specific documentation, because nothing in the schema says which shape maps to which version. operationId, which generated clients use as the method name, gets picked from whichever handler springdoc happened to process last β€” getAccountV2 here, arbitrarily from the reader’s side. That’s the generated-client bug from the opening story, reproduced directly: one method, unlabeled union of three shapes. One more thing worth naming, more cosmetic than the operation-merge but worth knowing if you’re staring at your own spec confused: the auto-documented header enum lists both "1" and "1.0.0" as if they were different values. They aren’t β€” "1.0.0" is just the semantic-version-normalised form of the default "1" configured via setDefaultVersion("1") β€” but springdoc surfaces the raw configured string and the normalised form side by side as if a caller could meaningfully choose between them.
version = “1” version = “1.1+” version = “2” same path: /accounts/{id} springdoc’soperation builderno version concept one merged operation operationId: getAccountV2 (arbitrary β€” whichever handler springdoc processed last) response: oneOf [ AccountV1, AccountV1Point1, AccountV2 ]

Customising it back into something usable

None of this is a crash, so the fix isn’t a patch to springdoc β€” it’s an OpenApiCustomizer that runs after springdoc has already built the document and rewrites it into something one version’s clients can actually use. springdoc’s GroupedOpenApi mechanism β€” already there for the ordinary case of splitting a large API into public/internal/admin documents β€” turns out to be exactly the right tool: one group per version, each scoped to the same paths, each carrying a customizer that collapses that version’s merged oneOf down to the one schema its clients actually receive.
@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();
}
Full source, including the collapse logic itself: config/VersionedOpenApiConfig.java. The customizer picks the matching schema out of the merged oneOf, fixes operationId and the summary to match, and drops the now-meaningless API-Version header β€” a version-scoped document has nothing left for a caller to choose. Each group is served at springdoc’s own /v3/api-docs/{group} convention, no extra routing needed:
GET /v3/api-docs/accounts-v1   -> operationId=getAccountV1, schema=#/components/schemas/AccountV1, params=[id]
GET /v3/api-docs/accounts-v1.1 -> operationId=getAccountV1Point1Plus, schema=#/components/schemas/AccountV1Point1, params=[id]
GET /v3/api-docs/accounts-v2   -> operationId=getAccountV2, schema=#/components/schemas/AccountV2, params=[id]
From docs/output/05-grouped-openapi-fix.txt, where the test also re-fetches the plain, ungrouped /v3/api-docs immediately afterward and confirms it’s untouched β€” still the same three-way oneOf from the section above. Adding version-specific groups is additive: existing tooling pointed at the default document, or the default view in Swagger UI, keeps working exactly as it did; /v3/api-docs/accounts-v2 becomes a new, additional resource for anyone who wants a clean one β€” generating a TypeScript client for a v2-only mobile app, say, without also generating types for a v1 shape it will never receive.
Why the versionβ†’schema map is a hand-written Map, not derived automatically: OpenApiCustomizer receives only the fully-built OpenAPI document β€” by the time it runs, springdoc has already folded the three handlers into one operation, so the association between “this handler” and “this version string” is already gone. There’s no supported springdoc extension point that intercepts before that fold. Reflecting Spring’s own RequestMappingInfo back out would work at the Spring MVC layer, but matching it back to springdoc’s internal notion of “this operation” means coupling to springdoc implementation details that can silently break on the next minor version. Three lines of boilerplate per version, keyed by the same version strings already in your @GetMapping annotations, is the more durable trade. Full reasoning: docs/03-why-a-hand-maintained-map.md.

The “officially supported” path turns out to be worse

springdoc’s own v3.0.2 release notes list, under Added: “#3229 – Add support for Spring Framework API Versioning with Functional Endpoints.” Read on its own, that’s a reasonable signal that functional (RouterFunction) endpoints are the better-documented path for a versioned API in springdoc β€” official, named support, versus the silent merge above that nothing documents. I built the functional-endpoint equivalent to check, and it isn’t.
@Bean
@RouterOperations({
    @RouterOperation(path = "/widgets/{id}", method = GET, beanClass = WidgetHandler.class, beanMethod = "getWidgetV1"),
    @RouterOperation(path = "/widgets/{id}", method = GET, beanClass = WidgetHandler.class, beanMethod = "getWidgetV2")
})
public RouterFunction<ServerResponse> widgetRoutes(WidgetHandler handler) {
    return RouterFunctions.route()
            .GET("/widgets/{id}", RequestPredicates.version("1"), handler::getWidgetV1)
            .GET("/widgets/{id}", RequestPredicates.version("2"), handler::getWidgetV2)
            .build();
}
Full source: functional/WidgetRouterConfig.java. Worth checking before running anything at all: @RouterOperation itself has no version attribute β€” path, method, consumes, produces, headers, params, beanClass, beanMethod, parameterTypes, operation, and nothing else. Whatever #3229 added, it did not add a way to tell springdoc which version a given @RouterOperation documents. The runtime routing is correct β€” Framework 7’s own job, not springdoc’s:
  GET /widgets/7, API-Version: 1 -> 200
{"apiVersion":"1","id":"7"}
  GET /widgets/7, API-Version: 2 -> 200
{"apiVersion":"2","widgetId":"7"}
The documentation for that same path, with two @RouterOperation entries registered:
{
  "tags" : [ "widget-handler" ],
  "operationId" : "getWidgetV1",
  "responses" : {
    "200" : {
      "description" : "OK",
      "content" : {
        "*/*" : {
          "schema" : {
            "$ref" : "#/components/schemas/ServerResponse"
          }
        }
      }
    }
  }
}
Both from docs/output/06-functional-endpoint-path.txt. Two entries went in; one operation came out β€” not merged into a oneOf the way the annotated handlers were, just silently dropped. In this build it was consistently the first-declared entry that survived across repeated test runs; I didn’t chase the exact selection rule, because it doesn’t change the practical conclusion β€” springdoc doesn’t attempt to represent both versions of a functional endpoint at all, which is a strictly worse outcome than the annotated-controller oneOf merge, not a fix for it. The response schema falling back to a near-empty ServerResponse component, instead of either handler’s real JSON shape, is a separate pre-existing springdoc limitation of functional endpoints generally β€” without an explicit operation() override, springdoc introspects the handler method’s own return type, which for a functional endpoint is always ServerResponse regardless of what body you construct inside it. My best reading of what #3229 actually fixed, cross-referenced against that same release’s “#3232 – Gracefully handle springdoc endpoint paths during API version resolution”: Spring’s request-mapping introspection previously had no case for a version(...) predicate node in a functional route’s predicate tree, which made springdoc’s own endpoints trip over API version resolution once versioning was enabled at all. #3229 most plausibly taught springdoc’s router-visiting code to walk past that predicate node without erroring, not to generate a separate operation per version. I can’t fully confirm that from the outside β€” the PR’s diff isn’t part of the release notes text β€” so take it as my best inference, not a confirmed fact; the observed behaviour itself is directly reproduced above and isn’t in question. If you need real per-version OpenAPI documentation today, the GroupedOpenApi customizer from the previous section is the approach that actually works β€” not the functional-endpoint path, whatever that changelog line might suggest on its own.

Two related issues worth knowing about, checked rather than assumed

The obvious next question for anyone hitting this in a real project is “is this a known springdoc issue?” β€” checked directly against the tracker rather than answered from memory, since a stale answer here would be worse than no answer. #2975, the original “add Framework 7 versioning support” request opened April 2025, is closed as of this writing; I couldn’t retrieve a linked resolving PR or maintainer comment through the tools available for this research, so I can confirm it’s closed without confirming why. What I can confirm independently, by actually running the code: springdoc 3.1.1 does not generate separate specs per version on its own β€” the oneOf merge above is exactly the gap that issue asked about, and the GroupedOpenApi workaround is something you build yourself, not built-in behaviour that shipped in response to it. Separately, #3354 is an open issue describing /v3/api-docs returning HTTP 500 when a default API version is absent from an operation’s @Schema(allowableValues = ...) enum β€” confirmed by its author as still present in 3.1.0, 3.1.1, and main as of September 2026. I tried to reproduce it here with the closest natural trigger (setDefaultVersion("1") combined with both an explicit supported-versions list and detectSupportedVersions(true)) and got a normal 200 both times β€” the issue’s description points at an operation carrying its own explicit allowableValues annotation that excludes the default, which is a narrower setup than anything in this module’s controller. Worth knowing if you’re debugging your own 500 from a versioned /v3/api-docs, not something this project reproduces first-hand.

What to actually do

If you’re adding springdoc-openapi to an API that already uses Framework 7’s version attribute, don’t trust the plain /v3/api-docs for anything version-specific β€” check whether any of your paths carry more than one version-scoped handler, and if they do, expect a oneOf merge with an arbitrary operationId, not an error, which is exactly the kind of thing that passes code review silently.

If you need a clean, per-version spec β€” for a generated client, for a partner integration, for anything where “which shape do I actually get” matters β€” add one GroupedOpenApi per version with an OpenApiCustomizer that collapses the merge, keyed by a small hand-maintained versionβ†’schema map. It’s a working, additive fix that leaves your existing default document untouched.

Don’t reach for functional endpoints expecting better version-aware documentation just because a springdoc changelog entry name-checks “API Versioning” β€” as tested here, it currently documents fewer of your versions, not more, regardless of what the mechanism sounds like it should do.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.