From 51fb9aa36c9e58eb589154b1fb9f5af6ed50cfa2 Mon Sep 17 00:00:00 2001 From: Oleg Zhurakousky Date: Fri, 21 Mar 2025 17:17:15 +0100 Subject: [PATCH] Add support for default routing functionality to functions in server webmvc (#3716) * Initial functionality for default function routing Signed-off-by: Oleg Zhurakousky * Add ability to default to s-c-function's RoutingFunction if target function can't be found Signed-off-by: Oleg Zhurakousky * Fix tests Added to teh tests that do not rely on spring-cloud-function Signed-off-by: Oleg Zhurakousky --------- Signed-off-by: Oleg Zhurakousky --- .../config/DefaultFunctionConfiguration.java | 46 +++++++ ...unctionHandlerRequestProcessingHelper.java | 38 +++--- .../server/mvc/handler/HandlerFunctions.java | 50 ++++++- ...ot.autoconfigure.AutoConfiguration.imports | 3 +- .../server/mvc/ServerMvcIntegrationTests.java | 3 +- ...ropertiesBeanDefinitionRegistrarTests.java | 3 +- .../mvc/filter/RetryFilterFunctionTests.java | 4 +- .../DefaultRouteFunctionHandlerTests.java | 124 ++++++++++++++++++ .../ratelimit/RedisRateLimiterTests.java | 2 +- 9 files changed, 240 insertions(+), 33 deletions(-) create mode 100644 spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/DefaultFunctionConfiguration.java create mode 100644 spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/DefaultRouteFunctionHandlerTests.java diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/DefaultFunctionConfiguration.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/DefaultFunctionConfiguration.java new file mode 100644 index 00000000..c90e3e58 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/DefaultFunctionConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.fn; + +@Configuration +@ConditionalOnClass(name = "org.springframework.cloud.function.context.FunctionCatalog") +@ConditionalOnProperty(name = "spring.cloud.gateway.function.enabled", havingValue = "true", matchIfMissing = true) +public class DefaultFunctionConfiguration { + + @Bean + RouterFunction gatewayToFunctionRouter() { + // @formatter:off + return route("functionroute") + .POST("/{path}/{name}", fn("{path}/{name}")) + .POST("/{path}", fn("{path}")) + .GET("/{path}/{name}", fn("{path}/{name}")) + .GET("/{path}", fn("{path}")) + .build(); + // @formatter:on + } + +} diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java index 1990991b..fd4f158a 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/FunctionHandlerRequestProcessingHelper.java @@ -17,6 +17,7 @@ package org.springframework.cloud.gateway.server.mvc.handler; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -50,9 +51,16 @@ final class FunctionHandlerRequestProcessingHelper { } - @SuppressWarnings({ "rawtypes", "unchecked" }) static ServerResponse processRequest(ServerRequest request, FunctionInvocationWrapper function, Object argument, boolean eventStream, List ignoredHeaders, List requestOnlyHeaders) { + return processRequest(request, function, argument, eventStream, ignoredHeaders, requestOnlyHeaders, null); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + static ServerResponse processRequest(ServerRequest request, FunctionInvocationWrapper function, Object argument, + boolean eventStream, List ignoredHeaders, List requestOnlyHeaders, + Map additionalHeaders) { + if (argument == null) { argument = ""; } @@ -70,42 +78,29 @@ final class FunctionHandlerRequestProcessingHelper { builder = builder.setHeader(FunctionHandlerHeaderUtils.HTTP_REQUEST_PARAM, request.params().toSingleValueMap()); } + + if (!CollectionUtils.isEmpty(additionalHeaders)) { + builder.copyHeaders(additionalHeaders); + } inputMessage = builder.copyHeaders(headers.toSingleValueMap()).build(); if (function.isRoutingFunction()) { function.setSkipOutputConversion(true); } + if (logger.isDebugEnabled()) { + logger.debug("Sending request to " + function + " with argument: " + inputMessage); + } Object result = function.apply(inputMessage); if (function.isConsumer()) { - /* - * if (result instanceof Publisher) { Mono.from((Publisher) - * result).subscribe(); } - */ return HttpMethod.DELETE.equals(request.method()) ? ServerResponse.ok().build() : ServerResponse.accepted() .headers(h -> h.addAll(sanitize(headers, ignoredHeaders, requestOnlyHeaders))) .build(); - // Mono.empty() : - // Mono.just(ResponseEntity.accepted().headers(FunctionHandlerHeaderUtils.sanitize(headers, - // ignoredHeaders, requestOnlyHeaders)).build()); } BodyBuilder responseOkBuilder = ServerResponse.ok() .headers(h -> h.addAll(sanitize(headers, ignoredHeaders, requestOnlyHeaders))); - - // FIXME: Mono/Flux - /* - * Publisher pResult; if (result instanceof Publisher) { pResult = (Publisher) - * result; if (eventStream) { return Flux.from(pResult); } - * - * if (pResult instanceof Flux) { pResult = ((Flux) pResult).onErrorContinue((e, - * v) -> { logger.error("Failed to process value: " + v, (Throwable) e); - * }).collectList(); } pResult = Mono.from(pResult); } else { pResult = - * Mono.just(result); } - */ - - // return Mono.from(pResult).map(v -> { if (result instanceof Iterable i) { List aggregatedResult = (List) StreamSupport.stream(i.spliterator(), false).map(m -> { return m instanceof Message ? processMessage(responseOkBuilder, (Message) m, ignoredHeaders) : m; @@ -118,7 +113,6 @@ final class FunctionHandlerRequestProcessingHelper { else { return responseOkBuilder.body(result); } - // }); } private static Object processMessage(BodyBuilder responseOkBuilder, Message message, diff --git a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java index 04cfc1a0..640ef7f4 100644 --- a/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java +++ b/spring-cloud-gateway-server-mvc/src/main/java/org/springframework/cloud/gateway/server/mvc/handler/HandlerFunctions.java @@ -22,12 +22,19 @@ import java.net.URI; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import jakarta.servlet.ServletException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.cloud.function.context.FunctionCatalog; +import org.springframework.cloud.function.context.FunctionProperties; import org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry.FunctionInvocationWrapper; +import org.springframework.cloud.function.context.config.RoutingFunction; +import org.springframework.cloud.gateway.server.mvc.GatewayMvcClassPathWarningAutoConfiguration; import org.springframework.cloud.gateway.server.mvc.common.MvcUtils; import org.springframework.cloud.gateway.server.mvc.config.RouteProperties; import org.springframework.cloud.stream.function.StreamOperations; @@ -43,6 +50,8 @@ import static org.springframework.cloud.gateway.server.mvc.handler.FunctionHandl public abstract class HandlerFunctions { + private static final Log log = LogFactory.getLog(GatewayMvcClassPathWarningAutoConfiguration.class); + private HandlerFunctions() { } @@ -56,13 +65,44 @@ public abstract class HandlerFunctions { public static HandlerFunction fn(String functionName) { Assert.hasText(functionName, "'functionName' must not be empty"); return request -> { - String expandedFunctionName = MvcUtils.expand(request, functionName); FunctionCatalog functionCatalog = MvcUtils.getApplicationContext(request).getBean(FunctionCatalog.class); - FunctionInvocationWrapper function = functionCatalog.lookup(expandedFunctionName, - request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new)); + String expandedFunctionName = MvcUtils.expand(request, functionName); + FunctionInvocationWrapper function; + Object body = null; + if (expandedFunctionName.contains("/")) { + String[] functionBodySplit = expandedFunctionName.split("/"); + function = functionCatalog.lookup(functionBodySplit[0], + request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new)); + if (function != null && function.isSupplier()) { + log.warn("Supplier must not have any arguments. Supplier: '" + function.getFunctionDefinition() + + "' has '" + functionBodySplit[1] + "' as an argument which is ignored."); + } + body = functionBodySplit[1]; + } + else { + function = functionCatalog.lookup(expandedFunctionName, + request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new)); + } + + /* + * If function can not be found in the current runtime, we will default to + * RoutingFunction which has additional logic to determine the function to + * invoke. + */ + Map additionalRequestHeaders = new HashMap<>(); + if (function == null) { + additionalRequestHeaders.put(FunctionProperties.FUNCTION_DEFINITION, expandedFunctionName); + + function = functionCatalog.lookup(RoutingFunction.FUNCTION_NAME, + request.headers().accept().stream().map(MimeType::toString).toArray(String[]::new)); + } + if (function != null) { - Object body = function.isSupplier() ? null : request.body(function.getRawInputType()); - return processRequest(request, function, body, false, Collections.emptyList(), Collections.emptyList()); + if (body == null) { + body = function.isSupplier() ? null : request.body(function.getRawInputType()); + } + return processRequest(request, function, body, false, Collections.emptyList(), Collections.emptyList(), + additionalRequestHeaders); } return ServerResponse.notFound().build(); }; diff --git a/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 0b209bc3..94282943 100644 --- a/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-gateway-server-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ org.springframework.cloud.gateway.server.mvc.GatewayServerMvcAutoConfiguration org.springframework.cloud.gateway.server.mvc.GatewayMvcClassPathWarningAutoConfiguration org.springframework.cloud.gateway.server.mvc.handler.GatewayMultipartAutoConfiguration -org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration \ No newline at end of file +org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration +org.springframework.cloud.gateway.server.mvc.config.DefaultFunctionConfiguration \ No newline at end of file diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java index 38bfa681..6d2a51a9 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -140,7 +140,8 @@ import static org.springframework.web.servlet.function.RequestPredicates.POST; import static org.springframework.web.servlet.function.RequestPredicates.path; @SuppressWarnings("unchecked") -@SpringBootTest(properties = { "spring.cloud.gateway.mvc.http-client.type=jdk" }, +@SpringBootTest( + properties = { "spring.cloud.gateway.mvc.http-client.type=jdk", "spring.cloud.gateway.function.enabled=false" }, webEnvironment = WebEnvironment.RANDOM_PORT) @ContextConfiguration(initializers = HttpbinTestcontainers.class) @ExtendWith(OutputCaptureExtension.class) diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java index 861b1694..41f6bff8 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/config/GatewayMvcPropertiesBeanDefinitionRegistrarTests.java @@ -53,7 +53,8 @@ import org.springframework.web.servlet.function.ServerRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.cloud.gateway.server.mvc.test.TestUtils.getMap; -@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.cloud.gateway.function.enabled=false" }) @ActiveProfiles("propertiesbeandefinitionregistrartests") @ContextConfiguration(initializers = HttpbinTestcontainers.class) public class GatewayMvcPropertiesBeanDefinitionRegistrarTests { diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java index edae11e4..d93dfc57 100644 --- a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/filter/RetryFilterFunctionTests.java @@ -56,8 +56,8 @@ import static org.springframework.cloud.gateway.server.mvc.filter.RetryFilterFun import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; -@SuppressWarnings("unchecked") -@SpringBootTest(properties = {}, webEnvironment = WebEnvironment.RANDOM_PORT) +@SpringBootTest(properties = { "spring.cloud.gateway.function.enabled=false" }, + webEnvironment = WebEnvironment.RANDOM_PORT) @ContextConfiguration(initializers = HttpbinTestcontainers.class) public class RetryFilterFunctionTests { diff --git a/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/DefaultRouteFunctionHandlerTests.java b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/DefaultRouteFunctionHandlerTests.java new file mode 100644 index 00000000..14f7c956 --- /dev/null +++ b/spring-cloud-gateway-server-mvc/src/test/java/org/springframework/cloud/gateway/server/mvc/handler/DefaultRouteFunctionHandlerTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2013-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.server.mvc.handler; + +import java.util.Locale; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.server.mvc.test.client.TestRestClient; +import org.springframework.context.annotation.Bean; +import org.springframework.http.MediaType; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = {}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class DefaultRouteFunctionHandlerTests { + + @Autowired + private TestRestClient restClient; + + @Test + public void testSupplierWorks() { + restClient.get() + .uri("/hello") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("hello"); + } + + @Test + public void testFunctionWorksGET() { + restClient.get() + .uri("/upper/bob") + .accept(MediaType.TEXT_PLAIN) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("BOB"); + } + + @Test + public void testFunctionWorksPOST() { + restClient.post() + .uri("/upper") + .accept(MediaType.APPLICATION_JSON) + .bodyValue("bob") + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("BOB"); + } + + @Test + public void testConsumerWorksGET() { + restClient.get().uri("/consume/hello").accept(MediaType.TEXT_PLAIN).exchange().expectStatus().isAccepted(); + assertThat(TestConfiguration.consumerInvoked).isTrue(); + } + + @Test + public void testConsumerWorksPOST() { + restClient.post() + .uri("/consume") + .accept(MediaType.APPLICATION_JSON) + .bodyValue("hello") + .exchange() + .expectStatus() + .isAccepted(); + assertThat(TestConfiguration.consumerInvoked).isTrue(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + protected static class TestConfiguration { + + static boolean consumerInvoked; + + @Bean + Function upper() { + return s -> s.toUpperCase(Locale.ROOT); + } + + @Bean + Consumer consume() { + return s -> { + consumerInvoked = false; + assertThat(s).isEqualTo("hello"); + consumerInvoked = true; + }; + } + + @Bean + Supplier hello() { + return () -> "hello"; + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java index 9181ce03..2be6617a 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterTests.java @@ -54,7 +54,7 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen * @author Denis Cutic * @author Andrey Muchnik */ -@SpringBootTest(webEnvironment = RANDOM_PORT) +@SpringBootTest(webEnvironment = RANDOM_PORT, properties = { "spring.cloud.gateway.function.enabled=false" }) @DirtiesContext @Testcontainers @Tag("DockerRequired")