Skip to content

Commit e3b8172

Browse files
committed
Add epoch milliseconds support for datetime predicates in webmvc
Enable After, Before, and Between predicates to accept epoch milliseconds in YAML configuration by automatically registering StringToZonedDateTimeConverter to the ConversionService used by RouterFunctionHolderFactory Signed-off-by: raccoonback <kosb15@naver.com>
1 parent 8769340 commit e3b8172

File tree

6 files changed

+374
-10
lines changed

6 files changed

+374
-10
lines changed

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/config/RouterFunctionHolderFactory.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@
6060
import org.springframework.cloud.gateway.server.mvc.invoke.reflect.ReflectiveOperationInvoker;
6161
import org.springframework.cloud.gateway.server.mvc.predicate.PredicateBeanFactoryDiscoverer;
6262
import org.springframework.cloud.gateway.server.mvc.predicate.PredicateDiscoverer;
63+
import org.springframework.cloud.gateway.server.mvc.support.StringToZonedDateTimeConverter;
6364
import org.springframework.core.convert.ConversionService;
65+
import org.springframework.core.convert.support.ConfigurableConversionService;
6466
import org.springframework.core.convert.support.DefaultConversionService;
6567
import org.springframework.core.env.Environment;
6668
import org.springframework.core.log.LogMessage;
@@ -82,6 +84,7 @@
8284
*
8385
* @author Spencer Gibb
8486
* @author Jürgen Wißkirchen
87+
* @author raccoonback
8588
*/
8689
public class RouterFunctionHolderFactory {
8790

@@ -112,7 +115,7 @@ public String toString() {
112115

113116
private final PredicateDiscoverer predicateDiscoverer = new PredicateDiscoverer();
114117

115-
private final ParameterValueMapper parameterValueMapper = new ConversionServiceParameterValueMapper();
118+
private final ParameterValueMapper parameterValueMapper;
116119

117120
private final BeanFactory beanFactory;
118121

@@ -140,6 +143,12 @@ public RouterFunctionHolderFactory(Environment env, BeanFactory beanFactory,
140143
else {
141144
this.conversionService = DefaultConversionService.getSharedInstance();
142145
}
146+
147+
if (this.conversionService instanceof ConfigurableConversionService configurableConversionService) {
148+
configurableConversionService.addConverter(new StringToZonedDateTimeConverter());
149+
}
150+
151+
this.parameterValueMapper = new ConversionServiceParameterValueMapper(this.conversionService);
143152
}
144153

145154
/**

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/invoke/convert/ConversionServiceParameterValueMapper.java

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
import java.util.Objects;
2020

21-
import org.springframework.boot.convert.ApplicationConversionService;
2221
import org.springframework.cloud.gateway.server.mvc.invoke.OperationParameter;
2322
import org.springframework.cloud.gateway.server.mvc.invoke.ParameterMappingException;
2423
import org.springframework.cloud.gateway.server.mvc.invoke.ParameterValueMapper;
@@ -29,19 +28,13 @@
2928
*
3029
* @author Stephane Nicoll
3130
* @author Phillip Webb
31+
* @author raccoonback
3232
* @since 2.0.0
3333
*/
3434
public class ConversionServiceParameterValueMapper implements ParameterValueMapper {
3535

3636
private final ConversionService conversionService;
3737

38-
/**
39-
* Create a new {@link ConversionServiceParameterValueMapper} instance.
40-
*/
41-
public ConversionServiceParameterValueMapper() {
42-
this(ApplicationConversionService.getSharedInstance());
43-
}
44-
4538
/**
4639
* Create a new {@link ConversionServiceParameterValueMapper} instance backed by a
4740
* specific conversion service.

spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/predicate/GatewayRequestPredicates.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ public static RequestPredicate before(ZonedDateTime dateTime) {
9191
return request -> ZonedDateTime.now().isBefore(dateTime);
9292
}
9393

94-
// TODO: accept and test datetime predicates (including yaml config)
9594
@Shortcut
9695
public static RequestPredicate between(ZonedDateTime dateTime1, ZonedDateTime dateTime2) {
9796
return request -> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2025-present 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.support;
18+
19+
import java.time.Instant;
20+
import java.time.ZoneOffset;
21+
import java.time.ZonedDateTime;
22+
23+
import org.springframework.core.convert.converter.Converter;
24+
25+
/**
26+
* Converter for converting String to ZonedDateTime. Supports both ISO-8601 format and
27+
* epoch milliseconds.
28+
*
29+
* @author raccoonback
30+
*/
31+
public class StringToZonedDateTimeConverter implements Converter<String, ZonedDateTime> {
32+
33+
@Override
34+
public ZonedDateTime convert(String source) {
35+
try {
36+
long epoch = Long.parseLong(source);
37+
return Instant.ofEpochMilli(epoch).atOffset(ZoneOffset.ofTotalSeconds(0)).toZonedDateTime();
38+
}
39+
catch (NumberFormatException e) {
40+
// try ZonedDateTime instead
41+
return ZonedDateTime.parse(source);
42+
}
43+
}
44+
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Copyright 2025-present 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.predicate;
18+
19+
import java.time.ZonedDateTime;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.beans.factory.annotation.Autowired;
24+
import org.springframework.boot.SpringBootConfiguration;
25+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
26+
import org.springframework.boot.test.context.SpringBootTest;
27+
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
28+
import org.springframework.cloud.gateway.server.mvc.test.HttpbinTestcontainers;
29+
import org.springframework.cloud.gateway.server.mvc.test.HttpbinUriResolver;
30+
import org.springframework.cloud.gateway.server.mvc.test.PermitAllSecurityConfiguration;
31+
import org.springframework.context.annotation.Bean;
32+
import org.springframework.context.annotation.Import;
33+
import org.springframework.test.context.ActiveProfiles;
34+
import org.springframework.test.context.ContextConfiguration;
35+
import org.springframework.test.web.servlet.client.RestTestClient;
36+
import org.springframework.web.servlet.function.RouterFunction;
37+
import org.springframework.web.servlet.function.ServerResponse;
38+
39+
import static org.springframework.cloud.gateway.server.mvc.filter.AfterFilterFunctions.addResponseHeader;
40+
import static org.springframework.cloud.gateway.server.mvc.filter.FilterFunctions.setPath;
41+
import static org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route;
42+
import static org.springframework.cloud.gateway.server.mvc.handler.HandlerFunctions.http;
43+
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.after;
44+
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.before;
45+
import static org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.between;
46+
47+
/**
48+
* Integration tests for datetime predicates (After, Before, Between) with YAML
49+
* configuration.
50+
*
51+
* @author raccoonback
52+
*/
53+
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT,
54+
properties = { "spring.cloud.gateway.server.webmvc.function.enabled=false" })
55+
@ActiveProfiles("datetime")
56+
@ContextConfiguration(initializers = HttpbinTestcontainers.class)
57+
class DateTimePredicateIntegrationTests {
58+
59+
@Autowired
60+
RestTestClient testClient;
61+
62+
@Test
63+
void afterPredicateWorksWithYamlConfig() {
64+
testClient.get()
65+
.uri("/test/after")
66+
.exchange()
67+
.expectStatus()
68+
.isOk()
69+
.expectHeader()
70+
.valueEquals("X-Predicate-Type", "After");
71+
}
72+
73+
@Test
74+
void beforePredicateWorksWithYamlConfig() {
75+
testClient.get()
76+
.uri("/test/before")
77+
.exchange()
78+
.expectStatus()
79+
.isOk()
80+
.expectHeader()
81+
.valueEquals("X-Predicate-Type", "Before");
82+
}
83+
84+
@Test
85+
void betweenPredicateWorksWithYamlConfig() {
86+
testClient.get()
87+
.uri("/test/between")
88+
.exchange()
89+
.expectStatus()
90+
.isOk()
91+
.expectHeader()
92+
.valueEquals("X-Predicate-Type", "Between");
93+
}
94+
95+
@Test
96+
void betweenPredicateWorksWithEpochMilliseconds() {
97+
testClient.get()
98+
.uri("/test/between-epoch")
99+
.exchange()
100+
.expectStatus()
101+
.isOk()
102+
.expectHeader()
103+
.valueEquals("X-Predicate-Type", "BetweenEpoch");
104+
}
105+
106+
@Test
107+
void afterPredicateRejectsPastTime() {
108+
// This route requires future time, should NOT match
109+
testClient.get().uri("/test/future-only").exchange().expectStatus().isNotFound();
110+
}
111+
112+
@Test
113+
void beforePredicateRejectsFutureTime() {
114+
// This route requires time before 2020, should NOT match
115+
testClient.get().uri("/test/past-only").exchange().expectStatus().isNotFound();
116+
}
117+
118+
@Test
119+
void betweenPredicateRejectsOutsideRange() {
120+
// This route requires time in 2020, should NOT match
121+
testClient.get().uri("/test/outside-range").exchange().expectStatus().isNotFound();
122+
}
123+
124+
@Test
125+
void betweenPredicateRejectsFutureRange() {
126+
// This route requires time in 2099, should NOT match
127+
testClient.get().uri("/test/future-range").exchange().expectStatus().isNotFound();
128+
}
129+
130+
@Test
131+
void afterPredicateWorksWithJavaDsl() {
132+
testClient.get()
133+
.uri("/test/after-dsl")
134+
.exchange()
135+
.expectStatus()
136+
.isOk()
137+
.expectHeader()
138+
.valueEquals("X-Predicate-Type", "AfterDSL");
139+
}
140+
141+
@Test
142+
void beforePredicateWorksWithJavaDsl() {
143+
testClient.get()
144+
.uri("/test/before-dsl")
145+
.exchange()
146+
.expectStatus()
147+
.isOk()
148+
.expectHeader()
149+
.valueEquals("X-Predicate-Type", "BeforeDSL");
150+
}
151+
152+
@Test
153+
void betweenPredicateWorksWithJavaDsl() {
154+
testClient.get()
155+
.uri("/test/between-dsl")
156+
.exchange()
157+
.expectStatus()
158+
.isOk()
159+
.expectHeader()
160+
.valueEquals("X-Predicate-Type", "BetweenDSL");
161+
}
162+
163+
@Test
164+
void afterPredicateRejectsPastTimeWithJavaDsl() {
165+
// This route requires future time, should NOT match
166+
testClient.get().uri("/test/after-future-dsl").exchange().expectStatus().isNotFound();
167+
}
168+
169+
@Test
170+
void beforePredicateRejectsFutureTimeWithJavaDsl() {
171+
// This route requires past time, should NOT match
172+
testClient.get().uri("/test/before-past-dsl").exchange().expectStatus().isNotFound();
173+
}
174+
175+
@Test
176+
void betweenPredicateRejectsOutsideRangeWithJavaDsl() {
177+
// This route requires time outside current range, should NOT match
178+
testClient.get().uri("/test/between-past-dsl").exchange().expectStatus().isNotFound();
179+
}
180+
181+
@EnableAutoConfiguration
182+
@SpringBootConfiguration
183+
@Import(PermitAllSecurityConfiguration.class)
184+
static class TestConfig {
185+
186+
@Bean
187+
public RouterFunction<ServerResponse> dateTimeRoutes() {
188+
// @formatter:off
189+
// Success cases
190+
return route("after_dsl")
191+
.GET("/test/after-dsl", after(ZonedDateTime.now().minusDays(1)), http())
192+
.filter(setPath("/anything/after-dsl"))
193+
.before(new HttpbinUriResolver())
194+
.after(addResponseHeader("X-Predicate-Type", "AfterDSL"))
195+
.build()
196+
.and(route("before_dsl")
197+
.GET("/test/before-dsl", before(ZonedDateTime.now().plusDays(1)), http())
198+
.filter(setPath("/anything/before-dsl"))
199+
.before(new HttpbinUriResolver())
200+
.after(addResponseHeader("X-Predicate-Type", "BeforeDSL"))
201+
.build())
202+
.and(route("between_dsl")
203+
.GET("/test/between-dsl", between(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(1)), http())
204+
.filter(setPath("/anything/between-dsl"))
205+
.before(new HttpbinUriResolver())
206+
.after(addResponseHeader("X-Predicate-Type", "BetweenDSL"))
207+
.build())
208+
// Failure cases - should NOT match
209+
.and(route("after_future_dsl")
210+
.GET("/test/after-future-dsl", after(ZonedDateTime.now().plusDays(365)), http())
211+
.filter(setPath("/anything/after-future-dsl"))
212+
.before(new HttpbinUriResolver())
213+
.after(addResponseHeader("X-Predicate-Type", "AfterFutureDSL"))
214+
.build())
215+
.and(route("before_past_dsl")
216+
.GET("/test/before-past-dsl", before(ZonedDateTime.now().minusDays(365)), http())
217+
.filter(setPath("/anything/before-past-dsl"))
218+
.before(new HttpbinUriResolver())
219+
.after(addResponseHeader("X-Predicate-Type", "BeforePastDSL"))
220+
.build())
221+
.and(route("between_past_dsl")
222+
.GET("/test/between-past-dsl", between(ZonedDateTime.now().minusDays(730), ZonedDateTime.now().minusDays(365)), http())
223+
.filter(setPath("/anything/between-past-dsl"))
224+
.before(new HttpbinUriResolver())
225+
.after(addResponseHeader("X-Predicate-Type", "BetweenPastDSL"))
226+
.build());
227+
// @formatter:on
228+
}
229+
230+
}
231+
232+
}

0 commit comments

Comments
 (0)