Skip to content

Commit 51fb9aa

Browse files
authored
Add support for default routing functionality to functions in server webmvc (#3716)
* Initial functionality for default function routing Signed-off-by: Oleg Zhurakousky <ozhurakousky@vmware.com> * Add ability to default to s-c-function's RoutingFunction if target function can't be found Signed-off-by: Oleg Zhurakousky <ozhurakousky@vmware.com> * Fix tests Added to teh tests that do not rely on spring-cloud-function Signed-off-by: Oleg Zhurakousky <ozhurakousky@vmware.com> --------- Signed-off-by: Oleg Zhurakousky <ozhurakousky@vmware.com>
1 parent 8553c04 commit 51fb9aa

File tree

9 files changed

+240
-33
lines changed

9 files changed

+240
-33
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2013-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.server.mvc.config;
18+
19+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
20+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
21+
import org.springframework.context.annotation.Bean;
22+
import org.springframework.context.annotation.Configuration;
23+
import org.springframework.web.servlet.function.RouterFunction;
24+
import org.springframework.web.servlet.function.ServerResponse;
25+
26+
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
27+
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.fn;
28+
29+
@Configuration
30+
@ConditionalOnClass(name = "org.springframework.cloud.function.context.FunctionCatalog")
31+
@ConditionalOnProperty(name = "spring.cloud.gateway.function.enabled", havingValue = "true", matchIfMissing = true)
32+
public class DefaultFunctionConfiguration {
33+
34+
@Bean
35+
RouterFunction<ServerResponse> gatewayToFunctionRouter() {
36+
// @formatter:off
37+
return route("functionroute")
38+
.POST("/{path}/{name}", fn("{path}/{name}"))
39+
.POST("/{path}", fn("{path}"))
40+
.GET("/{path}/{name}", fn("{path}/{name}"))
41+
.GET("/{path}", fn("{path}"))
42+
.build();
43+
// @formatter:on
44+
}
45+
46+
}

spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.cloud.gateway.server.mvc.handler;
1818

1919
import java.util.List;
20+
import java.util.Map;
2021
import java.util.stream.Collectors;
2122
import java.util.stream.StreamSupport;
2223

@@ -50,9 +51,16 @@ private FunctionHandlerRequestProcessingHelper() {
5051

5152
}
5253

53-
@SuppressWarnings({ "rawtypes", "unchecked" })
5454
static ServerResponse processRequest(ServerRequest request, FunctionInvocationWrapper function, Object argument,
5555
boolean eventStream, List<String> ignoredHeaders, List<String> requestOnlyHeaders) {
56+
return processRequest(request, function, argument, eventStream, ignoredHeaders, requestOnlyHeaders, null);
57+
}
58+
59+
@SuppressWarnings({ "rawtypes", "unchecked" })
60+
static ServerResponse processRequest(ServerRequest request, FunctionInvocationWrapper function, Object argument,
61+
boolean eventStream, List<String> ignoredHeaders, List<String> requestOnlyHeaders,
62+
Map<String, String> additionalHeaders) {
63+
5664
if (argument == null) {
5765
argument = "";
5866
}
@@ -70,42 +78,29 @@ static ServerResponse processRequest(ServerRequest request, FunctionInvocationWr
7078
builder = builder.setHeader(FunctionHandlerHeaderUtils.HTTP_REQUEST_PARAM,
7179
request.params().toSingleValueMap());
7280
}
81+
82+
if (!CollectionUtils.isEmpty(additionalHeaders)) {
83+
builder.copyHeaders(additionalHeaders);
84+
}
7385
inputMessage = builder.copyHeaders(headers.toSingleValueMap()).build();
7486

7587
if (function.isRoutingFunction()) {
7688
function.setSkipOutputConversion(true);
7789
}
7890

91+
if (logger.isDebugEnabled()) {
92+
logger.debug("Sending request to " + function + " with argument: " + inputMessage);
93+
}
7994
Object result = function.apply(inputMessage);
8095
if (function.isConsumer()) {
81-
/*
82-
* if (result instanceof Publisher) { Mono.from((Publisher)
83-
* result).subscribe(); }
84-
*/
8596
return HttpMethod.DELETE.equals(request.method()) ? ServerResponse.ok().build()
8697
: ServerResponse.accepted()
8798
.headers(h -> h.addAll(sanitize(headers, ignoredHeaders, requestOnlyHeaders)))
8899
.build();
89-
// Mono.empty() :
90-
// Mono.just(ResponseEntity.accepted().headers(FunctionHandlerHeaderUtils.sanitize(headers,
91-
// ignoredHeaders, requestOnlyHeaders)).build());
92100
}
93101

94102
BodyBuilder responseOkBuilder = ServerResponse.ok()
95103
.headers(h -> h.addAll(sanitize(headers, ignoredHeaders, requestOnlyHeaders)));
96-
97-
// FIXME: Mono/Flux
98-
/*
99-
* Publisher pResult; if (result instanceof Publisher) { pResult = (Publisher)
100-
* result; if (eventStream) { return Flux.from(pResult); }
101-
*
102-
* if (pResult instanceof Flux) { pResult = ((Flux) pResult).onErrorContinue((e,
103-
* v) -> { logger.error("Failed to process value: " + v, (Throwable) e);
104-
* }).collectList(); } pResult = Mono.from(pResult); } else { pResult =
105-
* Mono.just(result); }
106-
*/
107-
108-
// return Mono.from(pResult).map(v -> {
109104
if (result instanceof Iterable i) {
110105
List aggregatedResult = (List) StreamSupport.stream(i.spliterator(), false).map(m -> {
111106
return m instanceof Message ? processMessage(responseOkBuilder, (Message<?>) m, ignoredHeaders) : m;
@@ -118,7 +113,6 @@ else if (result instanceof Message message) {
118113
else {
119114
return responseOkBuilder.body(result);
120115
}
121-
// });
122116
}
123117

124118
private static Object processMessage(BodyBuilder responseOkBuilder, Message<?> message,

spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,19 @@
2222
import java.util.Arrays;
2323
import java.util.Collection;
2424
import java.util.Collections;
25+
import java.util.HashMap;
26+
import java.util.Map;
2527
import java.util.concurrent.atomic.AtomicReference;
2628

2729
import jakarta.servlet.ServletException;
30+
import org.apache.commons.logging.Log;
31+
import org.apache.commons.logging.LogFactory;
2832

2933
import org.springframework.cloud.function.context.FunctionCatalog;
34+
import org.springframework.cloud.function.context.FunctionProperties;
3035
import org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry.FunctionInvocationWrapper;
36+
import org.springframework.cloud.function.context.config.RoutingFunction;
37+
import org.springframework.cloud.gateway.server.mvc.GatewayMvcClassPathWarningAutoConfiguration;
3138
import org.springframework.cloud.gateway.server.mvc.common.MvcUtils;
3239
import org.springframework.cloud.gateway.server.mvc.config.RouteProperties;
3340
import org.springframework.cloud.stream.function.StreamOperations;
@@ -43,6 +50,8 @@
4350

4451
public abstract class HandlerFunctions {
4552

53+
private static final Log log = LogFactory.getLog(GatewayMvcClassPathWarningAutoConfiguration.class);
54+
4655
private HandlerFunctions() {
4756

4857
}
@@ -56,13 +65,44 @@ public static HandlerFunction<ServerResponse> fn(RouteProperties routeProperties
5665
public static HandlerFunction<ServerResponse> fn(String functionName) {
5766
Assert.hasText(functionName, "'functionName' must not be empty");
5867
return request -> {
59-
String expandedFunctionName = MvcUtils.expand(request, functionName);
6068
FunctionCatalog functionCatalog = MvcUtils.getApplicationContext(request).getBean(FunctionCatalog.class);
61-
FunctionInvocationWrapper function = functionCatalog.lookup(expandedFunctionName,
62-
request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new));
69+
String expandedFunctionName = MvcUtils.expand(request, functionName);
70+
FunctionInvocationWrapper function;
71+
Object body = null;
72+
if (expandedFunctionName.contains("/")) {
73+
String[] functionBodySplit = expandedFunctionName.split("/");
74+
function = functionCatalog.lookup(functionBodySplit[0],
75+
request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new));
76+
if (function != null && function.isSupplier()) {
77+
log.warn("Supplier must not have any arguments. Supplier: '" + function.getFunctionDefinition()
78+
+ "' has '" + functionBodySplit[1] + "' as an argument which is ignored.");
79+
}
80+
body = functionBodySplit[1];
81+
}
82+
else {
83+
function = functionCatalog.lookup(expandedFunctionName,
84+
request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new));
85+
}
86+
87+
/*
88+
* If function can not be found in the current runtime, we will default to
89+
* RoutingFunction which has additional logic to determine the function to
90+
* invoke.
91+
*/
92+
Map<String, String> additionalRequestHeaders = new HashMap<>();
93+
if (function == null) {
94+
additionalRequestHeaders.put(FunctionProperties.FUNCTION_DEFINITION, expandedFunctionName);
95+
96+
function = functionCatalog.lookup(RoutingFunction.FUNCTION_NAME,
97+
request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new));
98+
}
99+
63100
if (function != null) {
64-
Object body = function.isSupplier() ? null : request.body(function.getRawInputType());
65-
return processRequest(request, function, body, false, Collections.emptyList(), Collections.emptyList());
101+
if (body == null) {
102+
body = function.isSupplier() ? null : request.body(function.getRawInputType());
103+
}
104+
return processRequest(request, function, body, false, Collections.emptyList(), Collections.emptyList(),
105+
additionalRequestHeaders);
66106
}
67107
return ServerResponse.notFound().build();
68108
};
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
org.springframework.cloud.gateway.server.mvc.GatewayServerMvcAutoConfiguration
22
org.springframework.cloud.gateway.server.mvc.GatewayMvcClassPathWarningAutoConfiguration
33
org.springframework.cloud.gateway.server.mvc.handler.GatewayMultipartAutoConfiguration
4-
org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration
4+
org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration
5+
org.springframework.cloud.gateway.server.mvc.config.DefaultFunctionConfiguration

spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@
140140
import static org.springframework.web.servlet.function.RequestPredicates.path;
141141

142142
@SuppressWarnings("unchecked")
143-
@SpringBootTest(properties = { "spring.cloud.gateway.mvc.http-client.type=jdk" },
143+
@SpringBootTest(
144+
properties = { "spring.cloud.gateway.mvc.http-client.type=jdk", "spring.cloud.gateway.function.enabled=false" },
144145
webEnvironment = WebEnvironment.RANDOM_PORT)
145146
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
146147
@ExtendWith(OutputCaptureExtension.class)

spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
import static org.assertj.core.api.Assertions.assertThat;
5454
import static org.springframework.cloud.gateway.server.mvc.test.TestUtils.getMap;
5555

56-
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
56+
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
57+
properties = { "spring.cloud.gateway.function.enabled=false" })
5758
@ActiveProfiles("propertiesbeandefinitionregistrartests")
5859
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
5960
public class GatewayMvcPropertiesBeanDefinitionRegistrarTests {

spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
5757
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
5858

59-
@SuppressWarnings("unchecked")
60-
@SpringBootTest(properties = {}, webEnvironment = WebEnvironment.RANDOM_PORT)
59+
@SpringBootTest(properties = { "spring.cloud.gateway.function.enabled=false" },
60+
webEnvironment = WebEnvironment.RANDOM_PORT)
6161
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
6262
public class RetryFilterFunctionTests {
6363

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2013-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.cloud.gateway.server.mvc.handler;
18+
19+
import java.util.Locale;
20+
import java.util.function.Consumer;
21+
import java.util.function.Function;
22+
import java.util.function.Supplier;
23+
24+
import org.junit.jupiter.api.Test;
25+
26+
import org.springframework.beans.factory.annotation.Autowired;
27+
import org.springframework.boot.SpringBootConfiguration;
28+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
29+
import org.springframework.boot.test.context.SpringBootTest;
30+
import org.springframework.cloud.gateway.server.mvc.test.client.TestRestClient;
31+
import org.springframework.context.annotation.Bean;
32+
import org.springframework.http.MediaType;
33+
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
36+
@SpringBootTest(properties = {}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
37+
public class DefaultRouteFunctionHandlerTests {
38+
39+
@Autowired
40+
private TestRestClient restClient;
41+
42+
@Test
43+
public void testSupplierWorks() {
44+
restClient.get()
45+
.uri("/hello")
46+
.accept(MediaType.TEXT_PLAIN)
47+
.exchange()
48+
.expectStatus()
49+
.isOk()
50+
.expectBody(String.class)
51+
.isEqualTo("hello");
52+
}
53+
54+
@Test
55+
public void testFunctionWorksGET() {
56+
restClient.get()
57+
.uri("/upper/bob")
58+
.accept(MediaType.TEXT_PLAIN)
59+
.exchange()
60+
.expectStatus()
61+
.isOk()
62+
.expectBody(String.class)
63+
.isEqualTo("BOB");
64+
}
65+
66+
@Test
67+
public void testFunctionWorksPOST() {
68+
restClient.post()
69+
.uri("/upper")
70+
.accept(MediaType.APPLICATION_JSON)
71+
.bodyValue("bob")
72+
.exchange()
73+
.expectStatus()
74+
.isOk()
75+
.expectBody(String.class)
76+
.isEqualTo("BOB");
77+
}
78+
79+
@Test
80+
public void testConsumerWorksGET() {
81+
restClient.get().uri("/consume/hello").accept(MediaType.TEXT_PLAIN).exchange().expectStatus().isAccepted();
82+
assertThat(TestConfiguration.consumerInvoked).isTrue();
83+
}
84+
85+
@Test
86+
public void testConsumerWorksPOST() {
87+
restClient.post()
88+
.uri("/consume")
89+
.accept(MediaType.APPLICATION_JSON)
90+
.bodyValue("hello")
91+
.exchange()
92+
.expectStatus()
93+
.isAccepted();
94+
assertThat(TestConfiguration.consumerInvoked).isTrue();
95+
}
96+
97+
@SpringBootConfiguration
98+
@EnableAutoConfiguration
99+
protected static class TestConfiguration {
100+
101+
static boolean consumerInvoked;
102+
103+
@Bean
104+
Function<String, String> upper() {
105+
return s -> s.toUpperCase(Locale.ROOT);
106+
}
107+
108+
@Bean
109+
Consumer<String> consume() {
110+
return s -> {
111+
consumerInvoked = false;
112+
assertThat(s).isEqualTo("hello");
113+
consumerInvoked = true;
114+
};
115+
}
116+
117+
@Bean
118+
Supplier<String> hello() {
119+
return () -> "hello";
120+
}
121+
122+
}
123+
124+
}

spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
* @author Denis Cutic
5555
* @author Andrey Muchnik
5656
*/
57-
@SpringBootTest(webEnvironment = RANDOM_PORT)
57+
@SpringBootTest(webEnvironment = RANDOM_PORT, properties = { "spring.cloud.gateway.function.enabled=false" })
5858
@DirtiesContext
5959
@Testcontainers
6060
@Tag("DockerRequired")

0 commit comments

Comments
 (0)