Currently, Spring 7.0.0-M7/Spring Boot 4.0.0-M1, the server config is a little confused, we can set header, URI path index, query parameter, Media type, one by one, but can not configure them in the same backend API application.
-
On the server side, I think it could support one or more strategies in one application. eg. support
X-API-Version:1.0
and/api/someAPI?v=1.0
at the same time. The client application can select anyone to interact with it. -
On the client, we can configure a default strategy when building the HTTP client instance, and allow the developer to
mutate
it before sending the request in tests, like we handled the security credentials.
Comment From: spencergibb
You can add as many as you want currently with the use
methods. The configurer adds them to a list. See https://github.com/spring-projects/spring-framework/blob/09917fad7bca9b3997522f0a75d6319203f2127f/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/ApiVersionConfigurer.java#L67-L110
Comment From: hantsy
@spencergibb A simple producer example to describe the problem I encountered,
The web configuration like this.
public class WebConfig implements WebFluxConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.usePathSegment(0)
.useRequestHeader("X-API-Version")
//.useRequestParam("version")
.setDefaultVersion("1.0")
// When a defaultVersion is also set, this is automatically set to false.
// .setVersionRequired(true)
// default is SemanticApiVersionParser
//.setVersionParser(new SemanticApiVersionParser())
// set detectSupportedVersions(false) when adding supported versions
.detectSupportedVersions(false)
.addSupportedVersions("1.0", "1.1", "2.0");
And the rest controller for demonstration.
@RestController
@RequestMapping("/hello")
public class GreetingController {
@GetMapping()
public String helloDefault() {
return "Hello v1.0(Default)";
}
@GetMapping(version = "1.1")
public String helloV1_1() {
return "Hello v1.1";
}
@GetMapping(version = "2.0")
public String helloV2_0() {
return "Hello v2.0";
}
}
And then configure a WebClient to access the API.
@BeforeEach
public void setup() {
var reactorHttpClient = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 30_000);
var clientConnector = new ReactorClientHttpConnector(reactorHttpClient);
this.disposableServer = this.httpServer.bindNow(Duration.ofMillis(5_000));
this.client = WebClient.builder()
.baseUrl("http://localhost:" + this.port)
.codecs(c -> c.defaultCodecs().enableLoggingRequestDetails(true))
//.defaultHeaders(headers -> headers.set("X-API-Version", "1.0"))
.apiVersionInserter(ApiVersionInserter.builder()
.useHeader("X-API-Version")
// .usePathSegment(0)
// .useQueryParam("version")
// .withVersionFormatter(ApiVersionFormatter)
.build()
)
.clientConnector(clientConnector)
.build();
}
@AfterEach
public void teardown() {
this.disposableServer.disposeNow();
}
@Test
public void testHello() {
this.client.get().uri("/hello")
.apiVersion("1.0")
.retrieve()
.bodyToMono(String.class)
.as(StepVerifier::create)
.expectNext("Hello v1.0(Default)")
.verifyComplete();
}
It is failed due to exception like this.
2025-07-31 09:51:33,497 DEBUG [reactor-http-nio-4] o.s.w.s.h.ResponseStatusExceptionHandler:
[4fdc77a2-1] Resolved [InvalidApiVersionException:
"400 BAD_REQUEST "Invalid API version: 'hello'.""] for HTTP GET /hello
2025-07-31 09:51:33,501 TRACE [reactor-http-nio-4] o.s.c.l.LogFormatUtils:
[4fdc77a2-1] Completed 400 BAD_REQUEST, headers={masked}
2025-07-31 09:51:33,503 TRACE [reactor-http-nio-4] o.s.c.l.CompositeLog:
[4fdc77a2-1, L:/127.0.0.1:8888 - R:/127.0.0.1:61834] Handling completed
2025-07-31 09:51:33,530 TRACE [reactor-http-nio-3] o.s.c.l.LogFormatUtils:
[935493d] [b98aa0fc-1] Response 400 BAD_REQUEST, headers=[content-length:"0"]
...
java.lang.AssertionError: expectation "expectNext(Hello v1.0(Default))" failed (expected: onNext(Hello v1.0(Default)); actual: onError(org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest: 400 Bad Request from GET http://localhost:8888/hello))
at reactor.test.MessageFormatter.assertionError(MessageFormatter.java:115)
at reactor.test.MessageFormatter.failPrefix(MessageFormatter.java:104)
at reactor.test.MessageFormatter.fail(MessageFormatter.java:73)
at reactor.test.MessageFormatter.failOptional(MessageFormatter.java:88)
at reactor.test.DefaultStepVerifierBuilder.lambda$addExpectedValue$10(DefaultStepVerifierBuilder.java:509)
at reactor.test.DefaultStepVerifierBuilder$SignalEvent.test(DefaultStepVerifierBuilder.java:2289)
at reactor.test.DefaultStepVerifierBuilder$DefaultVerifySubscriber.onSignal(DefaultStepVerifierBuilder.java:1529)
at reactor.test.DefaultStepVerifierBuilder$DefaultVerifySubscriber.onExpectation(DefaultStepVerifierBuilder.java:1477)
at reactor.test.DefaultStepVerifierBuilder$DefaultVerifySubscriber.onError(DefaultStepVerifierBuilder.java:1129)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.secondError(MonoFlatMap.java:241)
at reactor.core.publisher.MonoFlatMap$FlatMapInner.onError(MonoFlatMap.java:315)
at reactor.core.publisher.Operators$MultiSubscriptionSubscriber.onError(Operators.java:2235)
at reactor.core.publisher.FluxOnAssembly$OnAssemblySubscriber.onError(FluxOnAssembly.java:544)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.onError(MonoFlatMap.java:180)
at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onError(FluxOnErrorResume.java:106)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.onError(MonoIgnoreThen.java:280)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:232)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.onComplete(MonoIgnoreThen.java:204)
at reactor.core.publisher.FluxOnErrorReturn$ReturnSubscriber.onComplete(FluxOnErrorReturn.java:169)
at reactor.core.publisher.MonoIgnoreElements$IgnoreElementsSubscriber.onComplete(MonoIgnoreElements.java:89)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:152)
at reactor.core.publisher.FluxOnAssembly$OnAssemblySubscriber.onComplete(FluxOnAssembly.java:549)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.core.publisher.FluxPeek$PeekSubscriber.onComplete(FluxPeek.java:260)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.core.publisher.Operators.complete(Operators.java:137)
at reactor.netty.channel.FluxReceive.startReceiver(FluxReceive.java:182)
at reactor.netty.channel.FluxReceive.subscribe(FluxReceive.java:148)
at reactor.core.publisher.InternalFluxOperator.subscribe(InternalFluxOperator.java:68)
at reactor.netty.ByteBufFlux.subscribe(ByteBufFlux.java:340)
at reactor.core.publisher.Mono.subscribe(Mono.java:4571)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:265)
at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:51)
at reactor.core.publisher.Mono.subscribe(Mono.java:4571)
at reactor.core.publisher.FluxOnErrorResume$ResumeSubscriber.onError(FluxOnErrorResume.java:103)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.secondError(MonoFlatMap.java:241)
at reactor.core.publisher.MonoFlatMap$FlatMapInner.onError(MonoFlatMap.java:315)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.onError(MonoIgnoreThen.java:280)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:232)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.onComplete(MonoIgnoreThen.java:204)
at reactor.core.publisher.FluxOnErrorReturn$ReturnSubscriber.onComplete(FluxOnErrorReturn.java:169)
at reactor.core.publisher.MonoIgnoreElements$IgnoreElementsSubscriber.onComplete(MonoIgnoreElements.java:89)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:152)
at reactor.core.publisher.FluxOnAssembly$OnAssemblySubscriber.onComplete(FluxOnAssembly.java:549)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.core.publisher.FluxPeek$PeekSubscriber.onComplete(FluxPeek.java:260)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.core.publisher.Operators.complete(Operators.java:137)
at reactor.netty.channel.FluxReceive.startReceiver(FluxReceive.java:182)
at reactor.netty.channel.FluxReceive.subscribe(FluxReceive.java:148)
at reactor.core.publisher.InternalFluxOperator.subscribe(InternalFluxOperator.java:68)
at reactor.netty.ByteBufFlux.subscribe(ByteBufFlux.java:340)
at reactor.core.publisher.Mono.subscribe(Mono.java:4571)
at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:265)
at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:51)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:165)
at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:122)
at reactor.core.publisher.FluxOnErrorReturn$ReturnSubscriber.onNext(FluxOnErrorReturn.java:162)
at reactor.core.publisher.Operators$BaseFluxToMonoOperator.completePossiblyEmpty(Operators.java:2096)
at reactor.core.publisher.FluxDefaultIfEmpty$DefaultIfEmptySubscriber.onComplete(FluxDefaultIfEmpty.java:134)
at reactor.core.publisher.FluxOnAssembly$OnAssemblySubscriber.onComplete(FluxOnAssembly.java:549)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:152)
at reactor.core.publisher.FluxContextWrite$ContextWriteSubscriber.onComplete(FluxContextWrite.java:126)
at reactor.core.publisher.FluxMapFuseable$MapFuseableConditionalSubscriber.onComplete(FluxMapFuseable.java:350)
at reactor.core.publisher.FluxFilterFuseable$FilterFuseableConditionalSubscriber.onComplete(FluxFilterFuseable.java:391)
at reactor.core.publisher.Operators$BaseFluxToMonoOperator.completePossiblyEmpty(Operators.java:2097)
at reactor.core.publisher.MonoCollect$CollectSubscriber.onComplete(MonoCollect.java:145)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.core.publisher.FluxPeek$PeekSubscriber.onComplete(FluxPeek.java:260)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.netty.channel.FluxReceive.onInboundComplete(FluxReceive.java:419)
at reactor.netty.channel.ChannelOperations.onInboundComplete(ChannelOperations.java:456)
at reactor.netty.channel.ChannelOperations.terminate(ChannelOperations.java:510)
at reactor.netty.http.client.HttpClientOperations.onInboundNext(HttpClientOperations.java:820)
at reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:115)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:356)
at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:434)
at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:346)
at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:318)
at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:249)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:167)
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
at io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:381)
at io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:575)
at io.netty.channel.nio.NioIoHandler.processSelectedKeysOptimized(NioIoHandler.java:550)
at io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:491)
at io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:468)
at io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:207)
at io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:178)
at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1073)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest: 400 Bad Request from GET http://localhost:8888/hello
at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:306)
Suppressed: The stacktrace has been enhanced by Reactor, refer to additional information below:
Error has been observed at the following site(s):
*__checkpoint ⇢ 400 BAD_REQUEST from GET http://localhost:8888/hello [DefaultWebClient]
Original Stack Trace:
at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:306)
at org.springframework.web.reactive.function.client.DefaultClientResponse.lambda$createException$1(DefaultClientResponse.java:214)
at reactor.core.publisher.FluxMap$MapSubscriber.onNext(FluxMap.java:106)
at reactor.core.publisher.FluxOnErrorReturn$ReturnSubscriber.onNext(FluxOnErrorReturn.java:162)
at reactor.core.publisher.Operators$BaseFluxToMonoOperator.completePossiblyEmpty(Operators.java:2096)
at reactor.core.publisher.FluxDefaultIfEmpty$DefaultIfEmptySubscriber.onComplete(FluxDefaultIfEmpty.java:134)
at reactor.core.publisher.FluxMapFuseable$MapFuseableSubscriber.onComplete(FluxMapFuseable.java:152)
at reactor.core.publisher.FluxContextWrite$ContextWriteSubscriber.onComplete(FluxContextWrite.java:126)
at reactor.core.publisher.FluxMapFuseable$MapFuseableConditionalSubscriber.onComplete(FluxMapFuseable.java:350)
at reactor.core.publisher.FluxFilterFuseable$FilterFuseableConditionalSubscriber.onComplete(FluxFilterFuseable.java:391)
at reactor.core.publisher.Operators$BaseFluxToMonoOperator.completePossiblyEmpty(Operators.java:2097)
at reactor.core.publisher.MonoCollect$CollectSubscriber.onComplete(MonoCollect.java:145)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.core.publisher.FluxPeek$PeekSubscriber.onComplete(FluxPeek.java:260)
at reactor.core.publisher.FluxMap$MapSubscriber.onComplete(FluxMap.java:144)
at reactor.netty.channel.FluxReceive.onInboundComplete(FluxReceive.java:419)
at reactor.netty.channel.ChannelOperations.onInboundComplete(ChannelOperations.java:456)
at reactor.netty.channel.ChannelOperations.terminate(ChannelOperations.java:510)
at reactor.netty.http.client.HttpClientOperations.onInboundNext(HttpClientOperations.java:820)
at reactor.netty.channel.ChannelOperationsHandler.channelRead(ChannelOperationsHandler.java:115)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:356)
at io.netty.channel.CombinedChannelDuplexHandler$DelegatingChannelHandlerContext.fireChannelRead(CombinedChannelDuplexHandler.java:434)
at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:346)
at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:318)
at io.netty.channel.CombinedChannelDuplexHandler.channelRead(CombinedChannelDuplexHandler.java:249)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:354)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1429)
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:918)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:167)
at io.netty.channel.nio.AbstractNioChannel$AbstractNioUnsafe.handle(AbstractNioChannel.java:445)
at io.netty.channel.nio.NioIoHandler$DefaultNioRegistration.handle(NioIoHandler.java:381)
at io.netty.channel.nio.NioIoHandler.processSelectedKey(NioIoHandler.java:575)
at io.netty.channel.nio.NioIoHandler.processSelectedKeysOptimized(NioIoHandler.java:550)
at io.netty.channel.nio.NioIoHandler.processSelectedKeys(NioIoHandler.java:491)
at io.netty.channel.nio.NioIoHandler.run(NioIoHandler.java:468)
at io.netty.channel.SingleThreadIoEventLoop.runIo(SingleThreadIoEventLoop.java:207)
at io.netty.channel.SingleThreadIoEventLoop.run(SingleThreadIoEventLoop.java:178)
at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:1073)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.base/java.lang.Thread.run(Thread.java:1583)
The problem is I used header to set the version, but server side use path segment to process version.
You can run the example project on your local machine, check out https://github.com/hantsy/spring7-sandbox/tree/master/api-versioning, change to the WebConfig
to set multiple version stetragy, run the tests.
Comment From: spencergibb
Yeah, I just ran into something similar, the path one will interfere. You can try playing with the ordering of how you add them
Comment From: hantsy
@spencergibb But in the real world, the RestClient could not know how server side handle the version in which order.
Comment From: hantsy
I think when analyze the version of incoming requests, the characteristics (eg, http header, request parameter, media type, etc.) existence and matching result for the version should be considered in a high order than the nature order of resolver registration.