diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 03fb4491..cacf90dd 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c - name: Set up JDK 8 - uses: actions/setup-java@8f12c5c4d1ebd18bf4b7a51a1b0b912633f0bc61 + uses: actions/setup-java@ea15b3b99cdc9ac45af1882d085e3f9297a75a8b with: java-version: '8' distribution: 'temurin' @@ -32,7 +32,7 @@ jobs: server-password: ${{ secrets.OSSRH_PASSWORD }} - name: Cache local Maven repository - uses: actions/cache@2b5a782c6414f96354f95902141714b127692d1e + uses: actions/cache@6998d139ddd3e68c71e9e398d8e40b71a2f39812 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -49,7 +49,7 @@ jobs: run: mvn --batch-mode --update-snapshots verify - name: Upload coverage to Codecov - uses: codecov/codecov-action@e0fbd592d323cb2991fb586fdd260734fcb41fcb + uses: codecov/codecov-action@742000aae0f04cc2a6bf74c9934c84b579cc1c18 with: flags: unittests # optional name: coverage # optional diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index fdefe2e1..62219ac5 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -19,19 +19,19 @@ jobs: uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c - name: Set up JDK 8 - uses: actions/setup-java@8f12c5c4d1ebd18bf4b7a51a1b0b912633f0bc61 + uses: actions/setup-java@ea15b3b99cdc9ac45af1882d085e3f9297a75a8b with: java-version: '8' distribution: 'temurin' cache: maven - name: Initialize CodeQL - uses: github/codeql-action/init@a58e90a9daf2aac731400f56d005f2b324a2c5b8 + uses: github/codeql-action/init@7ba5ed7eed12f15064a031cc1fa3341f93764020 with: languages: java - name: Cache local Maven repository - uses: actions/cache@2b5a782c6414f96354f95902141714b127692d1e + uses: actions/cache@6998d139ddd3e68c71e9e398d8e40b71a2f39812 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -42,7 +42,7 @@ jobs: run: mvn --batch-mode --update-snapshots verify -P integration-test - name: Upload coverage to Codecov - uses: codecov/codecov-action@e0fbd592d323cb2991fb586fdd260734fcb41fcb + uses: codecov/codecov-action@742000aae0f04cc2a6bf74c9934c84b579cc1c18 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos flags: unittests # optional @@ -51,4 +51,4 @@ jobs: verbose: true # optional (default = false) - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@a58e90a9daf2aac731400f56d005f2b324a2c5b8 + uses: github/codeql-action/analyze@7ba5ed7eed12f15064a031cc1fa3341f93764020 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 517b1be0..03d186c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c - name: Set up JDK 8 if: ${{ steps.release.outputs.releases_created }} - uses: actions/setup-java@8f12c5c4d1ebd18bf4b7a51a1b0b912633f0bc61 + uses: actions/setup-java@ea15b3b99cdc9ac45af1882d085e3f9297a75a8b with: java-version: '8' distribution: 'temurin' diff --git a/.github/workflows/static-code-scanning.yaml b/.github/workflows/static-code-scanning.yaml index 42085389..70edc8e5 100644 --- a/.github/workflows/static-code-scanning.yaml +++ b/.github/workflows/static-code-scanning.yaml @@ -33,12 +33,12 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@a58e90a9daf2aac731400f56d005f2b324a2c5b8 + uses: github/codeql-action/init@7ba5ed7eed12f15064a031cc1fa3341f93764020 with: languages: java - name: Autobuild - uses: github/codeql-action/autobuild@a58e90a9daf2aac731400f56d005f2b324a2c5b8 + uses: github/codeql-action/autobuild@7ba5ed7eed12f15064a031cc1fa3341f93764020 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@a58e90a9daf2aac731400f56d005f2b324a2c5b8 + uses: github/codeql-action/analyze@7ba5ed7eed12f15064a031cc1fa3341f93764020 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 077bc02a..391149ac 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"1.1.0"} \ No newline at end of file +{".":"1.2.0"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e42f88ca..51318976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.2.0](https://github.com/open-feature/java-sdk/compare/v1.1.0...v1.2.0) (2023-02-10) + + +### Features + +* added implementation of immutable evaluation context ([#210](https://github.com/open-feature/java-sdk/issues/210)) ([6c14d87](https://github.com/open-feature/java-sdk/commit/6c14d87c2e54c953eff351fa1ccdd914fa08b6ed)) + + +### Bug Fixes + +* **deps:** update dependency io.cucumber:cucumber-bom to v7.11.1 ([#271](https://github.com/open-feature/java-sdk/issues/271)) ([8845242](https://github.com/open-feature/java-sdk/commit/88452423303f8c1feada733f1c9d4db845d64a5b)) +* **deps:** update dependency org.projectlombok:lombok to v1.18.26 ([#277](https://github.com/open-feature/java-sdk/issues/277)) ([aad036a](https://github.com/open-feature/java-sdk/commit/aad036a0113e2d248e493d0d87e52320a66df7a2)) +* improve error logs for evaluation failure ([#276](https://github.com/open-feature/java-sdk/issues/276)) ([9349997](https://github.com/open-feature/java-sdk/commit/93499975d0b9ae30aa34db999d8aa3d7c955da70)) +* MutableContext and ImmutableContext merge are made recursive ([#280](https://github.com/open-feature/java-sdk/issues/280)) ([bd4e12e](https://github.com/open-feature/java-sdk/commit/bd4e12e16f3c4af5cdcad490977ccc0842e1ded6)) + ## [1.1.0](https://github.com/open-feature/java-sdk/compare/v1.0.1...v1.1.0) (2023-01-24) diff --git a/README.md b/README.md index 76c7883f..5ac1e6ea 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Maven Central](https://maven-badges.herokuapp.com/maven-central/dev.openfeature/sdk/badge.svg)](https://maven-badges.herokuapp.com/maven-central/dev.openfeature/sdk) [![javadoc](https://javadoc.io/badge2/dev.openfeature/sdk/javadoc.svg)](https://javadoc.io/doc/dev.openfeature/sdk) [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) -[![v0.5.1](https://img.shields.io/static/v1?label=Specification&message=v0.5.1&color=yellow)](https://github.com/open-feature/spec/tree/v0.5.1) +[![Specification](https://img.shields.io/static/v1?label=Specification&message=v0.5.2&color=yellow)](https://github.com/open-feature/spec/tree/v0.5.2) [![Known Vulnerabilities](https://snyk.io/test/github/open-feature/java-sdk/badge.svg)](https://snyk.io/test/github/open-feature/java-sdk) [![on-merge](https://github.com/open-feature/java-sdk/actions/workflows/merge.yml/badge.svg)](https://github.com/open-feature/java-sdk/actions/workflows/merge.yml) [![codecov](https://codecov.io/gh/open-feature/java-sdk/branch/main/graph/badge.svg?token=XMS9L7PBY1)](https://codecov.io/gh/open-feature/java-sdk) @@ -68,7 +68,7 @@ For complete documentation, visit: https://docs.openfeature.dev/docs/category/co dev.openfeature sdk - 1.1.0 + 1.2.0 ``` @@ -92,7 +92,7 @@ If you would like snapshot builds, this is the relevant repository information: ```groovy dependencies { - implementation 'dev.openfeature:sdk:1.1.0' + implementation 'dev.openfeature:sdk:1.2.0' } ``` diff --git a/pom.xml b/pom.xml index 83599d96..930d2b84 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ dev.openfeature sdk - 1.1.0 + 1.2.0 UTF-8 @@ -44,7 +44,7 @@ org.projectlombok lombok - 1.18.24 + 1.18.26 provided @@ -59,7 +59,7 @@ org.slf4j slf4j-api - 1.7.36 + 2.0.6 @@ -70,13 +70,6 @@ test - - uk.org.lidalia - slf4j-test - 1.2.0 - test - - org.assertj assertj-core @@ -131,6 +124,13 @@ test + + org.simplify4u + slf4j2-mock + 2.3.0 + test + + com.google.guava guava @@ -152,7 +152,7 @@ io.cucumber cucumber-bom - 7.11.0 + 7.11.1 pom import @@ -228,6 +228,7 @@ com.github.spotbugs:* org.junit* + org.simplify4u:slf4j2-mock* com.google.guava* @@ -235,7 +236,7 @@ org.junit* com.google.code.findbugs* com.github.spotbugs* - uk.org.lidalia:lidalia-slf4j-ext:* + org.simplify4u:slf4j-mock-common:* diff --git a/src/main/java/dev/openfeature/sdk/EvaluationContext.java b/src/main/java/dev/openfeature/sdk/EvaluationContext.java index d613c700..10a7ea17 100644 --- a/src/main/java/dev/openfeature/sdk/EvaluationContext.java +++ b/src/main/java/dev/openfeature/sdk/EvaluationContext.java @@ -8,6 +8,10 @@ public interface EvaluationContext extends Structure { String getTargetingKey(); + /** + * Mutating targeting key is not supported in all implementations and will be removed. + */ + @Deprecated void setTargetingKey(String targetingKey); /** diff --git a/src/main/java/dev/openfeature/sdk/ImmutableContext.java b/src/main/java/dev/openfeature/sdk/ImmutableContext.java new file mode 100644 index 00000000..3fb25c28 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ImmutableContext.java @@ -0,0 +1,96 @@ +package dev.openfeature.sdk; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; +import lombok.ToString; +import lombok.experimental.Delegate; + +/** + * The EvaluationContext is a container for arbitrary contextual data + * that can be used as a basis for dynamic evaluation. + * The ImmutableContext is an EvaluationContext implementation which is threadsafe, and whose attributes can + * not be modified after instantiation. + */ +@ToString +@SuppressWarnings("PMD.BeanMembersShouldSerialize") +public final class ImmutableContext implements EvaluationContext { + + @Getter + private final String targetingKey; + @Delegate + private final Structure structure; + + /** + * Create an immutable context with an empty targeting_key and attributes provided. + */ + public ImmutableContext() { + this("", new HashMap<>()); + } + + /** + * Create an immutable context with given targeting_key provided. + * + * @param targetingKey targeting key + */ + public ImmutableContext(String targetingKey) { + this(targetingKey, new HashMap<>()); + } + + /** + * Create an immutable context with an attributes provided. + * + * @param attributes evaluation context attributes + */ + public ImmutableContext(Map attributes) { + this("", attributes); + } + + /** + * Create an immutable context with given targetingKey and attributes provided. + * + * @param targetingKey targeting key + * @param attributes evaluation context attributes + */ + public ImmutableContext(String targetingKey, Map attributes) { + this.structure = new ImmutableStructure(attributes); + this.targetingKey = targetingKey; + } + + /** + * Mutating targeting key is not supported in ImmutableContext and will be removed. + */ + @Override + @Deprecated + public void setTargetingKey(String targetingKey) { + throw new UnsupportedOperationException("changing of targeting key is not allowed"); + } + + /** + * Merges this EvaluationContext object with the passed EvaluationContext, overriding in case of conflict. + * + * @param overridingContext overriding context + * @return resulting merged context + */ + @Override + public EvaluationContext merge(EvaluationContext overridingContext) { + if (overridingContext == null) { + return new ImmutableContext(this.targetingKey, this.asMap()); + } + String newTargetingKey = ""; + if (this.getTargetingKey() != null && !this.getTargetingKey().trim().equals("")) { + newTargetingKey = this.getTargetingKey(); + } + + if (overridingContext.getTargetingKey() != null && !overridingContext.getTargetingKey().trim().equals("")) { + newTargetingKey = overridingContext.getTargetingKey(); + } + + Map merged = this.merge(m -> new ImmutableStructure(m), + this.asMap(), + overridingContext.asMap()); + + return new ImmutableContext(newTargetingKey, merged); + } +} diff --git a/src/main/java/dev/openfeature/sdk/ImmutableStructure.java b/src/main/java/dev/openfeature/sdk/ImmutableStructure.java new file mode 100644 index 00000000..2dc2cdaf --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/ImmutableStructure.java @@ -0,0 +1,87 @@ +package dev.openfeature.sdk; + +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * {@link ImmutableStructure} represents a potentially nested object type which is used to represent + * structured data. + * The ImmutableStructure is a Structure implementation which is threadsafe, and whose attributes can + * not be modified after instantiation. + */ +@ToString +@EqualsAndHashCode +@SuppressWarnings({"PMD.BeanMembersShouldSerialize", "checkstyle:MissingJavadocType"}) +public final class ImmutableStructure implements Structure { + + private final Map attributes; + + /** + * create an immutable structure with the empty attributes. + */ + public ImmutableStructure() { + this(new HashMap<>()); + } + + /** + * create immutable structure with the given attributes. + * + * @param attributes attributes. + */ + public ImmutableStructure(Map attributes) { + Map copy = attributes.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().clone())); + this.attributes = new HashMap<>(copy); + } + + @Override + public Set keySet() { + return new HashSet<>(this.attributes.keySet()); + } + + // getters + @Override + public Value getValue(String key) { + Value value = this.attributes.get(key); + return value.clone(); + } + + /** + * Get all values. + * + * @return all attributes on the structure + */ + @Override + public Map asMap() { + return attributes + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> getValue(e.getKey()) + )); + } + + /** + * Get all values, with primitives types. + * + * @return all attributes on the structure into a Map + */ + @Override + public Map asObjectMap() { + return attributes + .entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> convertValue(getValue(e.getKey())) + )); + } +} diff --git a/src/main/java/dev/openfeature/sdk/MutableContext.java b/src/main/java/dev/openfeature/sdk/MutableContext.java index b11503c2..6abd74a5 100644 --- a/src/main/java/dev/openfeature/sdk/MutableContext.java +++ b/src/main/java/dev/openfeature/sdk/MutableContext.java @@ -1,7 +1,6 @@ package dev.openfeature.sdk; import java.time.Instant; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -92,18 +91,25 @@ public EvaluationContext merge(EvaluationContext overridingContext) { return new MutableContext(this.asMap()); } - Map merged = new HashMap(); + Map merged = this.merge(map -> new MutableStructure(map), + this.asMap(), + overridingContext.asMap()); - merged.putAll(this.asMap()); - merged.putAll(overridingContext.asMap()); - EvaluationContext ec = new MutableContext(merged); + String newTargetingKey = ""; if (this.getTargetingKey() != null && !this.getTargetingKey().trim().equals("")) { - ec.setTargetingKey(this.getTargetingKey()); + newTargetingKey = this.getTargetingKey(); } if (overridingContext.getTargetingKey() != null && !overridingContext.getTargetingKey().trim().equals("")) { - ec.setTargetingKey(overridingContext.getTargetingKey()); + newTargetingKey = overridingContext.getTargetingKey(); + } + + EvaluationContext ec = null; + if (newTargetingKey != null && !newTargetingKey.trim().equals("")) { + ec = new MutableContext(newTargetingKey, merged); + } else { + ec = new MutableContext(merged); } return ec; diff --git a/src/main/java/dev/openfeature/sdk/MutableStructure.java b/src/main/java/dev/openfeature/sdk/MutableStructure.java index 43a32f62..343fe2c9 100644 --- a/src/main/java/dev/openfeature/sdk/MutableStructure.java +++ b/src/main/java/dev/openfeature/sdk/MutableStructure.java @@ -1,5 +1,8 @@ package dev.openfeature.sdk; +import lombok.EqualsAndHashCode; +import lombok.ToString; + import java.time.Instant; import java.util.HashMap; import java.util.List; @@ -7,10 +10,6 @@ import java.util.Set; import java.util.stream.Collectors; -import dev.openfeature.sdk.exceptions.ValueNotConvertableError; -import lombok.EqualsAndHashCode; -import lombok.ToString; - /** * {@link MutableStructure} represents a potentially nested object type which is used to represent * structured data. @@ -109,53 +108,4 @@ public Map asObjectMap() { e -> convertValue(getValue(e.getKey())) )); } - - /** - * convertValue is converting the object type Value in a primitive type. - * - * @param value - Value object to convert - * @return an Object containing the primitive type. - */ - private Object convertValue(Value value) { - if (value.isBoolean()) { - return value.asBoolean(); - } - - if (value.isNumber()) { - Double valueAsDouble = value.asDouble(); - if (valueAsDouble == Math.floor(valueAsDouble) && !Double.isInfinite(valueAsDouble)) { - return value.asInteger(); - } - return valueAsDouble; - } - - if (value.isString()) { - return value.asString(); - } - - if (value.isInstant()) { - return value.asInstant(); - } - - if (value.isList()) { - return value.asList() - .stream() - .map(this::convertValue) - .collect(Collectors.toList()); - } - - if (value.isStructure()) { - Structure s = value.asStructure(); - return s.asMap() - .keySet() - .stream() - .collect( - Collectors.toMap( - key -> key, - key -> convertValue(s.getValue(key)) - ) - ); - } - throw new ValueNotConvertableError(); - } } diff --git a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java index 74690fe9..a13eb942 100644 --- a/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java +++ b/src/main/java/dev/openfeature/sdk/OpenFeatureClient.java @@ -93,7 +93,7 @@ private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key FlagEvaluationOptions flagOptions = ObjectUtils.defaultIfNull(options, () -> FlagEvaluationOptions.builder().build()); Map hints = Collections.unmodifiableMap(flagOptions.getHookHints()); - ctx = ObjectUtils.defaultIfNull(ctx, () -> new MutableContext()); + ctx = ObjectUtils.defaultIfNull(ctx, () -> new ImmutableContext()); FlagEvaluationDetails details = null; @@ -120,10 +120,10 @@ private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key // merge of: API.context, client.context, invocation.context apiContext = openfeatureApi.getEvaluationContext() != null ? openfeatureApi.getEvaluationContext() - : new MutableContext(); + : new ImmutableContext(); clientContext = this.getEvaluationContext() != null ? this.getEvaluationContext() - : new MutableContext(); + : new ImmutableContext(); EvaluationContext ctxFromHook = hookSupport.beforeHooks(type, hookCtx, mergedHooks, hints); @@ -137,7 +137,7 @@ private FlagEvaluationDetails evaluateFlag(FlagValueType type, String key details = FlagEvaluationDetails.from(providerEval, key); hookSupport.afterHooks(type, hookCtx, details, mergedHooks, hints); } catch (Exception e) { - log.error("Unable to correctly evaluate flag with key {} due to exception {}", key, e.getMessage()); + log.error("Unable to correctly evaluate flag with key '{}'", key, e); if (details == null) { details = FlagEvaluationDetails.builder().build(); } diff --git a/src/main/java/dev/openfeature/sdk/Structure.java b/src/main/java/dev/openfeature/sdk/Structure.java index 5c67551f..9d66c2be 100644 --- a/src/main/java/dev/openfeature/sdk/Structure.java +++ b/src/main/java/dev/openfeature/sdk/Structure.java @@ -1,7 +1,13 @@ package dev.openfeature.sdk; +import dev.openfeature.sdk.exceptions.ValueNotConvertableError; + +import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.Map.Entry; +import java.util.function.Function; +import java.util.stream.Collectors; /** * {@link Structure} represents a potentially nested object type which is used to represent @@ -38,4 +44,82 @@ public interface Structure { * @return all attributes on the structure into a Map */ Map asObjectMap(); + + /** + * convertValue is converting the object type Value in a primitive type. + * + * @param value - Value object to convert + * @return an Object containing the primitive type. + */ + default Object convertValue(Value value) { + if (value.isBoolean()) { + return value.asBoolean(); + } + + if (value.isNumber()) { + Double valueAsDouble = value.asDouble(); + if (valueAsDouble == Math.floor(valueAsDouble) && !Double.isInfinite(valueAsDouble)) { + return value.asInteger(); + } + return valueAsDouble; + } + + if (value.isString()) { + return value.asString(); + } + + if (value.isInstant()) { + return value.asInstant(); + } + + if (value.isList()) { + return value.asList() + .stream() + .map(this::convertValue) + .collect(Collectors.toList()); + } + + if (value.isStructure()) { + Structure s = value.asStructure(); + return s.asMap() + .keySet() + .stream() + .collect( + Collectors.toMap( + key -> key, + key -> convertValue(s.getValue(key)) + ) + ); + } + throw new ValueNotConvertableError(); + } + + /** + * Recursively merges the base map with the overriding map. + * + * @param Structure type + * @param newStructure function to create the right structure + * @param base base map to merge + * @param overriding overriding map to merge + * @return resulting merged map + */ + default Map merge(Function, Structure> newStructure, + Map base, + Map overriding) { + Map merged = new HashMap<>(); + + merged.putAll(base); + for (Entry overridingEntry : overriding.entrySet()) { + String key = overridingEntry.getKey(); + if (overridingEntry.getValue().isStructure() && merged.containsKey(key) && merged.get(key).isStructure()) { + Structure mergedValue = merged.get(key).asStructure(); + Structure overridingValue = overridingEntry.getValue().asStructure(); + Map newMap = this.merge(newStructure, mergedValue.asMap(), overridingValue.asMap()); + merged.put(key, new Value(newStructure.apply(newMap))); + } else { + merged.put(key, overridingEntry.getValue()); + } + } + return merged; + } } diff --git a/src/main/java/dev/openfeature/sdk/Value.java b/src/main/java/dev/openfeature/sdk/Value.java index a013064c..b1ad1c15 100644 --- a/src/main/java/dev/openfeature/sdk/Value.java +++ b/src/main/java/dev/openfeature/sdk/Value.java @@ -2,8 +2,11 @@ import java.time.Instant; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.EqualsAndHashCode; +import lombok.SneakyThrows; import lombok.ToString; /** @@ -14,7 +17,7 @@ @ToString @EqualsAndHashCode @SuppressWarnings({"PMD.BeanMembersShouldSerialize", "checkstyle:MissingJavadocType"}) -public class Value { +public class Value implements Cloneable { private final Object innerObject; @@ -238,4 +241,31 @@ public Instant asInstant() { } return null; } + + /** + * Perform deep clone of value object. + * + * @return Value + */ + + @SneakyThrows + @Override + protected Value clone() { + if (this.isList()) { + List copy = this.asList().stream().map(Value::new).collect(Collectors.toList()); + return new Value(copy); + } + if (this.isStructure()) { + Map copy = this.asStructure().asMap().entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().clone() + )); + return new Value(new ImmutableStructure(copy)); + } + if (this.isInstant()) { + Instant copy = Instant.ofEpochMilli(this.asInstant().toEpochMilli()); + return new Value(copy); + } + return new Value(this.asObject()); + } } diff --git a/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java b/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java index 12e03abe..f32711a2 100644 --- a/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java +++ b/src/test/java/dev/openfeature/sdk/DeveloperExperienceTest.java @@ -7,6 +7,8 @@ import static org.mockito.Mockito.verify; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -70,14 +72,14 @@ class DeveloperExperienceTest implements HookFixtures { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setProvider(new NoOpProvider()); Client client = api.getClient(); - - MutableContext ctx = new MutableContext() - .add("int-val", 3) - .add("double-val", 4.0) - .add("str-val", "works") - .add("bool-val", false) - .add("value-val", Arrays.asList(new Value(2), new Value(4))); - + Map attributes = new HashMap<>(); + List values = Arrays.asList(new Value(2), new Value(4)); + attributes.put("int-val", new Value(3)); + attributes.put("double-val", new Value(4.0)); + attributes.put("str-val", new Value("works")); + attributes.put("bool-val", new Value(false)); + attributes.put("value-val", new Value(values)); + EvaluationContext ctx = new ImmutableContext(attributes); Boolean retval = client.getBooleanValue(flagKey, false, ctx); assertFalse(retval); } diff --git a/src/test/java/dev/openfeature/sdk/EvalContextTest.java b/src/test/java/dev/openfeature/sdk/EvalContextTest.java index ba31b4b3..d2fbb3ac 100644 --- a/src/test/java/dev/openfeature/sdk/EvalContextTest.java +++ b/src/test/java/dev/openfeature/sdk/EvalContextTest.java @@ -3,11 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import io.cucumber.java.hu.Ha; import org.junit.jupiter.api.Test; public class EvalContextTest { @@ -15,8 +18,7 @@ public class EvalContextTest { text="The `evaluation context` structure **MUST** define an optional `targeting key` field of " + "type string, identifying the subject of the flag evaluation.") @Test void requires_targeting_key() { - MutableContext ec = new MutableContext(); - ec.setTargetingKey("targeting-key"); + EvaluationContext ec = new ImmutableContext("targeting-key", new HashMap<>()); assertEquals("targeting-key", ec.getTargetingKey()); } @@ -24,32 +26,35 @@ public class EvalContextTest { "custom fields, having keys of type `string`, and " + "values of type `boolean | string | number | datetime | structure`.") @Test void eval_context() { - MutableContext ec = new MutableContext(); + Map attributes = new HashMap<>(); + Instant dt = Instant.now().truncatedTo(ChronoUnit.MILLIS); + attributes.put("str", new Value("test")); + attributes.put("bool", new Value(true)); + attributes.put("int", new Value(4)); + attributes.put("dt", new Value(dt)); + EvaluationContext ec = new ImmutableContext(attributes); - ec.add("str", "test"); assertEquals("test", ec.getValue("str").asString()); - ec.add("bool", true); assertEquals(true, ec.getValue("bool").asBoolean()); - ec.add("int", 4); assertEquals(4, ec.getValue("int").asInteger()); - Instant dt = Instant.now(); - ec.add("dt", dt); - assertEquals(dt, ec.getValue("dt").asInstant()); + assertEquals(dt, ec.getValue("dt").asInstant().truncatedTo(ChronoUnit.MILLIS)); } @Specification(number="3.1.2", text="The evaluation context MUST support the inclusion of " + "custom fields, having keys of type `string`, and " + "values of type `boolean | string | number | datetime | structure`.") @Test void eval_context_structure_array() { - MutableContext ec = new MutableContext(); - ec.add("obj", new MutableStructure().add("val1", 1).add("val2", "2")); - ec.add("arr", new ArrayList(){{ + Map attributes = new HashMap<>(); + attributes.put("obj", new Value(new MutableStructure().add("val1", 1).add("val2", "2"))); + List values = new ArrayList(){{ add(new Value("one")); add(new Value("two")); - }}); + }}; + attributes.put("arr", new Value(values)); + EvaluationContext ec = new ImmutableContext(attributes); Structure str = ec.getValue("obj").asStructure(); assertEquals(1, str.getValue("val1").asInteger()); @@ -62,21 +67,18 @@ public class EvalContextTest { @Specification(number="3.1.3", text="The evaluation context MUST support fetching the custom fields by key and also fetching all key value pairs.") @Test void fetch_all() { - MutableContext ec = new MutableContext(); - - ec.add("str", "test"); - ec.add("str2", "test2"); - - ec.add("bool", true); - ec.add("bool2", false); - - ec.add("int", 4); - ec.add("int2", 2); - + Map attributes = new HashMap<>(); Instant dt = Instant.now(); - ec.add("dt", dt); - - ec.add("obj", new MutableStructure().add("val1", 1).add("val2", "2")); + MutableStructure mutableStructure = new MutableStructure().add("val1", 1).add("val2", "2"); + attributes.put("str", new Value("test")); + attributes.put("str2", new Value("test2")); + attributes.put("bool", new Value(true)); + attributes.put("bool2", new Value(false)); + attributes.put("int", new Value(4)); + attributes.put("int2", new Value(2)); + attributes.put("dt", new Value(dt)); + attributes.put("obj", new Value(mutableStructure)); + EvaluationContext ec = new ImmutableContext(attributes); Map foundStr = ec.asMap(); assertEquals(ec.getValue("str").asString(), foundStr.get("str").asString()); @@ -106,6 +108,16 @@ public class EvalContextTest { assertEquals(3, ec.getValue("key").asInteger()); } + @Test void unique_key_across_types_immutableContext() { + HashMap attributes = new HashMap<>(); + attributes.put("key", new Value("val")); + attributes.put("key", new Value("val2")); + attributes.put("key", new Value(3)); + EvaluationContext ec = new ImmutableContext(attributes); + assertEquals(null, ec.getValue("key").asString()); + assertEquals(3, ec.getValue("key").asInteger()); + } + @Test void can_chain_attribute_addition() { MutableContext ec = new MutableContext(); MutableContext out = ec.add("str", "test") @@ -132,6 +144,24 @@ public class EvalContextTest { assertEquals(null, ec.getValue("Instant").asString()); } + @Test void Immutable_context_merge_targeting_key() { + String key1 = "key1"; + EvaluationContext ctx1 = new ImmutableContext(key1, new HashMap<>()); + EvaluationContext ctx2 = new ImmutableContext(new HashMap<>()); + + EvaluationContext ctxMerged = ctx1.merge(ctx2); + assertEquals(key1, ctxMerged.getTargetingKey()); + + String key2 = "key2"; + ctx2 = new ImmutableContext(key2, new HashMap<>()); + ctxMerged = ctx1.merge(ctx2); + assertEquals(key2, ctxMerged.getTargetingKey()); + + ctx2 = new ImmutableContext(" ",new HashMap<>()); + ctxMerged = ctx1.merge(ctx2); + assertEquals(key1, ctxMerged.getTargetingKey()); + } + @Test void merge_targeting_key() { String key1 = "key1"; EvaluationContext ctx1 = new MutableContext(key1); diff --git a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java index 0bf9a6d4..e246c6d6 100644 --- a/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/FlagEvaluationSpecTest.java @@ -1,6 +1,7 @@ package dev.openfeature.sdk; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -11,19 +12,27 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; + +import dev.openfeature.sdk.exceptions.FlagNotFoundError; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import dev.openfeature.sdk.fixtures.HookFixtures; -import uk.org.lidalia.slf4jtest.LoggingEvent; -import uk.org.lidalia.slf4jtest.TestLogger; -import uk.org.lidalia.slf4jtest.TestLoggerFactory; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; +import org.simplify4u.slf4jmock.LoggerMock; +import org.slf4j.Logger; class FlagEvaluationSpecTest implements HookFixtures { - private static final TestLogger TEST_LOGGER = TestLoggerFactory.getTestLogger(OpenFeatureClient.class); + private Logger logger; private Client _client() { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); @@ -36,6 +45,15 @@ private Client _client() { api.setEvaluationContext(null); } + @BeforeEach void set_logger() { + logger = Mockito.mock(Logger.class); + LoggerMock.setMock(OpenFeatureClient.class, logger); + } + + @AfterEach void reset_logs() { + LoggerMock.setMock(OpenFeatureClient.class, logger); + } + @Specification(number="1.1.1", text="The API, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the API are present at runtime.") @Test void global_singleton() { assertSame(OpenFeatureAPI.getInstance(), OpenFeatureAPI.getInstance()); @@ -100,24 +118,24 @@ private Client _client() { String key = "key"; assertEquals(true, c.getBooleanValue(key, false)); - assertEquals(true, c.getBooleanValue(key, false, new MutableContext())); - assertEquals(true, c.getBooleanValue(key, false, new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(true, c.getBooleanValue(key, false, new ImmutableContext())); + assertEquals(true, c.getBooleanValue(key, false, new ImmutableContext(), FlagEvaluationOptions.builder().build())); assertEquals("gnirts-ym", c.getStringValue(key, "my-string")); - assertEquals("gnirts-ym", c.getStringValue(key, "my-string", new MutableContext())); - assertEquals("gnirts-ym", c.getStringValue(key, "my-string", new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals("gnirts-ym", c.getStringValue(key, "my-string", new ImmutableContext())); + assertEquals("gnirts-ym", c.getStringValue(key, "my-string", new ImmutableContext(), FlagEvaluationOptions.builder().build())); assertEquals(400, c.getIntegerValue(key, 4)); - assertEquals(400, c.getIntegerValue(key, 4, new MutableContext())); - assertEquals(400, c.getIntegerValue(key, 4, new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(400, c.getIntegerValue(key, 4, new ImmutableContext())); + assertEquals(400, c.getIntegerValue(key, 4, new ImmutableContext(), FlagEvaluationOptions.builder().build())); assertEquals(40.0, c.getDoubleValue(key, .4)); - assertEquals(40.0, c.getDoubleValue(key, .4, new MutableContext())); - assertEquals(40.0, c.getDoubleValue(key, .4, new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(40.0, c.getDoubleValue(key, .4, new ImmutableContext())); + assertEquals(40.0, c.getDoubleValue(key, .4, new ImmutableContext(), FlagEvaluationOptions.builder().build())); assertEquals(null, c.getObjectValue(key, new Value())); - assertEquals(null, c.getObjectValue(key, new Value(), new MutableContext())); - assertEquals(null, c.getObjectValue(key, new Value(), new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(null, c.getObjectValue(key, new Value(), new ImmutableContext())); + assertEquals(null, c.getObjectValue(key, new Value(), new ImmutableContext(), FlagEvaluationOptions.builder().build())); } @Specification(number="1.4.1", text="The client MUST provide methods for detailed flag value evaluation with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns an evaluation details structure.") @@ -138,8 +156,8 @@ private Client _client() { .variant(null) .build(); assertEquals(bd, c.getBooleanDetails(key, true)); - assertEquals(bd, c.getBooleanDetails(key, true, new MutableContext())); - assertEquals(bd, c.getBooleanDetails(key, true, new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(bd, c.getBooleanDetails(key, true, new ImmutableContext())); + assertEquals(bd, c.getBooleanDetails(key, true, new ImmutableContext(), FlagEvaluationOptions.builder().build())); FlagEvaluationDetails sd = FlagEvaluationDetails.builder() .flagKey(key) @@ -147,24 +165,24 @@ private Client _client() { .variant(null) .build(); assertEquals(sd, c.getStringDetails(key, "test")); - assertEquals(sd, c.getStringDetails(key, "test", new MutableContext())); - assertEquals(sd, c.getStringDetails(key, "test", new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(sd, c.getStringDetails(key, "test", new ImmutableContext())); + assertEquals(sd, c.getStringDetails(key, "test", new ImmutableContext(), FlagEvaluationOptions.builder().build())); FlagEvaluationDetails id = FlagEvaluationDetails.builder() .flagKey(key) .value(400) .build(); assertEquals(id, c.getIntegerDetails(key, 4)); - assertEquals(id, c.getIntegerDetails(key, 4, new MutableContext())); - assertEquals(id, c.getIntegerDetails(key, 4, new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(id, c.getIntegerDetails(key, 4, new ImmutableContext())); + assertEquals(id, c.getIntegerDetails(key, 4, new ImmutableContext(), FlagEvaluationOptions.builder().build())); FlagEvaluationDetails dd = FlagEvaluationDetails.builder() .flagKey(key) .value(40.0) .build(); assertEquals(dd, c.getDoubleDetails(key, .4)); - assertEquals(dd, c.getDoubleDetails(key, .4, new MutableContext())); - assertEquals(dd, c.getDoubleDetails(key, .4, new MutableContext(), FlagEvaluationOptions.builder().build())); + assertEquals(dd, c.getDoubleDetails(key, .4, new ImmutableContext())); + assertEquals(dd, c.getDoubleDetails(key, .4, new ImmutableContext(), FlagEvaluationOptions.builder().build())); // TODO: Structure detail tests. } @@ -197,13 +215,16 @@ private Client _client() { @Specification(number="1.4.10", text="In the case of abnormal execution, the client SHOULD log an informative error message.") @Test void log_on_error() throws NotImplementedException { - TEST_LOGGER.clear(); OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setProvider(new AlwaysBrokenProvider()); Client c = api.getClient(); FlagEvaluationDetails result = c.getBooleanDetails("test", false); + assertEquals(Reason.ERROR.toString(), result.getReason()); - assertThat(TEST_LOGGER.getLoggingEvents()).contains(LoggingEvent.error("Unable to correctly evaluate flag with key {} due to exception {}", "test", TestConstants.BROKEN_MESSAGE)); + Mockito.verify(logger).error( + ArgumentMatchers.contains("Unable to correctly evaluate flag with key"), + any(), + ArgumentMatchers.isA(FlagNotFoundError.class)); } @Specification(number="1.2.2", text="The client interface MUST define a metadata member or accessor, containing an immutable name field or accessor of type string, which corresponds to the name value supplied during client creation.") @@ -233,22 +254,25 @@ private Client _client() { DoSomethingProvider provider = new DoSomethingProvider(); api.setProvider(provider); - MutableContext apiCtx = new MutableContext(); - apiCtx.add("common", "1"); - apiCtx.add("common2", "1"); - apiCtx.add("api", "2"); + Map attributes = new HashMap<>(); + attributes.put("common", new Value("1")); + attributes.put("common2", new Value("1")); + attributes.put("api", new Value("2")); + EvaluationContext apiCtx = new ImmutableContext(attributes); + api.setEvaluationContext(apiCtx); Client c = api.getClient(); - MutableContext clientCtx = new MutableContext(); - clientCtx.add("common", "3"); - clientCtx.add("common2", "3"); - clientCtx.add("client", "4"); + Map attributes1 = new HashMap<>(); + attributes.put("common", new Value("3")); + attributes.put("common2", new Value("3")); + attributes.put("client", new Value("4")); + attributes.put("common", new Value("5")); + attributes.put("invocation", new Value("6")); + EvaluationContext clientCtx = new ImmutableContext(attributes); c.setEvaluationContext(clientCtx); - MutableContext invocationCtx = new MutableContext(); - clientCtx.add("common", "5"); - clientCtx.add("invocation", "6"); + EvaluationContext invocationCtx = new ImmutableContext(); // dosomethingprovider inverts this value. assertTrue(c.getBooleanValue("key", false, invocationCtx)); diff --git a/src/test/java/dev/openfeature/sdk/HookContextTest.java b/src/test/java/dev/openfeature/sdk/HookContextTest.java index e27d0ab5..14a2ef2b 100644 --- a/src/test/java/dev/openfeature/sdk/HookContextTest.java +++ b/src/test/java/dev/openfeature/sdk/HookContextTest.java @@ -15,7 +15,7 @@ class HookContextTest { FlagValueType.BOOLEAN, meta, meta, - new MutableContext(), + new ImmutableContext(), false ); diff --git a/src/test/java/dev/openfeature/sdk/HookSpecTest.java b/src/test/java/dev/openfeature/sdk/HookSpecTest.java index d028cc6c..26a1b49e 100644 --- a/src/test/java/dev/openfeature/sdk/HookSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSpecTest.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.Optional; +import io.cucumber.java.hu.Ha; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -103,7 +104,7 @@ void emptyApiHooks() { HookContext.builder() .flagKey("key") .type(FlagValueType.INTEGER) - .ctx(new MutableContext()) + .ctx(new ImmutableContext()) .build(); fail("Missing default value shouldn't be valid"); } catch (NullPointerException e) { @@ -115,7 +116,7 @@ void emptyApiHooks() { HookContext.builder() .flagKey("key") .type(FlagValueType.INTEGER) - .ctx(new MutableContext()) + .ctx(new ImmutableContext()) .defaultValue(1) .build(); } catch (NullPointerException e) { @@ -130,7 +131,7 @@ void emptyApiHooks() { HookContext.builder() .flagKey("key") .type(FlagValueType.INTEGER) - .ctx(new MutableContext()) + .ctx(new ImmutableContext()) .defaultValue(1) .build(); @@ -138,7 +139,7 @@ void emptyApiHooks() { HookContext.builder() .flagKey("key") .type(FlagValueType.INTEGER) - .ctx(new MutableContext()) + .ctx(new ImmutableContext()) .providerMetadata(new NoOpProvider().getMetadata()) .defaultValue(1) .build(); @@ -147,7 +148,7 @@ void emptyApiHooks() { HookContext.builder() .flagKey("key") .type(FlagValueType.INTEGER) - .ctx(new MutableContext()) + .ctx(new ImmutableContext()) .defaultValue(1) .clientMetadata(OpenFeatureAPI.getInstance().getClient().getMetadata()) .build(); @@ -160,7 +161,7 @@ void emptyApiHooks() { Client client = api.getClient(); Hook evalHook = mockBooleanHook(); - client.getBooleanValue("key", false, new MutableContext(), + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder().hook(evalHook).build()); verify(evalHook, times(1)).before(any(), any()); @@ -366,7 +367,7 @@ public void finallyAfter(HookContext ctx, Map hints) { hh.put(hintKey, "My hint value"); hh = Collections.unmodifiableMap(hh); - client.getBooleanValue("key", false, new MutableContext(), FlagEvaluationOptions.builder() + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder() .hook(mutatingHook) .hookHints(hh) .build()); @@ -391,7 +392,7 @@ public void finallyAfter(HookContext ctx, Map hints) { OpenFeatureAPI api = OpenFeatureAPI.getInstance(); api.setProvider(provider); Client client = api.getClient(); - client.getBooleanValue("key", false, new MutableContext(), + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder().hook(hook).build()); order.verify(hook).before(any(), any()); @@ -406,7 +407,7 @@ public void finallyAfter(HookContext ctx, Map hints) { Hook hook = mockBooleanHook(); doThrow(RuntimeException.class).when(hook).before(any(), any()); Client client = getClient(null); - Boolean value = client.getBooleanValue("key", false, new MutableContext(), + Boolean value = client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder().hook(hook).build()); verify(hook, times(1)).before(any(), any()); verify(hook, times(1)).error(any(), any(), any()); @@ -418,7 +419,7 @@ public void finallyAfter(HookContext ctx, Map hints) { Hook hook = mockBooleanHook(); doThrow(RuntimeException.class).when(hook).after(any(), any(), any()); Client client = getClient(null); - client.getBooleanValue("key", false, new MutableContext(), + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder().hook(hook).build()); verify(hook, times(1)).after(any(), any(), any()); verify(hook, times(1)).error(any(), any(), any()); @@ -431,7 +432,7 @@ public void finallyAfter(HookContext ctx, Map hints) { Client client = getClient(null); - client.getBooleanValue("key", false, new MutableContext(), + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder() .hook(hook2) .hook(hook) @@ -447,7 +448,7 @@ public void finallyAfter(HookContext ctx, Map hints) { @Specification(number = "4.1.4", text = "The evaluation context MUST be mutable only within the before hook.") @Specification(number = "4.3.3", text = "Any evaluation context returned from a before hook MUST be passed to subsequent before hooks (via HookContext).") @Test void beforeContextUpdated() { - MutableContext ctx = new MutableContext(); + EvaluationContext ctx = new ImmutableContext(); Hook hook = mockBooleanHook(); when(hook.before(any(), any())).thenReturn(Optional.of(ctx)); Hook hook2 = mockBooleanHook(); @@ -472,13 +473,16 @@ public void finallyAfter(HookContext ctx, Map hints) { @Specification(number="4.3.4", text="When before hooks have finished executing, any resulting evaluation context MUST be merged with the existing evaluation context.") @Test void mergeHappensCorrectly() { - MutableContext hookCtx = new MutableContext(); - hookCtx.add("test", "works"); - hookCtx.add("another", "exists"); + Map attributes= new HashMap<>(); + attributes.put("test", new Value("works")); + attributes.put("another", new Value("exists")); + EvaluationContext hookCtx = new ImmutableContext(attributes); - MutableContext invocationCtx = new MutableContext(); - invocationCtx.add("something", "here"); - invocationCtx.add("test", "broken"); + + Map attributes1= new HashMap<>(); + attributes1.put("something", new Value("here")); + attributes1.put("test", new Value("broken")); + EvaluationContext invocationCtx = new ImmutableContext(attributes1); Hook hook = mockBooleanHook(); when(hook.before(any(), any())).thenReturn(Optional.of(hookCtx)); @@ -498,7 +502,7 @@ public void finallyAfter(HookContext ctx, Map hints) { ArgumentCaptor captor = ArgumentCaptor.forClass(MutableContext.class); verify(provider).getBooleanEvaluation(any(), any(), captor.capture()); - MutableContext ec = captor.getValue(); + EvaluationContext ec = captor.getValue(); assertEquals("works", ec.getValue("test").asString()); assertEquals("exists", ec.getValue("another").asString()); assertEquals("here", ec.getValue("something").asString()); @@ -513,7 +517,7 @@ public void finallyAfter(HookContext ctx, Map hints) { InOrder order = inOrder(hook, hook2); Client client = getClient(null); - client.getBooleanValue("key", false, new MutableContext(), + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder() .hook(hook2) .hook(hook) @@ -533,7 +537,7 @@ public void finallyAfter(HookContext ctx, Map hints) { InOrder order = inOrder(hook, hook2); Client client = getClient(null); - client.getBooleanValue("key", false, new MutableContext(), + client.getBooleanValue("key", false, new ImmutableContext(), FlagEvaluationOptions.builder() .hook(hook2) .hook(hook) diff --git a/src/test/java/dev/openfeature/sdk/HookSupportTest.java b/src/test/java/dev/openfeature/sdk/HookSupportTest.java index d787bb0c..d8883780 100644 --- a/src/test/java/dev/openfeature/sdk/HookSupportTest.java +++ b/src/test/java/dev/openfeature/sdk/HookSupportTest.java @@ -7,6 +7,8 @@ import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -21,8 +23,9 @@ class HookSupportTest implements HookFixtures { @Test @DisplayName("should merge EvaluationContexts on before hooks correctly") void shouldMergeEvaluationContextsOnBeforeHooksCorrectly() { - MutableContext baseContext = new MutableContext(); - baseContext.add("baseKey", "baseValue"); + Map attributes = new HashMap<>(); + attributes.put("baseKey", new Value("baseValue")); + EvaluationContext baseContext = new ImmutableContext(attributes); HookContext hookContext = new HookContext<>("flagKey", FlagValueType.STRING, "defaultValue", baseContext, () -> "client", () -> "provider"); Hook hook1 = mockStringHook(); Hook hook2 = mockStringHook(); @@ -43,7 +46,7 @@ void shouldMergeEvaluationContextsOnBeforeHooksCorrectly() { void shouldAlwaysCallGenericHook(FlagValueType flagValueType) { Hook genericHook = mockGenericHook(); HookSupport hookSupport = new HookSupport(); - MutableContext baseContext = new MutableContext(); + EvaluationContext baseContext = new ImmutableContext(); IllegalStateException expectedException = new IllegalStateException("All fine, just a test"); HookContext hookContext = new HookContext<>("flagKey", flagValueType, createDefaultValue(flagValueType), baseContext, () -> "client", () -> "provider"); @@ -75,10 +78,11 @@ private Object createDefaultValue(FlagValueType flagValueType) { } } - private MutableContext evaluationContextWithValue(String key, String value) { - MutableContext result = new MutableContext(); - result.add(key, value); - return result; + private EvaluationContext evaluationContextWithValue(String key, String value) { + Map attributes = new HashMap<>(); + attributes.put(key, new Value(value)); + EvaluationContext baseContext = new ImmutableContext(attributes); + return baseContext; } } diff --git a/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java b/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java new file mode 100644 index 00000000..437e4922 --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ImmutableContextTest.java @@ -0,0 +1,117 @@ +package dev.openfeature.sdk; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ImmutableContextTest { + + @Test + @DisplayName("Mutating targeting key is not allowed on Immutable Context") + void shouldThrowUnsupportedExceptionWhenMutatingTargetingKey() { + EvaluationContext ctx = new ImmutableContext("targeting key", new HashMap<>()); + assertThrows(UnsupportedOperationException.class, () -> ctx.setTargetingKey("")); + } + + @DisplayName("attributes mutation should not affect the immutable context") + @Test + void shouldCreateCopyOfAttributesForImmutableContext() { + HashMap attributes = new HashMap<>(); + attributes.put("key1", new Value("val1")); + attributes.put("key2", new Value("val2")); + EvaluationContext ctx = new ImmutableContext("targeting key", attributes); + attributes.put("key3", new Value("val3")); + assertArrayEquals(new Object[]{"key1", "key2"}, ctx.keySet().toArray()); + } + + @DisplayName("targeting key should be changed from the overriding context") + @Test + void shouldChangeTargetingKeyFromOverridingContext() { + HashMap attributes = new HashMap<>(); + attributes.put("key1", new Value("val1")); + attributes.put("key2", new Value("val2")); + EvaluationContext ctx = new ImmutableContext("targeting key", attributes); + EvaluationContext overriding = new ImmutableContext("overriding_key"); + EvaluationContext merge = ctx.merge(overriding); + assertEquals("overriding_key", merge.getTargetingKey()); + } + + @DisplayName("targeting key should not changed from the overriding context if missing") + @Test + void shouldRetainTargetingKeyWhenOverridingContextTargetingKeyValueIsEmpty() { + HashMap attributes = new HashMap<>(); + attributes.put("key1", new Value("val1")); + attributes.put("key2", new Value("val2")); + EvaluationContext ctx = new ImmutableContext("targeting_key", attributes); + EvaluationContext overriding = new ImmutableContext(""); + EvaluationContext merge = ctx.merge(overriding); + assertEquals("targeting_key", merge.getTargetingKey()); + } + + @DisplayName("Merge should retain all the attributes from the existing context when overriding context is null") + @Test + void mergeShouldReturnAllTheValuesFromTheContextWhenOverridingContextIsNull() { + HashMap attributes = new HashMap<>(); + attributes.put("key1", new Value("val1")); + attributes.put("key2", new Value("val2")); + EvaluationContext ctx = new ImmutableContext("targeting_key", attributes); + EvaluationContext merge = ctx.merge(null); + assertEquals("targeting_key", merge.getTargetingKey()); + assertArrayEquals(new Object[]{"key1", "key2"}, merge.keySet().toArray()); + } + + @DisplayName("Merge should retain subkeys from the existing context when the overriding context has the same targeting key") + @Test + void mergeShouldRetainItsSubkeysWhenOverridingContextHasTheSameKey() { + HashMap attributes = new HashMap<>(); + HashMap overridingAttributes = new HashMap<>(); + HashMap key1Attributes = new HashMap<>(); + HashMap ovKey1Attributes = new HashMap<>(); + + key1Attributes.put("key1_1", new Value("val1_1")); + attributes.put("key1", new Value(new ImmutableStructure(key1Attributes))); + attributes.put("key2", new Value("val2")); + ovKey1Attributes.put("overriding_key1_1", new Value("overriding_val_1_1")); + overridingAttributes.put("key1", new Value(new ImmutableStructure(ovKey1Attributes))); + + EvaluationContext ctx = new ImmutableContext("targeting_key", attributes); + EvaluationContext overriding = new ImmutableContext("targeting_key", overridingAttributes); + EvaluationContext merge = ctx.merge(overriding); + assertEquals("targeting_key", merge.getTargetingKey()); + assertArrayEquals(new Object[]{"key1", "key2"}, merge.keySet().toArray()); + + Value key1 = merge.getValue("key1"); + assertTrue(key1.isStructure()); + + Structure value = key1.asStructure(); + assertArrayEquals(new Object[]{"key1_1","overriding_key1_1"}, value.keySet().toArray()); + } + + @DisplayName("Merge should retain subkeys from the existing context when the overriding context doesn't have targeting key") + @Test + void mergeShouldRetainItsSubkeysWhenOverridingContextHasNoTargetingKey() { + HashMap attributes = new HashMap<>(); + HashMap key1Attributes = new HashMap<>(); + + key1Attributes.put("key1_1", new Value("val1_1")); + attributes.put("key1", new Value(new ImmutableStructure(key1Attributes))); + attributes.put("key2", new Value("val2")); + + EvaluationContext ctx = new ImmutableContext(attributes); + EvaluationContext overriding = new ImmutableContext(); + EvaluationContext merge = ctx.merge(overriding); + assertArrayEquals(new Object[]{"key1", "key2"}, merge.keySet().toArray()); + + Value key1 = merge.getValue("key1"); + assertTrue(key1.isStructure()); + + Structure value = key1.asStructure(); + assertArrayEquals(new Object[]{"key1_1"}, value.keySet().toArray()); + } +} diff --git a/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java b/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java new file mode 100644 index 00000000..ff5f7afe --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/ImmutableStructureTest.java @@ -0,0 +1,111 @@ +package dev.openfeature.sdk; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ImmutableStructureTest { + @Test void noArgShouldContainEmptyAttributes() { + ImmutableStructure structure = new ImmutableStructure(); + assertEquals(0, structure.asMap().keySet().size()); + } + + @Test void mapArgShouldContainNewMap() { + String KEY = "key"; + Map map = new HashMap() { + { + put(KEY, new Value(KEY)); + } + }; + ImmutableStructure structure = new ImmutableStructure(map); + assertEquals(KEY, structure.asMap().get(KEY).asString()); + assertNotSame(structure.asMap(), map); // should be a copy + } + + @Test void MutatingGetValueShouldNotChangeOriginalValue() { + String KEY = "key"; + List lists = new ArrayList<>(); + lists.add(new Value(KEY)); + Map map = new HashMap() { + { + put(KEY, new Value(lists)); + } + }; + ImmutableStructure structure = new ImmutableStructure(map); + List values = structure.getValue(KEY).asList(); + values.add(new Value("dummyValue")); + lists.add(new Value("dummy")); + assertEquals(1, structure.getValue(KEY).asList().size()); + assertNotSame(structure.asMap(), map); // should be a copy + } + + @Test void MutatingGetInstantValueShouldNotChangeOriginalValue() { + String KEY = "key"; + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + Map map = new HashMap() { + { + put(KEY, new Value(now)); + } + }; + ImmutableStructure structure = new ImmutableStructure(map); + //mutate the original value + Instant tomorrow = now.plus(1, ChronoUnit.DAYS); + //mutate the getValue + structure.getValue(KEY).asInstant().plus(1, ChronoUnit.DAYS); + + assertNotEquals(tomorrow, structure.getValue(KEY).asInstant()); + assertEquals(now, structure.getValue(KEY).asInstant()); + } + + @Test void MutatingGetStructureValueShouldNotChangeOriginalValue() { + String KEY = "key"; + List lists = new ArrayList<>(); + lists.add(new Value("dummy_list_1")); + MutableStructure mutableStructure = new MutableStructure().add("key1","val1").add("list", lists); + Map map = new HashMap() { + { + put(KEY, new Value(mutableStructure)); + } + }; + ImmutableStructure structure = new ImmutableStructure(map); + //mutate the original structure + mutableStructure.add("key2", "val2"); + //mutate the return value + structure.getValue(KEY).asStructure().asMap().put("key3", new Value("val3")); + assertEquals(2, structure.getValue(KEY).asStructure().asMap().size()); + assertArrayEquals(new Object[]{"key1", "list"}, structure.getValue(KEY).asStructure().keySet().toArray()); + assertTrue(structure.getValue(KEY).asStructure() instanceof ImmutableStructure); + //mutate list value + lists.add(new Value("dummy_list_2")); + //mutate the return list value + structure.getValue(KEY).asStructure().asMap().get("list").asList().add(new Value("dummy_list_3")); + assertEquals(1, structure.getValue(KEY).asStructure().asMap().get("list").asList().size()); + assertEquals("dummy_list_1", structure.getValue(KEY).asStructure().asMap().get("list").asList().get(0).asString()); + } + + @Test + void ModifyingTheValuesReturnByTheKeySetMethodShouldNotModifyTheUnderlyingImmutableStructure() { + Map map = new HashMap() { + { + put("key", new Value(10)); + put("key1", new Value(20)); + } + }; + ImmutableStructure structure = new ImmutableStructure(map); + Set keys = structure.keySet(); + keys.remove("key1"); + assertEquals(2, structure.keySet().size()); + } +} diff --git a/src/test/java/dev/openfeature/sdk/LockingTest.java b/src/test/java/dev/openfeature/sdk/LockingTest.java index 1a8dac9c..f8dceee6 100644 --- a/src/test/java/dev/openfeature/sdk/LockingTest.java +++ b/src/test/java/dev/openfeature/sdk/LockingTest.java @@ -71,11 +71,11 @@ void getHooksShouldReadLockAndUnlock() { @Test void setContextShouldWriteLockAndUnlock() { - client.setEvaluationContext(new MutableContext()); + client.setEvaluationContext(new ImmutableContext()); verify(clientContextLock.writeLock()).lock(); verify(clientContextLock.writeLock()).unlock(); - api.setEvaluationContext(new MutableContext()); + api.setEvaluationContext(new ImmutableContext()); verify(apiContextLock.writeLock()).lock(); verify(apiContextLock.writeLock()).unlock(); } diff --git a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java index f4c6f100..ac150677 100644 --- a/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java +++ b/src/test/java/dev/openfeature/sdk/OpenFeatureClientTest.java @@ -4,20 +4,28 @@ import dev.openfeature.sdk.fixtures.HookFixtures; import org.junit.jupiter.api.*; -import uk.org.lidalia.slf4jext.Level; -import uk.org.lidalia.slf4jtest.*; +import org.mockito.Mockito; +import org.simplify4u.slf4jmock.LoggerMock; +import org.slf4j.Logger; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class OpenFeatureClientTest implements HookFixtures { - private static final TestLogger TEST_LOGGER = TestLoggerFactory.getTestLogger(OpenFeatureClient.class); + private Logger logger; + @BeforeEach void set_logger() { + logger = Mockito.mock(Logger.class); + LoggerMock.setMock(OpenFeatureClient.class, logger); + } + + @AfterEach void reset_logs() { + LoggerMock.setMock(OpenFeatureClient.class, logger); + } @Test @DisplayName("should not throw exception if hook has different type argument than hookContext") void shouldNotThrowExceptionIfHookHasDifferentTypeArgumentThanHookContext() { - TEST_LOGGER.clear(); OpenFeatureAPI api = mock(OpenFeatureAPI.class); when(api.getProvider()).thenReturn(new DoSomethingProvider()); when(api.getHooks()).thenReturn(Arrays.asList(mockBooleanHook(), mockStringHook())); @@ -27,18 +35,20 @@ void shouldNotThrowExceptionIfHookHasDifferentTypeArgumentThanHookContext() { FlagEvaluationDetails actual = client.getBooleanDetails("feature key", Boolean.FALSE); assertThat(actual.getValue()).isTrue(); - assertThat(TEST_LOGGER.getLoggingEvents()).filteredOn(event -> event.getLevel().equals(Level.ERROR)).isEmpty(); + // I dislike this, but given the mocking tools available, there's no way that I know of to say "no errors were logged" + Mockito.verify(logger, never()).error(any()); + Mockito.verify(logger, never()).error(anyString(), any(Throwable.class)); + Mockito.verify(logger, never()).error(anyString(), any(Object.class)); + Mockito.verify(logger, never()).error(anyString(), any(), any()); + Mockito.verify(logger, never()).error(anyString(), any(), any()); } @Test void mergeContextTest() { - TEST_LOGGER.clear(); - String flag = "feature key"; boolean defaultValue = false; String targetingKey = "targeting key"; - EvaluationContext ctx = new MutableContext(targetingKey); - + EvaluationContext ctx = new ImmutableContext(targetingKey, new HashMap<>()); OpenFeatureAPI api = mock(OpenFeatureAPI.class); FeatureProvider mockProvider = mock(FeatureProvider.class); // this makes it so that true is returned only if the targeting key set at the client level is honored diff --git a/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java b/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java index 23d8bc2b..31a6a5e8 100644 --- a/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java +++ b/src/test/java/dev/openfeature/sdk/ProviderSpecTest.java @@ -1,4 +1,5 @@ package dev.openfeature.sdk; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -8,76 +9,77 @@ public class ProviderSpecTest { NoOpProvider p = new NoOpProvider(); - @Specification(number="2.1.1", text="The provider interface MUST define a metadata member or accessor, containing a name field or accessor of type string, which identifies the provider implementation.") - @Test void name_accessor() { + @Specification(number = "2.1.1", text = "The provider interface MUST define a metadata member or accessor, containing a name field or accessor of type string, which identifies the provider implementation.") + @Test + void name_accessor() { assertNotNull(p.getName()); } - @Specification(number="2.2.2.1", text="The feature provider interface MUST define methods for typed " + + @Specification(number = "2.2.2.1", text = "The feature provider interface MUST define methods for typed " + "flag resolution, including boolean, numeric, string, and structure.") - @Specification(number="2.2.3", text="In cases of normal execution, the provider MUST populate the " + - "flag resolution structure's value field with the resolved flag value.") - @Specification(number="2.2.1", text="The feature provider interface MUST define methods to resolve " + - "flag values, with parameters flag key (string, required), default value " + - "(boolean | number | string | structure, required) and evaluation context (optional), " + - "which returns a flag resolution structure.") - @Specification(number="2.2.8.1", text="The flag resolution structure SHOULD accept a generic " + - "argument (or use an equivalent language feature) which indicates the type of the wrapped value field.") - @Test void flag_value_set() { - ProviderEvaluation int_result = p.getIntegerEvaluation("key", 4, new MutableContext()); + @Specification(number = "2.2.3", text = "In cases of normal execution, the `provider` MUST populate the `resolution details` structure's `value` field with the resolved flag value.") + @Specification(number = "2.2.1", text = "The `feature provider` interface MUST define methods to resolve flag values, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required) + and `evaluation context` (optional), which returns a `resolution details` structure.") + @Specification(number = "2.2.8.1", text = "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.") + @Test + void flag_value_set() { + ProviderEvaluation int_result = p.getIntegerEvaluation("key", 4, new ImmutableContext()); assertNotNull(int_result.getValue()); - ProviderEvaluation double_result = p.getDoubleEvaluation("key", 0.4, new MutableContext()); + ProviderEvaluation double_result = p.getDoubleEvaluation("key", 0.4, new ImmutableContext()); assertNotNull(double_result.getValue()); - ProviderEvaluation string_result = p.getStringEvaluation("key", "works", new MutableContext()); + ProviderEvaluation string_result = p.getStringEvaluation("key", "works", new ImmutableContext()); assertNotNull(string_result.getValue()); - ProviderEvaluation boolean_result = p.getBooleanEvaluation("key", false, new MutableContext()); + ProviderEvaluation boolean_result = p.getBooleanEvaluation("key", false, new ImmutableContext()); assertNotNull(boolean_result.getValue()); - ProviderEvaluation object_result = p.getObjectEvaluation("key", new Value(), new MutableContext()); + ProviderEvaluation object_result = p.getObjectEvaluation("key", new Value(), new ImmutableContext()); assertNotNull(object_result.getValue()); } - @Specification(number="2.2.5", text="The `provider` SHOULD populate the `flag resolution` structure's `reason` field with `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.") - @Test void has_reason() { - ProviderEvaluation result = p.getBooleanEvaluation("key", false, new MutableContext()); + @Specification(number = "2.2.5", text = "The `provider` SHOULD populate the `resolution details` structure's `reason` field with `\"STATIC\"`, `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"CACHED\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.") + @Test + void has_reason() { + ProviderEvaluation result = p.getBooleanEvaluation("key", false, new ImmutableContext()); assertEquals(Reason.DEFAULT.toString(), result.getReason()); } - @Specification(number="2.2.6", text="In cases of normal execution, the provider MUST NOT populate " + - "the flag resolution structure's error code field, or otherwise must populate it with a null or falsy value.") - @Test void no_error_code_by_default() { - ProviderEvaluation result = p.getBooleanEvaluation("key", false, new MutableContext()); + @Specification(number = "2.2.6", text = "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error code` field, or otherwise must populate it with a null or falsy value.") + @Test + void no_error_code_by_default() { + ProviderEvaluation result = p.getBooleanEvaluation("key", false, new ImmutableContext()); assertNull(result.getErrorCode()); } - @Specification(number="2.2.7", text="In cases of abnormal execution, the `provider` **MUST** indicate an error using the idioms of the implementation language, with an associated `error code` and optional associated `error message`.") - @Specification(number="2.3.2", text="In cases of normal execution, the `provider` **MUST NOT** populate the `flag resolution` structure's `error message` field, or otherwise must populate it with a null or falsy value.") - @Specification(number="2.3.3", text="In cases of abnormal execution, the `evaluation details` structure's `error message` field **MAY** contain a string containing additional detail about the nature of the error.") - @Test void up_to_provider_implementation() {} + @Specification(number = "2.2.7", text = "In cases of abnormal execution, the `provider` **MUST** indicate an error using the idioms of the implementation language, with an associated `error code` and optional associated `error message`.") + @Specification(number = "2.3.2", text = "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error message` field, or otherwise must populate it with a null or falsy value.") + @Specification(number = "2.3.3", text = "In cases of abnormal execution, the `resolution details` structure's `error message` field MAY contain a string containing additional detail about the nature of the error.") + @Test + void up_to_provider_implementation() { + } - @Specification(number="2.2.4", text="In cases of normal execution, the provider SHOULD populate the " + - "flag resolution structure's variant field with a string identifier corresponding to the returned flag value.") - @Test void variant_set() { - ProviderEvaluation int_result = p.getIntegerEvaluation("key", 4, new MutableContext()); + @Specification(number = "2.2.4", text = "In cases of normal execution, the `provider` SHOULD populate the `resolution details` structure's `variant` field with a string identifier corresponding to the returned flag value.") + @Test + void variant_set() { + ProviderEvaluation int_result = p.getIntegerEvaluation("key", 4, new ImmutableContext()); assertNotNull(int_result.getReason()); - ProviderEvaluation double_result = p.getDoubleEvaluation("key", 0.4, new MutableContext()); + ProviderEvaluation double_result = p.getDoubleEvaluation("key", 0.4, new ImmutableContext()); assertNotNull(double_result.getReason()); - ProviderEvaluation string_result = p.getStringEvaluation("key", "works", new MutableContext()); + ProviderEvaluation string_result = p.getStringEvaluation("key", "works", new ImmutableContext()); assertNotNull(string_result.getReason()); - ProviderEvaluation boolean_result = p.getBooleanEvaluation("key", false, new MutableContext()); + ProviderEvaluation boolean_result = p.getBooleanEvaluation("key", false, new ImmutableContext()); assertNotNull(boolean_result.getReason()); } - @Specification(number="2.3.1", text="The provider interface MUST define a provider hook mechanism which can be optionally implemented in order to add hook instances to the evaluation life-cycle.") - @Specification(number="4.4.1", text="The API, Client, Provider, and invocation MUST have a method for registering hooks.") - @Test void provider_hooks() { + @Specification(number = "2.3.1", text = "The provider interface MUST define a provider hook mechanism which can be optionally implemented in order to add hook instances to the evaluation life-cycle.") + @Specification(number = "4.4.1", text = "The API, Client, Provider, and invocation MUST have a method for registering hooks.") + @Test + void provider_hooks() { assertEquals(0, p.getProviderHooks().size()); } } diff --git a/src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java b/src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java index 1fe886dd..41c4dafc 100644 --- a/src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java +++ b/src/test/java/dev/openfeature/sdk/integration/StepDefinitions.java @@ -1,12 +1,10 @@ package dev.openfeature.sdk.integration; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - import dev.openfeature.contrib.providers.flagd.FlagdProvider; import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.Reason; import dev.openfeature.sdk.Structure; @@ -16,6 +14,12 @@ import io.cucumber.java.en.Then; import io.cucumber.java.en.When; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + public class StepDefinitions { private static Client client; @@ -33,7 +37,7 @@ public class StepDefinitions { private String contextAwareFlagKey; private String contextAwareDefaultValue; - private MutableContext context; + private EvaluationContext context; private String contextAwareValue; private String notFoundFlagKey; @@ -211,11 +215,12 @@ public void the_variant_should_be_and_the_reason_should_be(String expectedVarian @When("context contains keys {string}, {string}, {string}, {string} with values {string}, {string}, {int}, {string}") public void context_contains_keys_with_values(String field1, String field2, String field3, String field4, String value1, String value2, Integer value3, String value4) { - this.context = new MutableContext() - .add(field1, value1) - .add(field2, value2) - .add(field3, value3) - .add(field4, Boolean.valueOf(value4)); + Map attributes = new HashMap<>(); + attributes.put(field1, new Value(value1)); + attributes.put(field2, new Value(value2)); + attributes.put(field3, new Value(value3)); + attributes.put(field4, new Value(Boolean.valueOf(value4))); + this.context = new ImmutableContext(attributes); } @When("a flag with key {string} is evaluated with default value {string}") @@ -234,7 +239,7 @@ public void the_resolved_string_response_should_be(String expected) { @Then("the resolved flag value is {string} when the context is empty") public void the_resolved_flag_value_is_when_the_context_is_empty(String expected) { String emptyContextValue = client.getStringValue(contextAwareFlagKey, contextAwareDefaultValue, - new MutableContext()); + new ImmutableContext()); assertEquals(expected, emptyContextValue); } diff --git a/version.txt b/version.txt index 9084fa2f..26aaba0e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1.0 +1.2.0