From c19039d289ae8040380e00828910d7128e6604f5 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 09:05:06 +0200 Subject: [PATCH 1/8] feat(ffe): Add dd-openfeature SDK flag configuration and evaluation core Introduce the standalone :dd-openfeature module, not published yet, with the Universal Flag Configuration model and parser and the local flag evaluator. It depends on no dd-trace-java internal module. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 1 + dd-openfeature/build.gradle.kts | 38 ++ dd-openfeature/gradle.lockfile | 78 +++ .../evaluation/EvaluationContext.java | 10 + .../evaluation/EvaluationMetadata.java | 50 ++ .../internal/evaluation/EvaluationResult.java | 137 +++++ .../internal/evaluation/FlagEvaluator.java | 547 +++++++++++++++++ .../openfeature/internal/ufc/Allocation.java | 28 + .../internal/ufc/ConditionConfiguration.java | 18 + .../internal/ufc/ConditionOperator.java | 19 + .../openfeature/internal/ufc/Environment.java | 9 + .../openfeature/internal/ufc/Flag.java | 25 + .../internal/ufc/ParsedSemver.java | 318 ++++++++++ .../openfeature/internal/ufc/Rule.java | 11 + .../internal/ufc/ServerConfiguration.java | 32 + .../openfeature/internal/ufc/Shard.java | 16 + .../openfeature/internal/ufc/ShardRange.java | 12 + .../openfeature/internal/ufc/Split.java | 24 + .../ufc/UniversalFlagConfigParser.java | 568 ++++++++++++++++++ .../openfeature/internal/ufc/ValueType.java | 9 + .../openfeature/internal/ufc/Variant.java | 11 + .../internal/ufc/ParsedSemverTest.java | 220 +++++++ .../UniversalFlagConfigParserJsonApiTest.java | 435 ++++++++++++++ .../ufc/UniversalFlagConfigParserTest.java | 273 +++++++++ .../allocation-window-dates.json | 35 ++ .../resources/remote-config/empty-config.json | 8 + .../remote-config/malformed-allocations.json | 45 ++ .../resources/remote-config/null-flag.json | 34 ++ .../resources/remote-config/null-flags.json | 8 + .../resources/remote-config/serial-id.json | 34 ++ .../remote-config/sibling-serial-ids.json | 59 ++ .../remote-config/unknown-operator.json | 67 +++ .../unknown-top-level-fields.json | 13 + gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + 35 files changed, 3194 insertions(+) create mode 100644 dd-openfeature/build.gradle.kts create mode 100644 dd-openfeature/gradle.lockfile create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java create mode 100644 dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java create mode 100644 dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json create mode 100644 dd-openfeature/src/test/resources/remote-config/empty-config.json create mode 100644 dd-openfeature/src/test/resources/remote-config/malformed-allocations.json create mode 100644 dd-openfeature/src/test/resources/remote-config/null-flag.json create mode 100644 dd-openfeature/src/test/resources/remote-config/null-flags.json create mode 100644 dd-openfeature/src/test/resources/remote-config/serial-id.json create mode 100644 dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json create mode 100644 dd-openfeature/src/test/resources/remote-config/unknown-operator.json create mode 100644 dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 671d37e3d72..660d14776d4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -355,6 +355,7 @@ **/kafka*/**/*Iast* @DataDog/asm-java @DataDog/data-streams-monitoring @DataDog/apm-idm-java # @DataDog/feature-flagging-and-experimentation-sdk +/dd-openfeature/ @DataDog/feature-flagging-and-experimentation-sdk /dd-smoke-tests/openfeature/ @DataDog/feature-flagging-and-experimentation-sdk /products/feature-flagging/ @DataDog/feature-flagging-and-experimentation-sdk diff --git a/dd-openfeature/build.gradle.kts b/dd-openfeature/build.gradle.kts new file mode 100644 index 00000000000..17e9f6ee412 --- /dev/null +++ b/dd-openfeature/build.gradle.kts @@ -0,0 +1,38 @@ +import datadog.gradle.configureCompiler +import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension + +plugins { + `java-library` + idea + id("dd-trace-java.conventions.java") +} + +description = "Datadog OpenFeature provider SDK." + +configure { + minJavaVersion.set(JavaVersion.VERSION_11) +} + +idea { + module { + jdkName = "11" + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +dependencies { + api("dev.openfeature:sdk:1.22.0") + api(libs.jackson.core) + + testImplementation(libs.bundles.junit5) + testImplementation(libs.bundles.mockito) +} + +tasks.withType().configureEach { + configureCompiler(JavaVersion.VERSION_11) +} diff --git a/dd-openfeature/gradle.lockfile b/dd-openfeature/gradle.lockfile new file mode 100644 index 00000000000..c878fe4af10 --- /dev/null +++ b/dd-openfeature/gradle.lockfile @@ -0,0 +1,78 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +# To regenerate this file, run: ./gradlew :dd-openfeature:dependencies --write-locks +ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.javaparser:javaparser-core:3.25.6=codenarc +com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,spotbugs +com.github.spotbugs:spotbugs:4.10.4=spotbugs +com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs +com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +com.google.code.gson:gson:2.14.0=spotbugs +com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.thoughtworks.qdox:qdox:1.12.1=codenarc +commons-io:commons-io:2.21.0=spotbugs +de.thetaphi:forbiddenapis:3.11=compileClasspath +dev.openfeature:sdk:1.22.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy-agent:1.18.12=testCompileClasspath,testRuntimeClasspath +net.bytebuddy:byte-buddy:1.18.12=testCompileClasspath,testRuntimeClasspath +net.sf.saxon:Saxon-HE:12.10=spotbugs +org.apache.ant:ant-antlr:1.10.14=codenarc +org.apache.ant:ant-junit:1.10.14=codenarc +org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-lang3:3.20.0=spotbugs +org.apache.commons:commons-text:1.15.0=spotbugs +org.apache.logging.log4j:log4j-api:2.26.1=spotbugs +org.apache.logging.log4j:log4j-core:2.26.1=spotbugs +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-ant:3.0.23=codenarc +org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc +org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.23=codenarc +org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codehaus.groovy:groovy-templates:3.0.23=codenarc +org.codehaus.groovy:groovy-xml:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.23=codenarc +org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath +org.codenarc:CodeNarc:3.7.0=codenarc +org.dom4j:dom4j:2.2.0=spotbugs +org.gmetrics:GMetrics:2.1.0=codenarc +org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath +org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt +org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt +org.jacoco:org.jacoco.core:0.8.15=jacocoAnt +org.jacoco:org.jacoco.report:0.8.15=jacocoAnt +org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath +org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-core:4.4.0=testCompileClasspath,testRuntimeClasspath +org.mockito:mockito-junit-jupiter:4.4.0=testCompileClasspath,testRuntimeClasspath +org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm-analysis:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-tree:9.10.1=jacocoAnt,spotbugs +org.ow2.asm:asm-util:9.10.1=spotbugs +org.ow2.asm:asm:9.10.1=jacocoAnt,spotbugs +org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-junit:1.2.2=testCompileClasspath,testRuntimeClasspath +org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.xmlresolver:xmlresolver:5.3.3=spotbugs +empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java new file mode 100644 index 00000000000..0f4112b2f3d --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java @@ -0,0 +1,10 @@ +package com.datadog.openfeature.internal.evaluation; + +/** Lazy application context; adapters retain their public SDK's value-conversion rules. */ +public interface EvaluationContext { + String getTargetingKey(); + + boolean hasAttribute(String name); + + Object attribute(String name); +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java new file mode 100644 index 00000000000..b1b17d91501 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java @@ -0,0 +1,50 @@ +package com.datadog.openfeature.internal.evaluation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Immutable evaluation metadata, independent of the public OpenFeature SDK. */ +public final class EvaluationMetadata { + private final Map values; + + private EvaluationMetadata(final Map values) { + this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + public Map values() { + return values; + } + + public static EvaluationMetadataBuilder builder() { + return new EvaluationMetadataBuilder(); + } + + public static final class EvaluationMetadataBuilder { + private final Map values = new LinkedHashMap<>(); + + public EvaluationMetadataBuilder addString(String key, String value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addBoolean(String key, boolean value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addInteger(String key, int value) { + values.put(key, value); + return this; + } + + public EvaluationMetadataBuilder addLong(String key, long value) { + values.put(key, value); + return this; + } + + public EvaluationMetadata build() { + return new EvaluationMetadata(values); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java new file mode 100644 index 00000000000..e83edfd9355 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java @@ -0,0 +1,137 @@ +package com.datadog.openfeature.internal.evaluation; + +/** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ +public final class EvaluationResult { + public enum ErrorCode { + PROVIDER_NOT_READY, + INVALID_CONTEXT, + FLAG_NOT_FOUND, + TARGETING_KEY_MISSING, + TYPE_MISMATCH, + PARSE_ERROR, + GENERAL + } + + public enum Reason { + DISABLED, + DEFAULT, + ERROR, + TARGETING_MATCH, + SPLIT, + STATIC + } + + private final T value; + private final String reason; + private final String variant; + private final ErrorCode errorCode; + private final String errorMessage; + private final EvaluationMetadata metadata; + private final boolean logExposure; + private final Integer serialId; + + private EvaluationResult(final Builder builder) { + value = builder.value; + reason = builder.reason; + variant = builder.variant; + errorCode = builder.errorCode; + errorMessage = builder.errorMessage; + metadata = builder.metadata; + logExposure = builder.logExposure; + serialId = builder.serialId; + } + + public T getValue() { + return value; + } + + public String getReason() { + return reason; + } + + public String getVariant() { + return variant; + } + + public ErrorCode getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public EvaluationMetadata getFlagMetadata() { + return metadata; + } + + public boolean isLogExposure() { + return logExposure; + } + + /** + * @return the serial id of the resolved split, or {@code null} if absent. + */ + public Integer getSerialId() { + return serialId; + } + + public static Builder builder() { + return new Builder<>(); + } + + public static final class Builder { + private T value; + private String reason; + private String variant; + private ErrorCode errorCode; + private String errorMessage; + private EvaluationMetadata metadata; + private boolean logExposure; + private Integer serialId; + + public Builder value(T value) { + this.value = value; + return this; + } + + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + public Builder variant(String variant) { + this.variant = variant; + return this; + } + + public Builder errorCode(ErrorCode errorCode) { + this.errorCode = errorCode; + return this; + } + + public Builder errorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + public Builder flagMetadata(EvaluationMetadata metadata) { + this.metadata = metadata; + return this; + } + + public Builder serialId(Integer serialId) { + this.serialId = serialId; + return this; + } + + public Builder logExposure(boolean logExposure) { + this.logExposure = logExposure; + return this; + } + + public EvaluationResult build() { + return new EvaluationResult<>(this); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java new file mode 100644 index 00000000000..ac0c16675b5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -0,0 +1,547 @@ +package com.datadog.openfeature.internal.evaluation; + +import static java.util.Arrays.asList; + +import com.datadog.openfeature.internal.evaluation.EvaluationResult.ErrorCode; +import com.datadog.openfeature.internal.evaluation.EvaluationResult.Reason; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.ConditionConfiguration; +import com.datadog.openfeature.internal.ufc.ConditionOperator; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ParsedSemver; +import com.datadog.openfeature.internal.ufc.Rule; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Shard; +import com.datadog.openfeature.internal.ufc.ShardRange; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Local evaluation over a single configuration snapshot. No transport or OpenFeature dependency. + */ +public final class FlagEvaluator { + private static final Set> SUPPORTED_RESOLUTION_TYPES = + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Object.class)); + private static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = + "observe_full_evaluation_data"; + private static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; + private static final String METADATA_DO_LOG = "__dd_do_log"; + private final boolean spanEnrichmentEnabled; + + public FlagEvaluator(final boolean spanEnrichmentEnabled) { + this.spanEnrichmentEnabled = spanEnrichmentEnabled; + } + + public EvaluationResult evaluate( + final ServerConfiguration config, + final Class target, + final String key, + final T defaultValue, + final EvaluationContext context) { + // Snapshot the config once and thread observeFullEvaluationData through every + // EvaluationResult returned, so the hook's consent decision is pinned to this evaluation's + // config and cannot drift on a concurrent Remote Config swap. + // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without + // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. + final boolean observeFullEvaluationData = + config != null && Boolean.TRUE.equals(config.observeFullEvaluationData); + try { + if (config == null) { + return error(defaultValue, ErrorCode.PROVIDER_NOT_READY, null, observeFullEvaluationData); + } + + if (context == null) { + return error(defaultValue, ErrorCode.INVALID_CONTEXT, null, observeFullEvaluationData); + } + + final Flag flag = config.flags.get(key); + if (flag == null) { + if (config.invalidFlags != null && config.invalidFlags.containsKey(key)) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "invalid configuration for flag " + key, + observeFullEvaluationData); + } + return error(defaultValue, ErrorCode.FLAG_NOT_FOUND, null, observeFullEvaluationData); + } + + if (!flag.enabled) { + return EvaluationResult.builder() + .value(defaultValue) + .reason(Reason.DISABLED.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } + + if (flag.allocations == null) { + return error( + defaultValue, + ErrorCode.GENERAL, + "Missing allocations for flag " + key, + observeFullEvaluationData); + } + + final Instant now = Instant.now(); + final long evalTimestampMs = now.toEpochMilli(); + final String targetingKey = context.getTargetingKey(); + + for (final Allocation allocation : flag.allocations) { + if (!isAllocationActive(allocation, now)) { + continue; + } + + if (!isEmpty(allocation.rules)) { + if (!evaluateRules(allocation.rules, context)) { + continue; + } + } + + if (!isEmpty(allocation.splits)) { + for (final Split split : allocation.splits) { + if (isEmpty(split.shards)) { + return resolveVariant( + target, + key, + defaultValue, + flag, + split.variationKey, + allocation, + split, + context, + evalTimestampMs, + observeFullEvaluationData); + } else { + if (targetingKey == null) { + return error( + defaultValue, ErrorCode.TARGETING_KEY_MISSING, null, observeFullEvaluationData); + } + // To match a split, subject must match ALL underlying shards + boolean allShardsMatch = true; + for (final Shard shard : split.shards) { + if (!matchesShard(shard, targetingKey)) { + allShardsMatch = false; + break; + } + } + if (allShardsMatch) { + return resolveVariant( + target, + key, + defaultValue, + flag, + split.variationKey, + allocation, + split, + context, + evalTimestampMs, + observeFullEvaluationData); + } + } + } + } + } + + return EvaluationResult.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } catch (final PatternSyntaxException e) { + return error(defaultValue, ErrorCode.PARSE_ERROR, e.getMessage(), observeFullEvaluationData); + } catch (final NumberFormatException e) { + return error( + defaultValue, ErrorCode.TYPE_MISMATCH, e.getMessage(), observeFullEvaluationData); + } catch (final Exception e) { + return error(defaultValue, ErrorCode.GENERAL, e.getMessage(), observeFullEvaluationData); + } + } + + private static EvaluationMetadata consentMetadata(final boolean observeFullEvaluationData) { + return EvaluationMetadata.builder() + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build(); + } + + private static EvaluationResult error( + final T defaultValue, + final ErrorCode code, + final String errorMessage, + final boolean observeFullEvaluationData) { + // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks + // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they + // must never reach any consumer of EvaluationResult.getErrorMessage() — not just our own + // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators + // still get a stable signal like "TYPE_MISMATCH". + return EvaluationResult.builder() + .value(defaultValue) + .reason(Reason.ERROR.name()) + .errorCode(code) + .errorMessage(observeFullEvaluationData ? errorMessage : null) + .flagMetadata(consentMetadata(observeFullEvaluationData)) + .build(); + } + + private static boolean isEmpty(final List list) { + return list == null || list.isEmpty(); + } + + public static boolean isAllocationActive(final Allocation allocation, final Instant now) { + final Instant startDate = allocation.startAt; + if (startDate != null && now.isBefore(startDate)) { + return false; + } + + final Instant endDate = allocation.endAt; + if (endDate != null && now.isAfter(endDate)) { + return false; + } + + return true; + } + + private static boolean evaluateRules(final List rules, final EvaluationContext context) { + for (final Rule rule : rules) { + if (isEmpty(rule.conditions)) { + continue; + } + + boolean allConditionsMatch = true; + for (final ConditionConfiguration condition : rule.conditions) { + if (!evaluateCondition(condition, context)) { + allConditionsMatch = false; + break; + } + } + + if (allConditionsMatch) { + return true; + } + } + return false; + } + + private static boolean evaluateCondition( + final ConditionConfiguration condition, final EvaluationContext context) { + if (condition.operator == ConditionOperator.IS_NULL) { + final Object value = resolveAttribute(condition.attribute, context); + boolean isNull = value == null; + // condition.value determines if we're checking for null (true) or not null (false) + boolean expectedNull = condition.value instanceof Boolean ? (Boolean) condition.value : true; + return isNull == expectedNull; + } + + final Object attributeValue = resolveAttribute(condition.attribute, context); + if (attributeValue == null) { + return false; + } + + switch (condition.operator) { + case MATCHES: + return matchesRegex(attributeValue, condition.value); + case NOT_MATCHES: + return !matchesRegex(attributeValue, condition.value); + case ONE_OF: + return isOneOf(attributeValue, condition.value); + case NOT_ONE_OF: + return !isOneOf(attributeValue, condition.value); + case GTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a >= b); + case GT: + return compareNumber(attributeValue, condition.value, (a, b) -> a > b); + case LTE: + return compareNumber(attributeValue, condition.value, (a, b) -> a <= b); + case LT: + return compareNumber(attributeValue, condition.value, (a, b) -> a < b); + case SEMVER_EQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o == 0); + case SEMVER_NEQ: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o != 0); + case SEMVER_LT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o < 0); + case SEMVER_LTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o <= 0); + case SEMVER_GT: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o > 0); + case SEMVER_GTE: + return evaluateSemverCondition(attributeValue, condition.semverComparand, (o) -> o >= 0); + default: + return false; + } + } + + private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { + // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), + // which maps it to ErrorCode.PARSE_ERROR. + final Pattern pattern = Pattern.compile(normalizeRegex(String.valueOf(conditionValue))); + return pattern.matcher(String.valueOf(attributeValue)).find(); + } + + private static String normalizeRegex(final String regex) { + return regex + .replace("[:alnum:]", "\\p{Alnum}") + .replace("[:alpha:]", "\\p{Alpha}") + .replace("[:digit:]", "\\p{Digit}") + .replace("[:lower:]", "\\p{Lower}") + .replace("[:upper:]", "\\p{Upper}") + .replace("[:space:]", "\\p{Space}"); + } + + private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { + if (!(conditionValue instanceof Iterable)) { + return false; + } + for (final Object value : (Iterable) conditionValue) { + if (valuesEqual(attributeValue, value)) { + return true; + } + } + return false; + } + + private static boolean valuesEqual(final Object a, final Object b) { + if (Objects.equals(a, b)) { + return true; + } + + if (a instanceof Number || b instanceof Number) { + return compareNumber(a, b, (first, second) -> first == second); + } + + return String.valueOf(a).equals(String.valueOf(b)); + } + + private static boolean compareNumber( + final Object attributeValue, final Object conditionValue, NumberComparator comparator) { + final double a = mapValue(Double.class, attributeValue); + final double b = mapValue(Double.class, conditionValue); + return comparator.compare(a, b); + } + + /** + * Evaluates a semantic version comparison operator. The attribute value must be a string that is + * a valid semantic version, and the comparand must have been pre-parsed during configuration + * validation. If either is missing or invalid, the condition does not match. + */ + private static boolean evaluateSemverCondition( + final Object attributeValue, + final ParsedSemver comparand, + final SemverComparator comparator) { + if (!(attributeValue instanceof String) || comparand == null) { + return false; + } + final ParsedSemver parsedAttribute = ParsedSemver.parse((String) attributeValue); + if (parsedAttribute == null) { + return false; + } + return comparator.compare(ParsedSemver.compare(parsedAttribute, comparand)); + } + + private static boolean matchesShard(final Shard shard, final String targetingKey) { + final long assignedShard = getShard(shard.salt, targetingKey, shard.totalShards); + for (final ShardRange range : shard.ranges) { + if (assignedShard >= range.start && assignedShard < range.end) { + return true; + } + } + return false; + } + + private static long getShard( + final String salt, final String targetingKey, final long totalShards) { + final String hashKey = salt + "-" + targetingKey; + final String md5Hash = getMD5Hash(hashKey); + final String first8Chars = md5Hash.substring(0, Math.min(8, md5Hash.length())); + final long intFromHash = Long.parseLong(first8Chars, 16); + return intFromHash % totalShards; + } + + private static String getMD5Hash(final String input) { + try { + final MessageDigest md = MessageDigest.getInstance("MD5"); + final byte[] hashBytes = md.digest(input.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hexString = new StringBuilder(); + for (byte b : hashBytes) { + final String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("MD5 algorithm not available", e); + } + } + + private EvaluationResult resolveVariant( + final Class target, + final String key, + final T defaultValue, + final Flag flag, + final String variationKey, + final Allocation allocation, + final Split split, + final EvaluationContext context, + final long evalTimestampMs, + final boolean observeFullEvaluationData) { + final Variant variant = flag.variations.get(variationKey); + if (variant == null) { + return error( + defaultValue, + ErrorCode.GENERAL, + "Variant not found for: " + variationKey, + observeFullEvaluationData); + } + + if (!isTypeCompatible(target, flag.variationType)) { + return error( + defaultValue, + ErrorCode.TYPE_MISMATCH, + "Requested type " + + target.getSimpleName() + + " does not match flag variationType " + + flag.variationType.name(), + observeFullEvaluationData); + } + + final T mappedValue; + try { + mappedValue = mapValue(target, variant.value); + } catch (final NumberFormatException e) { + return error( + defaultValue, + ErrorCode.PARSE_ERROR, + "Variant '" + + variant.key + + "' value does not match declared type " + + flag.variationType.name() + + ": " + + e.getMessage(), + observeFullEvaluationData); + } + + // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, + // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". + final EvaluationMetadata.EvaluationMetadataBuilder metadataBuilder = + EvaluationMetadata.builder() + .addString("flagKey", flag.key) + .addString("variationType", flag.variationType.name()) + .addString("allocationKey", allocation.key) + .addLong("__dd_eval_timestamp_ms", evalTimestampMs) + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); + // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — + // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always + // present (when enrichment is on) so the span-enrichment hook can decide whether to record the + // subject. + if (spanEnrichmentEnabled) { + if (split.serialId != null) { + metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); + } + metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + } + final EvaluationResult result = + EvaluationResult.builder() + .value(mappedValue) + .reason( + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH.name() + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .logExposure(Boolean.TRUE.equals(allocation.doLog)) + .serialId(split.serialId) + .build(); + return result; + } + + private static Object resolveAttribute(final String name, final EvaluationContext context) { + // Special handling for "id" attribute: if not explicitly provided, use targeting key + if ("id".equals(name) && !context.hasAttribute(name)) { + return context.getTargetingKey(); + } + return context.attribute(name); + } + + public static boolean isTypeCompatible(final Class target, final ValueType variationType) { + if (variationType == null) { + return true; // No type info — allow any + } + switch (variationType) { + case BOOLEAN: + return target == Boolean.class; + case STRING: + return target == String.class; + case INTEGER: + return target == Integer.class; + case NUMERIC: + return target == Double.class; + case JSON: + return target == Object.class; + default: + return true; // Unknown types pass through — mapValue errors caught as GENERAL + } + } + + @SuppressWarnings("unchecked") + public static T mapValue(final Class target, final Object value) { + if (value == null) { + return null; + } + if (!SUPPORTED_RESOLUTION_TYPES.contains(target)) { + throw new IllegalArgumentException("Type not supported: " + target); + } + if (target.isInstance(value)) { + return target.cast(value); + } + if (target == String.class) { + return (T) String.valueOf(value); + } + if (target == Boolean.class) { + if (value instanceof Number) { + return (T) (Boolean) (parseDouble(value) != 0); + } + return (T) Boolean.valueOf(value.toString()); + } + if (target == Integer.class) { + final Double number = parseDouble(value); + return (T) (Integer) number.intValue(); + } + if (target == Double.class) { + final Double number = parseDouble(value); + return (T) number; + } + return (T) value; + } + + private static Double parseDouble(final Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return Double.parseDouble(String.valueOf(value)); + } + + @FunctionalInterface + private interface NumberComparator { + boolean compare(double a, double b); + } + + @FunctionalInterface + private interface SemverComparator { + boolean compare(int ordering); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java new file mode 100644 index 00000000000..b37a676571a --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Allocation.java @@ -0,0 +1,28 @@ +package com.datadog.openfeature.internal.ufc; + +import java.time.Instant; +import java.util.List; + +public final class Allocation { + public final String key; + public final List rules; + public final Instant startAt; + public final Instant endAt; + public final List splits; + public final Boolean doLog; + + public Allocation( + final String key, + final List rules, + final Instant startAt, + final Instant endAt, + final List splits, + final Boolean doLog) { + this.key = key; + this.rules = rules; + this.startAt = startAt; + this.endAt = endAt; + this.splits = splits; + this.doLog = doLog; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java new file mode 100644 index 00000000000..c5d79cd67e1 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionConfiguration.java @@ -0,0 +1,18 @@ +package com.datadog.openfeature.internal.ufc; + +public final class ConditionConfiguration { + public final ConditionOperator operator; + public final String attribute; + public final Object value; + + // The validated, parsed SemVer condition value. Set during configuration preprocessing + // (not from JSON) when the operator is a SEMVER_* operator. + public transient ParsedSemver semverComparand; + + public ConditionConfiguration( + final ConditionOperator operator, final String attribute, final Object value) { + this.operator = operator; + this.attribute = attribute; + this.value = value; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java new file mode 100644 index 00000000000..fc3d3bab683 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ConditionOperator.java @@ -0,0 +1,19 @@ +package com.datadog.openfeature.internal.ufc; + +public enum ConditionOperator { + LT, + LTE, + GT, + GTE, + MATCHES, + NOT_MATCHES, + ONE_OF, + NOT_ONE_OF, + IS_NULL, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_LT, + SEMVER_LTE, + SEMVER_GT, + SEMVER_GTE +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java new file mode 100644 index 00000000000..664ae3fa07e --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Environment.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.ufc; + +public final class Environment { + public final String name; + + public Environment(String name) { + this.name = name; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java new file mode 100644 index 00000000000..8ff4dd83989 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Flag.java @@ -0,0 +1,25 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; +import java.util.Map; + +public final class Flag { + public final String key; + public final boolean enabled; + public final ValueType variationType; + public final Map variations; + public final List allocations; + + public Flag( + final String key, + final boolean enabled, + final ValueType variationType, + final Map variations, + final List allocations) { + this.key = key; + this.enabled = enabled; + this.variationType = variationType; + this.variations = variations; + this.allocations = allocations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java new file mode 100644 index 00000000000..c4f99828516 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ParsedSemver.java @@ -0,0 +1,318 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.Arrays; + +/** + * ParsedSemver is the language-neutral representation of the SemVer subset used by FFE. Owning this + * parser and comparator keeps behavior consistent across SDKs and lets configuration preprocessing + * cache comparands instead of reparsing them during every evaluation. + * + *

Core identifiers (major, minor, patch) are limited to unsigned 64-bit integers, stored in + * {@code long} fields and compared with unsigned semantics. Numeric prerelease identifiers may be + * arbitrarily large and are compared by length-then-lexicographic order. Build metadata is + * validated during parsing but not retained because it does not affect SemVer precedence. + */ +public final class ParsedSemver { + + /** Sentinel returned by {@link #parse(String)} when the input is not a valid semantic version. */ + public static final ParsedSemver INVALID = null; + + private final long[] core; // unsigned, with omitted minor and patch normalized to zero + private final int coreLength; + private final String prerelease; + + ParsedSemver(final long[] core, final int coreLength, final String prerelease) { + this.core = core; + this.coreLength = coreLength; + this.prerelease = prerelease; + } + + /** + * Parses a semantic version string using the syntax supported by FFE. + * + * @param version the version string to parse + * @return a {@link ParsedSemver} instance, or {@code null} if the input is not a valid semantic + * version + */ + public static ParsedSemver parse(final String version) { + long[] core = new long[3]; + int coreSize = 0; + int next = 0; + while (true) { + final int end = coreIdentifierEnd(version, next); + if (end == -1) { + return INVALID; + } + final long component; + try { + component = Long.parseUnsignedLong(version.substring(next, end)); + } catch (final NumberFormatException e) { + return INVALID; // overflow + } + if (coreSize == core.length) { + core = Arrays.copyOf(core, core.length * 2); + } + core[coreSize++] = component; + next = end; + if (next == version.length() || version.charAt(next) != '.') { + break; + } + next++; + } + final int coreLength = Math.max(3, coreSize); + if (next == version.length()) { + return new ParsedSemver(core, coreLength, ""); + } + + // Parse prerelease and/or build metadata. + String remainder = version.substring(next); + String prerelease = ""; + if (remainder.charAt(0) == '-') { + remainder = remainder.substring(1); + final int buildStart = remainder.indexOf('+'); + if (buildStart == -1) { + if (!validSemverIdentifiers(remainder, false)) { + return INVALID; + } + return new ParsedSemver(core, coreLength, remainder); + } + prerelease = remainder.substring(0, buildStart); + if (!validSemverIdentifiers(prerelease, false)) { + return INVALID; + } + remainder = remainder.substring(buildStart + 1); + } else if (remainder.charAt(0) == '+') { + remainder = remainder.substring(1); + } else { + return INVALID; + } + if (!validSemverIdentifiers(remainder, true)) { + return INVALID; + } + return new ParsedSemver(core, coreLength, prerelease); + } + + /** + * Compares two parsed semantic versions by precedence. Build metadata is intentionally ignored. + * + * @param left the left operand + * @param right the right operand + * @return negative if {@code left} precedes {@code right}, positive if {@code left} follows + * {@code right}, zero if they have equal precedence + */ + public static int compare(final ParsedSemver left, final ParsedSemver right) { + final int coreLength = Math.max(left.coreLength, right.coreLength); + for (int i = 0; i < coreLength; i++) { + final int cmp = Long.compareUnsigned(left.coreComponent(i), right.coreComponent(i)); + if (cmp != 0) { + return cmp; + } + } + return comparePrerelease(left.prerelease, right.prerelease); + } + + /** + * Finds the end of a core version identifier (major, minor, or patch), rejecting leading zeros + * except for the value zero itself. + */ + private static int coreIdentifierEnd(final String version, final int start) { + if (start >= version.length() || !isASCIIDigit(version.charAt(start))) { + return -1; + } + if (version.charAt(start) == '0') { + return start + 1; + } + + int end = start; + while (end < version.length() && isASCIIDigit(version.charAt(end))) { + end++; + } + return end; + } + + private long coreComponent(final int index) { + return index < coreLength ? core[index] : 0; + } + + /** + * Returns the number of significant core components, excluding trailing zero components that + * {@link #compare} treats as equal to omitted components. This keeps {@link #equals} and {@link + * #hashCode} consistent with precedence comparison so that versions like {@code 1.2.3} and {@code + * 1.2.3.0} are equal and hash identically. + */ + private int effectiveCoreLength() { + int length = coreLength; + while (length > 0 && core[length - 1] == 0L) { + length--; + } + return length; + } + + /** + * Validates dot-separated identifiers. Permits leading zeros for build metadata only; numeric + * prerelease identifiers reject them. + */ + private static boolean validSemverIdentifiers( + final String value, final boolean allowLeadingZeros) { + int identifierStart = 0; + boolean identifierNumeric = true; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == '.') { + if (i == identifierStart) { + return false; // empty identifier + } + if (!allowLeadingZeros + && identifierNumeric + && i - identifierStart > 1 + && value.charAt(identifierStart) == '0') { + return false; // leading zero in numeric identifier + } + identifierStart = i + 1; + identifierNumeric = true; + continue; + } + + final char c = value.charAt(i); + if (!isASCIIAlphanumeric(c) && c != '-') { + return false; + } + if (!isASCIIDigit(c)) { + identifierNumeric = false; + } + } + return true; + } + + private static int comparePrerelease(final String left, final String right) { + if (left.equals(right)) { + return 0; + } + if (left.isEmpty()) { + return 1; // release > prerelease + } + if (right.isEmpty()) { + return -1; // prerelease < release + } + + int leftPos = 0; + int rightPos = 0; + while (true) { + // Extract next identifier from each side + int leftDot = left.indexOf('.', leftPos); + int rightDot = right.indexOf('.', rightPos); + final String leftIdentifier = + leftDot == -1 ? left.substring(leftPos) : left.substring(leftPos, leftDot); + final String rightIdentifier = + rightDot == -1 ? right.substring(rightPos) : right.substring(rightPos, rightDot); + + final int ordering = compareIdentifier(leftIdentifier, rightIdentifier); + if (ordering != 0) { + return ordering; + } + + if (leftDot == -1) { + if (rightDot == -1) { + return 0; + } + return -1; // left has fewer identifiers + } + if (rightDot == -1) { + return 1; // right has fewer identifiers + } + leftPos = leftDot + 1; + rightPos = rightDot + 1; + } + } + + private static int compareIdentifier(final String left, final String right) { + final boolean leftNumeric = isNumericIdentifier(left); + final boolean rightNumeric = isNumericIdentifier(right); + + if (leftNumeric && rightNumeric) { + // Numeric identifiers: compare by length first (longer = larger), then lexicographically + if (left.length() < right.length()) { + return -1; + } + if (left.length() > right.length()) { + return 1; + } + return left.compareTo(right); + } else if (leftNumeric) { + return -1; // numeric < alphanumeric + } else if (rightNumeric) { + return 1; // alphanumeric > numeric + } + return left.compareTo(right); + } + + private static boolean isNumericIdentifier(final String value) { + for (int i = 0; i < value.length(); i++) { + if (!isASCIIDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isASCIIDigit(final char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isASCIIAlphanumeric(final char c) { + return isASCIIDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + // --- Accessors for testing --- + + long getMajor() { + return core[0]; + } + + long getMinor() { + return core[1]; + } + + long getPatch() { + return core[2]; + } + + String getPrerelease() { + return prerelease; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ParsedSemver)) { + return false; + } + // Delegate to compare so equality matches precedence (SEMVER_EQ) and sorted collections, even + // when versions differ only by trailing zero components such as 1.2.3 and 1.2.3.0. + return compare(this, (ParsedSemver) o) == 0; + } + + @Override + public int hashCode() { + // Hash over the trailing-zero-stripped core so that compare-equal versions hash identically. + final int effectiveLength = effectiveCoreLength(); + int result = 1; + for (int i = 0; i < effectiveLength; i++) { + result = 31 * result + Long.hashCode(core[i]); + } + return 31 * result + prerelease.hashCode(); + } + + @Override + public String toString() { + final StringBuilder value = new StringBuilder(); + for (int i = 0; i < coreLength; i++) { + if (i > 0) { + value.append('.'); + } + value.append(Long.toUnsignedString(coreComponent(i))); + } + return value.append(prerelease.isEmpty() ? "" : "-" + prerelease).toString(); + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java new file mode 100644 index 00000000000..819ebeeaf23 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Rule.java @@ -0,0 +1,11 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +public final class Rule { + public final List conditions; + + public Rule(final List conditions) { + this.conditions = conditions; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java new file mode 100644 index 00000000000..96aa6288154 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ServerConfiguration.java @@ -0,0 +1,32 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.Collections; +import java.util.Map; + +public final class ServerConfiguration { + public final String createdAt; + public final String format; + + /** PII consent; {@code null} must be read as the privacy-preserving {@code false}. */ + public final Boolean observeFullEvaluationData; + + public final Environment environment; + public final Map flags; + + // Flags that could not be parsed or validated. The key is the flag key; the value is the error + // type (e.g. "invalid_semver_comparand"). Set during configuration preprocessing, not from JSON. + public transient Map invalidFlags = Collections.emptyMap(); + + public ServerConfiguration( + final String createdAt, + final String format, + final Boolean observeFullEvaluationData, + final Environment environment, + final Map flags) { + this.createdAt = createdAt; + this.format = format; + this.observeFullEvaluationData = observeFullEvaluationData; + this.environment = environment; + this.flags = flags; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java new file mode 100644 index 00000000000..9f9faf0f483 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Shard.java @@ -0,0 +1,16 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +/** A UFC shard. {@code totalShards} holds an unsigned 32-bit wire value. */ +public final class Shard { + public final String salt; + public final List ranges; + public final long totalShards; + + public Shard(final String salt, final List ranges, final long totalShards) { + this.salt = salt; + this.ranges = ranges; + this.totalShards = totalShards; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java new file mode 100644 index 00000000000..f48c9bfac45 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ShardRange.java @@ -0,0 +1,12 @@ +package com.datadog.openfeature.internal.ufc; + +/** A UFC shard range. {@code start} and {@code end} hold unsigned 32-bit wire values. */ +public final class ShardRange { + public final long start; + public final long end; + + public ShardRange(final long start, final long end) { + this.start = start; + this.end = end; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java new file mode 100644 index 00000000000..c4852517adc --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -0,0 +1,24 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; +import java.util.Map; + +public final class Split { + public final List shards; + public final String variationKey; + public final Map extraLogging; + + /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ + public final Integer serialId; + + public Split( + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId) { + this.shards = shards; + this.variationKey = variationKey; + this.extraLogging = extraLogging; + this.serialId = serialId; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java new file mode 100644 index 00000000000..fb67e8c19ac --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -0,0 +1,568 @@ +package com.datadog.openfeature.internal.ufc; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import java.io.IOException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses Universal Flag Configuration (UFC) documents. + * + *

The document is first read as a generic JSON tree (objects, arrays, strings, {@link Double} + * numbers, booleans and nulls), then mapped onto the UFC model. Malformed flags are dropped + * individually and reported through {@link ServerConfiguration#invalidFlags}; a malformed envelope + * fails the whole document. + */ +public final class UniversalFlagConfigParser { + private static final Logger LOGGER = LoggerFactory.getLogger(UniversalFlagConfigParser.class); + + static final String INVALID_FLAG = "invalid_flag"; + static final String INVALID_SEMVER_COMPARAND = "invalid_semver_comparand"; + private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; + private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + + private static final JsonFactory JSON_FACTORY = + new JsonFactory().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); + + private UniversalFlagConfigParser() {} + + /** + * Parses a raw UFC document, as delivered by Remote Configuration. + * + * @param content the UFC JSON document. + * @return the parsed configuration, or {@code null} for a JSON {@code null} document. + * @throws IOException if the document is not valid JSON or does not match the UFC envelope. + */ + @Nullable + public static ServerConfiguration parse(final byte[] content) throws IOException { + return toServerConfiguration(readDocument(content)); + } + + /** + * Parses a JSON:API response wrapping a UFC document, as delivered by the CDN. + * + * @param content the JSON:API response body. + * @return the parsed configuration, or {@code null} if the response holds no UFC document. + * @throws IOException if the response is not valid JSON or does not match the UFC envelope. + */ + @Nullable + public static ServerConfiguration parseJsonApi(final byte[] content) throws IOException { + final Object response = readDocument(content); + if (!(response instanceof Map)) { + return null; + } + final Object data = ((Map) response).get("data"); + if (!(data instanceof Map)) { + return null; + } + final Map dataObject = (Map) data; + if (!UNIVERSAL_FLAG_CONFIGURATION_TYPE.equals(dataObject.get("type"))) { + return null; + } + final ServerConfiguration configuration = toServerConfiguration(dataObject.get("attributes")); + return configuration != null && configuration.flags != null ? configuration : null; + } + + private static Object readDocument(final byte[] content) throws IOException { + try (JsonParser parser = JSON_FACTORY.createParser(content)) { + if (parser.nextToken() == null) { + throw new JsonParseException(parser, "Empty document"); + } + final Object document = readValue(parser); + if (parser.nextToken() != null) { + throw new JsonParseException(parser, "Unexpected content after the document"); + } + return document; + } + } + + private static Object readValue(final JsonParser parser) throws IOException { + final JsonToken token = parser.currentToken(); + switch (token) { + case START_OBJECT: + final Map object = new LinkedHashMap<>(); + while (parser.nextToken() == JsonToken.FIELD_NAME) { + final String name = parser.currentName(); + parser.nextToken(); + object.put(name, readValue(parser)); + } + return object; + case START_ARRAY: + final List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser)); + } + return array; + case VALUE_STRING: + return parser.getText(); + case VALUE_NUMBER_INT: + case VALUE_NUMBER_FLOAT: + return parser.getDoubleValue(); + case VALUE_TRUE: + return Boolean.TRUE; + case VALUE_FALSE: + return Boolean.FALSE; + case VALUE_NULL: + return null; + default: + throw new JsonParseException(parser, "Unexpected token " + token); + } + } + + @Nullable + private static ServerConfiguration toServerConfiguration(@Nullable final Object value) + throws IOException { + if (value == null) { + return null; + } + try { + final Map json = asObject(value, "configuration"); + final Map invalidFlags = new HashMap<>(); + final ServerConfiguration configuration = + new ServerConfiguration( + asString(json.get("createdAt"), "createdAt"), + asString(json.get("format"), "format"), + lenientBoolean(json.get("observeFullEvaluationData")), + toEnvironment(json.get("environment")), + toFlags(json.get("flags"), invalidFlags)); + if (!invalidFlags.isEmpty()) { + configuration.invalidFlags = invalidFlags; + } + return configuration; + } catch (final IllegalArgumentException e) { + throw new IOException("Invalid flag configuration: " + e.getMessage(), e); + } + } + + @Nullable + private static Environment toEnvironment(@Nullable final Object value) { + if (value == null) { + return null; + } + return new Environment(asString(asObject(value, "environment").get("name"), "name")); + } + + @Nullable + private static Map toFlags( + @Nullable final Object value, final Map invalidFlags) { + if (value == null) { + return null; + } + final Map flags = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "flags").entrySet()) { + final String flagKey = (String) entry.getKey(); + try { + final Flag flag = toFlag(entry.getValue()); + if (flag != null) { + validateFlag(flagKey, flag); + flags.put(flagKey, flag); + } + } catch (final IllegalArgumentException error) { + invalidFlags.put( + flagKey, + error instanceof InvalidSemverComparandException + ? INVALID_SEMVER_COMPARAND + : INVALID_FLAG); + LOGGER.warn("Dropping malformed FFE flag {}: {}", flagKey, error.toString()); + } + } + return flags; + } + + @Nullable + private static Flag toFlag(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "flag"); + return new Flag( + asString(json.get("key"), "key"), + strictBoolean(json.get("enabled"), "enabled"), + asEnum(json.get("variationType"), ValueType.class, "variationType"), + toVariants(json.get("variations")), + toList(json.get("allocations"), "allocations", UniversalFlagConfigParser::toAllocation)); + } + + @Nullable + private static Map toVariants(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map variants = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "variations").entrySet()) { + variants.put((String) entry.getKey(), toVariant(entry.getValue())); + } + return variants; + } + + @Nullable + private static Variant toVariant(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "variant"); + return new Variant(asString(json.get("key"), "key"), json.get("value")); + } + + @Nullable + private static Allocation toAllocation(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "allocation"); + return new Allocation( + asString(json.get("key"), "key"), + toList(json.get("rules"), "rules", UniversalFlagConfigParser::toRule), + toInstant(json.get("startAt")), + toInstant(json.get("endAt")), + toList(json.get("splits"), "splits", UniversalFlagConfigParser::toSplit), + lenientBoolean(json.get("doLog"))); + } + + @Nullable + private static Rule toRule(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "rule"); + return new Rule( + toList(json.get("conditions"), "conditions", UniversalFlagConfigParser::toCondition)); + } + + @Nullable + private static ConditionConfiguration toCondition(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "condition"); + return new ConditionConfiguration( + asEnum(json.get("operator"), ConditionOperator.class, "operator"), + asString(json.get("attribute"), "attribute"), + json.get("value")); + } + + @Nullable + private static Split toSplit(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "split"); + final Object serialId = json.get("serialId"); + return new Split( + toList(json.get("shards"), "shards", UniversalFlagConfigParser::toShard), + asString(json.get("variationKey"), "variationKey"), + toStringMap(json.get("extraLogging")), + serialId == null ? null : asLenientInt(serialId, "serialId")); + } + + @Nullable + private static Shard toShard(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "shard"); + return new Shard( + asString(json.get("salt"), "salt"), + toList(json.get("ranges"), "ranges", UniversalFlagConfigParser::toShardRange), + asUnsignedInt(json.get("totalShards"), "totalShards")); + } + + @Nullable + private static ShardRange toShardRange(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map json = asObject(value, "range"); + return new ShardRange( + asUnsignedInt(json.get("start"), "range start"), + asUnsignedInt(json.get("end"), "range end")); + } + + @Nullable + private static Map toStringMap(@Nullable final Object value) { + if (value == null) { + return null; + } + final Map map = new HashMap<>(); + for (final Map.Entry entry : asObject(value, "extraLogging").entrySet()) { + map.put((String) entry.getKey(), asString(entry.getValue(), (String) entry.getKey())); + } + return map; + } + + @Nullable + private static List toList( + @Nullable final Object value, final String name, final ElementMapper mapper) { + if (value == null) { + return null; + } + if (!(value instanceof List)) { + throw new IllegalArgumentException(name + " is not an array"); + } + final List json = (List) value; + final List list = new ArrayList<>(json.size()); + for (final Object element : json) { + list.add(mapper.map(element)); + } + return list; + } + + private static Map asObject(final Object value, final String name) { + if (!(value instanceof Map)) { + throw new IllegalArgumentException(name + " is not an object"); + } + return (Map) value; + } + + @Nullable + private static String asString(@Nullable final Object value, final String name) { + if (value == null || value instanceof String) { + return (String) value; + } + throw new IllegalArgumentException(name + " is not a string"); + } + + private static boolean strictBoolean(@Nullable final Object value, final String name) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + throw new IllegalArgumentException(name + " is not a boolean"); + } + + /** Maps wrong-typed values to {@code null} so callers fall back to their default. */ + @Nullable + private static Boolean lenientBoolean(@Nullable final Object value) { + return value instanceof Boolean ? (Boolean) value : null; + } + + @Nullable + private static > E asEnum( + @Nullable final Object value, final Class type, final String name) { + final String constant = asString(value, name); + if (constant == null) { + return null; + } + try { + return Enum.valueOf(type, constant); + } catch (final IllegalArgumentException e) { + throw new IllegalArgumentException(name + " has an unknown value " + constant); + } + } + + /** + * Coerces a number, or a numeric string, to an int, truncating fractions and saturating out of + * range values. + */ + private static int asLenientInt(final Object value, final String name) { + if (value instanceof Double) { + return (int) (double) (Double) value; + } + if (value instanceof String) { + try { + return (int) Double.parseDouble((String) value); + } catch (final NumberFormatException e) { + throw new IllegalArgumentException(name + " is not a number"); + } + } + throw new IllegalArgumentException(name + " is not a number"); + } + + private static long asUnsignedInt(@Nullable final Object value, final String name) { + if (value == null) { + return 0; + } + return asIntegral(value, 0, MAX_UNSIGNED_INT, name); + } + + private static long asIntegral( + final Object value, final long min, final long max, final String name) { + if (!(value instanceof Double)) { + throw new IllegalArgumentException(name + " is not a number"); + } + final double number = (Double) value; + if (number != Math.rint(number) || number < min || number > max) { + throw new IllegalArgumentException("flag contains an invalid " + name + " value"); + } + return (long) number; + } + + /** Ignores wrongly set dates. */ + @Nullable + private static Instant toInstant(@Nullable final Object value) { + if (!(value instanceof String)) { + return null; + } + try { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse((String) value, Instant::from); + } catch (final RuntimeException e) { + return null; + } + } + + /** Validates the required nested UFC fields and SemVer comparands for a flag. */ + private static void validateFlag(final String flagKey, final Flag flag) { + if (flag.allocations == null) { + return; + } + for (final Allocation allocation : flag.allocations) { + if (allocation == null) { + continue; + } + validateConditionOperands(flagKey, allocation); + if (allocation.splits == null) { + continue; + } + for (final Split split : allocation.splits) { + if (split == null) { + continue; + } + if (split.shards == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains a split with missing shards"); + } + for (final Shard shard : split.shards) { + if (shard == null || shard.totalShards == 0 || shard.ranges == null) { + throw new IllegalArgumentException("flag \"" + flagKey + "\" contains invalid shards"); + } + for (final ShardRange range : shard.ranges) { + if (range == null) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" contains an invalid shard range"); + } + } + } + } + } + validateAndCacheSemverComparands(flagKey, flag); + } + + private static void validateConditionOperands(final String flagKey, final Allocation allocation) { + if (allocation.rules == null) { + return; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case LT: + case LTE: + case GT: + case GTE: + if (!(condition.value instanceof Number)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-numeric condition"); + } + break; + case ONE_OF: + case NOT_ONE_OF: + if (!(condition.value instanceof List)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-list condition"); + } + break; + case IS_NULL: + if (!(condition.value instanceof Boolean)) { + throw new IllegalArgumentException( + "flag \"" + flagKey + "\" has a non-boolean condition"); + } + break; + default: + break; + } + } + } + } + + /** + * Validates and caches SemVer comparands for all SEMVER_* conditions in a flag. + * + * @throws InvalidSemverComparandException if any condition has an invalid or non-string comparand + * value. + */ + private static void validateAndCacheSemverComparands(final String flagKey, final Flag flag) { + for (int allocIdx = 0; allocIdx < flag.allocations.size(); allocIdx++) { + final Allocation allocation = flag.allocations.get(allocIdx); + if (allocation == null || allocation.rules == null) { + continue; + } + for (final Rule rule : allocation.rules) { + if (rule == null || rule.conditions == null) { + continue; + } + for (final ConditionConfiguration condition : rule.conditions) { + if (condition == null || condition.operator == null) { + continue; + } + switch (condition.operator) { + case SEMVER_EQ: + case SEMVER_NEQ: + case SEMVER_LT: + case SEMVER_LTE: + case SEMVER_GT: + case SEMVER_GTE: + if (!(condition.value instanceof String)) { + throw new InvalidSemverComparandException( + "flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" that requires string value"); + } + final ParsedSemver parsed = ParsedSemver.parse((String) condition.value); + if (parsed == null) { + throw new InvalidSemverComparandException( + "flag \"" + + flagKey + + "\" allocation " + + allocIdx + + " rule has condition with operator \"" + + condition.operator + + "\" and invalid semantic version \"" + + condition.value + + "\""); + } + condition.semverComparand = parsed; + break; + default: + // Non-semver operators are not validated here + break; + } + } + } + } + } + + @FunctionalInterface + private interface ElementMapper { + @Nullable + T map(@Nullable Object value); + } + + /** Thrown when a SEMVER_* condition has an invalid or non-string comparand value. */ + static final class InvalidSemverComparandException extends IllegalArgumentException { + InvalidSemverComparandException(final String message) { + super(message); + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java new file mode 100644 index 00000000000..4b260059ae9 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/ValueType.java @@ -0,0 +1,9 @@ +package com.datadog.openfeature.internal.ufc; + +public enum ValueType { + BOOLEAN, + INTEGER, + NUMERIC, + STRING, + JSON +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java new file mode 100644 index 00000000000..4d9246c5965 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Variant.java @@ -0,0 +1,11 @@ +package com.datadog.openfeature.internal.ufc; + +public final class Variant { + public final String key; + public final Object value; + + public Variant(final String key, final Object value) { + this.key = key; + this.value = value; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java new file mode 100644 index 00000000000..435bf9ac17f --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/ParsedSemverTest.java @@ -0,0 +1,220 @@ +package com.datadog.openfeature.internal.ufc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class ParsedSemverTest { + + // --- Parse tests (ported from Go semver_test.go TestParseSemver) --- + + static Stream validVersions() { + return Stream.of( + Arguments.of("0.0.0", 0L, 0L, 0L, ""), + Arguments.of( + "18446744073709551615.18446744073709551615.18446744073709551615", + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + Long.parseUnsignedLong("18446744073709551615"), + ""), + Arguments.of("1.2.3-alpha.1", 1L, 2L, 3L, "alpha.1"), + Arguments.of("1.2.3-18446744073709551616", 1L, 2L, 3L, "18446744073709551616"), + Arguments.of("1.2.3+build.001", 1L, 2L, 3L, ""), + Arguments.of("1.2.3-alpha-1+build.001", 1L, 2L, 3L, "alpha-1")); + } + + @ParameterizedTest(name = "valid: {0}") + @MethodSource("validVersions") + void testParseValid( + final String version, + final long major, + final long minor, + final long patch, + final String prerelease) { + final ParsedSemver parsed = ParsedSemver.parse(version); + assertTrue(parsed != null, "expected " + version + " to parse"); + assertEquals(major, parsed.getMajor()); + assertEquals(minor, parsed.getMinor()); + assertEquals(patch, parsed.getPatch()); + assertEquals(prerelease, parsed.getPrerelease()); + } + + static Stream invalidVersions() { + return Stream.of( + "", + "x", + "v1.2.3", + "01.2.3", + "1.02.3", + "1.2.03", + "18446744073709551616.0.0", + "0.18446744073709551616.0", + "0.0.18446744073709551616", + "1.2.3-", + "1.2.3+", + "1.2.3-alpha..1", + "1.2.3+build..1", + "1.2.3-01", + "1.2.3-alpha_1", + "1.2.3-alpha+build+other", + "1.2.3-α", + " 1.2.3", + "1.2.3 "); + } + + @ParameterizedTest(name = "invalid: {0}") + @MethodSource("invalidVersions") + void testParseInvalid(final String version) { + assertNull(ParsedSemver.parse(version), "expected " + version + " to be invalid"); + } + + // --- Compare tests (ported from Go semver_test.go TestCompareSemver) --- + + /** The canonical SemVer precedence ordering from the spec. */ + private static final String[] ORDERED_VERSIONS = { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + }; + + @Test + void normalizesMissingCorePartsAndComparesExtendedCoreParts() { + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18"), ParsedSemver.parse("18.0.0"))); + assertEquals(0, ParsedSemver.compare(ParsedSemver.parse("18.0"), ParsedSemver.parse("18.0.0"))); + assertEquals( + 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0"))); + assertEquals( + 0, ParsedSemver.compare(ParsedSemver.parse("1.2.3"), ParsedSemver.parse("1.2.3.0.0"))); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("1.2.3.4"), ParsedSemver.parse("1.2.3.5")) < 0); + assertTrue(ParsedSemver.parse("1.2.3.4.5.6") != null); + assertTrue(ParsedSemver.parse("1.2.3.4.5.6.7") != null); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5"), ParsedSemver.parse("1.2.3.4.6")) < 0); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("1.2.3.4.5.6"), ParsedSemver.parse("1.2.3.4.5.7")) + < 0); + assertTrue( + ParsedSemver.compare( + ParsedSemver.parse("1.2.3.4.5.6.7"), ParsedSemver.parse("1.2.3.4.5.6.8")) + < 0); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("18.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); + assertTrue( + ParsedSemver.compare(ParsedSemver.parse("18.0.0.0.0"), ParsedSemver.parse("17.0.0")) > 0); + } + + @Test + void testCompareSemverOrdering() { + for (int i = 0; i < ORDERED_VERSIONS.length; i++) { + final ParsedSemver left = ParsedSemver.parse(ORDERED_VERSIONS[i]); + assertTrue(left != null, "left parse failed: " + ORDERED_VERSIONS[i]); + for (int j = 0; j < ORDERED_VERSIONS.length; j++) { + final ParsedSemver right = ParsedSemver.parse(ORDERED_VERSIONS[j]); + assertTrue(right != null, "right parse failed: " + ORDERED_VERSIONS[j]); + final int ordering = ParsedSemver.compare(left, right); + if (i < j) { + assertTrue(ordering < 0, ORDERED_VERSIONS[i] + " should precede " + ORDERED_VERSIONS[j]); + } else if (i > j) { + assertTrue(ordering > 0, ORDERED_VERSIONS[i] + " should follow " + ORDERED_VERSIONS[j]); + } else { + assertEquals(0, ordering); + } + } + } + } + + @Test + void testCompareSemverArbitrarilyLargeNumericPrerelease() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-99999999999999999999"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-100000000000000000000"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testValueObjectMethods() { + final ParsedSemver value = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver equalValue = ParsedSemver.parse("1.2.3-alpha"); + final ParsedSemver release = ParsedSemver.parse("1.2.3"); + assertTrue(value != null); + assertTrue(equalValue != null); + assertTrue(release != null); + assertTrue(value.equals(value)); + assertTrue(value.equals(equalValue)); + assertFalse(value.equals(null)); + assertFalse(value.equals("1.2.3-alpha")); + assertEquals(value.hashCode(), equalValue.hashCode()); + assertEquals("1.2.3-alpha", value.toString()); + assertEquals("1.2.3", release.toString()); + } + + @Test + void testCompareSemverNumericPrereleaseIdentifiersLexicographically() { + final ParsedSemver left = ParsedSemver.parse("1.0.0-10"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0-11"); + assertTrue(right != null); + assertTrue(ParsedSemver.compare(left, right) < 0); + } + + @Test + void testEqualsAndHashCodeNormalizeTrailingZeroComponents() { + // Versions that compare as equal must also be equals() and share a hashCode, even when they + // differ only by trailing zero components (1.2.3 vs 1.2.3.0 vs 1.2.3.0.0). + final ParsedSemver base = ParsedSemver.parse("1.2.3"); + final ParsedSemver trailingZero = ParsedSemver.parse("1.2.3.0"); + final ParsedSemver trailingZeros = ParsedSemver.parse("1.2.3.0.0"); + assertTrue(base != null); + assertTrue(trailingZero != null); + assertTrue(trailingZeros != null); + assertEquals(0, ParsedSemver.compare(base, trailingZero)); + assertEquals(0, ParsedSemver.compare(base, trailingZeros)); + assertEquals(base, trailingZero); + assertEquals(base, trailingZeros); + assertEquals(trailingZero, trailingZeros); + assertEquals(base.hashCode(), trailingZero.hashCode()); + assertEquals(base.hashCode(), trailingZeros.hashCode()); + assertEquals(trailingZero.hashCode(), trailingZeros.hashCode()); + + // All-zero versions of varying length are also compare-equal and must hash identically. + final ParsedSemver zero = ParsedSemver.parse("0"); + final ParsedSemver zeroZero = ParsedSemver.parse("0.0.0.0"); + assertTrue(zero != null); + assertTrue(zeroZero != null); + assertEquals(0, ParsedSemver.compare(zero, zeroZero)); + assertEquals(zero, zeroZero); + assertEquals(zero.hashCode(), zeroZero.hashCode()); + + // Distinct versions must remain unequal and may differ in hash. + final ParsedSemver release = ParsedSemver.parse("1.2.4"); + assertTrue(release != null); + assertNotEquals(base, release); + } + + @Test + void testCompareSemverBuildMetadataIsIgnored() { + final ParsedSemver left = ParsedSemver.parse("1.0.0+build.1"); + assertTrue(left != null); + final ParsedSemver right = ParsedSemver.parse("1.0.0+build.2"); + assertTrue(right != null); + assertEquals(0, ParsedSemver.compare(left, right)); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java new file mode 100644 index 00000000000..8a7af915e14 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserJsonApiTest.java @@ -0,0 +1,435 @@ +package com.datadog.openfeature.internal.ufc; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class UniversalFlagConfigParserJsonApiTest { + + @Test + void parsesJsonApiMembersInAnyOrder() throws Exception { + final ServerConfiguration configuration = + parse( + "{" + + "\"meta\":{\"ignored\":true}," + + "\"data\":{" + + "\"attributes\":" + + emptyConfig() + + ",\"ignored\":true," + + "\"type\":\"universal-flag-configuration\"" + + "}" + + "}"); + + assertNotNull(configuration); + assertEquals("Test", configuration.environment.name); + assertTrue(configuration.flags.isEmpty()); + } + + @Test + void rejectsRawUfc() throws Exception { + assertNull(parse(emptyConfig())); + } + + @Test + void rejectsUnexpectedJsonApiType() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"other-type\",\"attributes\":" + emptyConfig() + "}}")); + } + + @Test + void rejectsNonStringJsonApiType() throws Exception { + assertNull(parse("{\"data\":{\"type\":null,\"attributes\":" + emptyConfig() + "}}")); + } + + @Test + void rejectsConfigurationWithoutFlags() throws Exception { + assertNull( + parse( + "{\"data\":{" + + "\"type\":\"universal-flag-configuration\"," + + "\"attributes\":{\"environment\":{\"name\":\"Test\"}}" + + "}}")); + } + + @Test + void rejectsNonObjectData() throws Exception { + assertNull(parse("{\"data\":[]}")); + } + + @Test + void rejectsTrailingJson() { + assertThrows( + IOException.class, + () -> + parse( + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + emptyConfig() + + "}}{}")); + } + + @Test + void preprocessesSemverComparandsAndDropsMalformedFlags() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag("no-allocations", ""), + booleanFlag( + "no-splits", ",\"allocations\":[{\"key\":\"no-splits\",\"rules\":[]}]"), + booleanFlag( + "null-split", + ",\"allocations\":[{\"key\":\"null-split\",\"rules\":[],\"splits\":[null]}]"), + booleanFlag("no-rules", allocation("no-rules", "")), + booleanFlag("no-conditions", allocation("no-conditions", "[{}]")), + booleanFlag( + "no-operator", allocation("no-operator", "[{\"conditions\":[{}]}]")), + booleanFlag( + "non-semver", + allocation( + "non-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"MATCHES\",\"value\":\"1\"}]}]")), + booleanFlag( + "valid-semver", + allocation( + "valid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.2\"}]}]")), + booleanFlag( + "invalid-semver", + allocation( + "invalid-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":\"1.02\"}]}]")), + booleanFlag( + "non-string-semver", + allocation( + "non-string-semver", + "[{\"conditions\":[{\"attribute\":\"version\",\"operator\":\"SEMVER_EQ\",\"value\":1}]}]")), + "\"null-flag\":null"))); + + assertNotNull(configuration); + assertTrue(configuration.flags.containsKey("no-allocations")); + assertTrue(configuration.flags.containsKey("no-splits")); + assertTrue(configuration.flags.containsKey("null-split")); + assertTrue(configuration.flags.containsKey("no-rules")); + assertTrue(configuration.flags.containsKey("no-conditions")); + assertTrue(configuration.flags.containsKey("no-operator")); + assertTrue(configuration.flags.containsKey("non-semver")); + assertTrue(configuration.flags.containsKey("valid-semver")); + assertFalse(configuration.flags.containsKey("invalid-semver")); + assertFalse(configuration.flags.containsKey("non-string-semver")); + assertFalse(configuration.flags.containsKey("null-flag")); + assertEquals(2, configuration.invalidFlags.size()); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("invalid-semver")); + assertEquals("invalid_semver_comparand", configuration.invalidFlags.get("non-string-semver")); + + assertNotNull( + configuration + .flags + .get("valid-semver") + .allocations + .get(0) + .rules + .get(0) + .conditions + .get(0) + .semverComparand); + } + + @Test + void dropsFlagWithMalformedAllocationsWithoutRejectingConfig() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag("malformed-allocations", ",\"allocations\":\"not-a-list\""), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("malformed-allocations")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("malformed-allocations")); + } + + @Test + void dropsFlagWithMissingSplitShards() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "missing-shards", + ",\"allocations\":[{\"key\":\"missing-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\"}]}]"), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("missing-shards")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("missing-shards")); + } + + @Test + void dropsFlagsWithInvalidConditionOperandsAndShardBounds() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "non-numeric-gt", + allocation( + "non-numeric-gt", + "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"GT\",\"value\":\"bad\"}]}]")), + booleanFlag( + "non-list-one-of", + allocation( + "non-list-one-of", + "[{\"conditions\":[{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":\"admin\"}]}]")), + booleanFlag( + "negative-shard-range", + ",\"allocations\":[{\"key\":\"negative-shard-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":100,\"ranges\":[{\"start\":-1,\"end\":1}]}]}]}]"), + booleanFlag("valid-sibling", "")))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("non-numeric-gt")); + assertFalse(configuration.flags.containsKey("non-list-one-of")); + assertFalse(configuration.flags.containsKey("negative-shard-range")); + assertTrue(configuration.flags.containsKey("valid-sibling")); + assertEquals("invalid_flag", configuration.invalidFlags.get("non-numeric-gt")); + assertEquals("invalid_flag", configuration.invalidFlags.get("non-list-one-of")); + assertEquals("invalid_flag", configuration.invalidFlags.get("negative-shard-range")); + } + + @Test + void dropsFlagsWithInvalidShardBoundsAndConditionOperands() throws Exception { + final ServerConfiguration configuration = + parse( + wrap( + configWithFlags( + booleanFlag( + "zero-total-shards", + ",\"allocations\":[{\"key\":\"zero-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":0,\"ranges\":[]}]}]}]"), + booleanFlag( + "unsigned-total-shards", + ",\"allocations\":[{\"key\":\"unsigned-total-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":2147483648,\"ranges\":[{\"start\":2147483648,\"end\":2147483649}]}]}]}]"), + booleanFlag( + "too-many-shards", + ",\"allocations\":[{\"key\":\"too-many-shards\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":4294967296,\"ranges\":[]}]}]}]"), + booleanFlag( + "missing-ranges", + ",\"allocations\":[{\"key\":\"missing-ranges\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1}]}]}]"), + booleanFlag( + "null-shard", + ",\"allocations\":[{\"key\":\"null-shard\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[null]}]}]"), + booleanFlag( + "null-range", + ",\"allocations\":[{\"key\":\"null-range\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[null]}]}]}]"), + booleanFlag( + "negative-range-end", + ",\"allocations\":[{\"key\":\"negative-range-end\",\"rules\":[],\"splits\":[{\"variationKey\":\"on\",\"shards\":[{\"salt\":\"salt\",\"totalShards\":1,\"ranges\":[{\"start\":0,\"end\":-1}]}]}]}]"), + booleanFlag( + "non-boolean-is-null", + allocation( + "non-boolean-is-null", + "[{\"conditions\":[{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":\"false\"}]}]")), + booleanFlag( + "valid-condition-operands", + allocation( + "valid-condition-operands", + "[{\"conditions\":[{\"attribute\":\"age\",\"operator\":\"LT\",\"value\":1},{\"attribute\":\"role\",\"operator\":\"ONE_OF\",\"value\":[\"admin\"]},{\"attribute\":\"enabled\",\"operator\":\"IS_NULL\",\"value\":true}]}]"))))); + + assertNotNull(configuration); + assertFalse(configuration.flags.containsKey("zero-total-shards")); + assertTrue(configuration.flags.containsKey("unsigned-total-shards")); + assertEquals( + 2_147_483_648L, + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .totalShards); + assertEquals( + 2_147_483_648L, + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .start); + assertEquals( + 2_147_483_649L, + configuration + .flags + .get("unsigned-total-shards") + .allocations + .get(0) + .splits + .get(0) + .shards + .get(0) + .ranges + .get(0) + .end); + assertFalse(configuration.flags.containsKey("too-many-shards")); + assertFalse(configuration.flags.containsKey("missing-ranges")); + assertFalse(configuration.flags.containsKey("null-shard")); + assertFalse(configuration.flags.containsKey("null-range")); + assertFalse(configuration.flags.containsKey("negative-range-end")); + assertFalse(configuration.flags.containsKey("non-boolean-is-null")); + assertTrue(configuration.flags.containsKey("valid-condition-operands")); + } + + @Test + void nullAttributesAreRejectedWithoutInvokingTheFlagParser() throws Exception { + assertNull(parse("{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":null}}")); + } + + @Test + void observeFullEvaluationDataDefaultsToFalseWhenAbsent() throws Exception { + // Absent → the parser leaves the boxed field null; the read site's Boolean.TRUE.equals(...) + // then + // resolves to the privacy-preserving default (consent-off). Either null-or-false is the + // documented invariant; assert the field never reads as true. + final ServerConfiguration configuration = parse(wrap(emptyConfig())); + assertNotNull(configuration); + assertFalse(Boolean.TRUE.equals(configuration.observeFullEvaluationData)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void observeFullEvaluationDataParsesExplicitValue(final boolean value) throws Exception { + final ServerConfiguration configuration = + parse(wrap(configWithObserveFullEvaluationData(value))); + assertNotNull(configuration); + assertEquals(value, configuration.observeFullEvaluationData); + } + + @Test + void observeFullEvaluationDataExplicitNullDefaultsToFalseWithoutRejectingConfig() + throws Exception { + // An explicit null (or a wrong-typed value) for this field must not abort the whole UFC parse. + // A pod that starts after a malformed UFC has no last-known-good, so aborting would strand + // every flag on PROVIDER_NOT_READY (its default value). We fail closed on privacy (consent + // stays false) but preserve availability: flags parse and evaluate. + final ServerConfiguration configuration = + parse(wrap(configWithRawObserveFullEvaluationData("null"))); + + assertNotNull(configuration); + assertFalse( + configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + @Test + void observeFullEvaluationDataWrongTypedStringDefaultsToFalse() throws Exception { + // The parser tolerates a stringified boolean like "true": it leaves the field null. Either way, + // downstream reads + // via Boolean.TRUE.equals(...) treat it as consent-off. The rest of the config must parse. + final ServerConfiguration configuration = + parse(wrap(configWithRawObserveFullEvaluationData("\"true\""))); + + assertNotNull(configuration); + assertFalse( + configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + @Test + void observeFullEvaluationDataWrongTypedNumberDefaultsToFalse() throws Exception { + final ServerConfiguration configuration = + parse(wrap(configWithRawObserveFullEvaluationData("1"))); + + assertNotNull(configuration); + assertFalse( + configuration.observeFullEvaluationData != null && configuration.observeFullEvaluationData); + assertNotNull(configuration.flags); + } + + private static ServerConfiguration parse(final String json) throws Exception { + return UniversalFlagConfigParser.parseJsonApi(json.getBytes(UTF_8)); + } + + private static String wrap(final String attributes) { + return "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + attributes + + "}}"; + } + + private static String configWithObserveFullEvaluationData(final boolean value) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"observeFullEvaluationData\":" + + value + + "," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } + + private static String configWithRawObserveFullEvaluationData(final String rawJsonValue) { + // Emit the field with a caller-controlled raw JSON value (null / "true" / 1 / ...) so we can + // assert the parser's tolerance of malformed shapes without going through configWith's + // boolean-typed helper. + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"observeFullEvaluationData\":" + + rawJsonValue + + "," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } + + private static String configWithFlags(final String... flags) { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{" + + String.join(",", flags) + + "}}"; + } + + private static String booleanFlag(final String key, final String allocations) { + return "\"" + + key + + "\":{" + + "\"key\":\"" + + key + + "\",\"enabled\":true,\"variationType\":\"BOOLEAN\"," + + "\"variations\":{\"on\":{\"key\":\"on\",\"value\":true}}" + + allocations + + "}"; + } + + private static String allocation(final String key, final String rules) { + return ",\"allocations\":[{\"key\":\"" + + key + + "\"" + + (rules.isEmpty() ? "" : ",\"rules\":" + rules) + + ",\"splits\":[]}]"; + } + + private static String emptyConfig() { + return "{" + + "\"createdAt\":\"2024-04-17T19:40:53.716Z\"," + + "\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}" + + "}"; + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java new file mode 100644 index 00000000000..b4a98c417fb --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -0,0 +1,273 @@ +package com.datadog.openfeature.internal.ufc; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.tabletest.junit.TableTest; + +/** Parses raw UFC documents, as delivered by Remote Configuration. */ +class UniversalFlagConfigParserTest { + + @Test + void parsesEmptyConfiguration() throws Exception { + final ServerConfiguration config = deserialize(emptyConfig()); + + assertNotNull(config); + assertTrue(config.flags.isEmpty()); + assertTrue(config.invalidFlags.isEmpty()); + } + + @Test + void parsesNullDocumentAsNoConfiguration() throws Exception { + assertNull(deserialize("null")); + } + + @Test + void rejectsNonObjectDocument() { + assertThrows(IOException.class, () -> deserialize("[]")); + } + + @Test + void rejectsDuplicateKeys() { + assertThrows(IOException.class, () -> deserialize("{\"format\":\"a\",\"format\":\"b\"}")); + } + + @Test + void rejectsWrongTypedFlagMap() { + assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); + } + + @Test + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":\"yes\"}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}" + + "}}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.get("good").enabled); + } + + @Test + void readsVariantNumbersAsDoubles() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true,\"variationType\":\"INTEGER\"," + + "\"variations\":{\"v\":{\"key\":\"v\",\"value\":3}}}}}"); + + assertEquals(3.0, config.flags.get("f").variations.get("v").value); + } + + @Test + void skipsMalformedFlagAllocationsAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("malformed-allocations.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void parsesSplitSerialId() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("340132")); + + assertNotNull(config); + assertEquals(Integer.valueOf(340132), serialIdOf(config)); + } + + @Test + void parsesSplitSerialIdZero() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("0")); + + assertNotNull(config); + assertEquals(Integer.valueOf(0), serialIdOf(config)); + } + + @Test + void parsesAbsentSplitSerialIdAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId(null)); + + assertNotNull(config); + assertNull(serialIdOf(config)); + } + + @Test + void parsesNullSplitSerialIdAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId("null")); + + assertNotNull(config); + assertNull(serialIdOf(config)); + } + + @Test + void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { + final ServerConfiguration config = deserialize(configWithSiblingSerialIds("true")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("malformed-flag")); + assertEquals("invalid_flag", config.invalidFlags.get("malformed-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + /** + * Records how leniently the per-flag value reader coerces a serial id, so a future change to the + * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no + * validation of its own; what matters is that a bad one never rejects the sibling flag. + */ + @ParameterizedTest + @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) + void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) + throws Exception { + final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); + + assertNotNull(config); + assertEquals(Integer.valueOf(expected), serialIdOf(config)); + } + + private static Integer serialIdOf(final ServerConfiguration config) { + return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; + } + + private static String configWithSerialId(final String serialIdJson) throws IOException { + return withSerialId(resource("serial-id.json"), serialIdJson); + } + + /** A malformed serial id must bind to its own flag and leave the sibling flag intact. */ + private static String configWithSiblingSerialIds(final String malformedSerialIdJson) + throws IOException { + return withSerialId(resource("sibling-serial-ids.json"), malformedSerialIdJson); + } + + /** Inserts the raw wire value so the parser sees its original JSON type; null omits the key. */ + private static String withSerialId(final String json, final String serialIdJson) { + return serialIdJson == null + ? json.replace("\"serialId\": \"${serialId}\",", "") + : json.replace("\"${serialId}\"", serialIdJson); + } + + @Test + void ignoresUnknownTopLevelFields() throws Exception { + final ServerConfiguration config = deserialize(resource("unknown-top-level-fields.json")); + + assertNotNull(config); + assertEquals("2024-04-17T19:40:53.716Z", config.createdAt); + assertEquals("SERVER", config.format); + assertNotNull(config.environment); + assertEquals("Test", config.environment.name); + assertTrue(config.flags.isEmpty()); + } + + @Test + void parsesAllocationWindowDatesWithMicrosecondPrecision() throws Exception { + final ServerConfiguration config = deserialize(resource("allocation-window-dates.json")); + + final Allocation allocation = config.flags.get("dated-flag").allocations.get(0); + assertEquals(Instant.parse("2023-01-01T00:00:00.123456Z"), allocation.startAt); + assertEquals(Instant.parse("2023-01-02T00:00:00.987654Z"), allocation.endAt); + } + + @Test + void rejectsTrailingJson() { + assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); + } + + @Test + void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("unknown-operator.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("operator-grease-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @Test + void allowsNullFlagMap() throws Exception { + final ServerConfiguration config = deserialize(resource("null-flags.json")); + + assertNotNull(config); + assertNull(config.flags); + } + + @Test + void skipsNullFlagAndKeepsValidFlag() throws Exception { + final ServerConfiguration config = deserialize(resource("null-flag.json")); + + assertNotNull(config); + assertFalse(config.flags.containsKey("null-flag")); + assertTrue(config.flags.containsKey("valid-flag")); + assertEquals("expected", config.flags.get("valid-flag").variations.get("expected").value); + } + + @TableTest({ + "scenario | value | expectedInstant ", + "utc second | '2023-01-01T00:00:00Z' | '2023-01-01T00:00:00Z' ", + "utc end of year | '2023-12-31T23:59:59Z' | '2023-12-31T23:59:59Z' ", + "leap day | '2024-02-29T12:00:00Z' | '2024-02-29T12:00:00Z' ", + "millisecond precision | '2023-01-01T00:00:00.000Z' | '2023-01-01T00:00:00Z' ", + "three fractional digits | '2023-06-15T14:30:45.123Z' | '2023-06-15T14:30:45.123Z' ", + "six fractional digits | '2023-06-15T14:30:45.123456Z' | '2023-06-15T14:30:45.123456Z' ", + "six fractional digits distinct | '2023-06-15T14:30:45.235982Z' | '2023-06-15T14:30:45.235982Z' ", + "nine fractional digits | '2023-06-15T14:30:45.123456789Z' | '2023-06-15T14:30:45.123456789Z'", + "one fractional digit | '2023-06-15T14:30:45.1Z' | '2023-06-15T14:30:45.100Z' ", + "two fractional digits | '2023-06-15T14:30:45.12Z' | '2023-06-15T14:30:45.120Z' ", + "positive offset | '2023-01-01T01:00:00+01:00' | '2023-01-01T00:00:00Z' ", + "negative offset | '2023-01-01T00:00:00-05:00' | '2023-01-01T05:00:00Z' ", + "date only | '2023-01-01' | ", + "invalid | 'invalid-date' | ", + "empty string | '' | ", + "not a date | 'not-a-date' | ", + "slash date | '2023/01/01T00:00:00Z' | ", + "null | | " + }) + void testInstantParsing(final String value, final String expectedInstant) throws Exception { + final String startAt = value == null ? "null" : "\"" + value + "\""; + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"startAt\":" + + startAt + + "}]}}}"); + + final Instant parsed = config.flags.get("f").allocations.get(0).startAt; + if (expectedInstant == null) { + assertNull(parsed); + } else { + assertNotNull(parsed); + assertEquals(expectedInstant, parsed.toString()); + } + } + + private static ServerConfiguration deserialize(final String json) throws Exception { + return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); + } + + private static String emptyConfig() throws IOException { + return resource("empty-config.json"); + } + + private static String resource(final String name) throws IOException { + try (InputStream stream = + UniversalFlagConfigParserTest.class.getResourceAsStream("/remote-config/" + name)) { + assertNotNull(stream, "Missing remote-config fixture: " + name); + return new String(stream.readAllBytes(), UTF_8); + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json b/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json new file mode 100644 index 00000000000..26d0a90da2a --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/allocation-window-dates.json @@ -0,0 +1,35 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "dated-flag": { + "key": "dated-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "dated-allocation", + "rules": [], + "startAt": "2023-01-01T01:00:00.123456+01:00", + "endAt": "2023-01-02T00:00:00.987654Z", + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/empty-config.json b/dd-openfeature/src/test/resources/remote-config/empty-config.json new file mode 100644 index 00000000000..51b021e5de7 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/empty-config.json @@ -0,0 +1,8 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": {} +} diff --git a/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json b/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json new file mode 100644 index 00000000000..93314b69e7d --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/malformed-allocations.json @@ -0,0 +1,45 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "malformed-flag": { + "key": "malformed-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "on": { + "key": "on", + "value": "on" + } + }, + "allocations": "this-is-not-a-list" + }, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/null-flag.json b/dd-openfeature/src/test/resources/remote-config/null-flag.json new file mode 100644 index 00000000000..a4f3a6c2502 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/null-flag.json @@ -0,0 +1,34 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "null-flag": null, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/null-flags.json b/dd-openfeature/src/test/resources/remote-config/null-flags.json new file mode 100644 index 00000000000..a083526e553 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/null-flags.json @@ -0,0 +1,8 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": null +} diff --git a/dd-openfeature/src/test/resources/remote-config/serial-id.json b/dd-openfeature/src/test/resources/remote-config/serial-id.json new file mode 100644 index 00000000000..6b7dc926854 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/serial-id.json @@ -0,0 +1,34 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": "${serialId}", + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json b/dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json new file mode 100644 index 00000000000..aa791903f66 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/sibling-serial-ids.json @@ -0,0 +1,59 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "malformed-flag": { + "key": "malformed-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "on": { + "key": "on", + "value": "on" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": "${serialId}", + "variationKey": "on", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": 7, + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/unknown-operator.json b/dd-openfeature/src/test/resources/remote-config/unknown-operator.json new file mode 100644 index 00000000000..a380463b1d5 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/unknown-operator.json @@ -0,0 +1,67 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "operator-grease-flag": { + "key": "operator-grease-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "trap": { + "key": "trap", + "value": "trap" + } + }, + "allocations": [ + { + "key": "grease-allocation", + "rules": [ + { + "conditions": [ + { + "attribute": "country", + "operator": "not-a-real-operator", + "value": "anything" + } + ] + } + ], + "splits": [ + { + "variationKey": "trap", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} diff --git a/dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json b/dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json new file mode 100644 index 00000000000..fe23d4a46b1 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/unknown-top-level-fields.json @@ -0,0 +1,13 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "segments": { + "new-schema-key": { + "ignored": true + } + }, + "flags": {} +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f106f80c147..5ca41e88055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -163,6 +163,7 @@ jul-to-slf4j = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } # JSON +jackson-core = {module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson"} jackson-databind = {module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"} moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 67fbb3ad7a0..86a6b18b058 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,7 @@ rootProject.name = "dd-trace-java" // external apis include( + ":dd-openfeature", ":dd-trace-api", ":dd-trace-ot", ":dd-trace-ot:correlation-id-injection", From 3f78321ef77ec8139944223477c891eba9f1cd3c Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:01:08 +0200 Subject: [PATCH 2/8] refactor(ffe): Drop EvaluationMetadata in favor of OpenFeature metadata The evaluator built its own metadata type that the provider copied into an OpenFeature ImmutableMetadata on every evaluation. The SDK already depends on the OpenFeature SDK, so build ImmutableMetadata directly. Co-Authored-By: Claude Opus 5.5 --- .../evaluation/EvaluationMetadata.java | 50 ------------------- .../internal/evaluation/EvaluationResult.java | 10 ++-- .../internal/evaluation/FlagEvaluator.java | 9 ++-- 3 files changed, 11 insertions(+), 58 deletions(-) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java deleted file mode 100644 index b1b17d91501..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationMetadata.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -/** Immutable evaluation metadata, independent of the public OpenFeature SDK. */ -public final class EvaluationMetadata { - private final Map values; - - private EvaluationMetadata(final Map values) { - this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); - } - - public Map values() { - return values; - } - - public static EvaluationMetadataBuilder builder() { - return new EvaluationMetadataBuilder(); - } - - public static final class EvaluationMetadataBuilder { - private final Map values = new LinkedHashMap<>(); - - public EvaluationMetadataBuilder addString(String key, String value) { - values.put(key, value); - return this; - } - - public EvaluationMetadataBuilder addBoolean(String key, boolean value) { - values.put(key, value); - return this; - } - - public EvaluationMetadataBuilder addInteger(String key, int value) { - values.put(key, value); - return this; - } - - public EvaluationMetadataBuilder addLong(String key, long value) { - values.put(key, value); - return this; - } - - public EvaluationMetadata build() { - return new EvaluationMetadata(values); - } - } -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java index e83edfd9355..d542171ecb2 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java @@ -1,5 +1,7 @@ package com.datadog.openfeature.internal.evaluation; +import dev.openfeature.sdk.ImmutableMetadata; + /** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ public final class EvaluationResult { public enum ErrorCode { @@ -26,7 +28,7 @@ public enum Reason { private final String variant; private final ErrorCode errorCode; private final String errorMessage; - private final EvaluationMetadata metadata; + private final ImmutableMetadata metadata; private final boolean logExposure; private final Integer serialId; @@ -61,7 +63,7 @@ public String getErrorMessage() { return errorMessage; } - public EvaluationMetadata getFlagMetadata() { + public ImmutableMetadata getFlagMetadata() { return metadata; } @@ -86,7 +88,7 @@ public static final class Builder { private String variant; private ErrorCode errorCode; private String errorMessage; - private EvaluationMetadata metadata; + private ImmutableMetadata metadata; private boolean logExposure; private Integer serialId; @@ -115,7 +117,7 @@ public Builder errorMessage(String errorMessage) { return this; } - public Builder flagMetadata(EvaluationMetadata metadata) { + public Builder flagMetadata(ImmutableMetadata metadata) { this.metadata = metadata; return this; } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index ac0c16675b5..ff5b9bd4558 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -16,6 +16,7 @@ import com.datadog.openfeature.internal.ufc.Split; import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.ImmutableMetadata; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -168,8 +169,8 @@ public EvaluationResult evaluate( } } - private static EvaluationMetadata consentMetadata(final boolean observeFullEvaluationData) { - return EvaluationMetadata.builder() + private static ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { + return ImmutableMetadata.builder() .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) .build(); } @@ -434,8 +435,8 @@ private EvaluationResult resolveVariant( // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". - final EvaluationMetadata.EvaluationMetadataBuilder metadataBuilder = - EvaluationMetadata.builder() + final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = + ImmutableMetadata.builder() .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) .addString("allocationKey", allocation.key) From ce7116636932c185b13c922026a607646dcdb2dc Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 11:24:31 +0200 Subject: [PATCH 3/8] refactor(ffe): Evaluate flags on OpenFeature types directly Drop the EvaluationContext and EvaluationResult mirrors of the OpenFeature types: the evaluator takes the OpenFeature context and returns a ProviderEvaluation, removing the per-evaluation context adapter and result conversion. Exposures are reported through an ExposureListener at resolution time. Co-Authored-By: Claude Opus 5.5 --- .../evaluation/EvaluationContext.java | 10 -- .../internal/evaluation/EvaluationResult.java | 139 ------------------ .../internal/evaluation/FlagEvaluator.java | 69 ++++++--- 3 files changed, 46 insertions(+), 172 deletions(-) delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java delete mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java deleted file mode 100644 index 0f4112b2f3d..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationContext.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -/** Lazy application context; adapters retain their public SDK's value-conversion rules. */ -public interface EvaluationContext { - String getTargetingKey(); - - boolean hasAttribute(String name); - - Object attribute(String name); -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java deleted file mode 100644 index d542171ecb2..00000000000 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/EvaluationResult.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.datadog.openfeature.internal.evaluation; - -import dev.openfeature.sdk.ImmutableMetadata; - -/** A resolved value plus product metadata. Transport and SDK adapters consume this result. */ -public final class EvaluationResult { - public enum ErrorCode { - PROVIDER_NOT_READY, - INVALID_CONTEXT, - FLAG_NOT_FOUND, - TARGETING_KEY_MISSING, - TYPE_MISMATCH, - PARSE_ERROR, - GENERAL - } - - public enum Reason { - DISABLED, - DEFAULT, - ERROR, - TARGETING_MATCH, - SPLIT, - STATIC - } - - private final T value; - private final String reason; - private final String variant; - private final ErrorCode errorCode; - private final String errorMessage; - private final ImmutableMetadata metadata; - private final boolean logExposure; - private final Integer serialId; - - private EvaluationResult(final Builder builder) { - value = builder.value; - reason = builder.reason; - variant = builder.variant; - errorCode = builder.errorCode; - errorMessage = builder.errorMessage; - metadata = builder.metadata; - logExposure = builder.logExposure; - serialId = builder.serialId; - } - - public T getValue() { - return value; - } - - public String getReason() { - return reason; - } - - public String getVariant() { - return variant; - } - - public ErrorCode getErrorCode() { - return errorCode; - } - - public String getErrorMessage() { - return errorMessage; - } - - public ImmutableMetadata getFlagMetadata() { - return metadata; - } - - public boolean isLogExposure() { - return logExposure; - } - - /** - * @return the serial id of the resolved split, or {@code null} if absent. - */ - public Integer getSerialId() { - return serialId; - } - - public static Builder builder() { - return new Builder<>(); - } - - public static final class Builder { - private T value; - private String reason; - private String variant; - private ErrorCode errorCode; - private String errorMessage; - private ImmutableMetadata metadata; - private boolean logExposure; - private Integer serialId; - - public Builder value(T value) { - this.value = value; - return this; - } - - public Builder reason(String reason) { - this.reason = reason; - return this; - } - - public Builder variant(String variant) { - this.variant = variant; - return this; - } - - public Builder errorCode(ErrorCode errorCode) { - this.errorCode = errorCode; - return this; - } - - public Builder errorMessage(String errorMessage) { - this.errorMessage = errorMessage; - return this; - } - - public Builder flagMetadata(ImmutableMetadata metadata) { - this.metadata = metadata; - return this; - } - - public Builder serialId(Integer serialId) { - this.serialId = serialId; - return this; - } - - public Builder logExposure(boolean logExposure) { - this.logExposure = logExposure; - return this; - } - - public EvaluationResult build() { - return new EvaluationResult<>(this); - } - } -} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java index ff5b9bd4558..96fa1f90829 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -2,8 +2,6 @@ import static java.util.Arrays.asList; -import com.datadog.openfeature.internal.evaluation.EvaluationResult.ErrorCode; -import com.datadog.openfeature.internal.evaluation.EvaluationResult.Reason; import com.datadog.openfeature.internal.ufc.Allocation; import com.datadog.openfeature.internal.ufc.ConditionConfiguration; import com.datadog.openfeature.internal.ufc.ConditionOperator; @@ -16,7 +14,12 @@ import com.datadog.openfeature.internal.ufc.Split; import com.datadog.openfeature.internal.ufc.ValueType; import com.datadog.openfeature.internal.ufc.Variant; +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -28,30 +31,31 @@ import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; -/** - * Local evaluation over a single configuration snapshot. No transport or OpenFeature dependency. - */ +/** Local evaluation over a single configuration snapshot. No transport dependency. */ public final class FlagEvaluator { private static final Set> SUPPORTED_RESOLUTION_TYPES = - new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Object.class)); + new HashSet<>(asList(String.class, Boolean.class, Integer.class, Double.class, Value.class)); private static final String METADATA_OBSERVE_FULL_EVALUATION_DATA = "observe_full_evaluation_data"; private static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; private static final String METADATA_DO_LOG = "__dd_do_log"; private final boolean spanEnrichmentEnabled; + private final ExposureListener exposureListener; - public FlagEvaluator(final boolean spanEnrichmentEnabled) { + public FlagEvaluator( + final boolean spanEnrichmentEnabled, final ExposureListener exposureListener) { this.spanEnrichmentEnabled = spanEnrichmentEnabled; + this.exposureListener = exposureListener; } - public EvaluationResult evaluate( + public ProviderEvaluation evaluate( final ServerConfiguration config, final Class target, final String key, final T defaultValue, final EvaluationContext context) { // Snapshot the config once and thread observeFullEvaluationData through every - // EvaluationResult returned, so the hook's consent decision is pinned to this evaluation's + // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's // config and cannot drift on a concurrent Remote Config swap. // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. @@ -79,7 +83,7 @@ public EvaluationResult evaluate( } if (!flag.enabled) { - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DISABLED.name()) .flagMetadata(consentMetadata(observeFullEvaluationData)) @@ -154,7 +158,7 @@ public EvaluationResult evaluate( } } - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.DEFAULT.name()) .flagMetadata(consentMetadata(observeFullEvaluationData)) @@ -175,17 +179,17 @@ private static ImmutableMetadata consentMetadata(final boolean observeFullEvalua .build(); } - private static EvaluationResult error( + private static ProviderEvaluation error( final T defaultValue, final ErrorCode code, final String errorMessage, final boolean observeFullEvaluationData) { // Under consent-off the errorMessage is dropped: exception messages from the outer catch blocks // (NumberFormatException, generic Exception) can echo raw evaluation-context values, so they - // must never reach any consumer of EvaluationResult.getErrorMessage() — not just our own + // must never reach any consumer of ProviderEvaluation.getErrorMessage() — not just our own // wire hook. Downstream (FlagEvalLoggingHook) falls back to ErrorCode.name(), so operators // still get a stable signal like "TYPE_MISMATCH". - return EvaluationResult.builder() + return ProviderEvaluation.builder() .value(defaultValue) .reason(Reason.ERROR.name()) .errorCode(code) @@ -386,7 +390,7 @@ private static String getMD5Hash(final String input) { } } - private EvaluationResult resolveVariant( + private ProviderEvaluation resolveVariant( final Class target, final String key, final T defaultValue, @@ -453,8 +457,8 @@ private EvaluationResult resolveVariant( } metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); } - final EvaluationResult result = - EvaluationResult.builder() + final ProviderEvaluation result = + ProviderEvaluation.builder() .value(mappedValue) .reason( !isEmpty(allocation.rules) @@ -464,18 +468,19 @@ private EvaluationResult resolveVariant( : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) .variant(variant.key) .flagMetadata(metadataBuilder.build()) - .logExposure(Boolean.TRUE.equals(allocation.doLog)) - .serialId(split.serialId) .build(); + if (Boolean.TRUE.equals(allocation.doLog)) { + exposureListener.onExposure(key, result, split.serialId, context); + } return result; } private static Object resolveAttribute(final String name, final EvaluationContext context) { // Special handling for "id" attribute: if not explicitly provided, use targeting key - if ("id".equals(name) && !context.hasAttribute(name)) { + if ("id".equals(name) && !context.keySet().contains(name)) { return context.getTargetingKey(); } - return context.attribute(name); + return context.convertValue(context.getValue(name)); } public static boolean isTypeCompatible(final Class target, final ValueType variationType) { @@ -492,7 +497,7 @@ public static boolean isTypeCompatible(final Class target, final ValueType va case NUMERIC: return target == Double.class; case JSON: - return target == Object.class; + return target == Value.class; default: return true; // Unknown types pass through — mapValue errors caught as GENERAL } @@ -526,7 +531,7 @@ public static T mapValue(final Class target, final Object value) { final Double number = parseDouble(value); return (T) number; } - return (T) value; + return (T) Value.objectToValue(value); } private static Double parseDouble(final Object value) { @@ -536,6 +541,24 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } + /** Receives the evaluations whose allocation logs exposures. */ + @FunctionalInterface + public interface ExposureListener { + /** + * Called when an evaluation resolves to an allocation logging exposures. + * + * @param flagKey the evaluated flag key. + * @param evaluation the evaluation result. + * @param serialId the split serial id, or {@code null} if absent. + * @param context the evaluation context. + */ + void onExposure( + String flagKey, + ProviderEvaluation evaluation, + Integer serialId, + EvaluationContext context); + } + @FunctionalInterface private interface NumberComparator { boolean compare(double a, double b); From 3bc8ee17dc518702f44d10819ab570d02093525f Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 12:02:49 +0200 Subject: [PATCH 4/8] fix(ffe): Reject flags with an explicit null enabled field An explicit "enabled": null was read as false, keeping the malformed flag as disabled instead of reporting it invalid. Only an absent field now defaults to false, as with the previous Moshi parsing. Also convert the serial id coercion cases to @TableTest. Co-Authored-By: Claude Opus 5.5 --- .../ufc/UniversalFlagConfigParser.java | 8 +++-- .../ufc/UniversalFlagConfigParserTest.java | 33 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index fb67e8c19ac..4155de6bd29 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -188,7 +188,7 @@ private static Flag toFlag(@Nullable final Object value) { final Map json = asObject(value, "flag"); return new Flag( asString(json.get("key"), "key"), - strictBoolean(json.get("enabled"), "enabled"), + strictBoolean(json, "enabled"), asEnum(json.get("variationType"), ValueType.class, "variationType"), toVariants(json.get("variations")), toList(json.get("allocations"), "allocations", UniversalFlagConfigParser::toAllocation)); @@ -333,10 +333,12 @@ private static String asString(@Nullable final Object value, final String name) throw new IllegalArgumentException(name + " is not a string"); } - private static boolean strictBoolean(@Nullable final Object value, final String name) { - if (value == null) { + /** Reads a required boolean field: absent reads {@code false}, explicit {@code null} rejects. */ + private static boolean strictBoolean(final Map json, final String name) { + if (!json.containsKey(name)) { return false; } + final Object value = json.get(name); if (value instanceof Boolean) { return (Boolean) value; } diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index b4a98c417fb..00b9a757e7c 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -12,8 +12,6 @@ import java.io.InputStream; import java.time.Instant; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.tabletest.junit.TableTest; /** Parses raw UFC documents, as delivered by Remote Configuration. */ @@ -48,12 +46,19 @@ void rejectsWrongTypedFlagMap() { assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); } - @Test - void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { + @TableTest({ + "scenario | enabled ", + "string | '\"yes\"'", + "number | '1' ", + "null | 'null' " + }) + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag(final String enabled) throws Exception { final ServerConfiguration config = deserialize( "{\"flags\":{" - + "\"bad\":{\"key\":\"bad\",\"enabled\":\"yes\"}," + + "\"bad\":{\"key\":\"bad\",\"enabled\":" + + enabled + + "}," + "\"good\":{\"key\":\"good\",\"enabled\":true}" + "}}"); @@ -63,6 +68,15 @@ void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag() throws Exception { assertTrue(config.flags.get("good").enabled); } + @Test + void readsAbsentEnabledAsDisabled() throws Exception { + final ServerConfiguration config = deserialize("{\"flags\":{\"f\":{\"key\":\"f\"}}}"); + + assertNotNull(config); + assertFalse(config.flags.get("f").enabled); + assertTrue(config.invalidFlags.isEmpty()); + } + @Test void readsVariantNumbersAsDoubles() throws Exception { final ServerConfiguration config = @@ -131,8 +145,13 @@ void skipsFlagWithUncoercibleSerialIdAndKeepsSiblingFlag() throws Exception { * parse path is visible here. The values come from the compiler-validated UFC, so the SDK adds no * validation of its own; what matters is that a bad one never rejects the sibling flag. */ - @ParameterizedTest - @CsvSource({"\"340132\", 340132", "1.5, 1", "-1, -1", "2147483648, 2147483647"}) + @TableTest({ + "scenario | wireValue | expected ", + "numeric string | '\"340132\"' | 340132 ", + "fraction truncated | '1.5' | 1 ", + "negative | '-1' | -1 ", + "overflow saturates | '2147483648' | 2147483647" + }) void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int expected) throws Exception { final ServerConfiguration config = deserialize(configWithSerialId(wireValue)); From 468e041ee82d04c7231e16e1297ea397759b254a Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 12:24:55 +0200 Subject: [PATCH 5/8] fix(ffe): Reject flags with non-string allocation dates A boolean, object or array startAt / endAt was read as null, silently removing the allocation bound. Reject the flag as invalid instead, as with the previous Moshi parsing, while still ignoring numbers and unparsable date strings. Co-Authored-By: Claude Opus 5.5 --- .../ufc/UniversalFlagConfigParser.java | 7 +++- .../ufc/UniversalFlagConfigParserTest.java | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 4155de6bd29..3931a9505dc 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -402,12 +402,15 @@ private static long asIntegral( return (long) number; } - /** Ignores wrongly set dates. */ + /** Ignores unparsable dates and numbers, but rejects other non-string values. */ @Nullable private static Instant toInstant(@Nullable final Object value) { - if (!(value instanceof String)) { + if (value == null || value instanceof Number) { return null; } + if (!(value instanceof String)) { + throw new IllegalArgumentException("allocation date is not a string"); + } try { return DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse((String) value, Instant::from); } catch (final RuntimeException e) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 00b9a757e7c..e22ed9b6dfa 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -274,6 +274,48 @@ void testInstantParsing(final String value, final String expectedInstant) throws } } + @TableTest({ + "scenario | field | date ", + "boolean start | 'startAt' | 'true' ", + "object start | 'startAt' | '{}' ", + "array start | 'startAt' | '[]' ", + "boolean end | 'endAt' | 'false'", + "object end | 'endAt' | '{}' ", + "array end | 'endAt' | '[]' " + }) + void rejectsFlagWithNonStringAllocationDateAndKeepsSiblingFlag( + final String field, final String date) throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"" + + field + + "\":" + + date + + "}]}," + + "\"good\":{\"key\":\"good\",\"enabled\":true}" + + "}}"); + + assertNotNull(config); + assertFalse(config.flags.containsKey("bad")); + assertEquals("invalid_flag", config.invalidFlags.get("bad")); + assertTrue(config.flags.containsKey("good")); + } + + @Test + void readsNumericAllocationDatesAsUnbounded() throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{\"f\":{\"key\":\"f\",\"enabled\":true," + + "\"allocations\":[{\"key\":\"a\",\"startAt\":1672531200,\"endAt\":1.5}]}}}"); + + final Allocation allocation = config.flags.get("f").allocations.get(0); + assertNull(allocation.startAt); + assertNull(allocation.endAt); + assertTrue(config.invalidFlags.isEmpty()); + } + private static ServerConfiguration deserialize(final String json) throws Exception { return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); } From 33ac491dd92e423cc7a60f71773f9d628f4b550d Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 13 Oct 2026 15:47:29 +0200 Subject: [PATCH 6/8] fix(ffe): Reject configurations with non-finite numbers A number overflowing double, like 1e400, was read as infinity and could resolve as a valid NUMERIC flag value. Reject the configuration instead, as the previous strict Moshi reader did. Co-Authored-By: Claude Opus 5.5 --- .../internal/ufc/UniversalFlagConfigParser.java | 6 +++++- .../internal/ufc/UniversalFlagConfigParserTest.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 3931a9505dc..dda46b58394 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -108,7 +108,11 @@ private static Object readValue(final JsonParser parser) throws IOException { return parser.getText(); case VALUE_NUMBER_INT: case VALUE_NUMBER_FLOAT: - return parser.getDoubleValue(); + final double number = parser.getDoubleValue(); + if (!Double.isFinite(number)) { + throw new JsonParseException(parser, "Non-finite JSON number"); + } + return number; case VALUE_TRUE: return Boolean.TRUE; case VALUE_FALSE: diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index e22ed9b6dfa..36fd37e9e32 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -46,6 +46,16 @@ void rejectsWrongTypedFlagMap() { assertThrows(IOException.class, () -> deserialize("{\"flags\":[]}")); } + @TableTest({ + "scenario | flag ", + "overflowing variant | '{\"key\":\"f\",\"variations\":{\"v\":{\"key\":\"v\",\"value\":1e400}}}' ", + "negative overflow | '{\"key\":\"f\",\"variations\":{\"v\":{\"key\":\"v\",\"value\":-1e400}}}' ", + "overflowing condition | '{\"key\":\"f\",\"allocations\":[{\"key\":\"a\",\"rules\":[{\"conditions\":[{\"value\":1e400}]}]}]}'" + }) + void rejectsNonFiniteNumbers(final String flag) { + assertThrows(IOException.class, () -> deserialize("{\"flags\":{\"f\":" + flag + "}}")); + } + @TableTest({ "scenario | enabled ", "string | '\"yes\"'", From eba9c907f83331a75457590ee803c28b810a32a0 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 09:01:18 +0200 Subject: [PATCH 7/8] feat(ffe): Parse features on UFC splits Port the UFC split features parsing from #12772 to the dd-openfeature SDK parser. Features only feed exposure hooks, so a malformed list or entry is dropped instead of rejecting the flag, and a feature without destinations is dropped. Co-Authored-By: Claude Opus 5.5 --- .../openfeature/internal/ufc/Feature.java | 23 ++++ .../openfeature/internal/ufc/Split.java | 13 +++ .../ufc/UniversalFlagConfigParser.java | 59 +++++++++- .../ufc/UniversalFlagConfigParserTest.java | 106 ++++++++++++++++++ .../remote-config/split-features.json | 35 ++++++ 5 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java create mode 100644 dd-openfeature/src/test/resources/remote-config/split-features.json diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java new file mode 100644 index 00000000000..768f01b72e5 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Feature.java @@ -0,0 +1,23 @@ +package com.datadog.openfeature.internal.ufc; + +import java.util.List; + +/** Key-value data that the UFC attaches to a split, with where SDKs deliver it. */ +public final class Feature { + public final String key; + + /** A {@link String}, {@link Double} or {@link Boolean}; the parser drops any other value. */ + public final Object value; + + /** + * Open strings such as {@code HOOK}, {@code EXPOSURE} or {@code EVALUATION}. Never empty: the + * parser drops a feature without destinations. + */ + public final List destinations; + + public Feature(final String key, final Object value, final List destinations) { + this.key = key; + this.value = value; + this.destinations = destinations; + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java index c4852517adc..0897b77a058 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -11,14 +11,27 @@ public final class Split { /** Absent in some UFC shapes. Surfaced as {@code __dd_split_serial_id} for span enrichment. */ public final Integer serialId; + /** Null when the UFC split has no features. */ + public final List features; + public Split( final List shards, final String variationKey, final Map extraLogging, final Integer serialId) { + this(shards, variationKey, extraLogging, serialId, null); + } + + public Split( + final List shards, + final String variationKey, + final Map extraLogging, + final Integer serialId, + final List features) { this.shards = shards; this.variationKey = variationKey; this.extraLogging = extraLogging; this.serialId = serialId; + this.features = features; } } diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index dda46b58394..3a699f51553 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -1,5 +1,8 @@ package com.datadog.openfeature.internal.ufc; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonParser; @@ -267,7 +270,61 @@ private static Split toSplit(@Nullable final Object value) { toList(json.get("shards"), "shards", UniversalFlagConfigParser::toShard), asString(json.get("variationKey"), "variationKey"), toStringMap(json.get("extraLogging")), - serialId == null ? null : asLenientInt(serialId, "serialId")); + serialId == null ? null : asLenientInt(serialId, "serialId"), + toFeatures(json.get("features"))); + } + + /** + * Reads a split's features. Features only feed exposure hooks, so a malformed list or entry is + * dropped instead of rejecting the flag: it must never change which variation a subject gets. + */ + @Nullable + private static List toFeatures(@Nullable final Object value) { + if (!(value instanceof List)) { + return null; + } + final List features = new ArrayList<>(); + for (final Object element : (List) value) { + final Feature feature = toFeature(element); + if (feature != null) { + features.add(feature); + } + } + return unmodifiableList(features); + } + + @Nullable + private static Feature toFeature(@Nullable final Object value) { + if (!(value instanceof Map)) { + return null; + } + final Map json = (Map) value; + final Object key = json.get("key"); + final Object featureValue = json.get("value"); + final List destinations = toDestinations(json.get("destinations")); + if (!(key instanceof String) + || ((String) key).isEmpty() + || !(featureValue instanceof String + || featureValue instanceof Double + || featureValue instanceof Boolean) + || destinations.isEmpty()) { + return null; + } + return new Feature((String) key, featureValue, destinations); + } + + /** Keeps every non-empty string, including destinations this SDK does not know yet. */ + private static List toDestinations(@Nullable final Object value) { + if (!(value instanceof List)) { + return emptyList(); + } + final List destinations = new ArrayList<>(); + for (final Object element : (List) value) { + if (element instanceof String && !((String) element).isEmpty()) { + destinations.add((String) element); + } + } + return unmodifiableList(destinations); } @Nullable diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 36fd37e9e32..838282044f2 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -1,6 +1,7 @@ package com.datadog.openfeature.internal.ufc; import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -11,6 +12,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.Instant; +import java.util.List; import org.junit.jupiter.api.Test; import org.tabletest.junit.TableTest; @@ -170,6 +172,110 @@ void coercesSerialIdWithoutRejectingTheFlag(final String wireValue, final int ex assertEquals(Integer.valueOf(expected), serialIdOf(config)); } + @Test + void parsesSplitFeatures() throws Exception { + final ServerConfiguration config = + deserialize( + configWithFeatures( + "[{\"key\": \"holdout.key\", \"value\": \"q4-global\", \"destinations\": [\"HOOK\"]}," + + " {\"key\": \"holdout.weight\", \"value\": 0.5," + + " \"destinations\": [\"EXPOSURE\", \"EVALUATION\"]}," + + " {\"key\": \"holdout.should_include_in_holdout_analysis\", \"value\": true," + + " \"destinations\": [\"HOOK\", \"SOME_FUTURE_DESTINATION\"]}]")); + + final List features = featuresOf(config); + assertEquals(3, features.size()); + assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + assertFeature(features.get(1), "holdout.weight", 0.5, "EXPOSURE", "EVALUATION"); + assertFeature( + features.get(2), + "holdout.should_include_in_holdout_analysis", + true, + "HOOK", + "SOME_FUTURE_DESTINATION"); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + @Test + void parsesAbsentSplitFeaturesAsNull() throws Exception { + final ServerConfiguration config = deserialize(configWithFeatures(null)); + + assertNull(featuresOf(config)); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + @Test + void dropsMalformedSplitFeaturesAndKeepsTheFlag() throws Exception { + final String hook = ", \"destinations\": [\"HOOK\"]"; + final ServerConfiguration config = + deserialize( + configWithFeatures( + "[{\"key\": \"object-value\", \"value\": {\"nested\": true}" + + hook + + "}," + + " {\"key\": \"array-value\", \"value\": [1]" + + hook + + "}," + + " {\"key\": \"null-value\", \"value\": null" + + hook + + "}," + + " {\"key\": \"\", \"value\": \"empty key\"" + + hook + + "}," + + " {\"key\": 3, \"value\": \"numeric key\"" + + hook + + "}," + + " {\"value\": \"missing key\"" + + hook + + "}," + + " {\"key\": \"missing value\"" + + hook + + "}," + + " {\"key\": \"no-destinations\", \"value\": \"dropped\"}," + + " {\"key\": \"empty-destinations\", \"value\": \"dropped\", \"destinations\": []}," + + " {\"key\": \"destinations-not-a-list\", \"value\": \"dropped\"," + + " \"destinations\": \"HOOK\"}," + + " {\"key\": \"only-invalid-destinations\", \"value\": \"dropped\"," + + " \"destinations\": [1, \"\", null, {\"name\": \"HOOK\"}]}," + + " \"not-an-object\"," + + " {\"key\": \"holdout.key\", \"value\": \"q4-global\"," + + " \"destinations\": [1, \"HOOK\", \"\"], \"destination\": \"HOOK\"}]")); + + assertTrue(config.flags.containsKey("valid-flag")); + final List features = featuresOf(config); + assertEquals(1, features.size()); + assertFeature(features.get(0), "holdout.key", "q4-global", "HOOK"); + } + + @Test + void ignoresSplitFeaturesThatAreNotAList() throws Exception { + final ServerConfiguration config = + deserialize(configWithFeatures("{\"holdout.key\": \"q4-global\"}")); + + assertTrue(config.flags.containsKey("valid-flag")); + assertNull(featuresOf(config)); + assertEquals(Integer.valueOf(7), serialIdOf(config)); + } + + private static void assertFeature( + final Feature feature, final String key, final Object value, final String... destinations) { + assertEquals(key, feature.key); + assertEquals(value, feature.value); + assertEquals(asList(destinations), feature.destinations); + } + + private static List featuresOf(final ServerConfiguration config) { + return config.flags.get("valid-flag").allocations.get(0).splits.get(0).features; + } + + /** Inserts the raw features JSON; null omits the key. */ + private static String configWithFeatures(final String featuresJson) throws IOException { + final String json = resource("split-features.json"); + return featuresJson == null + ? json.replace("\"features\": \"${features}\",", "") + : json.replace("\"${features}\"", featuresJson); + } + private static Integer serialIdOf(final ServerConfiguration config) { return config.flags.get("valid-flag").allocations.get(0).splits.get(0).serialId; } diff --git a/dd-openfeature/src/test/resources/remote-config/split-features.json b/dd-openfeature/src/test/resources/remote-config/split-features.json new file mode 100644 index 00000000000..d8ff27d02a0 --- /dev/null +++ b/dd-openfeature/src/test/resources/remote-config/split-features.json @@ -0,0 +1,35 @@ +{ + "createdAt": "2024-04-17T19:40:53.716Z", + "format": "SERVER", + "environment": { + "name": "Test" + }, + "flags": { + "valid-flag": { + "key": "valid-flag", + "enabled": true, + "variationType": "STRING", + "variations": { + "expected": { + "key": "expected", + "value": "expected" + } + }, + "allocations": [ + { + "key": "default-allocation", + "rules": [], + "splits": [ + { + "serialId": 7, + "features": "${features}", + "variationKey": "expected", + "shards": [] + } + ], + "doLog": true + } + ] + } + } +} From de691e466a13426c791ad8c2b3b39bcdf7ffda70 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Wed, 14 Oct 2026 17:03:59 +0200 Subject: [PATCH 8/8] fix(ffe): Cap UFC document nesting depth The UFC parser reads the document recursively, including ignored fields, so a deeply nested document could overflow the stack with an error the configuration sources do not catch. Reject documents nested deeper than 32 levels, as Moshi capped them at 255 before. The cap is enforced by the reader rather than Jackson StreamReadConstraints, which jackson-core older than 2.15 (e.g. Spring Boot 2.7) does not provide. Co-Authored-By: Claude Opus 5.5 --- .../ufc/UniversalFlagConfigParser.java | 15 ++++++++--- .../ufc/UniversalFlagConfigParserTest.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java index 3a699f51553..2721be5bfef 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -35,6 +35,9 @@ public final class UniversalFlagConfigParser { private static final String UNIVERSAL_FLAG_CONFIGURATION_TYPE = "universal-flag-configuration"; private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + /** UFC documents nest about ten levels deep; the cap bounds the recursive document reader. */ + private static final int MAX_NESTING_DEPTH = 32; + private static final JsonFactory JSON_FACTORY = new JsonFactory().enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION); @@ -82,7 +85,7 @@ private static Object readDocument(final byte[] content) throws IOException { if (parser.nextToken() == null) { throw new JsonParseException(parser, "Empty document"); } - final Object document = readValue(parser); + final Object document = readValue(parser, 1); if (parser.nextToken() != null) { throw new JsonParseException(parser, "Unexpected content after the document"); } @@ -90,21 +93,25 @@ private static Object readDocument(final byte[] content) throws IOException { } } - private static Object readValue(final JsonParser parser) throws IOException { + private static Object readValue(final JsonParser parser, final int depth) throws IOException { final JsonToken token = parser.currentToken(); + if ((token == JsonToken.START_OBJECT || token == JsonToken.START_ARRAY) + && depth > MAX_NESTING_DEPTH) { + throw new JsonParseException(parser, "Document nested deeper than " + MAX_NESTING_DEPTH); + } switch (token) { case START_OBJECT: final Map object = new LinkedHashMap<>(); while (parser.nextToken() == JsonToken.FIELD_NAME) { final String name = parser.currentName(); parser.nextToken(); - object.put(name, readValue(parser)); + object.put(name, readValue(parser, depth + 1)); } return object; case START_ARRAY: final List array = new ArrayList<>(); while (parser.nextToken() != JsonToken.END_ARRAY) { - array.add(readValue(parser)); + array.add(readValue(parser, depth + 1)); } return array; case VALUE_STRING: diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java index 838282044f2..5afe68fa544 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -323,6 +323,21 @@ void rejectsTrailingJson() { assertThrows(IOException.class, () -> deserialize(emptyConfig() + "{}")); } + @Test + void acceptsNestingUpToTheLimit() throws Exception { + assertNotNull(deserialize(nestedConfig(31))); + } + + @Test + void rejectsNestingBeyondTheLimit() { + assertThrows(IOException.class, () -> deserialize(nestedConfig(32))); + assertThrows( + IOException.class, + () -> + UniversalFlagConfigParser.parseJsonApi( + ("{\"data\":" + nestedConfig(32) + "}").getBytes(UTF_8))); + } + @Test void skipsUnknownOperatorFlagAndKeepsValidFlag() throws Exception { final ServerConfiguration config = deserialize(resource("unknown-operator.json")); @@ -436,6 +451,18 @@ private static ServerConfiguration deserialize(final String json) throws Excepti return UniversalFlagConfigParser.parse(json.getBytes(UTF_8)); } + private static String nestedConfig(final int arrays) { + final StringBuilder json = new StringBuilder("{\"flags\":{},\"ignored\":"); + for (int i = 0; i < arrays; i++) { + json.append('['); + } + json.append('0'); + for (int i = 0; i < arrays; i++) { + json.append(']'); + } + return json.append('}').toString(); + } + private static String emptyConfig() throws IOException { return resource("empty-config.json"); }