-
Notifications
You must be signed in to change notification settings - Fork 56
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
+1,521
−0
Merged
feat: ofrep provider #1429
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
c18d670
feat: ofrep provider
Rahul-Baradol 6522460
test: unit tests for ofrep provider
Rahul-Baradol 771d330
fix: shutting down executor on provider shutdown
Rahul-Baradol 54491a8
fix: set default thread pool size for executor
Rahul-Baradol 7ca1e33
docs: configuration and usage
Rahul-Baradol b502c6e
feat: adds request timeout option for provider
Rahul-Baradol 4fa2c70
fix: ensures graceful provider shutdown
Rahul-Baradol 7650bbf
refactor: making ofrep request dto immutable
Rahul-Baradol 57ae473
Merge branch 'main' into ofrep-provider
toddbaert 3f22783
Update .release-please-manifest.json
toddbaert b1436bf
Update providers/ofrep/pom.xml
toddbaert 4c855aa
Update .github/component_owners.yml
toddbaert 69bb17f
Apply suggestions from code review
toddbaert File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
139 changes: 139 additions & 0 deletions
139
providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
Rahul-Baradol marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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); | ||
} | ||
} |
38 changes: 38 additions & 0 deletions
38
...ers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/OfrepProviderOptions.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} |
132 changes: 132 additions & 0 deletions
132
providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepApi.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
toddbaert marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
liran2000 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* 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") | ||
Rahul-Baradol marked this conversation as resolved.
Show resolved
Hide resolved
|
||
.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; | ||
} | ||
} | ||
} |
12 changes: 12 additions & 0 deletions
12
...rs/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
|
||
/** | ||
Rahul-Baradol marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* Represents the request body for the OFREP API request. | ||
*/ | ||
@Value | ||
public class OfrepRequest { | ||
ImmutableMap<String, Object> context; | ||
} |
50 changes: 50 additions & 0 deletions
50
...s/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/OfrepResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
*/ | ||
Rahul-Baradol marked this conversation as resolved.
Show resolved
Hide resolved
|
||
@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; | ||
} | ||
} |
31 changes: 31 additions & 0 deletions
31
...ders/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolution.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
248 changes: 248 additions & 0 deletions
248
providers/ofrep/src/main/java/dev/openfeature/contrib/providers/ofrep/internal/Resolver.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: | ||
chrfwow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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(); | ||
} | ||
} | ||
} | ||
} |
642 changes: 642 additions & 0 deletions
642
providers/ofrep/src/test/java/dev/openfeature/contrib/OfrepProviderTest.java
Large diffs are not rendered by default.
Oops, something went wrong.
11 changes: 11 additions & 0 deletions
11
providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/OfrepRequestTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() {} | ||
} |
17 changes: 17 additions & 0 deletions
17
providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestExecutor.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
25 changes: 25 additions & 0 deletions
25
providers/ofrep/src/test/java/dev/openfeature/contrib/testclasses/TestProxySelector.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) {} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
0.0.1 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.