Skip to content

feat: add Prefab provider #1325

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
@@ -35,6 +35,9 @@ components:
- novalisdenahi
providers/statsig:
- liran2000
providers/prefab:
- liran2000
- jkebinger
providers/multiprovider:
- liran2000

1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -10,5 +10,6 @@
"providers/configcat": "0.1.0",
"providers/statsig": "0.1.0",
"providers/multiprovider": "0.0.1",
"providers/prefab": "0.0.1",
"tools/junit-openfeature": "0.1.2"
}
1 change: 1 addition & 0 deletions pom.xml
Original file line number Diff line number Diff line change
@@ -39,6 +39,7 @@
<module>providers/flipt</module>
<module>providers/configcat</module>
<module>providers/statsig</module>
<module>providers/prefab</module>
<module>providers/multiprovider</module>
</modules>

1 change: 1 addition & 0 deletions providers/prefab/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
59 changes: 59 additions & 0 deletions providers/prefab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Unofficial Prefab OpenFeature Provider for Java

[Prefab](https://www.prefab.cloud/) OpenFeature Provider can provide usage for Prefab via OpenFeature Java SDK.

## Installation

<!-- x-release-please-start-version -->

```xml

<dependency>
<groupId>dev.openfeature.contrib.providers</groupId>
<artifactId>prefab</artifactId>
<version>0.0.1</version>
</dependency>
```

<!-- x-release-please-end-version -->

## Usage
Prefab OpenFeature Provider is using Prefab Java SDK.

### Usage Example

```
PrefabProviderConfig prefabProviderConfig = PrefabProviderConfig.builder().sdkKey(sdkKey).build();
prefabProvider = new PrefabProvider(prefabProviderConfig);
OpenFeatureAPI.getInstance().setProviderAndWait(prefabProvider);


Options options = new Options().setApikey(sdkKey);
PrefabProviderConfig prefabProviderConfig = PrefabProviderConfig.builder()
.options(options).build();
PrefabProvider prefabProvider = new PrefabProvider(prefabProviderConfig);
OpenFeatureAPI.getInstance().setProviderAndWait(prefabProvider);

boolean featureEnabled = client.getBooleanValue(FLAG_NAME, false);

MutableContext evaluationContext = new MutableContext();
evaluationContext.add("user.key", "key1");
evaluationContext.add("team.domain", "prefab.cloud");
featureEnabled = client.getBooleanValue(USERS_FLAG_NAME, false, evaluationContext);
```

See [PrefabProviderTest](./src/test/java/dev/openfeature/contrib/providers/prefab/PrefabProviderTest.java)
for more information.

## Notes
Some Prefab custom operations are supported from the provider client via:

```java
prefabProvider.getPrefabCloudClient()...
```

## Prefab Provider Tests Strategies

Unit test based on Prefab local features file.
See [PrefabProviderTest](./src/test/java/dev/openfeature/contrib/providers/prefab/PrefabProviderTest.java)
for more information.
5 changes: 5 additions & 0 deletions providers/prefab/lombok.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# This file is needed to avoid errors throw by findbugs when working with lombok.
lombok.addSuppressWarnings = true
lombok.addLombokGeneratedAnnotation = true
config.stopBubbling = true
lombok.extern.findbugs.addSuppressFBWarnings = true
40 changes: 40 additions & 0 deletions providers/prefab/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.openfeature.contrib</groupId>
<artifactId>parent</artifactId>
<version>0.1.0</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<groupId>dev.openfeature.contrib.providers</groupId>
<artifactId>prefab</artifactId>
<version>0.0.1</version> <!--x-release-please-version -->

<name>prefab</name>
<description>Prefab provider for Java</description>
<url>https://www.prefab.cloud</url>

<dependencies>
<dependency>
<groupId>cloud.prefab</groupId>
<artifactId>client</artifactId>
<version>0.3.20</version>
</dependency>

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

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>2.23.1</version>
<scope>test</scope>
</dependency>

</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package dev.openfeature.contrib.providers.prefab;

import cloud.prefab.context.PrefabContext;
import cloud.prefab.context.PrefabContextSet;
import cloud.prefab.context.PrefabContextSetReadable;
import dev.openfeature.sdk.EvaluationContext;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
* Transformer from OpenFeature context to Prefab context.
*/
public class ContextTransformer {

private ContextTransformer() {}

protected static PrefabContextSetReadable transform(EvaluationContext ctx) {
Map<String, PrefabContext.Builder> contextsMap = new HashMap<>();
ctx.asObjectMap().forEach((k, v) -> {
String[] parts = k.split("\\.", 2);
if (parts.length < 2) {
throw new IllegalArgumentException("context key structure should be in the form of x.y: " + k);
}
contextsMap.putIfAbsent(parts[0], PrefabContext.newBuilder(parts[0]));
PrefabContext.Builder contextBuilder = contextsMap.get(parts[0]);
contextBuilder.put(parts[1], Objects.toString(v, null));
});
PrefabContextSet prefabContextSet = new PrefabContextSet();
contextsMap.forEach((key, value) -> {
PrefabContext prefabContext = value.build();
prefabContextSet.addContext(prefabContext);
});

return prefabContextSet;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package dev.openfeature.contrib.providers.prefab;

import cloud.prefab.client.PrefabCloudClient;
import cloud.prefab.context.PrefabContextSetReadable;
import cloud.prefab.domain.Prefab;
import dev.openfeature.sdk.EvaluationContext;
import dev.openfeature.sdk.EventProvider;
import dev.openfeature.sdk.Metadata;
import dev.openfeature.sdk.ProviderEvaluation;
import dev.openfeature.sdk.ProviderEventDetails;
import dev.openfeature.sdk.Value;
import dev.openfeature.sdk.exceptions.GeneralError;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

/**
* Provider implementation for Prefab.
*/
@Slf4j
public class PrefabProvider extends EventProvider {

@Getter
private static final String NAME = "Prefab";

public static final String PROVIDER_NOT_YET_INITIALIZED = "provider not yet initialized";
public static final String UNKNOWN_ERROR = "unknown error";

private final PrefabProviderConfig prefabProviderConfig;

@Getter
private PrefabCloudClient prefabCloudClient;

private final AtomicBoolean isInitialized = new AtomicBoolean(false);

/**
* Constructor.
*
* @param prefabProviderConfig prefabProvider Config
*/
public PrefabProvider(PrefabProviderConfig prefabProviderConfig) {
this.prefabProviderConfig = prefabProviderConfig;
}

/**
* Initialize the provider.
*
* @param evaluationContext evaluation context
* @throws Exception on error
*/
@Override
public void initialize(EvaluationContext evaluationContext) throws Exception {
boolean initialized = isInitialized.getAndSet(true);
if (initialized) {
throw new GeneralError("already initialized");
}
super.initialize(evaluationContext);
prefabCloudClient = new PrefabCloudClient(prefabProviderConfig.getOptions());
log.info("finished initializing provider");

prefabProviderConfig.getOptions().addConfigChangeListener(changeEvent -> {
ProviderEventDetails providerEventDetails = ProviderEventDetails.builder()
.flagsChanged(Collections.singletonList(changeEvent.getKey()))
.message("config changed")
.build();
emitProviderConfigurationChanged(providerEventDetails);
});
}

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

@Override
public ProviderEvaluation<Boolean> getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx);
Boolean evaluatedValue = prefabCloudClient.featureFlagClient().featureIsOn(key, context);
return ProviderEvaluation.<Boolean>builder().value(evaluatedValue).build();
}

@Override
public ProviderEvaluation<String> getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx);
String evaluatedValue = defaultValue;
Optional<Prefab.ConfigValue> opt = prefabCloudClient.featureFlagClient().get(key, context);
if (opt.isPresent()
&& Prefab.ConfigValue.TypeCase.STRING.equals(opt.get().getTypeCase())) {
evaluatedValue = opt.get().getString();
}
return ProviderEvaluation.<String>builder().value(evaluatedValue).build();
}

@Override
public ProviderEvaluation<Integer> getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx);
Integer evaluatedValue = defaultValue;
Optional<Prefab.ConfigValue> opt = prefabCloudClient.featureFlagClient().get(key, context);
if (opt.isPresent() && Prefab.ConfigValue.TypeCase.INT.equals(opt.get().getTypeCase())) {
evaluatedValue = Math.toIntExact(opt.get().getInt());
}
return ProviderEvaluation.<Integer>builder().value(evaluatedValue).build();
}

@Override
public ProviderEvaluation<Double> getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx);
Double evaluatedValue = defaultValue;
Optional<Prefab.ConfigValue> opt = prefabCloudClient.featureFlagClient().get(key, context);
if (opt.isPresent()
&& Prefab.ConfigValue.TypeCase.DOUBLE.equals(opt.get().getTypeCase())) {
evaluatedValue = opt.get().getDouble();
}
return ProviderEvaluation.<Double>builder().value(evaluatedValue).build();
}

@SneakyThrows
@Override
public ProviderEvaluation<Value> getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
String defaultValueString = defaultValue == null ? null : defaultValue.asString();
ProviderEvaluation<String> stringEvaluation = getStringEvaluation(key, defaultValueString, ctx);
Value evaluatedValue = new Value(stringEvaluation.getValue());
return ProviderEvaluation.<Value>builder().value(evaluatedValue).build();
}

@SneakyThrows
@Override
public void shutdown() {
super.shutdown();
log.info("shutdown");
if (prefabCloudClient != null) {
prefabCloudClient.close();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package dev.openfeature.contrib.providers.prefab;

import cloud.prefab.client.Options;
import lombok.Builder;
import lombok.Getter;

/**
* Options for initializing prefab provider.
*/
@Getter
@Builder
public class PrefabProviderConfig {
private Options options;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package dev.openfeature.contrib.providers.prefab;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import cloud.prefab.client.Options;
import cloud.prefab.context.PrefabContext;
import cloud.prefab.context.PrefabContextSet;
import cloud.prefab.context.PrefabContextSetReadable;
import dev.openfeature.sdk.Client;
import dev.openfeature.sdk.ImmutableContext;
import dev.openfeature.sdk.MutableContext;
import dev.openfeature.sdk.OpenFeatureAPI;
import dev.openfeature.sdk.ProviderEventDetails;
import dev.openfeature.sdk.Value;
import dev.openfeature.sdk.exceptions.GeneralError;
import java.io.File;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

/**
* PrefabProvider test, based local default config file.
*/
@Slf4j
class PrefabProviderTest {

public static final String FLAG_NAME = "sample_bool";
public static final String VARIANT_FLAG_NAME = "sample";
public static final String VARIANT_FLAG_VALUE = "test sample value";
public static final String INT_FLAG_NAME = "sample_int";
public static final Integer INT_FLAG_VALUE = 123;
public static final String DOUBLE_FLAG_NAME = "sample_double";
public static final Double DOUBLE_FLAG_VALUE = 12.12;
public static final String USERS_FLAG_NAME = "test1";
private static PrefabProvider prefabProvider;
private static Client client;

@BeforeAll
static void setUp() {
File localDataFile = new File("src/test/resources/features.json");
Options options = new Options()
.setPrefabDatasource(Options.Datasources.ALL)
.setLocalDatafile(localDataFile.toString())
.setInitializationTimeoutSec(10);
PrefabProviderConfig prefabProviderConfig =
PrefabProviderConfig.builder().options(options).build();
prefabProvider = new PrefabProvider(prefabProviderConfig);
OpenFeatureAPI.getInstance().setProviderAndWait(prefabProvider);
client = OpenFeatureAPI.getInstance().getClient();
}

@AfterAll
static void shutdown() {
prefabProvider.shutdown();
}

@Test
void getBooleanEvaluation() {
assertEquals(
true,
prefabProvider
.getBooleanEvaluation(FLAG_NAME, false, new ImmutableContext())
.getValue());
assertEquals(true, client.getBooleanValue(FLAG_NAME, false));
assertEquals(
false,
prefabProvider
.getBooleanEvaluation("non-existing", false, new ImmutableContext())
.getValue());
assertEquals(false, client.getBooleanValue("non-existing", false));
}

@Test
void getStringEvaluation() {
assertEquals(
VARIANT_FLAG_VALUE,
prefabProvider
.getStringEvaluation(VARIANT_FLAG_NAME, "", new ImmutableContext())
.getValue());
assertEquals(VARIANT_FLAG_VALUE, client.getStringValue(VARIANT_FLAG_NAME, ""));
assertEquals(
"fallback_str",
prefabProvider
.getStringEvaluation("non-existing", "fallback_str", new ImmutableContext())
.getValue());
assertEquals("fallback_str", client.getStringValue("non-existing", "fallback_str"));
}

@Test
void getObjectEvaluation() {
assertEquals(
VARIANT_FLAG_VALUE,
prefabProvider
.getStringEvaluation(VARIANT_FLAG_NAME, "", new ImmutableContext())
.getValue());
assertEquals(new Value(VARIANT_FLAG_VALUE), client.getObjectValue(VARIANT_FLAG_NAME, new Value("")));
assertEquals(
new Value("fallback_str"),
prefabProvider
.getObjectEvaluation("non-existing", new Value("fallback_str"), new ImmutableContext())
.getValue());
assertEquals(new Value("fallback_str"), client.getObjectValue("non-existing", new Value("fallback_str")));
}

@Test
void getIntegerEvaluation() {
MutableContext evaluationContext = new MutableContext();
assertEquals(
INT_FLAG_VALUE,
prefabProvider
.getIntegerEvaluation(INT_FLAG_NAME, 1, evaluationContext)
.getValue());
assertEquals(INT_FLAG_VALUE, client.getIntegerValue(INT_FLAG_NAME, 1));
assertEquals(1, client.getIntegerValue("non-existing", 1));

// non-number flag value
assertEquals(1, client.getIntegerValue(VARIANT_FLAG_NAME, 1));
}

@Test
void getDoubleEvaluation() {
MutableContext evaluationContext = new MutableContext();
assertEquals(
DOUBLE_FLAG_VALUE,
prefabProvider
.getDoubleEvaluation(DOUBLE_FLAG_NAME, 1.1, evaluationContext)
.getValue());
assertEquals(DOUBLE_FLAG_VALUE, client.getDoubleValue(DOUBLE_FLAG_NAME, 1.1));
assertEquals(1.1, client.getDoubleValue("non-existing", 1.1));

// non-number flag value
assertEquals(1.1, client.getDoubleValue(VARIANT_FLAG_NAME, 1.1));
}

@Test
void getBooleanEvaluationByUser() {
MutableContext evaluationContext = new MutableContext();
evaluationContext.add("user.key", "key1");
evaluationContext.add("team.domain", "prefab.cloud");

assertEquals(
true,
prefabProvider
.getBooleanEvaluation(USERS_FLAG_NAME, false, evaluationContext)
.getValue());
assertEquals(true, client.getBooleanValue(USERS_FLAG_NAME, false, evaluationContext));
evaluationContext.add("team.domain", "other.com");
assertEquals(
false,
prefabProvider
.getBooleanEvaluation(USERS_FLAG_NAME, false, evaluationContext)
.getValue());
assertEquals(false, client.getBooleanValue(USERS_FLAG_NAME, false, evaluationContext));
}

@SneakyThrows
@Test
void shouldThrowIfNotInitialized() {
Options options = new Options()
.setApikey("test-sdk-key")
.setPrefabDatasource(Options.Datasources.LOCAL_ONLY)
.setInitializationTimeoutSec(10);
PrefabProviderConfig prefabProviderConfig =
PrefabProviderConfig.builder().options(options).build();
PrefabProvider tempPrefabProvider = new PrefabProvider(prefabProviderConfig);

OpenFeatureAPI.getInstance().setProviderAndWait("tempPrefabProvider", tempPrefabProvider);

assertThrows(GeneralError.class, () -> tempPrefabProvider.initialize(null));

tempPrefabProvider.shutdown();
}

@Test
void eventsTest() {
prefabProvider.emitProviderReady(ProviderEventDetails.builder().build());
prefabProvider.emitProviderError(ProviderEventDetails.builder().build());
assertDoesNotThrow(() -> prefabProvider.emitProviderConfigurationChanged(
ProviderEventDetails.builder().build()));
}

@SneakyThrows
@Test
void contextTransformTest() {

MutableContext evaluationContext = new MutableContext();
evaluationContext.add("user.key", "key1");
evaluationContext.add("team.domain", "prefab.cloud");

PrefabContextSet expectedContext = PrefabContextSet.from(
PrefabContext.newBuilder("user").put("key", "key1").build(),
PrefabContext.newBuilder("team").put("domain", "prefab.cloud").build());
PrefabContextSetReadable transformedContext = ContextTransformer.transform(evaluationContext);

// equals not implemented for User, using toString
assertEquals(expectedContext.toString(), transformedContext.toString());
}
}
183 changes: 183 additions & 0 deletions providers/prefab/src/test/resources/features.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
{
"configs": [
{
"id": "17235540036203003",
"projectId": "453",
"key": "sample_int",
"changedBy": {
"userId": "878",
"email": "liran2000@gmail.com"
},
"rows": [
{
"projectEnvId": "962",
"values": [
{
"value": {
"int": "123"
}
}
]
}
],
"allowableValues": [
{
"int": "123"
}
],
"configType": "FEATURE_FLAG",
"valueType": "INT"
},
{
"id": "17235541126207669",
"projectId": "453",
"key": "sample_double",
"changedBy": {
"userId": "878",
"email": "liran2000@gmail.com"
},
"rows": [
{
"projectEnvId": "962",
"values": [
{
"value": {
"double": 12.12
}
}
]
}
],
"allowableValues": [
{
"double": 12.12
}
],
"configType": "FEATURE_FLAG",
"valueType": "DOUBLE"
},
{
"id": "17235541571344121",
"projectId": "453",
"key": "sample_bool",
"changedBy": {
"userId": "878",
"email": "liran2000@gmail.com"
},
"rows": [
{
"projectEnvId": "962",
"values": [
{
"value": {
"bool": true
}
}
]
}
],
"allowableValues": [
{
"bool": false
},
{
"bool": true
}
],
"configType": "FEATURE_FLAG",
"valueType": "BOOL"
},
{
"id": "17235603983939168",
"projectId": "453",
"key": "test1",
"changedBy": {
"userId": "878",
"email": "liran2000@gmail.com"
},
"rows": [
{
"projectEnvId": "962",
"values": [
{
"criteria": [
{
"propertyName": "user.key",
"operator": "PROP_IS_ONE_OF",
"valueToMatch": {
"stringList": {
"values": [
"key1"
]
}
}
},
{
"propertyName": "team.domain",
"operator": "PROP_IS_ONE_OF",
"valueToMatch": {
"stringList": {
"values": [
"prefab.cloud"
]
}
}
}
],
"value": {
"bool": true
}
},
{
"value": {
"bool": false
}
}
]
}
],
"allowableValues": [
{
"bool": false
},
{
"bool": true
}
],
"configType": "FEATURE_FLAG",
"valueType": "BOOL"
},
{
"id": "17235608162176898",
"projectId": "453",
"key": "sample",
"changedBy": {
"userId": "878",
"email": "liran2000@gmail.com"
},
"rows": [
{
"projectEnvId": "962",
"values": [
{
"value": {
"string": "test sample value"
}
}
]
}
],
"allowableValues": [
{
"string": "test sample value"
}
],
"configType": "FEATURE_FLAG",
"valueType": "STRING"
}
],
"configServicePointer": {
"projectId": "453",
"projectEnvId": "962"
}
}
13 changes: 13 additions & 0 deletions providers/prefab/src/test/resources/log4j2-test.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="INFO">
<Appenders>
<Console name="consoleLogger" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="consoleLogger"/>
</Root>
</Loggers>
</Configuration>
1 change: 1 addition & 0 deletions providers/prefab/version.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.0.1