Skip to content
This repository was archived by the owner on Jul 14, 2026. It is now read-only.

Commit 36ef79a

Browse files
authored
Merge pull request #51 from bright-room/feat/25-global-default-timezone-for-scheduled-feature-flags
Close #25: Add global default timezone for scheduled endpoint gates
2 parents 463ce01 + b0a618d commit 36ef79a

13 files changed

Lines changed: 347 additions & 16 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Built-in `AccessDeniedInterceptResolution` implementations (selected by `endpoin
111111
| `endpoint-gate.default-enabled` | `false` | Undefined gate policy (fail-closed / fail-open) |
112112
| `endpoint-gate.response.type` | `JSON` | Response format: `JSON`, `PLAIN_TEXT`, `HTML` |
113113
| `endpoint-gate.condition.fail-on-error` | `true` | SpEL error handling (fail-closed / fail-open) |
114+
| `endpoint-gate.schedule.default-timezone` || Default timezone for schedule evaluation (fallback: gate-specific → this default → system default) |
114115

115116
## Coding Guidelines
116117

spring/actuator/src/main/java/net/brightroom/endpointgate/spring/actuator/autoconfigure/EndpointGateActuatorAutoConfiguration.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ EndpointGateEndpoint endpointGateEndpoint(
188188
conditionProvider,
189189
scheduleProvider,
190190
endpointGateProperties.defaultEnabled(),
191+
endpointGateProperties.schedule().defaultTimezone(),
191192
eventPublisher,
192193
clock);
193194
}
@@ -316,6 +317,7 @@ ReactiveEndpointGateEndpoint reactiveEndpointGateEndpoint(
316317
reactiveConditionProvider,
317318
reactiveScheduleProvider,
318319
endpointGateProperties.defaultEnabled(),
320+
endpointGateProperties.schedule().defaultTimezone(),
319321
eventPublisher,
320322
clock);
321323
}

spring/actuator/src/main/java/net/brightroom/endpointgate/spring/actuator/endpoint/EndpointGateEndpoint.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class EndpointGateEndpoint {
4242
private final MutableConditionProvider conditionProvider;
4343
private final MutableScheduleProvider scheduleProvider;
4444
private final boolean defaultEnabled;
45+
@Nullable private final ZoneId defaultScheduleTimezone;
4546
private final ApplicationEventPublisher eventPublisher;
4647
private final Clock clock;
4748

@@ -107,7 +108,8 @@ public EndpointGateEndpointResponse gate(@Selector String gateId) {
107108
* @param scheduleStart the schedule start time, or {@code null} if only an end time is needed
108109
* @param scheduleEnd the schedule end time, or {@code null} for an open-ended schedule
109110
* @param scheduleTimezone the schedule timezone string (e.g. {@code "Asia/Tokyo"}), or {@code
110-
* null} to use the system default timezone
111+
* null} to use the global default timezone ({@code endpoint-gate.schedule.default-timezone}),
112+
* falling back to the system default timezone if no global default is configured
111113
* @param removeSchedule {@code true} to remove the schedule, or {@code null}/{@code false} to
112114
* leave unchanged
113115
* @return a response reflecting the updated state of all gates
@@ -147,7 +149,7 @@ public EndpointGatesEndpointResponse updateGate(
147149
throw new IllegalArgumentException(
148150
"At least one of scheduleStart or scheduleEnd is required when setting a schedule");
149151
}
150-
ZoneId timezone = null;
152+
ZoneId timezone = defaultScheduleTimezone;
151153
if (scheduleTimezone != null && !scheduleTimezone.isEmpty()) {
152154
timezone = ZoneId.of(scheduleTimezone);
153155
}
@@ -235,6 +237,8 @@ private ScheduleEndpointResponse buildScheduleResponse(@Nullable Schedule schedu
235237
* @param scheduleProvider the mutable schedule provider used to look up and mutate schedules per
236238
* gate
237239
* @param defaultEnabled the default-enabled value to include in responses
240+
* @param defaultScheduleTimezone the global default timezone used when a schedule update does not
241+
* specify a timezone, or {@code null} to use the system default timezone
238242
* @param eventPublisher the publisher used to broadcast gate change events
239243
* @param clock the clock used to determine schedule active status in responses
240244
*/
@@ -244,13 +248,15 @@ public EndpointGateEndpoint(
244248
MutableConditionProvider conditionProvider,
245249
MutableScheduleProvider scheduleProvider,
246250
boolean defaultEnabled,
251+
@Nullable ZoneId defaultScheduleTimezone,
247252
ApplicationEventPublisher eventPublisher,
248253
Clock clock) {
249254
this.provider = provider;
250255
this.rolloutProvider = rolloutProvider;
251256
this.conditionProvider = conditionProvider;
252257
this.scheduleProvider = scheduleProvider;
253258
this.defaultEnabled = defaultEnabled;
259+
this.defaultScheduleTimezone = defaultScheduleTimezone;
254260
this.eventPublisher = eventPublisher;
255261
this.clock = clock;
256262
}

spring/actuator/src/main/java/net/brightroom/endpointgate/spring/actuator/endpoint/ReactiveEndpointGateEndpoint.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ public class ReactiveEndpointGateEndpoint {
4444
private final MutableReactiveConditionProvider conditionProvider;
4545
private final MutableReactiveScheduleProvider reactiveScheduleProvider;
4646
private final boolean defaultEnabled;
47+
@Nullable private final ZoneId defaultScheduleTimezone;
4748
private final ApplicationEventPublisher eventPublisher;
4849
private final Clock clock;
4950

@@ -109,7 +110,8 @@ public EndpointGateEndpointResponse gate(@Selector String gateId) {
109110
* @param scheduleStart the schedule start time, or {@code null} if only an end time is needed
110111
* @param scheduleEnd the schedule end time, or {@code null} for an open-ended schedule
111112
* @param scheduleTimezone the schedule timezone string (e.g. {@code "Asia/Tokyo"}), or {@code
112-
* null} to use the system default timezone
113+
* null} to use the global default timezone ({@code endpoint-gate.schedule.default-timezone}),
114+
* falling back to the system default timezone if no global default is configured
113115
* @param removeSchedule {@code true} to remove the schedule, or {@code null}/{@code false} to
114116
* leave unchanged
115117
* @return a response reflecting the updated state of all gates
@@ -149,7 +151,7 @@ public EndpointGatesEndpointResponse updateGate(
149151
throw new IllegalArgumentException(
150152
"At least one of scheduleStart or scheduleEnd is required when setting a schedule");
151153
}
152-
ZoneId timezone = null;
154+
ZoneId timezone = defaultScheduleTimezone;
153155
if (scheduleTimezone != null && !scheduleTimezone.isEmpty()) {
154156
timezone = ZoneId.of(scheduleTimezone);
155157
}
@@ -228,6 +230,8 @@ private ScheduleEndpointResponse buildScheduleResponse(@Nullable Schedule schedu
228230
* @param reactiveScheduleProvider the mutable reactive schedule provider used to look up and
229231
* mutate schedules per gate
230232
* @param defaultEnabled the default-enabled value to include in responses
233+
* @param defaultScheduleTimezone the global default timezone used when a schedule update does not
234+
* specify a timezone, or {@code null} to use the system default timezone
231235
* @param eventPublisher the publisher used to broadcast gate change events
232236
* @param clock the clock used to determine schedule active status in responses
233237
*/
@@ -237,13 +241,15 @@ public ReactiveEndpointGateEndpoint(
237241
MutableReactiveConditionProvider conditionProvider,
238242
MutableReactiveScheduleProvider reactiveScheduleProvider,
239243
boolean defaultEnabled,
244+
@Nullable ZoneId defaultScheduleTimezone,
240245
ApplicationEventPublisher eventPublisher,
241246
Clock clock) {
242247
this.provider = provider;
243248
this.rolloutProvider = rolloutProvider;
244249
this.conditionProvider = conditionProvider;
245250
this.reactiveScheduleProvider = reactiveScheduleProvider;
246251
this.defaultEnabled = defaultEnabled;
252+
this.defaultScheduleTimezone = defaultScheduleTimezone;
247253
this.eventPublisher = eventPublisher;
248254
this.clock = clock;
249255
}

spring/actuator/src/test/java/net/brightroom/endpointgate/spring/actuator/endpoint/EndpointGateEndpointTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ private EndpointGateEndpoint endpoint(
6161
emptyConditionProvider(),
6262
emptyScheduleProvider(),
6363
defaultEnabled,
64+
null,
6465
eventPublisher,
6566
clock);
6667
}
@@ -75,6 +76,7 @@ private EndpointGateEndpoint endpointWithSchedule(
7576
emptyConditionProvider(),
7677
new MutableInMemoryScheduleProvider(schedules),
7778
defaultEnabled,
79+
null,
7880
eventPublisher,
7981
clock);
8082
}
@@ -482,6 +484,7 @@ void gate_returnsInactiveSchedule_whenScheduleWindowHasPassed() {
482484
emptyConditionProvider(),
483485
new MutableInMemoryScheduleProvider(Map.of("gate-a", schedule)),
484486
false,
487+
null,
485488
eventPublisher,
486489
fixedClock);
487490

@@ -528,6 +531,7 @@ private EndpointGateEndpoint endpointWithMutableSchedule(
528531
emptyConditionProvider(),
529532
emptyScheduleProvider(),
530533
false,
534+
null,
531535
eventPublisher,
532536
clock);
533537
}
@@ -566,6 +570,7 @@ void updateGate_replacesExistingSchedule_withNewSchedule() {
566570
emptyConditionProvider(),
567571
scheduleProvider,
568572
false,
573+
null,
569574
eventPublisher,
570575
clock);
571576

@@ -589,6 +594,7 @@ void updateGate_removesSchedule_whenRemoveScheduleIsTrue() {
589594
emptyConditionProvider(),
590595
scheduleProvider,
591596
false,
597+
null,
592598
eventPublisher,
593599
clock);
594600

@@ -626,6 +632,7 @@ void updateGate_publishesScheduleChangedEventWithNullSchedule_whenRemoveSchedule
626632
emptyConditionProvider(),
627633
scheduleProvider,
628634
false,
635+
null,
629636
eventPublisher,
630637
clock);
631638

@@ -651,6 +658,7 @@ void deleteGate_removesSchedule() {
651658
emptyConditionProvider(),
652659
scheduleProvider,
653660
false,
661+
null,
654662
eventPublisher,
655663
clock);
656664

@@ -682,4 +690,57 @@ void updateGate_throwsIllegalArgumentException_whenOnlyScheduleTimezoneProvided(
682690
() -> endpoint.updateGate("gate-a", true, null, null, null, null, "Asia/Tokyo", null))
683691
.withMessageContaining("At least one of scheduleStart or scheduleEnd is required");
684692
}
693+
694+
// --- defaultScheduleTimezone fallback ---
695+
696+
@Test
697+
void updateGate_usesDefaultScheduleTimezone_whenScheduleTimezoneNotSpecified() {
698+
var provider = new MutableInMemoryEndpointGateProvider(Map.of("gate-a", true), false);
699+
var scheduleProvider = new MutableInMemoryScheduleProvider(Map.of());
700+
var endpoint =
701+
new EndpointGateEndpoint(
702+
provider,
703+
emptyRolloutProvider(),
704+
emptyConditionProvider(),
705+
scheduleProvider,
706+
false,
707+
ZoneId.of("Asia/Tokyo"),
708+
eventPublisher,
709+
clock);
710+
711+
endpoint.updateGate(
712+
"gate-a", true, null, null, LocalDateTime.of(2026, 6, 1, 0, 0), null, null, null);
713+
714+
assertThat(scheduleProvider.getSchedule("gate-a"))
715+
.hasValueSatisfying(s -> assertThat(s.timezone()).isEqualTo(ZoneId.of("Asia/Tokyo")));
716+
}
717+
718+
@Test
719+
void updateGate_usesExplicitTimezone_overDefaultScheduleTimezone() {
720+
var provider = new MutableInMemoryEndpointGateProvider(Map.of("gate-a", true), false);
721+
var scheduleProvider = new MutableInMemoryScheduleProvider(Map.of());
722+
var endpoint =
723+
new EndpointGateEndpoint(
724+
provider,
725+
emptyRolloutProvider(),
726+
emptyConditionProvider(),
727+
scheduleProvider,
728+
false,
729+
ZoneId.of("Asia/Tokyo"),
730+
eventPublisher,
731+
clock);
732+
733+
endpoint.updateGate(
734+
"gate-a",
735+
true,
736+
null,
737+
null,
738+
LocalDateTime.of(2026, 6, 1, 0, 0),
739+
null,
740+
"America/New_York",
741+
null);
742+
743+
assertThat(scheduleProvider.getSchedule("gate-a"))
744+
.hasValueSatisfying(s -> assertThat(s.timezone()).isEqualTo(ZoneId.of("America/New_York")));
745+
}
685746
}

spring/actuator/src/test/java/net/brightroom/endpointgate/spring/actuator/endpoint/ReactiveEndpointGateEndpointTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ private ReactiveEndpointGateEndpoint endpoint(
6565
emptyConditionProvider(),
6666
emptyScheduleProvider(),
6767
defaultEnabled,
68+
null,
6869
eventPublisher,
6970
clock);
7071
}
@@ -79,6 +80,7 @@ private ReactiveEndpointGateEndpoint endpointWithSchedule(
7980
emptyConditionProvider(),
8081
new MutableInMemoryReactiveScheduleProvider(schedules),
8182
defaultEnabled,
83+
null,
8284
eventPublisher,
8385
clock);
8486
}
@@ -247,6 +249,7 @@ void gates_returnsEmptyList_whenProviderReturnsMonoEmpty() {
247249
emptyConditionProvider(),
248250
emptyScheduleProvider(),
249251
false,
252+
null,
250253
eventPublisher,
251254
clock);
252255

@@ -511,6 +514,7 @@ void gate_returnsInactiveSchedule_whenScheduleWindowHasPassed() {
511514
emptyConditionProvider(),
512515
new MutableInMemoryReactiveScheduleProvider(Map.of("gate-a", schedule)),
513516
false,
517+
null,
514518
eventPublisher,
515519
fixedClock);
516520

@@ -557,6 +561,7 @@ private ReactiveEndpointGateEndpoint endpointWithMutableSchedule(
557561
emptyConditionProvider(),
558562
emptyScheduleProvider(),
559563
false,
564+
null,
560565
eventPublisher,
561566
clock);
562567
}
@@ -595,6 +600,7 @@ void updateGate_replacesExistingSchedule_withNewSchedule() {
595600
emptyConditionProvider(),
596601
scheduleProvider,
597602
false,
603+
null,
598604
eventPublisher,
599605
clock);
600606

@@ -618,6 +624,7 @@ void updateGate_removesSchedule_whenRemoveScheduleIsTrue() {
618624
emptyConditionProvider(),
619625
scheduleProvider,
620626
false,
627+
null,
621628
eventPublisher,
622629
clock);
623630

@@ -655,6 +662,7 @@ void updateGate_publishesScheduleChangedEventWithNullSchedule_whenRemoveSchedule
655662
emptyConditionProvider(),
656663
scheduleProvider,
657664
false,
665+
null,
658666
eventPublisher,
659667
clock);
660668

@@ -680,6 +688,7 @@ void deleteGate_removesSchedule() {
680688
emptyConditionProvider(),
681689
scheduleProvider,
682690
false,
691+
null,
683692
eventPublisher,
684693
clock);
685694

@@ -701,6 +710,57 @@ void gate_returnsUpdatedSchedule_afterUpdateGate() {
701710
assertThat(response.schedule().start()).isEqualTo(LocalDateTime.of(2026, 4, 1, 0, 0));
702711
}
703712

713+
@Test
714+
void updateGate_usesDefaultScheduleTimezone_whenScheduleTimezoneNotSpecified() {
715+
var provider = new MutableInMemoryReactiveEndpointGateProvider(Map.of("gate-a", true), false);
716+
var scheduleProvider = new MutableInMemoryReactiveScheduleProvider(Map.of());
717+
var endpoint =
718+
new ReactiveEndpointGateEndpoint(
719+
provider,
720+
emptyRolloutProvider(),
721+
emptyConditionProvider(),
722+
scheduleProvider,
723+
false,
724+
ZoneId.of("Asia/Tokyo"),
725+
eventPublisher,
726+
clock);
727+
728+
endpoint.updateGate(
729+
"gate-a", true, null, null, LocalDateTime.of(2026, 6, 1, 0, 0), null, null, null);
730+
731+
assertThat(scheduleProvider.getSchedule("gate-a").blockOptional())
732+
.hasValueSatisfying(s -> assertThat(s.timezone()).isEqualTo(ZoneId.of("Asia/Tokyo")));
733+
}
734+
735+
@Test
736+
void updateGate_usesExplicitTimezone_overDefaultScheduleTimezone() {
737+
var provider = new MutableInMemoryReactiveEndpointGateProvider(Map.of("gate-a", true), false);
738+
var scheduleProvider = new MutableInMemoryReactiveScheduleProvider(Map.of());
739+
var endpoint =
740+
new ReactiveEndpointGateEndpoint(
741+
provider,
742+
emptyRolloutProvider(),
743+
emptyConditionProvider(),
744+
scheduleProvider,
745+
false,
746+
ZoneId.of("Asia/Tokyo"),
747+
eventPublisher,
748+
clock);
749+
750+
endpoint.updateGate(
751+
"gate-a",
752+
true,
753+
null,
754+
null,
755+
LocalDateTime.of(2026, 6, 1, 0, 0),
756+
null,
757+
"America/New_York",
758+
null);
759+
760+
assertThat(scheduleProvider.getSchedule("gate-a").blockOptional())
761+
.hasValueSatisfying(s -> assertThat(s.timezone()).isEqualTo(ZoneId.of("America/New_York")));
762+
}
763+
704764
@Test
705765
void updateGate_throwsIllegalArgumentException_whenOnlyScheduleTimezoneProvided() {
706766
var provider = new MutableInMemoryReactiveEndpointGateProvider(Map.of("gate-a", true), false);

0 commit comments

Comments
 (0)