diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/gateway-request-predicates.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/gateway-request-predicates.adoc index 5631cbf873..b1601de830 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/gateway-request-predicates.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webmvc/gateway-request-predicates.adoc @@ -11,6 +11,7 @@ You can combine multiple route predicate factories with the `RequestPredicate.an The `After` route predicate factory takes one parameter, a `datetime` (which is a java `ZonedDateTime`). This predicate matches requests that happen after the specified datetime. +The datetime can be specified as a ZonedDateTime string or as epoch milliseconds. The following example configures an after route predicate: .application.yml @@ -27,6 +28,22 @@ spring: - After=2017-01-20T17:42:47.789-07:00[America/Denver] ---- +The datetime can also be specified as epoch milliseconds: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + mvc: + routes: + - id: after_route + uri: https://example.org + predicates: + - After=1484968967789 +---- + .GatewaySampleApplication.java [source,java] ---- @@ -56,6 +73,7 @@ This route matches any request made after Jan 20, 2017 17:42 Mountain Time (Denv The `Before` route predicate factory takes one parameter, a `datetime` (which is a java `ZonedDateTime`). This predicate matches requests that happen before the specified `datetime`. +The datetime can be specified as a ZonedDateTime string or as epoch milliseconds. The following example configures a before route predicate: .application.yml @@ -72,6 +90,22 @@ spring: - Before=2017-01-20T17:42:47.789-07:00[America/Denver] ---- +The datetime can also be specified as epoch milliseconds: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + mvc: + routes: + - id: before_route + uri: https://example.org + predicates: + - Before=1484968967789 +---- + .GatewaySampleApplication.java [source,java] ---- @@ -103,6 +137,7 @@ The `Between` route predicate factory takes two parameters, `datetime1` and `dat which are java `ZonedDateTime` objects. This predicate matches requests that happen after `datetime1` and before `datetime2`. The `datetime2` parameter must be after `datetime1`. +The datetimes can be specified as ZonedDateTime strings or as epoch milliseconds. The following example configures a between route predicate: .application.yml @@ -119,6 +154,22 @@ spring: - Between=2017-01-20T17:42:47.789-07:00[America/Denver], 2017-01-21T17:42:47.789-07:00[America/Denver] ---- +The datetimes can also be specified as epoch milliseconds: + +.application.yml +[source,yaml] +---- +spring: + cloud: + gateway: + mvc: + routes: + - id: between_route + uri: https://example.org + predicates: + - Between=1484968967789, 1485055367789 +---- + .GatewaySampleApplication.java [source,java] ---- diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java index e788f363bc..a7fee09813 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java @@ -60,7 +60,9 @@ import org.springframework.cloud.gateway.server.mvc.invoke.reflect.ReflectiveOperationInvoker; import org.springframework.cloud.gateway.server.mvc.predicate.PredicateBeanFactoryDiscoverer; import org.springframework.cloud.gateway.server.mvc.predicate.PredicateDiscoverer; +import org.springframework.cloud.gateway.server.mvc.support.StringToZonedDateTimeConverter; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.env.Environment; import org.springframework.core.log.LogMessage; @@ -82,6 +84,7 @@ * * @author Spencer Gibb * @author Jürgen Wißkirchen + * @author raccoonback */ public class RouterFunctionHolderFactory { @@ -112,7 +115,7 @@ public String toString() { private final PredicateDiscoverer predicateDiscoverer = new PredicateDiscoverer(); - private final ParameterValueMapper parameterValueMapper = new ConversionServiceParameterValueMapper(); + private final ParameterValueMapper parameterValueMapper; private final BeanFactory beanFactory; @@ -140,6 +143,12 @@ public RouterFunctionHolderFactory(Environment env, BeanFactory beanFactory, else { this.conversionService = DefaultConversionService.getSharedInstance(); } + + if (this.conversionService instanceof ConfigurableConversionService configurableConversionService) { + configurableConversionService.addConverter(new StringToZonedDateTimeConverter()); + } + + this.parameterValueMapper = new ConversionServiceParameterValueMapper(this.conversionService); } /** diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java index aba932ed85..c2b4125e38 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java @@ -91,7 +91,6 @@ public static RequestPredicate before(ZonedDateTime dateTime) { return request -> ZonedDateTime.now().isBefore(dateTime); } - // TODO: accept and test datetime predicates (including yaml config) @Shortcut public static RequestPredicate between(ZonedDateTime dateTime1, ZonedDateTime dateTime2) { return request -> { diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/support/StringToZonedDateTimeConverter.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/support/StringToZonedDateTimeConverter.java new file mode 100644 index 0000000000..05a1972913 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/support/StringToZonedDateTimeConverter.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025-present 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.support; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; + +import org.springframework.core.convert.converter.Converter; + +/** + * Converter for converting String to ZonedDateTime. Supports both ISO-8601 format and + * epoch milliseconds. + * + * @author raccoonback + */ +public class StringToZonedDateTimeConverter implements Converter { + + @Override + public ZonedDateTime convert(String source) { + try { + long epoch = Long.parseLong(source); + return Instant.ofEpochMilli(epoch).atOffset(ZoneOffset.ofTotalSeconds(0)).toZonedDateTime(); + } + catch (NumberFormatException e) { + // try ZonedDateTime instead + return ZonedDateTime.parse(source); + } + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/DateTimePredicateIntegrationTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/DateTimePredicateIntegrationTests.java new file mode 100644 index 0000000000..5d46a53eaa --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/predicate/DateTimePredicateIntegrationTests.java @@ -0,0 +1,232 @@ +/* + * Copyright 2025-present 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.predicate; + +import java.time.ZonedDateTime; + +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.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers; +import org.springframework.cloud.gateway.server.mvc.test.HttpbinUriResolver; +import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.client.RestTestClient; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFunctions.addResponseHeader; +import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.setPath; +import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route; +import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http; +import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.after; +import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.before; +import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.between; + +/** + * Integration tests for datetime predicates (After, Before, Between) with YAML + * configuration. + * + * @author raccoonback + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.cloud.gateway.server.webmvc.function.enabled=false" }) +@ActiveProfiles("datetime") +@ContextConfiguration(initializers = HttpbinTestcontainers.class) +class DateTimePredicateIntegrationTests { + + @Autowired + RestTestClient testClient; + + @Test + void afterPredicateWorksWithYamlConfig() { + testClient.get() + .uri("/test/after") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "After"); + } + + @Test + void beforePredicateWorksWithYamlConfig() { + testClient.get() + .uri("/test/before") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "Before"); + } + + @Test + void betweenPredicateWorksWithYamlConfig() { + testClient.get() + .uri("/test/between") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "Between"); + } + + @Test + void betweenPredicateWorksWithEpochMilliseconds() { + testClient.get() + .uri("/test/between-epoch") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "BetweenEpoch"); + } + + @Test + void afterPredicateRejectsPastTime() { + // This route requires future time, should NOT match + testClient.get().uri("/test/future-only").exchange().expectStatus().isNotFound(); + } + + @Test + void beforePredicateRejectsFutureTime() { + // This route requires time before 2020, should NOT match + testClient.get().uri("/test/past-only").exchange().expectStatus().isNotFound(); + } + + @Test + void betweenPredicateRejectsOutsideRange() { + // This route requires time in 2020, should NOT match + testClient.get().uri("/test/outside-range").exchange().expectStatus().isNotFound(); + } + + @Test + void betweenPredicateRejectsFutureRange() { + // This route requires time in 2099, should NOT match + testClient.get().uri("/test/future-range").exchange().expectStatus().isNotFound(); + } + + @Test + void afterPredicateWorksWithJavaDsl() { + testClient.get() + .uri("/test/after-dsl") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "AfterDSL"); + } + + @Test + void beforePredicateWorksWithJavaDsl() { + testClient.get() + .uri("/test/before-dsl") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "BeforeDSL"); + } + + @Test + void betweenPredicateWorksWithJavaDsl() { + testClient.get() + .uri("/test/between-dsl") + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .valueEquals("X-Predicate-Type", "BetweenDSL"); + } + + @Test + void afterPredicateRejectsPastTimeWithJavaDsl() { + // This route requires future time, should NOT match + testClient.get().uri("/test/after-future-dsl").exchange().expectStatus().isNotFound(); + } + + @Test + void beforePredicateRejectsFutureTimeWithJavaDsl() { + // This route requires past time, should NOT match + testClient.get().uri("/test/before-past-dsl").exchange().expectStatus().isNotFound(); + } + + @Test + void betweenPredicateRejectsOutsideRangeWithJavaDsl() { + // This route requires time outside current range, should NOT match + testClient.get().uri("/test/between-past-dsl").exchange().expectStatus().isNotFound(); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(PermitAllSecurityConfiguration.class) + static class TestConfig { + + @Bean + public RouterFunction dateTimeRoutes() { + // @formatter:off + // Success cases + return route("after_dsl") + .GET("/test/after-dsl", after(ZonedDateTime.now().minusDays(1)), http()) + .filter(setPath("/anything/after-dsl")) + .before(new HttpbinUriResolver()) + .after(addResponseHeader("X-Predicate-Type", "AfterDSL")) + .build() + .and(route("before_dsl") + .GET("/test/before-dsl", before(ZonedDateTime.now().plusDays(1)), http()) + .filter(setPath("/anything/before-dsl")) + .before(new HttpbinUriResolver()) + .after(addResponseHeader("X-Predicate-Type", "BeforeDSL")) + .build()) + .and(route("between_dsl") + .GET("/test/between-dsl", between(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1)), http()) + .filter(setPath("/anything/between-dsl")) + .before(new HttpbinUriResolver()) + .after(addResponseHeader("X-Predicate-Type", "BetweenDSL")) + .build()) + // Failure cases - should NOT match + .and(route("after_future_dsl") + .GET("/test/after-future-dsl", after(ZonedDateTime.now().plusDays(365)), http()) + .filter(setPath("/anything/after-future-dsl")) + .before(new HttpbinUriResolver()) + .after(addResponseHeader("X-Predicate-Type", "AfterFutureDSL")) + .build()) + .and(route("before_past_dsl") + .GET("/test/before-past-dsl", before(ZonedDateTime.now().minusDays(365)), http()) + .filter(setPath("/anything/before-past-dsl")) + .before(new HttpbinUriResolver()) + .after(addResponseHeader("X-Predicate-Type", "BeforePastDSL")) + .build()) + .and(route("between_past_dsl") + .GET("/test/between-past-dsl", between(ZonedDateTime.now().minusDays(730), ZonedDateTime.now().minusDays(365)), http()) + .filter(setPath("/anything/between-past-dsl")) + .before(new HttpbinUriResolver()) + .after(addResponseHeader("X-Predicate-Type", "BetweenPastDSL")) + .build()); + // @formatter:on + } + + } + +} diff --git a/spring-cloud-gateway-server-webmvc/src/test/resources/application-datetime.yml b/spring-cloud-gateway-server-webmvc/src/test/resources/application-datetime.yml new file mode 100644 index 0000000000..0a9a945102 --- /dev/null +++ b/spring-cloud-gateway-server-webmvc/src/test/resources/application-datetime.yml @@ -0,0 +1,86 @@ +spring.cloud.gateway.server.webmvc: + routes: + - id: after_test + uri: no://op + predicates: + - Path=/test/after + - After=2020-01-20T17:42:47.789-07:00[America/Denver] + filters: + - SetPath=/anything/after + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,After + + - id: before_test + uri: no://op + predicates: + - Path=/test/before + - Before=2099-01-20T17:42:47.789-07:00[America/Denver] + filters: + - SetPath=/anything/before + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,Before + + - id: between_test + uri: no://op + predicates: + - Path=/test/between + - Between=2020-01-01T00:00:00Z,2099-01-01T00:00:00Z + filters: + - SetPath=/anything/between + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,Between + + - id: between_epoch_test + uri: no://op + predicates: + - Path=/test/between-epoch + # Epoch milliseconds: 2000-01-01 to 2100-01-01 + - Between=946684800000,4102444800000 + filters: + - SetPath=/anything/between-epoch + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,BetweenEpoch + + - id: after_future_test + uri: no://op + predicates: + - Path=/test/future-only + # This should NOT match as it's in the future + - After=2099-01-20T17:42:47.789-07:00[America/Denver] + filters: + - SetPath=/anything/future-only + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,AfterFuture + + - id: before_past_test + uri: no://op + predicates: + - Path=/test/past-only + # This should NOT match as it's in the past + - Before=2020-01-20T17:42:47.789-07:00[America/Denver] + filters: + - SetPath=/anything/past-only + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,BeforePast + + - id: between_outside_range_test + uri: no://op + predicates: + - Path=/test/outside-range + # This should NOT match as current time is outside this range + - Between=2020-01-01T00:00:00Z,2020-12-31T23:59:59Z + filters: + - SetPath=/anything/outside-range + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,BetweenOutsideRange + + - id: between_future_range_test + uri: no://op + predicates: + - Path=/test/future-range + # This should NOT match as the range is in the future + - Between=2099-01-01T00:00:00Z,2099-12-31T23:59:59Z + filters: + - SetPath=/anything/future-range + - HttpbinUriResolver= + - AddResponseHeader=X-Predicate-Type,BetweenFutureRange \ No newline at end of file