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,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);
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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();
}
}
@@ -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) {
}
@@ -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) {
}
}
@@ -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/&lt;name&gt;.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);
}
}
}