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,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).
|
||||
Reference in New Issue
Block a user