Skip to content

feat: ISSUE-658 go-feature-flag sdk - add cache #369

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
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions providers/go-feature-flag/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,24 @@
<version>4.11.0</version>
<scope>test</scope>
</dependency>

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

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

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>2.20.0</version>
</dependency>

</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package dev.openfeature.contrib.providers.gofeatureflag;

import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import dev.openfeature.contrib.providers.gofeatureflag.bean.BeanUtils;
import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagRequest;
import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagResponse;
import dev.openfeature.contrib.providers.gofeatureflag.bean.GoFeatureFlagUser;
import dev.openfeature.contrib.providers.gofeatureflag.events.Event;
import dev.openfeature.contrib.providers.gofeatureflag.events.Events;
import dev.openfeature.contrib.providers.gofeatureflag.events.EventsPublisher;
import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidEndpoint;
import dev.openfeature.contrib.providers.gofeatureflag.exception.InvalidOptions;
import dev.openfeature.sdk.ErrorCode;
Expand All @@ -26,6 +30,9 @@
import dev.openfeature.sdk.exceptions.GeneralError;
import dev.openfeature.sdk.exceptions.OpenFeatureError;
import dev.openfeature.sdk.exceptions.TypeMismatchError;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import okhttp3.ConnectionPool;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
Expand All @@ -34,28 +41,48 @@
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import static java.net.HttpURLConnection.*;
import static org.apache.commons.lang3.BooleanUtils.isFalse;

/**
* GoFeatureFlagProvider is the JAVA provider implementation for the feature flag solution GO Feature Flag.
*/
@Slf4j
public class GoFeatureFlagProvider implements FeatureProvider {
private static final String NAME = "GO Feature Flag Provider";
private static final ObjectMapper requestMapper = new ObjectMapper();
private static final ObjectMapper responseMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

public static final int DEFAULT_CACHE_TTL_MINUTES = 10;
public static final int DEFAULT_CACHE_CONCURRENCY_LEVEL = 1;
public static final int DEFAULT_CACHE_INITIAL_CAPACITY = 100;
public static final int DEFAULT_CACHE_MAXIMUM_SIZE = 1000;
protected static final String CACHED_REASON = Reason.CACHED.name();
protected static final String REASON_SEPARATOR = ", ";
private HttpUrl parsedEndpoint;
// httpClient is the instance of the OkHttpClient used by the provider
private OkHttpClient httpClient;

// apiKey contains the token to use while calling GO Feature Flag relay proxy
private String apiKey;

@Getter(AccessLevel.PROTECTED)
private Cache<String, ProviderEvaluation<?>> cache;

@Getter(AccessLevel.PROTECTED)
private EventsPublisher<Event> eventsPublisher;

/**
* Constructor of the provider.
*
Expand All @@ -82,6 +109,14 @@ private void validateInputOptions(GoFeatureFlagProviderOptions options) throws I
if (options.getEndpoint() == null || "".equals(options.getEndpoint())) {
throw new InvalidEndpoint("endpoint is a mandatory field when initializing the provider");
}

if (options.getFlushIntervalMinues() != null && options.getFlushIntervalMinues() <= 0) {
throw new InvalidOptions("flushIntervalMinues must be larger than 0");
}

if (isFalse(options.getEnableCache()) && options.getFlushIntervalMinues() != null) {
throw new InvalidOptions("flushIntervalMinues not used when cache is disabled");
}
}

/**
Expand Down Expand Up @@ -113,6 +148,25 @@ private void initializeProvider(GoFeatureFlagProviderOptions options) throws Inv
throw new InvalidEndpoint();
}
this.apiKey = options.getApiKey();
boolean enableCache = options.getEnableCache() == null || options.getEnableCache();
if (enableCache) {
if (options.getCacheBuilder() != null) {
this.cache = options.getCacheBuilder().build();
} else {
this.cache = buildDefaultCache();
}
long flushIntervalMinutes = options.getFlushIntervalMinues() == null ? 1 : options.getFlushIntervalMinues();
Consumer<List<Event>> publisher = events -> publishEventsQuietly(events);
eventsPublisher = new EventsPublisher(publisher, flushIntervalMinutes);
}
}

private Cache buildDefaultCache() {
return CacheBuilder.newBuilder()
.concurrencyLevel(DEFAULT_CACHE_CONCURRENCY_LEVEL)
.initialCapacity(DEFAULT_CACHE_INITIAL_CAPACITY).maximumSize(DEFAULT_CACHE_MAXIMUM_SIZE)
.expireAfterWrite(Duration.ofMinutes(DEFAULT_CACHE_TTL_MINUTES))
.build();
}

@Override
Expand All @@ -129,52 +183,100 @@ public List<Hook> getProviderHooks() {
public ProviderEvaluation<Boolean> getBooleanEvaluation(
String key, Boolean defaultValue, EvaluationContext evaluationContext
) {
return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Boolean.class);
return getEvaluation(key, defaultValue, evaluationContext, Boolean.class);
}

@Override
public ProviderEvaluation<String> getStringEvaluation(
String key, String defaultValue, EvaluationContext evaluationContext
) {
return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, String.class);
return getEvaluation(key, defaultValue, evaluationContext, String.class);
}

@Override
public ProviderEvaluation<Integer> getIntegerEvaluation(
String key, Integer defaultValue, EvaluationContext evaluationContext
) {
return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Integer.class);
return getEvaluation(key, defaultValue, evaluationContext, Integer.class);
}

@Override
public ProviderEvaluation<Double> getDoubleEvaluation(
String key, Double defaultValue, EvaluationContext evaluationContext
) {
return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Double.class);
return getEvaluation(key, defaultValue, evaluationContext, Double.class);
}

@Override
public ProviderEvaluation<Value> getObjectEvaluation(
String key, Value defaultValue, EvaluationContext evaluationContext
) {
return resolveEvaluationGoFeatureFlagProxy(key, defaultValue, evaluationContext, Value.class);
return getEvaluation(key, defaultValue, evaluationContext, Value.class);
}

private String buildCacheKey(String key, String userKey) {
return key + "," + userKey;
}

@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
FeatureProvider.super.initialize(evaluationContext);
}

private <T> ProviderEvaluation<T> getEvaluation(
String key, T defaultValue, EvaluationContext evaluationContext, Class<?> expectedType) {
ProviderEvaluation res = null;
GoFeatureFlagUser user = GoFeatureFlagUser.fromEvaluationContext(evaluationContext);
if (cache == null) {
res = resolveEvaluationGoFeatureFlagProxy(key, defaultValue, user, expectedType);
} else {
res = getProviderEvaluationWithCheckCache(key, defaultValue, expectedType, user);
}
eventsPublisher.add(Event.builder()
.key(key)
.defaultValue(defaultValue)
.variation(res.getVariant())
.value(res.getValue())
.userKey(user.getKey())
.creationDate(System.currentTimeMillis())
.build()
);
return res;
}

private <T> ProviderEvaluation getProviderEvaluationWithCheckCache(
String key, T defaultValue, Class<?> expectedType, GoFeatureFlagUser user) {
ProviderEvaluation res = null;
try {
String cacheKey = buildCacheKey(key, BeanUtils.buildKey(user));
res = cache.getIfPresent(cacheKey);
if (res == null) {
res = resolveEvaluationGoFeatureFlagProxy(key, defaultValue, user, expectedType);
cache.put(cacheKey, res);
} else {
res.setReason(CACHED_REASON);
}
} catch (JsonProcessingException e) {
log.error("Error building key for user", user);
res = resolveEvaluationGoFeatureFlagProxy(key, defaultValue, user, expectedType);
}
return res;
}

/**
* resolveEvaluationGoFeatureFlagProxy is calling the GO Feature Flag API to retrieve the flag value.
*
* @param key - name of the feature flag
* @param defaultValue - value used if something is not working as expected
* @param ctx - EvaluationContext used for the request
* @param user - user (containing EvaluationContext) used for the request
* @param expectedType - type expected for the value
* @return a ProviderEvaluation that contains the open-feature response
* @throws OpenFeatureError - if an error happen
*/
private <T> ProviderEvaluation<T> resolveEvaluationGoFeatureFlagProxy(
String key, T defaultValue, EvaluationContext ctx, Class<?> expectedType
String key, T defaultValue, GoFeatureFlagUser user, Class<?> expectedType
) throws OpenFeatureError {
try {
GoFeatureFlagUser user = GoFeatureFlagUser.fromEvaluationContext(ctx);
GoFeatureFlagRequest<T> goffRequest = new GoFeatureFlagRequest<T>(user, defaultValue);

HttpUrl url = this.parsedEndpoint.newBuilder()
Expand Down Expand Up @@ -322,4 +424,45 @@ private Structure mapToStructure(Map<String, Object> map) {
.filter(e -> e.getValue() != null)
.collect(Collectors.toMap(Map.Entry::getKey, e -> objectToValue(e.getValue()))));
}

private void publishEventsQuietly(List<Event> eventsList) {
try {
publishEvents(eventsList);
} catch (Exception e) {
log.error("Error publishing events", e);
}
}

private void publishEvents(List<Event> eventsList) throws Exception {
Events events = new Events(eventsList);
HttpUrl url = this.parsedEndpoint.newBuilder()
.addEncodedPathSegment("v1")
.addEncodedPathSegment("data")
.addEncodedPathSegment("collector")
.build();

Request.Builder reqBuilder = new Request.Builder()
.url(url)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(
requestMapper.writeValueAsBytes(events),
MediaType.get("application/json; charset=utf-8")));

if (this.apiKey != null && !"".equals(this.apiKey)) {
reqBuilder.addHeader("Authorization", "Bearer " + this.apiKey);
}

try (Response response = this.httpClient.newCall(reqBuilder.build()).execute()) {
if (response.code() == HTTP_UNAUTHORIZED) {
throw new GeneralError("Unauthorized");
}
if (response.code() >= HTTP_BAD_REQUEST) {
throw new GeneralError("Bad request: " + response.body());
}

if (response.code() == HTTP_OK) {
log.info("Published {} events successfully: {}", eventsList.size(), response.body());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,40 @@
package dev.openfeature.contrib.providers.gofeatureflag;

import com.google.common.cache.CacheBuilder;
import lombok.Builder;
import lombok.Getter;

/**
* GoFeatureFlagProviderOptions contains the options to initialise the provider.
*/
@Builder
@Getter
public class GoFeatureFlagProviderOptions {

/**
* (mandatory) endpoint contains the DNS of your GO Feature Flag relay proxy
* example: https://mydomain.com/gofeatureflagproxy/
*/
@Getter
private String endpoint;

/**
* (optional) timeout in millisecond we are waiting when calling the
* go-feature-flag relay proxy API.
* Default: 10000 ms
*/
@Getter
private int timeout;


/**
* (optional) maxIdleConnections is the maximum number of connexions in the connexion pool.
* Default: 1000
*/
@Getter
private int maxIdleConnections;

/**
* (optional) keepAliveDuration is the time in millisecond we keep the connexion open.
* Default: 7200000 (2 hours)
*/
@Getter
private Long keepAliveDuration;

/**
Expand All @@ -46,6 +44,26 @@ public class GoFeatureFlagProviderOptions {
* (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above)
* Default: null
*/
@Getter
private String apiKey;

/**
* (optional) If cache custom configuration is wanted, you should provide
* a cache builder.
* Default: null
*/
private CacheBuilder cacheBuilder;

/**
* (optional) enable cache value.
* Default: true
*/
private Boolean enableCache;

/**
* (optional) interval time we publish statistics collection data to the proxy.
* The parameter is used only if the cache is enabled, otherwise the collection of the data is done directly
* when calling the evaluation API.
* default: 1 minute
*/
private Long flushIntervalMinues;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.openfeature.contrib.providers.gofeatureflag.bean;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* Bean utils.
*/
public class BeanUtils {

private static ObjectMapper objectMapper = new ObjectMapper();

private BeanUtils() {

}

public static String buildKey(GoFeatureFlagUser goFeatureFlagUser) throws JsonProcessingException {
return objectMapper.writeValueAsString(goFeatureFlagUser);
}
}
Loading