From c18d670242db67737a6fcb5b4509a8cde0d1258d Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Wed, 18 Jun 2025 16:00:45 +0530 Subject: [PATCH 01/12] feat: ofrep provider Signed-off-by: Rahul Baradol --- .github/component_owners.yml | 2 + .release-please-manifest.json | 1 + pom.xml | 1 + providers/ofrep/README.md | 1 + providers/ofrep/pom.xml | 79 ++++++ .../providers/ofrep/OfrepProvider.java | 103 ++++++++ .../providers/ofrep/OfrepProviderOptions.java | 32 +++ .../providers/ofrep/internal/OfrepApi.java | 127 +++++++++ .../ofrep/internal/OfrepRequest.java | 12 + .../ofrep/internal/OfrepResponse.java | 50 ++++ .../providers/ofrep/internal/Resolution.java | 31 +++ .../providers/ofrep/internal/Resolver.java | 246 ++++++++++++++++++ .../java/dev/openfeature/contrib/AppTest.java | 33 +++ providers/ofrep/version.txt | 1 + release-please-config.json | 11 + 15 files changed, 730 insertions(+) create mode 100644 providers/ofrep/README.md create mode 100644 providers/ofrep/pom.xml create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepResponse.java create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolution.java create mode 100644 providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java create mode 100644 providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java create mode 100644 providers/ofrep/version.txt diff --git a/.github/component_owners.yml b/.github/component_owners.yml index c5641fde7..8288c4936 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -37,6 +37,8 @@ components: - liran2000 providers/multiprovider: - liran2000 + providers/ofrep-provider: + - Rahul-Baradol tools/flagd-http-connector: - liran2000 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 439769eef..df0604707 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -10,6 +10,7 @@ "providers/configcat": "0.1.0", "providers/statsig": "0.1.0", "providers/multiprovider": "0.0.1", + "providers/ofrep": "0.0.1", "tools/junit-openfeature": "0.1.2", "tools/flagd-http-connector": "0.0.2", ".": "0.2.2" diff --git a/pom.xml b/pom.xml index db06a4cd4..f32188f18 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ providers/configcat providers/statsig providers/multiprovider + providers/ofrep tools/flagd-http-connector diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md new file mode 100644 index 000000000..77d563f2e --- /dev/null +++ b/providers/ofrep/README.md @@ -0,0 +1 @@ +### Todo \ No newline at end of file diff --git a/providers/ofrep/pom.xml b/providers/ofrep/pom.xml new file mode 100644 index 000000000..37f091e71 --- /dev/null +++ b/providers/ofrep/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.2.2 + ../../pom.xml + + + dev.openfeature.contrib.providers + ofrep + 0.0.1 + + ofrep + OFREP Provider + https://openfeature.dev + + + + Rahul-Baradol + Rahul Baradol + OpenFeature + https://openfeature.dev/ + + + + + + junit + junit + 3.8.1 + test + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.19.1 + + + + com.fasterxml.jackson.core + jackson-core + 2.19.1 + + + + com.fasterxml.jackson.core + jackson-databind + 2.19.1 + + + + com.fasterxml.jackson.core + jackson-annotations + 2.19.1 + + + + org.slf4j + slf4j-api + 2.0.17 + + + + commons-validator + commons-validator + 1.7 + + + + com.google.guava + guava + 33.4.0-jre + + + diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java new file mode 100644 index 000000000..df795f0d5 --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java @@ -0,0 +1,103 @@ +package dev.openfeature.contrib.providers.ofrep; + +import dev.openfeature.contrib.providers.ofrep.internal.Resolver; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import org.apache.commons.validator.routines.UrlValidator; + +/** + * OpenFeature provider for OFREP. + */ +public final class OfrepProvider implements FeatureProvider { + + private static final String OFREP_PROVIDER = "ofrep"; + private final Resolver ofrepResolver; + + public static OfrepProvider constructProvider() { + return new OfrepProvider(); + } + + /** + * Constructs an OfrepProvider with the specified options. + * + * @param options The options for configuring the provider. + * @return An instance of OfrepProvider configured with the provided options. + * @throws IllegalArgumentException if any of the options are invalid. + */ + public static OfrepProvider constructProvider(OfrepProviderOptions options) { + if (!isValidUrl(options.getBaseUrl())) { + throw new IllegalArgumentException("Invalid base URL: " + options.getBaseUrl()); + } + + if (options.getHeaders() == null) { + throw new IllegalArgumentException("Headers cannot be null"); + } + + if (options.getTimeout() == null + || options.getTimeout().isNegative() + || options.getTimeout().isZero()) { + throw new IllegalArgumentException("Timeout must be a positive duration"); + } + + if (options.getProxySelector() == null) { + throw new IllegalArgumentException("ProxySelector cannot be null"); + } + + if (options.getExecutor() == null) { + throw new IllegalArgumentException("Executor cannot be null"); + } + + return new OfrepProvider(options); + } + + private OfrepProvider() { + this(new OfrepProviderOptions.Builder().build()); + } + + private OfrepProvider(OfrepProviderOptions options) { + this.ofrepResolver = new Resolver( + options.getBaseUrl(), + options.getHeaders(), + options.getTimeout(), + options.getProxySelector(), + options.getExecutor()); + } + + @Override + public Metadata getMetadata() { + return () -> OFREP_PROVIDER; + } + + @Override + public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + return ofrepResolver.resolveBoolean(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + return ofrepResolver.resolveString(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + return ofrepResolver.resolveInteger(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + return ofrepResolver.resolveDouble(key, defaultValue, ctx); + } + + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + return ofrepResolver.resolveObject(key, defaultValue, ctx); + } + + private static boolean isValidUrl(String url) { + UrlValidator validator = new UrlValidator(new String[] {"http", "https"}, UrlValidator.ALLOW_LOCAL_URLS); + return validator.isValid(url); + } +} diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java new file mode 100644 index 000000000..a26302e7d --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java @@ -0,0 +1,32 @@ +package dev.openfeature.contrib.providers.ofrep; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.net.ProxySelector; +import java.time.Duration; +import java.util.concurrent.Executor; +import lombok.Builder; +import lombok.Getter; + +/** + * Options for configuring the OFREP provider. + */ +@Getter +@Builder(builderClassName = "Builder", buildMethodName = "build") +public class OfrepProviderOptions { + + @Builder.Default + private final String baseUrl = "http://localhost:8016"; + + @Builder.Default + private final ProxySelector proxySelector = ProxySelector.getDefault(); + + @Builder.Default + private final Executor executor = Runnable::run; + + @Builder.Default + private final Duration timeout = Duration.ofSeconds(10); + + @Builder.Default + private final ImmutableMap> headers = ImmutableMap.of(); +} diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java new file mode 100644 index 000000000..4c31a8c5b --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java @@ -0,0 +1,127 @@ +package dev.openfeature.contrib.providers.ofrep.internal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.io.IOException; +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.time.Instant; +import java.util.concurrent.Executor; +import lombok.extern.slf4j.Slf4j; + +/** + * The OfrepApi class is responsible for communicating with the OFREP server to evaluate flags. + */ +@Slf4j +public class OfrepApi { + + private static final String path = "/ofrep/v1/evaluate/flags/"; + private final ObjectMapper serializer; + private final ObjectMapper deserializer; + private final HttpClient httpClient; + private Instant nextAllowedRequestTime = Instant.now(); + + /** + * Constructs an OfrepApi instance with a HTTP client and JSON serializers. + * + * @param timeout - The timeout duration for establishing HTTP connection. + * @param proxySelector - The ProxySelector to use for HTTP requests. + * @param executor - The Executor to use for operations. + */ + public OfrepApi(Duration timeout, ProxySelector proxySelector, Executor executor) { + httpClient = HttpClient.newBuilder() + .connectTimeout(timeout) + .proxy(proxySelector) + .executor(executor) + .build(); + serializer = new ObjectMapper(); + deserializer = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * prepareHttpRequest is preparing the request to be sent to the OFREP Server. + * + * @param url - url of the request + * @param headers - headers to be included in the request + * @param requestBody - body of the request + * + * @return HttpRequest ready to be sent + * @throws JsonProcessingException - if an error occurred while processing the json. + */ + private HttpRequest prepareHttpRequest( + final URI url, ImmutableMap> headers, final T requestBody) + throws JsonProcessingException { + + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() + .uri(url) + .header("Content-Type", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofByteArray(serializer.writeValueAsBytes(requestBody))); + + for (ImmutableMap.Entry> entry : headers.entrySet()) { + String key = entry.getKey(); + for (String value : entry.getValue()) { + reqBuilder.header(key, value); + } + } + + return reqBuilder.build(); + } + + /** + * resolve is the method that interacts with the OFREP server to evaluate flags. + * + * @param baseUrl - The base URL of the OFREP server. + * @param headers - headers to include in the request. + * @param key - The flag key to evaluate. + * @param requestBody - The evaluation context as a map of key-value pairs. + * + * @return Resolution object, containing the response status, headers, and body. + */ + public Resolution resolve( + String baseUrl, + ImmutableMap> headers, + String key, + final OfrepRequest requestBody) { + if (nextAllowedRequestTime.isAfter(Instant.now())) { + throw new GeneralError("Rate limit exceeded. Please wait before making another request."); + } + + try { + String fullPath = baseUrl + path + key; + URI uri = URI.create(fullPath); + + HttpRequest request = prepareHttpRequest(uri, headers, requestBody); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + OfrepResponse responseBody = deserializer.readValue(response.body(), OfrepResponse.class); + + return new Resolution(response.statusCode(), response.headers(), responseBody); + } catch (JsonProcessingException e) { + throw new GeneralError("Error processing JSON: " + e.getMessage()); + } catch (IOException e) { + throw new GeneralError("IO error: " + e.getMessage()); + } catch (InterruptedException e) { + throw new GeneralError("Request interrupted: " + e.getMessage()); + } + } + + /** + * Sets the next allowed request time based on the Retry-After header. + * If the provided time is later than the current next allowed request time, it updates it. + * + * @param retryAfter The value of the Retry-After header, which can be a number of seconds or a date string. + */ + public void setNextAllowedRequestTime(Instant retryAfter) { + if (retryAfter.isAfter(nextAllowedRequestTime)) { + nextAllowedRequestTime = retryAfter; + } + } +} diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java new file mode 100644 index 000000000..1300b59fe --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java @@ -0,0 +1,12 @@ +package dev.openfeature.contrib.providers.ofrep.internal; + +import com.google.common.collect.ImmutableMap; +import lombok.Data; + +/** + * Represents the request body for the OFREP API request. + */ +@Data +public class OfrepRequest { + private final ImmutableMap context; +} diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepResponse.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepResponse.java new file mode 100644 index 000000000..e8d80f6d7 --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepResponse.java @@ -0,0 +1,50 @@ +package dev.openfeature.contrib.providers.ofrep.internal; + +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * This class represents the response from an OFREP response. + */ +@Getter +@Setter +@ToString +public class OfrepResponse { + private Object value; + private String key; + private String variant; + private String reason; + private boolean cacheable; + private String errorCode; + private String errorDetails; + private Map metadata; + + public Map getMetadata() { + return ImmutableMap.copyOf(metadata); + } + + public void setMetadata(Map metadata) { + this.metadata = metadata != null ? ImmutableMap.copyOf(metadata) : ImmutableMap.of(); + } + + /** + * Creates a copy of the current OfrepResponse instance. + * + * @return A new OfrepResponse instance with the same values as the provided instance. + */ + public OfrepResponse copy() { + OfrepResponse newResponse = new OfrepResponse(); + newResponse.value = this.value; + newResponse.key = this.key; + newResponse.variant = this.variant; + newResponse.reason = this.reason; + newResponse.cacheable = this.cacheable; + newResponse.errorCode = this.errorCode; + newResponse.errorDetails = this.errorDetails; + newResponse.metadata = metadata != null ? ImmutableMap.copyOf(metadata) : ImmutableMap.of(); + return newResponse; + } +} diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolution.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolution.java new file mode 100644 index 000000000..7e91c027a --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolution.java @@ -0,0 +1,31 @@ +package dev.openfeature.contrib.providers.ofrep.internal; + +import java.net.http.HttpHeaders; +import lombok.Getter; + +/** + * Resolution class encapsulates the response from the OFREP server, along with additional fields. + */ +@Getter +public class Resolution { + private final int responseStatus; + private final HttpHeaders headers; + private final OfrepResponse response; + + /** + * Constructs a Resolution object with the given response status, headers, and response body. + * + * @param responseStatus - The HTTP response status code. + * @param headers - The HTTP headers from the response. + * @param response - The parsed response body as an OfrepResponse object. + */ + public Resolution(int responseStatus, HttpHeaders headers, OfrepResponse response) { + this.responseStatus = responseStatus; + this.headers = headers; + this.response = response.copy(); + } + + public OfrepResponse getResponse() { + return response.copy(); + } +} diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java new file mode 100644 index 000000000..8241a8a46 --- /dev/null +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java @@ -0,0 +1,246 @@ +package dev.openfeature.contrib.providers.ofrep.internal; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import java.net.HttpURLConnection; +import java.net.ProxySelector; +import java.net.http.HttpHeaders; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.concurrent.Executor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver class that interacts with the OFREP API to resolve feature flags. + */ +@Slf4j +public class Resolver { + + private final String baseUrl; + private final ImmutableMap> headers; + private final OfrepApi ofrepApi; + + /** + * Constructs a Resolver with the specified base URL, headers, timeout, proxySelector and executor. + * + * @param baseUrl - The base URL of the OFREP server. + * @param headers - The headers to include in the requests. + * @param timeout - The timeout for requests in seconds. + * @param proxySelector - The ProxySelector to use for HTTP requests. + * @param executor - The Executor to use for operations. + */ + public Resolver( + String baseUrl, + ImmutableMap> headers, + Duration timeout, + ProxySelector proxySelector, + Executor executor) { + this.baseUrl = baseUrl; + this.headers = headers; + this.ofrepApi = new OfrepApi(timeout, proxySelector, executor); + } + + private ProviderEvaluation resolve(Class type, String key, T defaultValue, EvaluationContext ctx) { + try { + OfrepRequest ofrepRequest = new OfrepRequest(ImmutableMap.copyOf(ctx.asObjectMap())); + Resolution resolution = ofrepApi.resolve(this.baseUrl, this.headers, key, ofrepRequest); + + int responseStatus = resolution.getResponseStatus(); + HttpHeaders responseHeaders = resolution.getHeaders(); + OfrepResponse responseBody = resolution.getResponse(); + + Map metadata = responseBody.getMetadata(); + ImmutableMetadata immutableMetadata = convertToImmutableMetadata(metadata); + + switch (responseStatus) { + case HttpURLConnection.HTTP_OK: + return handleResolved(key, defaultValue, type, responseBody, immutableMetadata); + case HttpURLConnection.HTTP_UNAUTHORIZED: + case HttpURLConnection.HTTP_FORBIDDEN: + return handleGeneralError( + defaultValue, immutableMetadata, "authentication/authorization error for flag: " + key); + case HttpURLConnection.HTTP_BAD_REQUEST: + case HttpURLConnection.HTTP_NOT_ACCEPTABLE: + return handleInvalidContext(key, defaultValue, immutableMetadata); + case HttpURLConnection.HTTP_NOT_FOUND: + return handleFlagNotFound(key, defaultValue, immutableMetadata); + case 429: + String retryAfter = + responseHeaders.firstValue("Retry-After").orElse(null); + Instant retryAfterInstant = parseRetryAfter(retryAfter); + ofrepApi.setNextAllowedRequestTime(retryAfterInstant); + return handleGeneralError( + defaultValue, + immutableMetadata, + "Rate limit exceeded for flag: " + key + ", retry after: " + retryAfterInstant); + default: + return handleGeneralError( + defaultValue, + immutableMetadata, + "Unknown error while retrieving flag: " + key + ", status code: " + responseStatus); + } + } catch (GeneralError e) { + String errorMessage = "general error for flag: " + key + "; " + e.getMessage(); + return handleGeneralError(defaultValue, ImmutableMetadata.builder().build(), errorMessage); + } + } + + public ProviderEvaluation resolveBoolean(String key, Boolean defaultValue, EvaluationContext ctx) { + return resolve(Boolean.class, key, defaultValue, ctx); + } + + public ProviderEvaluation resolveString(String key, String defaultValue, EvaluationContext ctx) { + return resolve(String.class, key, defaultValue, ctx); + } + + public ProviderEvaluation resolveInteger(String key, Integer defaultValue, EvaluationContext ctx) { + return resolve(Integer.class, key, defaultValue, ctx); + } + + public ProviderEvaluation resolveDouble(String key, Double defaultValue, EvaluationContext ctx) { + return resolve(Double.class, key, defaultValue, ctx); + } + + /** + * Resolves an object value for the given key. + * + * @param key - The flag key to evaluate. + * @param defaultValue - The default value to return if there is an error. + * @param ctx - The evaluation context containing additional information. + * + * @return A ProviderEvaluation containing the resolved value, variant, + * reason, and metadata. + */ + public ProviderEvaluation resolveObject(String key, Value defaultValue, EvaluationContext ctx) { + ProviderEvaluation evaluation = resolve(Object.class, key, defaultValue, ctx); + + return ProviderEvaluation.builder() + .value(Value.objectToValue(evaluation.getValue())) + .variant(evaluation.getVariant()) + .reason(evaluation.getReason()) + .errorCode(evaluation.getErrorCode()) + .errorMessage(evaluation.getErrorMessage()) + .flagMetadata(evaluation.getFlagMetadata()) + .build(); + } + + private ImmutableMetadata convertToImmutableMetadata(Map metadata) { + ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder(); + for (Map.Entry entry : metadata.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) { + log.warn("Null value for key: {}, skipping", key); + continue; + } + + if (value instanceof String) { + builder.addString(key, (String) value); + } else if (value instanceof Integer) { + builder.addInteger(key, (Integer) value); + } else if (value instanceof Long) { + builder.addLong(key, (Long) value); + } else if (value instanceof Float) { + builder.addFloat(key, (Float) value); + } else if (value instanceof Double) { + builder.addDouble(key, (Double) value); + } else if (value instanceof Boolean) { + builder.addBoolean(key, (Boolean) value); + } else { + log.warn("Unsupported metadata type for key: {}, value: {}", key, value); + builder.addString(key, value.toString()); + } + } + return builder.build(); + } + + private ProviderEvaluation handleResolved( + String key, T defaultValue, Class type, OfrepResponse response, ImmutableMetadata metadata) { + + Object responseValue = response.getValue(); + + if (responseValue == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("No value returned for flag: " + key) + .flagMetadata(metadata) + .build(); + } + + if (!type.isInstance(responseValue)) { + return ProviderEvaluation.builder() + .value(defaultValue) + .errorCode(ErrorCode.TYPE_MISMATCH) + .errorMessage("Type mismatch: expected " + type.getSimpleName() + " but got " + + responseValue.getClass().getSimpleName()) + .flagMetadata(metadata) + .build(); + } + + return ProviderEvaluation.builder() + .value(type.cast(responseValue)) + .reason(response.getReason()) + .variant(response.getVariant()) + .flagMetadata(metadata) + .build(); + } + + private ProviderEvaluation handleFlagNotFound(String key, T defaultValue, ImmutableMetadata metadata) { + return ProviderEvaluation.builder() + .value(defaultValue) + .errorMessage("flag: " + key + " not found") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(metadata) + .build(); + } + + private ProviderEvaluation handleInvalidContext(String key, T defaultValue, ImmutableMetadata metadata) { + return ProviderEvaluation.builder() + .value(defaultValue) + .errorMessage("invalid context for flag: " + key) + .errorCode(ErrorCode.INVALID_CONTEXT) + .flagMetadata(metadata) + .build(); + } + + private ProviderEvaluation handleGeneralError( + T defaultValue, ImmutableMetadata metadata, String errorMessage) { + return ProviderEvaluation.builder() + .value(defaultValue) + .errorMessage(errorMessage) + .errorCode(ErrorCode.GENERAL) + .flagMetadata(metadata) + .build(); + } + + private static Instant parseRetryAfter(String retryAfter) { + if (retryAfter == null || retryAfter.isEmpty()) { + return Instant.now(); + } + + try { + long seconds = Long.parseLong(retryAfter); + return Instant.now().plusSeconds(seconds); + } catch (NumberFormatException numberFormatException) { + try { + DateTimeFormatter rfc1123Formatter = DateTimeFormatter.RFC_1123_DATE_TIME; + ZonedDateTime zonedDateTime = ZonedDateTime.parse(retryAfter, rfc1123Formatter); + return zonedDateTime.toInstant(); + } catch (Exception e) { + log.error("Failed to parse Retry-After header: ", e); + return Instant.now(); + } + } + } +} diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java new file mode 100644 index 000000000..28c37e53e --- /dev/null +++ b/providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java @@ -0,0 +1,33 @@ +package dev.openfeature.contrib; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest extends TestCase { + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest(String testName) { + super(testName); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() { + return new TestSuite(AppTest.class); + } + + /** + * Rigourous Test :-) + */ + public void testApp() { + assertTrue(true); + } +} diff --git a/providers/ofrep/version.txt b/providers/ofrep/version.txt new file mode 100644 index 000000000..8a9ecc2ea --- /dev/null +++ b/providers/ofrep/version.txt @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/release-please-config.json b/release-please-config.json index 73712d911..f64dffa94 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -114,6 +114,17 @@ "README.md" ] }, + "providers/ofrep": { + "package-name": "dev.openfeature.contrib.providers.ofrep", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "hooks/open-telemetry": { "package-name": "dev.openfeature.contrib.hooks.otel", "release-type": "simple", From 6522460174d880387d1a7ca7e20aa145a52d638f Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Sat, 21 Jun 2025 17:03:05 +0530 Subject: [PATCH 02/12] test: unit tests for ofrep provider Signed-off-by: Rahul Baradol --- providers/ofrep/pom.xml | 12 +- .../java/dev/openfeature/contrib/AppTest.java | 33 - .../contrib/OfrepProviderTest.java | 586 ++++++++++++++++++ .../contrib/testclasses/OfrepRequestTest.java | 11 + .../contrib/testclasses/TestExecutor.java | 17 + .../testclasses/TestProxySelector.java | 25 + 6 files changed, 648 insertions(+), 36 deletions(-) delete mode 100644 providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java create mode 100644 providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java create mode 100644 providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/OfrepRequestTest.java create mode 100644 providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestExecutor.java create mode 100644 providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestProxySelector.java diff --git a/providers/ofrep/pom.xml b/providers/ofrep/pom.xml index 37f091e71..68980047f 100644 --- a/providers/ofrep/pom.xml +++ b/providers/ofrep/pom.xml @@ -28,9 +28,8 @@ - junit - junit - 3.8.1 + org.junit.jupiter + junit-jupiter test @@ -75,5 +74,12 @@ guava 33.4.0-jre + + + com.squareup.okhttp3 + mockwebserver + 4.12.0 + test + diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java deleted file mode 100644 index 28c37e53e..000000000 --- a/providers/ofrep/src/test/java/dev/openfeature/contrib/AppTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.openfeature.contrib; - -import junit.framework.Test; -import junit.framework.TestCase; -import junit.framework.TestSuite; - -/** - * Unit test for simple App. - */ -public class AppTest extends TestCase { - /** - * Create the test case - * - * @param testName name of the test case - */ - public AppTest(String testName) { - super(testName); - } - - /** - * @return the suite of tests being tested - */ - public static Test suite() { - return new TestSuite(AppTest.class); - } - - /** - * Rigourous Test :-) - */ - public void testApp() { - assertTrue(true); - } -} diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java new file mode 100644 index 000000000..707ea2ae5 --- /dev/null +++ b/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java @@ -0,0 +1,586 @@ +package dev.openfeature.contrib; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import dev.openfeature.contrib.providers.ofrep.OfrepProvider; +import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions; +import dev.openfeature.contrib.providers.ofrep.internal.OfrepResponse; +import dev.openfeature.contrib.testclasses.OfrepRequestTest; +import dev.openfeature.contrib.testclasses.TestExecutor; +import dev.openfeature.contrib.testclasses.TestProxySelector; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.io.IOException; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class OfrepProviderTest { + + private static final Map FLAG_METADATA = Map.of("flagSetId", "example", "version", "v1"); + private static final ImmutableMetadata FLAG_IMMUTABLE_METADATA = ImmutableMetadata.builder() + .addString("flagSetId", "example") + .addString("version", "v1") + .build(); + + private static final String FLAG_KEY = "testFlag"; + + private static final String DEFAULT_STRING_VALUE = "defaultValue"; + private static final Boolean DEFAULT_BOOLEAN_VALUE = false; + private static final int DEFAULT_INT_VALUE = 0; + private static final double DEFAULT_DOUBLE_VALUE = 0.0; + private static final Map DEFAULT_OBJECT_VALUE = Map.of(); + + private static final String SUCCESSFUL_STRING_VALUE = "successfulValue"; + private static final boolean SUCCESSFUL_BOOLEAN_VALUE = true; + private static final int SUCCESSFUL_INT_VALUE = 42; + private static final double SUCCESSFUL_DOUBLE_VALUE = 3.14; + private static final Map SUCCESSFUL_OBJECT_VALUE = Map.of("object1", Map.of("key", "val")); + + private static final String SUCCESSFUL_VARIANT = "variant"; + private static final String SUCCESSFUL_REASON = "TARGETTING_MATCH"; + + private static final String ERROR_CODE_FLAG_NOT_FOUND = ErrorCode.FLAG_NOT_FOUND.toString(); + private static final String ERROR_CODE_TYPE_MISMATCH = ErrorCode.TYPE_MISMATCH.toString(); + private static final String ERROR_CODE_INVALID_CONTEXT = ErrorCode.INVALID_CONTEXT.toString(); + private static final String ERROR_CODE_GENERAL = ErrorCode.GENERAL.toString(); + + private static final String ERROR_DETAIL_FLAG_NOT_FOUND = "flag: testFlag not found"; + private static final String ERROR_DETAIL_TYPE_MISMATCH = "Type mismatch: expected Boolean but got String"; + private static final String ERROR_DETAIL_INVALID_CONTEXT = "invalid context for flag: testFlag"; + private static final String ERROR_DETAIL_GENERAL_AUTH = "authentication/authorization error for flag: testFlag"; + private static final String ERROR_DETAIL_RATE_LIMIT_WITH_RETRY_AFTER = + "Rate limit exceeded for flag: testFlag, retry after: "; + private static final String ERROR_DETAIL_RATE_LIMIT_TRY_AGAIN = + "general error for flag: " + FLAG_KEY + "; Rate limit exceeded. Please wait before making another request."; + private static final String ERROR_DETAIL_GENERAL_HTTP_TIMEOUT = + "general error for flag: " + FLAG_KEY + "; IO error: HTTP connect timed out"; + + private static final String HEADER_AUTH_KEY = "Authorization"; + private static final String HEADER_AUTH_VALUE = "Bearer token"; + + private static final String HEADER_CUSTOM_KEY = "Custom-Header"; + private static final String HEADER_CUSTOM_VALUE = "Custom-Value"; + + private static final String CONTEXT_COLOR_KEY = "color"; + private static final String CONTEXT_COLOR_VALUE = "yellow"; + + private static final String CONTEXT_EMAIL_KEY = "email"; + private static final String CONTEXT_EMAIL_VALUE = "someone@example.com"; + + private static final TestProxySelector proxySelector = new TestProxySelector(); + private static final TestExecutor executor = new TestExecutor(); + + private static final ObjectMapper serializer = new ObjectMapper(); + private static final ObjectMapper deserializer = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private static final OfrepProvider ofrepProviderWithConnectTimeout = + OfrepProvider.constructProvider(OfrepProviderOptions.builder() + .baseUrl("http://10.255.255.1") + .timeout(Duration.ofSeconds(2)) + .build()); + + private static MockWebServer mockWebServer; + + private static OfrepProvider ofrepProvider; + private static OfrepProvider ofrepProviderWithProxy; + private static OfrepProvider ofrepProviderWithExecutor; + + private static EvaluationContext context; + + @BeforeEach + void setUpServer() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + String baseUrl = mockWebServer.url("/").toString(); + + ImmutableMap> headers = ImmutableMap.of( + HEADER_AUTH_KEY, ImmutableList.of(HEADER_AUTH_VALUE), + HEADER_CUSTOM_KEY, ImmutableList.of(HEADER_CUSTOM_VALUE)); + + Map content = new HashMap<>(); + content.put(CONTEXT_COLOR_KEY, new Value(CONTEXT_COLOR_VALUE)); + content.put(CONTEXT_EMAIL_KEY, new Value(CONTEXT_EMAIL_VALUE)); + + context = new ImmutableContext(content); + + ofrepProvider = OfrepProvider.constructProvider( + OfrepProviderOptions.builder().baseUrl(baseUrl).headers(headers).build()); + + ofrepProviderWithProxy = OfrepProvider.constructProvider(OfrepProviderOptions.builder() + .baseUrl(baseUrl) + .proxySelector(proxySelector) + .build()); + + ofrepProviderWithExecutor = OfrepProvider.constructProvider(OfrepProviderOptions.builder() + .baseUrl(baseUrl) + .executor(executor) + .build()); + } + + @AfterEach + void shutDownServer() throws Exception { + mockWebServer.shutdown(); + } + + @Test + void testHeaderFlow() throws InterruptedException { + mockWebServer.enqueue(new MockResponse().setBody("{}").setResponseCode(200)); + + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + RecordedRequest sentRequest = mockWebServer.takeRequest(); + + assertTrue(HEADER_AUTH_VALUE.equals(sentRequest.getHeader(HEADER_AUTH_KEY))); + assertTrue(HEADER_CUSTOM_VALUE.equals(sentRequest.getHeader(HEADER_CUSTOM_KEY))); + } + + @Test + void testEvaluationContextFlow() throws InterruptedException, IOException { + mockWebServer.enqueue(new MockResponse().setBody("{}").setResponseCode(200)); + + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, context); + + RecordedRequest sentRequest = mockWebServer.takeRequest(); + + OfrepRequestTest requestBody = + deserializer.readValue(sentRequest.getBody().readByteArray(), OfrepRequestTest.class); + String contextColor = (String) requestBody.getContext().get(CONTEXT_COLOR_KEY); + String contextEmail = (String) requestBody.getContext().get(CONTEXT_EMAIL_KEY); + + assertTrue(CONTEXT_COLOR_VALUE.equals(contextColor)); + assertTrue(CONTEXT_EMAIL_VALUE.equals(contextEmail)); + } + + @Test + void testSuccessfulStringResponse() throws JsonProcessingException { + OfrepResponse successfulStringResponse = new OfrepResponse(); + successfulStringResponse.setKey(FLAG_KEY); + successfulStringResponse.setValue(SUCCESSFUL_STRING_VALUE); + successfulStringResponse.setReason(SUCCESSFUL_REASON); + successfulStringResponse.setVariant(SUCCESSFUL_VARIANT); + successfulStringResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulStringResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(SUCCESSFUL_STRING_VALUE.equals(evaluation.getValue())); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testSuccessfulBooleanResponse() throws JsonProcessingException { + OfrepResponse successfulBooleanResponse = new OfrepResponse(); + successfulBooleanResponse.setKey(FLAG_KEY); + successfulBooleanResponse.setValue(SUCCESSFUL_BOOLEAN_VALUE); + successfulBooleanResponse.setReason(SUCCESSFUL_REASON); + successfulBooleanResponse.setVariant(SUCCESSFUL_VARIANT); + successfulBooleanResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulBooleanResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProvider.getBooleanEvaluation(FLAG_KEY, DEFAULT_BOOLEAN_VALUE, new ImmutableContext()); + + assertEquals(SUCCESSFUL_BOOLEAN_VALUE, evaluation.getValue()); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testSuccessfulIntResponse() throws JsonProcessingException { + OfrepResponse successfulIntResponse = new OfrepResponse(); + successfulIntResponse.setKey(FLAG_KEY); + successfulIntResponse.setValue(SUCCESSFUL_INT_VALUE); + successfulIntResponse.setReason(SUCCESSFUL_REASON); + successfulIntResponse.setVariant(SUCCESSFUL_VARIANT); + successfulIntResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulIntResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProvider.getIntegerEvaluation(FLAG_KEY, DEFAULT_INT_VALUE, new ImmutableContext()); + + assertEquals(SUCCESSFUL_INT_VALUE, evaluation.getValue()); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testSuccessfulDoubleResponse() throws JsonProcessingException { + OfrepResponse successfulDoubleResponse = new OfrepResponse(); + successfulDoubleResponse.setKey(FLAG_KEY); + successfulDoubleResponse.setValue(SUCCESSFUL_DOUBLE_VALUE); + successfulDoubleResponse.setReason(SUCCESSFUL_REASON); + successfulDoubleResponse.setVariant(SUCCESSFUL_VARIANT); + successfulDoubleResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulDoubleResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProvider.getDoubleEvaluation(FLAG_KEY, DEFAULT_DOUBLE_VALUE, new ImmutableContext()); + + assertEquals(SUCCESSFUL_DOUBLE_VALUE, evaluation.getValue()); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testSuccessfulObjectResponse() throws JsonProcessingException { + OfrepResponse successfulObjectResponse = new OfrepResponse(); + successfulObjectResponse.setKey(FLAG_KEY); + successfulObjectResponse.setValue(SUCCESSFUL_OBJECT_VALUE); + successfulObjectResponse.setReason(SUCCESSFUL_REASON); + successfulObjectResponse.setVariant(SUCCESSFUL_VARIANT); + successfulObjectResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulObjectResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = ofrepProvider.getObjectEvaluation( + FLAG_KEY, Value.objectToValue(DEFAULT_OBJECT_VALUE), new ImmutableContext()); + + assertTrue(SUCCESSFUL_OBJECT_VALUE.equals( + evaluation.getValue().asStructure().asObjectMap())); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testFlagNotFoundResponse() throws JsonProcessingException { + OfrepResponse flagNotFoundResponse = new OfrepResponse(); + flagNotFoundResponse.setKey(FLAG_KEY); + flagNotFoundResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(flagNotFoundResponse)) + .setResponseCode(404)); + + ProviderEvaluation evaluation = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_FLAG_NOT_FOUND.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_FLAG_NOT_FOUND.equals(evaluation.getErrorMessage())); + } + + @Test + void testTypeMismatch() throws JsonProcessingException { + OfrepResponse typeMismatchResponse = new OfrepResponse(); + typeMismatchResponse.setKey(FLAG_KEY); + typeMismatchResponse.setValue( + SUCCESSFUL_STRING_VALUE); // Intentionally returning a string value to get a type mismatch + typeMismatchResponse.setReason(SUCCESSFUL_REASON); + typeMismatchResponse.setVariant(SUCCESSFUL_VARIANT); + typeMismatchResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(typeMismatchResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProvider.getBooleanEvaluation(FLAG_KEY, DEFAULT_BOOLEAN_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_BOOLEAN_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_TYPE_MISMATCH.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_TYPE_MISMATCH.equals(evaluation.getErrorMessage())); + } + + @Test + void testInvalidContext() throws JsonProcessingException { + OfrepResponse invalidContextResponse = new OfrepResponse(); + invalidContextResponse.setKey(FLAG_KEY); + invalidContextResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(invalidContextResponse)) + .setResponseCode(400)); + + ProviderEvaluation evaluation = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_INVALID_CONTEXT.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_INVALID_CONTEXT.equals(evaluation.getErrorMessage())); + } + + @Test + void testGeneralUnauthorizedError() throws JsonProcessingException { + OfrepResponse generalAuthErrorResponse = new OfrepResponse(); + generalAuthErrorResponse.setKey(FLAG_KEY); + generalAuthErrorResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(generalAuthErrorResponse)) + .setResponseCode(401)); + + ProviderEvaluation evaluation = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_GENERAL.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_GENERAL_AUTH.equals(evaluation.getErrorMessage())); + } + + @Test + void testGeneralForbiddenError() throws JsonProcessingException { + OfrepResponse generalAuthErrorResponse = new OfrepResponse(); + generalAuthErrorResponse.setKey(FLAG_KEY); + generalAuthErrorResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(generalAuthErrorResponse)) + .setResponseCode(403)); + + ProviderEvaluation evaluation = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_GENERAL.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_GENERAL_AUTH.equals(evaluation.getErrorMessage())); + } + + @Test + void testRateLimit() throws JsonProcessingException, InterruptedException { + ZonedDateTime now = ZonedDateTime.now().plusSeconds(3); + String nowFormatted = DateTimeFormatter.RFC_1123_DATE_TIME.format(now); + + OfrepResponse rateLimitResponse = new OfrepResponse(); + rateLimitResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(rateLimitResponse)) + .setHeader("Retry-After", nowFormatted) + .setResponseCode(429)); + + ProviderEvaluation evaluation = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + ProviderEvaluation evaluationRejectedBySdk = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + String errorDetail = ERROR_DETAIL_RATE_LIMIT_WITH_RETRY_AFTER + + now.toInstant().truncatedTo(ChronoUnit.SECONDS).toString(); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_GENERAL.equals(evaluation.getErrorCode().toString())); + assertTrue(errorDetail.equals(evaluation.getErrorMessage())); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluationRejectedBySdk.getValue())); + assertNull(evaluationRejectedBySdk.getVariant()); + assertNull(evaluationRejectedBySdk.getReason()); + assertTrue(ImmutableMetadata.builder().build().equals(evaluationRejectedBySdk.getFlagMetadata())); + assertTrue( + ERROR_CODE_GENERAL.equals(evaluationRejectedBySdk.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_RATE_LIMIT_TRY_AGAIN.equals(evaluationRejectedBySdk.getErrorMessage())); + + Thread.sleep(3000); // Wait for the rate limit to expire + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(rateLimitResponse)) + .setHeader("Retry-After", nowFormatted) + .setResponseCode(429)); + + ProviderEvaluation evaluationAfterRateLimitExpired = + ofrepProvider.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluationAfterRateLimitExpired.getValue())); + assertNull(evaluationAfterRateLimitExpired.getVariant()); + assertNull(evaluationAfterRateLimitExpired.getReason()); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluationAfterRateLimitExpired.getFlagMetadata())); + assertTrue(ERROR_CODE_GENERAL.equals( + evaluationAfterRateLimitExpired.getErrorCode().toString())); + assertTrue(errorDetail.equals(evaluationAfterRateLimitExpired.getErrorMessage())); + } + + @Test + void testTimeoutConnection() throws JsonProcessingException { + OfrepResponse successfulStringResponse = new OfrepResponse(); + successfulStringResponse.setKey(FLAG_KEY); + successfulStringResponse.setValue(SUCCESSFUL_STRING_VALUE); + successfulStringResponse.setReason(SUCCESSFUL_REASON); + successfulStringResponse.setVariant(SUCCESSFUL_VARIANT); + successfulStringResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulStringResponse)) + .setBodyDelay(4, TimeUnit.SECONDS) + .setResponseCode(200)); + + ProviderEvaluation evaluation = ofrepProviderWithConnectTimeout.getStringEvaluation( + FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(ImmutableMetadata.builder().build().equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_GENERAL.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_GENERAL_HTTP_TIMEOUT.equals(evaluation.getErrorMessage())); + } + + @Test + void testCustomProxySelector() throws JsonProcessingException { + OfrepResponse successfulStringResponse = new OfrepResponse(); + successfulStringResponse.setKey(FLAG_KEY); + successfulStringResponse.setValue(SUCCESSFUL_STRING_VALUE); + successfulStringResponse.setReason(SUCCESSFUL_REASON); + successfulStringResponse.setVariant(SUCCESSFUL_VARIANT); + successfulStringResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulStringResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProviderWithProxy.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(!proxySelector.getSelectedUris().isEmpty()); + assertTrue(SUCCESSFUL_STRING_VALUE.equals(evaluation.getValue())); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testCustomExecutor() throws JsonProcessingException { + OfrepResponse successfulStringResponse = new OfrepResponse(); + successfulStringResponse.setKey(FLAG_KEY); + successfulStringResponse.setValue(SUCCESSFUL_STRING_VALUE); + successfulStringResponse.setReason(SUCCESSFUL_REASON); + successfulStringResponse.setVariant(SUCCESSFUL_VARIANT); + successfulStringResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulStringResponse)) + .setResponseCode(200)); + + ProviderEvaluation evaluation = + ofrepProviderWithExecutor.getStringEvaluation(FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(!executor.getTasks().isEmpty()); + assertTrue(SUCCESSFUL_STRING_VALUE.equals(evaluation.getValue())); + assertTrue(SUCCESSFUL_VARIANT.equals(evaluation.getVariant())); + assertTrue(SUCCESSFUL_REASON.equals(evaluation.getReason())); + assertTrue(FLAG_IMMUTABLE_METADATA.equals(evaluation.getFlagMetadata())); + } + + @Test + void testConfigurationValidation() { + Exception exceptionInvalidBaseUrl = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().baseUrl("invalid-url").build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNullBaseUrl = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().baseUrl(null).build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNullHeaders = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().headers(null).build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNegativeTimeout = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = OfrepProviderOptions.builder() + .timeout(Duration.ofSeconds(-10)) + .build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionZeroedTimeout = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = OfrepProviderOptions.builder() + .timeout(Duration.ofSeconds(0)) + .build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNullTimeout = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().timeout(null).build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNullProxySelector = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().proxySelector(null).build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNullExecutor = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().executor(null).build(); + OfrepProvider.constructProvider(options); + }); + + assertTrue(exceptionInvalidBaseUrl.getMessage().contains("Invalid base URL")); + assertTrue(exceptionNullBaseUrl.getMessage().contains("Invalid base URL")); + assertTrue(exceptionNullHeaders.getMessage().contains("Headers cannot be null")); + assertTrue(exceptionNegativeTimeout.getMessage().contains("Timeout must be a positive duration")); + assertTrue(exceptionZeroedTimeout.getMessage().contains("Timeout must be a positive duration")); + assertTrue(exceptionNullTimeout.getMessage().contains("Timeout must be a positive duration")); + assertTrue(exceptionNullProxySelector.getMessage().contains("ProxySelector cannot be null")); + assertTrue(exceptionNullExecutor.getMessage().contains("Executor cannot be null")); + } +} diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/OfrepRequestTest.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/OfrepRequestTest.java new file mode 100644 index 000000000..9134c4828 --- /dev/null +++ b/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/OfrepRequestTest.java @@ -0,0 +1,11 @@ +package dev.openfeature.contrib.testclasses; + +import java.util.Map; +import lombok.Data; + +@Data +public class OfrepRequestTest { + public Map context; + + public OfrepRequestTest() {} +} diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestExecutor.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestExecutor.java new file mode 100644 index 000000000..b11e60793 --- /dev/null +++ b/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestExecutor.java @@ -0,0 +1,17 @@ +package dev.openfeature.contrib.testclasses; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import lombok.Getter; + +@Getter +public class TestExecutor implements Executor { + private final List tasks = new ArrayList<>(); + + @Override + public void execute(Runnable command) { + tasks.add(command); + new Thread(command).start(); + } +} diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestProxySelector.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestProxySelector.java new file mode 100644 index 000000000..919a963c3 --- /dev/null +++ b/providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestProxySelector.java @@ -0,0 +1,25 @@ +package dev.openfeature.contrib.testclasses; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; + +@Getter +public class TestProxySelector extends ProxySelector { + private final List selectedUris = new ArrayList<>(); + + @Override + public List select(URI uri) { + selectedUris.add(uri); + return List.of(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(uri.getHost(), uri.getPort()))); + } + + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {} +} From 771d330bf7bd03630f9db36c031f86668f7c12fa Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Sun, 22 Jun 2025 16:40:09 +0530 Subject: [PATCH 03/12] fix: shutting down executor on provider shutdown Signed-off-by: Rahul Baradol --- .../providers/ofrep/OfrepProvider.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java index df795f0d5..0afca2150 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java @@ -6,15 +6,23 @@ import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.Value; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.validator.routines.UrlValidator; /** * OpenFeature provider for OFREP. */ +@Slf4j public final class OfrepProvider implements FeatureProvider { private static final String OFREP_PROVIDER = "ofrep"; + private static final long DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT = 1000; + private final Resolver ofrepResolver; + private Executor executor; public static OfrepProvider constructProvider() { return new OfrepProvider(); @@ -58,6 +66,7 @@ private OfrepProvider() { } private OfrepProvider(OfrepProviderOptions options) { + this.executor = options.getExecutor(); this.ofrepResolver = new Resolver( options.getBaseUrl(), options.getHeaders(), @@ -71,6 +80,19 @@ public Metadata getMetadata() { return () -> OFREP_PROVIDER; } + @Override + public void shutdown() { + if (executor instanceof ExecutorService) { + try { + ExecutorService executorService = (ExecutorService) executor; + executorService.shutdownNow(); + executorService.awaitTermination(DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + log.error("Error during shutdown {}", OFREP_PROVIDER, e); + } + } + } + @Override public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { return ofrepResolver.resolveBoolean(key, defaultValue, ctx); From 54491a80211e50d3106b7bc6b072b83868bfbbcb Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Sun, 22 Jun 2025 16:42:11 +0530 Subject: [PATCH 04/12] fix: set default thread pool size for executor Signed-off-by: Rahul Baradol --- .../contrib/providers/ofrep/OfrepProviderOptions.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java index a26302e7d..0693d055b 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java @@ -5,6 +5,7 @@ import java.net.ProxySelector; import java.time.Duration; import java.util.concurrent.Executor; +import java.util.concurrent.Executors; import lombok.Builder; import lombok.Getter; @@ -15,6 +16,8 @@ @Builder(builderClassName = "Builder", buildMethodName = "build") public class OfrepProviderOptions { + private static final int DEFAULT_THREAD_POOL_SIZE = 5; + @Builder.Default private final String baseUrl = "http://localhost:8016"; @@ -22,7 +25,7 @@ public class OfrepProviderOptions { private final ProxySelector proxySelector = ProxySelector.getDefault(); @Builder.Default - private final Executor executor = Runnable::run; + private final Executor executor = Executors.newFixedThreadPool(DEFAULT_THREAD_POOL_SIZE); @Builder.Default private final Duration timeout = Duration.ofSeconds(10); From 7ca1e335840ae3e317ca42a73867cb9317e53bf8 Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Sun, 22 Jun 2025 17:37:24 +0530 Subject: [PATCH 05/12] docs: configuration and usage Signed-off-by: Rahul Baradol --- providers/ofrep/README.md | 75 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/providers/ofrep/README.md b/providers/ofrep/README.md index 77d563f2e..d03cd79df 100644 --- a/providers/ofrep/README.md +++ b/providers/ofrep/README.md @@ -1 +1,74 @@ -### Todo \ No newline at end of file +# OFREP Provider for OpenFeature + +This provider allows to connect to any feature flag management system that supports OFREP. + +## Installation +For Maven + +```xml + + dev.openfeature.contrib.providers + ofrep + 0.0.1 + +``` + +For Gradle +```groovy +implementation 'dev.openfeature.contrib.providers:ofrep:0.0.1' +``` + + +## Configuration and Usage + +### Usage +```java +OfrepProviderOptions options = OfrepProviderOptions.builder().build(); +OfrepProvider ofrepProvider = OfrepProvider.constructProvider(options); +``` +### Example +```java +import dev.openfeature.contrib.providers.ofrep.OfrepProvider; +import dev.openfeature.contrib.providers.ofrep.OfrepProviderOptions; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; + +public class App { + public static void main(String[] args) { + OpenFeatureAPI openFeatureAPI = OpenFeatureAPI.getInstance(); + + OfrepProviderOptions options = OfrepProviderOptions.builder().build(); + OfrepProvider ofrepProvider = OfrepProvider.constructProvider(options); + + openFeatureAPI.setProvider(ofrepProvider); + + Client client = openFeatureAPI.getClient(); + + MutableContext context = new MutableContext(); + context.setTargetingKey("my-identify-id"); + + FlagEvaluationDetails details = client.getBooleanDetails("my-boolean-flag", false, context); + System.out.println("Flag value: " + details.getValue()); + + openFeatureAPI.shutdown(); + } +} +``` + +### Configuration options + +Options are passed via `OfrepProviderOptions`, using which default values can be overridden. + +Given below are the supported configurations: + + +| Option name | Type | Default | Description +| ----------- | ------- | --------- | --------- +| baseUrl | String | http://localhost:8016 | Override the default OFREP API URL. +| headers | ImmutableMap | Empty Map | Add custom headers which will be sent with each network request to the OFREP API. +| timeout | Duration | 10 Seconds | The timeout duration to establishing the connection. +| proxySelector | ProxySelector | ProxySelector.getDefault() | The proxy selector used by HTTP Client. +| executor | Executor | Thread Pool of size 5 | The executor used by HTTP Client. + From b502c6e676ead66d6ba534e1330331d6bc7a33e3 Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Mon, 23 Jun 2025 21:48:55 +0530 Subject: [PATCH 06/12] feat: adds request timeout option for provider Signed-off-by: Rahul Baradol --- .../providers/ofrep/OfrepProvider.java | 17 ++-- .../providers/ofrep/OfrepProviderOptions.java | 5 +- .../providers/ofrep/internal/OfrepApi.java | 10 ++- .../providers/ofrep/internal/Resolver.java | 8 +- .../contrib/OfrepProviderTest.java | 78 ++++++++++++++++--- 5 files changed, 95 insertions(+), 23 deletions(-) diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java index 0afca2150..e3ec859d0 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java @@ -44,10 +44,16 @@ public static OfrepProvider constructProvider(OfrepProviderOptions options) { throw new IllegalArgumentException("Headers cannot be null"); } - if (options.getTimeout() == null - || options.getTimeout().isNegative() - || options.getTimeout().isZero()) { - throw new IllegalArgumentException("Timeout must be a positive duration"); + if (options.getRequestTimeout() == null + || options.getRequestTimeout().isNegative() + || options.getRequestTimeout().isZero()) { + throw new IllegalArgumentException("Request timeout must be a positive duration"); + } + + if (options.getConnectTimeout() == null + || options.getConnectTimeout().isNegative() + || options.getConnectTimeout().isZero()) { + throw new IllegalArgumentException("Connect timeout must be a positive duration"); } if (options.getProxySelector() == null) { @@ -70,7 +76,8 @@ private OfrepProvider(OfrepProviderOptions options) { this.ofrepResolver = new Resolver( options.getBaseUrl(), options.getHeaders(), - options.getTimeout(), + options.getRequestTimeout(), + options.getConnectTimeout(), options.getProxySelector(), options.getExecutor()); } diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java index 0693d055b..abdff58bc 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java @@ -28,7 +28,10 @@ public class OfrepProviderOptions { private final Executor executor = Executors.newFixedThreadPool(DEFAULT_THREAD_POOL_SIZE); @Builder.Default - private final Duration timeout = Duration.ofSeconds(10); + private final Duration requestTimeout = Duration.ofSeconds(10); + + @Builder.Default + private final Duration connectTimeout = Duration.ofSeconds(10); @Builder.Default private final ImmutableMap> headers = ImmutableMap.of(); diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java index 4c31a8c5b..9c386011f 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java @@ -27,21 +27,24 @@ public class OfrepApi { private final ObjectMapper serializer; private final ObjectMapper deserializer; private final HttpClient httpClient; + private final Duration requestTimeout; private Instant nextAllowedRequestTime = Instant.now(); /** * Constructs an OfrepApi instance with a HTTP client and JSON serializers. * - * @param timeout - The timeout duration for establishing HTTP connection. + * @param requestTimeout - The request timeout duration for the request. + * @param connectTimeout - The connect timeout duration for establishing HTTP connection. * @param proxySelector - The ProxySelector to use for HTTP requests. * @param executor - The Executor to use for operations. */ - public OfrepApi(Duration timeout, ProxySelector proxySelector, Executor executor) { + public OfrepApi(Duration requestTimeout, Duration connectTimeout, ProxySelector proxySelector, Executor executor) { httpClient = HttpClient.newBuilder() - .connectTimeout(timeout) + .connectTimeout(connectTimeout) .proxy(proxySelector) .executor(executor) .build(); + this.requestTimeout = requestTimeout; serializer = new ObjectMapper(); deserializer = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } @@ -62,6 +65,7 @@ private HttpRequest prepareHttpRequest( HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(url) + .timeout(this.requestTimeout) .header("Content-Type", "application/json; charset=utf-8") .POST(HttpRequest.BodyPublishers.ofByteArray(serializer.writeValueAsBytes(requestBody))); diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java index 8241a8a46..31f282e26 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java @@ -34,19 +34,21 @@ public class Resolver { * * @param baseUrl - The base URL of the OFREP server. * @param headers - The headers to include in the requests. - * @param timeout - The timeout for requests in seconds. + * @param requestTimeout - The request timeout duration for the request. + * @param connectTimeout - The connect timeout duration for establishing the HTTP connection. * @param proxySelector - The ProxySelector to use for HTTP requests. * @param executor - The Executor to use for operations. */ public Resolver( String baseUrl, ImmutableMap> headers, - Duration timeout, + Duration requestTimeout, + Duration connectTimeout, ProxySelector proxySelector, Executor executor) { this.baseUrl = baseUrl; this.headers = headers; - this.ofrepApi = new OfrepApi(timeout, proxySelector, executor); + this.ofrepApi = new OfrepApi(requestTimeout, connectTimeout, proxySelector, executor); } private ProviderEvaluation resolve(Class type, String key, T defaultValue, EvaluationContext ctx) { diff --git a/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java b/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java index 707ea2ae5..80882161e 100644 --- a/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java +++ b/providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java @@ -97,10 +97,16 @@ public class OfrepProviderTest { private static final ObjectMapper deserializer = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final OfrepProvider ofrepProviderWithRequestTimeout = + OfrepProvider.constructProvider(OfrepProviderOptions.builder() + .baseUrl("http://10.255.255.1") + .requestTimeout(Duration.ofSeconds(2)) + .build()); + private static final OfrepProvider ofrepProviderWithConnectTimeout = OfrepProvider.constructProvider(OfrepProviderOptions.builder() .baseUrl("http://10.255.255.1") - .timeout(Duration.ofSeconds(2)) + .connectTimeout(Duration.ofSeconds(2)) .build()); private static MockWebServer mockWebServer; @@ -452,7 +458,32 @@ void testRateLimit() throws JsonProcessingException, InterruptedException { } @Test - void testTimeoutConnection() throws JsonProcessingException { + void testRequestTimeoutConnection() throws JsonProcessingException { + OfrepResponse successfulStringResponse = new OfrepResponse(); + successfulStringResponse.setKey(FLAG_KEY); + successfulStringResponse.setValue(SUCCESSFUL_STRING_VALUE); + successfulStringResponse.setReason(SUCCESSFUL_REASON); + successfulStringResponse.setVariant(SUCCESSFUL_VARIANT); + successfulStringResponse.setMetadata(FLAG_METADATA); + + mockWebServer.enqueue(new MockResponse() + .setBody(serializer.writeValueAsString(successfulStringResponse)) + .setBodyDelay(4, TimeUnit.SECONDS) + .setResponseCode(200)); + + ProviderEvaluation evaluation = ofrepProviderWithRequestTimeout.getStringEvaluation( + FLAG_KEY, DEFAULT_STRING_VALUE, new ImmutableContext()); + + assertTrue(DEFAULT_STRING_VALUE.equals(evaluation.getValue())); + assertNull(evaluation.getVariant()); + assertNull(evaluation.getReason()); + assertTrue(ImmutableMetadata.builder().build().equals(evaluation.getFlagMetadata())); + assertTrue(ERROR_CODE_GENERAL.equals(evaluation.getErrorCode().toString())); + assertTrue(ERROR_DETAIL_GENERAL_HTTP_TIMEOUT.equals(evaluation.getErrorMessage())); + } + + @Test + void testConnectTimeoutConnection() throws JsonProcessingException { OfrepResponse successfulStringResponse = new OfrepResponse(); successfulStringResponse.setKey(FLAG_KEY); successfulStringResponse.setValue(SUCCESSFUL_STRING_VALUE); @@ -542,23 +573,43 @@ void testConfigurationValidation() { OfrepProvider.constructProvider(options); }); - Exception exceptionNegativeTimeout = assertThrows(IllegalArgumentException.class, () -> { + Exception exceptionNegativeConnectTimeout = assertThrows(IllegalArgumentException.class, () -> { OfrepProviderOptions options = OfrepProviderOptions.builder() - .timeout(Duration.ofSeconds(-10)) + .connectTimeout(Duration.ofSeconds(-10)) .build(); OfrepProvider.constructProvider(options); }); - Exception exceptionZeroedTimeout = assertThrows(IllegalArgumentException.class, () -> { + Exception exceptionZeroedConnectTimeout = assertThrows(IllegalArgumentException.class, () -> { OfrepProviderOptions options = OfrepProviderOptions.builder() - .timeout(Duration.ofSeconds(0)) + .connectTimeout(Duration.ofSeconds(0)) .build(); OfrepProvider.constructProvider(options); }); - Exception exceptionNullTimeout = assertThrows(IllegalArgumentException.class, () -> { + Exception exceptionNullConnectTimeout = assertThrows(IllegalArgumentException.class, () -> { OfrepProviderOptions options = - OfrepProviderOptions.builder().timeout(null).build(); + OfrepProviderOptions.builder().connectTimeout(null).build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNegativeRequestTimeout = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = OfrepProviderOptions.builder() + .requestTimeout(Duration.ofSeconds(-10)) + .build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionZeroedRequestTimeout = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = OfrepProviderOptions.builder() + .requestTimeout(Duration.ofSeconds(0)) + .build(); + OfrepProvider.constructProvider(options); + }); + + Exception exceptionNullRequestTimeout = assertThrows(IllegalArgumentException.class, () -> { + OfrepProviderOptions options = + OfrepProviderOptions.builder().requestTimeout(null).build(); OfrepProvider.constructProvider(options); }); @@ -577,9 +628,14 @@ void testConfigurationValidation() { assertTrue(exceptionInvalidBaseUrl.getMessage().contains("Invalid base URL")); assertTrue(exceptionNullBaseUrl.getMessage().contains("Invalid base URL")); assertTrue(exceptionNullHeaders.getMessage().contains("Headers cannot be null")); - assertTrue(exceptionNegativeTimeout.getMessage().contains("Timeout must be a positive duration")); - assertTrue(exceptionZeroedTimeout.getMessage().contains("Timeout must be a positive duration")); - assertTrue(exceptionNullTimeout.getMessage().contains("Timeout must be a positive duration")); + assertTrue( + exceptionNegativeRequestTimeout.getMessage().contains("Request timeout must be a positive duration")); + assertTrue(exceptionZeroedRequestTimeout.getMessage().contains("Request timeout must be a positive duration")); + assertTrue(exceptionNullRequestTimeout.getMessage().contains("Request timeout must be a positive duration")); + assertTrue( + exceptionNegativeConnectTimeout.getMessage().contains("Connect timeout must be a positive duration")); + assertTrue(exceptionZeroedConnectTimeout.getMessage().contains("Connect timeout must be a positive duration")); + assertTrue(exceptionNullConnectTimeout.getMessage().contains("Connect timeout must be a positive duration")); assertTrue(exceptionNullProxySelector.getMessage().contains("ProxySelector cannot be null")); assertTrue(exceptionNullExecutor.getMessage().contains("Executor cannot be null")); } From 4fa2c70364535a29ac184b1034f211faf5932362 Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Mon, 23 Jun 2025 22:37:56 +0530 Subject: [PATCH 07/12] fix: ensures graceful provider shutdown Signed-off-by: Rahul Baradol --- .../contrib/providers/ofrep/OfrepProvider.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java index e3ec859d0..918b570f0 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java @@ -90,12 +90,19 @@ public Metadata getMetadata() { @Override public void shutdown() { if (executor instanceof ExecutorService) { + ExecutorService executorService = (ExecutorService) executor; try { - ExecutorService executorService = (ExecutorService) executor; - executorService.shutdownNow(); - executorService.awaitTermination(DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS); + executorService.shutdown(); + + if (!executorService.awaitTermination(DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS)) { + executorService.shutdownNow(); + if (!executorService.awaitTermination(DEFAULT_EXECUTOR_SHUTDOWN_TIMEOUT, TimeUnit.MILLISECONDS)) { + log.error("Provider couldn't shutdown gracefully."); + } + } } catch (InterruptedException e) { - log.error("Error during shutdown {}", OFREP_PROVIDER, e); + executorService.shutdownNow(); + Thread.currentThread().interrupt(); } } } From 7650bbf212c0593fa81c31e3b68fe313643aa05a Mon Sep 17 00:00:00 2001 From: Rahul Baradol Date: Mon, 23 Jun 2025 22:40:26 +0530 Subject: [PATCH 08/12] refactor: making ofrep request dto immutable Signed-off-by: Rahul Baradol --- .../contrib/providers/ofrep/internal/OfrepRequest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java index 1300b59fe..189de0ae1 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java @@ -1,12 +1,12 @@ package dev.openfeature.contrib.providers.ofrep.internal; import com.google.common.collect.ImmutableMap; -import lombok.Data; +import lombok.Value; /** * Represents the request body for the OFREP API request. */ -@Data +@Value public class OfrepRequest { - private final ImmutableMap context; + ImmutableMap context; } From 3f22783c0c9328b544a05ced0bae535e14d78d20 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 16 Jul 2025 15:05:48 -0400 Subject: [PATCH 09/12] Update .release-please-manifest.json Signed-off-by: Todd Baert --- .release-please-manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index db4ba65c7..54270dc9a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,7 +2,6 @@ "hooks/open-telemetry": "3.3.1", "providers/flagd": "0.11.14", "providers/go-feature-flag": "0.4.3", - "providers/flagsmith": "0.0.12", "providers/env-var": "0.0.11", "providers/jsonlogic-eval-provider": "1.2.1", From b1436bf2749b888f173651ffe2e1c5ae16a00dd2 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 16 Jul 2025 15:11:14 -0400 Subject: [PATCH 10/12] Update providers/ofrep/pom.xml Signed-off-by: Todd Baert --- providers/ofrep/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/ofrep/pom.xml b/providers/ofrep/pom.xml index 68980047f..564b83d89 100644 --- a/providers/ofrep/pom.xml +++ b/providers/ofrep/pom.xml @@ -5,7 +5,7 @@ dev.openfeature.contrib parent - 0.2.2 + 1.0.0 ../../pom.xml From 4c855aa3a471cc68d054024528c996b8388f09b7 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 16 Jul 2025 15:12:01 -0400 Subject: [PATCH 11/12] Update .github/component_owners.yml Signed-off-by: Todd Baert --- .github/component_owners.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 8288c4936..28697894a 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -39,6 +39,7 @@ components: - liran2000 providers/ofrep-provider: - Rahul-Baradol + - toddbaert tools/flagd-http-connector: - liran2000 From 69bb17f5d2c98a23a3ea928c8bfc62cd23dabcee Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 16 Jul 2025 15:12:54 -0400 Subject: [PATCH 12/12] Apply suggestions from code review Signed-off-by: Todd Baert --- .../openfeature/contrib/providers/ofrep/internal/OfrepApi.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java index 9c386011f..1c7025155 100644 --- a/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java +++ b/providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java @@ -6,6 +6,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ParseError; import java.io.IOException; import java.net.ProxySelector; import java.net.URI; @@ -109,7 +110,7 @@ public Resolution resolve( return new Resolution(response.statusCode(), response.headers(), responseBody); } catch (JsonProcessingException e) { - throw new GeneralError("Error processing JSON: " + e.getMessage()); + throw new ParseError("Error processing JSON: " + e.getMessage()); } catch (IOException e) { throw new GeneralError("IO error: " + e.getMessage()); } catch (InterruptedException e) {