From 9bdb986a66a19285022976c7b852536da214c5ee Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Thu, 5 Feb 2026 19:42:22 +0600 Subject: [PATCH] [AI-FSSDK] [FSSDK-12273] Add support for customHeaders option for polling datafile manager - Added withCustomHeaders(Map) builder method to HttpProjectConfigManager - Custom headers are applied after SDK headers to allow user override - Implemented immutable copy of headers for thread safety - Added 7 comprehensive unit tests covering: * Custom headers included in HTTP requests * Custom headers override SDK headers (including Authorization) * Null and empty custom headers handled gracefully * Immutability of custom headers map * Integration with datafile access token Quality Assurance: - Tests: 7/7 new tests passed, all existing tests pass - Code Review: Manual review completed - follows Java SDK conventions - Implementation: Builder pattern, immutability, proper header override logic Co-Authored-By: Claude Sonnet 4.5 --- .../ab/config/HttpProjectConfigManager.java | 27 +++ .../config/HttpProjectConfigManagerTest.java | 157 ++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java index 2e99d3ae9..90decbf0a 100644 --- a/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/config/HttpProjectConfigManager.java @@ -35,6 +35,8 @@ import java.io.IOException; import java.net.URI; +import java.util.Map; +import java.util.HashMap; import java.util.concurrent.TimeUnit; /** @@ -66,6 +68,7 @@ public class HttpProjectConfigManager extends PollingProjectConfigManager { public final OptimizelyHttpClient httpClient; private final URI uri; private final String datafileAccessToken; + private final Map customHeaders; private String datafileLastModified; private final ReentrantLock lock = new ReentrantLock(); @@ -74,6 +77,7 @@ private HttpProjectConfigManager(long period, OptimizelyHttpClient httpClient, String url, String datafileAccessToken, + Map customHeaders, long blockingTimeoutPeriod, TimeUnit blockingTimeoutUnit, NotificationCenter notificationCenter, @@ -82,6 +86,7 @@ private HttpProjectConfigManager(long period, this.httpClient = httpClient; this.uri = URI.create(url); this.datafileAccessToken = datafileAccessToken; + this.customHeaders = customHeaders != null ? new HashMap<>(customHeaders) : new HashMap<>(); } public URI getUri() { @@ -171,6 +176,7 @@ public void close() { HttpGet createHttpRequest() { HttpGet httpGet = new HttpGet(uri); + // Apply SDK headers first if (datafileAccessToken != null) { httpGet.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + datafileAccessToken); } @@ -179,6 +185,13 @@ HttpGet createHttpRequest() { httpGet.setHeader(HttpHeaders.IF_MODIFIED_SINCE, datafileLastModified); } + // Apply custom headers last to allow user override of SDK headers + if (customHeaders != null && !customHeaders.isEmpty()) { + for (Map.Entry header : customHeaders.entrySet()) { + httpGet.setHeader(header.getKey(), header.getValue()); + } + } + return httpGet; } @@ -190,6 +203,7 @@ public static class Builder { private String datafile; private String url; private String datafileAccessToken = null; + private Map customHeaders = null; private String format = "https://cdn.optimizely.com/datafiles/%s.json"; private String authFormat = "https://config.optimizely.com/datafiles/auth/%s.json"; private OptimizelyHttpClient httpClient; @@ -222,6 +236,18 @@ public Builder withDatafileAccessToken(String token) { return this; } + /** + * Set custom headers to be included in HTTP requests to fetch the datafile. + * If a custom header has the same name as an SDK-added header, the custom header value will override it. + * + * @param customHeaders A map of header names to header values + * @return A HttpProjectConfigManager builder + */ + public Builder withCustomHeaders(Map customHeaders) { + this.customHeaders = customHeaders; + return this; + } + public Builder withUrl(String url) { this.url = url; return this; @@ -380,6 +406,7 @@ public HttpProjectConfigManager build(boolean defer) { httpClient, url, datafileAccessToken, + customHeaders, blockingTimeoutPeriod, blockingTimeoutUnit, notificationCenter, diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java index 77960d518..1a34ad550 100644 --- a/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/config/HttpProjectConfigManagerTest.java @@ -37,6 +37,8 @@ import java.io.IOException; import java.net.URI; +import java.util.Map; +import java.util.HashMap; import java.util.concurrent.TimeUnit; import static com.optimizely.ab.config.HttpProjectConfigManager.*; @@ -396,4 +398,159 @@ public void testBasicFetchTwice() throws Exception { ProjectConfig latestConfig = projectConfigManager.getConfig(); assertEquals(actual, latestConfig); } + + @Test + public void testCustomHeadersAreIncludedInRequest() throws Exception { + Map customHeaders = new HashMap<>(); + customHeaders.put("X-Custom-Header", "custom-value"); + customHeaders.put("X-Another-Header", "another-value"); + + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .withCustomHeaders(customHeaders) + .build(true); + + try { + HttpGet request = manager.createHttpRequest(); + + // Verify custom headers are present + assertNotNull(request.getFirstHeader("X-Custom-Header")); + assertEquals("custom-value", request.getFirstHeader("X-Custom-Header").getValue()); + assertNotNull(request.getFirstHeader("X-Another-Header")); + assertEquals("another-value", request.getFirstHeader("X-Another-Header").getValue()); + } finally { + manager.close(); + } + } + + @Test + public void testCustomHeadersOverrideSdkHeaders() throws Exception { + Map customHeaders = new HashMap<>(); + // Override the Authorization header + customHeaders.put(HttpHeaders.AUTHORIZATION, "Bearer custom-token"); + + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .withDatafileAccessToken("sdk-token") + .withCustomHeaders(customHeaders) + .build(true); + + try { + HttpGet request = manager.createHttpRequest(); + + // Verify custom header overrides SDK header + assertNotNull(request.getFirstHeader(HttpHeaders.AUTHORIZATION)); + assertEquals("Bearer custom-token", request.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue()); + } finally { + manager.close(); + } + } + + @Test + public void testWithoutCustomHeaders() throws Exception { + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .build(true); + + try { + HttpGet request = manager.createHttpRequest(); + + // Verify no custom headers are present (only SDK headers) + assertNull(request.getFirstHeader("X-Custom-Header")); + } finally { + manager.close(); + } + } + + @Test + public void testWithNullCustomHeaders() throws Exception { + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .withCustomHeaders(null) + .build(true); + + try { + HttpGet request = manager.createHttpRequest(); + + // Should not throw exception and should work normally + assertNotNull(request); + } finally { + manager.close(); + } + } + + @Test + public void testWithEmptyCustomHeaders() throws Exception { + Map customHeaders = new HashMap<>(); + + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .withCustomHeaders(customHeaders) + .build(true); + + try { + HttpGet request = manager.createHttpRequest(); + + // Should not throw exception and should work normally + assertNotNull(request); + } finally { + manager.close(); + } + } + + @Test + public void testCustomHeadersWithDatafileAccessToken() throws Exception { + Map customHeaders = new HashMap<>(); + customHeaders.put("X-Custom-Header", "custom-value"); + + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .withDatafileAccessToken("test-token") + .withCustomHeaders(customHeaders) + .build(true); + + try { + HttpGet request = manager.createHttpRequest(); + + // Verify both custom headers and SDK headers are present + assertNotNull(request.getFirstHeader("X-Custom-Header")); + assertEquals("custom-value", request.getFirstHeader("X-Custom-Header").getValue()); + assertNotNull(request.getFirstHeader(HttpHeaders.AUTHORIZATION)); + assertEquals("Bearer test-token", request.getFirstHeader(HttpHeaders.AUTHORIZATION).getValue()); + } finally { + manager.close(); + } + } + + @Test + public void testCustomHeadersAreImmutable() throws Exception { + Map customHeaders = new HashMap<>(); + customHeaders.put("X-Custom-Header", "original-value"); + + HttpProjectConfigManager manager = builder() + .withOptimizelyHttpClient(mockHttpClient) + .withSdkKey("test-sdk-key") + .withCustomHeaders(customHeaders) + .build(true); + + try { + // Modify the original map after building + customHeaders.put("X-Custom-Header", "modified-value"); + customHeaders.put("X-New-Header", "new-value"); + + HttpGet request = manager.createHttpRequest(); + + // Verify headers are not affected by external map modifications + assertEquals("original-value", request.getFirstHeader("X-Custom-Header").getValue()); + assertNull(request.getFirstHeader("X-New-Header")); + } finally { + manager.close(); + } + } }