From b3e31d5554c1d37c81eeb43d8c1783743692761d Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 10 Feb 2026 15:58:36 -0500 Subject: [PATCH] feat: extract core flagd module Signed-off-by: Todd Baert --- .github/workflows/release-please.yml | 3 +- .gitmodules | 3 + .release-please-manifest.json | 2 + pom.xml | 2 + providers/flagd/pom.xml | 85 +-- .../resolver/process/InProcessResolver.java | 173 +----- .../resolver/process/storage/FlagStore.java | 83 +-- .../resolver/process/storage/Storage.java | 2 - .../process/storage/StorageQueryResult.java | 24 - .../targeting/TargetingRuleException.java | 10 - .../resolver/process/targeting/flag.json | 14 - .../providers/flagd/FlagdProviderTest.java | 2 +- .../process/InProcessResolverTest.java | 105 +--- .../flagd/resolver/process/MockEvaluator.java | 195 ++++++ .../flagd/resolver/process/MockFlags.java | 2 +- .../flagd/resolver/process/MockStorage.java | 18 +- .../process/storage/FlagStoreTest.java | 49 +- .../connector/file/FileConnectorTest.java | 34 +- release-please-config.json | 22 + tools/flagd-api/CHANGELOG.md | 2 + tools/flagd-api/README.md | 53 ++ tools/flagd-api/pom.xml | 36 ++ .../contrib/tools/flagd/api/Evaluator.java | 85 +++ .../tools/flagd/api/FlagStoreException.java | 26 + tools/flagd-api/version.txt | 1 + tools/flagd-core/CHANGELOG.md | 2 + tools/flagd-core/README.md | 47 ++ tools/flagd-core/lombok.config | 2 + tools/flagd-core/pom.xml | 159 +++++ tools/flagd-core/schemas | 1 + .../contrib/tools/flagd/core/FlagdCore.java | 326 ++++++++++ .../tools/flagd/core}/model/FeatureFlag.java | 34 +- .../tools/flagd/core}/model/FlagParser.java | 29 +- .../flagd/core/model/FlagParsingResult.java | 12 +- .../flagd/core}/model/StringSerializer.java | 9 +- .../flagd/core}/targeting/Fractional.java | 15 +- .../tools/flagd/core}/targeting/Operator.java | 20 +- .../tools/flagd/core}/targeting/SemVer.java | 7 +- .../flagd/core}/targeting/StringComp.java | 10 +- .../targeting/TargetingRuleException.java | 17 + .../main/resources/flagd/schemas/.gitignore | 3 + .../main/resources/flagd/schemas/flags.json | 216 +++++++ .../resources/flagd/schemas/targeting.json | 584 ++++++++++++++++++ .../tools/flagd/core/FlagdCoreTest.java | 127 ++++ .../contrib/tools/flagd/core}/TestUtils.java | 5 +- .../flagd/core}/model/FlagParserTest.java | 28 +- .../flagd/core}/targeting/FractionalTest.java | 4 +- .../flagd/core}/targeting/OperatorTest.java | 5 +- .../flagd/core}/targeting/SemVerTest.java | 2 +- .../flagd/core}/targeting/StringCompTest.java | 2 +- .../invalid-configuration.json | 0 .../invalid-flag-multiple-errors.json | 0 .../invalid-flag-set-metadata.json | 0 .../flagConfigurations/invalid-flag.json | 0 .../flagConfigurations/invalid-metadata.json | 0 .../flagConfigurations/updatableFlags.json | 0 .../valid-flag-set-metadata.json | 0 .../flagConfigurations/valid-long.json | 0 .../valid-simple-with-extra-fields.json | 0 .../flagConfigurations/valid-simple.json | 0 .../src/test/resources/flags/test-flags.json | 46 ++ .../test/resources/fractional/1-1-1-1.json | 0 .../resources/fractional/25-25-25-25.json | 0 .../src/test/resources/fractional/50-50.json | 0 .../fractional/notEnoughBuckets.json | 0 .../fractional/selfContainedFractionalA.json | 0 .../fractional/selfContainedFractionalB.json | 0 .../resources/fractional/sum-greater-100.json | 0 .../fractional/sum-lower-100.json.json | 0 .../test/resources/fractional/template.json | 0 .../fractional/weighting-not-set.json | 0 tools/flagd-core/version.txt | 1 + 72 files changed, 2196 insertions(+), 548 deletions(-) delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java delete mode 100644 providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json create mode 100644 providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockEvaluator.java create mode 100644 tools/flagd-api/CHANGELOG.md create mode 100644 tools/flagd-api/README.md create mode 100644 tools/flagd-api/pom.xml create mode 100644 tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/Evaluator.java create mode 100644 tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/FlagStoreException.java create mode 100644 tools/flagd-api/version.txt create mode 100644 tools/flagd-core/CHANGELOG.md create mode 100644 tools/flagd-core/README.md create mode 100644 tools/flagd-core/lombok.config create mode 100644 tools/flagd-core/pom.xml create mode 160000 tools/flagd-core/schemas create mode 100644 tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/model/FeatureFlag.java (68%) rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/model/FlagParser.java (85%) rename providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParsingResult.java (61%) rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/model/StringSerializer.java (76%) rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/targeting/Fractional.java (92%) rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/targeting/Operator.java (83%) rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/targeting/SemVer.java (95%) rename {providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core}/targeting/StringComp.java (90%) create mode 100644 tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/TargetingRuleException.java create mode 100644 tools/flagd-core/src/main/resources/flagd/schemas/.gitignore create mode 100644 tools/flagd-core/src/main/resources/flagd/schemas/flags.json create mode 100644 tools/flagd-core/src/main/resources/flagd/schemas/targeting.json create mode 100644 tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java rename {providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core}/TestUtils.java (88%) rename {providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core}/model/FlagParserTest.java (83%) rename {providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core}/targeting/FractionalTest.java (94%) rename {providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core}/targeting/OperatorTest.java (97%) rename {providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core}/targeting/SemVerTest.java (96%) rename {providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process => tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core}/targeting/StringCompTest.java (96%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/invalid-configuration.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/invalid-flag-multiple-errors.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/invalid-flag.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/invalid-metadata.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/updatableFlags.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/valid-flag-set-metadata.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/valid-long.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/valid-simple-with-extra-fields.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/flagConfigurations/valid-simple.json (100%) create mode 100644 tools/flagd-core/src/test/resources/flags/test-flags.json rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/1-1-1-1.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/25-25-25-25.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/50-50.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/notEnoughBuckets.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/selfContainedFractionalA.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/selfContainedFractionalB.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/sum-greater-100.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/sum-lower-100.json.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/template.json (100%) rename {providers/flagd => tools/flagd-core}/src/test/resources/fractional/weighting-not-set.json (100%) create mode 100644 tools/flagd-core/version.txt diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2575345ce..de9a136a4 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -65,11 +65,12 @@ jobs: # This means there's no way to skip publishing of a particular module in a multi-module build, so we iterate over each module and publish them individually, # letting exists-maven-plugin skip the nexus-staging-maven-plugin's entire deploy goal if the artifact exists. run: | + FLAGD_CORE_VERSION=$(cat tools/flagd-core/version.txt) mvn --non-recursive --batch-mode --settings release/m2-settings.xml -DskipTests -Dcheckstyle.skip clean deploy modules=($(cat pom.xml | grep "" | sed 's/\s*<.*>\(.*\)<.*>/\1/')) for module in "${modules[@]}" do - mvn --batch-mode --projects $module --settings release/m2-settings.xml -DskipTests -Dcheckstyle.skip clean deploy + mvn --batch-mode --projects $module --settings release/m2-settings.xml -DskipTests -Dcheckstyle.skip -Dflagd-core.version=$FLAGD_CORE_VERSION clean deploy done env: CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} diff --git a/.gitmodules b/.gitmodules index f2f505194..d9d154a22 100644 --- a/.gitmodules +++ b/.gitmodules @@ -11,3 +11,6 @@ [submodule "providers/go-feature-flag/wasm-releases"] path = providers/go-feature-flag/wasm-releases url = https://github.com/go-feature-flag/wasm-releases.git +[submodule "tools/flagd-core/schemas"] + path = tools/flagd-core/schemas + url = https://github.com/open-feature/schemas.git diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 241a24249..cacd07b8f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -13,5 +13,7 @@ "providers/ofrep": "0.0.1", "tools/junit-openfeature": "0.2.1", "tools/flagd-http-connector": "0.0.4", + "tools/flagd-api": "0.0.1", + "tools/flagd-core": "0.0.1", ".": "1.0.0" } diff --git a/pom.xml b/pom.xml index dd81901b2..fcbca317a 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,8 @@ hooks/open-telemetry tools/junit-openfeature + tools/flagd-api + tools/flagd-core providers/flagd providers/flagsmith providers/go-feature-flag diff --git a/providers/flagd/pom.xml b/providers/flagd/pom.xml index 47073039a..525fc174f 100644 --- a/providers/flagd/pom.xml +++ b/providers/flagd/pom.xml @@ -19,6 +19,8 @@ 1.79.0 3.25.6 + + [0.0.1,) flagd @@ -37,6 +39,13 @@ + + + dev.openfeature.contrib.tools + flagd-core + ${flagd-core.version} + + com.google.protobuf protobuf-java @@ -62,37 +71,6 @@ - - com.fasterxml.jackson.core - jackson-databind - 2.20.1 - - - - io.github.jamsesso - json-logic-java - 1.1.0 - - - - - com.google.code.gson - gson - 2.13.1 - - - - com.networknt - json-schema-validator - 1.5.9 - - - org.apache.commons - commons-lang3 - - - - org.apache.tomcat @@ -107,30 +85,12 @@ 4.5.0 - - org.apache.commons - commons-lang3 - 3.20.0 - - io.opentelemetry opentelemetry-api 1.56.0 - - org.semver4j - semver4j - 5.8.0 - - - - commons-codec - commons-codec - 1.20.0 - - org.junit.jupiter junit-jupiter @@ -205,7 +165,7 @@ exec - + git submodule @@ -217,31 +177,6 @@ - - maven-resources-plugin - 3.3.1 - - - copy-json-schemas - generate-resources - - copy-resources - - - ${basedir}/src/main/resources/flagd/schemas/ - - - ${basedir}/schemas/json/ - - flags.json - targeting.json - - - - - - - org.xolstice.maven.plugins diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java index ba69b3ad7..7132c7614 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolver.java @@ -1,37 +1,26 @@ package dev.openfeature.contrib.providers.flagd.resolver.process; -import static dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag.EMPTY_TARGETING_STRING; - import dev.openfeature.contrib.providers.flagd.FlagdOptions; import dev.openfeature.contrib.providers.flagd.resolver.Resolver; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.FlagStore; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageQueryResult; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.file.FileQueueSource; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.SyncStreamQueueSource; -import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator; -import dev.openfeature.contrib.providers.flagd.resolver.process.targeting.TargetingRuleException; +import dev.openfeature.contrib.tools.flagd.api.Evaluator; +import dev.openfeature.contrib.tools.flagd.core.FlagdCore; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.ProviderEvent; import dev.openfeature.sdk.ProviderEventDetails; -import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; -import dev.openfeature.sdk.exceptions.GeneralError; -import dev.openfeature.sdk.exceptions.ParseError; -import dev.openfeature.sdk.exceptions.TypeMismatchError; import dev.openfeature.sdk.internal.TriConsumer; -import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; /** * Resolves flag values using @@ -44,8 +33,7 @@ public class InProcessResolver implements Resolver { static final String STATE_WATCHER_THREAD_NAME = "InProcessResolver.stateWatcher"; private final Storage flagStore; private final TriConsumer onConnectionEvent; - private final Operator operator; - private final String scope; + private final Evaluator evaluator; private final QueueSource queueSource; private final AtomicBoolean shutdown = new AtomicBoolean(false); private final AtomicReference stateWatcher = new AtomicReference<>(); @@ -62,10 +50,10 @@ public class InProcessResolver implements Resolver { public InProcessResolver( FlagdOptions options, TriConsumer onConnectionEvent) { this.queueSource = getQueueSource(options); - this.flagStore = new FlagStore(queueSource); + Evaluator flagdCore = new FlagdCore(); + this.evaluator = flagdCore; + this.flagStore = new FlagStore(queueSource, flagdCore); this.onConnectionEvent = onConnectionEvent; - this.operator = new Operator(); - this.scope = options.getSelector(); } /** @@ -157,44 +145,35 @@ public void shutdown() throws InterruptedException { * Resolve a boolean flag. */ public ProviderEvaluation booleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) { - return resolve(Boolean.class, key, ctx); + return evaluator.resolveBooleanValue(key, ctx); } /** * Resolve a string flag. */ public ProviderEvaluation stringEvaluation(String key, String defaultValue, EvaluationContext ctx) { - return resolve(String.class, key, ctx); + return evaluator.resolveStringValue(key, ctx); } /** * Resolve a double flag. */ public ProviderEvaluation doubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) { - return resolve(Double.class, key, ctx); + return evaluator.resolveDoubleValue(key, ctx); } /** * Resolve an integer flag. */ public ProviderEvaluation integerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) { - return resolve(Integer.class, key, ctx); + return evaluator.resolveIntegerValue(key, ctx); } /** * Resolve an object flag. */ public ProviderEvaluation objectEvaluation(String key, Value defaultValue, EvaluationContext ctx) { - final ProviderEvaluation evaluation = resolve(Object.class, key, ctx); - - return ProviderEvaluation.builder() - .value(Value.objectToValue(evaluation.getValue())) - .variant(evaluation.getVariant()) - .reason(evaluation.getReason()) - .errorCode(evaluation.getErrorCode()) - .errorMessage(evaluation.getErrorMessage()) - .flagMetadata(evaluation.getFlagMetadata()) - .build(); + return evaluator.resolveObjectValue(key, ctx); } static QueueSource getQueueSource(final FlagdOptions options) { @@ -206,134 +185,4 @@ static QueueSource getQueueSource(final FlagdOptions options) { ? new FileQueueSource(options.getOfflineFlagSourcePath(), options.getOfflinePollIntervalMs()) : new SyncStreamQueueSource(options); } - - private ProviderEvaluation resolve(Class type, String key, EvaluationContext ctx) { - final StorageQueryResult storageQueryResult = flagStore.getFlag(key); - final FeatureFlag flag = storageQueryResult.getFeatureFlag(); - - // missing flag - if (flag == null) { - return ProviderEvaluation.builder() - .errorMessage("flag: " + key + " not found") - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .flagMetadata(getFlagMetadata(storageQueryResult)) - .build(); - } - - // state check - if ("DISABLED".equals(flag.getState())) { - return ProviderEvaluation.builder() - .errorMessage("flag: " + key + " is disabled") - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .flagMetadata(getFlagMetadata(storageQueryResult)) - .build(); - } - - final String resolvedVariant; - final String reason; - - if (EMPTY_TARGETING_STRING.equals(flag.getTargeting())) { - resolvedVariant = flag.getDefaultVariant(); - reason = Reason.STATIC.toString(); - } else { - try { - final Object jsonResolved = operator.apply(key, flag.getTargeting(), ctx); - if (jsonResolved == null) { - resolvedVariant = flag.getDefaultVariant(); - reason = Reason.DEFAULT.toString(); - } else { - resolvedVariant = jsonResolved.toString(); // convert to string to support shorthand - reason = Reason.TARGETING_MATCH.toString(); - } - } catch (TargetingRuleException e) { - String message = String.format("error evaluating targeting rule for flag %s", key); - log.debug(message, e); - throw new ParseError(message); - } - } - - // check variant existence - Object value = flag.getVariants().get(resolvedVariant); - if (value == null) { - if (StringUtils.isEmpty(resolvedVariant) && StringUtils.isEmpty(flag.getDefaultVariant())) { - return ProviderEvaluation.builder() - .reason(Reason.ERROR.toString()) - .errorCode(ErrorCode.FLAG_NOT_FOUND) - .errorMessage("Flag '" + key + "' has no default variant defined, will use code default") - .flagMetadata(getFlagMetadata(storageQueryResult)) - .build(); - } - - String message = String.format("variant %s not found in flag with key %s", resolvedVariant, key); - log.debug(message); - throw new GeneralError(message); - } - if (value instanceof Integer && type == Double.class) { - // if this is an integer and we are trying to resolve a double, convert - value = ((Integer) value).doubleValue(); - } else if (value instanceof Double && type == Integer.class) { - // if this is a double and we are trying to resolve an integer, convert - value = ((Double) value).intValue(); - } - if (!type.isAssignableFrom(value.getClass())) { - String message = "returning default variant for flagKey: %s, type not valid"; - log.debug(String.format(message, key)); - throw new TypeMismatchError(message); - } - - return ProviderEvaluation.builder() - .value((T) value) - .variant(resolvedVariant) - .reason(reason) - .flagMetadata(getFlagMetadata(storageQueryResult)) - .build(); - } - - private ImmutableMetadata getFlagMetadata(StorageQueryResult storageQueryResult) { - ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder(); - for (Map.Entry entry : - storageQueryResult.getFlagSetMetadata().entrySet()) { - addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); - } - - if (scope != null) { - metadataBuilder.addString("scope", scope); - } - - FeatureFlag flag = storageQueryResult.getFeatureFlag(); - if (flag != null) { - for (Map.Entry entry : flag.getMetadata().entrySet()) { - addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); - } - } - - return metadataBuilder.build(); - } - - private void addEntryToMetadataBuilder( - ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, String key, Object value) { - if (value instanceof Number) { - if (value instanceof Long) { - metadataBuilder.addLong(key, (Long) value); - return; - } else if (value instanceof Double) { - metadataBuilder.addDouble(key, (Double) value); - return; - } else if (value instanceof Integer) { - metadataBuilder.addInteger(key, (Integer) value); - return; - } else if (value instanceof Float) { - metadataBuilder.addFloat(key, (Float) value); - return; - } - } else if (value instanceof Boolean) { - metadataBuilder.addBoolean(key, (Boolean) value); - return; - } else if (value instanceof String) { - metadataBuilder.addString(key, (String) value); - return; - } - throw new IllegalArgumentException( - "The type of the Metadata entry with key " + key + " and value " + value + " is not supported"); - } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java index b6bfff1ff..21dd7c0e6 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStore.java @@ -3,25 +3,16 @@ import static dev.openfeature.contrib.providers.flagd.resolver.common.Convert.convertProtobufMapToStructure; import com.google.protobuf.Struct; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FlagParser; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.ParsingResult; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueueSource; +import dev.openfeature.contrib.tools.flagd.api.Evaluator; import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.Structure; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock; -import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; /** Feature flag storage. */ @@ -30,25 +21,15 @@ value = {"EI_EXPOSE_REP"}, justification = "Feature flag comes as a Json configuration, hence they must be exposed") public class FlagStore implements Storage { - private final ReentrantReadWriteLock sync = new ReentrantReadWriteLock(); - private final ReadLock readLock = sync.readLock(); - private final WriteLock writeLock = sync.writeLock(); - private final AtomicBoolean shutdown = new AtomicBoolean(false); private final BlockingQueue stateBlockingQueue = new LinkedBlockingQueue<>(4); - private final Map flags = new HashMap<>(); - private final Map flagSetMetadata = new HashMap<>(); private final QueueSource connector; - private final boolean throwIfInvalid; - - public FlagStore(final QueueSource connector) { - this(connector, false); - } + private final Evaluator evaluator; - public FlagStore(final QueueSource connector, final boolean throwIfInvalid) { + public FlagStore(final QueueSource connector, final Evaluator evaluator) { this.connector = connector; - this.throwIfInvalid = throwIfInvalid; + this.evaluator = evaluator; } /** Initialize storage layer. */ @@ -81,21 +62,6 @@ public void shutdown() throws InterruptedException { connector.shutdown(); } - /** Retrieve flag for the given key and the flag set metadata. */ - @Override - public StorageQueryResult getFlag(final String key) { - readLock.lock(); - FeatureFlag flag; - Map metadata; - try { - flag = flags.get(key); - metadata = new HashMap<>(flagSetMetadata); - } finally { - readLock.unlock(); - } - return new StorageQueryResult(flag, metadata); - } - /** Retrieve blocking queue to check storage status. */ @Override public BlockingQueue getStateQueue() { @@ -110,22 +76,10 @@ private void streamerListener(final QueueSource connector) throws InterruptedExc switch (payload.getType()) { case DATA: try { - List changedFlagsKeys = Collections.emptyList(); - ParsingResult parsingResult = FlagParser.parseString(payload.getFlagData(), throwIfInvalid); - Map flagMap = parsingResult.getFlags(); - Map flagSetMetadataMap = parsingResult.getFlagSetMetadata(); - + // Delegate flag parsing to the evaluator + List changedFlagsKeys = evaluator.setFlagsAndGetChangedKeys(payload.getFlagData()); Structure syncContext = parseSyncContext(payload.getSyncContext()); - writeLock.lock(); - try { - changedFlagsKeys = getChangedFlagsKeys(flagMap); - flags.clear(); - flags.putAll(flagMap); - flagSetMetadata.clear(); - flagSetMetadata.putAll(flagSetMetadataMap); - } finally { - writeLock.unlock(); - } + if (!stateBlockingQueue.offer( new StorageStateChange(StorageState.OK, changedFlagsKeys, syncContext))) { log.warn("Failed to convey OK status, queue is full"); @@ -167,27 +121,4 @@ private Structure parseSyncContext(Struct syncContext) { } return new ImmutableStructure(); } - - private List getChangedFlagsKeys(Map newFlags) { - Map changedFlags = new HashMap<>(); - Map addedFeatureFlags = new HashMap<>(); - Map removedFeatureFlags = new HashMap<>(); - Map updatedFeatureFlags = new HashMap<>(); - newFlags.forEach((key, value) -> { - if (!flags.containsKey(key)) { - addedFeatureFlags.put(key, value); - } else if (flags.containsKey(key) && !value.equals(flags.get(key))) { - updatedFeatureFlags.put(key, value); - } - }); - flags.forEach((key, value) -> { - if (!newFlags.containsKey(key)) { - removedFeatureFlags.put(key, value); - } - }); - changedFlags.putAll(addedFeatureFlags); - changedFlags.putAll(removedFeatureFlags); - changedFlags.putAll(updatedFeatureFlags); - return changedFlags.keySet().stream().collect(Collectors.toList()); - } } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java index 38cd89e26..7e6e2720f 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/Storage.java @@ -8,7 +8,5 @@ public interface Storage { void shutdown() throws InterruptedException; - StorageQueryResult getFlag(final String key); - BlockingQueue getStateQueue(); } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java deleted file mode 100644 index b0dad0533..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageQueryResult.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.storage; - -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import java.util.Map; -import lombok.Getter; - -/** - * To be returned by the storage when a flag is queried. Contains the flag (iff a flag associated with the given key - * exists, null otherwise) and flag set metadata - */ -@Getter -@SuppressFBWarnings( - value = {"EI_EXPOSE_REP"}, - justification = "The storage provides access to both feature flags and flag set metadata") -public class StorageQueryResult { - private final FeatureFlag featureFlag; - private final Map flagSetMetadata; - - public StorageQueryResult(FeatureFlag featureFlag, Map flagSetMetadata) { - this.featureFlag = featureFlag; - this.flagSetMetadata = flagSetMetadata; - } -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java deleted file mode 100644 index 48522d7ad..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/TargetingRuleException.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; - -/** Exception used by targeting rule package. */ -public class TargetingRuleException extends Exception { - - /** Construct exception. */ - public TargetingRuleException(final String message, final Throwable t) { - super(message, t); - } -} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json deleted file mode 100644 index 079bb2c71..000000000 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/flag.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "targeting": - [ - "bucketKet", - [ - "red", - 50 - ], - [ - "blue", - 50 - ] - ] -} \ No newline at end of file diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java index ad38fe15d..4e3129184 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderTest.java @@ -21,11 +21,11 @@ import dev.openfeature.contrib.providers.flagd.resolver.common.ChannelConnector; import dev.openfeature.contrib.providers.flagd.resolver.process.InProcessResolver; import dev.openfeature.contrib.providers.flagd.resolver.process.MockStorage; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; import dev.openfeature.contrib.providers.flagd.resolver.rpc.RpcResolver; import dev.openfeature.contrib.providers.flagd.resolver.rpc.cache.Cache; +import dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag; import dev.openfeature.flagd.grpc.evaluation.Evaluation.ResolveBooleanRequest; import dev.openfeature.flagd.grpc.evaluation.Evaluation.ResolveBooleanResponse; import dev.openfeature.flagd.grpc.evaluation.Evaluation.ResolveFloatResponse; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java index 4af959861..115852878 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/InProcessResolverTest.java @@ -14,7 +14,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.mockito.Mockito.mock; @@ -23,15 +22,14 @@ import dev.openfeature.contrib.providers.flagd.Config; import dev.openfeature.contrib.providers.flagd.FlagdOptions; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.MockConnector; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageState; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.file.FileQueueSource; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.sync.SyncStreamQueueSource; +import dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag; import dev.openfeature.sdk.ErrorCode; import dev.openfeature.sdk.ImmutableContext; -import dev.openfeature.sdk.ImmutableMetadata; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.MutableStructure; import dev.openfeature.sdk.ProviderEvaluation; @@ -420,53 +418,15 @@ public void targetingErrorEvaluationFlag() throws Exception { } @Test - public void validateMetadataInEvaluationResult() throws Exception { - // given - final String scope = "appName=myApp"; - final Map flagMap = new HashMap<>(); - flagMap.put("booleanFlag", BOOLEAN_FLAG); - - InProcessResolver inProcessResolver = - getInProcessResolverWith(FlagdOptions.builder().selector(scope).build(), new MockStorage(flagMap)); - - // when - ProviderEvaluation providerEvaluation = - inProcessResolver.booleanEvaluation("booleanFlag", false, new ImmutableContext()); - - // then - final ImmutableMetadata metadata = providerEvaluation.getFlagMetadata(); - assertNotNull(metadata); - assertEquals(scope, metadata.getString("scope")); - } - - @Test - void selectorIsAddedToFlagMetadata() throws Exception { - // given - final Map flagMap = new HashMap<>(); - flagMap.put("flag", INT_FLAG); - - InProcessResolver inProcessResolver = - getInProcessResolverWith(new MockStorage(flagMap), (event, details, metadata) -> {}, "selector"); - - // when - ProviderEvaluation providerEvaluation = - inProcessResolver.integerEvaluation("flag", 0, new ImmutableContext()); - - // then - assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); - assertThat(providerEvaluation.getFlagMetadata().getString("scope")).isEqualTo("selector"); - } - - @Test - void selectorIsOverwrittenByFlagMetadata() throws Exception { + void flagMetadataIsReturned() throws Exception { // given final Map flagMap = new HashMap<>(); final Map flagMetadata = new HashMap<>(); - flagMetadata.put("scope", "new selector"); + flagMetadata.put("mymetadata", "some-data"); flagMap.put("flag", new FeatureFlag("stage", "loop", stringVariants, "", flagMetadata)); InProcessResolver inProcessResolver = - getInProcessResolverWith(new MockStorage(flagMap), (event, details, metadata) -> {}, "selector"); + getInProcessResolverWith(new MockStorage(flagMap), (event, details, metadata) -> {}); // when ProviderEvaluation providerEvaluation = @@ -474,7 +434,7 @@ void selectorIsOverwrittenByFlagMetadata() throws Exception { // then assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); - assertThat(providerEvaluation.getFlagMetadata().getString("scope")).isEqualTo("new selector"); + assertThat(providerEvaluation.getFlagMetadata().getString("mymetadata")).isEqualTo("some-data"); } @Test @@ -482,13 +442,13 @@ void flagSetMetadataIsAddedToEvaluation() throws Exception { // given final Map flagMap = new HashMap<>(); final Map flagMetadata = new HashMap<>(); - flagMetadata.put("scope", "new selector"); + flagMetadata.put("mymetadata", "some-data"); flagMap.put("flag", new FeatureFlag("stage", "loop", stringVariants, "", flagMetadata)); final Map flagSetMetadata = new HashMap<>(); flagSetMetadata.put("flagSetMetadata", "metadata"); - InProcessResolver inProcessResolver = getInProcessResolverWith( - new MockStorage(flagMap, flagSetMetadata), (event, details, metadata) -> {}, "selector"); + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap, flagSetMetadata), (event, details, metadata) -> {}); // when ProviderEvaluation providerEvaluation = @@ -496,7 +456,7 @@ void flagSetMetadataIsAddedToEvaluation() throws Exception { // then assertThat(providerEvaluation.getFlagMetadata()).isNotNull(); - assertThat(providerEvaluation.getFlagMetadata().getString("scope")).isEqualTo("new selector"); + assertThat(providerEvaluation.getFlagMetadata().getString("mymetadata")).isEqualTo("some-data"); assertThat(providerEvaluation.getFlagMetadata().getString("flagSetMetadata")) .isEqualTo("metadata"); } @@ -508,8 +468,8 @@ void flagSetMetadataIsAddedToFailingEvaluation() throws Exception { final Map flagSetMetadata = new HashMap<>(); flagSetMetadata.put("flagSetMetadata", "metadata"); - InProcessResolver inProcessResolver = getInProcessResolverWith( - new MockStorage(flagMap, flagSetMetadata), (event, details, metadata) -> {}, "selector"); + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap, flagSetMetadata), (event, details, metadata) -> {}); // when ProviderEvaluation providerEvaluation = @@ -532,8 +492,8 @@ void flagSetMetadataIsOverwrittenByFlagMetadataToEvaluation() throws Exception { final Map flagSetMetadata = new HashMap<>(); flagSetMetadata.put("key", "unexpected"); - InProcessResolver inProcessResolver = getInProcessResolverWith( - new MockStorage(flagMap, flagSetMetadata), (event, details, metadata) -> {}, "selector"); + InProcessResolver inProcessResolver = + getInProcessResolverWith(new MockStorage(flagMap, flagSetMetadata), (event, details, metadata) -> {}); // when ProviderEvaluation providerEvaluation = @@ -569,8 +529,9 @@ void testStateWatcherThreadIsCleanedUpDuringShutdown() throws Exception { // then assertThat(stateWatcherWasStarted).isTrue(); assertThat(threadCountAfterInit).isGreaterThan(initialThreadCount); - Awaitility.await().until(() -> !stateWatcher.isAlive()); - assertThat(currentDaemonThreadCount()).isEqualTo(initialThreadCount); + Awaitility.await().atMost(Duration.ofSeconds(5)).until(() -> !stateWatcher.isAlive()); + // Note: We don't assert exact thread count equality because other tests or JVM + // background threads can affect the count, making such assertions flaky in CI. } private long currentDaemonThreadCount() { @@ -579,13 +540,6 @@ private long currentDaemonThreadCount() { .count(); } - private InProcessResolver getInProcessResolverWith(final FlagdOptions options, final MockStorage storage) - throws NoSuchFieldException, IllegalAccessException { - - final InProcessResolver resolver = new InProcessResolver(options, (event, details, metadata) -> {}); - return injectFlagStore(resolver, storage); - } - private InProcessResolver getInProcessResolverWith( final MockStorage storage, final TriConsumer onConnectionEvent) @@ -593,27 +547,22 @@ private InProcessResolver getInProcessResolverWith( final InProcessResolver resolver = new InProcessResolver(FlagdOptions.builder().deadline(1000).build(), onConnectionEvent); - return injectFlagStore(resolver, storage); + return injectFlagStoreAndEvaluator(resolver, storage); } - private InProcessResolver getInProcessResolverWith( - final MockStorage storage, - final TriConsumer onConnectionEvent, - String selector) + // helper to inject flagStore and evaluator override + private InProcessResolver injectFlagStoreAndEvaluator(final InProcessResolver resolver, final MockStorage storage) throws NoSuchFieldException, IllegalAccessException { - final InProcessResolver resolver = new InProcessResolver( - FlagdOptions.builder().selector(selector).deadline(1000).build(), onConnectionEvent); - return injectFlagStore(resolver, storage); - } - - // helper to inject flagStore override - private InProcessResolver injectFlagStore(final InProcessResolver resolver, final MockStorage storage) - throws NoSuchFieldException, IllegalAccessException { + final Field flagStoreField = InProcessResolver.class.getDeclaredField("flagStore"); + flagStoreField.setAccessible(true); + flagStoreField.set(resolver, storage); - final Field flagStore = InProcessResolver.class.getDeclaredField("flagStore"); - flagStore.setAccessible(true); - flagStore.set(resolver, storage); + // Create a MockEvaluator that wraps the storage flags + MockEvaluator mockEvaluator = new MockEvaluator(storage); + final Field evaluatorField = InProcessResolver.class.getDeclaredField("evaluator"); + evaluatorField.setAccessible(true); + evaluatorField.set(resolver, mockEvaluator); return resolver; } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockEvaluator.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockEvaluator.java new file mode 100644 index 000000000..d92c502e8 --- /dev/null +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockEvaluator.java @@ -0,0 +1,195 @@ +package dev.openfeature.contrib.providers.flagd.resolver.process; + +import static dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag.EMPTY_TARGETING_STRING; + +import dev.openfeature.contrib.tools.flagd.api.Evaluator; +import dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag; +import dev.openfeature.contrib.tools.flagd.core.targeting.Operator; +import dev.openfeature.contrib.tools.flagd.core.targeting.TargetingRuleException; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; + +/** + * Mock evaluator for testing that wraps a MockStorage. + * Mimics the evaluation behavior of the old InProcessResolver. + */ +public class MockEvaluator implements Evaluator { + + private final MockStorage storage; + private final Operator operator; + + public MockEvaluator(MockStorage storage) { + this.storage = storage; + this.operator = new Operator(); + } + + @Override + public void setFlags(String flagConfigurationJson) { + // No-op for mock - flags are set via constructor + } + + @Override + public List setFlagsAndGetChangedKeys(String flagConfigurationJson) { + // No-op for mock - flags are set via constructor + return Collections.emptyList(); + } + + @Override + public Map getFlagSetMetadata() { + return storage.getFlagSetMetadata(); + } + + @Override + public ProviderEvaluation resolveBooleanValue(String flagKey, EvaluationContext ctx) { + return resolve(Boolean.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveStringValue(String flagKey, EvaluationContext ctx) { + return resolve(String.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveIntegerValue(String flagKey, EvaluationContext ctx) { + return resolve(Integer.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveDoubleValue(String flagKey, EvaluationContext ctx) { + return resolve(Double.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveObjectValue(String flagKey, EvaluationContext ctx) { + final ProviderEvaluation evaluation = resolve(Object.class, flagKey, ctx); + return ProviderEvaluation.builder() + .value(Value.objectToValue(evaluation.getValue())) + .variant(evaluation.getVariant()) + .reason(evaluation.getReason()) + .errorCode(evaluation.getErrorCode()) + .errorMessage(evaluation.getErrorMessage()) + .flagMetadata(evaluation.getFlagMetadata()) + .build(); + } + + private ProviderEvaluation resolve(Class type, String key, EvaluationContext ctx) { + final FeatureFlag flag = storage.getFlag(key); + final Map flagSetMetadata = storage.getFlagSetMetadata(); + + // missing flag + if (flag == null) { + return ProviderEvaluation.builder() + .errorMessage("flag: " + key + " not found") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(getFlagMetadata(flagSetMetadata, null)) + .build(); + } + + // state check + if ("DISABLED".equals(flag.getState())) { + return ProviderEvaluation.builder() + .errorMessage("flag: " + key + " is disabled") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(getFlagMetadata(flagSetMetadata, flag)) + .build(); + } + + final String resolvedVariant; + final String reason; + + if (EMPTY_TARGETING_STRING.equals(flag.getTargeting())) { + resolvedVariant = flag.getDefaultVariant(); + reason = Reason.STATIC.toString(); + } else { + try { + final Object jsonResolved = operator.apply(key, flag.getTargeting(), ctx); + if (jsonResolved == null) { + resolvedVariant = flag.getDefaultVariant(); + reason = Reason.DEFAULT.toString(); + } else { + resolvedVariant = jsonResolved.toString(); + reason = Reason.TARGETING_MATCH.toString(); + } + } catch (TargetingRuleException e) { + String message = String.format("error evaluating targeting rule for flag %s", key); + throw new ParseError(message); + } + } + + // check variant existence + Object value = flag.getVariants().get(resolvedVariant); + if (value == null) { + if (StringUtils.isEmpty(resolvedVariant) && StringUtils.isEmpty(flag.getDefaultVariant())) { + return ProviderEvaluation.builder() + .reason(Reason.ERROR.toString()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag '" + key + "' has no default variant defined, will use code default") + .flagMetadata(getFlagMetadata(flagSetMetadata, flag)) + .build(); + } + String message = String.format("variant %s not found in flag with key %s", resolvedVariant, key); + throw new GeneralError(message); + } + if (value instanceof Integer && type == Double.class) { + value = ((Integer) value).doubleValue(); + } else if (value instanceof Double && type == Integer.class) { + value = ((Double) value).intValue(); + } + if (!type.isAssignableFrom(value.getClass())) { + String message = "returning default variant for flagKey: %s, type not valid"; + throw new TypeMismatchError(message); + } + + return ProviderEvaluation.builder() + .value((T) value) + .variant(resolvedVariant) + .reason(reason) + .flagMetadata(getFlagMetadata(flagSetMetadata, flag)) + .build(); + } + + private ImmutableMetadata getFlagMetadata(Map flagSetMetadata, FeatureFlag flag) { + ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder(); + for (Map.Entry entry : flagSetMetadata.entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } + + if (flag != null) { + for (Map.Entry entry : flag.getMetadata().entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } + } + + return metadataBuilder.build(); + } + + private void addEntryToMetadataBuilder( + ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, String key, Object value) { + if (value instanceof Number) { + if (value instanceof Long) { + metadataBuilder.addLong(key, (Long) value); + } else if (value instanceof Double) { + metadataBuilder.addDouble(key, (Double) value); + } else if (value instanceof Integer) { + metadataBuilder.addInteger(key, (Integer) value); + } else if (value instanceof Float) { + metadataBuilder.addFloat(key, (Float) value); + } + } else if (value instanceof Boolean) { + metadataBuilder.addBoolean(key, (Boolean) value); + } else if (value instanceof String) { + metadataBuilder.addString(key, (String) value); + } + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockFlags.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockFlags.java index fef135cc9..6c01454c4 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockFlags.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockFlags.java @@ -1,6 +1,6 @@ package dev.openfeature.contrib.providers.flagd.resolver.process; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; +import dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag; import java.util.HashMap; import java.util.Map; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java index 5e5d4b199..73a766c5f 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/MockStorage.java @@ -1,9 +1,8 @@ package dev.openfeature.contrib.providers.flagd.resolver.process; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.Storage; -import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageQueryResult; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.StorageStateChange; +import dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag; import java.util.Collections; import java.util.Map; import java.util.concurrent.BlockingQueue; @@ -41,9 +40,18 @@ public void shutdown() { // no-op } - @Override - public StorageQueryResult getFlag(String key) { - return new StorageQueryResult(mockFlags.get(key), flagSetMetadata); + /** + * Get a flag by key. This method is used by MockEvaluator for testing. + */ + public FeatureFlag getFlag(String key) { + return mockFlags.get(key); + } + + /** + * Get the flag set metadata. Used by MockEvaluator for testing. + */ + public Map getFlagSetMetadata() { + return flagSetMetadata; } @Nullable public BlockingQueue getStateQueue() { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java index e58b4eb3f..002053976 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/FlagStoreTest.java @@ -1,39 +1,40 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_LONG; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_SIMPLE; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.getFlagsFromResource; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FeatureFlag; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FlagParser; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayload; import dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.QueuePayloadType; +import dev.openfeature.contrib.tools.flagd.core.FlagdCore; import java.time.Duration; +import java.util.Arrays; import java.util.HashSet; -import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import java.util.stream.Collectors; import org.junit.jupiter.api.Test; class FlagStoreTest { + // Minimal valid flag configs for testing state transitions + private static final String VALID_FLAGS_1 = + "{\"flags\":{\"flag1\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true,\"off\":false},\"defaultVariant\":\"on\"}}}"; + private static final String VALID_FLAGS_2 = + "{\"flags\":{\"flag1\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true,\"off\":false},\"defaultVariant\":\"on\"},\"flag2\":{\"state\":\"ENABLED\",\"variants\":{\"a\":\"x\"},\"defaultVariant\":\"a\"}}}"; + private static final String INVALID_FLAGS = "not valid json"; + @Test void connectorHandling() throws Exception { final int maxDelay = 1000; final BlockingQueue payload = new LinkedBlockingQueue<>(); - FlagStore store = new FlagStore(new MockConnector(payload), true); + FlagStore store = new FlagStore(new MockConnector(payload), new FlagdCore(true)); store.init(); final BlockingQueue states = store.getStateQueue(); - // OK for simple flag + // OK for valid flags assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { - payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_SIMPLE))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, VALID_FLAGS_1)); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { @@ -42,16 +43,16 @@ void connectorHandling() throws Exception { // STALE for invalid flag assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { - payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(INVALID_FLAG))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, INVALID_FLAGS)); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { assertEquals(StorageState.STALE, states.take().getStorageState()); }); - // OK again for next payload + // OK again for next valid payload assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { - payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_LONG))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, VALID_FLAGS_2)); }); assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { @@ -79,28 +80,24 @@ void connectorHandling() throws Exception { public void changedFlags() throws Exception { final int maxDelay = 500; final BlockingQueue payload = new LinkedBlockingQueue<>(); - FlagStore store = new FlagStore(new MockConnector(payload), true); + FlagStore store = new FlagStore(new MockConnector(payload), new FlagdCore(true)); store.init(); final BlockingQueue storageStateDTOS = store.getStateQueue(); + // First payload - flag1 is new assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { - payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_SIMPLE))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, VALID_FLAGS_1)); }); - // flags changed for first time assertEquals( - FlagParser.parseString(getFlagsFromResource(VALID_SIMPLE), true).getFlags().keySet().stream() - .collect(Collectors.toList()), - storageStateDTOS.take().getChangedFlagsKeys()); + new HashSet<>(Arrays.asList("flag1")), + new HashSet<>(storageStateDTOS.take().getChangedFlagsKeys())); + // Second payload - flag2 is new, flag1 unchanged assertTimeoutPreemptively(Duration.ofMillis(maxDelay), () -> { - payload.offer(new QueuePayload(QueuePayloadType.DATA, getFlagsFromResource(VALID_LONG))); + payload.offer(new QueuePayload(QueuePayloadType.DATA, VALID_FLAGS_2)); }); - Map expectedChangedFlags = - FlagParser.parseString(getFlagsFromResource(VALID_LONG), true).getFlags(); - expectedChangedFlags.remove("myBoolFlag"); - // flags changed from initial VALID_SIMPLE flag, as a set because we don't care about order assertEquals( - expectedChangedFlags.keySet().stream().collect(Collectors.toSet()), + new HashSet<>(Arrays.asList("flag2")), new HashSet<>(storageStateDTOS.take().getChangedFlagsKeys())); } } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java index f3ccd0876..00190cf2e 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/connector/file/FileConnectorTest.java @@ -1,8 +1,5 @@ package dev.openfeature.contrib.providers.flagd.resolver.process.storage.connector.file; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.UPDATABLE_FILE; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_LONG; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.getResourcePath; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; @@ -12,19 +9,22 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.time.Duration; import java.util.concurrent.BlockingQueue; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class FileConnectorTest { @Test - void readAndExposeFeatureFlagsFromSource() throws IOException { - // given - final FileQueueSource connector = new FileQueueSource(getResourcePath(VALID_LONG), 5000); + void readAndExposeFeatureFlagsFromSource(@TempDir Path tempDir) throws IOException { + // given - create a temporary file with some content + Path testFile = tempDir.resolve("flags.txt"); + Files.write(testFile, "test data".getBytes()); + + final FileQueueSource connector = new FileQueueSource(testFile.toString(), 5000); // when connector.init(); @@ -65,17 +65,15 @@ void emitErrorStateForInvalidPath() throws IOException { @Test @Disabled("Disabled as unstable on GH Action. Useful for functionality validation") - void watchForFileUpdatesAndEmitThem() throws IOException { - final String initial = - "{\"flags\":{\"myBoolFlag\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true,\"off\":false},\"defaultVariant\":\"on\"}}}"; - final String updatedFlags = - "{\"flags\":{\"myBoolFlag\":{\"state\":\"ENABLED\",\"variants\":{\"on\":true,\"off\":false},\"defaultVariant\":\"off\"}}}"; + void watchForFileUpdatesAndEmitThem(@TempDir Path tempDir) throws IOException { + final String initial = "initial content"; + final String updated = "updated content"; - // given - final Path updPath = Paths.get(getResourcePath(UPDATABLE_FILE)); - Files.write(updPath, initial.getBytes(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + // given - create temp file with initial content + final Path testFile = tempDir.resolve("watchable.txt"); + Files.write(testFile, initial.getBytes()); - final FileQueueSource connector = new FileQueueSource(updPath.toString(), 5000); + final FileQueueSource connector = new FileQueueSource(testFile.toString(), 5000); // when connector.init(); @@ -92,13 +90,13 @@ void watchForFileUpdatesAndEmitThem() throws IOException { assertEquals(initial, payload[0].getFlagData()); // then update the flags - Files.write(updPath, updatedFlags.getBytes(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); + Files.write(testFile, updated.getBytes(), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING); // finally wait for updated payload assertTimeoutPreemptively(Duration.ofSeconds(10), () -> { payload[0] = stream.take(); }); - assertEquals(updatedFlags, payload[0].getFlagData()); + assertEquals(updated, payload[0].getFlagData()); } } diff --git a/release-please-config.json b/release-please-config.json index 7628f74c4..a8ca2b116 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -157,6 +157,28 @@ "README.md" ] }, + "tools/flagd-api": { + "package-name": "dev.openfeature.contrib.tools.flagdapi", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, + "tools/flagd-core": { + "package-name": "dev.openfeature.contrib.tools.flagdcore", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "pom.xml", + "README.md" + ] + }, "tools/flagd-http-connector": { "package-name": "dev.openfeature.contrib.tools.flagdhttpconnector", "release-type": "simple", diff --git a/tools/flagd-api/CHANGELOG.md b/tools/flagd-api/CHANGELOG.md new file mode 100644 index 000000000..4dc68c6ff --- /dev/null +++ b/tools/flagd-api/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/tools/flagd-api/README.md b/tools/flagd-api/README.md new file mode 100644 index 000000000..d38e29c69 --- /dev/null +++ b/tools/flagd-api/README.md @@ -0,0 +1,53 @@ +# flagd-api + +This module contains the API contracts for flagd in-process flag evaluation. + +## Purpose + +The `flagd-api` module defines the core interfaces that can be implemented by different flag evaluation engines. This allows: + +- Publishing the contract separately from implementations +- Creating alternative implementations in other repositories +- Substituting flag evaluation logic without changing consumer code + +## Installation + +```xml + + dev.openfeature.contrib.tools + flagd-api + 0.0.1 + +``` + +## Interfaces + +### Evaluator + +The `Evaluator` interface handles flag resolution: + +```java +public interface Evaluator { + ProviderEvaluation resolveBooleanValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveStringValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveIntegerValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveDoubleValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveObjectValue(String flagKey, EvaluationContext ctx); +} +``` + +### FlagStore + +The `FlagStore` interface handles flag configuration storage and updates: + +```java +public interface FlagStore { + void setFlags(String flagConfigurationJson) throws FlagStoreException; + List setFlagsAndGetChangedKeys(String flagConfigurationJson) throws FlagStoreException; + Map getFlagSetMetadata(); +} +``` + +## Implementations + +- [flagd-core](../flagd-core) - The default implementation using JsonLogic-based targeting diff --git a/tools/flagd-api/pom.xml b/tools/flagd-api/pom.xml new file mode 100644 index 000000000..5a09f664e --- /dev/null +++ b/tools/flagd-api/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + dev.openfeature.contrib.tools + flagd-api + 0.0.1 + + + ${groupId}.flagdapi + + + flagd-api + API contracts for flagd in-process evaluation + https://openfeature.dev + + + + toddbaert + Todd Baert + OpenFeature + https://openfeature.dev/ + + + + + + + + diff --git a/tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/Evaluator.java b/tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/Evaluator.java new file mode 100644 index 000000000..244420d52 --- /dev/null +++ b/tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/Evaluator.java @@ -0,0 +1,85 @@ +package dev.openfeature.contrib.tools.flagd.api; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Value; +import java.util.List; +import java.util.Map; + +/** + * Interface for in-process flag evaluation in flagd. + * Combines flag storage and evaluation into a single abstraction. + */ +public interface Evaluator { + + /** + * Set flag configurations from a JSON string. + * + * @param flagConfigurationJson the flag configuration JSON string + * @throws FlagStoreException if parsing or setting fails + */ + void setFlags(String flagConfigurationJson) throws FlagStoreException; + + /** + * Set flag configurations and return the list of changed flag keys. + * This is useful for emitting configuration change events. + * + * @param flagConfigurationJson the flag configuration JSON string + * @return the list of flag keys that were changed (added, modified, or removed) + * @throws FlagStoreException if parsing or setting fails + */ + List setFlagsAndGetChangedKeys(String flagConfigurationJson) throws FlagStoreException; + + /** + * Get the current flag set metadata. + * Flag set metadata is defined at the top level of the flag configuration. + * + * @return the flag set metadata (unmodifiable view) + */ + Map getFlagSetMetadata(); + + /** + * Resolve a boolean flag value. + * + * @param flagKey the flag key + * @param ctx the evaluation context + * @return the resolution result + */ + ProviderEvaluation resolveBooleanValue(String flagKey, EvaluationContext ctx); + + /** + * Resolve a string flag value. + * + * @param flagKey the flag key + * @param ctx the evaluation context + * @return the resolution result + */ + ProviderEvaluation resolveStringValue(String flagKey, EvaluationContext ctx); + + /** + * Resolve an integer flag value. + * + * @param flagKey the flag key + * @param ctx the evaluation context + * @return the resolution result + */ + ProviderEvaluation resolveIntegerValue(String flagKey, EvaluationContext ctx); + + /** + * Resolve a double/float flag value. + * + * @param flagKey the flag key + * @param ctx the evaluation context + * @return the resolution result + */ + ProviderEvaluation resolveDoubleValue(String flagKey, EvaluationContext ctx); + + /** + * Resolve an object flag value. + * + * @param flagKey the flag key + * @param ctx the evaluation context + * @return the resolution result + */ + ProviderEvaluation resolveObjectValue(String flagKey, EvaluationContext ctx); +} diff --git a/tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/FlagStoreException.java b/tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/FlagStoreException.java new file mode 100644 index 000000000..149d59df1 --- /dev/null +++ b/tools/flagd-api/src/main/java/dev/openfeature/contrib/tools/flagd/api/FlagStoreException.java @@ -0,0 +1,26 @@ +package dev.openfeature.contrib.tools.flagd.api; + +/** + * Exception thrown when flag store operations fail. + */ +public class FlagStoreException extends Exception { + + /** + * Construct a FlagStoreException with a message. + * + * @param message the exception message + */ + public FlagStoreException(String message) { + super(message); + } + + /** + * Construct a FlagStoreException with a message and cause. + * + * @param message the exception message + * @param cause the underlying cause + */ + public FlagStoreException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/tools/flagd-api/version.txt b/tools/flagd-api/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/tools/flagd-api/version.txt @@ -0,0 +1 @@ +0.0.1 diff --git a/tools/flagd-core/CHANGELOG.md b/tools/flagd-core/CHANGELOG.md new file mode 100644 index 000000000..4dc68c6ff --- /dev/null +++ b/tools/flagd-core/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/tools/flagd-core/README.md b/tools/flagd-core/README.md new file mode 100644 index 000000000..ada1b6397 --- /dev/null +++ b/tools/flagd-core/README.md @@ -0,0 +1,47 @@ +# flagd-core + +flagd-core contains the core logic for flagd [in-process evaluation](https://flagd.dev/architecture/#in-process-evaluation). +This package is intended to be used by concrete implementations of flagd in-process providers. + +This module implements the [`Evaluator`](../flagd-api) interface from the `flagd-api` module. + +## Usage + +flagd-core wraps a simple flagd feature flag storage and flag evaluation logic. + +To use this implementation, instantiate a `FlagdCore` and provide valid flagd flag configurations. + +```java +FlagdCore core = new FlagdCore(); +core.setFlags(FLAG_CONFIGURATION_STRING); +``` + +Once initialization is complete, use matching flag resolving call: + +```java +ProviderEvaluation result = core.resolveBooleanValue("myBoolFlag", evaluationContext); +``` + +## Installation + +```xml + + dev.openfeature.contrib.tools + flagd-core + 0.0.1 + +``` + +## Interface + +The core component implements the `Evaluator` interface from [flagd-api](../flagd-api): + +```java +public interface Evaluator { + ProviderEvaluation resolveBooleanValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveStringValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveIntegerValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveDoubleValue(String flagKey, EvaluationContext ctx); + ProviderEvaluation resolveObjectValue(String flagKey, EvaluationContext ctx); +} +``` diff --git a/tools/flagd-core/lombok.config b/tools/flagd-core/lombok.config new file mode 100644 index 000000000..df71bb6a0 --- /dev/null +++ b/tools/flagd-core/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true diff --git a/tools/flagd-core/pom.xml b/tools/flagd-core/pom.xml new file mode 100644 index 000000000..b43622842 --- /dev/null +++ b/tools/flagd-core/pom.xml @@ -0,0 +1,159 @@ + + + 4.0.0 + + dev.openfeature.contrib + parent + [1.0,2.0) + ../../pom.xml + + dev.openfeature.contrib.tools + flagd-core + 0.0.1 + + + ${groupId}.flagdcore + + + flagd-core + Core flagd flag evaluation logic for in-process evaluation + https://openfeature.dev + + + + toddbaert + Todd Baert + OpenFeature + https://openfeature.dev/ + + + + + + + + dev.openfeature.contrib.tools + flagd-api + 0.0.1 + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + + + + io.github.jamsesso + json-logic-java + 1.1.0 + + + + + com.google.code.gson + gson + 2.13.1 + + + + com.networknt + json-schema-validator + 1.5.9 + + + org.apache.commons + commons-lang3 + + + + + + org.apache.commons + commons-lang3 + 3.20.0 + + + + org.semver4j + semver4j + 5.8.0 + + + + commons-codec + commons-codec + 1.20.0 + + + + org.junit.jupiter + junit-jupiter + 5.14.1 + test + + + + org.assertj + assertj-core + 3.27.3 + test + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.2 + + + update-schemas-submodule + initialize + + exec + + + + git + + submodule + update + --init + schemas + + + + + + + maven-resources-plugin + 3.3.1 + + + copy-json-schemas + generate-resources + + copy-resources + + + ${basedir}/src/main/resources/flagd/schemas/ + + + ${basedir}/schemas/json/ + + flags.json + targeting.json + + + + + + + + + + + diff --git a/tools/flagd-core/schemas b/tools/flagd-core/schemas new file mode 160000 index 000000000..2852d7772 --- /dev/null +++ b/tools/flagd-core/schemas @@ -0,0 +1 @@ +Subproject commit 2852d7772e6b8674681a6ee6b88db10dbe3f6899 diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java new file mode 100644 index 000000000..964cad395 --- /dev/null +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/FlagdCore.java @@ -0,0 +1,326 @@ +package dev.openfeature.contrib.tools.flagd.core; + +import static dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag.EMPTY_TARGETING_STRING; + +import dev.openfeature.contrib.tools.flagd.api.Evaluator; +import dev.openfeature.contrib.tools.flagd.api.FlagStoreException; +import dev.openfeature.contrib.tools.flagd.core.model.FeatureFlag; +import dev.openfeature.contrib.tools.flagd.core.model.FlagParser; +import dev.openfeature.contrib.tools.flagd.core.model.FlagParsingResult; +import dev.openfeature.contrib.tools.flagd.core.targeting.Operator; +import dev.openfeature.contrib.tools.flagd.core.targeting.TargetingRuleException; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import dev.openfeature.sdk.exceptions.GeneralError; +import dev.openfeature.sdk.exceptions.ParseError; +import dev.openfeature.sdk.exceptions.TypeMismatchError; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock; +import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +/** + * Core flagd flag evaluation implementation. + * + *

This class provides the core logic for in-process flag evaluation, + * allowing flags to be stored and evaluated locally. It implements the + * {@link Evaluator} interface, which can be substituted with different + * implementations if needed. + * + *

Usage: + *

{@code
+ * FlagdCore core = new FlagdCore();
+ * core.setFlags(flagConfigurationJson);
+ * ProviderEvaluation result = core.resolveBooleanValue("myFlag", ctx);
+ * }
+ */ +@Slf4j +public class FlagdCore implements Evaluator { + + private final ReentrantReadWriteLock sync = new ReentrantReadWriteLock(); + private final ReadLock readLock = sync.readLock(); + private final WriteLock writeLock = sync.writeLock(); + + private final Map flags = new HashMap<>(); + private final Map flagSetMetadata = new HashMap<>(); + + private final Operator operator; + private final boolean throwIfInvalid; + + /** + * Construct a FlagdCore instance. + */ + public FlagdCore() { + this(false); + } + + /** + * Construct a FlagdCore instance. + * + * @param throwIfInvalid whether to throw an exception if flag configuration is invalid + */ + public FlagdCore(boolean throwIfInvalid) { + this.operator = new Operator(); + this.throwIfInvalid = throwIfInvalid; + } + + /** + * Set flag configurations from a JSON string. + * + * @param flagConfigurationJson the flag configuration JSON + * @throws FlagStoreException if parsing fails + */ + @Override + public void setFlags(String flagConfigurationJson) throws FlagStoreException { + try { + FlagParsingResult parsingResult = FlagParser.parseString(flagConfigurationJson, throwIfInvalid); + writeLock.lock(); + try { + flags.clear(); + flags.putAll(parsingResult.getFlags()); + flagSetMetadata.clear(); + flagSetMetadata.putAll(parsingResult.getFlagSetMetadata()); + } finally { + writeLock.unlock(); + } + } catch (IOException e) { + throw new FlagStoreException("Failed to parse flag configuration", e); + } + } + + /** + * Set flag configurations and return the list of changed flag keys. + * + * @param flagConfigurationJson the flag configuration JSON + * @return the list of changed flag keys + * @throws FlagStoreException if parsing fails + */ + @Override + public List setFlagsAndGetChangedKeys(String flagConfigurationJson) throws FlagStoreException { + try { + FlagParsingResult parsingResult = FlagParser.parseString(flagConfigurationJson, throwIfInvalid); + List changedKeys; + writeLock.lock(); + try { + changedKeys = getChangedFlagsKeys(parsingResult.getFlags()); + flags.clear(); + flags.putAll(parsingResult.getFlags()); + flagSetMetadata.clear(); + flagSetMetadata.putAll(parsingResult.getFlagSetMetadata()); + } finally { + writeLock.unlock(); + } + return changedKeys; + } catch (IOException e) { + throw new FlagStoreException("Failed to parse flag configuration", e); + } + } + + /** + * Get the current flag set metadata. + * + * @return the flag set metadata (unmodifiable view) + */ + @Override + public Map getFlagSetMetadata() { + readLock.lock(); + try { + return Collections.unmodifiableMap(new HashMap<>(flagSetMetadata)); + } finally { + readLock.unlock(); + } + } + + @Override + public ProviderEvaluation resolveBooleanValue(String flagKey, EvaluationContext ctx) { + return resolve(Boolean.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveStringValue(String flagKey, EvaluationContext ctx) { + return resolve(String.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveIntegerValue(String flagKey, EvaluationContext ctx) { + return resolve(Integer.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveDoubleValue(String flagKey, EvaluationContext ctx) { + return resolve(Double.class, flagKey, ctx); + } + + @Override + public ProviderEvaluation resolveObjectValue(String flagKey, EvaluationContext ctx) { + final ProviderEvaluation evaluation = resolve(Object.class, flagKey, ctx); + + return ProviderEvaluation.builder() + .value(Value.objectToValue(evaluation.getValue())) + .variant(evaluation.getVariant()) + .reason(evaluation.getReason()) + .errorCode(evaluation.getErrorCode()) + .errorMessage(evaluation.getErrorMessage()) + .flagMetadata(evaluation.getFlagMetadata()) + .build(); + } + + private ProviderEvaluation resolve(Class type, String key, EvaluationContext ctx) { + final FeatureFlag flag; + final Map currentFlagSetMetadata; + + readLock.lock(); + try { + flag = flags.get(key); + currentFlagSetMetadata = new HashMap<>(flagSetMetadata); + } finally { + readLock.unlock(); + } + + // missing flag + if (flag == null) { + return ProviderEvaluation.builder() + .errorMessage("flag: " + key + " not found") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(getFlagMetadata(currentFlagSetMetadata, null)) + .build(); + } + + // state check + if ("DISABLED".equals(flag.getState())) { + return ProviderEvaluation.builder() + .errorMessage("flag: " + key + " is disabled") + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .flagMetadata(getFlagMetadata(currentFlagSetMetadata, null)) + .build(); + } + + final String resolvedVariant; + final String reason; + + if (EMPTY_TARGETING_STRING.equals(flag.getTargeting())) { + resolvedVariant = flag.getDefaultVariant(); + reason = Reason.STATIC.toString(); + } else { + try { + final Object jsonResolved = operator.apply(key, flag.getTargeting(), ctx); + if (jsonResolved == null) { + resolvedVariant = flag.getDefaultVariant(); + reason = Reason.DEFAULT.toString(); + } else { + resolvedVariant = jsonResolved.toString(); // convert to string to support shorthand + reason = Reason.TARGETING_MATCH.toString(); + } + } catch (TargetingRuleException e) { + String message = String.format("error evaluating targeting rule for flag %s", key); + log.debug(message, e); + throw new ParseError(message); + } + } + + // check variant existence + Object value = flag.getVariants().get(resolvedVariant); + if (value == null) { + if (StringUtils.isEmpty(resolvedVariant) && StringUtils.isEmpty(flag.getDefaultVariant())) { + return ProviderEvaluation.builder() + .reason(Reason.ERROR.toString()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .errorMessage("Flag '" + key + "' has no default variant defined, will use code default") + .flagMetadata(getFlagMetadata(currentFlagSetMetadata, flag)) + .build(); + } + + String message = String.format("variant %s not found in flag with key %s", resolvedVariant, key); + log.debug(message); + throw new GeneralError(message); + } + if (value instanceof Integer && type == Double.class) { + // if this is an integer and we are trying to resolve a double, convert + value = ((Integer) value).doubleValue(); + } else if (value instanceof Double && type == Integer.class) { + // if this is a double and we are trying to resolve an integer, convert + value = ((Double) value).intValue(); + } + if (!type.isAssignableFrom(value.getClass())) { + String message = "returning default variant for flagKey: %s, type not valid"; + log.debug(String.format(message, key)); + throw new TypeMismatchError(message); + } + + return ProviderEvaluation.builder() + .value((T) value) + .variant(resolvedVariant) + .reason(reason) + .flagMetadata(getFlagMetadata(currentFlagSetMetadata, flag)) + .build(); + } + + private ImmutableMetadata getFlagMetadata(Map currentFlagSetMetadata, FeatureFlag flag) { + ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder(); + for (Map.Entry entry : currentFlagSetMetadata.entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } + + if (flag != null) { + for (Map.Entry entry : flag.getMetadata().entrySet()) { + addEntryToMetadataBuilder(metadataBuilder, entry.getKey(), entry.getValue()); + } + } + + return metadataBuilder.build(); + } + + private void addEntryToMetadataBuilder( + ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder, String key, Object value) { + if (value instanceof Number) { + if (value instanceof Long) { + metadataBuilder.addLong(key, (Long) value); + return; + } else if (value instanceof Double) { + metadataBuilder.addDouble(key, (Double) value); + return; + } else if (value instanceof Integer) { + metadataBuilder.addInteger(key, (Integer) value); + return; + } else if (value instanceof Float) { + metadataBuilder.addFloat(key, (Float) value); + return; + } + } else if (value instanceof Boolean) { + metadataBuilder.addBoolean(key, (Boolean) value); + return; + } else if (value instanceof String) { + metadataBuilder.addString(key, (String) value); + return; + } + throw new IllegalArgumentException( + "The type of the Metadata entry with key " + key + " and value " + value + " is not supported"); + } + + private List getChangedFlagsKeys(Map newFlags) { + // keys for flags that are new or have changed + Stream addedOrUpdated = newFlags.entrySet().stream() + .filter(entry -> { + FeatureFlag oldFlag = flags.get(entry.getKey()); + return oldFlag == null || !oldFlag.equals(entry.getValue()); + }) + .map(Map.Entry::getKey); + + // keys for flags that have been removed + Stream removed = flags.keySet().stream().filter(key -> !newFlags.containsKey(key)); + + return Stream.concat(addedOrUpdated, removed).collect(Collectors.toList()); + } +} diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FeatureFlag.java similarity index 68% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FeatureFlag.java index 2eaf2ed87..11f1a6cf5 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FeatureFlag.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FeatureFlag.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.model; +package dev.openfeature.contrib.tools.flagd.core.model; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -10,7 +10,9 @@ import lombok.EqualsAndHashCode; import lombok.Getter; -/** flagd feature flag model. */ +/** + * flagd feature flag model. + */ @Getter @SuppressFBWarnings( value = {"EI_EXPOSE_REP"}, @@ -18,6 +20,9 @@ @JsonIgnoreProperties(ignoreUnknown = true) @EqualsAndHashCode public class FeatureFlag { + /** + * Empty targeting string constant. + */ public static final String EMPTY_TARGETING_STRING = "{}"; private final String state; @@ -26,7 +31,15 @@ public class FeatureFlag { private final String targeting; private final Map metadata; - /** Construct a flagd feature flag. */ + /** + * Construct a flagd feature flag. + * + * @param state the flag state (ENABLED/DISABLED) + * @param defaultVariant the default variant key + * @param variants the variants map + * @param targeting the targeting rule as a JSON string + * @param metadata the flag metadata + */ @JsonCreator public FeatureFlag( @JsonProperty("state") String state, @@ -45,7 +58,14 @@ public FeatureFlag( } } - /** Construct a flagd feature flag. */ + /** + * Construct a flagd feature flag without metadata. + * + * @param state the flag state (ENABLED/DISABLED) + * @param defaultVariant the default variant key + * @param variants the variants map + * @param targeting the targeting rule as a JSON string + */ public FeatureFlag(String state, String defaultVariant, Map variants, String targeting) { this.state = state; this.defaultVariant = defaultVariant; @@ -54,7 +74,11 @@ public FeatureFlag(String state, String defaultVariant, Map vari this.metadata = new HashMap<>(); } - /** Get targeting rule of the flag. */ + /** + * Get targeting rule of the flag. + * + * @return the targeting rule, or empty object string if not set + */ public String getTargeting() { return this.targeting == null ? EMPTY_TARGETING_STRING : this.targeting; } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java similarity index 85% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java index ee456e31a..d15d93419 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParser.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParser.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.model; +package dev.openfeature.contrib.tools.flagd.core.model; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; @@ -20,7 +20,9 @@ import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; -/** flagd feature flag configuration parser. */ +/** + * flagd feature flag configuration parser. + */ @Slf4j public class FlagParser { private static final String FLAG_KEY = "flags"; @@ -34,7 +36,8 @@ private FlagParser() {} static { try { - // load both schemas from resources (root (flags.json) and referenced (targeting.json) + // load both schemas from resources (root (flags.json) and referenced + // (targeting.json) // we don't want to resolve anything from the network Map mappings = new HashMap<>(); mappings.put("https://flagd.dev/schema/v0/targeting.json", "classpath:flagd/schemas/targeting.json"); @@ -46,12 +49,20 @@ private FlagParser() {} .getSchema(new URI("https://flagd.dev/schema/v0/flags.json")); } catch (Throwable e) { // log only, do not throw - log.warn(String.format("Error loading schema resources, schema validation will be skipped")); + log.warn("Error loading schema resources, schema validation will be skipped"); } } - /** Parse {@link String} for feature flags. */ - public static ParsingResult parseString(final String configuration, boolean throwIfInvalid) throws IOException { + /** + * Parse {@link String} for feature flags. + * + * @param configuration the flag configuration JSON string + * @param throwIfInvalid whether to throw an exception if the configuration is + * invalid + * @return the parsing result containing flags and metadata + * @throws IOException if parsing fails + */ + public static FlagParsingResult parseString(final String configuration, boolean throwIfInvalid) throws IOException { if (SCHEMA_VALIDATOR != null) { try (JsonParser parser = MAPPER.createParser(configuration)) { Set validationMessages = SCHEMA_VALIDATOR.validate(parser.readValueAsTree()); @@ -91,7 +102,7 @@ public static ParsingResult parseString(final String configuration, boolean thro } } - return new ParsingResult(flagMap, flagSetMetadata); + return new FlagParsingResult(flagMap, flagSetMetadata); } private static Map parseMetadata(TreeNode metadataNode) throws JsonProcessingException { @@ -125,12 +136,12 @@ private static String transposeEvaluators(final String configuration) throws IOE final String replacePattern = String.format(REPLACER_FORMAT, evalName); // then derive pattern - final Pattern reg_replace = + final Pattern regReplace = patternMap.computeIfAbsent(replacePattern, s -> Pattern.compile(replacePattern)); // finally replace all references replacedConfigurations = - reg_replace.matcher(replacedConfigurations).replaceAll(replacer); + regReplace.matcher(replacedConfigurations).replaceAll(replacer); } return replacedConfigurations; diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParsingResult.java similarity index 61% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParsingResult.java index 485611250..e05312cac 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/ParsingResult.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParsingResult.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.model; +package dev.openfeature.contrib.tools.flagd.core.model; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.util.Map; @@ -11,11 +11,17 @@ @SuppressFBWarnings( value = {"EI_EXPOSE_REP"}, justification = "Feature flag comes as a Json configuration, hence they must be exposed") -public class ParsingResult { +public class FlagParsingResult { private final Map flags; private final Map flagSetMetadata; - public ParsingResult(Map flags, Map flagSetMetadata) { + /** + * Construct a parsing result. + * + * @param flags the parsed flags + * @param flagSetMetadata the flag set metadata + */ + public FlagParsingResult(Map flags, Map flagSetMetadata) { this.flags = flags; this.flagSetMetadata = flagSetMetadata; } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/StringSerializer.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/StringSerializer.java similarity index 76% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/StringSerializer.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/StringSerializer.java index ac18eae79..89a1dc928 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/StringSerializer.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/model/StringSerializer.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.model; +package dev.openfeature.contrib.tools.flagd.core.model; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.TreeNode; @@ -6,9 +6,14 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; -/** Custom serializer to preserve Json node as a {@link String}. */ +/** + * Custom serializer to preserve Json node as a {@link String}. + */ class StringSerializer extends StdDeserializer { + /** + * Construct the string serializer. + */ public StringSerializer() { super(String.class); } diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java similarity index 92% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Fractional.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 6658aab78..f36ebfffe 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; import io.github.jamsesso.jsonlogic.JsonLogicException; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; @@ -11,13 +11,18 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.MurmurHash3; +/** + * Fractional targeting operation for bucket-based flag distribution. + */ @Slf4j class Fractional implements PreEvaluatedArgumentsExpression { + @Override public String key() { return "fractional"; } + @Override public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException { if (arguments.size() < 2) { return null; @@ -29,14 +34,14 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json Object arg1 = arguments.get(0); final String bucketBy; - final Object[] distibutions; + final Object[] distributions; if (arg1 instanceof String) { // first arg is a String, use for bucketing bucketBy = (String) arg1; Object[] source = arguments.toArray(); - distibutions = Arrays.copyOfRange(source, 1, source.length); + distributions = Arrays.copyOfRange(source, 1, source.length); } else { // fallback to targeting key if present if (properties.getTargetingKey() == null) { @@ -45,14 +50,14 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json } bucketBy = properties.getFlagKey() + properties.getTargetingKey(); - distibutions = arguments.toArray(); + distributions = arguments.toArray(); } final List propertyList = new ArrayList<>(); int totalWeight = 0; try { - for (Object dist : distibutions) { + for (Object dist : distributions) { FractionProperty fractionProperty = new FractionProperty(dist, jsonPath); propertyList.add(fractionProperty); totalWeight += fractionProperty.getWeight(); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Operator.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java similarity index 83% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Operator.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java index 3df419993..b3b97e5df 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/Operator.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Operator.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; import dev.openfeature.sdk.EvaluationContext; import io.github.jamsesso.jsonlogic.JsonLogic; @@ -9,8 +9,8 @@ import lombok.Getter; /** - * Targeting operator wraps JsonLogic handlers and expose a simple API for external layers. This - * helps to isolate external dependencies to this package. + * Targeting operator wraps JsonLogic handlers and exposes a simple API for external layers. + * This helps to isolate external dependencies to this package. */ public class Operator { @@ -21,7 +21,9 @@ public class Operator { private final JsonLogic jsonLogicHandler; - /** Construct a targeting operator. */ + /** + * Construct a targeting operator. + */ public Operator() { jsonLogicHandler = new JsonLogic(); jsonLogicHandler.addOperation(new Fractional()); @@ -30,7 +32,15 @@ public Operator() { jsonLogicHandler.addOperation(new StringComp(StringComp.Type.ENDS_WITH)); } - /** Apply this operator on the provided rule. */ + /** + * Apply this operator on the provided rule. + * + * @param flagKey the flag key being evaluated + * @param targetingRule the targeting rule JSON string + * @param ctx the evaluation context + * @return the result of applying the targeting rule + * @throws TargetingRuleException if rule evaluation fails + */ public Object apply(final String flagKey, final String targetingRule, final EvaluationContext ctx) throws TargetingRuleException { final Map flagdProperties = new HashMap<>(); diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVer.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java similarity index 95% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVer.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java index 2ab1802a5..44573904b 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVer.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVer.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; @@ -8,6 +8,9 @@ import lombok.extern.slf4j.Slf4j; import org.semver4j.Semver; +/** + * Semantic version comparison targeting operation. + */ @Slf4j class SemVer implements PreEvaluatedArgumentsExpression { @@ -33,10 +36,12 @@ class SemVer implements PreEvaluatedArgumentsExpression { OPS.add(MINOR); } + @Override public String key() { return "sem_ver"; } + @Override public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException { if (arguments.size() != 3) { diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringComp.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringComp.java similarity index 90% rename from providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringComp.java rename to tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringComp.java index 9956ca8c6..fed0feef6 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringComp.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringComp.java @@ -1,10 +1,13 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; import java.util.List; import lombok.extern.slf4j.Slf4j; +/** + * String comparison targeting operation. + */ @Slf4j class StringComp implements PreEvaluatedArgumentsExpression { private final Type type; @@ -13,10 +16,12 @@ class StringComp implements PreEvaluatedArgumentsExpression { this.type = type; } + @Override public String key() { return type.key; } + @Override public Object evaluate(List arguments, Object data, String jsonPath) throws JsonLogicEvaluationException { if (arguments.size() != 2) { log.debug("Incorrect number of arguments for String comparison operator"); @@ -52,6 +57,9 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json } } + /** + * String comparison type. + */ enum Type { STARTS_WITH("starts_with"), ENDS_WITH("ends_with"); diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/TargetingRuleException.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/TargetingRuleException.java new file mode 100644 index 000000000..c25ca9e6f --- /dev/null +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/TargetingRuleException.java @@ -0,0 +1,17 @@ +package dev.openfeature.contrib.tools.flagd.core.targeting; + +/** + * Exception used by targeting rule package. + */ +public class TargetingRuleException extends Exception { + + /** + * Construct exception. + * + * @param message the exception message + * @param t the cause + */ + public TargetingRuleException(final String message, final Throwable t) { + super(message, t); + } +} diff --git a/tools/flagd-core/src/main/resources/flagd/schemas/.gitignore b/tools/flagd-core/src/main/resources/flagd/schemas/.gitignore new file mode 100644 index 000000000..45212354d --- /dev/null +++ b/tools/flagd-core/src/main/resources/flagd/schemas/.gitignore @@ -0,0 +1,3 @@ +# copied from the schemas submodule during build +flags.json +targeting.json diff --git a/tools/flagd-core/src/main/resources/flagd/schemas/flags.json b/tools/flagd-core/src/main/resources/flagd/schemas/flags.json new file mode 100644 index 000000000..6e045b654 --- /dev/null +++ b/tools/flagd-core/src/main/resources/flagd/schemas/flags.json @@ -0,0 +1,216 @@ +{ + "$id": "https://flagd.dev/schema/v0/flags.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "flagd Flag Configuration", + "description": "Defines flags for use in flagd, including typed variants and rules.", + "type": "object", + "properties": { + "flags": { + "title": "Flags", + "description": "Top-level flags object. All flags are defined here.", + "type": "object", + "$comment": "flag objects are one of the 4 flag types defined in definitions", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "oneOf": [ + { + "title": "Boolean flag", + "description": "A flag having boolean values.", + "$ref": "#/definitions/booleanFlag" + }, + { + "title": "String flag", + "description": "A flag having string values.", + "$ref": "#/definitions/stringFlag" + }, + { + "title": "Numeric flag", + "description": "A flag having numeric values.", + "$ref": "#/definitions/numberFlag" + }, + { + "title": "Object flag", + "description": "A flag having arbitrary object values.", + "$ref": "#/definitions/objectFlag" + } + ] + } + } + }, + "$evaluators": { + "title": "Evaluators", + "description": "Reusable targeting rules that can be referenced with \"$ref\": \"myRule\" in multiple flags.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$comment": "this relative ref means that targeting.json MUST be in the same dir, or available on the same HTTP path", + "$ref": "./targeting.json" + } + } + }, + "metadata": { + "title": "Flag Set Metadata", + "description": "Metadata about the flag set, with keys of type string, and values of type boolean, string, or number.", + "properties": { + "flagSetId": { + "description": "The unique identifier for the flag set.", + "type": "string" + }, + "version": { + "description": "The version of the flag set.", + "type": "string" + } + }, + "$ref": "#/definitions/metadata" + } + }, + "definitions": { + "flag": { + "$comment": "base flag object; no title/description here, allows for better UX, keep it in the overrides", + "type": "object", + "properties": { + "state": { + "title": "Flag State", + "description": "Indicates whether the flag is functional. Disabled flags are treated as if they don't exist.", + "type": "string", + "enum": [ + "ENABLED", + "DISABLED" + ] + }, + "defaultVariant": { + "title": "Default Variant", + "description": "The variant to serve if no dynamic targeting applies (including if the targeting returns null).", + "type": "string" + }, + "targeting": { + "$ref": "./targeting.json" + }, + "metadata": { + "title": "Flag Metadata", + "description": "Metadata about an individual feature flag, with keys of type string, and values of type boolean, string, or number.", + "$ref": "#/definitions/metadata" + } + }, + "required": [ + "state", + "defaultVariant" + ] + }, + "booleanVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "boolean" + } + }, + "default": { + "true": true, + "false": false + } + } + } + }, + "stringVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "string" + } + } + } + } + }, + "numberVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "number" + } + } + } + } + }, + "objectVariants": { + "type": "object", + "properties": { + "variants": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "type": "object" + } + } + } + } + }, + "booleanFlag": { + "$comment": "merge the variants with the base flag to build our typed flags", + "allOf": [ + { + "$ref": "#/definitions/flag" + }, + { + "$ref": "#/definitions/booleanVariants" + } + ] + }, + "stringFlag": { + "allOf": [ + { + "$ref": "#/definitions/flag" + }, + { + "$ref": "#/definitions/stringVariants" + } + ] + }, + "numberFlag": { + "allOf": [ + { + "$ref": "#/definitions/flag" + }, + { + "$ref": "#/definitions/numberVariants" + } + ] + }, + "objectFlag": { + "allOf": [ + { + "$ref": "#/definitions/flag" + }, + { + "$ref": "#/definitions/objectVariants" + } + ] + }, + "metadata": { + "type": "object", + "additionalProperties": { + "description": "Any additional key/value pair with value of type boolean, string, or number.", + "type": [ + "string", + "number", + "boolean" + ] + }, + "required": [] + } + } +} diff --git a/tools/flagd-core/src/main/resources/flagd/schemas/targeting.json b/tools/flagd-core/src/main/resources/flagd/schemas/targeting.json new file mode 100644 index 000000000..4409c3e37 --- /dev/null +++ b/tools/flagd-core/src/main/resources/flagd/schemas/targeting.json @@ -0,0 +1,584 @@ +{ + "$id": "https://flagd.dev/schema/v0/targeting.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "flagd Targeting", + "description": "Defines targeting logic for flagd; a extension of JSONLogic, including purpose-built feature-flagging operations. Note that this schema applies to top-level objects; no additional properties are supported, including \"$schema\", which means built-in JSON-schema support is not possible in editors. Please use flags.json (which imports this schema) for a rich editor experience.", + "type": "object", + "anyOf": [ + { + "$comment": "we need this to support empty targeting", + "type": "object", + "additionalProperties": false, + "properties": {} + }, + { + "$ref": "#/definitions/anyRule" + } + ], + "definitions": { + "primitive": { + "oneOf": [ + { + "description": "When returned from rules, a null value \"exits\", the targeting, and the \"defaultValue\" is returned, with the reason indicating the targeting did not match.", + "type": "null" + }, + { + "description": "When returned from rules, booleans are converted to strings (\"true\"/\"false\"), and used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!", + "type": "boolean" + }, + { + "description": "When returned from rules, the behavior of numbers is not defined.", + "type": "number" + }, + { + "description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.", + "type": "string" + }, + { + "description": "When returned from rules, strings are used to as keys to retrieve the associated value from the \"variants\" object. Be sure that the returned string is present as a key in the variants!.", + "type": "array" + } + ] + }, + "varRule": { + "title": "Var Operation", + "description": "Retrieve data from the provided data object.", + "type": "object", + "additionalProperties": false, + "properties": { + "var": { + "anyOf": [ + { + "type": "string", + "description": "flagd automatically injects \"$flagd.timestamp\" (unix epoch) and \"$flagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\.((timestamp)|(flagKey))$" + }, + { + "not": { + "$comment": "this is a negated (not) match of \"$flagd.{some-key}\", which is faster and more compatible that a negative lookahead regex", + "type": "string", + "description": "flagd automatically injects \"$flagd.timestamp\" (unix epoch) and \"$flagd.flagKey\" (the key of the flag in evaluation) into the context.", + "pattern": "^\\$flagd\\..*$" + } + }, + { + "type": "array", + "$comment": "this is to support the form of var with a default... there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", + "minItems": 1, + "items": [ + { + "type": "string" + } + ], + "additionalItems": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "number" + } + ] + } + } + ] + } + } + }, + "missingRule": { + "title": "Missing Operation", + "description": "Takes an array of data keys to search for (same format as var). Returns an array of any keys that are missing from the data object, or an empty array.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "missingSomeRule": { + "title": "Missing-Some Operation", + "description": "Takes a minimum number of data keys that are required, and an array of keys to search for (same format as var or missing). Returns an empty array if the minimum is met, or an array of the missing keys otherwise.", + "type": "object", + "additionalProperties": false, + "properties": { + "missing_some": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": [ + { + "type": "number" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "binaryOrTernaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "items": { + "$ref": "#/definitions/args" + } + }, + "binaryOrTernaryRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "substr": { + "title": "Substring Operation", + "description": "Get a portion of a string. Give a positive start position to return everything beginning at that index. Give a negative start position to work backwards from the end of the string, then return everything. Give a positive length to express how many characters to return.", + "$ref": "#/definitions/binaryOrTernaryOp" + }, + "<": { + "title": "Less-Than/Between Operation. Can be used to test that one value is between two others.", + "$ref": "#/definitions/binaryOrTernaryOp" + }, + "<=": { + "title": "Less-Than-Or-Equal-To/Between Operation. Can be used to test that one value is between two others.", + "$ref": "#/definitions/binaryOrTernaryOp" + } + } + }, + "binaryOp": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/definitions/args" + } + }, + "binaryRule": { + "title": "Binary Operation", + "description": "Any primitive JSONLogic operation with 2 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "if": { + "title": "If Operator", + "description": "The if statement takes 1 or more arguments: a condition (\"if\"), what to do if its true (\"then\", optional, defaults to returning true), and what to do if its false (\"else\", optional, defaults to returning false). Note that the else condition can be used as an else-if statement by adding additional arguments.", + "$ref": "#/definitions/variadicOp" + }, + "==": { + "title": "Lose Equality Operation", + "description": "Tests equality, with type coercion. Requires two arguments.", + "$ref": "#/definitions/binaryOp" + }, + "===": { + "title": "Strict Equality Operation", + "description": "Tests strict equality. Requires two arguments.", + "$ref": "#/definitions/binaryOp" + }, + "!=": { + "title": "Lose Inequality Operation", + "description": "Tests not-equal, with type coercion.", + "$ref": "#/definitions/binaryOp" + }, + "!==": { + "title": "Strict Inequality Operation", + "description": "Tests strict not-equal.", + "$ref": "#/definitions/binaryOp" + }, + ">": { + "title": "Greater-Than Operation", + "$ref": "#/definitions/binaryOp" + }, + ">=": { + "title": "Greater-Than-Or-Equal-To Operation", + "$ref": "#/definitions/binaryOp" + }, + "%": { + "title": "Modulo Operation", + "description": "Finds the remainder after the first argument is divided by the second argument.", + "$ref": "#/definitions/binaryOp" + }, + "/": { + "title": "Division Operation", + "$ref": "#/definitions/binaryOp" + }, + "map": { + "title": "Map Operation", + "description": "Perform an action on every member of an array. Note, that inside the logic being used to map, var operations are relative to the array element being worked on.", + "$ref": "#/definitions/binaryOp" + }, + "filter": { + "title": "Filter Operation", + "description": "Keep only elements of the array that pass a test. Note, that inside the logic being used to filter, var operations are relative to the array element being worked on.", + "$ref": "#/definitions/binaryOp" + }, + "all": { + "title": "All Operation", + "description": "Perform a test on each member of that array, returning true if all pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "none": { + "title": "None Operation", + "description": "Perform a test on each member of that array, returning true if none pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "some": { + "title": "Some Operation", + "description": "Perform a test on each member of that array, returning true if some pass. Inside the test code, var operations are relative to the array element being tested.", + "$ref": "#/definitions/binaryOp" + }, + "in": { + "title": "In Operation", + "description": "If the second argument is an array, tests that the first argument is a member of the array.", + "$ref": "#/definitions/binaryOp" + } + } + }, + "reduceRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "reduce": { + "title": "Reduce Operation", + "description": "Combine all the elements in an array into a single value, like adding up a list of numbers. Note, that inside the logic being used to reduce, var operations only have access to an object with a \"current\" and a \"accumulator\".", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "$ref": "#/definitions/args" + } + } + } + }, + "associativeOp": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/args" + } + }, + "associativeRule": { + "title": "Mathematically Associative Operation", + "description": "Operation applicable to 2 or more parameters.", + "type": "object", + "additionalProperties": false, + "properties": { + "*": { + "title": "Multiplication Operation", + "description": "Multiplication; associative, will accept and unlimited amount of arguments.", + "$ref": "#/definitions/associativeOp" + } + } + }, + "unaryOp": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/definitions/args" + } + }, + { + "$ref": "#/definitions/args" + } + ] + }, + "unaryRule": { + "title": "Unary Operation", + "description": "Any primitive JSONLogic operation with 1 operands.", + "type": "object", + "additionalProperties": false, + "properties": { + "!": { + "title": "Negation Operation", + "description": "Logical negation (“not”). Takes just one argument.", + "$ref": "#/definitions/unaryOp" + }, + "!!": { + "title": "Double Negation Operation", + "description": "Double negation, or 'cast to a boolean'. Takes a single argument.", + "$ref": "#/definitions/unaryOp" + } + } + }, + "variadicOp": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/args" + } + }, + "variadicRule": { + "$comment": "note < and <= can be used with up to 3 ops (between)", + "type": "object", + "additionalProperties": false, + "properties": { + "or": { + "title": "Or Operation", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"or\" returns the first truthy argument, or the last argument.", + "$ref": "#/definitions/variadicOp" + }, + "and": { + "title": "", + "description": "Simple boolean test, with 1 or more arguments. At a more sophisticated level, \"and\" returns the first falsy argument, or the last argument.", + "$ref": "#/definitions/variadicOp" + }, + "+": { + "title": "Addition Operation", + "description": "Addition; associative, will accept and unlimited amount of arguments.", + "$ref": "#/definitions/variadicOp" + }, + "-": { + "title": "Subtraction Operation", + "$ref": "#/definitions/variadicOp" + }, + "max": { + "title": "Maximum Operation", + "description": "Return the maximum from a list of values.", + "$ref": "#/definitions/variadicOp" + }, + "min": { + "title": "Minimum Operation", + "description": "Return the minimum from a list of values.", + "$ref": "#/definitions/variadicOp" + }, + "merge": { + "title": "Merge Operation", + "description": "Takes one or more arrays, and merges them into one array. If arguments aren't arrays, they get cast to arrays.", + "$ref": "#/definitions/variadicOp" + }, + "cat": { + "title": "Concatenate Operation", + "description": "Concatenate all the supplied arguments. Note that this is not a join or implode operation, there is no “glue” string.", + "$ref": "#/definitions/variadicOp" + } + } + }, + "stringCompareArg": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/anyRule" + } + ] + }, + "stringCompareArgs": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/definitions/stringCompareArg" + } + }, + "stringCompareRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "starts_with": { + "title": "Starts-With Operation", + "description": "The string attribute starts with the specified string value.", + "$ref": "#/definitions/stringCompareArgs" + }, + "ends_with": { + "title": "Ends-With Operation", + "description": "The string attribute ends with the specified string value.", + "$ref": "#/definitions/stringCompareArgs" + } + } + }, + "semVerString": { + "title": "Semantic Version String", + "description": "A string representing a valid semantic version expression as per https://semver.org/.", + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "ruleSemVer": { + "type": "object", + "additionalProperties": false, + "properties": { + "sem_ver": { + "title": "Semantic Version Operation", + "description": "Attribute matches a semantic version condition. Accepts \"npm-style\" range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": [ + { + "oneOf": [ + { + "$ref": "#/definitions/semVerString" + }, + { + "$ref": "#/definitions/varRule" + } + ] + }, + { + "description": "Range specifiers: \"=\", \"!=\", \">\", \"<\", \">=\", \"<=\", \"~\" (match minor version), \"^\" (match major version).", + "enum": [ + "=", + "!=", + ">", + "<", + ">=", + "<=", + "~", + "^" + ] + }, + { + "oneOf": [ + { + "$ref": "#/definitions/semVerString" + }, + { + "$ref": "#/definitions/varRule" + } + ] + } + ] + } + } + }, + "fractionalWeightArg": { + "description": "Distribution for all possible variants, with their associated weighting.", + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "description": "If this bucket is randomly selected, this string is used to as a key to retrieve the associated value from the \"variants\" object.", + "type": "string" + }, + { + "description": "Weighted distribution for this variant key.", + "type": "number" + } + ] + }, + "fractionalOp": { + "type": "array", + "minItems": 3, + "$comment": "there seems to be a bug here, where ajv gives a warning (not an error) because maxItems doesn't equal the number of entries in items, though this is valid in this case", + "items": [ + { + "description": "Bucketing value used in pseudorandom assignment; should be a string that is unique and stable for each subject of flag evaluation. Defaults to a concatenation of the flagKey and targetingKey.", + "$ref": "#/definitions/anyRule" + }, + { + "$ref": "#/definitions/fractionalWeightArg" + }, + { + "$ref": "#/definitions/fractionalWeightArg" + } + ], + "additionalItems": { + "$ref": "#/definitions/fractionalWeightArg" + } + }, + "fractionalShorthandOp": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/fractionalWeightArg" + } + }, + "fractionalRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "fractional": { + "title": "Fractional Operation", + "description": "Deterministic, pseudorandom fractional distribution.", + "oneOf": [ + { + "$ref": "#/definitions/fractionalOp" + }, + { + "$ref": "#/definitions/fractionalShorthandOp" + } + ] + } + } + }, + "reference": { + "additionalProperties": false, + "type": "object", + "$comment": "patternProperties here is a bit of a hack to prevent this definition from being dereferenced early.", + "patternProperties": { + "^\\$ref$": { + "title": "Reference", + "description": "A reference to another entity, used for $evaluators (shared rules).", + "type": "string" + } + } + }, + "args": { + "oneOf": [ + { + "$ref": "#/definitions/reference" + }, + { + "$ref": "#/definitions/anyRule" + }, + { + "$ref": "#/definitions/primitive" + } + ] + }, + "anyRule": { + "anyOf": [ + { + "$ref": "#/definitions/varRule" + }, + { + "$ref": "#/definitions/missingRule" + }, + { + "$ref": "#/definitions/missingSomeRule" + }, + { + "$ref": "#/definitions/binaryRule" + }, + { + "$ref": "#/definitions/binaryOrTernaryRule" + }, + { + "$ref": "#/definitions/associativeRule" + }, + { + "$ref": "#/definitions/unaryRule" + }, + { + "$ref": "#/definitions/variadicRule" + }, + { + "$ref": "#/definitions/reduceRule" + }, + { + "$ref": "#/definitions/stringCompareRule" + }, + { + "$ref": "#/definitions/ruleSemVer" + }, + { + "$ref": "#/definitions/fractionalRule" + } + ] + } + } +} diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java new file mode 100644 index 000000000..c6994838d --- /dev/null +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/FlagdCoreTest.java @@ -0,0 +1,127 @@ +package dev.openfeature.contrib.tools.flagd.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfeature.contrib.tools.flagd.api.FlagStoreException; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FlagdCoreTest { + + private FlagdCore flagdCore; + private String flagsConfig; + + @BeforeEach + void setUp() throws FlagStoreException, IOException { + flagsConfig = readResource("flags/test-flags.json"); + flagdCore = new FlagdCore(); + flagdCore.setFlags(flagsConfig); + } + + private String readResource(String path) throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) { + if (is == null) { + throw new IOException("Resource not found: " + path); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Test + void resolveBooleanValue_returnsCorrectValue() { + ProviderEvaluation result = flagdCore.resolveBooleanValue("boolFlag", new ImmutableContext()); + + assertThat(result.getValue()).isTrue(); + assertThat(result.getVariant()).isEqualTo("on"); + assertThat(result.getReason()).isEqualTo(Reason.STATIC.toString()); + } + + @Test + void resolveStringValue_returnsCorrectValue() { + ProviderEvaluation result = flagdCore.resolveStringValue("stringFlag", new ImmutableContext()); + + assertThat(result.getValue()).isEqualTo("hello"); + assertThat(result.getVariant()).isEqualTo("greeting"); + assertThat(result.getReason()).isEqualTo(Reason.STATIC.toString()); + } + + @Test + void resolveIntegerValue_returnsCorrectValue() { + ProviderEvaluation result = flagdCore.resolveIntegerValue("intFlag", new ImmutableContext()); + + assertThat(result.getValue()).isEqualTo(1); + assertThat(result.getVariant()).isEqualTo("one"); + } + + @Test + void resolveDoubleValue_returnsCorrectValue() { + ProviderEvaluation result = flagdCore.resolveDoubleValue("doubleFlag", new ImmutableContext()); + + assertThat(result.getValue()).isEqualTo(3.14); + assertThat(result.getVariant()).isEqualTo("pi"); + } + + @Test + void resolveBooleanValue_flagNotFound_returnsError() { + ProviderEvaluation result = flagdCore.resolveBooleanValue("missingFlag", new ImmutableContext()); + + assertThat(result.getErrorCode()).isNotNull(); + assertThat(result.getErrorMessage()).contains("not found"); + } + + @Test + void resolveBooleanValue_disabledFlag_returnsError() { + ProviderEvaluation result = flagdCore.resolveBooleanValue("disabledFlag", new ImmutableContext()); + + assertThat(result.getErrorCode()).isNotNull(); + assertThat(result.getErrorMessage()).contains("disabled"); + } + + @Test + void getFlagSetMetadata_returnsMetadata() { + assertThat(flagdCore.getFlagSetMetadata()).containsEntry("version", "1.0.0"); + } + + @Test + void setFlagsAndGetChangedKeys_returnsChangedKeys() throws FlagStoreException { + var changedKeys = flagdCore.setFlagsAndGetChangedKeys(flagsConfig); + assertThat(changedKeys).isEmpty(); // No changes, same config + + // Change the config + String newConfig = flagsConfig.replace("\"on\": true", "\"on\": false"); + changedKeys = flagdCore.setFlagsAndGetChangedKeys(newConfig); + assertThat(changedKeys).contains("boolFlag"); + } + + @Test + void setFlagsAndGetChangedKeys_detectsRemovedFlags() throws FlagStoreException { + // Given: initial config has boolFlag + assertThat(flagdCore + .resolveBooleanValue("boolFlag", new ImmutableContext()) + .getValue()) + .isTrue(); + + // When: update with config that removes boolFlag (keeps only stringFlag) + String configWithoutBoolFlag = "{" + + "\"$schema\": \"https://flagd.dev/schema/v0/flags.json\"," + + "\"flags\": {" + + " \"stringFlag\": {" + + " \"state\": \"ENABLED\"," + + " \"defaultVariant\": \"greeting\"," + + " \"variants\": { \"greeting\": \"hello\" }" + + " }" + + "}," + + "\"metadata\": { \"version\": \"1.0.0\" }" + + "}"; + var changedKeys = flagdCore.setFlagsAndGetChangedKeys(configWithoutBoolFlag); + + // Then: boolFlag should be in the changed keys (it was removed) + assertThat(changedKeys).contains("boolFlag"); + } +} diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/TestUtils.java similarity index 88% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java rename to tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/TestUtils.java index ee5ac389a..ccd1c14c8 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/TestUtils.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/TestUtils.java @@ -1,6 +1,5 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process; +package dev.openfeature.contrib.tools.flagd.core; -import dev.openfeature.contrib.providers.flagd.resolver.process.model.FlagParser; import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; @@ -32,7 +31,7 @@ public static String getResourcePath(final String relativePath) { private static Path getResourcePathInternal(String file) { try { - URL url = Objects.requireNonNull(FlagParser.class.getClassLoader().getResource(file)); + URL url = Objects.requireNonNull(TestUtils.class.getClassLoader().getResource(file)); return Paths.get(url.toURI()); } catch (NullPointerException e) { throw new IllegalStateException(String.format("Resource %s not found", file), e); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParserTest.java similarity index 83% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java rename to tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParserTest.java index ab7c0ac80..c116ee6af 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/model/FlagParserTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/model/FlagParserTest.java @@ -1,15 +1,15 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.model; - -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_CFG; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG_METADATA; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG_MULTIPLE_ERRORS; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.INVALID_FLAG_SET_METADATA; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_FLAG_SET_METADATA; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_LONG; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_SIMPLE; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.VALID_SIMPLE_EXTRA_FIELD; -import static dev.openfeature.contrib.providers.flagd.resolver.process.TestUtils.getFlagsFromResource; +package dev.openfeature.contrib.tools.flagd.core.model; + +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.INVALID_CFG; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.INVALID_FLAG; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.INVALID_FLAG_METADATA; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.INVALID_FLAG_MULTIPLE_ERRORS; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.INVALID_FLAG_SET_METADATA; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.VALID_FLAG_SET_METADATA; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.VALID_LONG; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.VALID_SIMPLE; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.VALID_SIMPLE_EXTRA_FIELD; +import static dev.openfeature.contrib.tools.flagd.core.TestUtils.getFlagsFromResource; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -93,7 +93,7 @@ void validJsonConfigurationWithTargetingRulesParsing() throws IOException { @Test void validJsonConfigurationWithFlagSetMetadataParsing() throws IOException { - ParsingResult parsingResult = FlagParser.parseString(getFlagsFromResource(VALID_FLAG_SET_METADATA), true); + FlagParsingResult parsingResult = FlagParser.parseString(getFlagsFromResource(VALID_FLAG_SET_METADATA), true); Map flagMap = parsingResult.getFlags(); FeatureFlag flag = flagMap.get("without-metadata"); @@ -114,7 +114,7 @@ void validJsonConfigurationWithFlagSetMetadataParsing() throws IOException { @Test void validJsonConfigurationWithFlagMetadataParsing() throws IOException { - ParsingResult parsingResult = FlagParser.parseString(getFlagsFromResource(VALID_FLAG_SET_METADATA), true); + FlagParsingResult parsingResult = FlagParser.parseString(getFlagsFromResource(VALID_FLAG_SET_METADATA), true); Map flagMap = parsingResult.getFlags(); FeatureFlag flag = flagMap.get("with-metadata"); diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java similarity index 94% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/FractionalTest.java rename to tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index 4520c0c66..897be5219 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -1,6 +1,6 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; -import static dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator.*; +import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/OperatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java similarity index 97% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/OperatorTest.java rename to tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java index 7ef1b6a83..cbd28a9e9 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/OperatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java @@ -1,6 +1,6 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; -import static dev.openfeature.contrib.providers.flagd.resolver.process.targeting.Operator.TARGET_KEY; +import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.TARGET_KEY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -43,7 +43,6 @@ void timestampPresent() throws TargetingRuleException { // when Object timestampString = OPERATOR.apply("some-key", targetingRule, new ImmutableContext()); - long timestamp = (long) Double.parseDouble(timestampString.toString()); // generating current unix timestamp & 5 minute threshold diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVerTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java similarity index 96% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVerTest.java rename to tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java index 9448ca79c..39692a53a 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/SemVerTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/SemVerTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; import static org.assertj.core.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertNull; diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringCompTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java similarity index 96% rename from providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringCompTest.java rename to tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java index 028f7ac8f..8deed2acb 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/resolver/process/targeting/StringCompTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/StringCompTest.java @@ -1,4 +1,4 @@ -package dev.openfeature.contrib.providers.flagd.resolver.process.targeting; +package dev.openfeature.contrib.tools.flagd.core.targeting; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-configuration.json b/tools/flagd-core/src/test/resources/flagConfigurations/invalid-configuration.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/invalid-configuration.json rename to tools/flagd-core/src/test/resources/flagConfigurations/invalid-configuration.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-flag-multiple-errors.json b/tools/flagd-core/src/test/resources/flagConfigurations/invalid-flag-multiple-errors.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/invalid-flag-multiple-errors.json rename to tools/flagd-core/src/test/resources/flagConfigurations/invalid-flag-multiple-errors.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json b/tools/flagd-core/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json rename to tools/flagd-core/src/test/resources/flagConfigurations/invalid-flag-set-metadata.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-flag.json b/tools/flagd-core/src/test/resources/flagConfigurations/invalid-flag.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/invalid-flag.json rename to tools/flagd-core/src/test/resources/flagConfigurations/invalid-flag.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/invalid-metadata.json b/tools/flagd-core/src/test/resources/flagConfigurations/invalid-metadata.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/invalid-metadata.json rename to tools/flagd-core/src/test/resources/flagConfigurations/invalid-metadata.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/updatableFlags.json b/tools/flagd-core/src/test/resources/flagConfigurations/updatableFlags.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/updatableFlags.json rename to tools/flagd-core/src/test/resources/flagConfigurations/updatableFlags.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-flag-set-metadata.json b/tools/flagd-core/src/test/resources/flagConfigurations/valid-flag-set-metadata.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/valid-flag-set-metadata.json rename to tools/flagd-core/src/test/resources/flagConfigurations/valid-flag-set-metadata.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-long.json b/tools/flagd-core/src/test/resources/flagConfigurations/valid-long.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/valid-long.json rename to tools/flagd-core/src/test/resources/flagConfigurations/valid-long.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-simple-with-extra-fields.json b/tools/flagd-core/src/test/resources/flagConfigurations/valid-simple-with-extra-fields.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/valid-simple-with-extra-fields.json rename to tools/flagd-core/src/test/resources/flagConfigurations/valid-simple-with-extra-fields.json diff --git a/providers/flagd/src/test/resources/flagConfigurations/valid-simple.json b/tools/flagd-core/src/test/resources/flagConfigurations/valid-simple.json similarity index 100% rename from providers/flagd/src/test/resources/flagConfigurations/valid-simple.json rename to tools/flagd-core/src/test/resources/flagConfigurations/valid-simple.json diff --git a/tools/flagd-core/src/test/resources/flags/test-flags.json b/tools/flagd-core/src/test/resources/flags/test-flags.json new file mode 100644 index 000000000..47ccf66a5 --- /dev/null +++ b/tools/flagd-core/src/test/resources/flags/test-flags.json @@ -0,0 +1,46 @@ +{ + "flags": { + "boolFlag": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "stringFlag": { + "state": "ENABLED", + "variants": { + "greeting": "hello", + "farewell": "goodbye" + }, + "defaultVariant": "greeting" + }, + "intFlag": { + "state": "ENABLED", + "variants": { + "one": 1, + "two": 2 + }, + "defaultVariant": "one" + }, + "doubleFlag": { + "state": "ENABLED", + "variants": { + "pi": 3.14, + "e": 2.71 + }, + "defaultVariant": "pi" + }, + "disabledFlag": { + "state": "DISABLED", + "variants": { + "on": true + }, + "defaultVariant": "on" + } + }, + "metadata": { + "version": "1.0.0" + } +} diff --git a/providers/flagd/src/test/resources/fractional/1-1-1-1.json b/tools/flagd-core/src/test/resources/fractional/1-1-1-1.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/1-1-1-1.json rename to tools/flagd-core/src/test/resources/fractional/1-1-1-1.json diff --git a/providers/flagd/src/test/resources/fractional/25-25-25-25.json b/tools/flagd-core/src/test/resources/fractional/25-25-25-25.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/25-25-25-25.json rename to tools/flagd-core/src/test/resources/fractional/25-25-25-25.json diff --git a/providers/flagd/src/test/resources/fractional/50-50.json b/tools/flagd-core/src/test/resources/fractional/50-50.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/50-50.json rename to tools/flagd-core/src/test/resources/fractional/50-50.json diff --git a/providers/flagd/src/test/resources/fractional/notEnoughBuckets.json b/tools/flagd-core/src/test/resources/fractional/notEnoughBuckets.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/notEnoughBuckets.json rename to tools/flagd-core/src/test/resources/fractional/notEnoughBuckets.json diff --git a/providers/flagd/src/test/resources/fractional/selfContainedFractionalA.json b/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalA.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/selfContainedFractionalA.json rename to tools/flagd-core/src/test/resources/fractional/selfContainedFractionalA.json diff --git a/providers/flagd/src/test/resources/fractional/selfContainedFractionalB.json b/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/selfContainedFractionalB.json rename to tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json diff --git a/providers/flagd/src/test/resources/fractional/sum-greater-100.json b/tools/flagd-core/src/test/resources/fractional/sum-greater-100.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/sum-greater-100.json rename to tools/flagd-core/src/test/resources/fractional/sum-greater-100.json diff --git a/providers/flagd/src/test/resources/fractional/sum-lower-100.json.json b/tools/flagd-core/src/test/resources/fractional/sum-lower-100.json.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/sum-lower-100.json.json rename to tools/flagd-core/src/test/resources/fractional/sum-lower-100.json.json diff --git a/providers/flagd/src/test/resources/fractional/template.json b/tools/flagd-core/src/test/resources/fractional/template.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/template.json rename to tools/flagd-core/src/test/resources/fractional/template.json diff --git a/providers/flagd/src/test/resources/fractional/weighting-not-set.json b/tools/flagd-core/src/test/resources/fractional/weighting-not-set.json similarity index 100% rename from providers/flagd/src/test/resources/fractional/weighting-not-set.json rename to tools/flagd-core/src/test/resources/fractional/weighting-not-set.json diff --git a/tools/flagd-core/version.txt b/tools/flagd-core/version.txt new file mode 100644 index 000000000..8acdd82b7 --- /dev/null +++ b/tools/flagd-core/version.txt @@ -0,0 +1 @@ +0.0.1