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/FlagEvaluator.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java new file mode 100644 index 00000000000..96fa1f90829 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/evaluation/FlagEvaluator.java @@ -0,0 +1,571 @@ +package com.datadog.openfeature.internal.evaluation; + +import static java.util.Arrays.asList; + +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 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; +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 dependency. */ +public final class FlagEvaluator { + private static final Set> SUPPORTED_RESOLUTION_TYPES = + 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, final ExposureListener exposureListener) { + this.spanEnrichmentEnabled = spanEnrichmentEnabled; + this.exposureListener = exposureListener; + } + + 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 + // 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. + 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 ProviderEvaluation.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 ProviderEvaluation.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 ImmutableMetadata consentMetadata(final boolean observeFullEvaluationData) { + return ImmutableMetadata.builder() + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .build(); + } + + 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 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 ProviderEvaluation.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 ProviderEvaluation 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 ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = + ImmutableMetadata.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 ProviderEvaluation result = + ProviderEvaluation.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()) + .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.keySet().contains(name)) { + return context.getTargetingKey(); + } + return context.convertValue(context.getValue(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 == Value.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.objectToValue(value); + } + + private static Double parseDouble(final Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + 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); + } + + @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/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/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..0897b77a058 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/Split.java @@ -0,0 +1,37 @@ +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; + + /** 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 new file mode 100644 index 00000000000..2721be5bfef --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParser.java @@ -0,0 +1,641 @@ +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; +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; + + /** 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); + + 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, 1); + if (parser.nextToken() != null) { + throw new JsonParseException(parser, "Unexpected content after the document"); + } + return document; + } + } + + 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, depth + 1)); + } + return object; + case START_ARRAY: + final List array = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + array.add(readValue(parser, depth + 1)); + } + return array; + case VALUE_STRING: + return parser.getText(); + case VALUE_NUMBER_INT: + case VALUE_NUMBER_FLOAT: + 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: + 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, "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"), + 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 + 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"); + } + + /** 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; + } + 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 unparsable dates and numbers, but rejects other non-string values. */ + @Nullable + private static Instant toInstant(@Nullable final Object value) { + 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) { + 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..5afe68fa544 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/ufc/UniversalFlagConfigParserTest.java @@ -0,0 +1,477 @@ +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; +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 java.util.List; +import org.junit.jupiter.api.Test; +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\":[]}")); + } + + @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\"'", + "number | '1' ", + "null | 'null' " + }) + void rejectsFlagWithNonBooleanEnabledAndKeepsSiblingFlag(final String enabled) throws Exception { + final ServerConfiguration config = + deserialize( + "{\"flags\":{" + + "\"bad\":{\"key\":\"bad\",\"enabled\":" + + enabled + + "}," + + "\"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 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 = + 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. + */ + @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)); + + assertNotNull(config); + 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; + } + + 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 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")); + + 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()); + } + } + + @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)); + } + + 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"); + } + + 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/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 + } + ] + } + } +} 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",