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:
2026-09-17 19:31:42 +00:00
co-authored by Claude Sonnet 5
parent a875bea55a
commit 8d0efb0d4b
26 changed files with 1163 additions and 0 deletions
@@ -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).