Skip to content

Commit 530474c

Browse files
committed
Setting API Version Required when using WebFlux breaks the Swagger UI. Fixes #3258
1 parent 304e7d6 commit 530474c

File tree

9 files changed

+290
-3
lines changed

9 files changed

+290
-3
lines changed

springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/api/SpringDocApiVersionStrategy.java

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import reactor.core.publisher.Mono;
3434

3535
import org.springframework.web.accept.InvalidApiVersionException;
36+
import org.springframework.web.accept.MissingApiVersionException;
3637
import org.springframework.web.reactive.accept.ApiVersionStrategy;
3738
import org.springframework.web.server.ServerWebExchange;
3839

@@ -66,6 +67,12 @@ public SpringDocApiVersionStrategy(ApiVersionStrategy delegate, List<String> spr
6667
return delegate.resolveParseAndValidateVersion(exchange);
6768
}
6869
catch (InvalidApiVersionException ex) {
70+
if (isSpringDocPath(exchange)) {
71+
return resolveVersionForSpringDocPath(ex);
72+
}
73+
throw ex;
74+
}
75+
catch (MissingApiVersionException ex) {
6976
if (isSpringDocPath(exchange)) {
7077
return delegate.getDefaultVersion();
7178
}
@@ -76,15 +83,40 @@ public SpringDocApiVersionStrategy(ApiVersionStrategy delegate, List<String> spr
7683
@Override
7784
public Mono<Comparable<?>> resolveParseAndValidateApiVersion(ServerWebExchange exchange) {
7885
return delegate.resolveParseAndValidateApiVersion(exchange)
79-
.onErrorResume(InvalidApiVersionException.class, ex -> {
86+
.onErrorResume(ex -> {
8087
if (isSpringDocPath(exchange)) {
81-
Comparable<?> defaultVersion = delegate.getDefaultVersion();
82-
return defaultVersion != null ? Mono.just(defaultVersion) : Mono.empty();
88+
if (ex instanceof InvalidApiVersionException invalidEx) {
89+
return Mono.just(resolveVersionForSpringDocPath(invalidEx));
90+
}
91+
else if (ex instanceof MissingApiVersionException) {
92+
Comparable<?> defaultVersion = delegate.getDefaultVersion();
93+
return defaultVersion != null ? Mono.just(defaultVersion) : Mono.just((Comparable<?>) "");
94+
}
8395
}
8496
return Mono.error(ex);
8597
});
8698
}
8799

100+
/**
101+
* Resolve a version for springdoc paths when validation fails.
102+
* <p>Re-parses the invalid version string without validation, since springdoc
103+
* endpoints have no version condition and will match any version. This ensures
104+
* a non-null value is returned, which is required for the reactive handler
105+
* mapping to proceed with handler lookup.
106+
*
107+
* @param ex the invalid API version exception
108+
* @return the parsed version, or the default version as fallback
109+
*/
110+
private Comparable<?> resolveVersionForSpringDocPath(InvalidApiVersionException ex) {
111+
try {
112+
return delegate.parseVersion(ex.getVersion());
113+
}
114+
catch (Exception parseEx) {
115+
Comparable<?> defaultVersion = delegate.getDefaultVersion();
116+
return defaultVersion != null ? defaultVersion : (Comparable<?>) ex.getVersion();
117+
}
118+
}
119+
88120
@Override
89121
public @Nullable String resolveVersion(ServerWebExchange exchange) {
90122
return delegate.resolveVersion(exchange);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2026 the original author or authors.
8+
* * * * * *
9+
* * * * * * Licensed under the Apache License, Version 2.0 (the "License");
10+
* * * * * * you may not use this file except in compliance with the License.
11+
* * * * * * You may obtain a copy of the License at
12+
* * * * * *
13+
* * * * * * https://www.apache.org/licenses/LICENSE-2.0
14+
* * * * * *
15+
* * * * * * Unless required by applicable law or agreed to in writing, software
16+
* * * * * * distributed under the License is distributed on an "AS IS" BASIS,
17+
* * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* * * * * * See the License for the specific language governing permissions and
19+
* * * * * * limitations under the License.
20+
* * * * *
21+
* * * *
22+
* * *
23+
* *
24+
*
25+
*/
26+
27+
package test.org.springdoc.api.v31.app203;
28+
29+
import test.org.springdoc.api.v31.AbstractSpringDocTest;
30+
31+
import org.springframework.boot.autoconfigure.SpringBootApplication;
32+
import org.springframework.context.annotation.ComponentScan;
33+
34+
/**
35+
* Test for path-based API versioning with versionRequired=true and no default version.
36+
*
37+
* @author bnasslahsen
38+
*/
39+
public class SpringDocApp203Test extends AbstractSpringDocTest {
40+
41+
@SpringBootApplication
42+
@ComponentScan(basePackages = { "org.springdoc", "test.org.springdoc.api.v31.app203" })
43+
static class SpringDocTestApp {}
44+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package test.org.springdoc.api.v31.app203.config;
2+
3+
/**
4+
* @author bnasslahsen
5+
*/
6+
public class ApiVersionParser implements org.springframework.web.accept.ApiVersionParser {
7+
8+
// allows us to use /api/v2/users instead of /api/2.0/users
9+
@Override
10+
public Comparable parseVersion(String version) {
11+
// Remove "v" prefix if it exists (v1 becomes 1, v2 becomes 2)
12+
if (version.startsWith("v") || version.startsWith("V")) {
13+
version = version.substring(1);
14+
}
15+
return version;
16+
}
17+
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package test.org.springdoc.api.v31.app203.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.web.reactive.config.ApiVersionConfigurer;
5+
import org.springframework.web.reactive.config.WebFluxConfigurer;
6+
7+
/**
8+
* @author bnasslahsen
9+
*/
10+
@Configuration
11+
public class WebConfig implements WebFluxConfigurer {
12+
13+
@Override
14+
public void configureApiVersioning(ApiVersionConfigurer configurer) {
15+
configurer
16+
.usePathSegment(1)
17+
.detectSupportedVersions(false)
18+
.addSupportedVersions("1.0", "2.0")
19+
.setVersionRequired(true)
20+
.setVersionParser(new ApiVersionParser());
21+
}
22+
23+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package test.org.springdoc.api.v31.app203.user;
2+
3+
public record User(
4+
Integer id,
5+
String name,
6+
String email
7+
) {
8+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package test.org.springdoc.api.v31.app203.user;
2+
3+
import java.util.List;
4+
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
import org.springframework.web.bind.annotation.GetMapping;
9+
import org.springframework.web.bind.annotation.RequestMapping;
10+
import org.springframework.web.bind.annotation.RestController;
11+
12+
@RestController
13+
@RequestMapping("/api")
14+
public class UserController {
15+
16+
private static final Logger log = LoggerFactory.getLogger(UserController.class);
17+
private final UserRepository userRepository;
18+
19+
public UserController(UserRepository userRepository) {
20+
this.userRepository = userRepository;
21+
}
22+
23+
@GetMapping(value = "/{version}/users", version = "1.0")
24+
public List<User> findAllv1() {
25+
log.info("Finding all users v1");
26+
return userRepository.findAll();
27+
}
28+
29+
@GetMapping(value = "/{version}/users", version = "2.0")
30+
public List<User> findAllv2() {
31+
log.info("Finding all users v2");
32+
return userRepository.findAll();
33+
}
34+
35+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package test.org.springdoc.api.v31.app203.user;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
6+
import jakarta.annotation.PostConstruct;
7+
8+
import org.springframework.stereotype.Repository;
9+
10+
@Repository
11+
public class UserRepository {
12+
13+
private final List<User> users = new ArrayList<>();
14+
15+
public List<User> findAll() {
16+
return users;
17+
}
18+
19+
@PostConstruct
20+
private void init() {
21+
users.add(new User(1,"Dan Vega","danvega@gmail.com"));
22+
}
23+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "OpenAPI definition",
5+
"version": "v0"
6+
},
7+
"servers": [
8+
{
9+
"url": "",
10+
"description": "Generated server url"
11+
}
12+
],
13+
"paths": {
14+
"/api/2.0/users": {
15+
"get": {
16+
"tags": [
17+
"user-controller"
18+
],
19+
"operationId": "findAllv2",
20+
"responses": {
21+
"200": {
22+
"description": "OK",
23+
"content": {
24+
"*/*": {
25+
"schema": {
26+
"type": "array",
27+
"items": {
28+
"$ref": "#/components/schemas/User"
29+
}
30+
}
31+
}
32+
}
33+
}
34+
}
35+
}
36+
},
37+
"/api/1.0/users": {
38+
"get": {
39+
"tags": [
40+
"user-controller"
41+
],
42+
"operationId": "findAllv1",
43+
"responses": {
44+
"200": {
45+
"description": "OK",
46+
"content": {
47+
"*/*": {
48+
"schema": {
49+
"type": "array",
50+
"items": {
51+
"$ref": "#/components/schemas/User"
52+
}
53+
}
54+
}
55+
}
56+
}
57+
}
58+
}
59+
}
60+
},
61+
"components": {
62+
"schemas": {
63+
"User": {
64+
"type": "object",
65+
"properties": {
66+
"id": {
67+
"type": "integer",
68+
"format": "int32"
69+
},
70+
"name": {
71+
"type": "string"
72+
},
73+
"email": {
74+
"type": "string"
75+
}
76+
}
77+
}
78+
}
79+
}
80+
}

springdoc-openapi-starter-webmvc-api/src/main/java/org/springdoc/webmvc/api/SpringDocApiVersionStrategy.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
import org.springframework.web.accept.ApiVersionStrategy;
3737
import org.springframework.web.accept.InvalidApiVersionException;
38+
import org.springframework.web.accept.MissingApiVersionException;
3839

3940
/**
4041
* Servlet-based delegating {@link ApiVersionStrategy} that gracefully handles springdoc endpoint paths.
@@ -66,13 +67,36 @@ public SpringDocApiVersionStrategy(ApiVersionStrategy delegate, List<String> spr
6667
return delegate.resolveParseAndValidateVersion(request);
6768
}
6869
catch (InvalidApiVersionException ex) {
70+
if (isSpringDocPath(request)) {
71+
return resolveVersionForSpringDocPath(ex);
72+
}
73+
throw ex;
74+
}
75+
catch (MissingApiVersionException ex) {
6976
if (isSpringDocPath(request)) {
7077
return delegate.getDefaultVersion();
7178
}
7279
throw ex;
7380
}
7481
}
7582

83+
/**
84+
* Resolve a version for springdoc paths when validation fails.
85+
* <p>Re-parses the invalid version string without validation, since springdoc
86+
* endpoints have no version condition and will match any version.
87+
*
88+
* @param ex the invalid API version exception
89+
* @return the parsed version, or the default version as fallback
90+
*/
91+
private @Nullable Comparable<?> resolveVersionForSpringDocPath(InvalidApiVersionException ex) {
92+
try {
93+
return delegate.parseVersion(ex.getVersion());
94+
}
95+
catch (Exception parseEx) {
96+
return delegate.getDefaultVersion();
97+
}
98+
}
99+
76100
@Override
77101
public @Nullable String resolveVersion(HttpServletRequest request) {
78102
return delegate.resolveVersion(request);

0 commit comments

Comments
 (0)