diff --git a/.gitmodules b/.gitmodules index 0b4e01ffc8..cace6b712c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,7 +4,7 @@ [submodule "providers/flagd/test-harness"] path = providers/flagd/test-harness url = https://github.com/open-feature/test-harness.git - branch = v3.10.3 + branch = v3.10.4 [submodule "providers/flagd/spec"] path = providers/flagd/spec url = https://github.com/open-feature/spec.git diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResources.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResources.java index 976870a979..84d4745ece 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResources.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResources.java @@ -18,7 +18,6 @@ class FlagdProviderSyncResources { @Setter private volatile ProviderEvent previousEvent; - @Setter private volatile boolean isFatal; private volatile ProviderEventDetails fatalProviderEventDetails; diff --git a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java index 9875cbe9cf..71f83bfb12 100644 --- a/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java +++ b/providers/flagd/src/main/java/dev/openfeature/contrib/providers/flagd/resolver/process/storage/StorageStateChange.java @@ -26,8 +26,10 @@ public class StorageStateChange { */ public StorageStateChange(StorageState storageState, List changedFlagsKeys, Structure syncMetadata) { this.storageState = storageState; - this.changedFlagsKeys = Collections.unmodifiableList(changedFlagsKeys); - this.syncMetadata = new ImmutableStructure(syncMetadata.asMap()); + this.changedFlagsKeys = + changedFlagsKeys != null ? Collections.unmodifiableList(changedFlagsKeys) : Collections.emptyList(); + this.syncMetadata = + syncMetadata != null ? new ImmutableStructure(syncMetadata.asMap()) : new ImmutableStructure(); } /** @@ -37,9 +39,7 @@ public StorageStateChange(StorageState storageState, List changedFlagsKe * @param changedFlagsKeys flags changed */ public StorageStateChange(StorageState storageState, List changedFlagsKeys) { - this.storageState = storageState; - this.changedFlagsKeys = Collections.unmodifiableList(changedFlagsKeys); - this.syncMetadata = new ImmutableStructure(); + this(storageState, changedFlagsKeys, null); } /** diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResourcesCTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResourcesCTest.java index f8c4d0977e..c129f3af5b 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResourcesCTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/FlagdProviderSyncResourcesCTest.java @@ -163,7 +163,7 @@ void callingFatalError_wakesUpWaitingThreadWithException() { }, () -> { startTime.set(System.currentTimeMillis()); - flagdProviderSyncResources.setFatal(true); + flagdProviderSyncResources.fatalError(null); }); Assertions.assertTrue( @@ -193,7 +193,6 @@ void concurrentInitializeAndShutdownShutsDownWork() { while (interleavings.hasNext()) { Runner.runParallel( () -> flagdProviderSyncResources.initialize(), () -> flagdProviderSyncResources.shutdown()); - Assertions.assertFalse(flagdProviderSyncResources.isInitialized()); Assertions.assertTrue(flagdProviderSyncResources.isShutDown()); } } @@ -208,8 +207,7 @@ void concurrentInitializeAndShutdownAndSetFatalShutsDownWork() { Runner.runParallel( () -> flagdProviderSyncResources.initialize(), () -> flagdProviderSyncResources.shutdown(), - () -> flagdProviderSyncResources.setFatal(true)); - Assertions.assertFalse(flagdProviderSyncResources.isInitialized()); + () -> flagdProviderSyncResources.fatalError(null)); Assertions.assertTrue(flagdProviderSyncResources.isShutDown()); Assertions.assertTrue(flagdProviderSyncResources.isFatal()); } @@ -222,7 +220,8 @@ void concurrentInitializeAndSetFatalShutsDownWork() { try (var interleavings = new AllInterleavings("concurrent initialize() and fatal() calls work")) { while (interleavings.hasNext()) { Runner.runParallel( - () -> flagdProviderSyncResources.initialize(), () -> flagdProviderSyncResources.setFatal(true)); + () -> flagdProviderSyncResources.initialize(), + () -> flagdProviderSyncResources.fatalError(null)); Assertions.assertFalse(flagdProviderSyncResources.isShutDown()); Assertions.assertTrue(flagdProviderSyncResources.isFatal()); } @@ -254,7 +253,7 @@ void waitForInitializationAfterCallingShutdown_returnsInstantly() { @Timeout(2) @Test void waitForInitializationAfterCallingFatal_returnsInstantly() { - flagdProviderSyncResources.setFatal(true); + flagdProviderSyncResources.fatalError(null); long start = System.currentTimeMillis(); Assertions.assertThrows(FatalError.class, () -> flagdProviderSyncResources.waitForInitialization(10000)); long end = System.currentTimeMillis(); @@ -265,7 +264,7 @@ void waitForInitializationAfterCallingFatal_returnsInstantly() { @Timeout(2) @Test void initializeAfterFatalReturnsFalse() { - flagdProviderSyncResources.setFatal(true); + flagdProviderSyncResources.fatalError(null); Assertions.assertFalse(flagdProviderSyncResources.initialize()); } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java index 5901fe08e3..21dea4589e 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunFileTest.java @@ -36,7 +36,7 @@ "events", "contextEnrichment", "fractional-v1", - "fractional-v3", + "fractional-v2", "deprecated" }) @Testcontainers diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java index 797756211f..bf31c3bc03 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java @@ -28,7 +28,7 @@ @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") @IncludeTags("in-process") -@ExcludeTags({"unixsocket", "fractional-v1", "fractional-v3", "deprecated"}) +@ExcludeTags({"unixsocket", "fractional-v1", "fractional-v2", "deprecated"}) @Testcontainers public class RunInProcessTest { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java index 05850c6e5a..527f145f53 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunRpcTest.java @@ -28,7 +28,7 @@ @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") @IncludeTags({"rpc"}) -@ExcludeTags({"unixsocket", "fractional-v1", "fractional-v3", "deprecated"}) +@ExcludeTags({"unixsocket", "fractional-v1", "fractional-v2", "deprecated"}) @Testcontainers public class RunRpcTest { diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java index caa66ee5c5..b92c994b7e 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/ContextSteps.java @@ -4,8 +4,11 @@ import dev.openfeature.sdk.ImmutableStructure; import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.Value; +import io.cucumber.datatable.DataTable; import io.cucumber.java.en.Given; +import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; public class ContextSteps extends AbstractSteps { @@ -14,32 +17,24 @@ public ContextSteps(State state) { super(state); } - @Given("a context containing a key {string}, with type {string} and with value {string}") + @Given("^a context containing a key \"([^\"]*)\", with type \"([^\"]*)\" and with value \"(.*)\"$") public void a_context_containing_a_key_with_type_and_with_value(String key, String type, String value) - throws ClassNotFoundException, InstantiationException { - Map map = state.context.asMap(); - Value typedValue; - switch (type) { - case "Integer": - long longVal = Long.parseLong(value); - if (longVal >= Integer.MIN_VALUE && longVal <= Integer.MAX_VALUE) { - typedValue = new Value((int) longVal); - } else { - // value exceeds int range; store as string to preserve precision - typedValue = new Value(value); - } - break; - case "Float": - typedValue = new Value(Double.parseDouble(value)); - break; - case "Boolean": - typedValue = new Value(Boolean.parseBoolean(value)); - break; - default: - typedValue = new Value(value); - break; + throws ClassNotFoundException, IOException { + Map map = new HashMap<>(state.context.asMap()); + map.put(key, Value.objectToValue(Utils.convert(value, type))); + state.context = new MutableContext(state.context.getTargetingKey(), map); + } + + @Given("a context with the following keys:") + public void a_context_with_the_following_keys(DataTable dataTable) throws ClassNotFoundException, IOException { + List> rows = dataTable.asMaps(String.class, String.class); + Map map = new HashMap<>(state.context.asMap()); + for (Map row : rows) { + String key = row.get("key"); + String type = row.get("type"); + String value = row.get("value"); + map.put(key, Value.objectToValue(Utils.convert(value, type))); } - map.put(key, typedValue); state.context = new MutableContext(state.context.getTargetingKey(), map); } diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java index 626105ce40..e53a83d290 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/Utils.java @@ -15,14 +15,19 @@ public final class Utils { private Utils() {} public static Object convert(String value, String type) throws ClassNotFoundException, IOException { - if (Objects.equals(value, "null")) return null; + if ("Null".equals(type)) return null; + if (Objects.equals(value, "null") && !"String".equals(type)) return null; switch (type) { case "Boolean": return Boolean.parseBoolean(value); case "String": return value; case "Integer": - return Integer.parseInt(value); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return Long.parseLong(value); + } case "Float": return Double.parseDouble(value); case "Long": diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java index db5ee14f17..7418fe1b03 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/steps/config/ConfigSteps.java @@ -66,7 +66,7 @@ public void we_have_an_option_of_type_with_value(String option, String type, Str return; } - Object converted = Utils.convert(value, type); + Object converted = ("null".equals(value) && "String".equals(type)) ? null : Utils.convert(value, type); Method method = Arrays.stream(state.builder.getClass().getMethods()) .filter(method1 -> method1.getName().equals(mapOptionNames(option))) .findFirst() @@ -87,7 +87,7 @@ public void we_have_an_environment_variable_with_value(String varName, String va @Then("the option {string} of type {string} should have the value {string}") public void the_option_of_type_should_have_the_value(String option, String type, String value) throws Throwable { - Object convert = Utils.convert(value, type); + Object convert = ("null".equals(value) && "String".equals(type)) ? null : Utils.convert(value, type); if (IGNORED_FOR_NOW.contains(option)) { log.error("option '{}' is not supported", option); diff --git a/providers/flagd/test-harness b/providers/flagd/test-harness index a1c2809649..2f8f72176b 160000 --- a/providers/flagd/test-harness +++ b/providers/flagd/test-harness @@ -1 +1 @@ -Subproject commit a1c280964979db3f93b6529b7b806ddcbe5c27ea +Subproject commit 2f8f72176b99d85a31b23cd331a775a7c3015423 diff --git a/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/ContextSteps.java b/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/ContextSteps.java index 245c0d1746..b875552047 100644 --- a/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/ContextSteps.java +++ b/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/ContextSteps.java @@ -4,9 +4,11 @@ import dev.openfeature.sdk.MutableContext; import dev.openfeature.sdk.Value; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.cucumber.datatable.DataTable; import io.cucumber.java.en.Given; import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -25,13 +27,27 @@ public ContextSteps(EvaluatorState state) { } /** Adds a typed key/value pair to the evaluation context. */ - @Given("a context containing a key {string}, with type {string} and with value {string}") + @Given("^a context containing a key \"([^\"]*)\", with type \"([^\"]*)\" and with value \"(.*)\"$") public void contextKeyWithTypeAndValue(String key, String type, String value) throws IOException { Map map = new HashMap<>(state.context.asMap()); map.put(key, Value.objectToValue(EvaluatorUtils.convert(value, type))); state.context = new MutableContext(state.context.getTargetingKey(), map); } + /** Adds multiple context keys from a data table. */ + @Given("a context with the following keys:") + public void contextWithFollowingKeys(DataTable dataTable) throws IOException { + List> rows = dataTable.asMaps(String.class, String.class); + Map map = new HashMap<>(state.context.asMap()); + for (Map row : rows) { + String key = row.get("key"); + String type = row.get("type"); + String value = row.get("value"); + map.put(key, Value.objectToValue(EvaluatorUtils.convert(value, type))); + } + state.context = new MutableContext(state.context.getTargetingKey(), map); + } + /** Sets the targeting key on the evaluation context. */ @Given("a context containing a targeting key with value {string}") public void contextTargetingKey(String targetingKey) { diff --git a/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluationSteps.java b/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluationSteps.java index c69ba55023..b3905d8e69 100644 --- a/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluationSteps.java +++ b/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluationSteps.java @@ -76,6 +76,7 @@ public void flagEvaluatedWithDetails() { state.evaluation = dev.openfeature.sdk.ProviderEvaluation.builder() .errorCode(ErrorCode.TYPE_MISMATCH) .errorMessage(e.getMessage()) + .reason("ERROR") .build(); } catch (dev.openfeature.sdk.exceptions.OpenFeatureError e) { // Mirror the OpenFeature SDK client behaviour: on any provider error, return the @@ -84,6 +85,7 @@ public void flagEvaluatedWithDetails() { .value(state.defaultValue) .errorCode(e.getErrorCode()) .errorMessage(e.getMessage()) + .reason("ERROR") .build(); } } @@ -94,7 +96,11 @@ public void resolvedValueEquals(String value) throws IOException { if (state.evaluation.getErrorCode() != null) { log.warning("Evaluation error: " + state.evaluation.getErrorMessage()); } - assertThat(state.evaluation.getValue()).isEqualTo(EvaluatorUtils.convert(value, state.flagType)); + Object actualValue = state.evaluation.getValue(); + if (actualValue == null && state.evaluation.getErrorCode() != null) { + actualValue = state.defaultValue; + } + assertThat(actualValue).isEqualTo(EvaluatorUtils.convert(value, state.flagType)); } /** Asserts the evaluation reason matches the expected value. */ diff --git a/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluatorUtils.java b/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluatorUtils.java index 646115d198..b8ecbbfba9 100644 --- a/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluatorUtils.java +++ b/tools/flagd-api-testkit/src/main/java/dev/openfeature/contrib/tools/flagd/api/testkit/EvaluatorUtils.java @@ -22,7 +22,10 @@ private EvaluatorUtils() {} * @return the converted value, or {@code null} if {@code value} is "null" or empty for Object */ public static Object convert(String value, String type) throws IOException { - if (value == null || value.equals("null")) { + if ("Null".equals(type)) { + return null; + } + if (value == null || (value.equals("null") && !"String".equals(type))) { return null; } switch (type) { diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/cbor/CborEncoder.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/cbor/CborEncoder.java new file mode 100644 index 0000000000..2309b2a89d --- /dev/null +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/cbor/CborEncoder.java @@ -0,0 +1,248 @@ +package dev.openfeature.contrib.tools.flagd.core.cbor; + +import com.fasterxml.jackson.databind.JsonNode; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Minimal deterministic (RFC 8949 core) CBOR encoder. + * + *

Covers only the value types a flagd fractional bucketing key can hold: null, boolean, text + * string, integer, double, array and map. Produces a reproducible byte stream adherent to RFC 8949 for our purposes + */ +public final class CborEncoder { + + private CborEncoder() {} + + // major types occupy the top 3 bits of the initial byte (value << MAJOR_SHIFT) + private static final int MAJOR_UNSIGNED_INT = 0; + private static final int MAJOR_NEGATIVE_INT = 1; + private static final int MAJOR_TEXT_STRING = 3; + private static final int MAJOR_ARRAY = 4; + private static final int MAJOR_MAP = 5; + private static final int MAJOR_SHIFT = 5; + + // initial-byte low 5 bits: 0-23 inline; 24-27 mean 1/2/4/8 more bytes follow + private static final int ARG_INLINE_MAX = 24; + private static final int ARG_ONE_BYTE = 24; + private static final int ARG_TWO_BYTES = 25; + private static final int ARG_FOUR_BYTES = 26; + private static final int ARG_EIGHT_BYTES = 27; + + // thresholds selecting the shortest argument encoding + private static final long UINT8_LIMIT = 0x100L; + private static final long UINT16_LIMIT = 0x10000L; + private static final long UINT32_LIMIT = 0x100000000L; + + // fully-formed initial bytes for major type 7 (simple values + floats) + private static final int SIMPLE_FALSE = 0xf4; + private static final int SIMPLE_TRUE = 0xf5; + private static final int SIMPLE_NULL = 0xf6; + private static final int FLOAT16 = 0xf9; + private static final int FLOAT32 = 0xfa; + private static final int FLOAT64 = 0xfb; + + // float32 fields: sign | 8-bit exponent | 23-bit mantissa + private static final int FLOAT32_EXPONENT_BIAS = 127; + private static final int FLOAT32_EXPONENT_MASK = 0xff; // all-ones exponent = inf/nan + private static final int FLOAT32_MANTISSA_MASK = 0x7fffff; // low 23 bits + private static final int FLOAT32_MANTISSA_BITS = 23; + private static final int FLOAT32_IMPLICIT_ONE = 0x800000; // hidden leading 1 (bit 23) + + // half (float16) fields: sign | 5-bit exponent | 10-bit mantissa + private static final int HALF_SIGN_BIT = 0x8000; + private static final int FLOAT32_TO_HALF_SIGN_SHIFT = 16; // float bit 31 -> half bit 15 + private static final int HALF_EXPONENT_BIAS = 15; + private static final int HALF_MANTISSA_BITS = 10; + private static final int HALF_MANTISSA_MAX = 0x3ff; // 10-bit mantissa (1023) + private static final int HALF_INFINITY = 0x7c00; // exp all-ones, mantissa 0 + private static final int HALF_NAN = 0x7e00; // canonical quiet nan + private static final int HALF_MANTISSA_DROP = FLOAT32_MANTISSA_BITS - HALF_MANTISSA_BITS; // 13 + private static final int HALF_MANTISSA_DROP_MASK = (1 << HALF_MANTISSA_DROP) - 1; // low 13 bits + // unbiased-exponent range representable by a normal half + private static final int HALF_NORMAL_MIN_EXP = -14; + private static final int HALF_NORMAL_MAX_EXP = 15; + private static final int HALF_SUBNORMAL_MIN_EXP = -24; + + private static final int BYTE_MASK = 0xff; + private static final int BITS_PER_BYTE = 8; + private static final int NOT_HALF_REPRESENTABLE = -1; + + // canonical map-key ordering: shorter UTF-8 keys first + private static final Comparator KEY_COMPARATOR = (left, right) -> { + byte[] leftBytes = left.getBytes(StandardCharsets.UTF_8); + byte[] rightBytes = right.getBytes(StandardCharsets.UTF_8); + if (leftBytes.length != rightBytes.length) { + return Integer.compare(leftBytes.length, rightBytes.length); + } + for (int i = 0; i < leftBytes.length; i++) { + // tiebreak by unsigned bytewise comparison + int comparison = Integer.compare(leftBytes[i] & BYTE_MASK, rightBytes[i] & BYTE_MASK); + if (comparison != 0) { + return comparison; + } + } + return 0; + }; + + /** + * Encodes a JSON node as deterministic CBOR. + * + * @param node the value to encode + * @return the CBOR byte stream + */ + public static byte[] encode(JsonNode node) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + encodeNode(node, output); + return output.toByteArray(); + } + + private static void encodeNode(JsonNode node, ByteArrayOutputStream output) { + if (node.isNull()) { + output.write(SIMPLE_NULL); + } else if (node.isBoolean()) { + output.write(node.asBoolean() ? SIMPLE_TRUE : SIMPLE_FALSE); + } else if (node.isTextual()) { + encodeString(node.asText(), output); + } else if (node.isNumber()) { + if (node.isBigInteger()) { + encodeBigInteger(node.bigIntegerValue(), output); + } else if (node.isIntegralNumber()) { + encodeLong(node.asLong(), output); + } else { + encodeDouble(node.asDouble(), output); + } + } else if (node.isArray()) { + writeHead(MAJOR_ARRAY, node.size(), output); + for (JsonNode item : node) { + encodeNode(item, output); + } + } else if (node.isObject()) { + List fieldNames = new ArrayList<>(); + node.fieldNames().forEachRemaining(fieldNames::add); + fieldNames.sort(KEY_COMPARATOR); + writeHead(MAJOR_MAP, fieldNames.size(), output); + for (String fieldName : fieldNames) { + encodeString(fieldName, output); + encodeNode(node.get(fieldName), output); + } + } else { + throw new IllegalArgumentException("Unsupported node type: " + node.getNodeType()); + } + } + + // writes a CBOR head: major type (top 3 bits) + argument (shortest length form); argument is unsigned 64-bit + private static void writeHead(int majorType, long argument, ByteArrayOutputStream output) { + int initialByte = majorType << MAJOR_SHIFT; + if (Long.compareUnsigned(argument, ARG_INLINE_MAX) < 0) { + output.write(initialByte | (int) argument); + } else if (Long.compareUnsigned(argument, UINT8_LIMIT) < 0) { + output.write(initialByte | ARG_ONE_BYTE); + output.write((int) argument); + } else if (Long.compareUnsigned(argument, UINT16_LIMIT) < 0) { + output.write(initialByte | ARG_TWO_BYTES); + writeBigEndian(argument, 2, output); + } else if (Long.compareUnsigned(argument, UINT32_LIMIT) < 0) { + output.write(initialByte | ARG_FOUR_BYTES); + writeBigEndian(argument, 4, output); + } else { + output.write(initialByte | ARG_EIGHT_BYTES); + writeBigEndian(argument, 8, output); + } + } + + private static void encodeLong(long value, ByteArrayOutputStream output) { + if (value >= 0) { + writeHead(MAJOR_UNSIGNED_INT, value, output); + } else { + writeHead(MAJOR_NEGATIVE_INT, -1L - value, output); // negative int argument = -1 - n + } + } + + // full CBOR integer range [-2^64, 2^64-1]; the low 64 bits are the unsigned argument + private static void encodeBigInteger(BigInteger value, ByteArrayOutputStream output) { + if (value.signum() >= 0 && value.bitLength() <= Long.SIZE) { + writeHead(MAJOR_UNSIGNED_INT, value.longValue(), output); + } else if (value.signum() < 0 && value.not().bitLength() <= Long.SIZE) { + writeHead(MAJOR_NEGATIVE_INT, value.not().longValue(), output); // not(n) == -1 - n + } else { + throw new IllegalArgumentException("Integer outside CBOR 64-bit range: " + value); + } + } + + private static void encodeString(String value, ByteArrayOutputStream output) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + writeHead(MAJOR_TEXT_STRING, bytes.length, output); + output.write(bytes, 0, bytes.length); + } + + // shortest-float: emit float16 if it round-trips exactly, else float32, else float64 + private static void encodeDouble(double value, ByteArrayOutputStream output) { + if (Double.isNaN(value)) { + // NaN != NaN, so it can't go through the round-trip check below; emit canonical half nan + output.write(FLOAT16); + writeBigEndian(HALF_NAN, 2, output); + return; + } + float floatValue = (float) value; + if ((double) floatValue == value) { + int halfBits = floatToHalfExact(floatValue); + if (halfBits != NOT_HALF_REPRESENTABLE) { + output.write(FLOAT16); + writeBigEndian(halfBits, 2, output); + } else { + output.write(FLOAT32); + writeBigEndian(Float.floatToRawIntBits(floatValue), 4, output); + } + } else { + output.write(FLOAT64); + writeBigEndian(Double.doubleToRawLongBits(value), 8, output); + } + } + + // returns 16-bit half bits if the value is EXACTLY representable as IEEE-754 half or NOT_HALF_REPRESENTABLE + private static int floatToHalfExact(float value) { + int floatBits = Float.floatToRawIntBits(value); + int signBit = (floatBits >>> FLOAT32_TO_HALF_SIGN_SHIFT) & HALF_SIGN_BIT; // bit 31 -> bit 15 + int exponent = (floatBits >>> FLOAT32_MANTISSA_BITS) & FLOAT32_EXPONENT_MASK; + int mantissa = floatBits & FLOAT32_MANTISSA_MASK; + if (exponent == 0) { + // +-0 ok; float subnormal too small for half + return mantissa == 0 ? signBit : NOT_HALF_REPRESENTABLE; + } + if (exponent == FLOAT32_EXPONENT_MASK) { + return signBit | HALF_INFINITY; // infinity (nan is handled before this is reached) + } + int powerOfTwo = exponent - FLOAT32_EXPONENT_BIAS; + if (powerOfTwo >= HALF_NORMAL_MIN_EXP && powerOfTwo <= HALF_NORMAL_MAX_EXP) { + // half normal + if ((mantissa & HALF_MANTISSA_DROP_MASK) != 0) { + return NOT_HALF_REPRESENTABLE; // low mantissa bits would be lost + } + return signBit + | ((powerOfTwo + HALF_EXPONENT_BIAS) << HALF_MANTISSA_BITS) + | (mantissa >> HALF_MANTISSA_DROP); + } + if (powerOfTwo >= HALF_SUBNORMAL_MIN_EXP && powerOfTwo < HALF_NORMAL_MIN_EXP) { + // half subnormal + int significand = mantissa | FLOAT32_IMPLICIT_ONE; // add implicit leading 1 + int rightShift = -(powerOfTwo + 1); // half = significand * 2^(powerOfTwo+1), exponent < 0 + if ((significand & ((1 << rightShift) - 1)) != 0) { + return NOT_HALF_REPRESENTABLE; + } + int halfBits = significand >> rightShift; + return (halfBits >= 1 && halfBits <= HALF_MANTISSA_MAX) ? (signBit | halfBits) : NOT_HALF_REPRESENTABLE; + } + return NOT_HALF_REPRESENTABLE; + } + + private static void writeBigEndian(long value, int numBytes, ByteArrayOutputStream output) { + for (int byteIndex = numBytes - 1; byteIndex >= 0; byteIndex--) { + output.write((int) ((value >> (BITS_PER_BYTE * byteIndex)) & BYTE_MASK)); + } + } +} diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index b0800ed75a..71dc4112f0 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -1,11 +1,19 @@ package dev.openfeature.contrib.tools.flagd.core.targeting; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.openfeature.contrib.tools.flagd.core.cbor.CborEncoder; import io.github.jamsesso.jsonlogic.JsonLogicException; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; -import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Map; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.digest.MurmurHash3; @@ -16,6 +24,11 @@ @Slf4j class Fractional implements PreEvaluatedArgumentsExpression { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final JsonNodeFactory NODE_FACTORY = JsonNodeFactory.instance; + // exact as doubles; Long.MAX_VALUE is not (it rounds up to 2^63) + private static final double TWO_POW_63 = 0x1p63; + private static final double TWO_POW_64 = 0x1p64; static final int MAX_WEIGHT = Integer.MAX_VALUE; @Override @@ -32,21 +45,27 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json final Operator.FlagProperties properties = new Operator.FlagProperties(data); - final String bucketBy; + final Object bucketBy; final List distributions; // json-logic pre-evaluation flattens a single-entry fractional // e.g. [["single",1]] becomes ["single", 1]; detect and re-wrap - if (isFlattened(arguments)) { + if (arguments.get(0) == null) { + log.debug("Invalid arguments for fractional targeting: first argument is null"); + return null; + } else if (isFlattened(arguments)) { if (properties.getTargetingKey() == null) { log.debug("Missing fallback targeting key"); return null; } - bucketBy = properties.getFlagKey() + properties.getTargetingKey(); + bucketBy = Arrays.asList(properties.getFlagKey(), properties.getTargetingKey()); distributions = List.of(arguments); - } else if (arguments.get(0) instanceof String) { - // first arg is a String, use for bucketing - bucketBy = (String) arguments.get(0); + } else if (arguments.get(0) instanceof String + || arguments.get(0) instanceof Boolean + || arguments.get(0) instanceof Number + || arguments.get(0) instanceof Map) { + // first arg is a primitive or Map, use for bucketing + bucketBy = arguments.get(0); distributions = arguments.subList(1, arguments.size()); } else { // fallback to targeting key if present @@ -54,22 +73,26 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json log.debug("Missing fallback targeting key"); return null; } - bucketBy = properties.getFlagKey() + properties.getTargetingKey(); + + bucketBy = Arrays.asList(properties.getFlagKey(), properties.getTargetingKey()); distributions = arguments; } final List propertyList = new ArrayList<>(); long totalWeight = 0; - try { - for (Object dist : distributions) { + for (Object dist : distributions) { + try { FractionProperty fractionProperty = new FractionProperty(dist, jsonPath); propertyList.add(fractionProperty); totalWeight += fractionProperty.getWeight(); + } catch (JsonLogicException e) { + if ("Property is not an array".equals(e.getMessage())) { + throw new JsonLogicEvaluationException( + "Error parsing fractional targeting rule: " + e.getMessage(), jsonPath); + } + return null; } - } catch (JsonLogicException e) { - log.debug("Error parsing fractional targeting rule", e); - return null; } if (totalWeight > MAX_WEIGHT) { @@ -87,16 +110,51 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json } private static Object distributeValue( - final String hashKey, + final Object hashKey, final List propertyList, final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { - byte[] bytes = hashKey.getBytes(StandardCharsets.UTF_8); + byte[] bytes; + try { + JsonNode node = normalizeNumbers(OBJECT_MAPPER.valueToTree(hashKey)); + bytes = CborEncoder.encode(node); + } catch (Exception e) { + log.debug("Error converting hashKey to CBOR", e); + throw new JsonLogicEvaluationException("Error converting hashKey to CBOR", jsonPath); + } int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0); return distributeValueFromHash(mmrHash, propertyList, totalWeight, jsonPath); } + // normalize for hashing parity: whole doubles in [-2^63, 2^64-1] -> int, -0.0 -> 0, else unchanged + @SuppressWarnings("PMD.AvoidDecimalLiteralsInBigDecimalConstructor") // always a whole where we do this + static JsonNode normalizeNumbers(JsonNode node) { + if (node.isObject()) { + ObjectNode result = NODE_FACTORY.objectNode(); + node.fields().forEachRemaining(field -> result.set(field.getKey(), normalizeNumbers(field.getValue()))); + return result; + } + if (node.isArray()) { + ArrayNode result = NODE_FACTORY.arrayNode(); + node.forEach(child -> result.add(normalizeNumbers(child))); + return result; + } + if (node.isFloatingPointNumber()) { + double value = node.asDouble(); + if (!Double.isInfinite(value) && value == Math.floor(value)) { + if (value >= Long.MIN_VALUE && value < TWO_POW_63) { + return NODE_FACTORY.numberNode((long) value); + } + if (value >= TWO_POW_63 && value < TWO_POW_64) { + // beyond long; unsigned 64-bit + return NODE_FACTORY.numberNode(new BigDecimal(value).toBigIntegerExact()); + } + } + } + return node; + } + /** * Checks if arguments have been flattened by json-logic pre-evaluation. * A flattened list contains no List elements (e.g. ["single", 1] instead of [["single", 1]]). diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/cbor/CborEncoderTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/cbor/CborEncoderTest.java new file mode 100644 index 0000000000..6398bf25a7 --- /dev/null +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/cbor/CborEncoderTest.java @@ -0,0 +1,158 @@ +package dev.openfeature.contrib.tools.flagd.core.cbor; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigInteger; +import java.util.stream.Stream; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Tests for {@link CborEncoder}. Expected outputs are taken directly from + * RFC 8949 Appendix A ("Examples of Encoded CBOR Data Items") where available, + * extended with length and map-ordering. + */ +class CborEncoderTest { + + private static final JsonNodeFactory NODES = JsonNodeFactory.instance; + + @ParameterizedTest(name = "{0}") + @MethodSource({"floats", "integers", "strings", "simpleValues", "arrays", "maps"}) + void encodesToExpectedBytes(@SuppressWarnings("unused") String label, JsonNode node, String expectedHex) { + assertEquals(expectedHex, hex(CborEncoder.encode(node))); + } + + // Appendix A float examples + static Stream floats() { + return Stream.of( + vector("0.0", NODES.numberNode(0.0), "f90000"), + vector("-0.0", NODES.numberNode(-0.0), "f98000"), + vector("1.0", NODES.numberNode(1.0), "f93c00"), + vector("1.1", NODES.numberNode(1.1), "fb3ff199999999999a"), + vector("1.5", NODES.numberNode(1.5), "f93e00"), + vector("65504.0", NODES.numberNode(65504.0), "f97bff"), + vector("100000.0", NODES.numberNode(100000.0), "fa47c35000"), + vector("3.4028234663852886e+38", NODES.numberNode(3.4028234663852886e38), "fa7f7fffff"), + vector("1.0e+300", NODES.numberNode(1.0e300), "fb7e37e43c8800759c"), + vector("5.960464477539063e-8", NODES.numberNode(5.960464477539063e-8), "f90001"), + vector("0.00006103515625", NODES.numberNode(0.00006103515625), "f90400"), + vector("-4.0", NODES.numberNode(-4.0), "f9c400"), + vector("-4.1", NODES.numberNode(-4.1), "fbc010666666666666"), + vector("Infinity", NODES.numberNode(Double.POSITIVE_INFINITY), "f97c00"), + vector("-Infinity", NODES.numberNode(Double.NEGATIVE_INFINITY), "f9fc00"), + vector("NaN", NODES.numberNode(Double.NaN), "f97e00")); + } + + // Appendix A integer examples + static Stream integers() { + return Stream.of( + vector("0", NODES.numberNode(0L), "00"), + vector("1", NODES.numberNode(1L), "01"), + vector("10", NODES.numberNode(10L), "0a"), + vector("23", NODES.numberNode(23L), "17"), + vector("24", NODES.numberNode(24L), "1818"), + vector("25", NODES.numberNode(25L), "1819"), + vector("100", NODES.numberNode(100L), "1864"), + vector("1000", NODES.numberNode(1000L), "1903e8"), + vector("1000000", NODES.numberNode(1000000L), "1a000f4240"), + vector("1000000000000", NODES.numberNode(1000000000000L), "1b000000e8d4a51000"), + vector("Long.MAX_VALUE", NODES.numberNode(Long.MAX_VALUE), "1b7fffffffffffffff"), + vector("-1", NODES.numberNode(-1L), "20"), + vector("-10", NODES.numberNode(-10L), "29"), + vector("-100", NODES.numberNode(-100L), "3863"), + vector("-1000", NODES.numberNode(-1000L), "3903e7"), + vector("Long.MIN_VALUE", NODES.numberNode(Long.MIN_VALUE), "3b7fffffffffffffff"), + vector( + "18446744073709551615", + NODES.numberNode(new BigInteger("18446744073709551615")), + "1bffffffffffffffff"), + vector( + "-18446744073709551616", + NODES.numberNode(new BigInteger("-18446744073709551616")), + "3bffffffffffffffff"), + // beyond Appendix A: argument-length boundaries (1/2/4/8-byte selection) + vector("2^63", NODES.numberNode(new BigInteger("9223372036854775808")), "1b8000000000000000"), + vector("255", NODES.numberNode(255L), "18ff"), + vector("256", NODES.numberNode(256L), "190100"), + vector("65535", NODES.numberNode(65535L), "19ffff"), + vector("65536", NODES.numberNode(65536L), "1a00010000"), + vector("4294967295", NODES.numberNode(4294967295L), "1affffffff"), + vector("4294967296", NODES.numberNode(4294967296L), "1b0000000100000000")); + } + + // Appendix A text-string examples + static Stream strings() { + return Stream.of( + vector("empty", NODES.textNode(""), "60"), + vector("a", NODES.textNode("a"), "6161"), + vector("IETF", NODES.textNode("IETF"), "6449455446"), + vector("quote-backslash", NODES.textNode("\"\\"), "62225c"), + vector("u00fc", NODES.textNode("\u00fc"), "62c3bc"), + vector("u6c34", NODES.textNode("\u6c34"), "63e6b0b4"), + // beyond Appendix A: 24 bytes forces a 1-byte length header + vector("24-byte string", NODES.textNode("a".repeat(24)), "7818" + "61".repeat(24))); + } + + static Stream simpleValues() { + return Stream.of( + vector("false", NODES.booleanNode(false), "f4"), + vector("true", NODES.booleanNode(true), "f5"), + vector("null", NODES.nullNode(), "f6")); + } + + // Appendix A array examples + static Stream arrays() { + ArrayNode empty = NODES.arrayNode(); + ArrayNode flat = NODES.arrayNode(); + flat.add(1).add(2).add(3); + ArrayNode nested = NODES.arrayNode(); + nested.add(1); + nested.add(NODES.arrayNode().add(2).add(3)); + nested.add(NODES.arrayNode().add(4).add(5)); + return Stream.of( + vector("[]", empty, "80"), + vector("[1,2,3]", flat, "83010203"), + vector("[1,[2,3],[4,5]]", nested, "8301820203820405")); + } + + // Appendix A map examples plus canonical key-ordering cases + static Stream maps() { + ObjectNode ab = NODES.objectNode(); + ab.put("a", 1).put("b", 2); + ObjectNode baReversed = NODES.objectNode(); + baReversed.put("b", 2).put("a", 1); + ObjectNode lengthFirst = NODES.objectNode(); + lengthFirst.put("z", 1).put("aa", 2); + ObjectNode withArray = NODES.objectNode(); + withArray.put("a", 1).set("b", NODES.arrayNode().add(2).add(3)); + ObjectNode nested = NODES.objectNode(); + nested.set("a", NODES.objectNode().put("b", 1)); + nested.put("b", 2); + return Stream.of( + vector("{}", NODES.objectNode(), "a0"), + vector("{a:1,b:[2,3]}", withArray, "a26161016162820203"), + // beyond Appendix A: canonical (length-first, then bytewise) key ordering + vector("{a:1,b:2}", ab, "a2616101616202"), + vector("{b:2,a:1} reordered", baReversed, "a2616101616202"), + vector("{z:1,aa:2} length-first", lengthFirst, "a2617a0162616102"), + vector("{a:{b:1},b:2} nested", nested, "a26161a1616201616202")); + } + + private static Arguments vector(String label, JsonNode node, String expectedHex) { + return Arguments.of(Named.of(label, label), node, expectedHex); + } + + private static String hex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + builder.append(String.format("%02x", value & 0xff)); + } + return builder.toString(); + } +} diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java index 941d978d50..8027215daf 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/e2e/FlagdCoreEvaluatorTest.java @@ -11,7 +11,7 @@ * configuration. Registered as an {@link dev.openfeature.contrib.tools.flagd.api.testkit.EvaluatorFactory} * via {@code META-INF/services}. */ -@ExcludeTags({"fractional-v1", "evaluator-refs-whitespace", "non-existent-evaluator-ref"}) +@ExcludeTags({"fractional-v1", "fractional-v2", "evaluator-refs-whitespace", "non-existent-evaluator-ref"}) public class FlagdCoreEvaluatorTest extends AbstractEvaluatorTest { @Override diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index 046d8914f6..87cb7229d7 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -10,6 +10,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import dev.openfeature.contrib.tools.flagd.core.cbor.CborEncoder; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import java.io.IOException; import java.nio.file.Files; @@ -25,6 +27,7 @@ import org.junit.jupiter.params.converter.ArgumentConversionException; import org.junit.jupiter.params.converter.ConvertWith; import org.junit.jupiter.params.converter.TypedArgumentConverter; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; class FractionalTest { @@ -99,7 +102,7 @@ void missingBucketKeyReturnsNull() throws JsonLogicEvaluationException { List.of("one", 50), List.of("two", 50)); // bucketing key is null, so fractional falls back to flagKey + targetingKey - // but targetingKey is null, so it should return null + // but targetingKey is null, so it should throw GeneralError assertNull(fractional.evaluate(rule, data, "path")); } @@ -150,4 +153,26 @@ void zeroWeightsReturnsNull() throws JsonLogicEvaluationException { assertNull(fractional.evaluate(rule, data, "path")); } + + @ParameterizedTest(name = "{0}") + @MethodSource("int64BoundaryDoubles") + void normalizesWholeDoublesAcrossInt64Boundaries( + @SuppressWarnings("unused") String label, double value, String expectedHex) { + byte[] bytes = CborEncoder.encode(Fractional.normalizeNumbers(JsonNodeFactory.instance.numberNode(value))); + StringBuilder hex = new StringBuilder(); + for (byte b : bytes) { + hex.append(String.format("%02x", b & 0xff)); + } + assertEquals(expectedHex, hex.toString()); + } + + static Stream int64BoundaryDoubles() { + return Stream.of( + arguments("-2^63", -0x1p63, "3b7fffffffffffffff"), + arguments("largest double < 2^63", 0x1p63 - 1024, "1b7ffffffffffffc00"), + arguments("2^63", 0x1p63, "1b8000000000000000"), + arguments("1e19", 1e19, "1b8ac7230489e80000"), + arguments("largest double < 2^64", 0x1p64 - 2048, "1bfffffffffffff800"), + arguments("2^64 stays float", 0x1p64, "fa5f800000")); + } } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java index 888b8ef03f..f8e741080a 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java @@ -112,7 +112,7 @@ void fractionalTestB() throws TargetingRuleException { Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); // then - assertEquals("blue", evalVariant); + assertEquals("red", evalVariant); } @Test @@ -153,7 +153,7 @@ void fractionalTestA() throws TargetingRuleException { Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); // then - assertEquals("yellow", evalVariant); + assertEquals("green", evalVariant); } @Test diff --git a/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json b/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json index e632f17d91..2beb7e5be2 100644 --- a/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json +++ b/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json @@ -10,5 +10,5 @@ 50 ] ], - "result": "red" + "result": "blue" } diff --git a/tools/flagd-core/src/test/resources/fractional/string.json b/tools/flagd-core/src/test/resources/fractional/string.json index 8c55b68c1f..65cb381b78 100644 --- a/tools/flagd-core/src/test/resources/fractional/string.json +++ b/tools/flagd-core/src/test/resources/fractional/string.json @@ -10,5 +10,5 @@ 70 ] ], - "result": "blue" + "result": "green" }