diff --git a/README.md b/README.md index 7c07990..3ce7c3f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ files. | [`spring-batch-partitioning/`](spring-batch-partitioning) | [Spring Batch Partitioning and Parallel Steps: Scaling a 10-Million-Row Job](https://ankurm.com/) | the real grid-size sweep at 10M and 300K rows (best speedup 1.42x, on 2 cores), `MultiResourcePartitioner` ignoring gridSize entirely, a rejected partition's `StepExecution` stuck at `STARTING` forever, and Spring Batch 6.0's new `JobOperator#recover` unsticking it | | [`db-migrations-flyway-liquibase/`](db-migrations-flyway-liquibase) | [Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines](https://ankurm.com/flyway-vs-liquibase-spring-boot-4-migrations-rollbacks-baselines/) | Flyway Community's `undo` throwing `FlywayRedgateEditionRequiredException` at runtime, a real Liquibase 5.0.3 filename-caching defect that produces a phantom successful run, Liquibase's 10-second default lock-poll rate versus Flyway's near-instant row lock, the FSL license change and its ASF/Keycloak fallout, and what actually happens when both tools are enabled against one database | | [`db-migrations-expand-contract/`](db-migrations-expand-contract) | [Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot](https://ankurm.com/zero-downtime-database-migrations-expand-contract-spring-boot/) | a real 4-deploy rolling sequence against two live replicas with a load generator proving 99.98% success, H2's `AUTO_SERVER` single-point-of-failure trap, a `NOT NULL` constraint that fails every Stage 4 insert, and `ALTER TABLE` silently dropping a concurrently committed row with no exception thrown | +| [`openapi-versioning/`](openapi-versioning) | [springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec](https://ankurm.com/) | springdoc 3.1.1 silently merging same-path, different-version handlers into one `oneOf` operation with an arbitrary `operationId`, a working per-version fix with `GroupedOpenApi` + `OpenApiCustomizer`, and the officially-versioning-supported functional-endpoint path turning out to document only one of two registered versions | Articles whose text is kept here rather than only on the blog have it under `/post/` — `post.md` for the body and `meta.md` for the title, excerpt and diff --git a/openapi-versioning/README.md b/openapi-versioning/README.md new file mode 100644 index 0000000..6c27907 --- /dev/null +++ b/openapi-versioning/README.md @@ -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. diff --git a/openapi-versioning/docs/01-versions-and-setup.md b/openapi-versioning/docs/01-versions-and-setup.md new file mode 100644 index 0000000..87fcca4 --- /dev/null +++ b/openapi-versioning/docs/01-versions-and-setup.md @@ -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 ``/`` 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 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.1.1 + +``` + +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. diff --git a/openapi-versioning/docs/02-what-springdoc-actually-generates.md b/openapi-versioning/docs/02-what-springdoc-actually-generates.md new file mode 100644 index 0000000..ccd9ce1 --- /dev/null +++ b/openapi-versioning/docs/02-what-springdoc-actually-generates.md @@ -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. diff --git a/openapi-versioning/docs/03-why-a-hand-maintained-map.md b/openapi-versioning/docs/03-why-a-hand-maintained-map.md new file mode 100644 index 0000000..49b3831 --- /dev/null +++ b/openapi-versioning/docs/03-why-a-hand-maintained-map.md @@ -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` 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. diff --git a/openapi-versioning/docs/04-the-functional-endpoint-path.md b/openapi-versioning/docs/04-the-functional-endpoint-path.md new file mode 100644 index 0000000..c3699f2 --- /dev/null +++ b/openapi-versioning/docs/04-the-functional-endpoint-path.md @@ -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 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. diff --git a/openapi-versioning/docs/05-the-grouped-openapi-fix.md b/openapi-versioning/docs/05-the-grouped-openapi-fix.md new file mode 100644 index 0000000..6063cc8 --- /dev/null +++ b/openapi-versioning/docs/05-the-grouped-openapi-fix.md @@ -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. diff --git a/openapi-versioning/docs/06-known-issues.md b/openapi-versioning/docs/06-known-issues.md new file mode 100644 index 0000000..d1837bf --- /dev/null +++ b/openapi-versioning/docs/06-known-issues.md @@ -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. diff --git a/openapi-versioning/docs/output/00-versions.txt b/openapi-versioning/docs/output/00-versions.txt new file mode 100644 index 0000000..9f4aa2f --- /dev/null +++ b/openapi-versioning/docs/output/00-versions.txt @@ -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). \ No newline at end of file diff --git a/openapi-versioning/docs/output/01-versioned-runtime-behaviour.txt b/openapi-versioning/docs/output/01-versioned-runtime-behaviour.txt new file mode 100644 index 0000000..0d436e6 --- /dev/null +++ b/openapi-versioning/docs/output/01-versioned-runtime-behaviour.txt @@ -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":"ankur@example.com","apiVersion":"1.1+"} + +GET /accounts/42, API-Version: 2 +200 +{"accountId":"42","displayName":"Ankur Mhatre","contact":{"email":"ankur@example.com"},"status":"ACTIVE","apiVersion":"2"} \ No newline at end of file diff --git a/openapi-versioning/docs/output/02-what-springdoc-actually-generates.txt b/openapi-versioning/docs/output/02-what-springdoc-actually-generates.txt new file mode 100644 index 0000000..7d7fa98 --- /dev/null +++ b/openapi-versioning/docs/output/02-what-springdoc-actually-generates.txt @@ -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" : { } + } + } +} \ No newline at end of file diff --git a/openapi-versioning/docs/output/05-grouped-openapi-fix.txt b/openapi-versioning/docs/output/05-grouped-openapi-fix.txt new file mode 100644 index 0000000..72d2460 --- /dev/null +++ b/openapi-versioning/docs/output/05-grouped-openapi-fix.txt @@ -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":{}}}} \ No newline at end of file diff --git a/openapi-versioning/docs/output/06-functional-endpoint-path.txt b/openapi-versioning/docs/output/06-functional-endpoint-path.txt new file mode 100644 index 0000000..db27400 --- /dev/null +++ b/openapi-versioning/docs/output/06-functional-endpoint-path.txt @@ -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). \ No newline at end of file diff --git a/openapi-versioning/pom.xml b/openapi-versioning/pom.xml new file mode 100644 index 0000000..4d4b472 --- /dev/null +++ b/openapi-versioning/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + openapi-versioning + 1.0.0 + openapi-versioning + springdoc-openapi 3.1.1 against Spring Framework 7 API versioning on Spring Boot 4.1 + + + 25 + 3.1.1 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/OpenapiVersioningApplication.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/OpenapiVersioningApplication.java new file mode 100644 index 0000000..27e229b --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/OpenapiVersioningApplication.java @@ -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); + } +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/config/VersionedOpenApiConfig.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/config/VersionedOpenApiConfig.java new file mode 100644 index 0000000..83dd927 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/config/VersionedOpenApiConfig.java @@ -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. + * + *

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 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 kept = operation.getParameters().stream() + .filter(p -> !"API-Version".equalsIgnoreCase(p.getName())) + .toList(); + operation.setParameters(kept); + } + + private record VersionSpec(String schemaName, String operationId, String summary) { + } +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/config/WebConfig.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/config/WebConfig.java new file mode 100644 index 0000000..b34e348 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/config/WebConfig.java @@ -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); + } +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/functional/WidgetHandler.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/functional/WidgetHandler.java new file mode 100644 index 0000000..950b479 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/functional/WidgetHandler.java @@ -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")); + } +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/functional/WidgetRouterConfig.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/functional/WidgetRouterConfig.java new file mode 100644 index 0000000..4941d4b --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/functional/WidgetRouterConfig.java @@ -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 widgetRoutes(WidgetHandler handler) { + return RouterFunctions.route() + .GET("/widgets/{id}", RequestPredicates.version("1"), handler::getWidgetV1) + .GET("/widgets/{id}", RequestPredicates.version("2"), handler::getWidgetV2) + .build(); + } +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountController.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountController.java new file mode 100644 index 0000000..ccc3106 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountController.java @@ -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", "ankur@example.com", "1.1+"); + } + + @GetMapping(path = "/accounts/{id}", version = "2") + public AccountV2 getAccountV2(@PathVariable String id) { + return new AccountV2(id, "Ankur Mhatre", + new AccountV2.Contact("ankur@example.com"), AccountStatus.ACTIVE, "2"); + } +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountStatus.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountStatus.java new file mode 100644 index 0000000..2e7af92 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountStatus.java @@ -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 +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV1.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV1.java new file mode 100644 index 0000000..4d26b7f --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV1.java @@ -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) { +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV1Point1.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV1Point1.java new file mode 100644 index 0000000..6ab4fd2 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV1Point1.java @@ -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) { +} diff --git a/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV2.java b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV2.java new file mode 100644 index 0000000..aa99b92 --- /dev/null +++ b/openapi-versioning/src/main/java/com/ankurm/openapiversioning/web/AccountV2.java @@ -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) { + } +} diff --git a/openapi-versioning/src/test/java/com/ankurm/openapiversioning/OpenApiVersioningTest.java b/openapi-versioning/src/test/java/com/ankurm/openapiversioning/OpenApiVersioningTest.java new file mode 100644 index 0000000..28c7791 --- /dev/null +++ b/openapi-versioning/src/test/java/com/ankurm/openapiversioning/OpenApiVersioningTest.java @@ -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 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 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(); + } +} diff --git a/openapi-versioning/src/test/java/com/ankurm/openapiversioning/Transcript.java b/openapi-versioning/src/test/java/com/ankurm/openapiversioning/Transcript.java new file mode 100644 index 0000000..07dac09 --- /dev/null +++ b/openapi-versioning/src/test/java/com/ankurm/openapiversioning/Transcript.java @@ -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); + } + } +}