From 808ab543ca020ddec8d1f1f8808562c79fd61937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:16:23 +0300 Subject: [PATCH 01/11] feat(discovery): enforce policy filtering on home feed --- .../api/discovery/DiscoveryController.java | 5 +++- .../discovery/discovery/HomeFeedService.java | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java index 1deb61d..cf7b84c 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java @@ -6,6 +6,7 @@ import com.cloudmedia.discovery.discovery.HomeFeedService; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; import java.time.Instant; import java.util.UUID; import org.springframework.http.ResponseEntity; @@ -31,9 +32,11 @@ public DiscoveryController(HomeFeedService homeFeedService) { public ResponseEntity> home( @RequestParam(value = "userId", required = false) String userId, @RequestParam(value = "size", required = false) @Min(1) @Max(50) Integer size, + @RequestParam(value = "countryCode", required = false) @Pattern(regexp = "^[A-Z]{2}$", message = "must be an ISO 3166-1 alpha-2 uppercase code") String countryCode, + @RequestParam(value = "ageVerified", required = false) Boolean ageVerified, @RequestHeader(value = "X-Request-Id", required = false) String requestId) { String effectiveRequestId = requestId(requestId); - HomeFeedResponse response = homeFeedService.homeFeed(userId, size); + HomeFeedResponse response = homeFeedService.homeFeed(userId, size, countryCode, ageVerified); return ResponseEntity.ok(new ApiSuccessResponse<>(response, new ApiMeta(effectiveRequestId, Instant.now()))); } diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java index c2cf574..c29948f 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java @@ -1,10 +1,14 @@ package com.cloudmedia.discovery.discovery; +import com.cloudmedia.discovery.error.ApiException; +import com.cloudmedia.discovery.policy.PolicyEvaluationClient; +import com.cloudmedia.discovery.policy.PolicyEvaluationException; import com.cloudmedia.discovery.search.SearchIndexReader; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service @@ -18,14 +22,18 @@ public class HomeFeedService { private final SearchIndexReader searchIndexReader; - public HomeFeedService(SearchIndexReader searchIndexReader) { + private final PolicyEvaluationClient policyEvaluationClient; + + public HomeFeedService(SearchIndexReader searchIndexReader, PolicyEvaluationClient policyEvaluationClient) { this.searchIndexReader = searchIndexReader; + this.policyEvaluationClient = policyEvaluationClient; } - public HomeFeedResponse homeFeed(String userId, Integer size) { + public HomeFeedResponse homeFeed(String userId, Integer size, String countryCode, Boolean ageVerified) { int resolvedSize = size == null ? DEFAULT_SIZE : Math.min(Math.max(size, MIN_SIZE), MAX_SIZE); HomeFeedCandidates candidates = searchIndexReader.homeFeed(userId, resolvedSize); - return new HomeFeedResponse(blend(candidates, resolvedSize), resolvedSize); + List blended = blend(candidates, resolvedSize); + return new HomeFeedResponse(filterByPolicy(blended, countryCode, ageVerified), resolvedSize); } private List blend(HomeFeedCandidates candidates, int size) { @@ -74,4 +82,15 @@ private void fillRemaining(Map deduped, HomeFeedCandidates private int slotsFor(int size, double ratio) { return Math.max(1, (int) Math.floor(size * ratio)); } + + private List filterByPolicy(List items, String countryCode, Boolean ageVerified) { + try { + return items.stream().filter( + item -> policyEvaluationClient.evaluate(item.contentId(), countryCode, ageVerified).allowed()) + .toList(); + } catch (PolicyEvaluationException exception) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "POLICY_SERVICE_UNAVAILABLE", + "Policy evaluation is temporarily unavailable", null); + } + } } From 0057e2c1489383b7b86d8df3afaa5a28367c46f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:16:29 +0300 Subject: [PATCH 02/11] test(discovery): cover policy-aware home feed behavior --- .../discovery/DiscoveryControllerTest.java | 11 +++++- .../discovery/HomeFeedServiceTest.java | 38 +++++++++++++++++-- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java index 9d62870..396b098 100644 --- a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java +++ b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java @@ -45,6 +45,12 @@ void homeValidatesSizeLimit() throws Exception { .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); } + @Test + void homeValidatesCountryCodeFormat() throws Exception { + mockMvc.perform(get("/v1/discovery/home").param("countryCode", "usa")).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); + } + @TestConfiguration static class HomeFeedTestConfiguration { @@ -66,9 +72,10 @@ public AutocompleteResponse autocomplete(String query, int size) { public HomeFeedCandidates homeFeed(String userId, int size) { return HomeFeedCandidates.empty(); } - }) { + }, (contentId, countryCode, ageVerified) -> new com.cloudmedia.discovery.policy.PolicyDecision(true, + List.of())) { @Override - public HomeFeedResponse homeFeed(String userId, Integer size) { + public HomeFeedResponse homeFeed(String userId, Integer size, String countryCode, Boolean ageVerified) { return new HomeFeedResponse(List.of(new HomeFeedItem("cnt_1", "chn_1", "Title", "Description", "VIDEO", "PUBLIC", Instant.parse("2026-03-14T12:00:00Z"), FeedSourceBucket.TRENDING)), 2); } diff --git a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java index 19b111a..c4d3a30 100644 --- a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java +++ b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java @@ -1,6 +1,8 @@ package com.cloudmedia.discovery.discovery; import com.cloudmedia.discovery.search.AutocompleteResponse; +import com.cloudmedia.discovery.policy.PolicyDecision; +import com.cloudmedia.discovery.policy.PolicyEvaluationClient; import com.cloudmedia.discovery.search.SearchIndexReader; import com.cloudmedia.discovery.search.SearchResponse; import java.time.Instant; @@ -14,11 +16,13 @@ class HomeFeedServiceTest { @Test void homeFeedUsesDefaultSizeAndBlendsDedupedItems() { RecordingSearchIndexReader reader = new RecordingSearchIndexReader(); - HomeFeedService service = new HomeFeedService(reader); + RecordingPolicyEvaluationClient policyClient = new RecordingPolicyEvaluationClient(); + HomeFeedService service = new HomeFeedService(reader, policyClient); - HomeFeedResponse response = service.homeFeed(null, null); + HomeFeedResponse response = service.homeFeed(null, null, "US", true); assertEquals(20, reader.size); + assertEquals(List.of("follow-1", "trend-1", "similar-1"), policyClient.recordedContentIds); assertEquals(3, response.items().size()); assertEquals(FeedSourceBucket.FOLLOWED, response.items().get(0).sourceBucket()); assertEquals(FeedSourceBucket.TRENDING, response.items().get(1).sourceBucket()); @@ -27,9 +31,9 @@ void homeFeedUsesDefaultSizeAndBlendsDedupedItems() { @Test void homeFeedClampsSizeAndAvoidsDuplicateContentIds() { RecordingSearchIndexReader reader = new RecordingSearchIndexReader(); - HomeFeedService service = new HomeFeedService(reader); + HomeFeedService service = new HomeFeedService(reader, new RecordingPolicyEvaluationClient()); - HomeFeedResponse response = service.homeFeed("user-1", 2); + HomeFeedResponse response = service.homeFeed("user-1", 2, null, null); assertEquals("user-1", reader.userId); assertEquals(2, response.size()); @@ -37,6 +41,18 @@ void homeFeedClampsSizeAndAvoidsDuplicateContentIds() { assertEquals(List.of("follow-1", "trend-1"), response.items().stream().map(HomeFeedItem::contentId).toList()); } + @Test + void homeFeedFiltersPolicyBlockedItems() { + RecordingSearchIndexReader reader = new RecordingSearchIndexReader(); + RecordingPolicyEvaluationClient policyClient = new RecordingPolicyEvaluationClient(); + policyClient.blockedContentIds = List.of("trend-1"); + HomeFeedService service = new HomeFeedService(reader, policyClient); + + HomeFeedResponse response = service.homeFeed("user-1", 3, "DE", true); + + assertEquals(List.of("follow-1", "similar-1"), response.items().stream().map(HomeFeedItem::contentId).toList()); + } + static class RecordingSearchIndexReader implements SearchIndexReader { private String userId; @@ -70,4 +86,18 @@ private HomeFeedItem item(String contentId, FeedSourceBucket bucket) { Instant.parse("2026-03-14T12:00:00Z"), bucket); } } + + static class RecordingPolicyEvaluationClient implements PolicyEvaluationClient { + + private final List recordedContentIds = new java.util.ArrayList<>(); + + private List blockedContentIds = List.of(); + + @Override + public PolicyDecision evaluate(String contentId, String countryCode, Boolean ageVerified) { + recordedContentIds.add(contentId); + boolean allowed = !blockedContentIds.contains(contentId); + return new PolicyDecision(allowed, allowed ? List.of() : List.of("CONTENT_BLOCKED")); + } + } } From a658ddb734044c26c976b2474beb0d3e8d482c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:16:33 +0300 Subject: [PATCH 03/11] docs(discovery): document policy context for home feed --- docs/contracts/rest-api-v1.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/contracts/rest-api-v1.md b/docs/contracts/rest-api-v1.md index dafbd6c..431707f 100644 --- a/docs/contracts/rest-api-v1.md +++ b/docs/contracts/rest-api-v1.md @@ -131,8 +131,9 @@ This document defines the MVP API contract groups, standards, and core request/r ### `GET /v1/discovery/home` - Balanced feed (followed + trending + fresh + similar) -- MVP params: optional `userId`; optional integer `size` with min `1`, default `20`, max `50` +- MVP params: optional `userId`; optional integer `size` with min `1`, default `20`, max `50`; optional `countryCode` (ISO 3166-1 alpha-2 uppercase, e.g. `US`); optional `ageVerified` - Returns a generic blended feed when `userId` is absent +- Filters out policy-blocked items from the blended feed ### `GET /v1/discovery/trending` - Region-level trending feed From 11df0579510664e147abee8443e47a391aac3250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:40:25 +0300 Subject: [PATCH 04/11] fix(discovery): validate home countryCode against ISO set --- .../api/discovery/DiscoveryController.java | 4 ++-- .../api/validation/CountryCodeValidator.java | 21 ++++++++++++++++++ .../api/validation/ValidCountryCode.java | 22 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java create mode 100644 services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/ValidCountryCode.java diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java index cf7b84c..778e95e 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/discovery/DiscoveryController.java @@ -2,11 +2,11 @@ import com.cloudmedia.discovery.api.response.ApiMeta; import com.cloudmedia.discovery.api.response.ApiSuccessResponse; +import com.cloudmedia.discovery.api.validation.ValidCountryCode; import com.cloudmedia.discovery.discovery.HomeFeedResponse; import com.cloudmedia.discovery.discovery.HomeFeedService; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.Pattern; import java.time.Instant; import java.util.UUID; import org.springframework.http.ResponseEntity; @@ -32,7 +32,7 @@ public DiscoveryController(HomeFeedService homeFeedService) { public ResponseEntity> home( @RequestParam(value = "userId", required = false) String userId, @RequestParam(value = "size", required = false) @Min(1) @Max(50) Integer size, - @RequestParam(value = "countryCode", required = false) @Pattern(regexp = "^[A-Z]{2}$", message = "must be an ISO 3166-1 alpha-2 uppercase code") String countryCode, + @RequestParam(value = "countryCode", required = false) @ValidCountryCode String countryCode, @RequestParam(value = "ageVerified", required = false) Boolean ageVerified, @RequestHeader(value = "X-Request-Id", required = false) String requestId) { String effectiveRequestId = requestId(requestId); diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java new file mode 100644 index 0000000..6fdb4f2 --- /dev/null +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java @@ -0,0 +1,21 @@ +package com.cloudmedia.discovery.api.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public class CountryCodeValidator implements ConstraintValidator { + + private static final Set ISO_COUNTRY_CODES = Arrays.stream(java.util.Locale.getISOCountries()) + .collect(Collectors.toUnmodifiableSet()); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + return value.matches("^[A-Z]{2}$") && ISO_COUNTRY_CODES.contains(value); + } +} diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/ValidCountryCode.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/ValidCountryCode.java new file mode 100644 index 0000000..b3711df --- /dev/null +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/ValidCountryCode.java @@ -0,0 +1,22 @@ +package com.cloudmedia.discovery.api.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = CountryCodeValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidCountryCode { + + String message() default "must be an ISO 3166-1 alpha-2 uppercase code"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} From 5635379625b438f802aafb911df157af68989863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:42:09 +0300 Subject: [PATCH 05/11] fix(discovery): apply policy checks before home feed trim --- .../cloudmedia/discovery/discovery/HomeFeedService.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java index c29948f..0e0de80 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java @@ -32,8 +32,12 @@ public HomeFeedService(SearchIndexReader searchIndexReader, PolicyEvaluationClie public HomeFeedResponse homeFeed(String userId, Integer size, String countryCode, Boolean ageVerified) { int resolvedSize = size == null ? DEFAULT_SIZE : Math.min(Math.max(size, MIN_SIZE), MAX_SIZE); HomeFeedCandidates candidates = searchIndexReader.homeFeed(userId, resolvedSize); - List blended = blend(candidates, resolvedSize); - return new HomeFeedResponse(filterByPolicy(blended, countryCode, ageVerified), resolvedSize); + HomeFeedCandidates policyFilteredCandidates = new HomeFeedCandidates( + filterByPolicy(candidates.followed(), countryCode, ageVerified), + filterByPolicy(candidates.trending(), countryCode, ageVerified), + filterByPolicy(candidates.fresh(), countryCode, ageVerified), + filterByPolicy(candidates.similar(), countryCode, ageVerified)); + return new HomeFeedResponse(blend(policyFilteredCandidates, resolvedSize), resolvedSize); } private List blend(HomeFeedCandidates candidates, int size) { From 0069081dc45e9114c3143a258c6160bc0c2692cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:44:13 +0300 Subject: [PATCH 06/11] test(discovery): cover home policy forwarding and 503 path --- .../api/discovery/DiscoveryControllerTest.java | 13 +++++++++++++ .../discovery/discovery/HomeFeedServiceTest.java | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java index 396b098..69103a8 100644 --- a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java +++ b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/api/discovery/DiscoveryControllerTest.java @@ -1,5 +1,6 @@ package com.cloudmedia.discovery.api.discovery; +import com.cloudmedia.discovery.error.ApiException; import com.cloudmedia.discovery.discovery.FeedSourceBucket; import com.cloudmedia.discovery.discovery.HomeFeedCandidates; import com.cloudmedia.discovery.discovery.HomeFeedItem; @@ -17,6 +18,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpStatus; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -51,6 +53,13 @@ void homeValidatesCountryCodeFormat() throws Exception { .andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR")); } + @Test + void homeReturnsServiceUnavailableWhenPolicyEvaluationFails() throws Exception { + mockMvc.perform(get("/v1/discovery/home").param("userId", "fail-policy")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error.code").value("POLICY_SERVICE_UNAVAILABLE")); + } + @TestConfiguration static class HomeFeedTestConfiguration { @@ -76,6 +85,10 @@ public HomeFeedCandidates homeFeed(String userId, int size) { List.of())) { @Override public HomeFeedResponse homeFeed(String userId, Integer size, String countryCode, Boolean ageVerified) { + if ("fail-policy".equals(userId)) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "POLICY_SERVICE_UNAVAILABLE", + "Policy evaluation is temporarily unavailable", null); + } return new HomeFeedResponse(List.of(new HomeFeedItem("cnt_1", "chn_1", "Title", "Description", "VIDEO", "PUBLIC", Instant.parse("2026-03-14T12:00:00Z"), FeedSourceBucket.TRENDING)), 2); } diff --git a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java index c4d3a30..99f35be 100644 --- a/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java +++ b/services/java/discovery-service/src/test/java/com/cloudmedia/discovery/discovery/HomeFeedServiceTest.java @@ -22,7 +22,9 @@ void homeFeedUsesDefaultSizeAndBlendsDedupedItems() { HomeFeedResponse response = service.homeFeed(null, null, "US", true); assertEquals(20, reader.size); - assertEquals(List.of("follow-1", "trend-1", "similar-1"), policyClient.recordedContentIds); + assertEquals(List.of("follow-1", "trend-1", "trend-1", "similar-1"), policyClient.recordedContentIds); + assertEquals(List.of("US", "US", "US", "US"), policyClient.recordedCountryCodes); + assertEquals(List.of(Boolean.TRUE, Boolean.TRUE, Boolean.TRUE, Boolean.TRUE), policyClient.recordedAgeVerified); assertEquals(3, response.items().size()); assertEquals(FeedSourceBucket.FOLLOWED, response.items().get(0).sourceBucket()); assertEquals(FeedSourceBucket.TRENDING, response.items().get(1).sourceBucket()); @@ -91,11 +93,17 @@ static class RecordingPolicyEvaluationClient implements PolicyEvaluationClient { private final List recordedContentIds = new java.util.ArrayList<>(); + private final List recordedCountryCodes = new java.util.ArrayList<>(); + + private final List recordedAgeVerified = new java.util.ArrayList<>(); + private List blockedContentIds = List.of(); @Override public PolicyDecision evaluate(String contentId, String countryCode, Boolean ageVerified) { recordedContentIds.add(contentId); + recordedCountryCodes.add(countryCode); + recordedAgeVerified.add(ageVerified); boolean allowed = !blockedContentIds.contains(contentId); return new PolicyDecision(allowed, allowed ? List.of() : List.of("CONTENT_BLOCKED")); } From 02858a7e336a1bc22f963d903d971dba6ea97448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:44:17 +0300 Subject: [PATCH 07/11] docs(discovery): document home feed policy 503 behavior --- docs/contracts/rest-api-v1.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contracts/rest-api-v1.md b/docs/contracts/rest-api-v1.md index 431707f..797421c 100644 --- a/docs/contracts/rest-api-v1.md +++ b/docs/contracts/rest-api-v1.md @@ -134,6 +134,7 @@ This document defines the MVP API contract groups, standards, and core request/r - MVP params: optional `userId`; optional integer `size` with min `1`, default `20`, max `50`; optional `countryCode` (ISO 3166-1 alpha-2 uppercase, e.g. `US`); optional `ageVerified` - Returns a generic blended feed when `userId` is absent - Filters out policy-blocked items from the blended feed +- Returns `503 POLICY_SERVICE_UNAVAILABLE` when policy evaluation fails ### `GET /v1/discovery/trending` - Region-level trending feed @@ -187,3 +188,4 @@ This document defines the MVP API contract groups, standards, and core request/r - `409`: conflict/idempotency collision - `429`: rate limit exceeded - `500`: internal error +- `503`: dependency unavailable (e.g. `POLICY_SERVICE_UNAVAILABLE` on `GET /v1/discovery/home`) From e7fcb65ca0a1fe97e6303ca7ac8aab66fc362f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:57:08 +0300 Subject: [PATCH 08/11] perf(discovery): precompile country code validation regex --- .../discovery/api/validation/CountryCodeValidator.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java index 6fdb4f2..cfa463f 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java @@ -5,17 +5,20 @@ import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; +import java.util.regex.Pattern; public class CountryCodeValidator implements ConstraintValidator { private static final Set ISO_COUNTRY_CODES = Arrays.stream(java.util.Locale.getISOCountries()) .collect(Collectors.toUnmodifiableSet()); + private static final Pattern COUNTRY_CODE_PATTERN = Pattern.compile("^[A-Z]{2}$"); + @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) { return true; } - return value.matches("^[A-Z]{2}$") && ISO_COUNTRY_CODES.contains(value); + return COUNTRY_CODE_PATTERN.matcher(value).matches() && ISO_COUNTRY_CODES.contains(value); } } From 75ce5d8951b4b007dbddb72086e8aace726bdb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:57:14 +0300 Subject: [PATCH 09/11] refactor(discovery): batch home policy decisions by content id --- .../discovery/discovery/HomeFeedService.java | 13 ++++++++++--- .../discovery/policy/PolicyEvaluationClient.java | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java index 0e0de80..4eb5516 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/discovery/HomeFeedService.java @@ -2,12 +2,14 @@ import com.cloudmedia.discovery.error.ApiException; import com.cloudmedia.discovery.policy.PolicyEvaluationClient; +import com.cloudmedia.discovery.policy.PolicyDecision; import com.cloudmedia.discovery.policy.PolicyEvaluationException; import com.cloudmedia.discovery.search.SearchIndexReader; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -89,9 +91,14 @@ private int slotsFor(int size, double ratio) { private List filterByPolicy(List items, String countryCode, Boolean ageVerified) { try { - return items.stream().filter( - item -> policyEvaluationClient.evaluate(item.contentId(), countryCode, ageVerified).allowed()) - .toList(); + List distinctContentIds = items.stream().map(HomeFeedItem::contentId).filter(Objects::nonNull) + .distinct().toList(); + Map decisions = policyEvaluationClient.evaluateBatch(distinctContentIds, + countryCode, ageVerified); + return items.stream().filter(item -> { + PolicyDecision decision = decisions.get(item.contentId()); + return decision != null && decision.allowed(); + }).toList(); } catch (PolicyEvaluationException exception) { throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "POLICY_SERVICE_UNAVAILABLE", "Policy evaluation is temporarily unavailable", null); diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/PolicyEvaluationClient.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/PolicyEvaluationClient.java index 4d1a5c2..720fee0 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/PolicyEvaluationClient.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/PolicyEvaluationClient.java @@ -1,6 +1,21 @@ package com.cloudmedia.discovery.policy; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + public interface PolicyEvaluationClient { PolicyDecision evaluate(String contentId, String countryCode, Boolean ageVerified); + + default Map evaluateBatch(Collection contentIds, String countryCode, + Boolean ageVerified) { + Map decisions = new LinkedHashMap<>(); + for (String contentId : contentIds) { + if (contentId != null && !decisions.containsKey(contentId)) { + decisions.put(contentId, evaluate(contentId, countryCode, ageVerified)); + } + } + return decisions; + } } From b0d271c22e4fab2ccb4501a493b6d941a50ca004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:06:32 +0300 Subject: [PATCH 10/11] style(discovery): use Locale import in country validator --- .../discovery/api/validation/CountryCodeValidator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java index cfa463f..4782efc 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/api/validation/CountryCodeValidator.java @@ -3,13 +3,14 @@ import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.util.Arrays; +import java.util.Locale; import java.util.Set; import java.util.stream.Collectors; import java.util.regex.Pattern; public class CountryCodeValidator implements ConstraintValidator { - private static final Set ISO_COUNTRY_CODES = Arrays.stream(java.util.Locale.getISOCountries()) + private static final Set ISO_COUNTRY_CODES = Arrays.stream(Locale.getISOCountries()) .collect(Collectors.toUnmodifiableSet()); private static final Pattern COUNTRY_CODE_PATTERN = Pattern.compile("^[A-Z]{2}$"); From e4f73dab069b43e0651c20ec83cba00beb86515e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:06:37 +0300 Subject: [PATCH 11/11] perf(discovery): parallelize batch policy evaluation requests --- .../policy/HttpPolicyEvaluationClient.java | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/HttpPolicyEvaluationClient.java b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/HttpPolicyEvaluationClient.java index 0dd5b04..19e98e6 100644 --- a/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/HttpPolicyEvaluationClient.java +++ b/services/java/discovery-service/src/main/java/com/cloudmedia/discovery/policy/HttpPolicyEvaluationClient.java @@ -2,11 +2,20 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.annotation.Nullable; +import jakarta.annotation.PreDestroy; import java.time.Duration; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; @@ -15,12 +24,16 @@ public class HttpPolicyEvaluationClient implements PolicyEvaluationClient { private final RestClient restClient; + private final ExecutorService batchExecutor; + public HttpPolicyEvaluationClient(RestClient.Builder restClientBuilder, @Value("${policy.service.base-url:http://localhost:8084}") String baseUrl) { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); requestFactory.setConnectTimeout((int) Duration.ofSeconds(2).toMillis()); requestFactory.setReadTimeout((int) Duration.ofSeconds(3).toMillis()); this.restClient = restClientBuilder.baseUrl(baseUrl).requestFactory(requestFactory).build(); + this.batchExecutor = Executors + .newFixedThreadPool(Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors()))); } @Override @@ -38,6 +51,36 @@ public PolicyDecision evaluate(String contentId, String countryCode, Boolean age } } + @Override + public Map evaluateBatch(Collection contentIds, String countryCode, + Boolean ageVerified) { + List uniqueIds = contentIds.stream().filter(Objects::nonNull).distinct().toList(); + Map> futures = new LinkedHashMap<>(); + for (String contentId : uniqueIds) { + futures.put(contentId, + CompletableFuture.supplyAsync(() -> evaluate(contentId, countryCode, ageVerified), batchExecutor)); + } + + Map decisions = new LinkedHashMap<>(); + for (Map.Entry> entry : futures.entrySet()) { + try { + decisions.put(entry.getKey(), entry.getValue().join()); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof PolicyEvaluationException policyEvaluationException) { + throw policyEvaluationException; + } + throw new PolicyEvaluationException("Policy service batch evaluation failed", cause); + } + } + return decisions; + } + + @PreDestroy + void shutdownExecutor() { + batchExecutor.shutdownNow(); + } + private record PolicyEvaluateRequest(@Nullable String countryCode, @Nullable Boolean ageVerified) { }