diff --git a/.github/component_owners.yml b/.github/component_owners.yml index c5641fde7..61d24eb6b 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -35,6 +35,9 @@ components: - novalisdenahi providers/statsig: - liran2000 + providers/prefab: + - liran2000 + - jkebinger providers/multiprovider: - liran2000 tools/flagd-http-connector: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 27073e080..e5da39301 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -10,6 +10,7 @@ "providers/configcat": "0.1.0", "providers/statsig": "0.1.0", "providers/multiprovider": "0.0.1", + "providers/prefab": "0.0.1", "tools/junit-openfeature": "0.1.2", "tools/flagd-http-connector": "0.0.2", ".": "0.2.2" diff --git a/pom.xml b/pom.xml index 91b920c12..e63e88424 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ providers/flipt providers/configcat providers/statsig + providers/prefab providers/multiprovider tools/flagd-http-connector diff --git a/providers/prefab/CHANGELOG.md b/providers/prefab/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/providers/prefab/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/providers/prefab/README.md b/providers/prefab/README.md new file mode 100644 index 000000000..654f80a85 --- /dev/null +++ b/providers/prefab/README.md @@ -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 + + + +```xml + + + dev.openfeature.contrib.providers + prefab + 0.0.1 + +``` + + + +## 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. diff --git a/providers/prefab/lombok.config b/providers/prefab/lombok.config new file mode 100644 index 000000000..bcd1afdae --- /dev/null +++ b/providers/prefab/lombok.config @@ -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 diff --git a/providers/prefab/pom.xml b/providers/prefab/pom.xml new file mode 100644 index 000000000..ebb7bd279 --- /dev/null +++ b/providers/prefab/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + 0.1.0 + ../../pom.xml + + dev.openfeature.contrib.providers + prefab + 0.0.1 + + prefab + Prefab provider for Java + https://www.prefab.cloud + + + + cloud.prefab + client + 0.3.20 + + + + org.slf4j + slf4j-api + 2.0.16 + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.23.1 + test + + + + diff --git a/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/ContextTransformer.java b/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/ContextTransformer.java new file mode 100644 index 000000000..ebc7dafa7 --- /dev/null +++ b/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/ContextTransformer.java @@ -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 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; + } +} diff --git a/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/PrefabProvider.java b/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/PrefabProvider.java new file mode 100644 index 000000000..d795c1294 --- /dev/null +++ b/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/PrefabProvider.java @@ -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 getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { + PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx); + Boolean evaluatedValue = prefabCloudClient.featureFlagClient().featureIsOn(key, context); + return ProviderEvaluation.builder().value(evaluatedValue).build(); + } + + @Override + public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) { + PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx); + String evaluatedValue = defaultValue; + Optional opt = prefabCloudClient.featureFlagClient().get(key, context); + if (opt.isPresent() + && Prefab.ConfigValue.TypeCase.STRING.equals(opt.get().getTypeCase())) { + evaluatedValue = opt.get().getString(); + } + return ProviderEvaluation.builder().value(evaluatedValue).build(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { + PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx); + Integer evaluatedValue = defaultValue; + Optional 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.builder().value(evaluatedValue).build(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { + PrefabContextSetReadable context = ctx == null ? null : ContextTransformer.transform(ctx); + Double evaluatedValue = defaultValue; + Optional opt = prefabCloudClient.featureFlagClient().get(key, context); + if (opt.isPresent() + && Prefab.ConfigValue.TypeCase.DOUBLE.equals(opt.get().getTypeCase())) { + evaluatedValue = opt.get().getDouble(); + } + return ProviderEvaluation.builder().value(evaluatedValue).build(); + } + + @SneakyThrows + @Override + public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { + String defaultValueString = defaultValue == null ? null : defaultValue.asString(); + ProviderEvaluation stringEvaluation = getStringEvaluation(key, defaultValueString, ctx); + Value evaluatedValue = new Value(stringEvaluation.getValue()); + return ProviderEvaluation.builder().value(evaluatedValue).build(); + } + + @SneakyThrows + @Override + public void shutdown() { + super.shutdown(); + log.info("shutdown"); + if (prefabCloudClient != null) { + prefabCloudClient.close(); + } + } +} diff --git a/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/PrefabProviderConfig.java b/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/PrefabProviderConfig.java new file mode 100644 index 000000000..6d3c9a977 --- /dev/null +++ b/providers/prefab/src/main/java/dev/openfeature/contrib/providers/prefab/PrefabProviderConfig.java @@ -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; +} diff --git a/providers/prefab/src/test/java/dev/openfeature/contrib/providers/prefab/PrefabProviderTest.java b/providers/prefab/src/test/java/dev/openfeature/contrib/providers/prefab/PrefabProviderTest.java new file mode 100644 index 000000000..f9ae9351c --- /dev/null +++ b/providers/prefab/src/test/java/dev/openfeature/contrib/providers/prefab/PrefabProviderTest.java @@ -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()); + } +} diff --git a/providers/prefab/src/test/resources/features.json b/providers/prefab/src/test/resources/features.json new file mode 100644 index 000000000..5e6e29fcb --- /dev/null +++ b/providers/prefab/src/test/resources/features.json @@ -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" + } +} \ No newline at end of file diff --git a/providers/prefab/src/test/resources/log4j2-test.xml b/providers/prefab/src/test/resources/log4j2-test.xml new file mode 100644 index 000000000..aced30f8a --- /dev/null +++ b/providers/prefab/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/providers/prefab/version.txt b/providers/prefab/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/providers/prefab/version.txt @@ -0,0 +1 @@ +0.0.1