Skip to content

Commit 8f410d7

Browse files
committed
Gracefully handle springdoc endpoint paths during API version resolution. fixes #3232
1 parent 2057ccf commit 8f410d7

35 files changed

Lines changed: 773 additions & 133 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2025 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 org.springdoc.core.versions;
28+
29+
import java.util.List;
30+
31+
/**
32+
* Abstract base for delegating API version strategies that gracefully handle springdoc endpoint paths.
33+
* <p>
34+
* When path-based API versioning is configured (e.g., {@code usePathSegment(1)}),
35+
* the version resolver extracts a path segment from every request URI as the API version.
36+
* For springdoc endpoints like {@code /v3/api-docs}, this incorrectly extracts
37+
* {@code "api-docs"} as the version, causing an {@code InvalidApiVersionException}.
38+
* <p>
39+
* Subclasses wrap the platform-specific {@code ApiVersionStrategy} (servlet or reactive)
40+
* and use {@link #isSpringDocPath(String)} to detect springdoc paths before falling back
41+
* to the default version.
42+
*
43+
* @author bnasslahsen
44+
*/
45+
public abstract class AbstractSpringDocApiVersionStrategy {
46+
47+
/**
48+
* The springdoc path prefixes to protect from version resolution.
49+
*/
50+
private final List<String> springDocPaths;
51+
52+
/**
53+
* Instantiates a new abstract SpringDoc API version strategy.
54+
*
55+
* @param springDocPaths the springdoc path prefixes to protect
56+
*/
57+
protected AbstractSpringDocApiVersionStrategy(List<String> springDocPaths) {
58+
this.springDocPaths = springDocPaths;
59+
}
60+
61+
/**
62+
* Check if the given path targets a springdoc endpoint.
63+
*
64+
* @param path the request path (without context path)
65+
* @return true if the path matches a springdoc endpoint prefix
66+
*/
67+
protected boolean isSpringDocPath(String path) {
68+
for (String springDocPath : springDocPaths) {
69+
if (path.startsWith(springDocPath)) {
70+
int len = springDocPath.length();
71+
if (path.length() == len || path.charAt(len) == '/' || path.charAt(len) == '.') {
72+
return true;
73+
}
74+
}
75+
}
76+
return false;
77+
}
78+
79+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * *
7+
* * * * * * Copyright 2019-2025 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 org.springdoc.webflux.api;
28+
29+
import java.util.List;
30+
31+
import org.jspecify.annotations.Nullable;
32+
import org.springdoc.core.versions.AbstractSpringDocApiVersionStrategy;
33+
import reactor.core.publisher.Mono;
34+
35+
import org.springframework.web.accept.InvalidApiVersionException;
36+
import org.springframework.web.reactive.accept.ApiVersionStrategy;
37+
import org.springframework.web.server.ServerWebExchange;
38+
39+
/**
40+
* Reactive delegating {@link ApiVersionStrategy} that gracefully handles springdoc endpoint paths.
41+
*
42+
* @author bnasslahsen
43+
* @see AbstractSpringDocApiVersionStrategy
44+
*/
45+
public class SpringDocApiVersionStrategy extends AbstractSpringDocApiVersionStrategy implements ApiVersionStrategy {
46+
47+
/**
48+
* The delegate strategy.
49+
*/
50+
private final ApiVersionStrategy delegate;
51+
52+
/**
53+
* Instantiates a new SpringDoc API version strategy.
54+
*
55+
* @param delegate the delegate strategy
56+
* @param springDocPaths the springdoc path prefixes to protect
57+
*/
58+
public SpringDocApiVersionStrategy(ApiVersionStrategy delegate, List<String> springDocPaths) {
59+
super(springDocPaths);
60+
this.delegate = delegate;
61+
}
62+
63+
@Override
64+
public @Nullable Comparable<?> resolveParseAndValidateVersion(ServerWebExchange exchange) {
65+
try {
66+
return delegate.resolveParseAndValidateVersion(exchange);
67+
}
68+
catch (InvalidApiVersionException ex) {
69+
if (isSpringDocPath(exchange)) {
70+
return delegate.getDefaultVersion();
71+
}
72+
throw ex;
73+
}
74+
}
75+
76+
@Override
77+
public Mono<Comparable<?>> resolveParseAndValidateApiVersion(ServerWebExchange exchange) {
78+
return delegate.resolveParseAndValidateApiVersion(exchange)
79+
.onErrorResume(InvalidApiVersionException.class, ex -> {
80+
if (isSpringDocPath(exchange)) {
81+
Comparable<?> defaultVersion = delegate.getDefaultVersion();
82+
return defaultVersion != null ? Mono.just(defaultVersion) : Mono.empty();
83+
}
84+
return Mono.error(ex);
85+
});
86+
}
87+
88+
@Override
89+
public @Nullable String resolveVersion(ServerWebExchange exchange) {
90+
return delegate.resolveVersion(exchange);
91+
}
92+
93+
@Override
94+
public Comparable<?> parseVersion(String version) {
95+
return delegate.parseVersion(version);
96+
}
97+
98+
@Override
99+
public void validateVersion(@Nullable Comparable<?> requestVersion, ServerWebExchange exchange) {
100+
delegate.validateVersion(requestVersion, exchange);
101+
}
102+
103+
@Override
104+
public @Nullable Comparable<?> getDefaultVersion() {
105+
return delegate.getDefaultVersion();
106+
}
107+
108+
@Override
109+
public void handleDeprecations(Comparable<?> version, Object handler, ServerWebExchange exchange) {
110+
delegate.handleDeprecations(version, handler, exchange);
111+
}
112+
113+
/**
114+
* Check if the request targets a springdoc endpoint path.
115+
*
116+
* @param exchange the server web exchange
117+
* @return true if the request is for a springdoc endpoint
118+
*/
119+
private boolean isSpringDocPath(ServerWebExchange exchange) {
120+
String path = exchange.getRequest().getPath().pathWithinApplication().value();
121+
return isSpringDocPath(path);
122+
}
123+
124+
}

springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/configuration/SpringDocWebFluxConfiguration.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
package org.springdoc.webflux.core.configuration;
2828

29+
import java.util.ArrayList;
30+
import java.util.List;
2931
import java.util.Optional;
3032

3133
import org.springdoc.core.configuration.SpringDocConfiguration;
@@ -34,6 +36,7 @@
3436
import org.springdoc.core.discoverer.SpringDocParameterNameDiscoverer;
3537
import org.springdoc.core.extractor.MethodParameterPojoExtractor;
3638
import org.springdoc.core.properties.SpringDocConfigProperties;
39+
import org.springdoc.core.properties.SwaggerUiConfigProperties;
3740
import org.springdoc.core.providers.ActuatorProvider;
3841
import org.springdoc.core.providers.SpringDocProviders;
3942
import org.springdoc.core.providers.SpringWebProvider;
@@ -43,14 +46,17 @@
4346
import org.springdoc.core.service.OpenAPIService;
4447
import org.springdoc.core.service.OperationService;
4548
import org.springdoc.core.service.RequestBodyService;
49+
import org.springdoc.core.utils.Constants;
4650
import org.springdoc.core.utils.PropertyResolverUtils;
4751
import org.springdoc.webflux.api.OpenApiActuatorResource;
4852
import org.springdoc.webflux.api.OpenApiWebfluxResource;
53+
import org.springdoc.webflux.api.SpringDocApiVersionStrategy;
4954
import org.springdoc.webflux.core.providers.ActuatorWebFluxProvider;
5055
import org.springdoc.webflux.core.providers.SpringWebFluxProvider;
5156
import org.springdoc.webflux.core.service.RequestService;
5257

5358
import org.springframework.beans.factory.ObjectFactory;
59+
import org.springframework.beans.factory.SmartInitializingSingleton;
5460
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
5561
import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort;
5662
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
@@ -66,6 +72,7 @@
6672
import org.springframework.context.annotation.Configuration;
6773
import org.springframework.context.annotation.Lazy;
6874
import org.springframework.web.reactive.accept.ApiVersionStrategy;
75+
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
6976

7077
import static org.springdoc.core.utils.Constants.SPRINGDOC_ENABLED;
7178

@@ -154,6 +161,39 @@ SpringWebProvider springWebProvider(Optional<ApiVersionStrategy> apiVersionStrat
154161
return new SpringWebFluxProvider(apiVersionStrategyOptional);
155162
}
156163

164+
/**
165+
* Wraps the {@link ApiVersionStrategy} on all {@link RequestMappingHandlerMapping} beans
166+
* to gracefully handle springdoc endpoint paths during API version resolution.
167+
*
168+
* @param apiVersionStrategyOptional the api version strategy optional
169+
* @param springDocConfigProperties the spring doc config properties
170+
* @param swaggerUiConfigPropertiesOptional the swagger ui config properties optional
171+
* @param handlerMappings the request mapping handler mappings
172+
* @return the smart initializing singleton
173+
*/
174+
@Bean
175+
@Lazy(false)
176+
SmartInitializingSingleton springDocApiVersionCustomizer(
177+
Optional<ApiVersionStrategy> apiVersionStrategyOptional,
178+
SpringDocConfigProperties springDocConfigProperties,
179+
Optional<SwaggerUiConfigProperties> swaggerUiConfigPropertiesOptional,
180+
List<RequestMappingHandlerMapping> handlerMappings) {
181+
return () -> apiVersionStrategyOptional.ifPresent(strategy -> {
182+
List<String> springDocPaths = new ArrayList<>();
183+
springDocPaths.add(springDocConfigProperties.getApiDocs().getPath());
184+
swaggerUiConfigPropertiesOptional.ifPresent(swaggerUiConfig -> {
185+
springDocPaths.add(swaggerUiConfig.getPath());
186+
springDocPaths.add(Constants.SWAGGER_UI_PREFIX);
187+
});
188+
for (RequestMappingHandlerMapping mapping : handlerMappings) {
189+
ApiVersionStrategy original = mapping.getApiVersionStrategy();
190+
if (original != null) {
191+
mapping.setApiVersionStrategy(new SpringDocApiVersionStrategy(original, springDocPaths));
192+
}
193+
}
194+
});
195+
}
196+
157197
/**
158198
* The type Spring doc web flux actuator configuration.
159199
*

springdoc-openapi-starter-webflux-api/src/test/java/test/org/springdoc/api/v31/app193/config/ApiVersionParser.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,6 @@ public Comparable parseVersion(String version) {
99
if (version.startsWith("v") || version.startsWith("V")) {
1010
version = version.substring(1);
1111
}
12-
13-
if("api-docs".equals(version) || "index.html".equals(version)
14-
|| "swagger-ui-bundle.js".equals(version)
15-
|| "swagger-ui.css".equals(version)
16-
|| "index.css".equals(version)
17-
|| "swagger-ui-standalone-preset.js".equals(version)
18-
|| "favicon-32x32.png".equals(version)
19-
|| "favicon-16x16.png".equals(version)
20-
|| "swagger-initializer.js".equals(version))
21-
return null;
2212
return version;
2313
}
2414
}

springdoc-openapi-starter-webflux-api/src/test/java/test/org/springdoc/api/v31/app195/config/ApiVersionParser.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,6 @@ public Comparable parseVersion(String version) {
99
if (version.startsWith("v") || version.startsWith("V")) {
1010
version = version.substring(1);
1111
}
12-
13-
if("api-docs".equals(version) || "index.html".equals(version)
14-
|| "swagger-ui-bundle.js".equals(version)
15-
|| "swagger-ui.css".equals(version)
16-
|| "index.css".equals(version)
17-
|| "swagger-ui-standalone-preset.js".equals(version)
18-
|| "favicon-32x32.png".equals(version)
19-
|| "favicon-16x16.png".equals(version)
20-
|| "swagger-initializer.js".equals(version))
21-
return null;
2212
return version;
2313
}
2414
}

springdoc-openapi-starter-webflux-api/src/test/java/test/org/springdoc/api/v31/app196/config/ApiVersionParser.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,6 @@ public Comparable parseVersion(String version) {
99
if (version.startsWith("v") || version.startsWith("V")) {
1010
version = version.substring(1);
1111
}
12-
13-
if("api-docs".equals(version) || "index.html".equals(version)
14-
|| "swagger-ui-bundle.js".equals(version)
15-
|| "swagger-ui.css".equals(version)
16-
|| "index.css".equals(version)
17-
|| "swagger-ui-standalone-preset.js".equals(version)
18-
|| "favicon-32x32.png".equals(version)
19-
|| "favicon-16x16.png".equals(version)
20-
|| "swagger-initializer.js".equals(version))
21-
return null;
2212
return version;
2313
}
2414
}

springdoc-openapi-starter-webflux-api/src/test/java/test/org/springdoc/api/v31/app199/config/ApiVersionParser.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,7 @@ public Comparable parseVersion(String version) {
3636
if (version.startsWith("v") || version.startsWith("V")) {
3737
version = version.substring(1);
3838
}
39-
if ("api-docs".equals(version) || "index.html".equals(version) || "swagger-ui-bundle.js".equals(version)
40-
|| "swagger-ui.css".equals(version) || "index.css".equals(version)
41-
|| "swagger-ui-standalone-preset.js".equals(version) || "favicon-32x32.png".equals(version)
42-
|| "favicon-16x16.png".equals(version) || "swagger-initializer.js".equals(version))
43-
return null;
39+
4440
return version;
4541
}
4642

springdoc-openapi-starter-webflux-api/src/test/java/test/org/springdoc/api/v31/app201/config/ApiVersionParser.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,7 @@ public Comparable parseVersion(String version) {
3636
if (version.startsWith("v") || version.startsWith("V")) {
3737
version = version.substring(1);
3838
}
39-
if ("api-docs".equals(version) || "index.html".equals(version) || "swagger-ui-bundle.js".equals(version)
40-
|| "swagger-ui.css".equals(version) || "index.css".equals(version)
41-
|| "swagger-ui-standalone-preset.js".equals(version) || "favicon-32x32.png".equals(version)
42-
|| "favicon-16x16.png".equals(version) || "swagger-initializer.js".equals(version))
43-
return null;
39+
4440
return version;
4541
}
4642

springdoc-openapi-starter-webflux-api/src/test/java/test/org/springdoc/api/v31/app202/config/ApiVersionParser.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,6 @@ public Comparable parseVersion(String version) {
3636
if (version.startsWith("v") || version.startsWith("V")) {
3737
version = version.substring(1);
3838
}
39-
if ("api-docs".equals(version) || "index.html".equals(version) || "swagger-ui-bundle.js".equals(version)
40-
|| "swagger-ui.css".equals(version) || "index.css".equals(version)
41-
|| "swagger-ui-standalone-preset.js".equals(version) || "favicon-32x32.png".equals(version)
42-
|| "favicon-16x16.png".equals(version) || "swagger-initializer.js".equals(version))
43-
return null;
4439
return version;
4540
}
4641

0 commit comments

Comments
 (0)