Skip to content

Commit be23b85

Browse files
bnasslahsenclaude
andcommitted
feat(core): add API versioning support for functional router endpoints
Capture version info from RequestPredicates.version() in the router function visitor and flow it through RouterFunctionData to resolve the appropriate SpringDocVersionStrategy (path, media-type, header, query-param) for functional DSL routes. Fixes #2975 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7be72d commit be23b85

29 files changed

Lines changed: 1052 additions & 15 deletions

File tree

springdoc-openapi-starter-common/src/main/java/org/springdoc/api/AbstractOpenApiResource.java

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -861,7 +861,9 @@ protected void getRouterFunctionPaths(String beanName, AbstractRouterFunctionVis
861861
boolean withRouterOperation = routerFunctionVisitor.getRouterFunctionDatas().stream()
862862
.anyMatch(routerFunctionData -> routerFunctionData.getAttributes().containsKey(OPERATION_ATTRIBUTE));
863863
if (withRouterOperation) {
864-
List<RouterOperation> operationList = routerFunctionVisitor.getRouterFunctionDatas().stream().map(RouterOperation::new).collect(Collectors.toList());
864+
List<RouterFunctionData> datas = routerFunctionVisitor.getRouterFunctionDatas();
865+
List<RouterOperation> operationList = datas.stream().map(RouterOperation::new).collect(Collectors.toList());
866+
resolveRouterFunctionVersionStrategies(datas, operationList);
865867
calculatePath(operationList, locale, openAPI);
866868
}
867869
else {
@@ -875,16 +877,48 @@ protected void getRouterFunctionPaths(String beanName, AbstractRouterFunctionVis
875877
}
876878
else
877879
routerOperationList.addAll(Arrays.asList(routerOperations.value()));
878-
if (routerOperationList.size() == 1)
879-
calculatePath(routerOperationList.stream().map(routerOperation -> new RouterOperation(routerOperation, routerFunctionVisitor.getRouterFunctionDatas().get(0))).collect(Collectors.toList()), locale, openAPI);
880+
if (routerOperationList.size() == 1) {
881+
List<RouterFunctionData> datas = routerFunctionVisitor.getRouterFunctionDatas();
882+
List<RouterOperation> operationList = routerOperationList.stream().map(routerOperation -> new RouterOperation(routerOperation, datas.get(0))).collect(Collectors.toList());
883+
resolveRouterFunctionVersionStrategies(datas, operationList);
884+
calculatePath(operationList, locale, openAPI);
885+
}
880886
else {
887+
List<RouterFunctionData> datas = routerFunctionVisitor.getRouterFunctionDatas();
881888
List<RouterOperation> operationList = routerOperationList.stream().map(RouterOperation::new).collect(Collectors.toList());
882-
mergeRouters(routerFunctionVisitor.getRouterFunctionDatas(), operationList);
889+
mergeRouters(datas, operationList);
890+
resolveRouterFunctionVersionStrategies(datas, operationList);
883891
calculatePath(operationList, locale, openAPI);
884892
}
885893
}
886894
}
887895

896+
/**
897+
* Resolve version strategies for router function operations.
898+
*
899+
* @param datas the router function datas
900+
* @param operations the router operations
901+
*/
902+
private void resolveRouterFunctionVersionStrategies(List<RouterFunctionData> datas, List<RouterOperation> operations) {
903+
springDocProviders.getSpringWebProvider().ifPresent(springWebProvider -> {
904+
for (int i = 0; i < operations.size() && i < datas.size(); i++) {
905+
String version = datas.get(i).getVersion();
906+
if (version != null) {
907+
RouterOperation op = operations.get(i);
908+
SpringDocVersionStrategy strategy = springWebProvider.getSpringDocVersionStrategy(version, datas.get(i).getParams());
909+
if (strategy != null) {
910+
// Ensure version is set for functional routes where params may be empty
911+
if (strategy.getVersion() == null) {
912+
strategy.setVersion(version);
913+
}
914+
op.setPath(strategy.updateOperationPath(op.getPath(), version));
915+
}
916+
op.setVersionStrategy(strategy);
917+
}
918+
}
919+
});
920+
}
921+
888922
/**
889923
* Is filter condition boolean.
890924
*

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/fn/AbstractRouterFunctionVisitor.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ public class AbstractRouterFunctionVisitor {
9191
*/
9292
protected Map<String, Object> attributes = new LinkedHashMap<>();
9393

94+
/**
95+
* The Version.
96+
*/
97+
private String version;
98+
9499
/**
95100
* The Level.
96101
*/
@@ -178,6 +183,15 @@ public void queryParam(String name, String value) {
178183
currentRouterFunctionDatas.forEach(routerFunctionData -> routerFunctionData.addQueryParams(name, value));
179184
}
180185

186+
/**
187+
* Version.
188+
*
189+
* @param version the version
190+
*/
191+
public void version(String version) {
192+
this.version = version;
193+
}
194+
181195
/**
182196
* Path extension.
183197
*
@@ -282,8 +296,15 @@ protected void commonStartNested() {
282296
* Common route.
283297
*/
284298
protected void commonRoute() {
299+
String currentVersion = this.version;
300+
this.version = null;
285301
this.routerFunctionDatas.addAll(currentRouterFunctionDatas);
286-
currentRouterFunctionDatas.forEach(routerFunctionData -> routerFunctionData.addAttributes(this.attributes));
302+
currentRouterFunctionDatas.forEach(routerFunctionData -> {
303+
routerFunctionData.addAttributes(this.attributes);
304+
if (currentVersion != null) {
305+
routerFunctionData.setVersion(currentVersion);
306+
}
307+
});
287308
this.attributes = new HashMap<>();
288309
}
289310

springdoc-openapi-starter-common/src/main/java/org/springdoc/core/fn/RouterFunctionData.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ public class RouterFunctionData {
8383
*/
8484
private RequestMethod[] methods;
8585

86+
/**
87+
* The Version.
88+
*/
89+
private String version;
90+
8691
/**
8792
* The Attributes.
8893
*/
@@ -109,6 +114,7 @@ public RouterFunctionData(String nestedOrPath, RouterFunctionData functionData)
109114
this.queryParams = functionData.getQueryParams();
110115
this.methods = functionData.getMethods();
111116
this.attributes = functionData.getAttributes();
117+
this.version = functionData.getVersion();
112118
}
113119

114120
/**
@@ -312,4 +318,22 @@ public Map<String, Object> getAttributes() {
312318
public void addAttributes(Map<String, Object> attributes) {
313319
this.attributes.putAll(attributes);
314320
}
321+
322+
/**
323+
* Gets version.
324+
*
325+
* @return the version
326+
*/
327+
public String getVersion() {
328+
return version;
329+
}
330+
331+
/**
332+
* Sets version.
333+
*
334+
* @param version the version
335+
*/
336+
public void setVersion(String version) {
337+
this.version = version;
338+
}
315339
}

springdoc-openapi-starter-webflux-api/src/main/java/org/springdoc/webflux/core/visitor/RouterFunctionVisitor.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,6 @@ public void unknown(RouterFunction<?> routerFunction) {
9292
// Not yet needed
9393
}
9494

95-
@Override
96-
public void version(String version) {
97-
// Not yet needed
98-
}
99-
10095
@Override
10196
public void unknown(RequestPredicate predicate) {
10297
// Not yet needed

springdoc-openapi-starter-webmvc-api/src/main/java/org/springdoc/webmvc/core/providers/RouterFunctionWebMvcProvider.java

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,6 @@ public void unknown(RouterFunction<?> routerFunction) {
107107
// Not yet needed
108108
}
109109

110-
@Override
111-
public void version(String version) {
112-
// Not yet needed
113-
}
114-
115110
@Override
116111
public void unknown(RequestPredicate predicate) {
117112
// Not yet needed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * * Copyright 2019-2025 the original author or authors.
7+
* * * * *
8+
* * * * * Licensed under the Apache License, Version 2.0 (the "License");
9+
* * * * * you may not use this file except in compliance with the License.
10+
* * * * * You may obtain a copy of the License at
11+
* * * * *
12+
* * * * * https://www.apache.org/licenses/LICENSE-2.0
13+
* * * * *
14+
* * * * * Unless required by applicable law or agreed to in writing, software
15+
* * * * * distributed under the License is distributed on an "AS IS" BASIS,
16+
* * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* * * * * See the License for the specific language governing permissions and
18+
* * * * * limitations under the License.
19+
* * * *
20+
* * *
21+
* *
22+
*
23+
*/
24+
25+
package test.org.springdoc.api.v31.app255;
26+
27+
import test.org.springdoc.api.v31.AbstractSpringDocTest;
28+
29+
import org.springframework.boot.autoconfigure.SpringBootApplication;
30+
31+
/**
32+
* Tests API versioning support for functional router endpoints.
33+
*
34+
* @author bnasslahsen
35+
*/
36+
public class SpringDocApp255Test extends AbstractSpringDocTest {
37+
38+
@SpringBootApplication
39+
static class SpringDocTestApp {
40+
41+
}
42+
43+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * * Copyright 2019-2025 the original author or authors.
7+
* * * * *
8+
* * * * * Licensed under the Apache License, Version 2.0 (the "License");
9+
* * * * * you may not use this file except in compliance with the License.
10+
* * * * * You may obtain a copy of the License at
11+
* * * * *
12+
* * * * * https://www.apache.org/licenses/LICENSE-2.0
13+
* * * * *
14+
* * * * * Unless required by applicable law or agreed to in writing, software
15+
* * * * * distributed under the License is distributed on an "AS IS" BASIS,
16+
* * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* * * * * See the License for the specific language governing permissions and
18+
* * * * * limitations under the License.
19+
* * * *
20+
* * *
21+
* *
22+
*
23+
*/
24+
25+
package test.org.springdoc.api.v31.app255;
26+
27+
import java.util.List;
28+
29+
import org.springframework.stereotype.Component;
30+
import org.springframework.web.servlet.function.ServerRequest;
31+
import org.springframework.web.servlet.function.ServerResponse;
32+
33+
/**
34+
* The type User handler.
35+
*
36+
* @author bnasslahsen
37+
*/
38+
@Component
39+
public class UserHandler {
40+
41+
/**
42+
* Find all server response.
43+
* @param request the request
44+
* @return the server response
45+
*/
46+
public ServerResponse findAll(ServerRequest request) {
47+
return ServerResponse.ok().body(List.of("user1", "user2"));
48+
}
49+
50+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
*
3+
* *
4+
* * *
5+
* * * *
6+
* * * * * Copyright 2019-2025 the original author or authors.
7+
* * * * *
8+
* * * * * Licensed under the Apache License, Version 2.0 (the "License");
9+
* * * * * you may not use this file except in compliance with the License.
10+
* * * * * You may obtain a copy of the License at
11+
* * * * *
12+
* * * * * https://www.apache.org/licenses/LICENSE-2.0
13+
* * * * *
14+
* * * * * Unless required by applicable law or agreed to in writing, software
15+
* * * * * distributed under the License is distributed on an "AS IS" BASIS,
16+
* * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* * * * * See the License for the specific language governing permissions and
18+
* * * * * limitations under the License.
19+
* * * *
20+
* * *
21+
* *
22+
*
23+
*/
24+
25+
package test.org.springdoc.api.v31.app255;
26+
27+
import org.springdoc.webmvc.core.fn.SpringdocRouteBuilder;
28+
29+
import org.springframework.context.annotation.Bean;
30+
import org.springframework.context.annotation.Configuration;
31+
import org.springframework.web.servlet.function.RequestPredicates;
32+
import org.springframework.web.servlet.function.RouterFunction;
33+
import org.springframework.web.servlet.function.ServerResponse;
34+
35+
/**
36+
* The type User router config.
37+
*
38+
* @author bnasslahsen
39+
*/
40+
@Configuration
41+
public class UserRouterConfig {
42+
43+
/**
44+
* Routes router function.
45+
*
46+
* @param handler the handler
47+
* @return the router function
48+
*/
49+
@Bean
50+
RouterFunction<ServerResponse> routes(UserHandler handler) {
51+
return SpringdocRouteBuilder.route()
52+
.GET("/api/{version}/users", RequestPredicates.version("1.0"),
53+
handler::findAll,
54+
ops -> ops.beanClass(UserHandler.class).beanMethod("findAll"))
55+
.build();
56+
}
57+
58+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package test.org.springdoc.api.v31.app255.config;
2+
3+
public class ApiVersionParser implements org.springframework.web.accept.ApiVersionParser {
4+
5+
// allows us to use /api/v2/users instead of /api/2.0/users
6+
@Override
7+
public Comparable parseVersion(String version) {
8+
// Remove "v" prefix if it exists (v1 becomes 1, v2 becomes 2)
9+
if (version.startsWith("v") || version.startsWith("V")) {
10+
version = version.substring(1);
11+
}
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;
22+
return version;
23+
}
24+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package test.org.springdoc.api.v31.app255.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
5+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
6+
7+
@Configuration
8+
public class WebConfig implements WebMvcConfigurer {
9+
10+
@Override
11+
public void configureApiVersioning(ApiVersionConfigurer configurer) {
12+
configurer
13+
.usePathSegment(1)
14+
.detectSupportedVersions(false)
15+
.addSupportedVersions("1.0","2.0")
16+
.setDefaultVersion("1.0")
17+
.setVersionRequired(false)
18+
.setVersionParser(new ApiVersionParser());
19+
}
20+
21+
}

0 commit comments

Comments
 (0)