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
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# openapi-versioning
|
||||
|
||||
Companion code for **springdoc-openapi with Spring Boot 4.1: Generating, Customising and
|
||||
Versioning Your API Spec** on [ankurm.com](https://ankurm.com).
|
||||
|
||||
Reuses the exact `ApiVersionConfigurer` setup from the companion project for [Spring Framework 7
|
||||
API Versioning: The Complete Guide](https://ankurm.com/spring-framework-7-api-versioning-guide/)
|
||||
and asks one question on top of it: once springdoc-openapi 3.1.1 is added, what does it actually
|
||||
generate for a path with multiple `version`-scoped handlers — and how do you get a clean,
|
||||
per-version spec out of it?
|
||||
|
||||
**Tested with:** Spring Boot 4.1.1 / Spring Framework 7.0.9 / springdoc-openapi 3.1.1 / JDK 25
|
||||
(Temurin 25.0.4.1+1).
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
mvn test # runs everything, regenerates docs/output/
|
||||
mvn spring-boot:run # then, in another shell:
|
||||
curl http://localhost:8080/v3/api-docs # the merged oneOf (default)
|
||||
curl http://localhost:8080/v3/api-docs/accounts-v2 # the collapsed, clean v2-only spec
|
||||
open http://localhost:8080/swagger-ui.html # group selector: default / accounts-v1 / accounts-v1.1 / accounts-v2
|
||||
```
|
||||
|
||||
## Where things are
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Versions and setup | [docs/01-versions-and-setup.md](docs/01-versions-and-setup.md) |
|
||||
| What springdoc actually generates for a versioned path | [docs/02-what-springdoc-actually-generates.md](docs/02-what-springdoc-actually-generates.md) |
|
||||
| Why the fix's version→schema map is hand-maintained | [docs/03-why-a-hand-maintained-map.md](docs/03-why-a-hand-maintained-map.md) |
|
||||
| The functional-endpoint path: officially supported, and worse | [docs/04-the-functional-endpoint-path.md](docs/04-the-functional-endpoint-path.md) |
|
||||
| The fix: one GroupedOpenApi per version | [docs/05-the-grouped-openapi-fix.md](docs/05-the-grouped-openapi-fix.md) |
|
||||
| Known issues, checked directly | [docs/06-known-issues.md](docs/06-known-issues.md) |
|
||||
|
||||
## Captured output
|
||||
|
||||
Every figure quoted in the article is one of these files, regenerated by `mvn test`:
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| [docs/output/00-versions.txt](docs/output/00-versions.txt) | Exact resolved versions, including Maven Central metadata for springdoc 3.1.1 |
|
||||
| [docs/output/01-versioned-runtime-behaviour.txt](docs/output/01-versioned-runtime-behaviour.txt) | The three `/accounts/{id}` versions serving their real, distinct shapes |
|
||||
| [docs/output/02-what-springdoc-actually-generates.txt](docs/output/02-what-springdoc-actually-generates.txt) | The default `/v3/api-docs` merging all three versions into one `oneOf` operation |
|
||||
| [docs/output/05-grouped-openapi-fix.txt](docs/output/05-grouped-openapi-fix.txt) | Three `GroupedOpenApi` groups, each collapsed to one clean schema |
|
||||
| [docs/output/06-functional-endpoint-path.txt](docs/output/06-functional-endpoint-path.txt) | The officially-versioning-supported functional-endpoint path silently documenting only one of two registered versions |
|
||||
|
||||
## Source layout
|
||||
|
||||
- `web/` — the annotated `@GetMapping(version = ...)` `AccountController` and its three response records.
|
||||
- `functional/` — the `RouterFunction`-based `WidgetHandler`/`WidgetRouterConfig`, Framework 7's officially-versioning-supported alternative.
|
||||
- `config/WebConfig.java` — the `ApiVersionConfigurer` setup, unchanged from the versioning-mechanics companion project.
|
||||
- `config/VersionedOpenApiConfig.java` — the three per-version `GroupedOpenApi` beans and the `OpenApiCustomizer` that makes them clean.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Versions and setup
|
||||
|
||||
| Component | Version | Source of truth |
|
||||
|---|---|---|
|
||||
| Spring Boot | 4.1.1 | `pom.xml` parent |
|
||||
| Spring Framework | 7.0.9 | resolved transitively by the Boot 4.1.1 BOM |
|
||||
| springdoc-openapi | 3.1.1 | `pom.xml` property `springdoc.version`; confirmed as the current `<latest>`/`<release>` against Maven Central's `springdoc-openapi-starter-webmvc-ui/maven-metadata.xml` (`lastUpdated` 2026-09-06T16:37:40Z) |
|
||||
| JDK | 25 (Temurin 25.0.4.1+1) | `java -version` on the build host |
|
||||
|
||||
Single dependency beyond the web starter:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>3.1.1</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
No other springdoc configuration is required to get `/v3/api-docs` and `/swagger-ui.html` — both
|
||||
are enabled by default (springdoc logs a `WARN` about this at startup, pointing at
|
||||
`springdoc.api-docs.enabled=false` / `springdoc.swagger-ui.enabled=false` for production).
|
||||
|
||||
## The versioning setup this module builds on
|
||||
|
||||
[`WebConfig`](../src/main/java/com/ankurm/openapiversioning/config/WebConfig.java) is the *exact*
|
||||
`ApiVersionConfigurer` setup from the companion project for [Spring Framework 7 API
|
||||
Versioning: The Complete Guide](https://ankurm.com/spring-framework-7-api-versioning-guide/):
|
||||
header-based resolution (`API-Version`), `setVersionRequired(false)`, `setDefaultVersion("1")`.
|
||||
That article already verified the versioning *mechanics* — baseline handlers, the
|
||||
supported-versions allow-list, what an unversioned request resolves to. This module deliberately
|
||||
does not re-litigate any of that; it takes the mechanics as given and asks a narrower question:
|
||||
once you add springdoc-openapi on top, what actually ends up in the generated spec?
|
||||
|
||||
[`AccountController`](../src/main/java/com/ankurm/openapiversioning/web/AccountController.java)
|
||||
mirrors that article's three-handler shape (`version = "1"`, `"1.1+"`, `"2"`), with one addition:
|
||||
an `AccountStatus` enum on the v2 response, so the OpenAPI output actually exercises enum
|
||||
handling, not just flat string fields.
|
||||
@@ -0,0 +1,74 @@
|
||||
# What springdoc actually generates for a versioned path
|
||||
|
||||
Full transcript: [docs/output/02-what-springdoc-actually-generates.txt](output/02-what-springdoc-actually-generates.txt),
|
||||
produced by `OpenApiVersioningTest#b_defaultApiDocsMergesAllVersionsIntoOneOf`.
|
||||
|
||||
Hit the plain, ungrouped `/v3/api-docs` on this module — no custom configuration beyond adding
|
||||
the starter — and here is the entire operation springdoc built for `GET /accounts/{id}`, which
|
||||
has three handlers mapped to it (`version = "1"`, `"1.1+"`, `"2"`):
|
||||
|
||||
```json
|
||||
{
|
||||
"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": {
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/components/schemas/AccountV1" },
|
||||
{ "$ref": "#/components/schemas/AccountV2" },
|
||||
{ "$ref": "#/components/schemas/AccountV1Point1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Three things worth naming precisely, since none of them are documented behaviour as far as I
|
||||
could find — this is what running it produced, not what any springdoc doc page promises:
|
||||
|
||||
**One operation, not three.** springdoc does not know that `version` is a routing discriminant
|
||||
the way it knows `@PathVariable` or `@RequestParam` are. As far as its operation-building code is
|
||||
concerned, three `HandlerMethod`s map to the same path and HTTP method, so it folds them into one
|
||||
`Operation` — the same folding it would do for, say, an overloaded controller method disambiguated
|
||||
by a `headers = "X-Foo"` condition.
|
||||
|
||||
**The response schema becomes `oneOf` all three shapes, with no discriminator.** This is
|
||||
technically *not wrong* — a client that doesn't send `API-Version` really could get any of the
|
||||
three shapes back, so `oneOf` is a defensible schema for "the operation's response, considered
|
||||
without reference to versioning." But it is unusable as version-specific documentation: nothing in
|
||||
this schema says which shape maps to which version.
|
||||
|
||||
**`operationId` is picked from whichever handler springdoc processes last, arbitrarily from the
|
||||
reader's perspective.** It happened to be `getAccountV2` here. Generated API clients
|
||||
(openapi-generator, openapi-typescript, etc.) use `operationId` as the method name they generate —
|
||||
so a client generated from this spec gets one method, named after one version, that claims to
|
||||
return `oneOf` three different shapes.
|
||||
|
||||
**The auto-documented `API-Version` header enum is also slightly wrong on its own terms**: it
|
||||
lists `"1"` and `"1.0.0"` as if they were two different values, when `"1.0.0"` is just the
|
||||
semantic-version-normalised form of the same default `"1"` configured in `WebConfig`
|
||||
(`setDefaultVersion("1")`). Framework 7's version parser is doing exactly what it's supposed to —
|
||||
`1` and `1.0.0` really do compare equal — but springdoc surfaces both the raw configured string and
|
||||
the normalised form as if a caller could sensibly choose between them.
|
||||
|
||||
None of this is a crash — the document is valid OpenAPI 3.1, it just isn't the document most teams
|
||||
actually want from a versioned API. [The next chapter](05-the-grouped-openapi-fix.md) is a working
|
||||
fix for the first two problems; the header-enum oddity is cosmetic enough that I didn't chase it
|
||||
further, and is called out in [06-known-issues.md](06-known-issues.md) instead.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Why the version→schema map in the fix is hand-maintained
|
||||
|
||||
[`VersionedOpenApiConfig`](../src/main/java/com/ankurm/openapiversioning/config/VersionedOpenApiConfig.java)
|
||||
keeps a small, explicit `Map<String, VersionSpec>` from version string to schema name /
|
||||
operationId / summary, rather than deriving that mapping automatically from the running
|
||||
application. This is a deliberate limitation, not an oversight, and it's worth being honest about
|
||||
why: an `OpenApiCustomizer` (`org.springdoc.core.customizers.OpenApiCustomizer`) receives exactly
|
||||
one argument, the fully-built `io.swagger.v3.oas.models.OpenAPI` document — see
|
||||
`javap org/springdoc/core/customizers/OpenApiCustomizer.class`:
|
||||
|
||||
```
|
||||
public interface org.springdoc.core.customizers.OpenApiCustomizer {
|
||||
public abstract void customise(io.swagger.v3.oas.models.OpenAPI);
|
||||
}
|
||||
```
|
||||
|
||||
By the time a customizer runs, springdoc has already folded the three `HandlerMethod`s into one
|
||||
merged `Operation` (see the previous chapter) — the association between "this specific handler
|
||||
method" and "this specific version string" has already been discarded. An `OperationCustomizer`
|
||||
(`org.springdoc.core.customizers.OperationCustomizer`) does get a `HandlerMethod` argument per
|
||||
call, which looks more promising, but it still runs once per merged operation, not once per
|
||||
original handler — it cannot see the three original mappings separately either, because springdoc
|
||||
folds first and customizes second. There is no supported extension point in 3.1.1 that intercepts
|
||||
before the fold.
|
||||
|
||||
Reflectively pulling the `version` condition back out of Spring's own `RequestMappingInfo` (via
|
||||
`RequestMappingHandlerMapping.getHandlerMethods()`) would work at the Spring MVC layer — Framework
|
||||
7 exposes it there in principle — but wiring that into springdoc's generation pipeline means
|
||||
matching springdoc's internal notion of "this operation" back to Spring's `RequestMappingInfo` by
|
||||
path and method, which is exactly the kind of implementation-detail-coupled reflection that breaks
|
||||
silently on the next springdoc minor version. A hand-maintained map keyed by the version strings
|
||||
you already wrote in your own `@GetMapping(version = ...)` annotations is three lines of
|
||||
boilerplate per version and it does not break when springdoc changes how it folds operations
|
||||
internally.
|
||||
@@ -0,0 +1,99 @@
|
||||
# The functional-endpoint path: officially supported, and worse
|
||||
|
||||
springdoc-openapi's [v3.0.2 release notes](https://github.com/springdoc/springdoc-openapi/releases/tag/v3.0.2)
|
||||
(28 Feb 2026) list, under **Added**: "#3229 – Add support for Spring Framework API Versioning with
|
||||
Functional Endpoints." Reading that changelog line on its own, it's reasonable to expect functional
|
||||
(`RouterFunction`) endpoints to be the *better*-supported path for versioned APIs in springdoc —
|
||||
official, named support, versus the annotated-controller behaviour in the previous chapter that
|
||||
nothing documents. I built [`WidgetRouterConfig`](../src/main/java/com/ankurm/openapiversioning/functional/WidgetRouterConfig.java)
|
||||
to check.
|
||||
|
||||
Full transcript: [docs/output/06-functional-endpoint-path.txt](output/06-functional-endpoint-path.txt),
|
||||
from `OpenApiVersioningTest#d_functionalEndpointVersioningRuntimeWorksButDocsDoNot`.
|
||||
|
||||
## The setup
|
||||
|
||||
Two handler methods, one `RouterFunction` with Framework 7's `RequestPredicates.version(...)`
|
||||
doing the routing, and springdoc's `@RouterOperations`/`@RouterOperation` — the mechanism
|
||||
functional endpoints have always needed for OpenAPI documentation, since there's no
|
||||
`@GetMapping` for springdoc to reflect on:
|
||||
|
||||
```java
|
||||
@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();
|
||||
}
|
||||
```
|
||||
|
||||
First thing worth checking before running anything: `@RouterOperation` itself has **no version
|
||||
attribute** (`javap org/springdoc/core/annotations/RouterOperation.class` — `path`, `method`,
|
||||
`consumes`, `produces`, `headers`, `params`, `beanClass`, `beanMethod`, `parameterTypes`,
|
||||
`operation`; nothing else). Whatever PR #3229 added, it did not add a way to tell springdoc which
|
||||
version a given `@RouterOperation` documents.
|
||||
|
||||
## Runtime: correct
|
||||
|
||||
```
|
||||
GET /widgets/7, API-Version: 1 -> {"id":"7","apiVersion":"1"}
|
||||
GET /widgets/7, API-Version: 2 -> {"widgetId":"7","apiVersion":"2"}
|
||||
```
|
||||
|
||||
Framework 7's `version(...)` `RequestPredicate` routes correctly — no surprise there, that's
|
||||
Framework's job, not springdoc's, and it was already verified independently in the companion
|
||||
project for the versioning-mechanics article.
|
||||
|
||||
## Documentation: worse than the annotated-controller case
|
||||
|
||||
```json
|
||||
{
|
||||
"tags": ["widget-handler"],
|
||||
"operationId": "getWidgetV1",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": { "*/*": { "schema": { "$ref": "#/components/schemas/ServerResponse" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two registered `@RouterOperation`s went in; **one operation came out**, silently — not merged into
|
||||
a `oneOf` the way the annotated `@GetMapping` handlers were in the previous chapter, just dropped.
|
||||
In this build it was consistently the first-declared entry (`getWidgetV1`) that survived across
|
||||
repeated `mvn test` runs; I did not chase the exact selection rule (declaration order in the
|
||||
`@RouterOperations` array vs. `RouterFunction` registration order) because it doesn't change the
|
||||
practical takeaway — springdoc does not attempt to represent both versions of a functional
|
||||
endpoint at all, which is a strictly worse outcome for a reader than the annotated-controller
|
||||
`oneOf` merge, not a fix for it.
|
||||
|
||||
The response schema resolving to a near-empty `ServerResponse` component (rather than either
|
||||
handler's actual JSON shape) is a separate, pre-existing springdoc limitation of functional
|
||||
endpoints in general: `@RouterOperation.operation()` lets you attach a full `@Operation`
|
||||
annotation with an explicit response type, and without it springdoc falls back to introspecting
|
||||
the handler method's own return type — which for a functional endpoint is always `ServerResponse`,
|
||||
not whatever body you happen to construct inside it. That fallback isn't about versioning at all;
|
||||
it would happen on an unversioned functional endpoint too.
|
||||
|
||||
**My best reading of what #3229 actually fixed**, based on this and on the same release's fix
|
||||
entry "#3232 – Gracefully handle springdoc endpoint paths during API version resolution": Spring's
|
||||
own 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 (`/v3/api-docs`,
|
||||
`/swagger-ui.html`, etc.) trip over API version resolution once versioning was enabled at all,
|
||||
functional endpoints or not. #3229 most plausibly taught springdoc's router-visiting code to walk
|
||||
past a `version(...)` predicate node without erroring — not to generate a separate operation per
|
||||
version. I can't fully confirm that reading from the outside (the PR's own diff isn't part of the
|
||||
release notes text), so treat it as my best inference from the observed behaviour rather than a
|
||||
confirmed fact — but the observed behaviour itself (one operation survives, not versioned
|
||||
separately) is directly reproduced above and is not in question.
|
||||
|
||||
**Practical takeaway: if you need real per-version OpenAPI documentation today, the annotated
|
||||
`@GetMapping` + `GroupedOpenApi` customizer approach in the next chapter is the one that actually
|
||||
works.** The functional-endpoint path is not currently a better-documented alternative for
|
||||
versioned APIs, whatever "Add support for ... Functional Endpoints" in the changelog might suggest
|
||||
on its own.
|
||||
@@ -0,0 +1,60 @@
|
||||
# The fix: one GroupedOpenApi per version
|
||||
|
||||
Full transcript: [docs/output/05-grouped-openapi-fix.txt](output/05-grouped-openapi-fix.txt), from
|
||||
`OpenApiVersioningTest#c_groupedOpenApiCustomizerCollapsesToOneCleanSchemaPerVersion`.
|
||||
|
||||
[`VersionedOpenApiConfig`](../src/main/java/com/ankurm/openapiversioning/config/VersionedOpenApiConfig.java)
|
||||
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:
|
||||
|
||||
```java
|
||||
@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](03-why-a-hand-maintained-map.md) 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Known issues, checked directly rather than assumed
|
||||
|
||||
Three springdoc-openapi GitHub issues are relevant enough to this module's topic to be worth
|
||||
listing — checked directly against the live issue tracker rather than repeated from memory, and
|
||||
reported here with exactly the confidence the check supports, not more.
|
||||
|
||||
## #2975 — "Spring Framework 7 - API versioning support"
|
||||
|
||||
Opened 28 April 2025, requesting that springdoc generate separate OpenAPI specs per API version
|
||||
once Framework 7 shipped its versioning support. **Status: closed**, as of this module's research
|
||||
(September 2026). I could not retrieve a linked resolving PR or a maintainer closing comment
|
||||
through the tools available to this project — so treat "closed" as confirmed and the *reason* it
|
||||
was closed as unconfirmed. What this module confirms independently, by actually running the code,
|
||||
is the substantive question the issue asked about: springdoc 3.1.1 does not generate separate
|
||||
specs per version on its own (see [chapter 2](02-what-springdoc-actually-generates.md)); the
|
||||
`GroupedOpenApi` approach in [chapter 5](05-the-grouped-openapi-fix.md) is a workaround you build
|
||||
yourself, not built-in behaviour that shipped in response to this issue.
|
||||
|
||||
## #3354 — "OpenAPI generation fails when MVC API version default is absent from @Schema allowableValues"
|
||||
|
||||
Open as of September 2026, reported against springdoc-openapi 3.0.3 and confirmed by its author
|
||||
still present in 3.1.0, 3.1.1, and the `main` branch. Described root cause: `AbstractRequestService.getHeaders()`
|
||||
attempts to add the configured default API version into an operation's `@Schema(allowableValues =
|
||||
...)` enum when that operation doesn't already support the default version, and that enum can be
|
||||
an immutable list, producing `UnsupportedOperationException` → HTTP 500 from `/v3/api-docs`.
|
||||
|
||||
**I did not reproduce this specific crash in this module.** I tried the closest natural trigger —
|
||||
`setDefaultVersion("1")` combined with both an explicit `addSupportedVersions(...)` list and
|
||||
`detectSupportedVersions(true)` — and in both cases `/v3/api-docs` returned a normal 200 (see
|
||||
[chapter 2](02-what-springdoc-actually-generates.md)'s transcript). The issue's description points
|
||||
at an operation carrying its own explicit `@Schema(allowableValues = ...)` that doesn't include the
|
||||
default version, which is a narrower and more specific setup than anything in this module's
|
||||
`AccountController`. I'm listing it here as a real, open, citable issue worth knowing about if
|
||||
you're debugging a 500 from your own `/v3/api-docs` with versioning enabled — not as something
|
||||
this module demonstrates first-hand.
|
||||
|
||||
## #3163 — "HTTP 400 with Spring Boot 4 API versioning enabled"
|
||||
|
||||
Closed and labelled `invalid` — the reporter saw `/swagger-ui.html` and `/v3/api-docs` return 400
|
||||
with API versioning properties set via `spring.mvc.apiversion.*`. Listed here mainly so it doesn't
|
||||
get confused with #3354 above: this one was determined by maintainers not to be a genuine
|
||||
springdoc bug (most likely a request routing/configuration issue on the reporter's side, based on
|
||||
the `invalid` label), separate from the still-open schema-enum crash in #3354.
|
||||
@@ -0,0 +1,2 @@
|
||||
Spring Boot 4.1.1 / Spring Framework 7.0.9 / springdoc-openapi 3.1.1 / JDK 25 (Temurin 25.0.4.1+1)
|
||||
springdoc-openapi-starter-webmvc-ui:3.1.1, resolved from Maven Central maven-metadata.xml (latest=release=3.1.1, lastUpdated 2026-09-06T16:37:40Z).
|
||||
@@ -0,0 +1,11 @@
|
||||
GET /accounts/42, API-Version: 1
|
||||
200
|
||||
{"id":"42","name":"Ankur Mhatre","apiVersion":"1"}
|
||||
|
||||
GET /accounts/42, API-Version: 1.1
|
||||
200
|
||||
{"id":"42","name":"Ankur Mhatre","email":"[email protected]","apiVersion":"1.1+"}
|
||||
|
||||
GET /accounts/42, API-Version: 2
|
||||
200
|
||||
{"accountId":"42","displayName":"Ankur Mhatre","contact":{"email":"[email protected]"},"status":"ACTIVE","apiVersion":"2"}
|
||||
@@ -0,0 +1,160 @@
|
||||
GET /v3/api-docs (ungrouped, default), operation at /accounts/{id}:
|
||||
operationId = getAccountV2 (picked from ONE of the three handlers, not versioned itself)
|
||||
response schema = oneOf [3 entries]: #/components/schemas/AccountV1, #/components/schemas/AccountV2, #/components/schemas/AccountV1Point1
|
||||
API-Version header enum (auto-documented) = ["2","1","1.0.0","1.1"]
|
||||
API-Version header default = "1.0.0"
|
||||
|
||||
Full raw document:
|
||||
{
|
||||
"openapi" : "3.1.0",
|
||||
"info" : {
|
||||
"title" : "OpenAPI definition",
|
||||
"version" : "v0"
|
||||
},
|
||||
"servers" : [ {
|
||||
"url" : "http://localhost:36991",
|
||||
"description" : "Generated server url"
|
||||
} ],
|
||||
"paths" : {
|
||||
"/accounts/{id}" : {
|
||||
"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"
|
||||
} ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/widgets/{id}" : {
|
||||
"get" : {
|
||||
"tags" : [ "widget-handler" ],
|
||||
"operationId" : "getWidgetV1",
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "OK",
|
||||
"content" : {
|
||||
"*/*" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/ServerResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components" : {
|
||||
"schemas" : {
|
||||
"AccountV2" : {
|
||||
"type" : "object",
|
||||
"description" : "Account shape served by API version 2 — restructured, nested contact object",
|
||||
"properties" : {
|
||||
"accountId" : {
|
||||
"type" : "string",
|
||||
"description" : "Account identifier"
|
||||
},
|
||||
"displayName" : {
|
||||
"type" : "string",
|
||||
"description" : "Account holder display name"
|
||||
},
|
||||
"contact" : {
|
||||
"$ref" : "#/components/schemas/Contact",
|
||||
"description" : "Nested contact details, replacing the flat email field from 1.1"
|
||||
},
|
||||
"status" : {
|
||||
"type" : "string",
|
||||
"description" : "Lifecycle status, new in v2",
|
||||
"enum" : [ "ACTIVE", "SUSPENDED", "CLOSED" ]
|
||||
},
|
||||
"apiVersion" : {
|
||||
"type" : "string",
|
||||
"description" : "Echoes the resolved API version"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Contact" : {
|
||||
"type" : "object",
|
||||
"description" : "Contact details nested under an account, v2+",
|
||||
"properties" : {
|
||||
"email" : {
|
||||
"type" : "string",
|
||||
"description" : "Contact email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AccountV1" : {
|
||||
"type" : "object",
|
||||
"description" : "Account shape served by API version 1",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"type" : "string",
|
||||
"description" : "Account identifier"
|
||||
},
|
||||
"name" : {
|
||||
"type" : "string",
|
||||
"description" : "Account holder name"
|
||||
},
|
||||
"apiVersion" : {
|
||||
"type" : "string",
|
||||
"description" : "Echoes the resolved API version"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AccountV1Point1" : {
|
||||
"type" : "object",
|
||||
"description" : "Account shape served from API version 1.1 onward (baseline handler)",
|
||||
"properties" : {
|
||||
"id" : {
|
||||
"type" : "string",
|
||||
"description" : "Account identifier"
|
||||
},
|
||||
"name" : {
|
||||
"type" : "string",
|
||||
"description" : "Account holder name"
|
||||
},
|
||||
"email" : {
|
||||
"type" : "string",
|
||||
"description" : "Contact email, added in 1.1"
|
||||
},
|
||||
"apiVersion" : {
|
||||
"type" : "string",
|
||||
"description" : "Echoes the resolved API version"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ServerResponse" : { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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]
|
||||
|
||||
Each group's VersionedOpenApiConfig#collapseToSingleVersion customizer rewrites the same
|
||||
merged oneOf operation shown in 02-what-springdoc-actually-generates.txt down to exactly
|
||||
one schema and drops the now-meaningless API-Version header — the default (ungrouped)
|
||||
/v3/api-docs document is completely unaffected, confirmed by re-fetching it below:
|
||||
|
||||
200
|
||||
{"openapi":"3.1.0","info":{"title":"OpenAPI definition","version":"v0"},"servers":[{"url":"http://localhost:36991","description":"Generated server url"}],"paths":{"/accounts/{id}":{"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"}]}}}}}}},"/widgets/{id}":{"get":{"tags":["widget-handler"],"operationId":"getWidgetV1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ServerResponse"}}}}}}}},"components":{"schemas":{"AccountV2":{"type":"object","description":"Account shape served by API version 2 — restructured, nested contact object","properties":{"accountId":{"type":"string","description":"Account identifier"},"displayName":{"type":"string","description":"Account holder display name"},"contact":{"$ref":"#/components/schemas/Contact","description":"Nested contact details, replacing the flat email field from 1.1"},"status":{"type":"string","description":"Lifecycle status, new in v2","enum":["ACTIVE","SUSPENDED","CLOSED"]},"apiVersion":{"type":"string","description":"Echoes the resolved API version"}}},"Contact":{"type":"object","description":"Contact details nested under an account, v2+","properties":{"email":{"type":"string","description":"Contact email"}}},"AccountV1":{"type":"object","description":"Account shape served by API version 1","properties":{"id":{"type":"string","description":"Account identifier"},"name":{"type":"string","description":"Account holder name"},"apiVersion":{"type":"string","description":"Echoes the resolved API version"}}},"AccountV1Point1":{"type":"object","description":"Account shape served from API version 1.1 onward (baseline handler)","properties":{"id":{"type":"string","description":"Account identifier"},"name":{"type":"string","description":"Account holder name"},"email":{"type":"string","description":"Contact email, added in 1.1"},"apiVersion":{"type":"string","description":"Echoes the resolved API version"}}},"ServerResponse":{}}}}
|
||||
@@ -0,0 +1,29 @@
|
||||
Runtime (Framework 7 RequestPredicates.version(...), routing is correct):
|
||||
GET /widgets/7, API-Version: 1 -> 200
|
||||
{"apiVersion":"1","id":"7"}
|
||||
GET /widgets/7, API-Version: 2 -> 200
|
||||
{"apiVersion":"2","widgetId":"7"}
|
||||
|
||||
springdoc documentation for the SAME path (2 @RouterOperation entries registered,
|
||||
one per version, via @RouterOperations on the RouterFunction bean):
|
||||
{
|
||||
"tags" : [ "widget-handler" ],
|
||||
"operationId" : "getWidgetV1",
|
||||
"responses" : {
|
||||
"200" : {
|
||||
"description" : "OK",
|
||||
"content" : {
|
||||
"*/*" : {
|
||||
"schema" : {
|
||||
"$ref" : "#/components/schemas/ServerResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Only ONE @RouterOperation survived (operationId=getWidgetV1) — the second is silently absent, not merged into a oneOf the way the annotated
|
||||
@GetMapping handlers were in 02-what-springdoc-actually-generates.txt. The response schema
|
||||
also did not resolve to either AccountV1-style record — it fell back to "#/components/schemas/ServerResponse" (springdoc introspecting the handler method's own ServerResponse return type,
|
||||
since @RouterOperation has no version attribute and I added no explicit operation() override).
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>openapi-versioning</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>openapi-versioning</name>
|
||||
<description>springdoc-openapi 3.1.1 against Spring Framework 7 API versioning on Spring Boot 4.1</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<springdoc.version>3.1.1</springdoc.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Annotation-based (@GetMapping) OpenAPI generation + Swagger UI. -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.openapiversioning;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class OpenapiVersioningApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OpenapiVersioningApplication.class, args);
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package com.ankurm.openapiversioning.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.Operation;
|
||||
import io.swagger.v3.oas.models.media.Content;
|
||||
import io.swagger.v3.oas.models.media.MediaType;
|
||||
import io.swagger.v3.oas.models.media.Schema;
|
||||
import io.swagger.v3.oas.models.parameters.Parameter;
|
||||
import org.springdoc.core.customizers.OpenApiCustomizer;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* springdoc-openapi 3.1.1 does not itself untangle Framework 7's per-handler {@code version}
|
||||
* attribute: it merges every handler mapped to the same path into one Operation with a
|
||||
* {@code oneOf} response (see docs/02-what-springdoc-actually-generates.md for the raw,
|
||||
* unmodified /v3/api-docs output this is reacting to). This customizer is the practical fix —
|
||||
* one {@link GroupedOpenApi} per API version, each collapsing that merged oneOf down to the
|
||||
* single schema real clients on that version actually receive.
|
||||
*
|
||||
* <p>The version-to-schema mapping is maintained by hand here because springdoc's
|
||||
* {@link OpenApiCustomizer} receives the fully-built {@link OpenAPI} document, not the
|
||||
* {@code HandlerMethod}-to-version association Spring's own {@code VersionResourceMetadata}
|
||||
* has already discarded by that point — see docs/03-why-a-hand-maintained-map.md for why an
|
||||
* automatic version-aware customizer isn't a realistic option against the public springdoc API.
|
||||
*/
|
||||
@Configuration
|
||||
public class VersionedOpenApiConfig {
|
||||
|
||||
private static final Map<String, VersionSpec> SPECS = Map.of(
|
||||
"1", new VersionSpec("AccountV1", "getAccountV1", "Get account (v1)"),
|
||||
"1.1", new VersionSpec("AccountV1Point1", "getAccountV1Point1Plus", "Get account (v1.1+)"),
|
||||
"2", new VersionSpec("AccountV2", "getAccountV2", "Get account (v2)")
|
||||
);
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi accountsV1Group() {
|
||||
return groupFor("accounts-v1", "1");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi accountsV1Point1Group() {
|
||||
return groupFor("accounts-v1.1", "1.1");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi accountsV2Group() {
|
||||
return groupFor("accounts-v2", "2");
|
||||
}
|
||||
|
||||
private GroupedOpenApi groupFor(String groupName, String version) {
|
||||
VersionSpec spec = SPECS.get(version);
|
||||
return GroupedOpenApi.builder()
|
||||
.group(groupName)
|
||||
.pathsToMatch("/accounts/**")
|
||||
.addOpenApiCustomizer(collapseToSingleVersion(spec))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites every operation's {@code oneOf} response schema down to the one schema that
|
||||
* matches this group's version, fixes the operationId to match (springdoc otherwise leaves
|
||||
* whichever operationId the merge step picked — see the raw-output doc above), and drops the
|
||||
* now-redundant API-Version request header, since a version-scoped document has nothing left
|
||||
* for a caller to choose.
|
||||
*/
|
||||
private OpenApiCustomizer collapseToSingleVersion(VersionSpec spec) {
|
||||
return openApi -> openApi.getPaths().values().forEach(pathItem ->
|
||||
pathItem.readOperations().forEach(operation -> {
|
||||
collapseResponseSchema(operation, spec);
|
||||
operation.setOperationId(spec.operationId());
|
||||
operation.setSummary(spec.summary());
|
||||
removeVersionHeader(operation);
|
||||
}));
|
||||
}
|
||||
|
||||
private void collapseResponseSchema(Operation operation, VersionSpec spec) {
|
||||
if (operation.getResponses() == null) {
|
||||
return;
|
||||
}
|
||||
operation.getResponses().values().forEach(apiResponse -> {
|
||||
Content content = apiResponse.getContent();
|
||||
if (content == null) {
|
||||
return;
|
||||
}
|
||||
for (MediaType mediaType : content.values()) {
|
||||
Schema<?> schema = mediaType.getSchema();
|
||||
if (schema != null && schema.getOneOf() != null) {
|
||||
String targetRef = "#/components/schemas/" + spec.schemaName();
|
||||
schema.getOneOf().stream()
|
||||
.filter(candidate -> targetRef.equals(candidate.get$ref()))
|
||||
.findFirst()
|
||||
.ifPresent(match -> {
|
||||
mediaType.setSchema(match);
|
||||
schema.setOneOf(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void removeVersionHeader(Operation operation) {
|
||||
if (operation.getParameters() == null) {
|
||||
return;
|
||||
}
|
||||
List<Parameter> kept = operation.getParameters().stream()
|
||||
.filter(p -> !"API-Version".equalsIgnoreCase(p.getName()))
|
||||
.toList();
|
||||
operation.setParameters(kept);
|
||||
}
|
||||
|
||||
private record VersionSpec(String schemaName, String operationId, String summary) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.openapiversioning.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* The same Framework 7 API versioning setup used in the companion project for
|
||||
* "Spring Framework 7 API Versioning: The Complete Guide" (ankurm.com), reused here
|
||||
* unchanged so this module's OpenAPI-generation findings are directly comparable to
|
||||
* that article's versioning-mechanics findings, not a different setup drawing different
|
||||
* conclusions.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureApiVersioning(ApiVersionConfigurer configurer) {
|
||||
configurer.useRequestHeader("API-Version");
|
||||
configurer.setVersionRequired(false);
|
||||
configurer.setDefaultVersion("1");
|
||||
configurer.detectSupportedVersions(true);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.ankurm.openapiversioning.functional;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.function.ServerRequest;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class WidgetHandler {
|
||||
|
||||
public ServerResponse getWidgetV1(ServerRequest request) {
|
||||
return ServerResponse.ok().body(Map.of("id", request.pathVariable("id"), "apiVersion", "1"));
|
||||
}
|
||||
|
||||
public ServerResponse getWidgetV2(ServerRequest request) {
|
||||
return ServerResponse.ok().body(Map.of("widgetId", request.pathVariable("id"), "apiVersion", "2"));
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.openapiversioning.functional;
|
||||
|
||||
import org.springdoc.core.annotations.RouterOperation;
|
||||
import org.springdoc.core.annotations.RouterOperations;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.servlet.function.RequestPredicates;
|
||||
import org.springframework.web.servlet.function.RouterFunction;
|
||||
import org.springframework.web.servlet.function.RouterFunctions;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
|
||||
/**
|
||||
* The functional-endpoint style springdoc-openapi added dedicated versioning support for in
|
||||
* 3.0.2 (PR #3229, "Add support for Spring Framework API Versioning with Functional Endpoints") —
|
||||
* built to check what that support actually produces, since {@code @RouterOperation} (below)
|
||||
* has no version-related attribute at all, which is itself worth confirming before trusting the
|
||||
* changelog line at face value. See docs/04-the-functional-endpoint-path.md.
|
||||
*/
|
||||
@Configuration
|
||||
public class WidgetRouterConfig {
|
||||
|
||||
@Bean
|
||||
@RouterOperations({
|
||||
@RouterOperation(path = "/widgets/{id}", method = RequestMethod.GET, beanClass = WidgetHandler.class, beanMethod = "getWidgetV1"),
|
||||
@RouterOperation(path = "/widgets/{id}", method = RequestMethod.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();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.openapiversioning.web;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Same three-handler shape as the AccountController in the API-versioning-mechanics
|
||||
* article (ankurm.com), so this module's OpenAPI findings sit on top of already-verified
|
||||
* versioning behaviour rather than a new, unverified routing setup.
|
||||
*/
|
||||
@RestController
|
||||
public class AccountController {
|
||||
|
||||
@GetMapping(path = "/accounts/{id}", version = "1")
|
||||
public AccountV1 getAccountV1(@PathVariable String id) {
|
||||
return new AccountV1(id, "Ankur Mhatre", "1");
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}", version = "1.1+")
|
||||
public AccountV1Point1 getAccountV1Point1Plus(@PathVariable String id) {
|
||||
return new AccountV1Point1(id, "Ankur Mhatre", "[email protected]", "1.1+");
|
||||
}
|
||||
|
||||
@GetMapping(path = "/accounts/{id}", version = "2")
|
||||
public AccountV2 getAccountV2(@PathVariable String id) {
|
||||
return new AccountV2(id, "Ankur Mhatre",
|
||||
new AccountV2.Contact("[email protected]"), AccountStatus.ACTIVE, "2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.openapiversioning.web;
|
||||
|
||||
/** Only present from v2 onward — used to show springdoc documenting a per-version-only field. */
|
||||
public enum AccountStatus {
|
||||
ACTIVE, SUSPENDED, CLOSED
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.openapiversioning.web;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "AccountV1", description = "Account shape served by API version 1")
|
||||
public record AccountV1(
|
||||
@Schema(description = "Account identifier") String id,
|
||||
@Schema(description = "Account holder name") String name,
|
||||
@Schema(description = "Echoes the resolved API version") String apiVersion) {
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.openapiversioning.web;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "AccountV1Point1", description = "Account shape served from API version 1.1 onward (baseline handler)")
|
||||
public record AccountV1Point1(
|
||||
@Schema(description = "Account identifier") String id,
|
||||
@Schema(description = "Account holder name") String name,
|
||||
@Schema(description = "Contact email, added in 1.1") String email,
|
||||
@Schema(description = "Echoes the resolved API version") String apiVersion) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.openapiversioning.web;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "AccountV2", description = "Account shape served by API version 2 — restructured, nested contact object")
|
||||
public record AccountV2(
|
||||
@Schema(description = "Account identifier") String accountId,
|
||||
@Schema(description = "Account holder display name") String displayName,
|
||||
@Schema(description = "Nested contact details, replacing the flat email field from 1.1") Contact contact,
|
||||
@Schema(description = "Lifecycle status, new in v2") AccountStatus status,
|
||||
@Schema(description = "Echoes the resolved API version") String apiVersion) {
|
||||
|
||||
@Schema(name = "Contact", description = "Contact details nested under an account, v2+")
|
||||
public record Contact(@Schema(description = "Contact email") String email) {
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package com.ankurm.openapiversioning;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Iterator;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Every test here hits a real, running Spring Boot 4.1.1 / springdoc-openapi 3.1.1 instance over
|
||||
* real HTTP — nothing is mocked and nothing is asserted from documentation or the changelog
|
||||
* without being reproduced first. See docs/ for the narrative; this class is where every number
|
||||
* and every JSON shape quoted there actually comes from.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
class OpenApiVersioningTest {
|
||||
|
||||
@LocalServerPort
|
||||
int port;
|
||||
|
||||
private static final HttpClient CLIENT = HttpClient.newHttpClient();
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private String base;
|
||||
|
||||
@BeforeAll
|
||||
static void header() {
|
||||
Transcript.write("00-versions",
|
||||
"Spring Boot 4.1.1 / Spring Framework 7.0.9 / springdoc-openapi 3.1.1 / JDK 25 (Temurin 25.0.4.1+1)\n" +
|
||||
"springdoc-openapi-starter-webmvc-ui:3.1.1, resolved from Maven Central maven-metadata.xml " +
|
||||
"(latest=release=3.1.1, lastUpdated 2026-09-06T16:37:40Z).");
|
||||
}
|
||||
|
||||
private String get(String path, String apiVersion) throws IOException, InterruptedException {
|
||||
if (base == null) {
|
||||
base = "http://localhost:" + port;
|
||||
}
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + path)).GET();
|
||||
if (apiVersion != null) {
|
||||
builder.header("API-Version", apiVersion);
|
||||
}
|
||||
HttpResponse<String> response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
return response.statusCode() + "\n" + response.body();
|
||||
}
|
||||
|
||||
@Test
|
||||
void a_versionedEndpointsServeDistinctShapes() throws Exception {
|
||||
String v1 = get("/accounts/42", "1");
|
||||
String v1dot1 = get("/accounts/42", "1.1");
|
||||
String v2 = get("/accounts/42", "2");
|
||||
Transcript.write("01-versioned-runtime-behaviour",
|
||||
"GET /accounts/42, API-Version: 1\n" + v1 + "\n\n" +
|
||||
"GET /accounts/42, API-Version: 1.1\n" + v1dot1 + "\n\n" +
|
||||
"GET /accounts/42, API-Version: 2\n" + v2);
|
||||
assertThat(v1).contains("\"apiVersion\":\"1\"").doesNotContain("email");
|
||||
assertThat(v2).contains("\"contact\"").contains("\"status\":\"ACTIVE\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void b_defaultApiDocsMergesAllVersionsIntoOneOf() throws Exception {
|
||||
String body = get("/v3/api-docs", null);
|
||||
JsonNode root = MAPPER.readTree(body.substring(body.indexOf('\n') + 1));
|
||||
JsonNode operation = root.at("/paths/~1accounts~1{id}/get");
|
||||
|
||||
String operationId = operation.get("operationId").asText();
|
||||
JsonNode schema = operation.at("/responses/200/content/*~1*/schema");
|
||||
boolean hasOneOf = schema.has("oneOf");
|
||||
int oneOfCount = hasOneOf ? schema.get("oneOf").size() : 0;
|
||||
String refs = hasOneOf
|
||||
? StreamSupport.stream(schema.get("oneOf").spliterator(), false)
|
||||
.map(n -> n.get("$ref").asText())
|
||||
.collect(Collectors.joining(", "))
|
||||
: "(no oneOf — single ref: " + schema.path("$ref").asText() + ")";
|
||||
|
||||
JsonNode headerParam = null;
|
||||
for (JsonNode p : operation.get("parameters")) {
|
||||
if ("API-Version".equals(p.get("name").asText())) {
|
||||
headerParam = p;
|
||||
}
|
||||
}
|
||||
|
||||
Transcript.write("02-what-springdoc-actually-generates",
|
||||
"GET /v3/api-docs (ungrouped, default), operation at /accounts/{id}:\n" +
|
||||
" operationId = " + operationId + " (picked from ONE of the three handlers, not versioned itself)\n" +
|
||||
" response schema = oneOf [" + oneOfCount + " entries]: " + refs + "\n" +
|
||||
" API-Version header enum (auto-documented) = " + headerParam.get("schema").get("enum") + "\n" +
|
||||
" API-Version header default = " + headerParam.get("schema").get("default") + "\n\n" +
|
||||
"Full raw document:\n" + MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(root));
|
||||
|
||||
assertThat(hasOneOf).as("springdoc 3.1.1 merges same-path/different-version handlers into one oneOf operation").isTrue();
|
||||
assertThat(oneOfCount).isEqualTo(3);
|
||||
assertThat(operation.get("parameters").size()).isEqualTo(2); // id + API-Version, no per-version split
|
||||
}
|
||||
|
||||
@Test
|
||||
void c_groupedOpenApiCustomizerCollapsesToOneCleanSchemaPerVersion() throws Exception {
|
||||
String v1Doc = get("/v3/api-docs/accounts-v1", null);
|
||||
String v1dot1Doc = get("/v3/api-docs/accounts-v1.1", null);
|
||||
String v2Doc = get("/v3/api-docs/accounts-v2", null);
|
||||
|
||||
JsonNode v1 = MAPPER.readTree(v1Doc.substring(v1Doc.indexOf('\n') + 1)).at("/paths/~1accounts~1{id}/get");
|
||||
JsonNode v1dot1 = MAPPER.readTree(v1dot1Doc.substring(v1dot1Doc.indexOf('\n') + 1)).at("/paths/~1accounts~1{id}/get");
|
||||
JsonNode v2 = MAPPER.readTree(v2Doc.substring(v2Doc.indexOf('\n') + 1)).at("/paths/~1accounts~1{id}/get");
|
||||
|
||||
Transcript.write("05-grouped-openapi-fix",
|
||||
"GET /v3/api-docs/accounts-v1 -> operationId=" + v1.get("operationId").asText() +
|
||||
", schema=" + v1.at("/responses/200/content/*~1*/schema/$ref").asText() +
|
||||
", params=" + paramNames(v1) + "\n" +
|
||||
"GET /v3/api-docs/accounts-v1.1 -> operationId=" + v1dot1.get("operationId").asText() +
|
||||
", schema=" + v1dot1.at("/responses/200/content/*~1*/schema/$ref").asText() +
|
||||
", params=" + paramNames(v1dot1) + "\n" +
|
||||
"GET /v3/api-docs/accounts-v2 -> operationId=" + v2.get("operationId").asText() +
|
||||
", schema=" + v2.at("/responses/200/content/*~1*/schema/$ref").asText() +
|
||||
", params=" + paramNames(v2) + "\n\n" +
|
||||
"Each group's VersionedOpenApiConfig#collapseToSingleVersion customizer rewrites the same\n" +
|
||||
"merged oneOf operation shown in 02-what-springdoc-actually-generates.txt down to exactly\n" +
|
||||
"one schema and drops the now-meaningless API-Version header — the default (ungrouped)\n" +
|
||||
"/v3/api-docs document is completely unaffected, confirmed by re-fetching it below:\n\n" +
|
||||
get("/v3/api-docs", null));
|
||||
|
||||
assertThat(v1.get("operationId").asText()).isEqualTo("getAccountV1");
|
||||
assertThat(v1.at("/responses/200/content/*~1*/schema/$ref").asText()).isEqualTo("#/components/schemas/AccountV1");
|
||||
assertThat(v2.get("operationId").asText()).isEqualTo("getAccountV2");
|
||||
assertThat(v2.at("/responses/200/content/*~1*/schema/$ref").asText()).isEqualTo("#/components/schemas/AccountV2");
|
||||
assertThat(paramNames(v1)).doesNotContain("API-Version");
|
||||
}
|
||||
|
||||
@Test
|
||||
void d_functionalEndpointVersioningRuntimeWorksButDocsDoNot() throws Exception {
|
||||
String v1 = get("/widgets/7", "1");
|
||||
String v2 = get("/widgets/7", "2");
|
||||
|
||||
String docsBody = get("/v3/api-docs", null);
|
||||
JsonNode root = MAPPER.readTree(docsBody.substring(docsBody.indexOf('\n') + 1));
|
||||
JsonNode widgetOp = root.at("/paths/~1widgets~1{id}/get");
|
||||
|
||||
Transcript.write("06-functional-endpoint-path",
|
||||
"Runtime (Framework 7 RequestPredicates.version(...), routing is correct):\n" +
|
||||
" GET /widgets/7, API-Version: 1 -> " + v1 + "\n" +
|
||||
" GET /widgets/7, API-Version: 2 -> " + v2 + "\n\n" +
|
||||
"springdoc documentation for the SAME path (2 @RouterOperation entries registered,\n" +
|
||||
"one per version, via @RouterOperations on the RouterFunction bean):\n" +
|
||||
MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(widgetOp) + "\n\n" +
|
||||
"Only ONE @RouterOperation survived (operationId=" + widgetOp.get("operationId").asText() +
|
||||
") — the second is silently absent, not merged into a oneOf the way the annotated\n" +
|
||||
"@GetMapping handlers were in 02-what-springdoc-actually-generates.txt. The response schema\n" +
|
||||
"also did not resolve to either AccountV1-style record — it fell back to \"" +
|
||||
widgetOp.at("/responses/200/content/*~1*/schema/$ref").asText() +
|
||||
"\" (springdoc introspecting the handler method's own ServerResponse return type,\n" +
|
||||
"since @RouterOperation has no version attribute and I added no explicit operation() override).");
|
||||
|
||||
assertThat(v1).contains("\"id\":\"7\"").contains("\"apiVersion\":\"1\"");
|
||||
assertThat(v2).contains("\"widgetId\":\"7\"").contains("\"apiVersion\":\"2\"");
|
||||
assertThat(widgetOp.has("oneOf")).isFalse();
|
||||
// Only one of the two registered RouterOperations is documented - the defect this test locks in.
|
||||
assertThat(widgetOp.get("operationId").asText()).isIn("getWidgetV1", "getWidgetV2");
|
||||
}
|
||||
|
||||
private static String paramNames(JsonNode operation) {
|
||||
Iterator<JsonNode> it = operation.get("parameters").elements();
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
while (it.hasNext()) {
|
||||
sb.append(it.next().get("name").asText());
|
||||
if (it.hasNext()) sb.append(", ");
|
||||
}
|
||||
return sb.append("]").toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ankurm.openapiversioning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/** Writes docs/output/<name>.txt and echoes to stdout — the same mechanism used across
|
||||
* this repo's other modules, so every figure quoted in the article traces to a file a reader
|
||||
* can open and regenerate with {@code mvn test}. */
|
||||
public final class Transcript {
|
||||
|
||||
private static final Path OUTPUT_DIR = Paths.get("docs", "output");
|
||||
|
||||
private Transcript() {
|
||||
}
|
||||
|
||||
public static void write(String name, String content) {
|
||||
try {
|
||||
Files.createDirectories(OUTPUT_DIR);
|
||||
Path file = OUTPUT_DIR.resolve(name + ".txt");
|
||||
Files.writeString(file, content);
|
||||
System.out.println("=== " + name + " ===");
|
||||
System.out.println(content);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user