From b4ae05c52f19b1015d48448dadb9b06173e344d5 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 30 Mar 2025 15:13:17 +0300 Subject: [PATCH 01/18] adding http connector Signed-off-by: liran2000 --- providers/flagd/README.md | 14 +- .../storage/connector/sync/HttpConnector.java | 183 ++++++ .../providers/flagd/util/ConcurrentUtils.java | 61 ++ .../connector/sync/HttpConnectorTest.java | 543 ++++++++++++++++++ 4 files changed, 795 insertions(+), 6 deletions(-) create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java diff --git a/providers/flagd/README.md b/providers/flagd/README.md index de0d8a091..4fee01e84 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -74,15 +74,17 @@ This mode is useful for local development, tests and offline applications. #### Custom Connector You can include a custom connector as a configuration option to customize how the in-process resolver fetches flags. -The custom connector must implement the [Connector interface](https://github.com/open-feature/java-sdk-contrib/blob/main/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/Connector.java). +The custom connector must implement the [QueueSource interface](https://github.com/open-feature/java-sdk-contrib/blob/main/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueueSource.java). ```java -Connector myCustomConnector = new MyCustomConnector(); +QueueSource connector = HttpConnector.builder() + .url(testUrl) + .build(); FlagdOptions options = - FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .customConnector(myCustomConnector) - .build(); + FlagdOptions.builder() + .resolverType(Config.Resolver.IN_PROCESS) + .customConnector(myCustomConnector) + .build(); FlagdProvider flagdProvider = new FlagdProvider(options); ``` diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java new file mode 100644 index 000000000..9d92c83cb --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java @@ -0,0 +1,183 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; +import dev.openfeature.contrib.providers.flagd.util.ConcurrentUtils; +import lombok.Builder; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.URL; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static java.net.http.HttpClient.Builder.NO_PROXY; + +/** + * HttpConnector is responsible for managing HTTP connections and polling data from a specified URL + * at regular intervals. It implements the QueueSource interface to enqueue and dequeue change messages. + * The class supports configurable parameters such as poll interval, request timeout, and proxy settings. + * It uses a ScheduledExecutorService to schedule polling tasks and an ExecutorService for HTTP client execution. + * The class also provides methods to initialize, retrieve the stream queue, and shutdown the connector gracefully. + */ +@Slf4j +public class HttpConnector implements QueueSource { + + private static final int DEFAULT_POLL_INTERVAL_SECONDS = 60; + private static final int DEFAULT_LINKED_BLOCKING_QUEUE_CAPACITY = 100; + private static final int DEFAULT_SCHEDULED_THREAD_POOL_SIZE = 1; + private static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; + private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; + + private Integer pollIntervalSeconds; + private Integer requestTimeoutSeconds; + private BlockingQueue queue; + private HttpClient client; + private ExecutorService httpClientExecutor; + private ScheduledExecutorService scheduler; + private Map headers; + @NonNull + private String url; + + // TODO init failure backup cache redis + + // todo update provider readme + + @Builder + public HttpConnector(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, + Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort) { + validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, + connectTimeoutSeconds, proxyHost, proxyPort); + this.pollIntervalSeconds = pollIntervalSeconds == null ? DEFAULT_POLL_INTERVAL_SECONDS : pollIntervalSeconds; + int thisLinkedBlockingQueueCapacity = linkedBlockingQueueCapacity == null ? DEFAULT_LINKED_BLOCKING_QUEUE_CAPACITY : linkedBlockingQueueCapacity; + int thisScheduledThreadPoolSize = scheduledThreadPoolSize == null ? DEFAULT_SCHEDULED_THREAD_POOL_SIZE : scheduledThreadPoolSize; + this.requestTimeoutSeconds = requestTimeoutSeconds == null ? DEFAULT_REQUEST_TIMEOUT_SECONDS : requestTimeoutSeconds; + int thisConnectTimeoutSeconds = connectTimeoutSeconds == null ? DEFAULT_CONNECT_TIMEOUT_SECONDS : connectTimeoutSeconds; + ProxySelector proxySelector = NO_PROXY; + if (proxyHost != null && proxyPort != null) { + proxySelector = ProxySelector.of(new InetSocketAddress(proxyHost, proxyPort)); + } + + this.url = url; + this.headers = headers; + this.httpClientExecutor = httpClientExecutor == null ? Executors.newFixedThreadPool(1) : + httpClientExecutor; + scheduler = Executors.newScheduledThreadPool(thisScheduledThreadPoolSize); + if (headers == null) { + this.headers = new HashMap<>(); + } + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(thisConnectTimeoutSeconds)) + .proxy(proxySelector) + .executor(this.httpClientExecutor) + .build(); + this.queue = new LinkedBlockingQueue<>(thisLinkedBlockingQueueCapacity); + } + + @SneakyThrows + private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, + String proxyHost, Integer proxyPort) { + new URL(url).toURI(); + if (pollIntervalSeconds != null && (pollIntervalSeconds < 1 || pollIntervalSeconds > 600)) { + throw new IllegalArgumentException("pollIntervalSeconds must be between 1 and 600"); + } + if (linkedBlockingQueueCapacity != null && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { + throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); + } + if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { + throw new IllegalArgumentException("scheduledThreadPoolSize must be between 1 and 10"); + } + if (requestTimeoutSeconds != null && (requestTimeoutSeconds < 1 || requestTimeoutSeconds > 60)) { + throw new IllegalArgumentException("requestTimeoutSeconds must be between 1 and 60"); + } + if (connectTimeoutSeconds != null && (connectTimeoutSeconds < 1 || connectTimeoutSeconds > 60)) { + throw new IllegalArgumentException("connectTimeoutSeconds must be between 1 and 60"); + } + if (proxyPort != null && (proxyPort < 1 || proxyPort > 65535)) { + throw new IllegalArgumentException("proxyPort must be between 1 and 65535"); + } + if (proxyHost != null && proxyPort == null ) { + throw new IllegalArgumentException("proxyPort must be set if proxyHost is set"); + } else if (proxyHost == null && proxyPort != null) { + throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); + } + } + + @Override + public void init() throws Exception { + log.info("init Http Connector"); + } + + @Override + public BlockingQueue getStreamQueue() { + Runnable pollTask = buildPollTask(); + + // run first poll immediately and wait for it to finish + pollTask.run(); + + scheduler.scheduleAtFixedRate(pollTask, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); + return queue; + } + + protected Runnable buildPollTask() { + return () -> { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(requestTimeoutSeconds)) + .GET(); + headers.forEach(requestBuilder::header); + HttpRequest request = requestBuilder + .build(); + + HttpResponse response; + try { + log.debug("fetching response"); + response = execute(request); + } catch (IOException e) { + log.info("could not fetch", e); + return; + } catch (Exception e) { + log.debug("exception", e); + return; + } + log.debug("fetched response"); + if (response.statusCode() != 200) { + log.info("received non-successful status code: {} {}", response.statusCode(), response.body()); + return; + } + if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, response.body()))) { + log.warn("Unable to offer file content to queue: queue is full"); + } + }; + } + + protected HttpResponse execute(HttpRequest request) throws IOException, InterruptedException { + HttpResponse response; + response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response; + } + + @Override + public void shutdown() throws InterruptedException { + ConcurrentUtils.shutdownAndAwaitTermination(scheduler, 10); + ConcurrentUtils.shutdownAndAwaitTermination(httpClientExecutor, 10); + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java new file mode 100644 index 000000000..b57faca77 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java @@ -0,0 +1,61 @@ +package dev.openfeature.contrib.providers.flagd.util; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Concurrent / Concurrency utilities. + * + * @author Liran Mendelovich + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Slf4j +public class ConcurrentUtils { + + /** + * Graceful shutdown a thread pool.
+ * See + * https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html + * + * @param pool thread pool + * @param timeoutSeconds grace period timeout in seconds - timeout can be twice than this value, + * as first it waits for existing tasks to terminate, then waits for cancelled tasks to + * terminate. + */ + public static void shutdownAndAwaitTermination(ExecutorService pool, int timeoutSeconds) { + if (pool == null) { + return; + } + + // Disable new tasks from being submitted + pool.shutdown(); + try { + + // Wait a while for existing tasks to terminate + if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + + // Cancel currently executing tasks - best effort, based on interrupt handling + // implementation. + pool.shutdownNow(); + + // Wait a while for tasks to respond to being cancelled + if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + log.error("Thread pool did not shutdown all tasks after the timeout: {} seconds.", timeoutSeconds); + } + } + } catch (InterruptedException e) { + + log.info("Current thread interrupted during shutdownAndAwaitTermination, calling shutdownNow."); + + // (Re-)Cancel if current thread also interrupted + pool.shutdownNow(); + + // Preserve interrupt status + Thread.currentThread().interrupt(); + } + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java new file mode 100644 index 000000000..60ffd05ef --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java @@ -0,0 +1,543 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.MalformedURLException; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +@Slf4j +class HttpConnectorTest { + + @SneakyThrows + @Test + void testConstructorInitializesDefaultValues() { + String testUrl = "http://example.com"; + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .build(); + + Field pollIntervalField = HttpConnector.class.getDeclaredField("pollIntervalSeconds"); + pollIntervalField.setAccessible(true); + assertEquals(60, pollIntervalField.get(connector)); + + Field requestTimeoutField = HttpConnector.class.getDeclaredField("requestTimeoutSeconds"); + requestTimeoutField.setAccessible(true); + assertEquals(10, requestTimeoutField.get(connector)); + + Field queueField = HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertEquals(100, queue.remainingCapacity() + queue.size()); + + Field headersField = HttpConnector.class.getDeclaredField("headers"); + headersField.setAccessible(true); + Map headers = (Map) headersField.get(connector); + assertNotNull(headers); + assertTrue(headers.isEmpty()); + } + + @SneakyThrows + @Test + void testConstructorValidationRejectsInvalidParameters() { + String testUrl = "http://example.com"; + + HttpConnector.HttpConnectorBuilder builder3 = HttpConnector.builder() + .url(testUrl) + .pollIntervalSeconds(0); + IllegalArgumentException pollIntervalException = assertThrows( + IllegalArgumentException.class, + builder3::build + ); + assertEquals("pollIntervalSeconds must be between 1 and 600", pollIntervalException.getMessage()); + + HttpConnector.HttpConnectorBuilder builder2 = HttpConnector.builder() + .url(testUrl) + .linkedBlockingQueueCapacity(1001); + IllegalArgumentException queueCapacityException = assertThrows( + IllegalArgumentException.class, + builder2::build + ); + assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", queueCapacityException.getMessage()); + + HttpConnector.HttpConnectorBuilder builder1 = HttpConnector.builder() + .url(testUrl) + .scheduledThreadPoolSize(11); + IllegalArgumentException threadPoolException = assertThrows( + IllegalArgumentException.class, + builder1::build + ); + assertEquals("scheduledThreadPoolSize must be between 1 and 10", threadPoolException.getMessage()); + + HttpConnector.HttpConnectorBuilder builder = HttpConnector.builder() + .url(testUrl) + .proxyHost("localhost"); + IllegalArgumentException proxyException = assertThrows( + IllegalArgumentException.class, + builder::build + ); + assertEquals("proxyPort must be set if proxyHost is set", proxyException.getMessage()); + } + + @SneakyThrows + @Test + void testGetStreamQueueInitialAndScheduledPolls() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + + connector.shutdown(); + } + + @SneakyThrows + @Test + void testBuildPollTaskFetchesDataAndAddsToQueue() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newFixedThreadPool(1)) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + Runnable pollTask = connector.buildPollTask(); + pollTask.run(); + + Field queueField = HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + } + + @SneakyThrows + @Test + void testHttpRequestIncludesHeaders() { + String testUrl = "http://example.com"; + Map testHeaders = new HashMap<>(); + testHeaders.put("Authorization", "Bearer token"); + testHeaders.put("Content-Type", "application/json"); + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .headers(testHeaders) + .build(); + + Field headersField = HttpConnector.class.getDeclaredField("headers"); + headersField.setAccessible(true); + Map headers = (Map) headersField.get(connector); + assertNotNull(headers); + assertEquals(2, headers.size()); + assertEquals("Bearer token", headers.get("Authorization")); + assertEquals("application/json", headers.get("Content-Type")); + } + + @SneakyThrows + @Test + void testConstructorInitializesWithProvidedValues() { + Integer pollIntervalSeconds = 120; + Integer linkedBlockingQueueCapacity = 200; + Integer scheduledThreadPoolSize = 2; + Integer requestTimeoutSeconds = 20; + Integer connectTimeoutSeconds = 15; + String url = "http://example.com"; + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer token"); + ExecutorService httpClientExecutor = Executors.newFixedThreadPool(2); + String proxyHost = "proxy.example.com"; + Integer proxyPort = 8080; + + HttpConnector connector = HttpConnector.builder() + .pollIntervalSeconds(pollIntervalSeconds) + .linkedBlockingQueueCapacity(linkedBlockingQueueCapacity) + .scheduledThreadPoolSize(scheduledThreadPoolSize) + .requestTimeoutSeconds(requestTimeoutSeconds) + .connectTimeoutSeconds(connectTimeoutSeconds) + .url(url) + .headers(headers) + .httpClientExecutor(httpClientExecutor) + .proxyHost(proxyHost) + .proxyPort(proxyPort) + .build(); + + Field pollIntervalField = HttpConnector.class.getDeclaredField("pollIntervalSeconds"); + pollIntervalField.setAccessible(true); + assertEquals(pollIntervalSeconds, pollIntervalField.get(connector)); + + Field requestTimeoutField = HttpConnector.class.getDeclaredField("requestTimeoutSeconds"); + requestTimeoutField.setAccessible(true); + assertEquals(requestTimeoutSeconds, requestTimeoutField.get(connector)); + + Field queueField = HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertEquals(linkedBlockingQueueCapacity, queue.remainingCapacity() + queue.size()); + + Field headersField = HttpConnector.class.getDeclaredField("headers"); + headersField.setAccessible(true); + Map actualHeaders = (Map) headersField.get(connector); + assertEquals(headers, actualHeaders); + + Field urlField = HttpConnector.class.getDeclaredField("url"); + urlField.setAccessible(true); + assertEquals(url, urlField.get(connector)); + } + + @SneakyThrows + @Test + void testSuccessfulHttpResponseAddsDataToQueue() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + } + + @SneakyThrows + @Test + void testQueueBecomesFull() { + String testUrl = "http://example.com"; + int queueCapacity = 1; + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .linkedBlockingQueueCapacity(queueCapacity) + .build(); + + BlockingQueue queue = connector.getStreamQueue(); + + queue.offer(new QueuePayload(QueuePayloadType.DATA, "test data 1")); + + boolean wasOffered = queue.offer(new QueuePayload(QueuePayloadType.DATA, "test data 2")); + + assertFalse(wasOffered, "Queue should be full and not accept more items"); + } + + @SneakyThrows + @Test + void testShutdownProperlyTerminatesSchedulerAndHttpClientExecutor() throws InterruptedException { + ExecutorService mockHttpClientExecutor = mock(ExecutorService.class); + ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class); + String testUrl = "http://example.com"; + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(mockHttpClientExecutor) + .build(); + + Field schedulerField = HttpConnector.class.getDeclaredField("scheduler"); + schedulerField.setAccessible(true); + schedulerField.set(connector, mockScheduler); + + connector.shutdown(); + + Mockito.verify(mockScheduler).shutdown(); + Mockito.verify(mockHttpClientExecutor).shutdown(); + } + + @SneakyThrows + @Test + void testHttpResponseNonSuccessStatusCode() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(404); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertTrue(queue.isEmpty(), "Queue should be empty when response status is non-200"); + } + + @SneakyThrows + @Test + void test_constructor_handles_proxy_configuration() { + String testUrl = "http://example.com"; + String proxyHost = "proxy.example.com"; + int proxyPort = 8080; + + HttpConnector connectorWithProxy = HttpConnector.builder() + .url(testUrl) + .proxyHost(proxyHost) + .proxyPort(proxyPort) + .build(); + + HttpConnector connectorWithoutProxy = HttpConnector.builder() + .url(testUrl) + .build(); + + Field clientFieldWithProxy = HttpConnector.class.getDeclaredField("client"); + clientFieldWithProxy.setAccessible(true); + HttpClient clientWithProxy = (HttpClient) clientFieldWithProxy.get(connectorWithProxy); + assertNotNull(clientWithProxy); + + Field clientFieldWithoutProxy = HttpConnector.class.getDeclaredField("client"); + clientFieldWithoutProxy.setAccessible(true); + HttpClient clientWithoutProxy = (HttpClient) clientFieldWithoutProxy.get(connectorWithoutProxy); + assertNotNull(clientWithoutProxy); + + Optional proxySelectorWithProxy = clientWithProxy.proxy(); + assertNotNull(proxySelectorWithProxy.get()); + } + + @SneakyThrows + @Test + void testHttpRequestFailsWithException() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new RuntimeException("Test exception")); + + BlockingQueue queue = connector.getStreamQueue(); + + assertTrue(queue.isEmpty(), "Queue should be empty when request fails with exception"); + } + + @SneakyThrows + @Test + void testHttpRequestFailsWithIoexception() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Simulated IO Exception")); + + connector.getStreamQueue(); + + Field queueField = HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertTrue(queue.isEmpty(), "Queue should be empty due to IOException"); + } + + @SneakyThrows + @Test + void testMalformedUrlThrowsException() { + String malformedUrl = "htp://invalid-url"; + + assertThrows(MalformedURLException.class, () -> { + HttpConnector.builder() + .url(malformedUrl) + .build(); + }); + } + + @SneakyThrows + @Test + void testHeadersInitializationWhenNull() { + String testUrl = "http://example.com"; + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .headers(null) + .build(); + + Field headersField = HttpConnector.class.getDeclaredField("headers"); + headersField.setAccessible(true); + Map headers = (Map) headersField.get(connector); + assertNotNull(headers); + assertTrue(headers.isEmpty()); + } + + @SneakyThrows + @Test + void testScheduledPollingContinuesAtFixedIntervals() { + String testUrl = "http://exampOle.com"; + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + + HttpConnector connector = spy(HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build()); + + doReturn(mockResponse).when(connector).execute(any()); + + BlockingQueue queue = connector.getStreamQueue(); + + delay(2000); + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + + connector.shutdown(); + } + + @SneakyThrows + @Test + void testDefaultValuesWhenOptionalParametersAreNull() { + String testUrl = "http://example.com"; + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .build(); + + Field pollIntervalField = HttpConnector.class.getDeclaredField("pollIntervalSeconds"); + pollIntervalField.setAccessible(true); + assertEquals(60, pollIntervalField.get(connector)); + + Field requestTimeoutField = HttpConnector.class.getDeclaredField("requestTimeoutSeconds"); + requestTimeoutField.setAccessible(true); + assertEquals(10, requestTimeoutField.get(connector)); + + Field queueField = HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertEquals(100, queue.remainingCapacity() + queue.size()); + + Field headersField = HttpConnector.class.getDeclaredField("headers"); + headersField.setAccessible(true); + Map headers = (Map) headersField.get(connector); + assertNotNull(headers); + assertTrue(headers.isEmpty()); + + Field httpClientExecutorField = HttpConnector.class.getDeclaredField("httpClientExecutor"); + httpClientExecutorField.setAccessible(true); + ExecutorService httpClientExecutor = (ExecutorService) httpClientExecutorField.get(connector); + assertNotNull(httpClientExecutor); + } + + @SneakyThrows + @Test + void testQueuePayloadTypeSetToDataOnSuccess() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + ExecutorService mockExecutor = Executors.newFixedThreadPool(1); + + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("response body"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(mockExecutor) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + QueuePayload payload = queue.poll(1, TimeUnit.SECONDS); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("response body", payload.getFlagData()); + } + + @SneakyThrows + private static void delay(long ms) { + Thread.sleep(ms); + } + +} From f93014ef89354d014db48e53dcdeaee833cc997a Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 30 Mar 2025 18:37:37 +0300 Subject: [PATCH 02/18] adding http connector - cont. Signed-off-by: liran2000 --- providers/flagd/README.md | 33 +++++ .../storage/connector/sync/HttpConnector.java | 138 ++++++++++++------ .../storage/connector/sync/PayloadCache.java | 6 + .../connector/sync/PayloadCacheOptions.java | 24 +++ .../connector/sync/PayloadCacheWrapper.java | 56 +++++++ .../connector/sync/HttpConnectorTest.java | 43 ++++++ 6 files changed, 254 insertions(+), 46 deletions(-) create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java diff --git a/providers/flagd/README.md b/providers/flagd/README.md index 4fee01e84..f0438f727 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -54,6 +54,39 @@ The value is updated with every (re)connection to the sync implementation. This can be used to enrich evaluations with such data. If the `in-process` mode is not used, and before the provider is ready, the `getSyncMetadata` returns an empty map. +#### Http Connector +HttpConnector is responsible for polling data from a specified URL at regular intervals. +The implementation is using Java HttpClient. + +##### What happens if the Http source is down when application is starting ? + +It supports optional fail-safe initialization via cache, such that on initial fetch error following by +source downtime window, initial payload is taken from cache to avoid starting with default values until +the source is back up. Therefore, the cache ttl expected to be higher than the expected source +down-time to recover from during initialization. + +##### Sample flow +Sample flow can use: +- Github as the flags payload source. +- Redis cache as a fail-safe initialization cache. + +Sample flow of initialization during Github down-time window, showing that application can still use flags +values as fetched from cache. +```mermaid +sequenceDiagram + participant Provider + participant Github + participant Redis + + break source downtime + Provider->>Github: initialize + Github->>Provider: failure + end + Provider->>Redis: fetch + Redis->>Provider: last payload + +``` + ### Offline mode (File resolver) In-process resolvers can also work in an offline mode. diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java index 9d92c83cb..63311ede7 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java @@ -30,18 +30,22 @@ import static java.net.http.HttpClient.Builder.NO_PROXY; /** - * HttpConnector is responsible for managing HTTP connections and polling data from a specified URL - * at regular intervals. It implements the QueueSource interface to enqueue and dequeue change messages. + * HttpConnector is responsible for polling data from a specified URL at regular intervals. + * Notice rate limits for polling http sources like Github. + * It implements the QueueSource interface to enqueue and dequeue change messages. * The class supports configurable parameters such as poll interval, request timeout, and proxy settings. * It uses a ScheduledExecutorService to schedule polling tasks and an ExecutorService for HTTP client execution. * The class also provides methods to initialize, retrieve the stream queue, and shutdown the connector gracefully. + * It supports optional fail-safe initialization via cache. + * + * See readme - Http Connector section. */ @Slf4j public class HttpConnector implements QueueSource { private static final int DEFAULT_POLL_INTERVAL_SECONDS = 60; private static final int DEFAULT_LINKED_BLOCKING_QUEUE_CAPACITY = 100; - private static final int DEFAULT_SCHEDULED_THREAD_POOL_SIZE = 1; + private static final int DEFAULT_SCHEDULED_THREAD_POOL_SIZE = 2; private static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; @@ -52,19 +56,19 @@ public class HttpConnector implements QueueSource { private ExecutorService httpClientExecutor; private ScheduledExecutorService scheduler; private Map headers; + private PayloadCacheWrapper payloadCacheWrapper; + private PayloadCache payloadCache; + @NonNull private String url; - // TODO init failure backup cache redis - - // todo update provider readme - @Builder public HttpConnector(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, - Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort) { + Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort, + PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache) { validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, - connectTimeoutSeconds, proxyHost, proxyPort); + connectTimeoutSeconds, proxyHost, proxyPort, payloadCacheOptions, payloadCache); this.pollIntervalSeconds = pollIntervalSeconds == null ? DEFAULT_POLL_INTERVAL_SECONDS : pollIntervalSeconds; int thisLinkedBlockingQueueCapacity = linkedBlockingQueueCapacity == null ? DEFAULT_LINKED_BLOCKING_QUEUE_CAPACITY : linkedBlockingQueueCapacity; int thisScheduledThreadPoolSize = scheduledThreadPoolSize == null ? DEFAULT_SCHEDULED_THREAD_POOL_SIZE : scheduledThreadPoolSize; @@ -89,12 +93,20 @@ public HttpConnector(Integer pollIntervalSeconds, Integer linkedBlockingQueueCap .executor(this.httpClientExecutor) .build(); this.queue = new LinkedBlockingQueue<>(thisLinkedBlockingQueueCapacity); + this.payloadCache = payloadCache; + if (payloadCache != null) { + this.payloadCacheWrapper = PayloadCacheWrapper.builder() + .payloadCache(payloadCache) + .payloadCacheOptions(payloadCacheOptions) + .build(); + } } @SneakyThrows private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, - Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, - String proxyHost, Integer proxyPort) { + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, + String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, + PayloadCache payloadCache) { new URL(url).toURI(); if (pollIntervalSeconds != null && (pollIntervalSeconds < 1 || pollIntervalSeconds > 600)) { throw new IllegalArgumentException("pollIntervalSeconds must be between 1 and 600"); @@ -119,6 +131,12 @@ private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlo } else if (proxyHost == null && proxyPort != null) { throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); } + if (payloadCacheOptions != null && payloadCache == null) { + throw new IllegalArgumentException("payloadCache must be set if payloadCacheOptions is set"); + } + if (payloadCache != null && payloadCacheOptions == null) { + throw new IllegalArgumentException("payloadCacheOptions must be set if payloadCache is set"); + } } @Override @@ -128,51 +146,79 @@ public void init() throws Exception { @Override public BlockingQueue getStreamQueue() { + boolean success = fetchAndUpdate(); + if (!success) { + log.info("failed initial fetch"); + if (payloadCache != null) { + updateFromCache(); + } + } Runnable pollTask = buildPollTask(); - - // run first poll immediately and wait for it to finish - pollTask.run(); - scheduler.scheduleAtFixedRate(pollTask, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); return queue; } + private void updateFromCache() { + log.info("taking initial payload from cache to avoid starting with default values"); + String flagData = payloadCache.get(); + if (flagData == null) { + log.debug("got null from cache"); + return; + } + if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, flagData))) { + log.warn("init: Unable to offer file content to queue: queue is full"); + } + } + protected Runnable buildPollTask() { - return () -> { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(requestTimeoutSeconds)) - .GET(); - headers.forEach(requestBuilder::header); - HttpRequest request = requestBuilder - .build(); + return this::fetchAndUpdate; + } - HttpResponse response; - try { - log.debug("fetching response"); - response = execute(request); - } catch (IOException e) { - log.info("could not fetch", e); - return; - } catch (Exception e) { - log.debug("exception", e); - return; - } - log.debug("fetched response"); - if (response.statusCode() != 200) { - log.info("received non-successful status code: {} {}", response.statusCode(), response.body()); - return; - } - if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, response.body()))) { - log.warn("Unable to offer file content to queue: queue is full"); - } - }; + private boolean fetchAndUpdate() { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(requestTimeoutSeconds)) + .GET(); + headers.forEach(requestBuilder::header); + HttpRequest request = requestBuilder + .build(); + + HttpResponse response; + try { + log.debug("fetching response"); + response = execute(request); + } catch (IOException e) { + log.info("could not fetch", e); + return false; + } catch (Exception e) { + log.debug("exception", e); + return false; + } + log.debug("fetched response"); + String payload = response.body(); + if (response.statusCode() != 200) { + log.info("received non-successful status code: {} {}", response.statusCode(), payload); + return false; + } + if (payload == null) { + log.debug("payload is null"); + return false; + } + if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, payload))) { + log.warn("Unable to offer file content to queue: queue is full"); + return false; + } + if (payloadCacheWrapper != null) { + log.debug("scheduling cache update if needed"); + scheduler.execute(() -> + payloadCacheWrapper.updatePayloadIfNeeded(payload) + ); + } + return payload != null; } protected HttpResponse execute(HttpRequest request) throws IOException, InterruptedException { - HttpResponse response; - response = client.send(request, HttpResponse.BodyHandlers.ofString()); - return response; + return client.send(request, HttpResponse.BodyHandlers.ofString()); } @Override diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java new file mode 100644 index 000000000..c2e8f62c4 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java @@ -0,0 +1,6 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +public interface PayloadCache { + public void put(String payload); + public String get(); +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java new file mode 100644 index 000000000..50b614c3b --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java @@ -0,0 +1,24 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +import lombok.Builder; +import lombok.Getter; + +/** + * Represents configuration options for caching payloads. + *

+ * This class provides options to configure the caching behavior, + * specifically the interval at which the cache should be updated. + *

+ *

+ * The default update interval is set to 30 minutes. + * Change it typically to a value according to cache ttl and tradeoff with not updating it too much for + * corner cases. + *

+ */ +@Builder +@Getter +public class PayloadCacheOptions { + + @Builder.Default + private int updateIntervalSeconds = 60 * 30; // 30 minutes +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java new file mode 100644 index 000000000..1b84b7d35 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java @@ -0,0 +1,56 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * A wrapper class for managing a payload cache with a specified update interval. + * This class ensures that the cache is only updated if the specified time interval + * has passed since the last update. It logs debug messages when updates are skipped + * and error messages if the update process fails. + * Not thread-safe. + * + *

Usage involves creating an instance with {@link PayloadCacheOptions} to set + * the update interval, and then using {@link #updatePayloadIfNeeded(String)} to + * conditionally update the cache and {@link #get()} to retrieve the cached payload.

+ */ +@Slf4j +public class PayloadCacheWrapper { + private long lastUpdateTimeMs; + private long updateIntervalMs; + private PayloadCache payloadCache; + + @Builder + public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloadCacheOptions) { + if (payloadCacheOptions.getUpdateIntervalSeconds() < 500) { + throw new IllegalArgumentException("pollIntervalSeconds must be larger than 500"); + } + this.updateIntervalMs = payloadCacheOptions.getUpdateIntervalSeconds() * 1000; + this.payloadCache = payloadCache; + } + + public void updatePayloadIfNeeded(String payload) { + if ((System.currentTimeMillis() - lastUpdateTimeMs) < updateIntervalMs) { + log.debug("not updating payload, updateIntervalMs not reached"); + return; + } + + try { + log.debug("updating payload"); + payloadCache.put(payload); + lastUpdateTimeMs = System.currentTimeMillis(); + } catch (Exception e) { + log.error("failed updating cache", e); + } + } + + public String get() { + try { + return payloadCache.get(); + } catch (Exception e) { + log.error("failed getting from cache", e); + return null; + } + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java index 60ffd05ef..6bae6b627 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java @@ -272,6 +272,49 @@ void testSuccessfulHttpResponseAddsDataToQueue() { assertEquals("test data", payload.getFlagData()); } + @SneakyThrows + @Test + void testInitFailureUsingCache() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Simulated IO Exception")); + + final String cachedData = "cached data"; + PayloadCache payloadCache = new PayloadCache() { + @Override + public void put(String payload) { + // do nothing + } + + @Override + public String get() { + return cachedData; + } + }; + + HttpConnector connector = HttpConnector.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .payloadCache(payloadCache) + .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals(cachedData, payload.getFlagData()); + } + @SneakyThrows @Test void testQueueBecomesFull() { From d8f6943ca8e38fd4c973e66ff086cbd0cc7b7fb8 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Mon, 31 Mar 2025 09:10:32 +0300 Subject: [PATCH 03/18] adding http cache Signed-off-by: liran2000 --- providers/flagd/README.md | 5 +- .../connector/sync/HttpCacheFetcher.java | 46 +++++++++++ .../storage/connector/sync/HttpConnector.java | 27 +++++-- .../sync/HttpConnectorIntegrationTest.java | 78 +++++++++++++++++++ .../connector/sync/HttpConnectorTest.java | 2 +- .../test/resources/simplelogger.properties | 2 +- 6 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java diff --git a/providers/flagd/README.md b/providers/flagd/README.md index f0438f727..1f2e6de0a 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -55,7 +55,10 @@ This can be used to enrich evaluations with such data. If the `in-process` mode is not used, and before the provider is ready, the `getSyncMetadata` returns an empty map. #### Http Connector -HttpConnector is responsible for polling data from a specified URL at regular intervals. +HttpConnector is responsible for polling data from a specified URL at regular intervals. +It is implementing Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, reducing traffic and +changes updates. Can be enabled via useHttpCache option. +One of its benefits is to reduce infrastructure/devops work, without additional containers needed. The implementation is using Java HttpClient. ##### What happens if the Http source is down when application is starting ? diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java new file mode 100644 index 000000000..97fb17b18 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java @@ -0,0 +1,46 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** + * Fetches content from a given HTTP endpoint using caching headers to optimize network usage. + * If cached ETag or Last-Modified values are available, they are included in the request headers + * to potentially receive a 304 Not Modified response, reducing data transfer. + * Updates the cached ETag and Last-Modified values upon receiving a 200 OK response. + * It does not store the cached response, assuming not needed after first successful fetching. + * + * @param httpClient the HTTP client used to send the request + * @param httpRequestBuilder the builder for constructing the HTTP request + * @return the HTTP response received from the server + */ +@Slf4j +public class HttpCacheFetcher { + private static String cachedETag = null; + private static String cachedLastModified = null; + + @SneakyThrows + public HttpResponse fetchContent(HttpClient httpClient, HttpRequest.Builder httpRequestBuilder) { + if (cachedETag != null) { + httpRequestBuilder.header("If-None-Match", cachedETag); + } + if (cachedLastModified != null) { + httpRequestBuilder.header("If-Modified-Since", cachedLastModified); + } + + HttpRequest request = httpRequestBuilder.build(); + HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (httpResponse.statusCode() == 200) { + cachedETag = httpResponse.headers().firstValue("ETag").orElse(null); + cachedLastModified = httpResponse.headers().firstValue("Last-Modified").orElse(null); + log.debug("fetched new content"); + } else if (httpResponse.statusCode() == 304) { + log.debug("got 304 Not Modified"); + } + return httpResponse; + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java index 63311ede7..abb0989c0 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java @@ -58,6 +58,7 @@ public class HttpConnector implements QueueSource { private Map headers; private PayloadCacheWrapper payloadCacheWrapper; private PayloadCache payloadCache; + private HttpCacheFetcher httpCacheFetcher; @NonNull private String url; @@ -66,7 +67,7 @@ public class HttpConnector implements QueueSource { public HttpConnector(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort, - PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache) { + PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache, Boolean useHttpCache) { validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, connectTimeoutSeconds, proxyHost, proxyPort, payloadCacheOptions, payloadCache); this.pollIntervalSeconds = pollIntervalSeconds == null ? DEFAULT_POLL_INTERVAL_SECONDS : pollIntervalSeconds; @@ -100,6 +101,9 @@ public HttpConnector(Integer pollIntervalSeconds, Integer linkedBlockingQueueCap .payloadCacheOptions(payloadCacheOptions) .build(); } + if (Boolean.TRUE.equals(useHttpCache)) { + httpCacheFetcher = new HttpCacheFetcher(); + } } @SneakyThrows @@ -180,13 +184,11 @@ private boolean fetchAndUpdate() { .timeout(Duration.ofSeconds(requestTimeoutSeconds)) .GET(); headers.forEach(requestBuilder::header); - HttpRequest request = requestBuilder - .build(); HttpResponse response; try { log.debug("fetching response"); - response = execute(request); + response = execute(requestBuilder); } catch (IOException e) { log.info("could not fetch", e); return false; @@ -196,14 +198,18 @@ private boolean fetchAndUpdate() { } log.debug("fetched response"); String payload = response.body(); - if (response.statusCode() != 200) { + if (!isSuccessful(response)) { log.info("received non-successful status code: {} {}", response.statusCode(), payload); return false; + } else if (response.statusCode() == 304) { + log.debug("got 304 Not Modified, skipping update"); + return false; } if (payload == null) { log.debug("payload is null"); return false; } + log.debug("adding payload to queue"); if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, payload))) { log.warn("Unable to offer file content to queue: queue is full"); return false; @@ -217,8 +223,15 @@ private boolean fetchAndUpdate() { return payload != null; } - protected HttpResponse execute(HttpRequest request) throws IOException, InterruptedException { - return client.send(request, HttpResponse.BodyHandlers.ofString()); + private static boolean isSuccessful(HttpResponse response) { + return response.statusCode() == 200 || response.statusCode() == 304; + } + + protected HttpResponse execute(HttpRequest.Builder requestBuilder) throws IOException, InterruptedException { + if (httpCacheFetcher != null) { + return httpCacheFetcher.fetchContent(client, requestBuilder); + } + return client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); } @Override diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java new file mode 100644 index 000000000..a870ff62a --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java @@ -0,0 +1,78 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; + +import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.HttpConnectorTest.delay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.MalformedURLException; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Integration test for the HttpConnector class, specifically testing the ability to fetch + * raw content from a GitHub URL. This test assumes that integration tests are enabled + * and verifies that the HttpConnector can successfully enqueue data from the specified URL. + * The test initializes the HttpConnector with specific configurations, waits for data + * to be enqueued, and asserts the expected queue size. The connector is shut down + * gracefully after the test execution. + * As this integration test using external request, it is disabled by default, and not part of the CI build. + */ +@Slf4j +class HttpConnectorIntegrationTest { + + @SneakyThrows + @Test + void testGithubRawContent() { + assumeTrue(parseBoolean("integrationTestsEnabled")); + HttpConnector connector = null; + try { + String testUrl = "https://raw.githubusercontent.com/open-feature/java-sdk-contrib/58fe5da7d4e2f6f4ae2c1caf3411a01e84a1dc1a/providers/flagd/version.txt"; + connector = HttpConnector.builder() + .url(testUrl) + .connectTimeoutSeconds(10) + .requestTimeoutSeconds(10) + .useHttpCache(true) + .pollIntervalSeconds(5) + .build(); + BlockingQueue queue = connector.getStreamQueue(); + delay(20000); + assertEquals(1, queue.size()); + } finally { + if (connector != null) { + connector.shutdown(); + } + } + } + + public static boolean parseBoolean(String key) { + return Boolean.parseBoolean(System.getProperty(key, System.getenv(key))); + } + +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java index 6bae6b627..910fa3480 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java @@ -579,7 +579,7 @@ void testQueuePayloadTypeSetToDataOnSuccess() { } @SneakyThrows - private static void delay(long ms) { + protected static void delay(long ms) { Thread.sleep(ms); } diff --git a/providers/flagd/src/test/resources/simplelogger.properties b/providers/flagd/src/test/resources/simplelogger.properties index d2ca1bbdc..769e4e8bf 100644 --- a/providers/flagd/src/test/resources/simplelogger.properties +++ b/providers/flagd/src/test/resources/simplelogger.properties @@ -1,4 +1,4 @@ -org.org.slf4j.simpleLogger.defaultLogLevel=debug +org.slf4j.simpleLogger.defaultLogLevel=debug org.slf4j.simpleLogger.showDateTime= io.grpc.level=trace From c5d93b5d558bb0560215202ebeee106f608edb63 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Mon, 31 Mar 2025 12:40:39 +0300 Subject: [PATCH 04/18] refactor for using options Signed-off-by: liran2000 --- providers/flagd/README.md | 2 +- .../sync/{ => http}/HttpCacheFetcher.java | 13 +- .../sync/{ => http}/HttpConnector.java | 94 +--- .../sync/http/HttpConnectorOptions.java | 125 ++++++ .../sync/{ => http}/PayloadCache.java | 2 +- .../sync/{ => http}/PayloadCacheOptions.java | 2 +- .../sync/{ => http}/PayloadCacheWrapper.java | 17 +- .../sync/http/HttpCacheFetcherTest.java | 308 +++++++++++++ .../HttpConnectorIntegrationTest.java | 35 +- .../sync/http/HttpConnectorOptionsTest.java | 406 ++++++++++++++++++ .../sync/{ => http}/HttpConnectorTest.java | 316 +++----------- .../sync/http/PayloadCacheWrapperTest.java | 267 ++++++++++++ 12 files changed, 1223 insertions(+), 364 deletions(-) rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/HttpCacheFetcher.java (81%) rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/HttpConnector.java (55%) create mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/PayloadCache.java (84%) rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/PayloadCacheOptions.java (95%) rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/PayloadCacheWrapper.java (82%) create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java rename providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/HttpConnectorIntegrationTest.java (65%) create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java rename providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/{ => http}/HttpConnectorTest.java (57%) create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java diff --git a/providers/flagd/README.md b/providers/flagd/README.md index 1f2e6de0a..d26ef6433 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -56,7 +56,7 @@ If the `in-process` mode is not used, and before the provider is ready, the `get #### Http Connector HttpConnector is responsible for polling data from a specified URL at regular intervals. -It is implementing Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, reducing traffic and +It is leveraging Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, reducing traffic and changes updates. Can be enabled via useHttpCache option. One of its benefits is to reduce infrastructure/devops work, without additional containers needed. The implementation is using Java HttpClient. diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java similarity index 81% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java index 97fb17b18..e2a59a9fc 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpCacheFetcher.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -12,6 +12,7 @@ * to potentially receive a 304 Not Modified response, reducing data transfer. * Updates the cached ETag and Last-Modified values upon receiving a 200 OK response. * It does not store the cached response, assuming not needed after first successful fetching. + * Non thread-safe. * * @param httpClient the HTTP client used to send the request * @param httpRequestBuilder the builder for constructing the HTTP request @@ -19,8 +20,8 @@ */ @Slf4j public class HttpCacheFetcher { - private static String cachedETag = null; - private static String cachedLastModified = null; + private String cachedETag = null; + private String cachedLastModified = null; @SneakyThrows public HttpResponse fetchContent(HttpClient httpClient, HttpRequest.Builder httpRequestBuilder) { @@ -35,8 +36,10 @@ public HttpResponse fetchContent(HttpClient httpClient, HttpRequest.Buil HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (httpResponse.statusCode() == 200) { - cachedETag = httpResponse.headers().firstValue("ETag").orElse(null); - cachedLastModified = httpResponse.headers().firstValue("Last-Modified").orElse(null); + if (httpResponse.headers() != null) { + cachedETag = httpResponse.headers().firstValue("ETag").orElse(null); + cachedLastModified = httpResponse.headers().firstValue("Last-Modified").orElse(null); + } log.debug("fetched new content"); } else if (httpResponse.statusCode() == 304) { log.debug("got 304 Not Modified"); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java similarity index 55% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java index abb0989c0..b72b5c035 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnector.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; @@ -6,19 +6,16 @@ import dev.openfeature.contrib.providers.flagd.util.ConcurrentUtils; import lombok.Builder; import lombok.NonNull; -import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.net.InetSocketAddress; import java.net.ProxySelector; import java.net.URI; -import java.net.URL; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; @@ -43,12 +40,6 @@ @Slf4j public class HttpConnector implements QueueSource { - private static final int DEFAULT_POLL_INTERVAL_SECONDS = 60; - private static final int DEFAULT_LINKED_BLOCKING_QUEUE_CAPACITY = 100; - private static final int DEFAULT_SCHEDULED_THREAD_POOL_SIZE = 2; - private static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 10; - private static final int DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; - private Integer pollIntervalSeconds; private Integer requestTimeoutSeconds; private BlockingQueue queue; @@ -64,85 +55,36 @@ public class HttpConnector implements QueueSource { private String url; @Builder - public HttpConnector(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, - Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, - Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort, - PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache, Boolean useHttpCache) { - validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, - connectTimeoutSeconds, proxyHost, proxyPort, payloadCacheOptions, payloadCache); - this.pollIntervalSeconds = pollIntervalSeconds == null ? DEFAULT_POLL_INTERVAL_SECONDS : pollIntervalSeconds; - int thisLinkedBlockingQueueCapacity = linkedBlockingQueueCapacity == null ? DEFAULT_LINKED_BLOCKING_QUEUE_CAPACITY : linkedBlockingQueueCapacity; - int thisScheduledThreadPoolSize = scheduledThreadPoolSize == null ? DEFAULT_SCHEDULED_THREAD_POOL_SIZE : scheduledThreadPoolSize; - this.requestTimeoutSeconds = requestTimeoutSeconds == null ? DEFAULT_REQUEST_TIMEOUT_SECONDS : requestTimeoutSeconds; - int thisConnectTimeoutSeconds = connectTimeoutSeconds == null ? DEFAULT_CONNECT_TIMEOUT_SECONDS : connectTimeoutSeconds; + public HttpConnector(HttpConnectorOptions httpConnectorOptions) { + this.pollIntervalSeconds = httpConnectorOptions.getPollIntervalSeconds(); + this.requestTimeoutSeconds = httpConnectorOptions.getRequestTimeoutSeconds(); ProxySelector proxySelector = NO_PROXY; - if (proxyHost != null && proxyPort != null) { - proxySelector = ProxySelector.of(new InetSocketAddress(proxyHost, proxyPort)); - } - - this.url = url; - this.headers = headers; - this.httpClientExecutor = httpClientExecutor == null ? Executors.newFixedThreadPool(1) : - httpClientExecutor; - scheduler = Executors.newScheduledThreadPool(thisScheduledThreadPoolSize); - if (headers == null) { - this.headers = new HashMap<>(); - } + if (httpConnectorOptions.getProxyHost() != null && httpConnectorOptions.getProxyPort() != null) { + proxySelector = ProxySelector.of(new InetSocketAddress(httpConnectorOptions.getProxyHost(), + httpConnectorOptions.getProxyPort())); + } + this.url = httpConnectorOptions.getUrl(); + this.headers = httpConnectorOptions.getHeaders(); + this.httpClientExecutor = httpConnectorOptions.getHttpClientExecutor(); + scheduler = Executors.newScheduledThreadPool(httpConnectorOptions.getScheduledThreadPoolSize()); this.client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(thisConnectTimeoutSeconds)) + .connectTimeout(Duration.ofSeconds(httpConnectorOptions.getConnectTimeoutSeconds())) .proxy(proxySelector) .executor(this.httpClientExecutor) .build(); - this.queue = new LinkedBlockingQueue<>(thisLinkedBlockingQueueCapacity); - this.payloadCache = payloadCache; + this.queue = new LinkedBlockingQueue<>(httpConnectorOptions.getLinkedBlockingQueueCapacity()); + this.payloadCache = httpConnectorOptions.getPayloadCache(); if (payloadCache != null) { this.payloadCacheWrapper = PayloadCacheWrapper.builder() .payloadCache(payloadCache) - .payloadCacheOptions(payloadCacheOptions) + .payloadCacheOptions(httpConnectorOptions.getPayloadCacheOptions()) .build(); } - if (Boolean.TRUE.equals(useHttpCache)) { + if (Boolean.TRUE.equals(httpConnectorOptions.getUseHttpCache())) { httpCacheFetcher = new HttpCacheFetcher(); } } - @SneakyThrows - private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, - Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, - String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, - PayloadCache payloadCache) { - new URL(url).toURI(); - if (pollIntervalSeconds != null && (pollIntervalSeconds < 1 || pollIntervalSeconds > 600)) { - throw new IllegalArgumentException("pollIntervalSeconds must be between 1 and 600"); - } - if (linkedBlockingQueueCapacity != null && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { - throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); - } - if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { - throw new IllegalArgumentException("scheduledThreadPoolSize must be between 1 and 10"); - } - if (requestTimeoutSeconds != null && (requestTimeoutSeconds < 1 || requestTimeoutSeconds > 60)) { - throw new IllegalArgumentException("requestTimeoutSeconds must be between 1 and 60"); - } - if (connectTimeoutSeconds != null && (connectTimeoutSeconds < 1 || connectTimeoutSeconds > 60)) { - throw new IllegalArgumentException("connectTimeoutSeconds must be between 1 and 60"); - } - if (proxyPort != null && (proxyPort < 1 || proxyPort > 65535)) { - throw new IllegalArgumentException("proxyPort must be between 1 and 65535"); - } - if (proxyHost != null && proxyPort == null ) { - throw new IllegalArgumentException("proxyPort must be set if proxyHost is set"); - } else if (proxyHost == null && proxyPort != null) { - throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); - } - if (payloadCacheOptions != null && payloadCache == null) { - throw new IllegalArgumentException("payloadCache must be set if payloadCacheOptions is set"); - } - if (payloadCache != null && payloadCacheOptions == null) { - throw new IllegalArgumentException("payloadCacheOptions must be set if payloadCache is set"); - } - } - @Override public void init() throws Exception { log.info("init Http Connector"); @@ -158,7 +100,7 @@ public BlockingQueue getStreamQueue() { } } Runnable pollTask = buildPollTask(); - scheduler.scheduleAtFixedRate(pollTask, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); + scheduler.scheduleWithFixedDelay(pollTask, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); return queue; } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java new file mode 100644 index 000000000..0f8ff0186 --- /dev/null +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java @@ -0,0 +1,125 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; +import lombok.SneakyThrows; + +@Getter +public class HttpConnectorOptions { + + @Builder.Default + private Integer pollIntervalSeconds = 60; + @Builder.Default + private Integer connectTimeoutSeconds = 10; + @Builder.Default + private Integer requestTimeoutSeconds = 10; + @Builder.Default + private Integer linkedBlockingQueueCapacity = 100; + @Builder.Default + private Integer scheduledThreadPoolSize = 2; + @Builder.Default + private Map headers = new HashMap<>(); + @Builder.Default + private ExecutorService httpClientExecutor = Executors.newFixedThreadPool(1); + @Builder.Default + private String proxyHost; + @Builder.Default + private Integer proxyPort; + @Builder.Default + private PayloadCacheOptions payloadCacheOptions; + @Builder.Default + private PayloadCache payloadCache; + @Builder.Default + private Boolean useHttpCache; + @NonNull + private String url; + + @Builder + public HttpConnectorOptions(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, + Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort, + PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache, Boolean useHttpCache) { + validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, + connectTimeoutSeconds, proxyHost, proxyPort, payloadCacheOptions, payloadCache); + if (pollIntervalSeconds != null) { + this.pollIntervalSeconds = pollIntervalSeconds; + } + if (linkedBlockingQueueCapacity != null) { + this.linkedBlockingQueueCapacity = linkedBlockingQueueCapacity; + } + if (scheduledThreadPoolSize != null) { + this.scheduledThreadPoolSize = scheduledThreadPoolSize; + } + if (requestTimeoutSeconds != null) { + this.requestTimeoutSeconds = requestTimeoutSeconds; + } + if (connectTimeoutSeconds != null) { + this.connectTimeoutSeconds = connectTimeoutSeconds; + } + this.url = url; + if (headers != null) { + this.headers = headers; + } + if (httpClientExecutor != null) { + this.httpClientExecutor = httpClientExecutor; + } + if (proxyHost != null) { + this.proxyHost = proxyHost; + } + if (proxyPort != null) { + this.proxyPort = proxyPort; + } + if (payloadCache != null) { + this.payloadCache = payloadCache; + } + if (payloadCacheOptions != null) { + this.payloadCacheOptions = payloadCacheOptions; + } + if (useHttpCache != null) { + this.useHttpCache = useHttpCache; + } + } + + @SneakyThrows + private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, + String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, + PayloadCache payloadCache) { + new URL(url).toURI(); + if (linkedBlockingQueueCapacity != null && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { + throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); + } + if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { + throw new IllegalArgumentException("scheduledThreadPoolSize must be between 1 and 10"); + } + if (requestTimeoutSeconds != null && (requestTimeoutSeconds < 1 || requestTimeoutSeconds > 60)) { + throw new IllegalArgumentException("requestTimeoutSeconds must be between 1 and 60"); + } + if (connectTimeoutSeconds != null && (connectTimeoutSeconds < 1 || connectTimeoutSeconds > 60)) { + throw new IllegalArgumentException("connectTimeoutSeconds must be between 1 and 60"); + } + if (pollIntervalSeconds != null && (pollIntervalSeconds < 1 || pollIntervalSeconds > 600)) { + throw new IllegalArgumentException("pollIntervalSeconds must be between 1 and 600"); + } + if (proxyPort != null && (proxyPort < 1 || proxyPort > 65535)) { + throw new IllegalArgumentException("proxyPort must be between 1 and 65535"); + } + if (proxyHost != null && proxyPort == null ) { + throw new IllegalArgumentException("proxyPort must be set if proxyHost is set"); + } else if (proxyHost == null && proxyPort != null) { + throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); + } + if (payloadCacheOptions != null && payloadCache == null) { + throw new IllegalArgumentException("payloadCache must be set if payloadCacheOptions is set"); + } + if (payloadCache != null && payloadCacheOptions == null) { + throw new IllegalArgumentException("payloadCacheOptions must be set if payloadCache is set"); + } + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java similarity index 84% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java index c2e8f62c4..31416af1e 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCache.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; public interface PayloadCache { public void put(String payload); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java similarity index 95% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java index 50b614c3b..d29ed115d 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheOptions.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; import lombok.Builder; import lombok.Getter; diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java similarity index 82% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java rename to providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java index 1b84b7d35..449cf1969 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/PayloadCacheWrapper.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java @@ -1,7 +1,6 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; import lombok.Builder; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; /** @@ -23,15 +22,15 @@ public class PayloadCacheWrapper { @Builder public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloadCacheOptions) { - if (payloadCacheOptions.getUpdateIntervalSeconds() < 500) { - throw new IllegalArgumentException("pollIntervalSeconds must be larger than 500"); + if (payloadCacheOptions.getUpdateIntervalSeconds() < 1) { + throw new IllegalArgumentException("pollIntervalSeconds must be larger than 0"); } - this.updateIntervalMs = payloadCacheOptions.getUpdateIntervalSeconds() * 1000; + this.updateIntervalMs = payloadCacheOptions.getUpdateIntervalSeconds() * 1000L; this.payloadCache = payloadCache; } public void updatePayloadIfNeeded(String payload) { - if ((System.currentTimeMillis() - lastUpdateTimeMs) < updateIntervalMs) { + if ((getCurrentTimeMillis() - lastUpdateTimeMs) < updateIntervalMs) { log.debug("not updating payload, updateIntervalMs not reached"); return; } @@ -39,12 +38,16 @@ public void updatePayloadIfNeeded(String payload) { try { log.debug("updating payload"); payloadCache.put(payload); - lastUpdateTimeMs = System.currentTimeMillis(); + lastUpdateTimeMs = getCurrentTimeMillis(); } catch (Exception e) { log.error("failed updating cache", e); } } + protected long getCurrentTimeMillis() { + return System.currentTimeMillis(); + } + public String get() { try { return payloadCache.get(); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java new file mode 100644 index 000000000..a6d0b859e --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java @@ -0,0 +1,308 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +public class HttpCacheFetcherTest { + + @Test + public void testFirstRequestSendsNoCacheHeaders() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); + verify(requestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); + } + + @Test + public void testResponseWith200ButNoCacheHeaders() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = mock(HttpResponse.class); + HttpHeaders headersMock = mock(HttpHeaders.class); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + when(responseMock.headers()).thenReturn(headersMock); + when(headersMock.firstValue("ETag")).thenReturn(Optional.empty()); + when(headersMock.firstValue("Last-Modified")).thenReturn(Optional.empty()); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); + + assertEquals(200, response.statusCode()); + + HttpRequest.Builder secondRequestBuilderMock = mock(HttpRequest.Builder.class); + when(secondRequestBuilderMock.build()).thenReturn(requestMock); + + fetcher.fetchContent(httpClientMock, secondRequestBuilderMock); + + verify(secondRequestBuilderMock, never()).header(eq("If-None-Match"), anyString()); + verify(secondRequestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); + } + + @Test + public void testFetchContentReturnsHttpResponse() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(404); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + HttpResponse result = fetcher.fetchContent(httpClientMock, requestBuilderMock); + + assertEquals(responseMock, result); + } + + @Test + public void test200ResponseNoEtagOrLastModified() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); + cachedETagField.setAccessible(true); + assertNull(cachedETagField.get(fetcher)); + Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + cachedLastModifiedField.setAccessible(true); + assertNull(cachedLastModifiedField.get(fetcher)); + } + + @Test + public void testUpdateCacheOn200Response() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("Last-Modified", Arrays.asList("Wed, 21 Oct 2015 07:28:00 GMT"), + "ETag", Arrays.asList("etag-value")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); + cachedETagField.setAccessible(true); + assertEquals("etag-value", cachedETagField.get(fetcher)); + Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + cachedLastModifiedField.setAccessible(true); + assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", cachedLastModifiedField.get(fetcher)); + } + + @Test + public void testRequestWithCachedEtagIncludesIfNoneMatchHeader() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("ETag", Arrays.asList("12345")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock, times(1)).header("If-None-Match", "12345"); + } + + @Test + public void testNullHttpClientOrRequestBuilder() { + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + + assertThrows(NullPointerException.class, () -> { + fetcher.fetchContent(null, requestBuilderMock); + }); + + assertThrows(NullPointerException.class, () -> { + fetcher.fetchContent(mock(HttpClient.class), null); + }); + } + + @Test + public void testResponseWithUnexpectedStatusCode() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(500); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); + + assertEquals(500, response.statusCode()); + verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); + verify(requestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); + } + + @Test + public void testRequestIncludesIfModifiedSinceHeaderWhenLastModifiedCached() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("Last-Modified", Arrays.asList("Wed, 21 Oct 2015 07:28:00 GMT")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock).header(eq("If-Modified-Since"), eq("Wed, 21 Oct 2015 07:28:00 GMT")); + } + + @Test + public void testCalls200And304Responses() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock200 = mock(HttpResponse.class); + HttpResponse responseMock304 = mock(HttpResponse.class); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) + .thenReturn(responseMock200) + .thenReturn(responseMock304); + when(responseMock200.statusCode()).thenReturn(200); + when(responseMock304.statusCode()).thenReturn(304); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(responseMock200, times(1)).statusCode(); + verify(responseMock304, times(2)).statusCode(); + } + + @Test + public void testRequestIncludesBothEtagAndLastModifiedHeaders() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); + cachedETagField.setAccessible(true); + cachedETagField.set(fetcher, "test-etag"); + Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + cachedLastModifiedField.setAccessible(true); + cachedLastModifiedField.set(fetcher, "test-last-modified"); + + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock).header("If-None-Match", "test-etag"); + verify(requestBuilderMock).header("If-Modified-Since", "test-last-modified"); + } + + @SneakyThrows + @Test + public void testHttpClientSendExceptionPropagation() { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Network error")); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + assertThrows(IOException.class, () -> { + fetcher.fetchContent(httpClientMock, requestBuilderMock); + }); + } + + @Test + public void testOnlyEtagAndLastModifiedHeadersCached() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("Last-Modified", Arrays.asList("last-modified-value"), + "ETag", Arrays.asList("etag-value")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock, never()).header(eq("Some-Other-Header"), anyString()); + } + +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java similarity index 65% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java index a870ff62a..e27a37b0c 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorIntegrationTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java @@ -1,39 +1,14 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; -import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.HttpConnectorTest.delay; +import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; -import java.io.IOException; -import java.lang.reflect.Field; -import java.net.MalformedURLException; -import java.net.ProxySelector; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; /** * Integration test for the HttpConnector class, specifically testing the ability to fetch @@ -54,13 +29,17 @@ void testGithubRawContent() { HttpConnector connector = null; try { String testUrl = "https://raw.githubusercontent.com/open-feature/java-sdk-contrib/58fe5da7d4e2f6f4ae2c1caf3411a01e84a1dc1a/providers/flagd/version.txt"; - connector = HttpConnector.builder() + + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .connectTimeoutSeconds(10) .requestTimeoutSeconds(10) .useHttpCache(true) .pollIntervalSeconds(5) .build(); + connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); BlockingQueue queue = connector.getStreamQueue(); delay(20000); assertEquals(1, queue.size()); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java new file mode 100644 index 000000000..8b6f87b77 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java @@ -0,0 +1,406 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.net.MalformedURLException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.Test; + +public class HttpConnectorOptionsTest { + + + @Test + public void testDefaultValuesInitialization() { + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("https://example.com") + .build(); + + assertEquals(60, options.getPollIntervalSeconds().intValue()); + assertEquals(10, options.getConnectTimeoutSeconds().intValue()); + assertEquals(10, options.getRequestTimeoutSeconds().intValue()); + assertEquals(100, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(2, options.getScheduledThreadPoolSize().intValue()); + assertNotNull(options.getHeaders()); + assertTrue(options.getHeaders().isEmpty()); + assertNotNull(options.getHttpClientExecutor()); + assertNull(options.getProxyHost()); + assertNull(options.getProxyPort()); + assertNull(options.getPayloadCacheOptions()); + assertNull(options.getPayloadCache()); + assertNull(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testInvalidUrlFormat() { + MalformedURLException exception = assertThrows( + MalformedURLException.class, + () -> HttpConnectorOptions.builder() + .url("invalid-url") + .build() + ); + + assertNotNull(exception); + } + + @Test + public void testCustomValuesInitialization() { + HttpConnectorOptions options = HttpConnectorOptions.builder() + .pollIntervalSeconds(120) + .connectTimeoutSeconds(20) + .requestTimeoutSeconds(30) + .linkedBlockingQueueCapacity(200) + .scheduledThreadPoolSize(5) + .url("http://example.com") + .build(); + + assertEquals(120, options.getPollIntervalSeconds().intValue()); + assertEquals(20, options.getConnectTimeoutSeconds().intValue()); + assertEquals(30, options.getRequestTimeoutSeconds().intValue()); + assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(5, options.getScheduledThreadPoolSize().intValue()); + assertEquals("http://example.com", options.getUrl()); + } + + @Test + public void testCustomHeadersMap() { + Map customHeaders = new HashMap<>(); + customHeaders.put("Authorization", "Bearer token"); + customHeaders.put("Content-Type", "application/json"); + + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("http://example.com") + .headers(customHeaders) + .build(); + + assertEquals("Bearer token", options.getHeaders().get("Authorization")); + assertEquals("application/json", options.getHeaders().get("Content-Type")); + } + + @Test + public void testCustomExecutorService() { + ExecutorService customExecutor = Executors.newFixedThreadPool(5); + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("https://example.com") + .httpClientExecutor(customExecutor) + .build(); + + assertEquals(customExecutor, options.getHttpClientExecutor()); + } + + @Test + public void testSettingPayloadCacheWithValidOptions() { + PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder() + .updateIntervalSeconds(1800) + .build(); + PayloadCache payloadCache = new PayloadCache() { + private String payload; + + @Override + public void put(String payload) { + this.payload = payload; + } + + @Override + public String get() { + return this.payload; + } + }; + + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("https://example.com") + .payloadCacheOptions(cacheOptions) + .payloadCache(payloadCache) + .build(); + + assertNotNull(options.getPayloadCacheOptions()); + assertNotNull(options.getPayloadCache()); + assertEquals(1800, options.getPayloadCacheOptions().getUpdateIntervalSeconds()); + } + + @Test + public void testProxyConfigurationWithValidHostAndPort() { + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("https://example.com") + .proxyHost("proxy.example.com") + .proxyPort(8080) + .build(); + + assertEquals("proxy.example.com", options.getProxyHost()); + assertEquals(8080, options.getProxyPort().intValue()); + } + + @Test + public void testLinkedBlockingQueueCapacityOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .linkedBlockingQueueCapacity(0) + .build(); + }); + assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); + + exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .linkedBlockingQueueCapacity(1001) + .build(); + }); + assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); + } + + @Test + public void testPollIntervalSecondsOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .pollIntervalSeconds(700) + .build(); + }); + assertEquals("pollIntervalSeconds must be between 1 and 600", exception.getMessage()); + } + + @Test + public void testAdditionalCustomValuesInitialization() { + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer token"); + ExecutorService executorService = Executors.newFixedThreadPool(2); + PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder().build(); + PayloadCache cache = new PayloadCache() { + @Override + public void put(String payload) { + // do nothing + } + @Override + public String get() { return null; } + }; + + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("https://example.com") + .pollIntervalSeconds(120) + .connectTimeoutSeconds(20) + .requestTimeoutSeconds(30) + .linkedBlockingQueueCapacity(200) + .scheduledThreadPoolSize(4) + .headers(headers) + .httpClientExecutor(executorService) + .proxyHost("proxy.example.com") + .proxyPort(8080) + .payloadCacheOptions(cacheOptions) + .payloadCache(cache) + .useHttpCache(true) + .build(); + + assertEquals(120, options.getPollIntervalSeconds().intValue()); + assertEquals(20, options.getConnectTimeoutSeconds().intValue()); + assertEquals(30, options.getRequestTimeoutSeconds().intValue()); + assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(4, options.getScheduledThreadPoolSize().intValue()); + assertNotNull(options.getHeaders()); + assertEquals("Bearer token", options.getHeaders().get("Authorization")); + assertNotNull(options.getHttpClientExecutor()); + assertEquals("proxy.example.com", options.getProxyHost()); + assertEquals(8080, options.getProxyPort().intValue()); + assertNotNull(options.getPayloadCacheOptions()); + assertNotNull(options.getPayloadCache()); + assertTrue(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testRequestTimeoutSecondsOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .requestTimeoutSeconds(61) + .build(); + }); + assertEquals("requestTimeoutSeconds must be between 1 and 60", exception.getMessage()); + } + + @Test + public void testBuilderInitializesAllFields() { + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer token"); + ExecutorService executorService = Executors.newFixedThreadPool(2); + PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder().build(); + PayloadCache cache = new PayloadCache() { + @Override + public void put(String payload) { + // do nothing + } + @Override + public String get() { return null; } + }; + + HttpConnectorOptions options = HttpConnectorOptions.builder() + .pollIntervalSeconds(120) + .connectTimeoutSeconds(20) + .requestTimeoutSeconds(30) + .linkedBlockingQueueCapacity(200) + .scheduledThreadPoolSize(4) + .headers(headers) + .httpClientExecutor(executorService) + .proxyHost("proxy.example.com") + .proxyPort(8080) + .payloadCacheOptions(cacheOptions) + .payloadCache(cache) + .useHttpCache(true) + .url("https://example.com") + .build(); + + assertEquals(120, options.getPollIntervalSeconds().intValue()); + assertEquals(20, options.getConnectTimeoutSeconds().intValue()); + assertEquals(30, options.getRequestTimeoutSeconds().intValue()); + assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(4, options.getScheduledThreadPoolSize().intValue()); + assertEquals(headers, options.getHeaders()); + assertEquals(executorService, options.getHttpClientExecutor()); + assertEquals("proxy.example.com", options.getProxyHost()); + assertEquals(8080, options.getProxyPort().intValue()); + assertEquals(cacheOptions, options.getPayloadCacheOptions()); + assertEquals(cache, options.getPayloadCache()); + assertTrue(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testScheduledThreadPoolSizeOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .scheduledThreadPoolSize(11) + .build(); + }); + assertEquals("scheduledThreadPoolSize must be between 1 and 10", exception.getMessage()); + } + + @Test + public void testProxyPortOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .proxyHost("proxy.example.com") + .proxyPort(70000) // Invalid port, out of range + .build(); + }); + assertEquals("proxyPort must be between 1 and 65535", exception.getMessage()); + } + + @Test + public void testConnectTimeoutSecondsOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .connectTimeoutSeconds(0) + .build(); + }); + assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); + + exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .connectTimeoutSeconds(61) + .build(); + }); + assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); + } + + @Test + public void testProxyPortWithoutProxyHost() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .proxyPort(8080) + .build(); + }); + assertEquals("proxyHost must be set if proxyPort is set", exception.getMessage()); + } + + @Test + public void testDefaultValuesWhenNullParametersProvided() { + HttpConnectorOptions options = HttpConnectorOptions.builder() + .url("https://example.com") + .pollIntervalSeconds(null) + .linkedBlockingQueueCapacity(null) + .scheduledThreadPoolSize(null) + .requestTimeoutSeconds(null) + .connectTimeoutSeconds(null) + .headers(null) + .httpClientExecutor(null) + .proxyHost(null) + .proxyPort(null) + .payloadCacheOptions(null) + .payloadCache(null) + .useHttpCache(null) + .build(); + + assertEquals(60, options.getPollIntervalSeconds().intValue()); + assertEquals(10, options.getConnectTimeoutSeconds().intValue()); + assertEquals(10, options.getRequestTimeoutSeconds().intValue()); + assertEquals(100, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(2, options.getScheduledThreadPoolSize().intValue()); + assertNotNull(options.getHeaders()); + assertTrue(options.getHeaders().isEmpty()); + assertNotNull(options.getHttpClientExecutor()); + assertNull(options.getProxyHost()); + assertNull(options.getProxyPort()); + assertNull(options.getPayloadCacheOptions()); + assertNull(options.getPayloadCache()); + assertNull(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testProxyHostWithoutProxyPort() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .proxyHost("proxy.example.com") + .build(); + }); + assertEquals("proxyPort must be set if proxyHost is set", exception.getMessage()); + } + + @Test + public void testSettingPayloadCacheWithoutOptions() { + PayloadCache mockPayloadCache = new PayloadCache() { + @Override + public void put(String payload) { + // Mock implementation + } + + @Override + public String get() { + return "mockPayload"; + } + }; + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .payloadCache(mockPayloadCache) + .build(); + }); + + assertEquals("payloadCacheOptions must be set if payloadCache is set", exception.getMessage()); + } + + @Test + public void testPayloadCacheOptionsWithoutPayloadCache() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .build(); + }); + assertEquals("payloadCache must be set if payloadCacheOptions is set", exception.getMessage()); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java similarity index 57% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java rename to providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java index 910fa3480..a62bc8ecd 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/HttpConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java @@ -1,9 +1,8 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync; +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; @@ -15,14 +14,11 @@ import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; import java.io.IOException; import java.lang.reflect.Field; -import java.net.MalformedURLException; -import java.net.ProxySelector; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.HashMap; import java.util.Map; -import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -36,76 +32,6 @@ @Slf4j class HttpConnectorTest { - @SneakyThrows - @Test - void testConstructorInitializesDefaultValues() { - String testUrl = "http://example.com"; - HttpConnector connector = HttpConnector.builder() - .url(testUrl) - .build(); - - Field pollIntervalField = HttpConnector.class.getDeclaredField("pollIntervalSeconds"); - pollIntervalField.setAccessible(true); - assertEquals(60, pollIntervalField.get(connector)); - - Field requestTimeoutField = HttpConnector.class.getDeclaredField("requestTimeoutSeconds"); - requestTimeoutField.setAccessible(true); - assertEquals(10, requestTimeoutField.get(connector)); - - Field queueField = HttpConnector.class.getDeclaredField("queue"); - queueField.setAccessible(true); - BlockingQueue queue = (BlockingQueue) queueField.get(connector); - assertEquals(100, queue.remainingCapacity() + queue.size()); - - Field headersField = HttpConnector.class.getDeclaredField("headers"); - headersField.setAccessible(true); - Map headers = (Map) headersField.get(connector); - assertNotNull(headers); - assertTrue(headers.isEmpty()); - } - - @SneakyThrows - @Test - void testConstructorValidationRejectsInvalidParameters() { - String testUrl = "http://example.com"; - - HttpConnector.HttpConnectorBuilder builder3 = HttpConnector.builder() - .url(testUrl) - .pollIntervalSeconds(0); - IllegalArgumentException pollIntervalException = assertThrows( - IllegalArgumentException.class, - builder3::build - ); - assertEquals("pollIntervalSeconds must be between 1 and 600", pollIntervalException.getMessage()); - - HttpConnector.HttpConnectorBuilder builder2 = HttpConnector.builder() - .url(testUrl) - .linkedBlockingQueueCapacity(1001); - IllegalArgumentException queueCapacityException = assertThrows( - IllegalArgumentException.class, - builder2::build - ); - assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", queueCapacityException.getMessage()); - - HttpConnector.HttpConnectorBuilder builder1 = HttpConnector.builder() - .url(testUrl) - .scheduledThreadPoolSize(11); - IllegalArgumentException threadPoolException = assertThrows( - IllegalArgumentException.class, - builder1::build - ); - assertEquals("scheduledThreadPoolSize must be between 1 and 10", threadPoolException.getMessage()); - - HttpConnector.HttpConnectorBuilder builder = HttpConnector.builder() - .url(testUrl) - .proxyHost("localhost"); - IllegalArgumentException proxyException = assertThrows( - IllegalArgumentException.class, - builder::build - ); - assertEquals("proxyPort must be set if proxyHost is set", proxyException.getMessage()); - } - @SneakyThrows @Test void testGetStreamQueueInitialAndScheduledPolls() { @@ -117,10 +43,13 @@ void testGetStreamQueueInitialAndScheduledPolls() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .httpClientExecutor(Executors.newSingleThreadExecutor()) .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); @@ -148,10 +77,30 @@ void testBuildPollTaskFetchesDataAndAddsToQueue() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - HttpConnector connector = HttpConnector.builder() + PayloadCache payloadCache = new PayloadCache() { + private String payload; + @Override + public void put(String payload) { + this.payload = payload; + } + + @Override + public String get() { + return payload; + } + }; + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newFixedThreadPool(1)) + .proxyHost("proxy-host") + .proxyPort(8080) + .useHttpCache(true) + .payloadCache(payloadCache) + .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); + connector.init(); Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); @@ -178,10 +127,13 @@ void testHttpRequestIncludesHeaders() { testHeaders.put("Authorization", "Bearer token"); testHeaders.put("Content-Type", "application/json"); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .headers(testHeaders) .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); Field headersField = HttpConnector.class.getDeclaredField("headers"); headersField.setAccessible(true); @@ -192,57 +144,6 @@ void testHttpRequestIncludesHeaders() { assertEquals("application/json", headers.get("Content-Type")); } - @SneakyThrows - @Test - void testConstructorInitializesWithProvidedValues() { - Integer pollIntervalSeconds = 120; - Integer linkedBlockingQueueCapacity = 200; - Integer scheduledThreadPoolSize = 2; - Integer requestTimeoutSeconds = 20; - Integer connectTimeoutSeconds = 15; - String url = "http://example.com"; - Map headers = new HashMap<>(); - headers.put("Authorization", "Bearer token"); - ExecutorService httpClientExecutor = Executors.newFixedThreadPool(2); - String proxyHost = "proxy.example.com"; - Integer proxyPort = 8080; - - HttpConnector connector = HttpConnector.builder() - .pollIntervalSeconds(pollIntervalSeconds) - .linkedBlockingQueueCapacity(linkedBlockingQueueCapacity) - .scheduledThreadPoolSize(scheduledThreadPoolSize) - .requestTimeoutSeconds(requestTimeoutSeconds) - .connectTimeoutSeconds(connectTimeoutSeconds) - .url(url) - .headers(headers) - .httpClientExecutor(httpClientExecutor) - .proxyHost(proxyHost) - .proxyPort(proxyPort) - .build(); - - Field pollIntervalField = HttpConnector.class.getDeclaredField("pollIntervalSeconds"); - pollIntervalField.setAccessible(true); - assertEquals(pollIntervalSeconds, pollIntervalField.get(connector)); - - Field requestTimeoutField = HttpConnector.class.getDeclaredField("requestTimeoutSeconds"); - requestTimeoutField.setAccessible(true); - assertEquals(requestTimeoutSeconds, requestTimeoutField.get(connector)); - - Field queueField = HttpConnector.class.getDeclaredField("queue"); - queueField.setAccessible(true); - BlockingQueue queue = (BlockingQueue) queueField.get(connector); - assertEquals(linkedBlockingQueueCapacity, queue.remainingCapacity() + queue.size()); - - Field headersField = HttpConnector.class.getDeclaredField("headers"); - headersField.setAccessible(true); - Map actualHeaders = (Map) headersField.get(connector); - assertEquals(headers, actualHeaders); - - Field urlField = HttpConnector.class.getDeclaredField("url"); - urlField.setAccessible(true); - assertEquals(url, urlField.get(connector)); - } - @SneakyThrows @Test void testSuccessfulHttpResponseAddsDataToQueue() { @@ -254,9 +155,11 @@ void testSuccessfulHttpResponseAddsDataToQueue() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); @@ -295,11 +198,13 @@ public String get() { } }; - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) - .payloadCache(payloadCache) - .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .payloadCache(payloadCache) + .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); @@ -320,10 +225,13 @@ public String get() { void testQueueBecomesFull() { String testUrl = "http://example.com"; int queueCapacity = 1; + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + .url(testUrl) + .linkedBlockingQueueCapacity(queueCapacity) + .build(); HttpConnector connector = HttpConnector.builder() - .url(testUrl) - .linkedBlockingQueueCapacity(queueCapacity) - .build(); + .httpConnectorOptions(httpConnectorOptions) + .build(); BlockingQueue queue = connector.getStreamQueue(); @@ -341,10 +249,13 @@ void testShutdownProperlyTerminatesSchedulerAndHttpClientExecutor() throws Inter ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class); String testUrl = "http://example.com"; - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .httpClientExecutor(mockHttpClientExecutor) .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); Field schedulerField = HttpConnector.class.getDeclaredField("scheduler"); schedulerField.setAccessible(true); @@ -366,9 +277,11 @@ void testHttpResponseNonSuccessStatusCode() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); @@ -380,45 +293,16 @@ void testHttpResponseNonSuccessStatusCode() { assertTrue(queue.isEmpty(), "Queue should be empty when response status is non-200"); } - @SneakyThrows - @Test - void test_constructor_handles_proxy_configuration() { - String testUrl = "http://example.com"; - String proxyHost = "proxy.example.com"; - int proxyPort = 8080; - - HttpConnector connectorWithProxy = HttpConnector.builder() - .url(testUrl) - .proxyHost(proxyHost) - .proxyPort(proxyPort) - .build(); - - HttpConnector connectorWithoutProxy = HttpConnector.builder() - .url(testUrl) - .build(); - - Field clientFieldWithProxy = HttpConnector.class.getDeclaredField("client"); - clientFieldWithProxy.setAccessible(true); - HttpClient clientWithProxy = (HttpClient) clientFieldWithProxy.get(connectorWithProxy); - assertNotNull(clientWithProxy); - - Field clientFieldWithoutProxy = HttpConnector.class.getDeclaredField("client"); - clientFieldWithoutProxy.setAccessible(true); - HttpClient clientWithoutProxy = (HttpClient) clientFieldWithoutProxy.get(connectorWithoutProxy); - assertNotNull(clientWithoutProxy); - - Optional proxySelectorWithProxy = clientWithProxy.proxy(); - assertNotNull(proxySelectorWithProxy.get()); - } - @SneakyThrows @Test void testHttpRequestFailsWithException() { String testUrl = "http://example.com"; HttpClient mockClient = mock(HttpClient.class); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); @@ -438,9 +322,11 @@ void testHttpRequestFailsWithException() { void testHttpRequestFailsWithIoexception() { String testUrl = "http://example.com"; HttpClient mockClient = mock(HttpClient.class); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); @@ -458,35 +344,6 @@ void testHttpRequestFailsWithIoexception() { assertTrue(queue.isEmpty(), "Queue should be empty due to IOException"); } - @SneakyThrows - @Test - void testMalformedUrlThrowsException() { - String malformedUrl = "htp://invalid-url"; - - assertThrows(MalformedURLException.class, () -> { - HttpConnector.builder() - .url(malformedUrl) - .build(); - }); - } - - @SneakyThrows - @Test - void testHeadersInitializationWhenNull() { - String testUrl = "http://example.com"; - - HttpConnector connector = HttpConnector.builder() - .url(testUrl) - .headers(null) - .build(); - - Field headersField = HttpConnector.class.getDeclaredField("headers"); - headersField.setAccessible(true); - Map headers = (Map) headersField.get(connector); - assertNotNull(headers); - assertTrue(headers.isEmpty()); - } - @SneakyThrows @Test void testScheduledPollingContinuesAtFixedIntervals() { @@ -495,9 +352,11 @@ void testScheduledPollingContinuesAtFixedIntervals() { when(mockResponse.statusCode()).thenReturn(200); when(mockResponse.body()).thenReturn("test data"); - HttpConnector connector = spy(HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + HttpConnector connector = spy(HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build()); doReturn(mockResponse).when(connector).execute(any()); @@ -514,56 +373,23 @@ void testScheduledPollingContinuesAtFixedIntervals() { connector.shutdown(); } - @SneakyThrows - @Test - void testDefaultValuesWhenOptionalParametersAreNull() { - String testUrl = "http://example.com"; - - HttpConnector connector = HttpConnector.builder() - .url(testUrl) - .build(); - - Field pollIntervalField = HttpConnector.class.getDeclaredField("pollIntervalSeconds"); - pollIntervalField.setAccessible(true); - assertEquals(60, pollIntervalField.get(connector)); - - Field requestTimeoutField = HttpConnector.class.getDeclaredField("requestTimeoutSeconds"); - requestTimeoutField.setAccessible(true); - assertEquals(10, requestTimeoutField.get(connector)); - - Field queueField = HttpConnector.class.getDeclaredField("queue"); - queueField.setAccessible(true); - BlockingQueue queue = (BlockingQueue) queueField.get(connector); - assertEquals(100, queue.remainingCapacity() + queue.size()); - - Field headersField = HttpConnector.class.getDeclaredField("headers"); - headersField.setAccessible(true); - Map headers = (Map) headersField.get(connector); - assertNotNull(headers); - assertTrue(headers.isEmpty()); - - Field httpClientExecutorField = HttpConnector.class.getDeclaredField("httpClientExecutor"); - httpClientExecutorField.setAccessible(true); - ExecutorService httpClientExecutor = (ExecutorService) httpClientExecutorField.get(connector); - assertNotNull(httpClientExecutor); - } - @SneakyThrows @Test void testQueuePayloadTypeSetToDataOnSuccess() { String testUrl = "http://example.com"; HttpClient mockClient = mock(HttpClient.class); HttpResponse mockResponse = mock(HttpResponse.class); - ExecutorService mockExecutor = Executors.newFixedThreadPool(1); when(mockResponse.statusCode()).thenReturn(200); when(mockResponse.body()).thenReturn("response body"); when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - HttpConnector connector = HttpConnector.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) - .httpClientExecutor(mockExecutor) + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) .build(); Field clientField = HttpConnector.class.getDeclaredField("client"); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java new file mode 100644 index 000000000..65f92e0ae --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java @@ -0,0 +1,267 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; + +import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + + +public class PayloadCacheWrapperTest { + + @Test + public void testConstructorInitializesWithValidParameters() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + assertNotNull(wrapper); + + String testPayload = "test-payload"; + wrapper.updatePayloadIfNeeded(testPayload); + wrapper.get(); + + verify(mockCache).put(testPayload); + verify(mockCache).get(); + } + + @Test + public void testConstructorThrowsExceptionForInvalidInterval() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(0) + .build(); + + PayloadCacheWrapper.PayloadCacheWrapperBuilder payloadCacheWrapperBuilder = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options); + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + payloadCacheWrapperBuilder::build + ); + + assertEquals("pollIntervalSeconds must be larger than 0", exception.getMessage()); + } + + @Test + public void testUpdateSkipsWhenIntervalNotPassed() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String initialPayload = "initial-payload"; + wrapper.updatePayloadIfNeeded(initialPayload); + + String newPayload = "new-payload"; + wrapper.updatePayloadIfNeeded(newPayload); + + verify(mockCache, times(1)).put(initialPayload); + verify(mockCache, never()).put(newPayload); + } + + @Test + public void testUpdatePayloadIfNeededHandlesPutException() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + String testPayload = "test-payload"; + + doThrow(new RuntimeException("put exception")).when(mockCache).put(testPayload); + + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache).put(testPayload); + } + + @Test + public void testUpdatePayloadIfNeededUpdatesCacheAfterInterval() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(1) // 1 second interval for quick test + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String initialPayload = "initial-payload"; + String newPayload = "new-payload"; + + wrapper.updatePayloadIfNeeded(initialPayload); + delay(1100); + wrapper.updatePayloadIfNeeded(newPayload); + + verify(mockCache).put(initialPayload); + verify(mockCache).put(newPayload); + } + + @Test + public void testGetReturnsNullWhenCacheGetThrowsException() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + when(mockCache.get()).thenThrow(new RuntimeException("Cache get failed")); + + String result = wrapper.get(); + + assertNull(result); + + verify(mockCache).get(); + } + + @Test + public void test_get_returns_cached_payload() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + String expectedPayload = "cached-payload"; + when(mockCache.get()).thenReturn(expectedPayload); + + String actualPayload = wrapper.get(); + + assertEquals(expectedPayload, actualPayload); + + verify(mockCache).get(); + } + + @Test + public void test_first_call_updates_cache() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String testPayload = "initial-payload"; + + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache).put(testPayload); + } + + @Test + public void test_update_payload_once_within_interval() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(1) // 1 second interval + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String testPayload = "test-payload"; + + wrapper.updatePayloadIfNeeded(testPayload); + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache, times(1)).put(testPayload); + } + + @SneakyThrows + @Test + public void test_last_update_time_ms_updated_after_successful_cache_update() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + String testPayload = "test-payload"; + + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache).put(testPayload); + + Field lastUpdateTimeMsField = PayloadCacheWrapper.class.getDeclaredField("lastUpdateTimeMs"); + lastUpdateTimeMsField.setAccessible(true); + long lastUpdateTimeMs = (Long) lastUpdateTimeMsField.get(wrapper); + + assertTrue(System.currentTimeMillis() - lastUpdateTimeMs < 1000, + "lastUpdateTimeMs should be updated to current time"); + } + + @Test + public void test_update_payload_if_needed_respects_update_interval() { + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + PayloadCacheWrapper wrapper = spy(PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build()); + + String testPayload = "test-payload"; + long initialTime = System.currentTimeMillis(); + long updateIntervalMs = options.getUpdateIntervalSeconds() * 1000L; + + doReturn(initialTime).when(wrapper).getCurrentTimeMillis(); + + // First update should succeed + wrapper.updatePayloadIfNeeded(testPayload); + + // Verify the payload was updated + verify(mockCache).put(testPayload); + + // Attempt to update before interval has passed + doReturn(initialTime + updateIntervalMs - 1).when(wrapper).getCurrentTimeMillis(); + wrapper.updatePayloadIfNeeded(testPayload); + + // Verify the payload was not updated again + verify(mockCache, times(1)).put(testPayload); + + // Update after interval has passed + doReturn(initialTime + updateIntervalMs + 1).when(wrapper).getCurrentTimeMillis(); + wrapper.updatePayloadIfNeeded(testPayload); + + // Verify the payload was updated again + verify(mockCache, times(2)).put(testPayload); + } + +} From d201b1fb262e22eb805fce4ce5074d844a4970fa Mon Sep 17 00:00:00 2001 From: liran2000 Date: Mon, 31 Mar 2025 13:23:43 +0300 Subject: [PATCH 05/18] readme update Signed-off-by: liran2000 --- providers/flagd/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/flagd/README.md b/providers/flagd/README.md index d26ef6433..20f6c8fde 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -56,8 +56,8 @@ If the `in-process` mode is not used, and before the provider is ready, the `get #### Http Connector HttpConnector is responsible for polling data from a specified URL at regular intervals. -It is leveraging Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, reducing traffic and -changes updates. Can be enabled via useHttpCache option. +It is leveraging Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, +reducing traffic, reducing rate limits effects and changes updates. Can be enabled via useHttpCache option. One of its benefits is to reduce infrastructure/devops work, without additional containers needed. The implementation is using Java HttpClient. From 2091826a506ecf4280c2dae6bba1e61696b05fe8 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Thu, 24 Apr 2025 15:54:11 +0300 Subject: [PATCH 06/18] move to tool - draft Signed-off-by: liran2000 --- providers/flagd/README.md | 5 +- .../sync/http/util/ConcurrentUtils.java | 61 +++ .../sync/http/HttpCacheFetcherTest.java | 309 +++++++++++++ .../http/HttpConnectorIntegrationTest.java | 59 +++ .../sync/http/HttpConnectorOptionsTest.java | 409 +++++++++++++++++ .../sync/http/HttpConnectorTest.java | 416 ++++++++++++++++++ .../sync/http/PayloadCacheWrapperTest.java | 270 ++++++++++++ .../test/resources/simplelogger.properties | 3 + 8 files changed, 1531 insertions(+), 1 deletion(-) create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java create mode 100644 tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java create mode 100644 tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java create mode 100644 tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java create mode 100644 tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java create mode 100644 tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java create mode 100644 tools/flagd-http-connector/src/test/resources/simplelogger.properties diff --git a/providers/flagd/README.md b/providers/flagd/README.md index 20f6c8fde..f848ca5f6 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -58,9 +58,12 @@ If the `in-process` mode is not used, and before the provider is ready, the `get HttpConnector is responsible for polling data from a specified URL at regular intervals. It is leveraging Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, reducing traffic, reducing rate limits effects and changes updates. Can be enabled via useHttpCache option. -One of its benefits is to reduce infrastructure/devops work, without additional containers needed. The implementation is using Java HttpClient. +##### Use cases and benefits +* Reduce infrastructure/devops work, without additional containers needed. +* Use as an additional provider for fallback / internal backup service via multi-provider. + ##### What happens if the Http source is down when application is starting ? It supports optional fail-safe initialization via cache, such that on initial fetch error following by diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java new file mode 100644 index 000000000..e7ccbc3b9 --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java @@ -0,0 +1,61 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Concurrent / Concurrency utilities. + * + * @author Liran Mendelovich + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Slf4j +public class ConcurrentUtils { + + /** + * Graceful shutdown a thread pool.
+ * See + * https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html + * + * @param pool thread pool + * @param timeoutSeconds grace period timeout in seconds - timeout can be twice than this value, + * as first it waits for existing tasks to terminate, then waits for cancelled tasks to + * terminate. + */ + public static void shutdownAndAwaitTermination(ExecutorService pool, int timeoutSeconds) { + if (pool == null) { + return; + } + + // Disable new tasks from being submitted + pool.shutdown(); + try { + + // Wait a while for existing tasks to terminate + if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + + // Cancel currently executing tasks - best effort, based on interrupt handling + // implementation. + pool.shutdownNow(); + + // Wait a while for tasks to respond to being cancelled + if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { + log.error("Thread pool did not shutdown all tasks after the timeout: {} seconds.", timeoutSeconds); + } + } + } catch (InterruptedException e) { + + log.info("Current thread interrupted during shutdownAndAwaitTermination, calling shutdownNow."); + + // (Re-)Cancel if current thread also interrupted + pool.shutdownNow(); + + // Preserve interrupt status + Thread.currentThread().interrupt(); + } + } +} diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java new file mode 100644 index 000000000..37e353917 --- /dev/null +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java @@ -0,0 +1,309 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +public class HttpCacheFetcherTest { + + @Test + public void testFirstRequestSendsNoCacheHeaders() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); + verify(requestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); + } + + @Test + public void testResponseWith200ButNoCacheHeaders() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = mock(HttpResponse.class); + HttpHeaders headersMock = mock(HttpHeaders.class); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + when(responseMock.headers()).thenReturn(headersMock); + when(headersMock.firstValue("ETag")).thenReturn(Optional.empty()); + when(headersMock.firstValue("Last-Modified")).thenReturn(Optional.empty()); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); + + assertEquals(200, response.statusCode()); + + HttpRequest.Builder secondRequestBuilderMock = mock(HttpRequest.Builder.class); + when(secondRequestBuilderMock.build()).thenReturn(requestMock); + + fetcher.fetchContent(httpClientMock, secondRequestBuilderMock); + + verify(secondRequestBuilderMock, never()).header(eq("If-None-Match"), anyString()); + verify(secondRequestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); + } + + @Test + public void testFetchContentReturnsHttpResponse() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(404); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpResponse result = fetcher.fetchContent(httpClientMock, requestBuilderMock); + + assertEquals(responseMock, result); + } + + @Test + public void test200ResponseNoEtagOrLastModified() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + Field cachedETagField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedETag"); + cachedETagField.setAccessible(true); + assertNull(cachedETagField.get(fetcher)); + Field cachedLastModifiedField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + cachedLastModifiedField.setAccessible(true); + assertNull(cachedLastModifiedField.get(fetcher)); + } + + @Test + public void testUpdateCacheOn200Response() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("Last-Modified", Arrays.asList("Wed, 21 Oct 2015 07:28:00 GMT"), + "ETag", Arrays.asList("etag-value")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + Field cachedETagField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedETag"); + cachedETagField.setAccessible(true); + assertEquals("etag-value", cachedETagField.get(fetcher)); + Field cachedLastModifiedField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + cachedLastModifiedField.setAccessible(true); + assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", cachedLastModifiedField.get(fetcher)); + } + + @Test + public void testRequestWithCachedEtagIncludesIfNoneMatchHeader() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("ETag", Arrays.asList("12345")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock, times(1)).header("If-None-Match", "12345"); + } + + @Test + public void testNullHttpClientOrRequestBuilder() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + + assertThrows(NullPointerException.class, () -> { + fetcher.fetchContent(null, requestBuilderMock); + }); + + assertThrows(NullPointerException.class, () -> { + fetcher.fetchContent(mock(HttpClient.class), null); + }); + } + + @Test + public void testResponseWithUnexpectedStatusCode() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(500); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); + + assertEquals(500, response.statusCode()); + verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); + verify(requestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); + } + + @Test + public void testRequestIncludesIfModifiedSinceHeaderWhenLastModifiedCached() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("Last-Modified", Arrays.asList("Wed, 21 Oct 2015 07:28:00 GMT")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock).header(eq("If-Modified-Since"), eq("Wed, 21 Oct 2015 07:28:00 GMT")); + } + + @Test + public void testCalls200And304Responses() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock200 = mock(HttpResponse.class); + HttpResponse responseMock304 = mock(HttpResponse.class); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) + .thenReturn(responseMock200) + .thenReturn(responseMock304); + when(responseMock200.statusCode()).thenReturn(200); + when(responseMock304.statusCode()).thenReturn(304); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(responseMock200, times(1)).statusCode(); + verify(responseMock304, times(2)).statusCode(); + } + + @Test + public void testRequestIncludesBothEtagAndLastModifiedHeaders() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + Field cachedETagField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedETag"); + cachedETagField.setAccessible(true); + cachedETagField.set(fetcher, "test-etag"); + Field cachedLastModifiedField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + cachedLastModifiedField.setAccessible(true); + cachedLastModifiedField.set(fetcher, "test-last-modified"); + + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock).header("If-None-Match", "test-etag"); + verify(requestBuilderMock).header("If-Modified-Since", "test-last-modified"); + } + + @SneakyThrows + @Test + public void testHttpClientSendExceptionPropagation() { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Network error")); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + assertThrows(IOException.class, () -> { + fetcher.fetchContent(httpClientMock, requestBuilderMock); + }); + } + + @Test + public void testOnlyEtagAndLastModifiedHeadersCached() throws Exception { + HttpClient httpClientMock = mock(HttpClient.class); + HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); + HttpRequest requestMock = mock(HttpRequest.class); + HttpResponse responseMock = spy(HttpResponse.class); + doReturn(HttpHeaders.of( + Map.of("Last-Modified", Arrays.asList("last-modified-value"), + "ETag", Arrays.asList("etag-value")), + (a, b) -> true)).when(responseMock).headers(); + + when(requestBuilderMock.build()).thenReturn(requestMock); + when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); + when(responseMock.statusCode()).thenReturn(200); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new HttpCacheFetcher(); + fetcher.fetchContent(httpClientMock, requestBuilderMock); + + verify(requestBuilderMock, never()).header(eq("Some-Other-Header"), anyString()); + } + +} diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java new file mode 100644 index 000000000..75b69721a --- /dev/null +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java @@ -0,0 +1,59 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions; +import java.util.concurrent.BlockingQueue; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; + +/** + * Integration test for the HttpConnector class, specifically testing the ability to fetch + * raw content from a GitHub URL. This test assumes that integration tests are enabled + * and verifies that the HttpConnector can successfully enqueue data from the specified URL. + * The test initializes the HttpConnector with specific configurations, waits for data + * to be enqueued, and asserts the expected queue size. The connector is shut down + * gracefully after the test execution. + * As this integration test using external request, it is disabled by default, and not part of the CI build. + */ +@Slf4j +class HttpConnectorIntegrationTest { + + @SneakyThrows + @Test + void testGithubRawContent() { + assumeTrue(parseBoolean("integrationTestsEnabled")); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = null; + try { + String testUrl = "https://raw.githubusercontent.com/open-feature/java-sdk-contrib/58fe5da7d4e2f6f4ae2c1caf3411a01e84a1dc1a/providers/flagd/version.txt"; + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + .url(testUrl) + .connectTimeoutSeconds(10) + .requestTimeoutSeconds(10) + .useHttpCache(true) + .pollIntervalSeconds(5) + .build(); + connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + BlockingQueue queue = connector.getStreamQueue(); + delay(20000); + assertEquals(1, queue.size()); + } finally { + if (connector != null) { + connector.shutdown(); + } + } + } + + public static boolean parseBoolean(String key) { + return Boolean.parseBoolean(System.getProperty(key, System.getenv(key))); + } + +} diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java new file mode 100644 index 000000000..a90bbae38 --- /dev/null +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java @@ -0,0 +1,409 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions; +import java.net.MalformedURLException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.Test; + +public class HttpConnectorOptionsTest { + + + @Test + public void testDefaultValuesInitialization() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .build(); + + assertEquals(60, options.getPollIntervalSeconds().intValue()); + assertEquals(10, options.getConnectTimeoutSeconds().intValue()); + assertEquals(10, options.getRequestTimeoutSeconds().intValue()); + assertEquals(100, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(2, options.getScheduledThreadPoolSize().intValue()); + assertNotNull(options.getHeaders()); + assertTrue(options.getHeaders().isEmpty()); + assertNotNull(options.getHttpClientExecutor()); + assertNull(options.getProxyHost()); + assertNull(options.getProxyPort()); + assertNull(options.getPayloadCacheOptions()); + assertNull(options.getPayloadCache()); + assertNull(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testInvalidUrlFormat() { + MalformedURLException exception = assertThrows( + MalformedURLException.class, + () -> dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("invalid-url") + .build() + ); + + assertNotNull(exception); + } + + @Test + public void testCustomValuesInitialization() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .pollIntervalSeconds(120) + .connectTimeoutSeconds(20) + .requestTimeoutSeconds(30) + .linkedBlockingQueueCapacity(200) + .scheduledThreadPoolSize(5) + .url("http://example.com") + .build(); + + assertEquals(120, options.getPollIntervalSeconds().intValue()); + assertEquals(20, options.getConnectTimeoutSeconds().intValue()); + assertEquals(30, options.getRequestTimeoutSeconds().intValue()); + assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(5, options.getScheduledThreadPoolSize().intValue()); + assertEquals("http://example.com", options.getUrl()); + } + + @Test + public void testCustomHeadersMap() { + Map customHeaders = new HashMap<>(); + customHeaders.put("Authorization", "Bearer token"); + customHeaders.put("Content-Type", "application/json"); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("http://example.com") + .headers(customHeaders) + .build(); + + assertEquals("Bearer token", options.getHeaders().get("Authorization")); + assertEquals("application/json", options.getHeaders().get("Content-Type")); + } + + @Test + public void testCustomExecutorService() { + ExecutorService customExecutor = Executors.newFixedThreadPool(5); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .httpClientExecutor(customExecutor) + .build(); + + assertEquals(customExecutor, options.getHttpClientExecutor()); + } + + @Test + public void testSettingPayloadCacheWithValidOptions() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions cacheOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(1800) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache payloadCache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + private String payload; + + @Override + public void put(String payload) { + this.payload = payload; + } + + @Override + public String get() { + return this.payload; + } + }; + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .payloadCacheOptions(cacheOptions) + .payloadCache(payloadCache) + .build(); + + assertNotNull(options.getPayloadCacheOptions()); + assertNotNull(options.getPayloadCache()); + assertEquals(1800, options.getPayloadCacheOptions().getUpdateIntervalSeconds()); + } + + @Test + public void testProxyConfigurationWithValidHostAndPort() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .proxyHost("proxy.example.com") + .proxyPort(8080) + .build(); + + assertEquals("proxy.example.com", options.getProxyHost()); + assertEquals(8080, options.getProxyPort().intValue()); + } + + @Test + public void testLinkedBlockingQueueCapacityOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .linkedBlockingQueueCapacity(0) + .build(); + }); + assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); + + exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .linkedBlockingQueueCapacity(1001) + .build(); + }); + assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); + } + + @Test + public void testPollIntervalSecondsOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .pollIntervalSeconds(700) + .build(); + }); + assertEquals("pollIntervalSeconds must be between 1 and 600", exception.getMessage()); + } + + @Test + public void testAdditionalCustomValuesInitialization() { + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer token"); + ExecutorService executorService = Executors.newFixedThreadPool(2); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions cacheOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder().build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache cache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + @Override + public void put(String payload) { + // do nothing + } + @Override + public String get() { return null; } + }; + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .pollIntervalSeconds(120) + .connectTimeoutSeconds(20) + .requestTimeoutSeconds(30) + .linkedBlockingQueueCapacity(200) + .scheduledThreadPoolSize(4) + .headers(headers) + .httpClientExecutor(executorService) + .proxyHost("proxy.example.com") + .proxyPort(8080) + .payloadCacheOptions(cacheOptions) + .payloadCache(cache) + .useHttpCache(true) + .build(); + + assertEquals(120, options.getPollIntervalSeconds().intValue()); + assertEquals(20, options.getConnectTimeoutSeconds().intValue()); + assertEquals(30, options.getRequestTimeoutSeconds().intValue()); + assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(4, options.getScheduledThreadPoolSize().intValue()); + assertNotNull(options.getHeaders()); + assertEquals("Bearer token", options.getHeaders().get("Authorization")); + assertNotNull(options.getHttpClientExecutor()); + assertEquals("proxy.example.com", options.getProxyHost()); + assertEquals(8080, options.getProxyPort().intValue()); + assertNotNull(options.getPayloadCacheOptions()); + assertNotNull(options.getPayloadCache()); + assertTrue(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testRequestTimeoutSecondsOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .requestTimeoutSeconds(61) + .build(); + }); + assertEquals("requestTimeoutSeconds must be between 1 and 60", exception.getMessage()); + } + + @Test + public void testBuilderInitializesAllFields() { + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer token"); + ExecutorService executorService = Executors.newFixedThreadPool(2); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions cacheOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder().build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache cache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + @Override + public void put(String payload) { + // do nothing + } + @Override + public String get() { return null; } + }; + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .pollIntervalSeconds(120) + .connectTimeoutSeconds(20) + .requestTimeoutSeconds(30) + .linkedBlockingQueueCapacity(200) + .scheduledThreadPoolSize(4) + .headers(headers) + .httpClientExecutor(executorService) + .proxyHost("proxy.example.com") + .proxyPort(8080) + .payloadCacheOptions(cacheOptions) + .payloadCache(cache) + .useHttpCache(true) + .url("https://example.com") + .build(); + + assertEquals(120, options.getPollIntervalSeconds().intValue()); + assertEquals(20, options.getConnectTimeoutSeconds().intValue()); + assertEquals(30, options.getRequestTimeoutSeconds().intValue()); + assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(4, options.getScheduledThreadPoolSize().intValue()); + assertEquals(headers, options.getHeaders()); + assertEquals(executorService, options.getHttpClientExecutor()); + assertEquals("proxy.example.com", options.getProxyHost()); + assertEquals(8080, options.getProxyPort().intValue()); + assertEquals(cacheOptions, options.getPayloadCacheOptions()); + assertEquals(cache, options.getPayloadCache()); + assertTrue(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testScheduledThreadPoolSizeOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .scheduledThreadPoolSize(11) + .build(); + }); + assertEquals("scheduledThreadPoolSize must be between 1 and 10", exception.getMessage()); + } + + @Test + public void testProxyPortOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .proxyHost("proxy.example.com") + .proxyPort(70000) // Invalid port, out of range + .build(); + }); + assertEquals("proxyPort must be between 1 and 65535", exception.getMessage()); + } + + @Test + public void testConnectTimeoutSecondsOutOfRange() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .connectTimeoutSeconds(0) + .build(); + }); + assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); + + exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .connectTimeoutSeconds(61) + .build(); + }); + assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); + } + + @Test + public void testProxyPortWithoutProxyHost() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .proxyPort(8080) + .build(); + }); + assertEquals("proxyHost must be set if proxyPort is set", exception.getMessage()); + } + + @Test + public void testDefaultValuesWhenNullParametersProvided() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .pollIntervalSeconds(null) + .linkedBlockingQueueCapacity(null) + .scheduledThreadPoolSize(null) + .requestTimeoutSeconds(null) + .connectTimeoutSeconds(null) + .headers(null) + .httpClientExecutor(null) + .proxyHost(null) + .proxyPort(null) + .payloadCacheOptions(null) + .payloadCache(null) + .useHttpCache(null) + .build(); + + assertEquals(60, options.getPollIntervalSeconds().intValue()); + assertEquals(10, options.getConnectTimeoutSeconds().intValue()); + assertEquals(10, options.getRequestTimeoutSeconds().intValue()); + assertEquals(100, options.getLinkedBlockingQueueCapacity().intValue()); + assertEquals(2, options.getScheduledThreadPoolSize().intValue()); + assertNotNull(options.getHeaders()); + assertTrue(options.getHeaders().isEmpty()); + assertNotNull(options.getHttpClientExecutor()); + assertNull(options.getProxyHost()); + assertNull(options.getProxyPort()); + assertNull(options.getPayloadCacheOptions()); + assertNull(options.getPayloadCache()); + assertNull(options.getUseHttpCache()); + assertEquals("https://example.com", options.getUrl()); + } + + @Test + public void testProxyHostWithoutProxyPort() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .proxyHost("proxy.example.com") + .build(); + }); + assertEquals("proxyPort must be set if proxyHost is set", exception.getMessage()); + } + + @Test + public void testSettingPayloadCacheWithoutOptions() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockPayloadCache = new PayloadCache() { + @Override + public void put(String payload) { + // Mock implementation + } + + @Override + public String get() { + return "mockPayload"; + } + }; + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url("https://example.com") + .payloadCache(mockPayloadCache) + .build(); + }); + + assertEquals("payloadCacheOptions must be set if payloadCache is set", exception.getMessage()); + } + + @Test + public void testPayloadCacheOptionsWithoutPayloadCache() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + HttpConnectorOptions.builder() + .url("https://example.com") + .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .build(); + }); + assertEquals("payloadCache must be set if payloadCacheOptions is set", exception.getMessage()); + } +} diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java new file mode 100644 index 000000000..0edb440a8 --- /dev/null +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java @@ -0,0 +1,416 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +@Slf4j +class HttpConnectorTest { + + @SneakyThrows + @Test + void testGetStreamQueueInitialAndScheduledPolls() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .httpClientExecutor(Executors.newSingleThreadExecutor()) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + + connector.shutdown(); + } + + @SneakyThrows + @Test + void testBuildPollTaskFetchesDataAndAddsToQueue() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache payloadCache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + private String payload; + @Override + public void put(String payload) { + this.payload = payload; + } + + @Override + public String get() { + return payload; + } + }; + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .proxyHost("proxy-host") + .proxyPort(8080) + .useHttpCache(true) + .payloadCache(payloadCache) + .payloadCacheOptions(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder().build()) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + connector.init(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + Runnable pollTask = connector.buildPollTask(); + pollTask.run(); + + Field queueField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + } + + @SneakyThrows + @Test + void testHttpRequestIncludesHeaders() { + String testUrl = "http://example.com"; + Map testHeaders = new HashMap<>(); + testHeaders.put("Authorization", "Bearer token"); + testHeaders.put("Content-Type", "application/json"); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .headers(testHeaders) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field headersField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("headers"); + headersField.setAccessible(true); + Map headers = (Map) headersField.get(connector); + assertNotNull(headers); + assertEquals(2, headers.size()); + assertEquals("Bearer token", headers.get("Authorization")); + assertEquals("application/json", headers.get("Content-Type")); + } + + @SneakyThrows + @Test + void testSuccessfulHttpResponseAddsDataToQueue() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + } + + @SneakyThrows + @Test + void testInitFailureUsingCache() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Simulated IO Exception")); + + final String cachedData = "cached data"; + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache payloadCache = new PayloadCache() { + @Override + public void put(String payload) { + // do nothing + } + + @Override + public String get() { + return cachedData; + } + }; + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .payloadCache(payloadCache) + .payloadCacheOptions(PayloadCacheOptions.builder().build()) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals(cachedData, payload.getFlagData()); + } + + @SneakyThrows + @Test + void testQueueBecomesFull() { + String testUrl = "http://example.com"; + int queueCapacity = 1; + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .linkedBlockingQueueCapacity(queueCapacity) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + BlockingQueue queue = connector.getStreamQueue(); + + queue.offer(new QueuePayload(QueuePayloadType.DATA, "test data 1")); + + boolean wasOffered = queue.offer(new QueuePayload(QueuePayloadType.DATA, "test data 2")); + + assertFalse(wasOffered, "Queue should be full and not accept more items"); + } + + @SneakyThrows + @Test + void testShutdownProperlyTerminatesSchedulerAndHttpClientExecutor() throws InterruptedException { + ExecutorService mockHttpClientExecutor = mock(ExecutorService.class); + ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class); + String testUrl = "http://example.com"; + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .httpClientExecutor(mockHttpClientExecutor) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field schedulerField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("scheduler"); + schedulerField.setAccessible(true); + schedulerField.set(connector, mockScheduler); + + connector.shutdown(); + + Mockito.verify(mockScheduler).shutdown(); + Mockito.verify(mockHttpClientExecutor).shutdown(); + } + + @SneakyThrows + @Test + void testHttpResponseNonSuccessStatusCode() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(404); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + assertTrue(queue.isEmpty(), "Queue should be empty when response status is non-200"); + } + + @SneakyThrows + @Test + void testHttpRequestFailsWithException() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new RuntimeException("Test exception")); + + BlockingQueue queue = connector.getStreamQueue(); + + assertTrue(queue.isEmpty(), "Queue should be empty when request fails with exception"); + } + + @SneakyThrows + @Test + void testHttpRequestFailsWithIoexception() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenThrow(new IOException("Simulated IO Exception")); + + connector.getStreamQueue(); + + Field queueField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("queue"); + queueField.setAccessible(true); + BlockingQueue queue = (BlockingQueue) queueField.get(connector); + assertTrue(queue.isEmpty(), "Queue should be empty due to IOException"); + } + + @SneakyThrows + @Test + void testScheduledPollingContinuesAtFixedIntervals() { + String testUrl = "http://exampOle.com"; + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("test data"); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + .url(testUrl) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = spy(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build()); + + doReturn(mockResponse).when(connector).execute(any()); + + BlockingQueue queue = connector.getStreamQueue(); + + delay(2000); + assertFalse(queue.isEmpty()); + QueuePayload payload = queue.poll(); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("test data", payload.getFlagData()); + + connector.shutdown(); + } + + @SneakyThrows + @Test + void testQueuePayloadTypeSetToDataOnSuccess() { + String testUrl = "http://example.com"; + HttpClient mockClient = mock(HttpClient.class); + HttpResponse mockResponse = mock(HttpResponse.class); + + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("response body"); + when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(mockResponse); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + .url(testUrl) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + Field clientField = HttpConnector.class.getDeclaredField("client"); + clientField.setAccessible(true); + clientField.set(connector, mockClient); + + BlockingQueue queue = connector.getStreamQueue(); + + QueuePayload payload = queue.poll(1, TimeUnit.SECONDS); + assertNotNull(payload); + assertEquals(QueuePayloadType.DATA, payload.getType()); + assertEquals("response body", payload.getFlagData()); + } + + @SneakyThrows + protected static void delay(long ms) { + Thread.sleep(ms); + } + +} diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java new file mode 100644 index 000000000..28fba6881 --- /dev/null +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java @@ -0,0 +1,270 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper; +import java.lang.reflect.Field; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; + + +public class PayloadCacheWrapperTest { + + @Test + public void testConstructorInitializesWithValidParameters() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + assertNotNull(wrapper); + + String testPayload = "test-payload"; + wrapper.updatePayloadIfNeeded(testPayload); + wrapper.get(); + + verify(mockCache).put(testPayload); + verify(mockCache).get(); + } + + @Test + public void testConstructorThrowsExceptionForInvalidInterval() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(0) + .build(); + + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.PayloadCacheWrapperBuilder payloadCacheWrapperBuilder = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options); + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + payloadCacheWrapperBuilder::build + ); + + assertEquals("pollIntervalSeconds must be larger than 0", exception.getMessage()); + } + + @Test + public void testUpdateSkipsWhenIntervalNotPassed() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String initialPayload = "initial-payload"; + wrapper.updatePayloadIfNeeded(initialPayload); + + String newPayload = "new-payload"; + wrapper.updatePayloadIfNeeded(newPayload); + + verify(mockCache, times(1)).put(initialPayload); + verify(mockCache, never()).put(newPayload); + } + + @Test + public void testUpdatePayloadIfNeededHandlesPutException() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + String testPayload = "test-payload"; + + doThrow(new RuntimeException("put exception")).when(mockCache).put(testPayload); + + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache).put(testPayload); + } + + @Test + public void testUpdatePayloadIfNeededUpdatesCacheAfterInterval() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(1) // 1 second interval for quick test + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String initialPayload = "initial-payload"; + String newPayload = "new-payload"; + + wrapper.updatePayloadIfNeeded(initialPayload); + delay(1100); + wrapper.updatePayloadIfNeeded(newPayload); + + verify(mockCache).put(initialPayload); + verify(mockCache).put(newPayload); + } + + @Test + public void testGetReturnsNullWhenCacheGetThrowsException() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + when(mockCache.get()).thenThrow(new RuntimeException("Cache get failed")); + + String result = wrapper.get(); + + assertNull(result); + + verify(mockCache).get(); + } + + @Test + public void test_get_returns_cached_payload() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + String expectedPayload = "cached-payload"; + when(mockCache.get()).thenReturn(expectedPayload); + + String actualPayload = wrapper.get(); + + assertEquals(expectedPayload, actualPayload); + + verify(mockCache).get(); + } + + @Test + public void test_first_call_updates_cache() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String testPayload = "initial-payload"; + + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache).put(testPayload); + } + + @Test + public void test_update_payload_once_within_interval() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(1) // 1 second interval + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + + String testPayload = "test-payload"; + + wrapper.updatePayloadIfNeeded(testPayload); + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache, times(1)).put(testPayload); + } + + @SneakyThrows + @Test + public void test_last_update_time_ms_updated_after_successful_cache_update() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build(); + String testPayload = "test-payload"; + + wrapper.updatePayloadIfNeeded(testPayload); + + verify(mockCache).put(testPayload); + + Field lastUpdateTimeMsField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.class.getDeclaredField("lastUpdateTimeMs"); + lastUpdateTimeMsField.setAccessible(true); + long lastUpdateTimeMs = (Long) lastUpdateTimeMsField.get(wrapper); + + assertTrue(System.currentTimeMillis() - lastUpdateTimeMs < 1000, + "lastUpdateTimeMs should be updated to current time"); + } + + @Test + public void test_update_payload_if_needed_respects_update_interval() { + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(PayloadCache.class); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = PayloadCacheOptions.builder() + .updateIntervalSeconds(600) + .build(); + dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = spy(PayloadCacheWrapper.builder() + .payloadCache(mockCache) + .payloadCacheOptions(options) + .build()); + + String testPayload = "test-payload"; + long initialTime = System.currentTimeMillis(); + long updateIntervalMs = options.getUpdateIntervalSeconds() * 1000L; + + doReturn(initialTime).when(wrapper).getCurrentTimeMillis(); + + // First update should succeed + wrapper.updatePayloadIfNeeded(testPayload); + + // Verify the payload was updated + verify(mockCache).put(testPayload); + + // Attempt to update before interval has passed + doReturn(initialTime + updateIntervalMs - 1).when(wrapper).getCurrentTimeMillis(); + wrapper.updatePayloadIfNeeded(testPayload); + + // Verify the payload was not updated again + verify(mockCache, times(1)).put(testPayload); + + // Update after interval has passed + doReturn(initialTime + updateIntervalMs + 1).when(wrapper).getCurrentTimeMillis(); + wrapper.updatePayloadIfNeeded(testPayload); + + // Verify the payload was updated again + verify(mockCache, times(2)).put(testPayload); + } + +} diff --git a/tools/flagd-http-connector/src/test/resources/simplelogger.properties b/tools/flagd-http-connector/src/test/resources/simplelogger.properties new file mode 100644 index 000000000..d9d489e82 --- /dev/null +++ b/tools/flagd-http-connector/src/test/resources/simplelogger.properties @@ -0,0 +1,3 @@ +org.org.slf4j.simpleLogger.defaultLogLevel=debug + +io.grpc.level=trace From 60386134b97f71f3737f3c232b90039ceb23e874 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Thu, 24 Apr 2025 17:42:39 +0300 Subject: [PATCH 07/18] move to tool - draft - cont. Signed-off-by: liran2000 --- pom.xml | 1 + .../providers/flagd/util/ConcurrentUtils.java | 61 ------ tools/flagd-http-connector/CHANGELOG.md | 35 ++++ tools/flagd-http-connector/README.md | 81 ++++++++ tools/flagd-http-connector/pom.xml | 54 ++++++ .../connector/sync/http/HttpCacheFetcher.java | 49 +++++ .../connector/sync/http/HttpConnector.java | 183 ++++++++++++++++++ .../sync/http/HttpConnectorOptions.java | 125 ++++++++++++ .../connector/sync/http/PayloadCache.java | 6 + .../sync/http/PayloadCacheOptions.java | 24 +++ .../sync/http/PayloadCacheWrapper.java | 59 ++++++ .../sync/http/HttpCacheFetcherTest.java | 41 ++-- .../http/HttpConnectorIntegrationTest.java | 8 +- .../sync/http/HttpConnectorOptionsTest.java | 74 ++++--- .../sync/http/HttpConnectorTest.java | 106 ++++++---- .../sync/http/PayloadCacheWrapperTest.java | 73 ++++--- tools/flagd-http-connector/version.txt | 1 + 17 files changed, 775 insertions(+), 206 deletions(-) delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java create mode 100644 tools/flagd-http-connector/CHANGELOG.md create mode 100644 tools/flagd-http-connector/README.md create mode 100644 tools/flagd-http-connector/pom.xml create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java create mode 100644 tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java create mode 100644 tools/flagd-http-connector/version.txt diff --git a/pom.xml b/pom.xml index 2d1a97ce2..85bdce3e8 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ providers/configcat providers/statsig providers/multiprovider + tools/flagd-http-connector diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java deleted file mode 100644 index b57faca77..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/util/ConcurrentUtils.java +++ /dev/null @@ -1,61 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.util; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Concurrent / Concurrency utilities. - * - * @author Liran Mendelovich - */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) -@Slf4j -public class ConcurrentUtils { - - /** - * Graceful shutdown a thread pool.
- * See - * https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html - * - * @param pool thread pool - * @param timeoutSeconds grace period timeout in seconds - timeout can be twice than this value, - * as first it waits for existing tasks to terminate, then waits for cancelled tasks to - * terminate. - */ - public static void shutdownAndAwaitTermination(ExecutorService pool, int timeoutSeconds) { - if (pool == null) { - return; - } - - // Disable new tasks from being submitted - pool.shutdown(); - try { - - // Wait a while for existing tasks to terminate - if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { - - // Cancel currently executing tasks - best effort, based on interrupt handling - // implementation. - pool.shutdownNow(); - - // Wait a while for tasks to respond to being cancelled - if (!pool.awaitTermination(timeoutSeconds, TimeUnit.SECONDS)) { - log.error("Thread pool did not shutdown all tasks after the timeout: {} seconds.", timeoutSeconds); - } - } - } catch (InterruptedException e) { - - log.info("Current thread interrupted during shutdownAndAwaitTermination, calling shutdownNow."); - - // (Re-)Cancel if current thread also interrupted - pool.shutdownNow(); - - // Preserve interrupt status - Thread.currentThread().interrupt(); - } - } -} diff --git a/tools/flagd-http-connector/CHANGELOG.md b/tools/flagd-http-connector/CHANGELOG.md new file mode 100644 index 000000000..069e8ebdc --- /dev/null +++ b/tools/flagd-http-connector/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +## [0.1.2](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.junitopenfeature-v0.1.1...dev.openfeature.contrib.tools.junitopenfeature-v0.1.2) (2024-12-03) + + +### ✨ New Features + +* added interception of parameterized tests to Junit OpenFeature Extension ([#1093](https://github.com/open-feature/java-sdk-contrib/issues/1093)) ([a78c906](https://github.com/open-feature/java-sdk-contrib/commit/a78c906b24b53f7d25eb01aad85ed614eb30ca05)) + +## [0.1.1](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.junitopenfeature-v0.1.0...dev.openfeature.contrib.tools.junitopenfeature-v0.1.1) (2024-09-27) + + +### 🐛 Bug Fixes + +* race condition causing default when multiple flags are used ([#983](https://github.com/open-feature/java-sdk-contrib/issues/983)) ([356a973](https://github.com/open-feature/java-sdk-contrib/commit/356a973cf2b6ddf82b8311ea200fa30df4f1d048)) + +## [0.1.0](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.junitopenfeature-v0.0.3...dev.openfeature.contrib.tools.junitopenfeature-v0.1.0) (2024-09-25) + + +### 🐛 Bug Fixes + +* **deps:** update dependency org.apache.commons:commons-lang3 to v3.17.0 ([#932](https://github.com/open-feature/java-sdk-contrib/issues/932)) ([c598d9f](https://github.com/open-feature/java-sdk-contrib/commit/c598d9f0a61f2324fb85d72fdfea34811283c575)) + + +### 🐛 Bug Fixes + +* added missing dependency and installation instruction ([#895](https://github.com/open-feature/java-sdk-contrib/issues/895)) ([6748d02](https://github.com/open-feature/java-sdk-contrib/commit/6748d02403f0ceecb6cb9ecdfb2fecf98423a7db)) +* **deps:** update dependency org.apache.commons:commons-lang3 to v3.16.0 ([#908](https://github.com/open-feature/java-sdk-contrib/issues/908)) ([d21cfe3](https://github.com/open-feature/java-sdk-contrib/commit/d21cfe3ac7da1ff6e1a4dc2ee4b0db5c24ed4847)) + +## [0.0.2](https://github.com/open-feature/java-sdk-contrib/compare/dev.openfeature.contrib.tools.junitopenfeature-v0.0.1...dev.openfeature.contrib.tools.junitopenfeature-v0.0.2) (2024-07-29) + + +### ✨ New Features + +* Add JUnit5 extension for OpenFeature ([#888](https://github.com/open-feature/java-sdk-contrib/issues/888)) ([9fff9db](https://github.com/open-feature/java-sdk-contrib/commit/9fff9db4bcee3c3ae8128a1b2fb040f53df1d5ed)) diff --git a/tools/flagd-http-connector/README.md b/tools/flagd-http-connector/README.md new file mode 100644 index 000000000..f6925f4bf --- /dev/null +++ b/tools/flagd-http-connector/README.md @@ -0,0 +1,81 @@ +# Http Connector + +## Introduction +Http Connector is a tool for [flagd](https://github.com/open-feature/flagd) in-process resolver. + +This mode performs flag evaluations locally (in-process). +Flag configurations for evaluation are obtained via gRPC protocol using +[sync protobuf schema](https://buf.build/open-feature/flagd/file/main:sync/v1/sync_service.proto) service definition. + +## Http Connector functionality + +HttpConnector is responsible for polling data from a specified URL at regular intervals. +It is leveraging Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, +reducing traffic, reducing rate limits effects and changes updates. Can be enabled via useHttpCache option. +The implementation is using Java HttpClient. + +## Use cases and benefits +* Reduce infrastructure/devops work, without additional containers needed. +* Use as an additional provider for fallback / internal backup service via multi-provider. + +### What happens if the Http source is down when application is starting ? + +It supports optional fail-safe initialization via cache, such that on initial fetch error following by +source downtime window, initial payload is taken from cache to avoid starting with default values until +the source is back up. Therefore, the cache ttl expected to be higher than the expected source +down-time to recover from during initialization. + +### Sample flow +Sample flow can use: +- Github as the flags payload source. +- Redis cache as a fail-safe initialization cache. + +Sample flow of initialization during Github down-time window, showing that application can still use flags +values as fetched from cache. +```mermaid +sequenceDiagram + participant Provider + participant Github + participant Redis + + break source downtime + Provider->>Github: initialize + Github->>Provider: failure + end + Provider->>Redis: fetch + Redis->>Provider: last payload + +``` + +## Usage + +### Installation + +```xml + + dev.openfeature.contrib.tools + flagd-http-connector + 0.0.1 + +``` + + +### Usage example + +```java + +HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + .url("http://example.com/flags") + .build(); +HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + +FlagdOptions options = + FlagdOptions.builder() + .resolverType(Config.Resolver.IN_PROCESS) + .customConnector(connector) + .build(); + +FlagdProvider flagdProvider = new FlagdProvider(options); +``` diff --git a/tools/flagd-http-connector/pom.xml b/tools/flagd-http-connector/pom.xml new file mode 100644 index 000000000..8945c7e97 --- /dev/null +++ b/tools/flagd-http-connector/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.1.0 + ../../pom.xml + + dev.openfeature.contrib.tools + flagd-http-connector + 0.0.1 + + flagd-http-connector + Flagd Http Connector + https://openfeature.dev + + + + liran2000 + Liran Mendelovich + OpenFeature + https://openfeature.dev/ + + + + + + dev.openfeature.contrib.providers + flagd + 0.11.8 + + + + org.apache.commons + commons-lang3 + 3.17.0 + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-api + test + + + + + diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java new file mode 100644 index 000000000..0cc069032 --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java @@ -0,0 +1,49 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +/** + * Fetches content from a given HTTP endpoint using caching headers to optimize network usage. + * If cached ETag or Last-Modified values are available, they are included in the request headers + * to potentially receive a 304 Not Modified response, reducing data transfer. + * Updates the cached ETag and Last-Modified values upon receiving a 200 OK response. + * It does not store the cached response, assuming not needed after first successful fetching. + * Non thread-safe. + * + * @param httpClient the HTTP client used to send the request + * @param httpRequestBuilder the builder for constructing the HTTP request + * @return the HTTP response received from the server + */ +@Slf4j +public class HttpCacheFetcher { + private String cachedETag = null; + private String cachedLastModified = null; + + @SneakyThrows + public HttpResponse fetchContent(HttpClient httpClient, HttpRequest.Builder httpRequestBuilder) { + if (cachedETag != null) { + httpRequestBuilder.header("If-None-Match", cachedETag); + } + if (cachedLastModified != null) { + httpRequestBuilder.header("If-Modified-Since", cachedLastModified); + } + + HttpRequest request = httpRequestBuilder.build(); + HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (httpResponse.statusCode() == 200) { + if (httpResponse.headers() != null) { + cachedETag = httpResponse.headers().firstValue("ETag").orElse(null); + cachedLastModified = httpResponse.headers().firstValue("Last-Modified").orElse(null); + } + log.debug("fetched new content"); + } else if (httpResponse.statusCode() == 304) { + log.debug("got 304 Not Modified"); + } + return httpResponse; + } +} diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java new file mode 100644 index 000000000..3eb8a116d --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java @@ -0,0 +1,183 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import static java.net.http.HttpClient.Builder.NO_PROXY; + +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.util.ConcurrentUtils; +import lombok.Builder; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; + +/** + * HttpConnector is responsible for polling data from a specified URL at regular intervals. + * Notice rate limits for polling http sources like Github. + * It implements the QueueSource interface to enqueue and dequeue change messages. + * The class supports configurable parameters such as poll interval, request timeout, and proxy settings. + * It uses a ScheduledExecutorService to schedule polling tasks and an ExecutorService for HTTP client execution. + * The class also provides methods to initialize, retrieve the stream queue, and shutdown the connector gracefully. + * It supports optional fail-safe initialization via cache. + * + * See readme - Http Connector section. + */ +@Slf4j +public class HttpConnector implements QueueSource { + + private Integer pollIntervalSeconds; + private Integer requestTimeoutSeconds; + private BlockingQueue queue; + private HttpClient client; + private ExecutorService httpClientExecutor; + private ScheduledExecutorService scheduler; + private Map headers; + private PayloadCacheWrapper payloadCacheWrapper; + private PayloadCache payloadCache; + private HttpCacheFetcher httpCacheFetcher; + + @NonNull + private String url; + + @Builder + public HttpConnector(HttpConnectorOptions httpConnectorOptions) { + this.pollIntervalSeconds = httpConnectorOptions.getPollIntervalSeconds(); + this.requestTimeoutSeconds = httpConnectorOptions.getRequestTimeoutSeconds(); + ProxySelector proxySelector = NO_PROXY; + if (httpConnectorOptions.getProxyHost() != null && httpConnectorOptions.getProxyPort() != null) { + proxySelector = ProxySelector.of(new InetSocketAddress(httpConnectorOptions.getProxyHost(), + httpConnectorOptions.getProxyPort())); + } + this.url = httpConnectorOptions.getUrl(); + this.headers = httpConnectorOptions.getHeaders(); + this.httpClientExecutor = httpConnectorOptions.getHttpClientExecutor(); + scheduler = Executors.newScheduledThreadPool(httpConnectorOptions.getScheduledThreadPoolSize()); + this.client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(httpConnectorOptions.getConnectTimeoutSeconds())) + .proxy(proxySelector) + .executor(this.httpClientExecutor) + .build(); + this.queue = new LinkedBlockingQueue<>(httpConnectorOptions.getLinkedBlockingQueueCapacity()); + this.payloadCache = httpConnectorOptions.getPayloadCache(); + if (payloadCache != null) { + this.payloadCacheWrapper = PayloadCacheWrapper.builder() + .payloadCache(payloadCache) + .payloadCacheOptions(httpConnectorOptions.getPayloadCacheOptions()) + .build(); + } + if (Boolean.TRUE.equals(httpConnectorOptions.getUseHttpCache())) { + httpCacheFetcher = new HttpCacheFetcher(); + } + } + + @Override + public void init() throws Exception { + log.info("init Http Connector"); + } + + @Override + public BlockingQueue getStreamQueue() { + boolean success = fetchAndUpdate(); + if (!success) { + log.info("failed initial fetch"); + if (payloadCache != null) { + updateFromCache(); + } + } + Runnable pollTask = buildPollTask(); + scheduler.scheduleWithFixedDelay(pollTask, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); + return queue; + } + + private void updateFromCache() { + log.info("taking initial payload from cache to avoid starting with default values"); + String flagData = payloadCache.get(); + if (flagData == null) { + log.debug("got null from cache"); + return; + } + if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, flagData))) { + log.warn("init: Unable to offer file content to queue: queue is full"); + } + } + + protected Runnable buildPollTask() { + return this::fetchAndUpdate; + } + + private boolean fetchAndUpdate() { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(requestTimeoutSeconds)) + .GET(); + headers.forEach(requestBuilder::header); + + HttpResponse response; + try { + log.debug("fetching response"); + response = execute(requestBuilder); + } catch (IOException e) { + log.info("could not fetch", e); + return false; + } catch (Exception e) { + log.debug("exception", e); + return false; + } + log.debug("fetched response"); + String payload = response.body(); + if (!isSuccessful(response)) { + log.info("received non-successful status code: {} {}", response.statusCode(), payload); + return false; + } else if (response.statusCode() == 304) { + log.debug("got 304 Not Modified, skipping update"); + return false; + } + if (payload == null) { + log.debug("payload is null"); + return false; + } + log.debug("adding payload to queue"); + if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, payload))) { + log.warn("Unable to offer file content to queue: queue is full"); + return false; + } + if (payloadCacheWrapper != null) { + log.debug("scheduling cache update if needed"); + scheduler.execute(() -> + payloadCacheWrapper.updatePayloadIfNeeded(payload) + ); + } + return payload != null; + } + + private static boolean isSuccessful(HttpResponse response) { + return response.statusCode() == 200 || response.statusCode() == 304; + } + + protected HttpResponse execute(HttpRequest.Builder requestBuilder) throws IOException, InterruptedException { + if (httpCacheFetcher != null) { + return httpCacheFetcher.fetchContent(client, requestBuilder); + } + return client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + } + + @Override + public void shutdown() throws InterruptedException { + ConcurrentUtils.shutdownAndAwaitTermination(scheduler, 10); + ConcurrentUtils.shutdownAndAwaitTermination(httpClientExecutor, 10); + } +} diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java new file mode 100644 index 000000000..740d54ddf --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java @@ -0,0 +1,125 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; +import lombok.SneakyThrows; + +@Getter +public class HttpConnectorOptions { + + @Builder.Default + private Integer pollIntervalSeconds = 60; + @Builder.Default + private Integer connectTimeoutSeconds = 10; + @Builder.Default + private Integer requestTimeoutSeconds = 10; + @Builder.Default + private Integer linkedBlockingQueueCapacity = 100; + @Builder.Default + private Integer scheduledThreadPoolSize = 2; + @Builder.Default + private Map headers = new HashMap<>(); + @Builder.Default + private ExecutorService httpClientExecutor = Executors.newFixedThreadPool(1); + @Builder.Default + private String proxyHost; + @Builder.Default + private Integer proxyPort; + @Builder.Default + private PayloadCacheOptions payloadCacheOptions; + @Builder.Default + private PayloadCache payloadCache; + @Builder.Default + private Boolean useHttpCache; + @NonNull + private String url; + + @Builder + public HttpConnectorOptions(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, + Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort, + PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache, Boolean useHttpCache) { + validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, + connectTimeoutSeconds, proxyHost, proxyPort, payloadCacheOptions, payloadCache); + if (pollIntervalSeconds != null) { + this.pollIntervalSeconds = pollIntervalSeconds; + } + if (linkedBlockingQueueCapacity != null) { + this.linkedBlockingQueueCapacity = linkedBlockingQueueCapacity; + } + if (scheduledThreadPoolSize != null) { + this.scheduledThreadPoolSize = scheduledThreadPoolSize; + } + if (requestTimeoutSeconds != null) { + this.requestTimeoutSeconds = requestTimeoutSeconds; + } + if (connectTimeoutSeconds != null) { + this.connectTimeoutSeconds = connectTimeoutSeconds; + } + this.url = url; + if (headers != null) { + this.headers = headers; + } + if (httpClientExecutor != null) { + this.httpClientExecutor = httpClientExecutor; + } + if (proxyHost != null) { + this.proxyHost = proxyHost; + } + if (proxyPort != null) { + this.proxyPort = proxyPort; + } + if (payloadCache != null) { + this.payloadCache = payloadCache; + } + if (payloadCacheOptions != null) { + this.payloadCacheOptions = payloadCacheOptions; + } + if (useHttpCache != null) { + this.useHttpCache = useHttpCache; + } + } + + @SneakyThrows + private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, + Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, + String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, + PayloadCache payloadCache) { + new URL(url).toURI(); + if (linkedBlockingQueueCapacity != null && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { + throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); + } + if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { + throw new IllegalArgumentException("scheduledThreadPoolSize must be between 1 and 10"); + } + if (requestTimeoutSeconds != null && (requestTimeoutSeconds < 1 || requestTimeoutSeconds > 60)) { + throw new IllegalArgumentException("requestTimeoutSeconds must be between 1 and 60"); + } + if (connectTimeoutSeconds != null && (connectTimeoutSeconds < 1 || connectTimeoutSeconds > 60)) { + throw new IllegalArgumentException("connectTimeoutSeconds must be between 1 and 60"); + } + if (pollIntervalSeconds != null && (pollIntervalSeconds < 1 || pollIntervalSeconds > 600)) { + throw new IllegalArgumentException("pollIntervalSeconds must be between 1 and 600"); + } + if (proxyPort != null && (proxyPort < 1 || proxyPort > 65535)) { + throw new IllegalArgumentException("proxyPort must be between 1 and 65535"); + } + if (proxyHost != null && proxyPort == null ) { + throw new IllegalArgumentException("proxyPort must be set if proxyHost is set"); + } else if (proxyHost == null && proxyPort != null) { + throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); + } + if (payloadCacheOptions != null && payloadCache == null) { + throw new IllegalArgumentException("payloadCache must be set if payloadCacheOptions is set"); + } + if (payloadCache != null && payloadCacheOptions == null) { + throw new IllegalArgumentException("payloadCacheOptions must be set if payloadCache is set"); + } + } +} diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java new file mode 100644 index 000000000..4af5f5f1d --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java @@ -0,0 +1,6 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +public interface PayloadCache { + public void put(String payload); + public String get(); +} diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java new file mode 100644 index 000000000..9ca0dabcc --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java @@ -0,0 +1,24 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import lombok.Builder; +import lombok.Getter; + +/** + * Represents configuration options for caching payloads. + *

+ * This class provides options to configure the caching behavior, + * specifically the interval at which the cache should be updated. + *

+ *

+ * The default update interval is set to 30 minutes. + * Change it typically to a value according to cache ttl and tradeoff with not updating it too much for + * corner cases. + *

+ */ +@Builder +@Getter +public class PayloadCacheOptions { + + @Builder.Default + private int updateIntervalSeconds = 60 * 30; // 30 minutes +} diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java new file mode 100644 index 000000000..be213403e --- /dev/null +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java @@ -0,0 +1,59 @@ +package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +/** + * A wrapper class for managing a payload cache with a specified update interval. + * This class ensures that the cache is only updated if the specified time interval + * has passed since the last update. It logs debug messages when updates are skipped + * and error messages if the update process fails. + * Not thread-safe. + * + *

Usage involves creating an instance with {@link PayloadCacheOptions} to set + * the update interval, and then using {@link #updatePayloadIfNeeded(String)} to + * conditionally update the cache and {@link #get()} to retrieve the cached payload.

+ */ +@Slf4j +public class PayloadCacheWrapper { + private long lastUpdateTimeMs; + private long updateIntervalMs; + private PayloadCache payloadCache; + + @Builder + public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloadCacheOptions) { + if (payloadCacheOptions.getUpdateIntervalSeconds() < 1) { + throw new IllegalArgumentException("pollIntervalSeconds must be larger than 0"); + } + this.updateIntervalMs = payloadCacheOptions.getUpdateIntervalSeconds() * 1000L; + this.payloadCache = payloadCache; + } + + public void updatePayloadIfNeeded(String payload) { + if ((getCurrentTimeMillis() - lastUpdateTimeMs) < updateIntervalMs) { + log.debug("not updating payload, updateIntervalMs not reached"); + return; + } + + try { + log.debug("updating payload"); + payloadCache.put(payload); + lastUpdateTimeMs = getCurrentTimeMillis(); + } catch (Exception e) { + log.error("failed updating cache", e); + } + } + + protected long getCurrentTimeMillis() { + return System.currentTimeMillis(); + } + + public String get() { + try { + return payloadCache.get(); + } catch (Exception e) { + log.error("failed getting from cache", e); + return null; + } + } +} diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java index 37e353917..fe49219ec 100644 --- a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java @@ -1,6 +1,6 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; -import static org.junit.Assert.assertNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -14,7 +14,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher; import java.io.IOException; import java.lang.reflect.Field; import java.net.http.HttpClient; @@ -41,7 +40,7 @@ public void testFirstRequestSendsNoCacheHeaders() throws Exception { when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); @@ -63,7 +62,7 @@ public void testResponseWith200ButNoCacheHeaders() throws Exception { when(headersMock.firstValue("ETag")).thenReturn(Optional.empty()); when(headersMock.firstValue("Last-Modified")).thenReturn(Optional.empty()); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); assertEquals(200, response.statusCode()); @@ -89,7 +88,7 @@ public void testFetchContentReturnsHttpResponse() throws Exception { when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(404); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); HttpResponse result = fetcher.fetchContent(httpClientMock, requestBuilderMock); assertEquals(responseMock, result); @@ -107,13 +106,13 @@ public void test200ResponseNoEtagOrLastModified() throws Exception { when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); - Field cachedETagField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedETag"); + Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); cachedETagField.setAccessible(true); assertNull(cachedETagField.get(fetcher)); - Field cachedLastModifiedField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); cachedLastModifiedField.setAccessible(true); assertNull(cachedLastModifiedField.get(fetcher)); } @@ -132,13 +131,13 @@ public void testUpdateCacheOn200Response() throws Exception { when(requestBuilderMock.build()).thenReturn(requestMock); when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); - Field cachedETagField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedETag"); + Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); cachedETagField.setAccessible(true); assertEquals("etag-value", cachedETagField.get(fetcher)); - Field cachedLastModifiedField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); cachedLastModifiedField.setAccessible(true); assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", cachedLastModifiedField.get(fetcher)); } @@ -157,7 +156,7 @@ public void testRequestWithCachedEtagIncludesIfNoneMatchHeader() throws Exceptio when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); fetcher.fetchContent(httpClientMock, requestBuilderMock); @@ -166,7 +165,7 @@ public void testRequestWithCachedEtagIncludesIfNoneMatchHeader() throws Exceptio @Test public void testNullHttpClientOrRequestBuilder() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); assertThrows(NullPointerException.class, () -> { @@ -190,7 +189,7 @@ public void testResponseWithUnexpectedStatusCode() throws Exception { when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(500); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); assertEquals(500, response.statusCode()); @@ -212,7 +211,7 @@ public void testRequestIncludesIfModifiedSinceHeaderWhenLastModifiedCached() thr when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); fetcher.fetchContent(httpClientMock, requestBuilderMock); @@ -234,7 +233,7 @@ public void testCalls200And304Responses() throws Exception { when(responseMock200.statusCode()).thenReturn(200); when(responseMock304.statusCode()).thenReturn(304); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); fetcher.fetchContent(httpClientMock, requestBuilderMock); @@ -254,11 +253,11 @@ public void testRequestIncludesBothEtagAndLastModifiedHeaders() throws Exception when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); - Field cachedETagField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedETag"); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); + Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); cachedETagField.setAccessible(true); cachedETagField.set(fetcher, "test-etag"); - Field cachedLastModifiedField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); + Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); cachedLastModifiedField.setAccessible(true); cachedLastModifiedField.set(fetcher, "test-last-modified"); @@ -279,7 +278,7 @@ public void testHttpClientSendExceptionPropagation() { when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) .thenThrow(new IOException("Network error")); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); assertThrows(IOException.class, () -> { fetcher.fetchContent(httpClientMock, requestBuilderMock); }); @@ -300,7 +299,7 @@ public void testOnlyEtagAndLastModifiedHeadersCached() throws Exception { when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); when(responseMock.statusCode()).thenReturn(200); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpCacheFetcher fetcher = new HttpCacheFetcher(); + HttpCacheFetcher fetcher = new HttpCacheFetcher(); fetcher.fetchContent(httpClientMock, requestBuilderMock); verify(requestBuilderMock, never()).header(eq("Some-Other-Header"), anyString()); diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java index 75b69721a..bd198c1c4 100644 --- a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java @@ -1,12 +1,10 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; -import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; +import static dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions; import java.util.concurrent.BlockingQueue; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; @@ -28,11 +26,11 @@ class HttpConnectorIntegrationTest { @Test void testGithubRawContent() { assumeTrue(parseBoolean("integrationTestsEnabled")); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = null; + HttpConnector connector = null; try { String testUrl = "https://raw.githubusercontent.com/open-feature/java-sdk-contrib/58fe5da7d4e2f6f4ae2c1caf3411a01e84a1dc1a/providers/flagd/version.txt"; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .connectTimeoutSeconds(10) .requestTimeoutSeconds(10) diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java index a90bbae38..96bd81c4d 100644 --- a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java @@ -1,27 +1,23 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions; +import org.junit.jupiter.api.Test; import java.net.MalformedURLException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import org.junit.Test; -public class HttpConnectorOptionsTest { +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +public class HttpConnectorOptionsTest { @Test public void testDefaultValuesInitialization() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("https://example.com") .build(); @@ -45,7 +41,7 @@ public void testDefaultValuesInitialization() { public void testInvalidUrlFormat() { MalformedURLException exception = assertThrows( MalformedURLException.class, - () -> dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + () -> HttpConnectorOptions.builder() .url("invalid-url") .build() ); @@ -55,7 +51,7 @@ public void testInvalidUrlFormat() { @Test public void testCustomValuesInitialization() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .pollIntervalSeconds(120) .connectTimeoutSeconds(20) .requestTimeoutSeconds(30) @@ -78,7 +74,7 @@ public void testCustomHeadersMap() { customHeaders.put("Authorization", "Bearer token"); customHeaders.put("Content-Type", "application/json"); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("http://example.com") .headers(customHeaders) .build(); @@ -90,7 +86,7 @@ public void testCustomHeadersMap() { @Test public void testCustomExecutorService() { ExecutorService customExecutor = Executors.newFixedThreadPool(5); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("https://example.com") .httpClientExecutor(customExecutor) .build(); @@ -100,10 +96,10 @@ public void testCustomExecutorService() { @Test public void testSettingPayloadCacheWithValidOptions() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions cacheOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder() .updateIntervalSeconds(1800) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache payloadCache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + PayloadCache payloadCache = new PayloadCache() { private String payload; @Override @@ -117,7 +113,7 @@ public String get() { } }; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("https://example.com") .payloadCacheOptions(cacheOptions) .payloadCache(payloadCache) @@ -130,7 +126,7 @@ public String get() { @Test public void testProxyConfigurationWithValidHostAndPort() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("https://example.com") .proxyHost("proxy.example.com") .proxyPort(8080) @@ -143,7 +139,7 @@ public void testProxyConfigurationWithValidHostAndPort() { @Test public void testLinkedBlockingQueueCapacityOutOfRange() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .linkedBlockingQueueCapacity(0) .build(); @@ -151,7 +147,7 @@ public void testLinkedBlockingQueueCapacityOutOfRange() { assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .linkedBlockingQueueCapacity(1001) .build(); @@ -162,7 +158,7 @@ public void testLinkedBlockingQueueCapacityOutOfRange() { @Test public void testPollIntervalSecondsOutOfRange() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .pollIntervalSeconds(700) .build(); @@ -175,8 +171,8 @@ public void testAdditionalCustomValuesInitialization() { Map headers = new HashMap<>(); headers.put("Authorization", "Bearer token"); ExecutorService executorService = Executors.newFixedThreadPool(2); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions cacheOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder().build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache cache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder().build(); + PayloadCache cache = new PayloadCache() { @Override public void put(String payload) { // do nothing @@ -185,7 +181,7 @@ public void put(String payload) { public String get() { return null; } }; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("https://example.com") .pollIntervalSeconds(120) .connectTimeoutSeconds(20) @@ -220,7 +216,7 @@ public void put(String payload) { @Test public void testRequestTimeoutSecondsOutOfRange() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .requestTimeoutSeconds(61) .build(); @@ -233,8 +229,8 @@ public void testBuilderInitializesAllFields() { Map headers = new HashMap<>(); headers.put("Authorization", "Bearer token"); ExecutorService executorService = Executors.newFixedThreadPool(2); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions cacheOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder().build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache cache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder().build(); + PayloadCache cache = new PayloadCache() { @Override public void put(String payload) { // do nothing @@ -243,7 +239,7 @@ public void put(String payload) { public String get() { return null; } }; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .pollIntervalSeconds(120) .connectTimeoutSeconds(20) .requestTimeoutSeconds(30) @@ -277,7 +273,7 @@ public void put(String payload) { @Test public void testScheduledThreadPoolSizeOutOfRange() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .scheduledThreadPoolSize(11) .build(); @@ -288,7 +284,7 @@ public void testScheduledThreadPoolSizeOutOfRange() { @Test public void testProxyPortOutOfRange() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .proxyHost("proxy.example.com") .proxyPort(70000) // Invalid port, out of range @@ -300,7 +296,7 @@ public void testProxyPortOutOfRange() { @Test public void testConnectTimeoutSecondsOutOfRange() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .connectTimeoutSeconds(0) .build(); @@ -308,7 +304,7 @@ public void testConnectTimeoutSecondsOutOfRange() { assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .connectTimeoutSeconds(61) .build(); @@ -319,7 +315,7 @@ public void testConnectTimeoutSecondsOutOfRange() { @Test public void testProxyPortWithoutProxyHost() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .proxyPort(8080) .build(); @@ -329,7 +325,7 @@ public void testProxyPortWithoutProxyHost() { @Test public void testDefaultValuesWhenNullParametersProvided() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions options = HttpConnectorOptions.builder() .url("https://example.com") .pollIntervalSeconds(null) .linkedBlockingQueueCapacity(null) @@ -364,7 +360,7 @@ public void testDefaultValuesWhenNullParametersProvided() { @Test public void testProxyHostWithoutProxyPort() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .proxyHost("proxy.example.com") .build(); @@ -374,7 +370,7 @@ public void testProxyHostWithoutProxyPort() { @Test public void testSettingPayloadCacheWithoutOptions() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockPayloadCache = new PayloadCache() { + PayloadCache mockPayloadCache = new PayloadCache() { @Override public void put(String payload) { // Mock implementation @@ -387,7 +383,7 @@ public String get() { }; IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions.builder() .url("https://example.com") .payloadCache(mockPayloadCache) .build(); diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java index 0edb440a8..5298d98f6 100644 --- a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java @@ -1,5 +1,6 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -10,12 +11,11 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions; import java.io.IOException; import java.lang.reflect.Field; import java.net.http.HttpClient; @@ -28,6 +28,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; +import dev.openfeature.sdk.EvaluationContext; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; @@ -47,15 +49,15 @@ void testGetStreamQueueInitialAndScheduledPolls() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .httpClientExecutor(Executors.newSingleThreadExecutor()) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); @@ -81,7 +83,7 @@ void testBuildPollTaskFetchesDataAndAddsToQueue() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache payloadCache = new dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache() { + PayloadCache payloadCache = new PayloadCache() { private String payload; @Override public void put(String payload) { @@ -93,27 +95,27 @@ public String get() { return payload; } }; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .proxyHost("proxy-host") .proxyPort(8080) .useHttpCache(true) .payloadCache(payloadCache) - .payloadCacheOptions(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder().build()) + .payloadCacheOptions(PayloadCacheOptions.builder().build()) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); connector.init(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); Runnable pollTask = connector.buildPollTask(); pollTask.run(); - Field queueField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("queue"); + Field queueField = HttpConnector.class.getDeclaredField("queue"); queueField.setAccessible(true); BlockingQueue queue = (BlockingQueue) queueField.get(connector); assertFalse(queue.isEmpty()); @@ -131,15 +133,15 @@ void testHttpRequestIncludesHeaders() { testHeaders.put("Authorization", "Bearer token"); testHeaders.put("Content-Type", "application/json"); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .headers(testHeaders) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field headersField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("headers"); + Field headersField = HttpConnector.class.getDeclaredField("headers"); headersField.setAccessible(true); Map headers = (Map) headersField.get(connector); assertNotNull(headers); @@ -159,14 +161,14 @@ void testSuccessfulHttpResponseAddsDataToQueue() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); @@ -190,7 +192,7 @@ void testInitFailureUsingCache() { .thenThrow(new IOException("Simulated IO Exception")); final String cachedData = "cached data"; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache payloadCache = new PayloadCache() { + PayloadCache payloadCache = new PayloadCache() { @Override public void put(String payload) { // do nothing @@ -202,16 +204,16 @@ public String get() { } }; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .payloadCache(payloadCache) .payloadCacheOptions(PayloadCacheOptions.builder().build()) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); @@ -229,11 +231,11 @@ public String get() { void testQueueBecomesFull() { String testUrl = "http://example.com"; int queueCapacity = 1; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .linkedBlockingQueueCapacity(queueCapacity) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); @@ -253,15 +255,15 @@ void testShutdownProperlyTerminatesSchedulerAndHttpClientExecutor() throws Inter ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class); String testUrl = "http://example.com"; - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .httpClientExecutor(mockHttpClientExecutor) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field schedulerField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("scheduler"); + Field schedulerField = HttpConnector.class.getDeclaredField("scheduler"); schedulerField.setAccessible(true); schedulerField.set(connector, mockScheduler); @@ -281,14 +283,14 @@ void testHttpResponseNonSuccessStatusCode() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); @@ -302,14 +304,14 @@ void testHttpResponseNonSuccessStatusCode() { void testHttpRequestFailsWithException() { String testUrl = "http://example.com"; HttpClient mockClient = mock(HttpClient.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); @@ -326,14 +328,14 @@ void testHttpRequestFailsWithException() { void testHttpRequestFailsWithIoexception() { String testUrl = "http://example.com"; HttpClient mockClient = mock(HttpClient.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); - Field clientField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("client"); + Field clientField = HttpConnector.class.getDeclaredField("client"); clientField.setAccessible(true); clientField.set(connector, mockClient); @@ -342,7 +344,7 @@ void testHttpRequestFailsWithIoexception() { connector.getStreamQueue(); - Field queueField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.class.getDeclaredField("queue"); + Field queueField = HttpConnector.class.getDeclaredField("queue"); queueField.setAccessible(true); BlockingQueue queue = (BlockingQueue) queueField.get(connector); assertTrue(queue.isEmpty(), "Queue should be empty due to IOException"); @@ -356,10 +358,10 @@ void testScheduledPollingContinuesAtFixedIntervals() { when(mockResponse.statusCode()).thenReturn(200); when(mockResponse.body()).thenReturn("test data"); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = spy(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = spy(HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build()); @@ -389,10 +391,10 @@ void testQueuePayloadTypeSetToDataOnSuccess() { when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(mockResponse); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() .url(testUrl) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector connector = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnector.builder() + HttpConnector connector = HttpConnector.builder() .httpConnectorOptions(httpConnectorOptions) .build(); @@ -408,6 +410,26 @@ void testQueuePayloadTypeSetToDataOnSuccess() { assertEquals("response body", payload.getFlagData()); } + @Test + public void providerTest() { + HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() + .url("http://example.com") + .build(); + HttpConnector connector = HttpConnector.builder() + .httpConnectorOptions(httpConnectorOptions) + .build(); + + FlagdOptions options = + FlagdOptions.builder() + .resolverType(Config.Resolver.IN_PROCESS) + .customConnector(connector) + .build(); + + FlagdProvider flagdProvider = new FlagdProvider(options); + + assertDoesNotThrow(() -> flagdProvider.getMetadata()); + } + @SneakyThrows protected static void delay(long ms) { Thread.sleep(ms); diff --git a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java index 28fba6881..3ff3ff679 100644 --- a/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java +++ b/tools/flagd-http-connector/src/test/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java @@ -1,6 +1,6 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; -import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; +import static dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -15,9 +15,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper; import java.lang.reflect.Field; import lombok.SneakyThrows; import org.junit.jupiter.api.Test; @@ -27,12 +24,12 @@ public class PayloadCacheWrapperTest { @Test public void testConstructorInitializesWithValidParameters() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -49,12 +46,12 @@ public void testConstructorInitializesWithValidParameters() { @Test public void testConstructorThrowsExceptionForInvalidInterval() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(0) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.PayloadCacheWrapperBuilder payloadCacheWrapperBuilder = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper.PayloadCacheWrapperBuilder payloadCacheWrapperBuilder = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options); IllegalArgumentException exception = assertThrows( @@ -67,11 +64,11 @@ public void testConstructorThrowsExceptionForInvalidInterval() { @Test public void testUpdateSkipsWhenIntervalNotPassed() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -88,11 +85,11 @@ public void testUpdateSkipsWhenIntervalNotPassed() { @Test public void testUpdatePayloadIfNeededHandlesPutException() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -107,11 +104,11 @@ public void testUpdatePayloadIfNeededHandlesPutException() { @Test public void testUpdatePayloadIfNeededUpdatesCacheAfterInterval() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(1) // 1 second interval for quick test .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -129,11 +126,11 @@ public void testUpdatePayloadIfNeededUpdatesCacheAfterInterval() { @Test public void testGetReturnsNullWhenCacheGetThrowsException() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -149,11 +146,11 @@ public void testGetReturnsNullWhenCacheGetThrowsException() { @Test public void test_get_returns_cached_payload() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -169,11 +166,11 @@ public void test_get_returns_cached_payload() { @Test public void test_first_call_updates_cache() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -187,11 +184,11 @@ public void test_first_call_updates_cache() { @Test public void test_update_payload_once_within_interval() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(1) // 1 second interval .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -207,11 +204,11 @@ public void test_update_payload_once_within_interval() { @SneakyThrows @Test public void test_last_update_time_ms_updated_after_successful_cache_update() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build(); @@ -221,7 +218,7 @@ public void test_last_update_time_ms_updated_after_successful_cache_update() { verify(mockCache).put(testPayload); - Field lastUpdateTimeMsField = dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper.class.getDeclaredField("lastUpdateTimeMs"); + Field lastUpdateTimeMsField = PayloadCacheWrapper.class.getDeclaredField("lastUpdateTimeMs"); lastUpdateTimeMsField.setAccessible(true); long lastUpdateTimeMs = (Long) lastUpdateTimeMsField.get(wrapper); @@ -231,11 +228,11 @@ public void test_last_update_time_ms_updated_after_successful_cache_update() { @Test public void test_update_payload_if_needed_respects_update_interval() { - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCache mockCache = mock(PayloadCache.class); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheOptions options = PayloadCacheOptions.builder() + PayloadCache mockCache = mock(PayloadCache.class); + PayloadCacheOptions options = PayloadCacheOptions.builder() .updateIntervalSeconds(600) .build(); - dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.PayloadCacheWrapper wrapper = spy(PayloadCacheWrapper.builder() + PayloadCacheWrapper wrapper = spy(PayloadCacheWrapper.builder() .payloadCache(mockCache) .payloadCacheOptions(options) .build()); diff --git a/tools/flagd-http-connector/version.txt b/tools/flagd-http-connector/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/tools/flagd-http-connector/version.txt @@ -0,0 +1 @@ +0.0.1 From 8f03143ba466aae5fcd290bf11cdac2fb8af18b9 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Thu, 24 Apr 2025 17:45:51 +0300 Subject: [PATCH 08/18] move to tool - draft - cont. Signed-off-by: liran2000 --- .../connector/sync/http/HttpCacheFetcher.java | 49 --- .../connector/sync/http/HttpConnector.java | 184 -------- .../sync/http/HttpConnectorOptions.java | 125 ------ .../connector/sync/http/PayloadCache.java | 6 - .../sync/http/PayloadCacheOptions.java | 24 - .../sync/http/PayloadCacheWrapper.java | 59 --- .../main/resources/simplelogger.properties | 2 +- .../sync/http/HttpCacheFetcherTest.java | 308 ------------- .../http/HttpConnectorIntegrationTest.java | 57 --- .../sync/http/HttpConnectorOptionsTest.java | 406 ----------------- .../sync/http/HttpConnectorTest.java | 412 ------------------ .../sync/http/PayloadCacheWrapperTest.java | 267 ------------ 12 files changed, 1 insertion(+), 1898 deletions(-) delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java delete mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java deleted file mode 100644 index e2a59a9fc..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java +++ /dev/null @@ -1,49 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; - -/** - * Fetches content from a given HTTP endpoint using caching headers to optimize network usage. - * If cached ETag or Last-Modified values are available, they are included in the request headers - * to potentially receive a 304 Not Modified response, reducing data transfer. - * Updates the cached ETag and Last-Modified values upon receiving a 200 OK response. - * It does not store the cached response, assuming not needed after first successful fetching. - * Non thread-safe. - * - * @param httpClient the HTTP client used to send the request - * @param httpRequestBuilder the builder for constructing the HTTP request - * @return the HTTP response received from the server - */ -@Slf4j -public class HttpCacheFetcher { - private String cachedETag = null; - private String cachedLastModified = null; - - @SneakyThrows - public HttpResponse fetchContent(HttpClient httpClient, HttpRequest.Builder httpRequestBuilder) { - if (cachedETag != null) { - httpRequestBuilder.header("If-None-Match", cachedETag); - } - if (cachedLastModified != null) { - httpRequestBuilder.header("If-Modified-Since", cachedLastModified); - } - - HttpRequest request = httpRequestBuilder.build(); - HttpResponse httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (httpResponse.statusCode() == 200) { - if (httpResponse.headers() != null) { - cachedETag = httpResponse.headers().firstValue("ETag").orElse(null); - cachedLastModified = httpResponse.headers().firstValue("Last-Modified").orElse(null); - } - log.debug("fetched new content"); - } else if (httpResponse.statusCode() == 304) { - log.debug("got 304 Not Modified"); - } - return httpResponse; - } -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java deleted file mode 100644 index b72b5c035..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java +++ /dev/null @@ -1,184 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; -import dev.openfeature.contrib.providers.flagd.util.ConcurrentUtils; -import lombok.Builder; -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.ProxySelector; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Map; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import static java.net.http.HttpClient.Builder.NO_PROXY; - -/** - * HttpConnector is responsible for polling data from a specified URL at regular intervals. - * Notice rate limits for polling http sources like Github. - * It implements the QueueSource interface to enqueue and dequeue change messages. - * The class supports configurable parameters such as poll interval, request timeout, and proxy settings. - * It uses a ScheduledExecutorService to schedule polling tasks and an ExecutorService for HTTP client execution. - * The class also provides methods to initialize, retrieve the stream queue, and shutdown the connector gracefully. - * It supports optional fail-safe initialization via cache. - * - * See readme - Http Connector section. - */ -@Slf4j -public class HttpConnector implements QueueSource { - - private Integer pollIntervalSeconds; - private Integer requestTimeoutSeconds; - private BlockingQueue queue; - private HttpClient client; - private ExecutorService httpClientExecutor; - private ScheduledExecutorService scheduler; - private Map headers; - private PayloadCacheWrapper payloadCacheWrapper; - private PayloadCache payloadCache; - private HttpCacheFetcher httpCacheFetcher; - - @NonNull - private String url; - - @Builder - public HttpConnector(HttpConnectorOptions httpConnectorOptions) { - this.pollIntervalSeconds = httpConnectorOptions.getPollIntervalSeconds(); - this.requestTimeoutSeconds = httpConnectorOptions.getRequestTimeoutSeconds(); - ProxySelector proxySelector = NO_PROXY; - if (httpConnectorOptions.getProxyHost() != null && httpConnectorOptions.getProxyPort() != null) { - proxySelector = ProxySelector.of(new InetSocketAddress(httpConnectorOptions.getProxyHost(), - httpConnectorOptions.getProxyPort())); - } - this.url = httpConnectorOptions.getUrl(); - this.headers = httpConnectorOptions.getHeaders(); - this.httpClientExecutor = httpConnectorOptions.getHttpClientExecutor(); - scheduler = Executors.newScheduledThreadPool(httpConnectorOptions.getScheduledThreadPoolSize()); - this.client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(httpConnectorOptions.getConnectTimeoutSeconds())) - .proxy(proxySelector) - .executor(this.httpClientExecutor) - .build(); - this.queue = new LinkedBlockingQueue<>(httpConnectorOptions.getLinkedBlockingQueueCapacity()); - this.payloadCache = httpConnectorOptions.getPayloadCache(); - if (payloadCache != null) { - this.payloadCacheWrapper = PayloadCacheWrapper.builder() - .payloadCache(payloadCache) - .payloadCacheOptions(httpConnectorOptions.getPayloadCacheOptions()) - .build(); - } - if (Boolean.TRUE.equals(httpConnectorOptions.getUseHttpCache())) { - httpCacheFetcher = new HttpCacheFetcher(); - } - } - - @Override - public void init() throws Exception { - log.info("init Http Connector"); - } - - @Override - public BlockingQueue getStreamQueue() { - boolean success = fetchAndUpdate(); - if (!success) { - log.info("failed initial fetch"); - if (payloadCache != null) { - updateFromCache(); - } - } - Runnable pollTask = buildPollTask(); - scheduler.scheduleWithFixedDelay(pollTask, pollIntervalSeconds, pollIntervalSeconds, TimeUnit.SECONDS); - return queue; - } - - private void updateFromCache() { - log.info("taking initial payload from cache to avoid starting with default values"); - String flagData = payloadCache.get(); - if (flagData == null) { - log.debug("got null from cache"); - return; - } - if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, flagData))) { - log.warn("init: Unable to offer file content to queue: queue is full"); - } - } - - protected Runnable buildPollTask() { - return this::fetchAndUpdate; - } - - private boolean fetchAndUpdate() { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() - .uri(URI.create(url)) - .timeout(Duration.ofSeconds(requestTimeoutSeconds)) - .GET(); - headers.forEach(requestBuilder::header); - - HttpResponse response; - try { - log.debug("fetching response"); - response = execute(requestBuilder); - } catch (IOException e) { - log.info("could not fetch", e); - return false; - } catch (Exception e) { - log.debug("exception", e); - return false; - } - log.debug("fetched response"); - String payload = response.body(); - if (!isSuccessful(response)) { - log.info("received non-successful status code: {} {}", response.statusCode(), payload); - return false; - } else if (response.statusCode() == 304) { - log.debug("got 304 Not Modified, skipping update"); - return false; - } - if (payload == null) { - log.debug("payload is null"); - return false; - } - log.debug("adding payload to queue"); - if (!this.queue.offer(new QueuePayload(QueuePayloadType.DATA, payload))) { - log.warn("Unable to offer file content to queue: queue is full"); - return false; - } - if (payloadCacheWrapper != null) { - log.debug("scheduling cache update if needed"); - scheduler.execute(() -> - payloadCacheWrapper.updatePayloadIfNeeded(payload) - ); - } - return payload != null; - } - - private static boolean isSuccessful(HttpResponse response) { - return response.statusCode() == 200 || response.statusCode() == 304; - } - - protected HttpResponse execute(HttpRequest.Builder requestBuilder) throws IOException, InterruptedException { - if (httpCacheFetcher != null) { - return httpCacheFetcher.fetchContent(client, requestBuilder); - } - return client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); - } - - @Override - public void shutdown() throws InterruptedException { - ConcurrentUtils.shutdownAndAwaitTermination(scheduler, 10); - ConcurrentUtils.shutdownAndAwaitTermination(httpClientExecutor, 10); - } -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java deleted file mode 100644 index 0f8ff0186..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java +++ /dev/null @@ -1,125 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import java.net.URL; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import lombok.Builder; -import lombok.Getter; -import lombok.NonNull; -import lombok.SneakyThrows; - -@Getter -public class HttpConnectorOptions { - - @Builder.Default - private Integer pollIntervalSeconds = 60; - @Builder.Default - private Integer connectTimeoutSeconds = 10; - @Builder.Default - private Integer requestTimeoutSeconds = 10; - @Builder.Default - private Integer linkedBlockingQueueCapacity = 100; - @Builder.Default - private Integer scheduledThreadPoolSize = 2; - @Builder.Default - private Map headers = new HashMap<>(); - @Builder.Default - private ExecutorService httpClientExecutor = Executors.newFixedThreadPool(1); - @Builder.Default - private String proxyHost; - @Builder.Default - private Integer proxyPort; - @Builder.Default - private PayloadCacheOptions payloadCacheOptions; - @Builder.Default - private PayloadCache payloadCache; - @Builder.Default - private Boolean useHttpCache; - @NonNull - private String url; - - @Builder - public HttpConnectorOptions(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, - Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, - Map headers, ExecutorService httpClientExecutor, String proxyHost, Integer proxyPort, - PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache, Boolean useHttpCache) { - validate(url, pollIntervalSeconds, linkedBlockingQueueCapacity, scheduledThreadPoolSize, requestTimeoutSeconds, - connectTimeoutSeconds, proxyHost, proxyPort, payloadCacheOptions, payloadCache); - if (pollIntervalSeconds != null) { - this.pollIntervalSeconds = pollIntervalSeconds; - } - if (linkedBlockingQueueCapacity != null) { - this.linkedBlockingQueueCapacity = linkedBlockingQueueCapacity; - } - if (scheduledThreadPoolSize != null) { - this.scheduledThreadPoolSize = scheduledThreadPoolSize; - } - if (requestTimeoutSeconds != null) { - this.requestTimeoutSeconds = requestTimeoutSeconds; - } - if (connectTimeoutSeconds != null) { - this.connectTimeoutSeconds = connectTimeoutSeconds; - } - this.url = url; - if (headers != null) { - this.headers = headers; - } - if (httpClientExecutor != null) { - this.httpClientExecutor = httpClientExecutor; - } - if (proxyHost != null) { - this.proxyHost = proxyHost; - } - if (proxyPort != null) { - this.proxyPort = proxyPort; - } - if (payloadCache != null) { - this.payloadCache = payloadCache; - } - if (payloadCacheOptions != null) { - this.payloadCacheOptions = payloadCacheOptions; - } - if (useHttpCache != null) { - this.useHttpCache = useHttpCache; - } - } - - @SneakyThrows - private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, - Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, - String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, - PayloadCache payloadCache) { - new URL(url).toURI(); - if (linkedBlockingQueueCapacity != null && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { - throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); - } - if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { - throw new IllegalArgumentException("scheduledThreadPoolSize must be between 1 and 10"); - } - if (requestTimeoutSeconds != null && (requestTimeoutSeconds < 1 || requestTimeoutSeconds > 60)) { - throw new IllegalArgumentException("requestTimeoutSeconds must be between 1 and 60"); - } - if (connectTimeoutSeconds != null && (connectTimeoutSeconds < 1 || connectTimeoutSeconds > 60)) { - throw new IllegalArgumentException("connectTimeoutSeconds must be between 1 and 60"); - } - if (pollIntervalSeconds != null && (pollIntervalSeconds < 1 || pollIntervalSeconds > 600)) { - throw new IllegalArgumentException("pollIntervalSeconds must be between 1 and 600"); - } - if (proxyPort != null && (proxyPort < 1 || proxyPort > 65535)) { - throw new IllegalArgumentException("proxyPort must be between 1 and 65535"); - } - if (proxyHost != null && proxyPort == null ) { - throw new IllegalArgumentException("proxyPort must be set if proxyHost is set"); - } else if (proxyHost == null && proxyPort != null) { - throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); - } - if (payloadCacheOptions != null && payloadCache == null) { - throw new IllegalArgumentException("payloadCache must be set if payloadCacheOptions is set"); - } - if (payloadCache != null && payloadCacheOptions == null) { - throw new IllegalArgumentException("payloadCacheOptions must be set if payloadCache is set"); - } - } -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java deleted file mode 100644 index 31416af1e..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -public interface PayloadCache { - public void put(String payload); - public String get(); -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java deleted file mode 100644 index d29ed115d..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import lombok.Builder; -import lombok.Getter; - -/** - * Represents configuration options for caching payloads. - *

- * This class provides options to configure the caching behavior, - * specifically the interval at which the cache should be updated. - *

- *

- * The default update interval is set to 30 minutes. - * Change it typically to a value according to cache ttl and tradeoff with not updating it too much for - * corner cases. - *

- */ -@Builder -@Getter -public class PayloadCacheOptions { - - @Builder.Default - private int updateIntervalSeconds = 60 * 30; // 30 minutes -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java deleted file mode 100644 index 449cf1969..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java +++ /dev/null @@ -1,59 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -/** - * A wrapper class for managing a payload cache with a specified update interval. - * This class ensures that the cache is only updated if the specified time interval - * has passed since the last update. It logs debug messages when updates are skipped - * and error messages if the update process fails. - * Not thread-safe. - * - *

Usage involves creating an instance with {@link PayloadCacheOptions} to set - * the update interval, and then using {@link #updatePayloadIfNeeded(String)} to - * conditionally update the cache and {@link #get()} to retrieve the cached payload.

- */ -@Slf4j -public class PayloadCacheWrapper { - private long lastUpdateTimeMs; - private long updateIntervalMs; - private PayloadCache payloadCache; - - @Builder - public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloadCacheOptions) { - if (payloadCacheOptions.getUpdateIntervalSeconds() < 1) { - throw new IllegalArgumentException("pollIntervalSeconds must be larger than 0"); - } - this.updateIntervalMs = payloadCacheOptions.getUpdateIntervalSeconds() * 1000L; - this.payloadCache = payloadCache; - } - - public void updatePayloadIfNeeded(String payload) { - if ((getCurrentTimeMillis() - lastUpdateTimeMs) < updateIntervalMs) { - log.debug("not updating payload, updateIntervalMs not reached"); - return; - } - - try { - log.debug("updating payload"); - payloadCache.put(payload); - lastUpdateTimeMs = getCurrentTimeMillis(); - } catch (Exception e) { - log.error("failed updating cache", e); - } - } - - protected long getCurrentTimeMillis() { - return System.currentTimeMillis(); - } - - public String get() { - try { - return payloadCache.get(); - } catch (Exception e) { - log.error("failed getting from cache", e); - return null; - } - } -} diff --git a/providers/flagd/src/main/resources/simplelogger.properties b/providers/flagd/src/main/resources/simplelogger.properties index d9d489e82..80c478930 100644 --- a/providers/flagd/src/main/resources/simplelogger.properties +++ b/providers/flagd/src/main/resources/simplelogger.properties @@ -1,3 +1,3 @@ -org.org.slf4j.simpleLogger.defaultLogLevel=debug +org.slf4j.simpleLogger.defaultLogLevel=debug io.grpc.level=trace diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java deleted file mode 100644 index a6d0b859e..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcherTest.java +++ /dev/null @@ -1,308 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import static org.junit.Assert.assertNull; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.lang.reflect.Field; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; -import lombok.SneakyThrows; -import org.junit.jupiter.api.Test; -public class HttpCacheFetcherTest { - - @Test - public void testFirstRequestSendsNoCacheHeaders() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); - verify(requestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); - } - - @Test - public void testResponseWith200ButNoCacheHeaders() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = mock(HttpResponse.class); - HttpHeaders headersMock = mock(HttpHeaders.class); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - when(responseMock.headers()).thenReturn(headersMock); - when(headersMock.firstValue("ETag")).thenReturn(Optional.empty()); - when(headersMock.firstValue("Last-Modified")).thenReturn(Optional.empty()); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); - - assertEquals(200, response.statusCode()); - - HttpRequest.Builder secondRequestBuilderMock = mock(HttpRequest.Builder.class); - when(secondRequestBuilderMock.build()).thenReturn(requestMock); - - fetcher.fetchContent(httpClientMock, secondRequestBuilderMock); - - verify(secondRequestBuilderMock, never()).header(eq("If-None-Match"), anyString()); - verify(secondRequestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); - } - - @Test - public void testFetchContentReturnsHttpResponse() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(404); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - HttpResponse result = fetcher.fetchContent(httpClientMock, requestBuilderMock); - - assertEquals(responseMock, result); - } - - @Test - public void test200ResponseNoEtagOrLastModified() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); - cachedETagField.setAccessible(true); - assertNull(cachedETagField.get(fetcher)); - Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); - cachedLastModifiedField.setAccessible(true); - assertNull(cachedLastModifiedField.get(fetcher)); - } - - @Test - public void testUpdateCacheOn200Response() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of( - Map.of("Last-Modified", Arrays.asList("Wed, 21 Oct 2015 07:28:00 GMT"), - "ETag", Arrays.asList("etag-value")), - (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); - cachedETagField.setAccessible(true); - assertEquals("etag-value", cachedETagField.get(fetcher)); - Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); - cachedLastModifiedField.setAccessible(true); - assertEquals("Wed, 21 Oct 2015 07:28:00 GMT", cachedLastModifiedField.get(fetcher)); - } - - @Test - public void testRequestWithCachedEtagIncludesIfNoneMatchHeader() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of( - Map.of("ETag", Arrays.asList("12345")), - (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - verify(requestBuilderMock, times(1)).header("If-None-Match", "12345"); - } - - @Test - public void testNullHttpClientOrRequestBuilder() { - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - - assertThrows(NullPointerException.class, () -> { - fetcher.fetchContent(null, requestBuilderMock); - }); - - assertThrows(NullPointerException.class, () -> { - fetcher.fetchContent(mock(HttpClient.class), null); - }); - } - - @Test - public void testResponseWithUnexpectedStatusCode() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(500); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - HttpResponse response = fetcher.fetchContent(httpClientMock, requestBuilderMock); - - assertEquals(500, response.statusCode()); - verify(requestBuilderMock, never()).header(eq("If-None-Match"), anyString()); - verify(requestBuilderMock, never()).header(eq("If-Modified-Since"), anyString()); - } - - @Test - public void testRequestIncludesIfModifiedSinceHeaderWhenLastModifiedCached() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of( - Map.of("Last-Modified", Arrays.asList("Wed, 21 Oct 2015 07:28:00 GMT")), - (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - verify(requestBuilderMock).header(eq("If-Modified-Since"), eq("Wed, 21 Oct 2015 07:28:00 GMT")); - } - - @Test - public void testCalls200And304Responses() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock200 = mock(HttpResponse.class); - HttpResponse responseMock304 = mock(HttpResponse.class); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) - .thenReturn(responseMock200) - .thenReturn(responseMock304); - when(responseMock200.statusCode()).thenReturn(200); - when(responseMock304.statusCode()).thenReturn(304); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - verify(responseMock200, times(1)).statusCode(); - verify(responseMock304, times(2)).statusCode(); - } - - @Test - public void testRequestIncludesBothEtagAndLastModifiedHeaders() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of(new HashMap<>(), (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - Field cachedETagField = HttpCacheFetcher.class.getDeclaredField("cachedETag"); - cachedETagField.setAccessible(true); - cachedETagField.set(fetcher, "test-etag"); - Field cachedLastModifiedField = HttpCacheFetcher.class.getDeclaredField("cachedLastModified"); - cachedLastModifiedField.setAccessible(true); - cachedLastModifiedField.set(fetcher, "test-last-modified"); - - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - verify(requestBuilderMock).header("If-None-Match", "test-etag"); - verify(requestBuilderMock).header("If-Modified-Since", "test-last-modified"); - } - - @SneakyThrows - @Test - public void testHttpClientSendExceptionPropagation() { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))) - .thenThrow(new IOException("Network error")); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - assertThrows(IOException.class, () -> { - fetcher.fetchContent(httpClientMock, requestBuilderMock); - }); - } - - @Test - public void testOnlyEtagAndLastModifiedHeadersCached() throws Exception { - HttpClient httpClientMock = mock(HttpClient.class); - HttpRequest.Builder requestBuilderMock = mock(HttpRequest.Builder.class); - HttpRequest requestMock = mock(HttpRequest.class); - HttpResponse responseMock = spy(HttpResponse.class); - doReturn(HttpHeaders.of( - Map.of("Last-Modified", Arrays.asList("last-modified-value"), - "ETag", Arrays.asList("etag-value")), - (a, b) -> true)).when(responseMock).headers(); - - when(requestBuilderMock.build()).thenReturn(requestMock); - when(httpClientMock.send(eq(requestMock), any(HttpResponse.BodyHandler.class))).thenReturn(responseMock); - when(responseMock.statusCode()).thenReturn(200); - - HttpCacheFetcher fetcher = new HttpCacheFetcher(); - fetcher.fetchContent(httpClientMock, requestBuilderMock); - - verify(requestBuilderMock, never()).header(eq("Some-Other-Header"), anyString()); - } - -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java deleted file mode 100644 index e27a37b0c..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorIntegrationTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; -import java.util.concurrent.BlockingQueue; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Test; - -/** - * Integration test for the HttpConnector class, specifically testing the ability to fetch - * raw content from a GitHub URL. This test assumes that integration tests are enabled - * and verifies that the HttpConnector can successfully enqueue data from the specified URL. - * The test initializes the HttpConnector with specific configurations, waits for data - * to be enqueued, and asserts the expected queue size. The connector is shut down - * gracefully after the test execution. - * As this integration test using external request, it is disabled by default, and not part of the CI build. - */ -@Slf4j -class HttpConnectorIntegrationTest { - - @SneakyThrows - @Test - void testGithubRawContent() { - assumeTrue(parseBoolean("integrationTestsEnabled")); - HttpConnector connector = null; - try { - String testUrl = "https://raw.githubusercontent.com/open-feature/java-sdk-contrib/58fe5da7d4e2f6f4ae2c1caf3411a01e84a1dc1a/providers/flagd/version.txt"; - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .connectTimeoutSeconds(10) - .requestTimeoutSeconds(10) - .useHttpCache(true) - .pollIntervalSeconds(5) - .build(); - connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - BlockingQueue queue = connector.getStreamQueue(); - delay(20000); - assertEquals(1, queue.size()); - } finally { - if (connector != null) { - connector.shutdown(); - } - } - } - - public static boolean parseBoolean(String key) { - return Boolean.parseBoolean(System.getProperty(key, System.getenv(key))); - } - -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java deleted file mode 100644 index 8b6f87b77..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptionsTest.java +++ /dev/null @@ -1,406 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import java.net.MalformedURLException; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import org.junit.Test; - -public class HttpConnectorOptionsTest { - - - @Test - public void testDefaultValuesInitialization() { - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("https://example.com") - .build(); - - assertEquals(60, options.getPollIntervalSeconds().intValue()); - assertEquals(10, options.getConnectTimeoutSeconds().intValue()); - assertEquals(10, options.getRequestTimeoutSeconds().intValue()); - assertEquals(100, options.getLinkedBlockingQueueCapacity().intValue()); - assertEquals(2, options.getScheduledThreadPoolSize().intValue()); - assertNotNull(options.getHeaders()); - assertTrue(options.getHeaders().isEmpty()); - assertNotNull(options.getHttpClientExecutor()); - assertNull(options.getProxyHost()); - assertNull(options.getProxyPort()); - assertNull(options.getPayloadCacheOptions()); - assertNull(options.getPayloadCache()); - assertNull(options.getUseHttpCache()); - assertEquals("https://example.com", options.getUrl()); - } - - @Test - public void testInvalidUrlFormat() { - MalformedURLException exception = assertThrows( - MalformedURLException.class, - () -> HttpConnectorOptions.builder() - .url("invalid-url") - .build() - ); - - assertNotNull(exception); - } - - @Test - public void testCustomValuesInitialization() { - HttpConnectorOptions options = HttpConnectorOptions.builder() - .pollIntervalSeconds(120) - .connectTimeoutSeconds(20) - .requestTimeoutSeconds(30) - .linkedBlockingQueueCapacity(200) - .scheduledThreadPoolSize(5) - .url("http://example.com") - .build(); - - assertEquals(120, options.getPollIntervalSeconds().intValue()); - assertEquals(20, options.getConnectTimeoutSeconds().intValue()); - assertEquals(30, options.getRequestTimeoutSeconds().intValue()); - assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); - assertEquals(5, options.getScheduledThreadPoolSize().intValue()); - assertEquals("http://example.com", options.getUrl()); - } - - @Test - public void testCustomHeadersMap() { - Map customHeaders = new HashMap<>(); - customHeaders.put("Authorization", "Bearer token"); - customHeaders.put("Content-Type", "application/json"); - - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("http://example.com") - .headers(customHeaders) - .build(); - - assertEquals("Bearer token", options.getHeaders().get("Authorization")); - assertEquals("application/json", options.getHeaders().get("Content-Type")); - } - - @Test - public void testCustomExecutorService() { - ExecutorService customExecutor = Executors.newFixedThreadPool(5); - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("https://example.com") - .httpClientExecutor(customExecutor) - .build(); - - assertEquals(customExecutor, options.getHttpClientExecutor()); - } - - @Test - public void testSettingPayloadCacheWithValidOptions() { - PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder() - .updateIntervalSeconds(1800) - .build(); - PayloadCache payloadCache = new PayloadCache() { - private String payload; - - @Override - public void put(String payload) { - this.payload = payload; - } - - @Override - public String get() { - return this.payload; - } - }; - - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("https://example.com") - .payloadCacheOptions(cacheOptions) - .payloadCache(payloadCache) - .build(); - - assertNotNull(options.getPayloadCacheOptions()); - assertNotNull(options.getPayloadCache()); - assertEquals(1800, options.getPayloadCacheOptions().getUpdateIntervalSeconds()); - } - - @Test - public void testProxyConfigurationWithValidHostAndPort() { - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("https://example.com") - .proxyHost("proxy.example.com") - .proxyPort(8080) - .build(); - - assertEquals("proxy.example.com", options.getProxyHost()); - assertEquals(8080, options.getProxyPort().intValue()); - } - - @Test - public void testLinkedBlockingQueueCapacityOutOfRange() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .linkedBlockingQueueCapacity(0) - .build(); - }); - assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); - - exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .linkedBlockingQueueCapacity(1001) - .build(); - }); - assertEquals("linkedBlockingQueueCapacity must be between 1 and 1000", exception.getMessage()); - } - - @Test - public void testPollIntervalSecondsOutOfRange() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .pollIntervalSeconds(700) - .build(); - }); - assertEquals("pollIntervalSeconds must be between 1 and 600", exception.getMessage()); - } - - @Test - public void testAdditionalCustomValuesInitialization() { - Map headers = new HashMap<>(); - headers.put("Authorization", "Bearer token"); - ExecutorService executorService = Executors.newFixedThreadPool(2); - PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder().build(); - PayloadCache cache = new PayloadCache() { - @Override - public void put(String payload) { - // do nothing - } - @Override - public String get() { return null; } - }; - - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("https://example.com") - .pollIntervalSeconds(120) - .connectTimeoutSeconds(20) - .requestTimeoutSeconds(30) - .linkedBlockingQueueCapacity(200) - .scheduledThreadPoolSize(4) - .headers(headers) - .httpClientExecutor(executorService) - .proxyHost("proxy.example.com") - .proxyPort(8080) - .payloadCacheOptions(cacheOptions) - .payloadCache(cache) - .useHttpCache(true) - .build(); - - assertEquals(120, options.getPollIntervalSeconds().intValue()); - assertEquals(20, options.getConnectTimeoutSeconds().intValue()); - assertEquals(30, options.getRequestTimeoutSeconds().intValue()); - assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); - assertEquals(4, options.getScheduledThreadPoolSize().intValue()); - assertNotNull(options.getHeaders()); - assertEquals("Bearer token", options.getHeaders().get("Authorization")); - assertNotNull(options.getHttpClientExecutor()); - assertEquals("proxy.example.com", options.getProxyHost()); - assertEquals(8080, options.getProxyPort().intValue()); - assertNotNull(options.getPayloadCacheOptions()); - assertNotNull(options.getPayloadCache()); - assertTrue(options.getUseHttpCache()); - assertEquals("https://example.com", options.getUrl()); - } - - @Test - public void testRequestTimeoutSecondsOutOfRange() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .requestTimeoutSeconds(61) - .build(); - }); - assertEquals("requestTimeoutSeconds must be between 1 and 60", exception.getMessage()); - } - - @Test - public void testBuilderInitializesAllFields() { - Map headers = new HashMap<>(); - headers.put("Authorization", "Bearer token"); - ExecutorService executorService = Executors.newFixedThreadPool(2); - PayloadCacheOptions cacheOptions = PayloadCacheOptions.builder().build(); - PayloadCache cache = new PayloadCache() { - @Override - public void put(String payload) { - // do nothing - } - @Override - public String get() { return null; } - }; - - HttpConnectorOptions options = HttpConnectorOptions.builder() - .pollIntervalSeconds(120) - .connectTimeoutSeconds(20) - .requestTimeoutSeconds(30) - .linkedBlockingQueueCapacity(200) - .scheduledThreadPoolSize(4) - .headers(headers) - .httpClientExecutor(executorService) - .proxyHost("proxy.example.com") - .proxyPort(8080) - .payloadCacheOptions(cacheOptions) - .payloadCache(cache) - .useHttpCache(true) - .url("https://example.com") - .build(); - - assertEquals(120, options.getPollIntervalSeconds().intValue()); - assertEquals(20, options.getConnectTimeoutSeconds().intValue()); - assertEquals(30, options.getRequestTimeoutSeconds().intValue()); - assertEquals(200, options.getLinkedBlockingQueueCapacity().intValue()); - assertEquals(4, options.getScheduledThreadPoolSize().intValue()); - assertEquals(headers, options.getHeaders()); - assertEquals(executorService, options.getHttpClientExecutor()); - assertEquals("proxy.example.com", options.getProxyHost()); - assertEquals(8080, options.getProxyPort().intValue()); - assertEquals(cacheOptions, options.getPayloadCacheOptions()); - assertEquals(cache, options.getPayloadCache()); - assertTrue(options.getUseHttpCache()); - assertEquals("https://example.com", options.getUrl()); - } - - @Test - public void testScheduledThreadPoolSizeOutOfRange() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .scheduledThreadPoolSize(11) - .build(); - }); - assertEquals("scheduledThreadPoolSize must be between 1 and 10", exception.getMessage()); - } - - @Test - public void testProxyPortOutOfRange() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .proxyHost("proxy.example.com") - .proxyPort(70000) // Invalid port, out of range - .build(); - }); - assertEquals("proxyPort must be between 1 and 65535", exception.getMessage()); - } - - @Test - public void testConnectTimeoutSecondsOutOfRange() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .connectTimeoutSeconds(0) - .build(); - }); - assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); - - exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .connectTimeoutSeconds(61) - .build(); - }); - assertEquals("connectTimeoutSeconds must be between 1 and 60", exception.getMessage()); - } - - @Test - public void testProxyPortWithoutProxyHost() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .proxyPort(8080) - .build(); - }); - assertEquals("proxyHost must be set if proxyPort is set", exception.getMessage()); - } - - @Test - public void testDefaultValuesWhenNullParametersProvided() { - HttpConnectorOptions options = HttpConnectorOptions.builder() - .url("https://example.com") - .pollIntervalSeconds(null) - .linkedBlockingQueueCapacity(null) - .scheduledThreadPoolSize(null) - .requestTimeoutSeconds(null) - .connectTimeoutSeconds(null) - .headers(null) - .httpClientExecutor(null) - .proxyHost(null) - .proxyPort(null) - .payloadCacheOptions(null) - .payloadCache(null) - .useHttpCache(null) - .build(); - - assertEquals(60, options.getPollIntervalSeconds().intValue()); - assertEquals(10, options.getConnectTimeoutSeconds().intValue()); - assertEquals(10, options.getRequestTimeoutSeconds().intValue()); - assertEquals(100, options.getLinkedBlockingQueueCapacity().intValue()); - assertEquals(2, options.getScheduledThreadPoolSize().intValue()); - assertNotNull(options.getHeaders()); - assertTrue(options.getHeaders().isEmpty()); - assertNotNull(options.getHttpClientExecutor()); - assertNull(options.getProxyHost()); - assertNull(options.getProxyPort()); - assertNull(options.getPayloadCacheOptions()); - assertNull(options.getPayloadCache()); - assertNull(options.getUseHttpCache()); - assertEquals("https://example.com", options.getUrl()); - } - - @Test - public void testProxyHostWithoutProxyPort() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .proxyHost("proxy.example.com") - .build(); - }); - assertEquals("proxyPort must be set if proxyHost is set", exception.getMessage()); - } - - @Test - public void testSettingPayloadCacheWithoutOptions() { - PayloadCache mockPayloadCache = new PayloadCache() { - @Override - public void put(String payload) { - // Mock implementation - } - - @Override - public String get() { - return "mockPayload"; - } - }; - - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .payloadCache(mockPayloadCache) - .build(); - }); - - assertEquals("payloadCacheOptions must be set if payloadCache is set", exception.getMessage()); - } - - @Test - public void testPayloadCacheOptionsWithoutPayloadCache() { - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { - HttpConnectorOptions.builder() - .url("https://example.com") - .payloadCacheOptions(PayloadCacheOptions.builder().build()) - .build(); - }); - assertEquals("payloadCache must be set if payloadCacheOptions is set", exception.getMessage()); - } -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java deleted file mode 100644 index a62bc8ecd..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/HttpConnectorTest.java +++ /dev/null @@ -1,412 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; - -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; -import java.io.IOException; -import java.lang.reflect.Field; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import lombok.SneakyThrows; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -@Slf4j -class HttpConnectorTest { - - @SneakyThrows - @Test - void testGetStreamQueueInitialAndScheduledPolls() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpResponse mockResponse = mock(HttpResponse.class); - when(mockResponse.statusCode()).thenReturn(200); - when(mockResponse.body()).thenReturn("test data"); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(mockResponse); - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .httpClientExecutor(Executors.newSingleThreadExecutor()) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - BlockingQueue queue = connector.getStreamQueue(); - - assertFalse(queue.isEmpty()); - QueuePayload payload = queue.poll(); - assertNotNull(payload); - assertEquals(QueuePayloadType.DATA, payload.getType()); - assertEquals("test data", payload.getFlagData()); - - connector.shutdown(); - } - - @SneakyThrows - @Test - void testBuildPollTaskFetchesDataAndAddsToQueue() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpResponse mockResponse = mock(HttpResponse.class); - when(mockResponse.statusCode()).thenReturn(200); - when(mockResponse.body()).thenReturn("test data"); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(mockResponse); - - PayloadCache payloadCache = new PayloadCache() { - private String payload; - @Override - public void put(String payload) { - this.payload = payload; - } - - @Override - public String get() { - return payload; - } - }; - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .proxyHost("proxy-host") - .proxyPort(8080) - .useHttpCache(true) - .payloadCache(payloadCache) - .payloadCacheOptions(PayloadCacheOptions.builder().build()) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - connector.init(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - Runnable pollTask = connector.buildPollTask(); - pollTask.run(); - - Field queueField = HttpConnector.class.getDeclaredField("queue"); - queueField.setAccessible(true); - BlockingQueue queue = (BlockingQueue) queueField.get(connector); - assertFalse(queue.isEmpty()); - QueuePayload payload = queue.poll(); - assertNotNull(payload); - assertEquals(QueuePayloadType.DATA, payload.getType()); - assertEquals("test data", payload.getFlagData()); - } - - @SneakyThrows - @Test - void testHttpRequestIncludesHeaders() { - String testUrl = "http://example.com"; - Map testHeaders = new HashMap<>(); - testHeaders.put("Authorization", "Bearer token"); - testHeaders.put("Content-Type", "application/json"); - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .headers(testHeaders) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field headersField = HttpConnector.class.getDeclaredField("headers"); - headersField.setAccessible(true); - Map headers = (Map) headersField.get(connector); - assertNotNull(headers); - assertEquals(2, headers.size()); - assertEquals("Bearer token", headers.get("Authorization")); - assertEquals("application/json", headers.get("Content-Type")); - } - - @SneakyThrows - @Test - void testSuccessfulHttpResponseAddsDataToQueue() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpResponse mockResponse = mock(HttpResponse.class); - when(mockResponse.statusCode()).thenReturn(200); - when(mockResponse.body()).thenReturn("test data"); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(mockResponse); - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - BlockingQueue queue = connector.getStreamQueue(); - - assertFalse(queue.isEmpty()); - QueuePayload payload = queue.poll(); - assertNotNull(payload); - assertEquals(QueuePayloadType.DATA, payload.getType()); - assertEquals("test data", payload.getFlagData()); - } - - @SneakyThrows - @Test - void testInitFailureUsingCache() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpResponse mockResponse = mock(HttpResponse.class); - when(mockResponse.statusCode()).thenReturn(200); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenThrow(new IOException("Simulated IO Exception")); - - final String cachedData = "cached data"; - PayloadCache payloadCache = new PayloadCache() { - @Override - public void put(String payload) { - // do nothing - } - - @Override - public String get() { - return cachedData; - } - }; - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .payloadCache(payloadCache) - .payloadCacheOptions(PayloadCacheOptions.builder().build()) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - BlockingQueue queue = connector.getStreamQueue(); - - assertFalse(queue.isEmpty()); - QueuePayload payload = queue.poll(); - assertNotNull(payload); - assertEquals(QueuePayloadType.DATA, payload.getType()); - assertEquals(cachedData, payload.getFlagData()); - } - - @SneakyThrows - @Test - void testQueueBecomesFull() { - String testUrl = "http://example.com"; - int queueCapacity = 1; - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .linkedBlockingQueueCapacity(queueCapacity) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - BlockingQueue queue = connector.getStreamQueue(); - - queue.offer(new QueuePayload(QueuePayloadType.DATA, "test data 1")); - - boolean wasOffered = queue.offer(new QueuePayload(QueuePayloadType.DATA, "test data 2")); - - assertFalse(wasOffered, "Queue should be full and not accept more items"); - } - - @SneakyThrows - @Test - void testShutdownProperlyTerminatesSchedulerAndHttpClientExecutor() throws InterruptedException { - ExecutorService mockHttpClientExecutor = mock(ExecutorService.class); - ScheduledExecutorService mockScheduler = mock(ScheduledExecutorService.class); - String testUrl = "http://example.com"; - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .httpClientExecutor(mockHttpClientExecutor) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field schedulerField = HttpConnector.class.getDeclaredField("scheduler"); - schedulerField.setAccessible(true); - schedulerField.set(connector, mockScheduler); - - connector.shutdown(); - - Mockito.verify(mockScheduler).shutdown(); - Mockito.verify(mockHttpClientExecutor).shutdown(); - } - - @SneakyThrows - @Test - void testHttpResponseNonSuccessStatusCode() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpResponse mockResponse = mock(HttpResponse.class); - when(mockResponse.statusCode()).thenReturn(404); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(mockResponse); - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - BlockingQueue queue = connector.getStreamQueue(); - - assertTrue(queue.isEmpty(), "Queue should be empty when response status is non-200"); - } - - @SneakyThrows - @Test - void testHttpRequestFailsWithException() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenThrow(new RuntimeException("Test exception")); - - BlockingQueue queue = connector.getStreamQueue(); - - assertTrue(queue.isEmpty(), "Queue should be empty when request fails with exception"); - } - - @SneakyThrows - @Test - void testHttpRequestFailsWithIoexception() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenThrow(new IOException("Simulated IO Exception")); - - connector.getStreamQueue(); - - Field queueField = HttpConnector.class.getDeclaredField("queue"); - queueField.setAccessible(true); - BlockingQueue queue = (BlockingQueue) queueField.get(connector); - assertTrue(queue.isEmpty(), "Queue should be empty due to IOException"); - } - - @SneakyThrows - @Test - void testScheduledPollingContinuesAtFixedIntervals() { - String testUrl = "http://exampOle.com"; - HttpResponse mockResponse = mock(HttpResponse.class); - when(mockResponse.statusCode()).thenReturn(200); - when(mockResponse.body()).thenReturn("test data"); - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .build(); - HttpConnector connector = spy(HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build()); - - doReturn(mockResponse).when(connector).execute(any()); - - BlockingQueue queue = connector.getStreamQueue(); - - delay(2000); - assertFalse(queue.isEmpty()); - QueuePayload payload = queue.poll(); - assertNotNull(payload); - assertEquals(QueuePayloadType.DATA, payload.getType()); - assertEquals("test data", payload.getFlagData()); - - connector.shutdown(); - } - - @SneakyThrows - @Test - void testQueuePayloadTypeSetToDataOnSuccess() { - String testUrl = "http://example.com"; - HttpClient mockClient = mock(HttpClient.class); - HttpResponse mockResponse = mock(HttpResponse.class); - - when(mockResponse.statusCode()).thenReturn(200); - when(mockResponse.body()).thenReturn("response body"); - when(mockClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(mockResponse); - - HttpConnectorOptions httpConnectorOptions = HttpConnectorOptions.builder() - .url(testUrl) - .build(); - HttpConnector connector = HttpConnector.builder() - .httpConnectorOptions(httpConnectorOptions) - .build(); - - Field clientField = HttpConnector.class.getDeclaredField("client"); - clientField.setAccessible(true); - clientField.set(connector, mockClient); - - BlockingQueue queue = connector.getStreamQueue(); - - QueuePayload payload = queue.poll(1, TimeUnit.SECONDS); - assertNotNull(payload); - assertEquals(QueuePayloadType.DATA, payload.getType()); - assertEquals("response body", payload.getFlagData()); - } - - @SneakyThrows - protected static void delay(long ms) { - Thread.sleep(ms); - } - -} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java deleted file mode 100644 index 65f92e0ae..000000000 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapperTest.java +++ /dev/null @@ -1,267 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http; - -import static dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.http.HttpConnectorTest.delay; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.lang.reflect.Field; -import lombok.SneakyThrows; -import org.junit.jupiter.api.Test; - - -public class PayloadCacheWrapperTest { - - @Test - public void testConstructorInitializesWithValidParameters() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - - assertNotNull(wrapper); - - String testPayload = "test-payload"; - wrapper.updatePayloadIfNeeded(testPayload); - wrapper.get(); - - verify(mockCache).put(testPayload); - verify(mockCache).get(); - } - - @Test - public void testConstructorThrowsExceptionForInvalidInterval() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(0) - .build(); - - PayloadCacheWrapper.PayloadCacheWrapperBuilder payloadCacheWrapperBuilder = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options); - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - payloadCacheWrapperBuilder::build - ); - - assertEquals("pollIntervalSeconds must be larger than 0", exception.getMessage()); - } - - @Test - public void testUpdateSkipsWhenIntervalNotPassed() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - - String initialPayload = "initial-payload"; - wrapper.updatePayloadIfNeeded(initialPayload); - - String newPayload = "new-payload"; - wrapper.updatePayloadIfNeeded(newPayload); - - verify(mockCache, times(1)).put(initialPayload); - verify(mockCache, never()).put(newPayload); - } - - @Test - public void testUpdatePayloadIfNeededHandlesPutException() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - String testPayload = "test-payload"; - - doThrow(new RuntimeException("put exception")).when(mockCache).put(testPayload); - - wrapper.updatePayloadIfNeeded(testPayload); - - verify(mockCache).put(testPayload); - } - - @Test - public void testUpdatePayloadIfNeededUpdatesCacheAfterInterval() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(1) // 1 second interval for quick test - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - - String initialPayload = "initial-payload"; - String newPayload = "new-payload"; - - wrapper.updatePayloadIfNeeded(initialPayload); - delay(1100); - wrapper.updatePayloadIfNeeded(newPayload); - - verify(mockCache).put(initialPayload); - verify(mockCache).put(newPayload); - } - - @Test - public void testGetReturnsNullWhenCacheGetThrowsException() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - - when(mockCache.get()).thenThrow(new RuntimeException("Cache get failed")); - - String result = wrapper.get(); - - assertNull(result); - - verify(mockCache).get(); - } - - @Test - public void test_get_returns_cached_payload() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - String expectedPayload = "cached-payload"; - when(mockCache.get()).thenReturn(expectedPayload); - - String actualPayload = wrapper.get(); - - assertEquals(expectedPayload, actualPayload); - - verify(mockCache).get(); - } - - @Test - public void test_first_call_updates_cache() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - - String testPayload = "initial-payload"; - - wrapper.updatePayloadIfNeeded(testPayload); - - verify(mockCache).put(testPayload); - } - - @Test - public void test_update_payload_once_within_interval() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(1) // 1 second interval - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - - String testPayload = "test-payload"; - - wrapper.updatePayloadIfNeeded(testPayload); - wrapper.updatePayloadIfNeeded(testPayload); - - verify(mockCache, times(1)).put(testPayload); - } - - @SneakyThrows - @Test - public void test_last_update_time_ms_updated_after_successful_cache_update() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build(); - String testPayload = "test-payload"; - - wrapper.updatePayloadIfNeeded(testPayload); - - verify(mockCache).put(testPayload); - - Field lastUpdateTimeMsField = PayloadCacheWrapper.class.getDeclaredField("lastUpdateTimeMs"); - lastUpdateTimeMsField.setAccessible(true); - long lastUpdateTimeMs = (Long) lastUpdateTimeMsField.get(wrapper); - - assertTrue(System.currentTimeMillis() - lastUpdateTimeMs < 1000, - "lastUpdateTimeMs should be updated to current time"); - } - - @Test - public void test_update_payload_if_needed_respects_update_interval() { - PayloadCache mockCache = mock(PayloadCache.class); - PayloadCacheOptions options = PayloadCacheOptions.builder() - .updateIntervalSeconds(600) - .build(); - PayloadCacheWrapper wrapper = spy(PayloadCacheWrapper.builder() - .payloadCache(mockCache) - .payloadCacheOptions(options) - .build()); - - String testPayload = "test-payload"; - long initialTime = System.currentTimeMillis(); - long updateIntervalMs = options.getUpdateIntervalSeconds() * 1000L; - - doReturn(initialTime).when(wrapper).getCurrentTimeMillis(); - - // First update should succeed - wrapper.updatePayloadIfNeeded(testPayload); - - // Verify the payload was updated - verify(mockCache).put(testPayload); - - // Attempt to update before interval has passed - doReturn(initialTime + updateIntervalMs - 1).when(wrapper).getCurrentTimeMillis(); - wrapper.updatePayloadIfNeeded(testPayload); - - // Verify the payload was not updated again - verify(mockCache, times(1)).put(testPayload); - - // Update after interval has passed - doReturn(initialTime + updateIntervalMs + 1).when(wrapper).getCurrentTimeMillis(); - wrapper.updatePayloadIfNeeded(testPayload); - - // Verify the payload was updated again - verify(mockCache, times(2)).put(testPayload); - } - -} From 59ffacb41273a3eaa4926df28a7d0fa8b6e37f21 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Thu, 24 Apr 2025 17:47:28 +0300 Subject: [PATCH 09/18] move to tool - draft - cont. Signed-off-by: liran2000 --- providers/flagd/README.md | 53 +++---------------- .../test/resources/simplelogger.properties | 2 +- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/providers/flagd/README.md b/providers/flagd/README.md index f848ca5f6..de0d8a091 100644 --- a/providers/flagd/README.md +++ b/providers/flagd/README.md @@ -54,45 +54,6 @@ The value is updated with every (re)connection to the sync implementation. This can be used to enrich evaluations with such data. If the `in-process` mode is not used, and before the provider is ready, the `getSyncMetadata` returns an empty map. -#### Http Connector -HttpConnector is responsible for polling data from a specified URL at regular intervals. -It is leveraging Http cache mechanism with 'ETag' header, then when receiving 304 Not Modified response, -reducing traffic, reducing rate limits effects and changes updates. Can be enabled via useHttpCache option. -The implementation is using Java HttpClient. - -##### Use cases and benefits -* Reduce infrastructure/devops work, without additional containers needed. -* Use as an additional provider for fallback / internal backup service via multi-provider. - -##### What happens if the Http source is down when application is starting ? - -It supports optional fail-safe initialization via cache, such that on initial fetch error following by -source downtime window, initial payload is taken from cache to avoid starting with default values until -the source is back up. Therefore, the cache ttl expected to be higher than the expected source -down-time to recover from during initialization. - -##### Sample flow -Sample flow can use: -- Github as the flags payload source. -- Redis cache as a fail-safe initialization cache. - -Sample flow of initialization during Github down-time window, showing that application can still use flags -values as fetched from cache. -```mermaid -sequenceDiagram - participant Provider - participant Github - participant Redis - - break source downtime - Provider->>Github: initialize - Github->>Provider: failure - end - Provider->>Redis: fetch - Redis->>Provider: last payload - -``` - ### Offline mode (File resolver) In-process resolvers can also work in an offline mode. @@ -113,17 +74,15 @@ This mode is useful for local development, tests and offline applications. #### Custom Connector You can include a custom connector as a configuration option to customize how the in-process resolver fetches flags. -The custom connector must implement the [QueueSource interface](https://github.com/open-feature/java-sdk-contrib/blob/main/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/QueueSource.java). +The custom connector must implement the [Connector interface](https://github.com/open-feature/java-sdk-contrib/blob/main/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/Connector.java). ```java -QueueSource connector = HttpConnector.builder() - .url(testUrl) - .build(); +Connector myCustomConnector = new MyCustomConnector(); FlagdOptions options = - FlagdOptions.builder() - .resolverType(Config.Resolver.IN_PROCESS) - .customConnector(myCustomConnector) - .build(); + FlagdOptions.builder() + .resolverType(Config.Resolver.IN_PROCESS) + .customConnector(myCustomConnector) + .build(); FlagdProvider flagdProvider = new FlagdProvider(options); ``` diff --git a/tools/flagd-http-connector/src/test/resources/simplelogger.properties b/tools/flagd-http-connector/src/test/resources/simplelogger.properties index d9d489e82..80c478930 100644 --- a/tools/flagd-http-connector/src/test/resources/simplelogger.properties +++ b/tools/flagd-http-connector/src/test/resources/simplelogger.properties @@ -1,3 +1,3 @@ -org.org.slf4j.simpleLogger.defaultLogLevel=debug +org.slf4j.simpleLogger.defaultLogLevel=debug io.grpc.level=trace From 64054725bf84418d1724cd42b24adb8e9cd0ea43 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sat, 26 Apr 2025 16:34:57 +0300 Subject: [PATCH 10/18] add Configuration section to readme Signed-off-by: liran2000 --- tools/flagd-http-connector/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tools/flagd-http-connector/README.md b/tools/flagd-http-connector/README.md index f6925f4bf..78b459af8 100644 --- a/tools/flagd-http-connector/README.md +++ b/tools/flagd-http-connector/README.md @@ -79,3 +79,26 @@ FlagdOptions options = FlagdProvider flagdProvider = new FlagdProvider(options); ``` + +### Configuration +The Http Connector can be configured using the following properties in the `HttpConnectorOptions` class.: + +| Property Name | Type | Description | +|-------------------------------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| url | String | The URL to poll for updates. This is a required field. | +| pollIntervalSeconds | Integer | The interval in seconds at which the connector will poll the URL for updates. Default is 60 seconds. | +| connectTimeoutSeconds | Integer | The timeout in seconds for establishing a connection to the URL. Default is 10 seconds. | +| requestTimeoutSeconds | Integer | The timeout in seconds for the request to complete. Default is 10 seconds. | +| linkedBlockingQueueCapacity | Integer | The capacity of the linked blocking queue used for processing requests. Default is 100. | +| scheduledThreadPoolSize | Integer | The size of the scheduled thread pool used for processing requests. Default is 2. | +| headers | Map | A map of headers to be included in the request. Default is an empty map. | +| httpClientExecutor | ExecutorService | The executor service used for making HTTP requests. Default is a fixed thread pool with 1 thread. | +| proxyHost | String | The host of the proxy server to use for requests. Default is null. | +| proxyPort | Integer | The port of the proxy server to use for requests. Default is null. | +| payloadCacheOptions | PayloadCacheOptions | Options for configuring the payload cache. Default is null. | +| payloadCache | PayloadCache | The payload cache to use for caching responses. Default is null. | +| useHttpCache | Boolean | Whether to use HTTP caching for the requests. Default is false. | +| PayloadCacheOptions.updateIntervalSeconds | Integer | The interval, in seconds, at which the cache is updated. By default, this is set to 30 minutes. The goal is to avoid overloading fallback cache writes, since the cache serves only as a fallback mechanism. Typically, this value can be tuned to be shorter than the cache's TTL, balancing the need to minimize unnecessary updates while still handling edge cases effectively. | + + + From 512d3a2dc4f5e0b4368b1bb3c82219425fa5e504 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sat, 26 Apr 2025 16:50:12 +0300 Subject: [PATCH 11/18] readme update Signed-off-by: liran2000 --- tools/flagd-http-connector/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/flagd-http-connector/README.md b/tools/flagd-http-connector/README.md index 78b459af8..39a3f3336 100644 --- a/tools/flagd-http-connector/README.md +++ b/tools/flagd-http-connector/README.md @@ -4,8 +4,7 @@ Http Connector is a tool for [flagd](https://github.com/open-feature/flagd) in-process resolver. This mode performs flag evaluations locally (in-process). -Flag configurations for evaluation are obtained via gRPC protocol using -[sync protobuf schema](https://buf.build/open-feature/flagd/file/main:sync/v1/sync_service.proto) service definition. +Flag configurations for evaluation are obtained via Http. ## Http Connector functionality From d615b0c85f3299bdc3e85050e3600e358c61432f Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 15:03:36 +0300 Subject: [PATCH 12/18] updates Signed-off-by: liran2000 --- tools/flagd-http-connector/pom.xml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tools/flagd-http-connector/pom.xml b/tools/flagd-http-connector/pom.xml index 8945c7e97..0f979b14d 100644 --- a/tools/flagd-http-connector/pom.xml +++ b/tools/flagd-http-connector/pom.xml @@ -9,7 +9,7 @@ ../../pom.xml dev.openfeature.contrib.tools - flagd-http-connector + flagdhttpconnector 0.0.1 flagd-http-connector @@ -38,17 +38,6 @@ 3.17.0 - - org.junit.jupiter - junit-jupiter-api - test - - - org.junit.jupiter - junit-jupiter-api - test - - From 36f5bc92a767c6d0637ea8a13c86ac56568b6f7e Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 15:09:37 +0300 Subject: [PATCH 13/18] updates Signed-off-by: liran2000 --- .../process/storage/connector/sync/http/HttpCacheFetcher.java | 4 ---- .../process/storage/connector/sync/http/HttpConnector.java | 2 +- .../process/storage/connector/sync/http/PayloadCache.java | 4 ++-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java index 0cc069032..b457b4bd2 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java @@ -13,10 +13,6 @@ * Updates the cached ETag and Last-Modified values upon receiving a 200 OK response. * It does not store the cached response, assuming not needed after first successful fetching. * Non thread-safe. - * - * @param httpClient the HTTP client used to send the request - * @param httpRequestBuilder the builder for constructing the HTTP request - * @return the HTTP response received from the server */ @Slf4j public class HttpCacheFetcher { diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java index 3eb8a116d..a2346d492 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java @@ -161,7 +161,7 @@ private boolean fetchAndUpdate() { payloadCacheWrapper.updatePayloadIfNeeded(payload) ); } - return payload != null; + return true; } private static boolean isSuccessful(HttpResponse response) { diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java index 4af5f5f1d..da67b23e6 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java @@ -1,6 +1,6 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; public interface PayloadCache { - public void put(String payload); - public String get(); + void put(String payload); + String get(); } From ec5f1581a0e5e2679052e76fe1c9420d9cb653ef Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 15:33:27 +0300 Subject: [PATCH 14/18] updates Signed-off-by: liran2000 --- .../checkstyle-suppressions.xml | 8 + tools/flagd-http-connector/checkstyle.xml | 442 ++++++++++++++++++ .../test/resources/simplelogger.properties | 5 +- 3 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 tools/flagd-http-connector/checkstyle-suppressions.xml create mode 100644 tools/flagd-http-connector/checkstyle.xml diff --git a/tools/flagd-http-connector/checkstyle-suppressions.xml b/tools/flagd-http-connector/checkstyle-suppressions.xml new file mode 100644 index 000000000..3d374f555 --- /dev/null +++ b/tools/flagd-http-connector/checkstyle-suppressions.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/tools/flagd-http-connector/checkstyle.xml b/tools/flagd-http-connector/checkstyle.xml new file mode 100644 index 000000000..f8ca27ad6 --- /dev/null +++ b/tools/flagd-http-connector/checkstyle.xml @@ -0,0 +1,442 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/flagd-http-connector/src/test/resources/simplelogger.properties b/tools/flagd-http-connector/src/test/resources/simplelogger.properties index 80c478930..50b5bd1bd 100644 --- a/tools/flagd-http-connector/src/test/resources/simplelogger.properties +++ b/tools/flagd-http-connector/src/test/resources/simplelogger.properties @@ -1,3 +1,2 @@ -org.slf4j.simpleLogger.defaultLogLevel=debug - -io.grpc.level=trace +org.slf4j.simpleLogger.defaultLogLevel=info +io.grpc.level=info From 17d5d0ba7d8a24914612446daaa182c238ccd4d5 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 15:55:42 +0300 Subject: [PATCH 15/18] updates Signed-off-by: liran2000 --- .../connector/sync/http/HttpCacheFetcher.java | 6 ++++++ .../storage/connector/sync/http/HttpConnector.java | 11 ++++++++--- .../connector/sync/http/HttpConnectorOptions.java | 11 +++++++++-- .../storage/connector/sync/http/PayloadCache.java | 4 ++++ .../connector/sync/http/PayloadCacheOptions.java | 8 ++++---- .../connector/sync/http/PayloadCacheWrapper.java | 13 +++++++++++++ .../connector/sync/http/util/ConcurrentUtils.java | 4 ++-- 7 files changed, 46 insertions(+), 11 deletions(-) diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java index b457b4bd2..fbf158ebf 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java @@ -19,6 +19,12 @@ public class HttpCacheFetcher { private String cachedETag = null; private String cachedLastModified = null; + /** + * Fetches content from the given HTTP endpoint using the provided HttpClient and HttpRequest.Builder. + * @param httpClient the HttpClient to use for the request + * @param httpRequestBuilder the HttpRequest.Builder to build the request + * @return the HttpResponse containing the response body as a String + */ @SneakyThrows public HttpResponse fetchContent(HttpClient httpClient, HttpRequest.Builder httpRequestBuilder) { if (cachedETag != null) { diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java index a2346d492..baeb68acf 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java @@ -5,6 +5,7 @@ import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; +import dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.util.ConcurrentUtils; import java.io.IOException; import java.net.InetSocketAddress; import java.net.ProxySelector; @@ -20,7 +21,6 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.util.ConcurrentUtils; import lombok.Builder; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -33,7 +33,7 @@ * It uses a ScheduledExecutorService to schedule polling tasks and an ExecutorService for HTTP client execution. * The class also provides methods to initialize, retrieve the stream queue, and shutdown the connector gracefully. * It supports optional fail-safe initialization via cache. - * + *

* See readme - Http Connector section. */ @Slf4j @@ -53,6 +53,10 @@ public class HttpConnector implements QueueSource { @NonNull private String url; + /** + * HttpConnector constructor. + * @param httpConnectorOptions options for configuring the HttpConnector. + */ @Builder public HttpConnector(HttpConnectorOptions httpConnectorOptions) { this.pollIntervalSeconds = httpConnectorOptions.getPollIntervalSeconds(); @@ -168,7 +172,8 @@ private static boolean isSuccessful(HttpResponse response) { return response.statusCode() == 200 || response.statusCode() == 304; } - protected HttpResponse execute(HttpRequest.Builder requestBuilder) throws IOException, InterruptedException { + protected HttpResponse execute(HttpRequest.Builder requestBuilder) + throws IOException, InterruptedException { if (httpCacheFetcher != null) { return httpCacheFetcher.fetchContent(client, requestBuilder); } diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java index 740d54ddf..a3494e724 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java @@ -10,6 +10,9 @@ import lombok.NonNull; import lombok.SneakyThrows; +/** + * Represents configuration options for the HTTP connector. + */ @Getter public class HttpConnectorOptions { @@ -40,6 +43,9 @@ public class HttpConnectorOptions { @NonNull private String url; + /** + * HttpConnectorOptions constructor. + */ @Builder public HttpConnectorOptions(Integer pollIntervalSeconds, Integer linkedBlockingQueueCapacity, Integer scheduledThreadPoolSize, Integer requestTimeoutSeconds, Integer connectTimeoutSeconds, String url, @@ -92,7 +98,8 @@ private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlo String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache) { new URL(url).toURI(); - if (linkedBlockingQueueCapacity != null && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { + if (linkedBlockingQueueCapacity != null && + (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); } if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { @@ -110,7 +117,7 @@ private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlo if (proxyPort != null && (proxyPort < 1 || proxyPort > 65535)) { throw new IllegalArgumentException("proxyPort must be between 1 and 65535"); } - if (proxyHost != null && proxyPort == null ) { + if (proxyHost != null && proxyPort == null) { throw new IllegalArgumentException("proxyPort must be set if proxyHost is set"); } else if (proxyHost == null && proxyPort != null) { throw new IllegalArgumentException("proxyHost must be set if proxyPort is set"); diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java index da67b23e6..25149a938 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java @@ -1,6 +1,10 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http; +/** + * Interface for a simple payload cache. + */ public interface PayloadCache { + void put(String payload); String get(); } diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java index 9ca0dabcc..cc5edd15c 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java @@ -5,12 +5,12 @@ /** * Represents configuration options for caching payloads. - *

- * This class provides options to configure the caching behavior, + * + *

This class provides options to configure the caching behavior, * specifically the interval at which the cache should be updated. *

- *

- * The default update interval is set to 30 minutes. + * + *

The default update interval is set to 30 minutes. * Change it typically to a value according to cache ttl and tradeoff with not updating it too much for * corner cases. *

diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java index be213403e..080493539 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java @@ -20,6 +20,11 @@ public class PayloadCacheWrapper { private long updateIntervalMs; private PayloadCache payloadCache; + /** + * Constructor for PayloadCacheWrapper. + * @param payloadCache the payload cache to be used + * @param payloadCacheOptions the options for configuring the cache + */ @Builder public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloadCacheOptions) { if (payloadCacheOptions.getUpdateIntervalSeconds() < 1) { @@ -29,6 +34,10 @@ public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloa this.payloadCache = payloadCache; } + /** + * Updates the payload in the cache if the specified update interval has passed + * @param payload the payload to be cached + */ public void updatePayloadIfNeeded(String payload) { if ((getCurrentTimeMillis() - lastUpdateTimeMs) < updateIntervalMs) { log.debug("not updating payload, updateIntervalMs not reached"); @@ -48,6 +57,10 @@ protected long getCurrentTimeMillis() { return System.currentTimeMillis(); } + /** + * Retrieves the cached payload. + * @return the cached payload + */ public String get() { try { return payloadCache.get(); diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java index e7ccbc3b9..29aefd46f 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/util/ConcurrentUtils.java @@ -1,10 +1,10 @@ package dev.openfeature.contrib.tools.flagd.resolver.process.storage.connector.sync.http.util; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import lombok.AccessLevel; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeUnit; /** * Concurrent / Concurrency utilities. From 1a48989d7520cc0f902dc8aa7af190962d5bd282 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 16:00:33 +0300 Subject: [PATCH 16/18] updates Signed-off-by: liran2000 --- .../process/storage/connector/sync/http/HttpConnector.java | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java index baeb68acf..262bf872c 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java @@ -33,7 +33,6 @@ * It uses a ScheduledExecutorService to schedule polling tasks and an ExecutorService for HTTP client execution. * The class also provides methods to initialize, retrieve the stream queue, and shutdown the connector gracefully. * It supports optional fail-safe initialization via cache. - *

* See readme - Http Connector section. */ @Slf4j From 273f2fcc7eb43526ff91c7c534356c5f4fb047c0 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 16:15:07 +0300 Subject: [PATCH 17/18] updates Signed-off-by: liran2000 --- .../process/storage/connector/sync/http/HttpCacheFetcher.java | 1 + .../process/storage/connector/sync/http/HttpConnector.java | 1 + .../storage/connector/sync/http/HttpConnectorOptions.java | 4 ++-- .../process/storage/connector/sync/http/PayloadCache.java | 1 + .../storage/connector/sync/http/PayloadCacheOptions.java | 2 +- .../storage/connector/sync/http/PayloadCacheWrapper.java | 3 +++ 6 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java index fbf158ebf..1096f0365 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpCacheFetcher.java @@ -21,6 +21,7 @@ public class HttpCacheFetcher { /** * Fetches content from the given HTTP endpoint using the provided HttpClient and HttpRequest.Builder. + * * @param httpClient the HttpClient to use for the request * @param httpRequestBuilder the HttpRequest.Builder to build the request * @return the HttpResponse containing the response body as a String diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java index 262bf872c..836dcf930 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnector.java @@ -54,6 +54,7 @@ public class HttpConnector implements QueueSource { /** * HttpConnector constructor. + * * @param httpConnectorOptions options for configuring the HttpConnector. */ @Builder diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java index a3494e724..87a6efacb 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/HttpConnectorOptions.java @@ -98,8 +98,8 @@ private void validate(String url, Integer pollIntervalSeconds, Integer linkedBlo String proxyHost, Integer proxyPort, PayloadCacheOptions payloadCacheOptions, PayloadCache payloadCache) { new URL(url).toURI(); - if (linkedBlockingQueueCapacity != null && - (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { + if (linkedBlockingQueueCapacity != null + && (linkedBlockingQueueCapacity < 1 || linkedBlockingQueueCapacity > 1000)) { throw new IllegalArgumentException("linkedBlockingQueueCapacity must be between 1 and 1000"); } if (scheduledThreadPoolSize != null && (scheduledThreadPoolSize < 1 || scheduledThreadPoolSize > 10)) { diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java index 25149a938..6aed35754 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCache.java @@ -6,5 +6,6 @@ public interface PayloadCache { void put(String payload); + String get(); } diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java index cc5edd15c..4431b5ef5 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheOptions.java @@ -10,7 +10,7 @@ * specifically the interval at which the cache should be updated. *

* - *

The default update interval is set to 30 minutes. + *

The default update interval is set to 30 minutes. * Change it typically to a value according to cache ttl and tradeoff with not updating it too much for * corner cases. *

diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java index 080493539..ebaa53017 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java @@ -22,6 +22,7 @@ public class PayloadCacheWrapper { /** * Constructor for PayloadCacheWrapper. + * * @param payloadCache the payload cache to be used * @param payloadCacheOptions the options for configuring the cache */ @@ -36,6 +37,7 @@ public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloa /** * Updates the payload in the cache if the specified update interval has passed + * * @param payload the payload to be cached */ public void updatePayloadIfNeeded(String payload) { @@ -59,6 +61,7 @@ protected long getCurrentTimeMillis() { /** * Retrieves the cached payload. + * * @return the cached payload */ public String get() { From 8fac5956372b299c419335e2173967eb8d5076d6 Mon Sep 17 00:00:00 2001 From: liran2000 Date: Sun, 27 Apr 2025 16:22:42 +0300 Subject: [PATCH 18/18] updates Signed-off-by: liran2000 --- .../storage/connector/sync/http/PayloadCacheWrapper.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java index ebaa53017..3e2e20cdc 100644 --- a/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java +++ b/tools/flagd-http-connector/src/main/java/dev/openfeature/contrib/tools/flagd/resolver/process/storage/connector/sync/http/PayloadCacheWrapper.java @@ -36,7 +36,7 @@ public PayloadCacheWrapper(PayloadCache payloadCache, PayloadCacheOptions payloa } /** - * Updates the payload in the cache if the specified update interval has passed + * Updates the payload in the cache if the specified update interval has passed. * * @param payload the payload to be cached */