Skip to content

feat: ofrep provider #1429

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 17, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/component_owners.yml
Original file line number Diff line number Diff line change
@@ -37,6 +37,9 @@ components:
- liran2000
providers/multiprovider:
- liran2000
providers/ofrep-provider:
- Rahul-Baradol
- toddbaert
tools/flagd-http-connector:
- liran2000

1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@
"providers/configcat": "0.2.1",
"providers/statsig": "0.2.1",
"providers/multiprovider": "0.0.3",
"providers/ofrep": "0.0.1",
"tools/junit-openfeature": "0.2.1",
"tools/flagd-http-connector": "0.0.4",
".": "1.0.0"
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -40,6 +40,7 @@
<module>providers/configcat</module>
<module>providers/statsig</module>
<module>providers/multiprovider</module>
<module>providers/ofrep</module>
<module>tools/flagd-http-connector</module>
</modules>

74 changes: 74 additions & 0 deletions providers/ofrep/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# OFREP Provider for OpenFeature

This provider allows to connect to any feature flag management system that supports OFREP.

## Installation
For Maven
<!-- x-release-please-start-version -->
```xml
<dependency>
<groupId>dev.openfeature.contrib.providers</groupId>
<artifactId>ofrep</artifactId>
<version>0.0.1</version>
</dependency>
```

For Gradle
```groovy
implementation 'dev.openfeature.contrib.providers:ofrep:0.0.1'
```
<!-- x-release-please-end-version -->

## 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<Boolean> 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.

85 changes: 85 additions & 0 deletions providers/ofrep/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?xml version="1.0"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.openfeature.contrib</groupId>
<artifactId>parent</artifactId>
<version>1.0.0</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<groupId>dev.openfeature.contrib.providers</groupId>
<artifactId>ofrep</artifactId>
<version>0.0.1</version> <!--x-release-please-version -->

<name>ofrep</name>
<description>OFREP Provider</description>
<url>https://openfeature.dev</url>

<developers>
<developer>
<id>Rahul-Baradol</id>
<name>Rahul Baradol</name>
<organization>OpenFeature</organization>
<url>https://openfeature.dev/</url>
</developer>
</developers>

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.19.1</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.19.1</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.19.1</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.19.1</version>
</dependency>

<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.17</version>
</dependency>

<dependency>
<groupId>commons-validator</groupId>
<artifactId>commons-validator</artifactId>
<version>1.7</version>
</dependency>

<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.0-jre</version>
</dependency>

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
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 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();
}

/**
* 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.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) {
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.executor = options.getExecutor();
this.ofrepResolver = new Resolver(
options.getBaseUrl(),
options.getHeaders(),
options.getRequestTimeout(),
options.getConnectTimeout(),
options.getProxySelector(),
options.getExecutor());
}

@Override
public Metadata getMetadata() {
return () -> OFREP_PROVIDER;
}

@Override
public void shutdown() {
if (executor instanceof ExecutorService) {
ExecutorService executorService = (ExecutorService) executor;
try {
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) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
}
}

@Override
public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
return ofrepResolver.resolveBoolean(key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
return ofrepResolver.resolveString(key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
return ofrepResolver.resolveInteger(key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
return ofrepResolver.resolveDouble(key, defaultValue, ctx);
}

@Override
public ProviderEvaluation<Value> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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 java.util.concurrent.Executors;
import lombok.Builder;
import lombok.Getter;

/**
* Options for configuring the OFREP provider.
*/
@Getter
@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";

@Builder.Default
private final ProxySelector proxySelector = ProxySelector.getDefault();

@Builder.Default
private final Executor executor = Executors.newFixedThreadPool(DEFAULT_THREAD_POOL_SIZE);

@Builder.Default
private final Duration requestTimeout = Duration.ofSeconds(10);

@Builder.Default
private final Duration connectTimeout = Duration.ofSeconds(10);

@Builder.Default
private final ImmutableMap<String, ImmutableList<String>> headers = ImmutableMap.of();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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 dev.openfeature.sdk.exceptions.ParseError;
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 final Duration requestTimeout;
private Instant nextAllowedRequestTime = Instant.now();

/**
* Constructs an OfrepApi instance with a HTTP client and JSON serializers.
*
* @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 requestTimeout, Duration connectTimeout, ProxySelector proxySelector, Executor executor) {
httpClient = HttpClient.newBuilder()
.connectTimeout(connectTimeout)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could possibly add keepAlive settings, but by default I think there's a 30s pooled connection so I think that's good for now.

.proxy(proxySelector)
.executor(executor)
.build();
this.requestTimeout = requestTimeout;
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 <T> HttpRequest prepareHttpRequest(
final URI url, ImmutableMap<String, ImmutableList<String>> headers, final T requestBody)
throws JsonProcessingException {

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)));

for (ImmutableMap.Entry<String, ImmutableList<String>> 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<String, ImmutableList<String>> 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<String> 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 ParseError("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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package dev.openfeature.contrib.providers.ofrep.internal;

import com.google.common.collect.ImmutableMap;
import lombok.Value;

/**
* Represents the request body for the OFREP API request.
*/
@Value
public class OfrepRequest {
ImmutableMap<String, Object> context;
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> metadata;

public Map<String, Object> getMetadata() {
return ImmutableMap.copyOf(metadata);
}

public void setMetadata(Map<String, Object> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
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<String, ImmutableList<String>> 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 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<String, ImmutableList<String>> headers,
Duration requestTimeout,
Duration connectTimeout,
ProxySelector proxySelector,
Executor executor) {
this.baseUrl = baseUrl;
this.headers = headers;
this.ofrepApi = new OfrepApi(requestTimeout, connectTimeout, proxySelector, executor);
}

private <T> ProviderEvaluation<T> resolve(Class<T> 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<String, Object> 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<Boolean> resolveBoolean(String key, Boolean defaultValue, EvaluationContext ctx) {
return resolve(Boolean.class, key, defaultValue, ctx);
}

public ProviderEvaluation<String> resolveString(String key, String defaultValue, EvaluationContext ctx) {
return resolve(String.class, key, defaultValue, ctx);
}

public ProviderEvaluation<Integer> resolveInteger(String key, Integer defaultValue, EvaluationContext ctx) {
return resolve(Integer.class, key, defaultValue, ctx);
}

public ProviderEvaluation<Double> 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<Value> resolveObject(String key, Value defaultValue, EvaluationContext ctx) {
ProviderEvaluation<Object> evaluation = resolve(Object.class, key, defaultValue, ctx);

return ProviderEvaluation.<Value>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<String, Object> metadata) {
ImmutableMetadata.ImmutableMetadataBuilder builder = ImmutableMetadata.builder();
for (Map.Entry<String, Object> 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 <T> ProviderEvaluation<T> handleResolved(
String key, T defaultValue, Class<T> type, OfrepResponse response, ImmutableMetadata metadata) {

Object responseValue = response.getValue();

if (responseValue == null) {
return ProviderEvaluation.<T>builder()
.value(defaultValue)
.errorCode(ErrorCode.FLAG_NOT_FOUND)
.errorMessage("No value returned for flag: " + key)
.flagMetadata(metadata)
.build();
}

if (!type.isInstance(responseValue)) {
return ProviderEvaluation.<T>builder()
.value(defaultValue)
.errorCode(ErrorCode.TYPE_MISMATCH)
.errorMessage("Type mismatch: expected " + type.getSimpleName() + " but got "
+ responseValue.getClass().getSimpleName())
.flagMetadata(metadata)
.build();
}

return ProviderEvaluation.<T>builder()
.value(type.cast(responseValue))
.reason(response.getReason())
.variant(response.getVariant())
.flagMetadata(metadata)
.build();
}

private <T> ProviderEvaluation<T> handleFlagNotFound(String key, T defaultValue, ImmutableMetadata metadata) {
return ProviderEvaluation.<T>builder()
.value(defaultValue)
.errorMessage("flag: " + key + " not found")
.errorCode(ErrorCode.FLAG_NOT_FOUND)
.flagMetadata(metadata)
.build();
}

private <T> ProviderEvaluation<T> handleInvalidContext(String key, T defaultValue, ImmutableMetadata metadata) {
return ProviderEvaluation.<T>builder()
.value(defaultValue)
.errorMessage("invalid context for flag: " + key)
.errorCode(ErrorCode.INVALID_CONTEXT)
.flagMetadata(metadata)
.build();
}

private <T> ProviderEvaluation<T> handleGeneralError(
T defaultValue, ImmutableMetadata metadata, String errorMessage) {
return ProviderEvaluation.<T>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();
}
}
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dev.openfeature.contrib.testclasses;

import java.util.Map;
import lombok.Data;

@Data
public class OfrepRequestTest {
public Map<String, Object> context;

public OfrepRequestTest() {}
}
Original file line number Diff line number Diff line change
@@ -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<Runnable> tasks = new ArrayList<>();

@Override
public void execute(Runnable command) {
tasks.add(command);
new Thread(command).start();
}
}
Original file line number Diff line number Diff line change
@@ -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<URI> selectedUris = new ArrayList<>();

@Override
public List<Proxy> 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) {}
}
1 change: 1 addition & 0 deletions providers/ofrep/version.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.0.1
11 changes: 11 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -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",