diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfe62be..cfc0eea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,10 @@ jobs: ${{ runner.os }}-cargo-test- - name: Run tests - run: cargo test --verbose + run: | + cargo test --lib --verbose -- --test-threads=1 + cargo test --test integration_tests --test metadata_merging_tests --verbose -- --test-threads=1 + cargo test --test gherkin_tests --verbose clippy: name: Clippy diff --git a/java/src/main/java/dev/openfeature/flagd/evaluator/FlagEvaluator.java b/java/src/main/java/dev/openfeature/flagd/evaluator/FlagEvaluator.java index 91bc7cf..15b2410 100644 --- a/java/src/main/java/dev/openfeature/flagd/evaluator/FlagEvaluator.java +++ b/java/src/main/java/dev/openfeature/flagd/evaluator/FlagEvaluator.java @@ -19,7 +19,10 @@ import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; /** * Thread-safe flag evaluator using the flagd-evaluator WASM module. @@ -97,6 +100,15 @@ public class FlagEvaluator implements AutoCloseable { // Cache of pre-evaluated results for static/disabled flags (replaced atomically on updateState) private volatile Map> preEvaluatedCache = Collections.emptyMap(); + // Per-flag required context keys for host-side filtering (read/written inside synchronized methods) + private Map> requiredContextKeysCache = Collections.emptyMap(); + + // Flag key to numeric index mapping (read/written inside synchronized methods) + private Map flagIndexCache = Collections.emptyMap(); + + // WASM export for index-based evaluation (may be null if WASM module doesn't support it) + private final ExportFunction evaluateByIndexFunction; + /** * Creates a new flag evaluator with strict validation mode. * @@ -119,6 +131,15 @@ public FlagEvaluator(ValidationMode validationMode) { this.deallocFunction = wasmInstance.export("dealloc"); this.memory = wasmInstance.memory(); + // Bind evaluate_by_index if available (newer WASM modules) + ExportFunction evalByIndex = null; + try { + evalByIndex = wasmInstance.export("evaluate_by_index"); + } catch (Exception e) { + // Older WASM module without evaluate_by_index — fall back to string-based eval + } + this.evaluateByIndexFunction = evalByIndex; + // Pre-allocate buffers for evaluation (avoids alloc calls per evaluation) this.flagKeyBufferPtr = allocFunction.apply(MAX_FLAG_KEY_SIZE)[0]; this.contextBufferPtr = allocFunction.apply(MAX_CONTEXT_SIZE)[0]; @@ -170,6 +191,22 @@ public synchronized UpdateStateResult updateState(String jsonConfig) throws Eval Map> preEval = result.getPreEvaluated(); this.preEvaluatedCache = (preEval != null) ? preEval : Collections.emptyMap(); + // Update required context keys cache + Map> reqKeys = result.getRequiredContextKeys(); + if (reqKeys != null) { + Map> keySets = new HashMap<>(reqKeys.size()); + for (Map.Entry> entry : reqKeys.entrySet()) { + keySets.put(entry.getKey(), new HashSet<>(entry.getValue())); + } + this.requiredContextKeysCache = keySets; + } else { + this.requiredContextKeysCache = Collections.emptyMap(); + } + + // Update flag index cache + Map indices = result.getFlagIndices(); + this.flagIndexCache = (indices != null) ? indices : Collections.emptyMap(); + return result; } catch (Exception e) { throw new EvaluatorException("Failed to update state", e); @@ -217,6 +254,13 @@ public synchronized EvaluationResult evaluateFlag(Class type, String f return (EvaluationResult) (EvaluationResult) cached; } + return evaluateFlagInternal(type, flagKey, contextJson); + } + + /** + * Internal evaluation using flag key string and evaluate_reusable WASM export. + */ + private EvaluationResult evaluateFlagInternal(Class type, String flagKey, String contextJson) throws EvaluatorException { byte[] flagBytes = flagKey.getBytes(StandardCharsets.UTF_8); if (flagBytes.length > MAX_FLAG_KEY_SIZE) { throw new EvaluatorException("Flag key exceeds maximum size of " + MAX_FLAG_KEY_SIZE + " bytes"); @@ -254,6 +298,39 @@ public synchronized EvaluationResult evaluateFlag(Class type, String f } } + /** + * Evaluates a flag using the numeric index path (evaluate_by_index WASM export). + * + *

This avoids flag key string serialization and uses O(1) Vec lookup on the Rust side. + * The context must already be pre-enriched with {@code $flagd.*} and {@code targetingKey}. + */ + private EvaluationResult evaluateByIndex(Class type, int flagIndex, String contextJson) throws EvaluatorException { + long contextPtr = 0; + int contextLen = 0; + if (contextJson != null && !contextJson.isEmpty()) { + byte[] contextBytes = contextJson.getBytes(StandardCharsets.UTF_8); + if (contextBytes.length > MAX_CONTEXT_SIZE) { + throw new EvaluatorException("Context exceeds maximum size of " + MAX_CONTEXT_SIZE + " bytes"); + } + memory.write((int) contextBufferPtr, contextBytes); + contextPtr = contextBufferPtr; + contextLen = contextBytes.length; + } + + try { + long packedResult = evaluateByIndexFunction.apply(flagIndex, contextPtr, contextLen)[0]; + int resultPtr = (int) (packedResult >>> 32); + int resultLen = (int) (packedResult & 0xFFFFFFFFL); + + String resultJson = memory.readString(resultPtr, resultLen); + deallocFunction.apply(resultPtr, resultLen); + + return OBJECT_MAPPER.readValue(resultJson, JAVA_TYPE_MAP.get(type)); + } catch (Exception e) { + throw new EvaluatorException("Failed to evaluate flag by index: " + flagIndex, e); + } + } + /** * Evaluates a flag with an EvaluationContext. * @@ -266,19 +343,45 @@ public synchronized EvaluationResult evaluateFlag(Class type, String f * @return the evaluation result containing value, variant, reason, and metadata * @throws EvaluatorException if the evaluation or serialization fails */ - public EvaluationResult evaluateFlag(Class type, String flagKey, EvaluationContext context) throws EvaluatorException { + @SuppressWarnings("unchecked") + public synchronized EvaluationResult evaluateFlag(Class type, String flagKey, EvaluationContext context) throws EvaluatorException { try { + // Fast path: return cached result for static/disabled flags + EvaluationResult cached = preEvaluatedCache.get(flagKey); + if (cached != null) { + return (EvaluationResult) (EvaluationResult) cached; + } + // Fast path: empty context if (context == null || context.isEmpty()) { - return evaluateFlag(type, flagKey, (String) null); + return evaluateFlagInternal(type, flagKey, (String) null); + } + + // Check if we can use filtered serialization + Set requiredKeys = requiredContextKeysCache.get(flagKey); + String contextJson; + if (requiredKeys != null) { + // Filtered path: only serialize keys the targeting rule references + contextJson = EvaluationContextSerializer.serializeFiltered(context, requiredKeys, flagKey); + } else { + // Full serialization path (flag uses {"var": ""} or older WASM module) + ByteArrayOutputStream buffer = JSON_BUFFER.get(); + buffer.reset(); + try (JsonGenerator generator = JSON_FACTORY.createGenerator(buffer)) { + OBJECT_MAPPER.writeValue(generator, context); + } + contextJson = buffer.toString(StandardCharsets.UTF_8.name()); } - // Use ThreadLocal buffer for streaming serialization - ByteArrayOutputStream buffer = JSON_BUFFER.get(); - buffer.reset(); - try (JsonGenerator generator = JSON_FACTORY.createGenerator(buffer)) { - OBJECT_MAPPER.writeValue(generator, context); + + // Check if we can use index-based evaluation + Integer flagIndex = flagIndexCache.get(flagKey); + if (flagIndex != null && evaluateByIndexFunction != null && requiredKeys != null) { + // Index-based path: avoids flag key string overhead + return evaluateByIndex(type, flagIndex, contextJson); } - return evaluateFlag(type, flagKey, buffer.toString(StandardCharsets.UTF_8.name())); + + // Fall back to string-based evaluation + return evaluateFlagInternal(type, flagKey, contextJson); } catch (EvaluatorException e) { throw e; } catch (Exception e) { diff --git a/java/src/main/java/dev/openfeature/flagd/evaluator/UpdateStateResult.java b/java/src/main/java/dev/openfeature/flagd/evaluator/UpdateStateResult.java index ef7ec7d..602ae7e 100644 --- a/java/src/main/java/dev/openfeature/flagd/evaluator/UpdateStateResult.java +++ b/java/src/main/java/dev/openfeature/flagd/evaluator/UpdateStateResult.java @@ -18,6 +18,10 @@ public class UpdateStateResult { private Map> preEvaluated; + private Map> requiredContextKeys; + + private Map flagIndices; + public UpdateStateResult() { } @@ -78,6 +82,39 @@ public void setPreEvaluated(Map> preEvaluated) this.preEvaluated = preEvaluated; } + /** + * Gets the required context keys per flag for host-side filtering. + * + *

When present for a flag, the host should only serialize these context keys + * (plus {@code $flagd.*} enrichment and {@code targetingKey}) before calling evaluate. + * If a flag is absent from this map, send the full context. + * + * @return map of flag key to required context keys, or null if not available + */ + public Map> getRequiredContextKeys() { + return requiredContextKeys; + } + + public void setRequiredContextKeys(Map> requiredContextKeys) { + this.requiredContextKeys = requiredContextKeys; + } + + /** + * Gets the flag key to numeric index mapping for {@code evaluate_by_index}. + * + *

Allows calling the WASM {@code evaluate_by_index(index, ...)} export + * instead of passing flag key strings. + * + * @return map of flag key to numeric index, or null if not available + */ + public Map getFlagIndices() { + return flagIndices; + } + + public void setFlagIndices(Map flagIndices) { + this.flagIndices = flagIndices; + } + @Override public String toString() { return "UpdateStateResult{" + diff --git a/java/src/main/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializer.java b/java/src/main/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializer.java index fc18cf8..add540d 100644 --- a/java/src/main/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializer.java +++ b/java/src/main/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializer.java @@ -7,11 +7,13 @@ import dev.openfeature.sdk.Structure; import dev.openfeature.sdk.Value; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * Custom serializer for EvaluationContext (including LayeredEvaluationContext and MutableContext). @@ -39,6 +41,108 @@ public void serialize(EvaluationContext ctx, JsonGenerator gen, SerializerProvid gen.writeEndObject(); } + // ThreadLocal buffer for filtered serialization + private static final ThreadLocal FILTERED_BUFFER = + ThreadLocal.withInitial(() -> new ByteArrayOutputStream(2048)); + + private static final com.fasterxml.jackson.core.JsonFactory SHARED_JSON_FACTORY = + new com.fasterxml.jackson.core.JsonFactory(); + + /** + * Serializes a filtered subset of the evaluation context with $flagd enrichment. + * + *

Only includes the specified required keys from the context, plus the + * {@code $flagd} enrichment object and {@code targetingKey}. This dramatically + * reduces the serialized size for large contexts where the targeting rule only + * references a few fields. + * + * @param ctx the evaluation context to filter and serialize + * @param requiredKeys the set of context keys that the targeting rule references + * @param flagKey the flag key (for $flagd.flagKey enrichment) + * @return the filtered, enriched JSON string + * @throws IOException if serialization fails + */ + public static String serializeFiltered( + EvaluationContext ctx, + Set requiredKeys, + String flagKey) throws IOException { + ByteArrayOutputStream buffer = FILTERED_BUFFER.get(); + buffer.reset(); + + try (JsonGenerator gen = SHARED_JSON_FACTORY.createGenerator(buffer)) { + gen.writeStartObject(); + + // Write only the required keys from the context + for (String key : requiredKeys) { + // Skip $flagd — we add it ourselves below + if (key.startsWith("$flagd")) { + continue; + } + Value value = ctx.getValue(key); + if (value != null) { + gen.writeFieldName(key); + writeValue(gen, value); + } else if ("targetingKey".equals(key)) { + // targetingKey defaults to empty string if not in context + String tk = ctx.getTargetingKey(); + gen.writeStringField("targetingKey", tk != null ? tk : ""); + } + } + + // Ensure targetingKey is always present + if (!requiredKeys.contains("targetingKey")) { + String tk = ctx.getTargetingKey(); + gen.writeStringField("targetingKey", tk != null ? tk : ""); + } + + // Add $flagd enrichment + gen.writeObjectFieldStart("$flagd"); + gen.writeStringField("flagKey", flagKey); + gen.writeNumberField("timestamp", System.currentTimeMillis() / 1000); + gen.writeEndObject(); + + gen.writeEndObject(); + } + + return buffer.toString("UTF-8"); + } + + /** + * Writes an OpenFeature Value to a JsonGenerator. + */ + private static void writeValue(JsonGenerator gen, Value value) throws IOException { + if (value == null || value.isNull()) { + gen.writeNull(); + } else if (value.isBoolean()) { + gen.writeBoolean(value.asBoolean()); + } else if (value.isNumber()) { + double d = value.asDouble(); + if (d == Math.floor(d) && !Double.isInfinite(d)) { + gen.writeNumber((long) d); + } else { + gen.writeNumber(d); + } + } else if (value.isString()) { + gen.writeString(value.asString()); + } else if (value.isList()) { + gen.writeStartArray(); + for (Value item : value.asList()) { + writeValue(gen, item); + } + gen.writeEndArray(); + } else if (value.isStructure()) { + gen.writeStartObject(); + Structure structure = value.asStructure(); + for (String key : structure.keySet()) { + gen.writeFieldName(key); + writeValue(gen, structure.getValue(key)); + } + gen.writeEndObject(); + } else { + gen.writeNull(); + } + } + /** * Extracts the raw Java object from an OpenFeature Value wrapper. * Recursively handles nested structures and lists. diff --git a/java/src/test/java/dev/openfeature/flagd/evaluator/FlagEvaluatorTest.java b/java/src/test/java/dev/openfeature/flagd/evaluator/FlagEvaluatorTest.java index bc7b5e0..e4d285e 100644 --- a/java/src/test/java/dev/openfeature/flagd/evaluator/FlagEvaluatorTest.java +++ b/java/src/test/java/dev/openfeature/flagd/evaluator/FlagEvaluatorTest.java @@ -248,4 +248,149 @@ void testUpdateStateWithChangedFlags() throws EvaluatorException { assertThat(result2.isSuccess()).isTrue(); assertThat(result2.getChangedFlags()).containsExactlyInAnyOrder("flag-a", "flag-b"); } + + @Test + void testUpdateStateReturnsRequiredContextKeys() throws EvaluatorException { + String config = "{\n" + + " \"flags\": {\n" + + " \"targeted-flag\": {\n" + + " \"state\": \"ENABLED\",\n" + + " \"defaultVariant\": \"off\",\n" + + " \"variants\": { \"on\": true, \"off\": false },\n" + + " \"targeting\": {\n" + + " \"if\": [\n" + + " { \"==\": [{ \"var\": \"email\" }, \"admin@example.com\"] },\n" + + " \"on\", \"off\"\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"static-flag\": {\n" + + " \"state\": \"ENABLED\",\n" + + " \"defaultVariant\": \"on\",\n" + + " \"variants\": { \"on\": true }\n" + + " }\n" + + " }\n" + + "}"; + + UpdateStateResult result = evaluator.updateState(config); + assertThat(result.isSuccess()).isTrue(); + + // Should have required context keys for the targeted flag + assertThat(result.getRequiredContextKeys()).isNotNull(); + assertThat(result.getRequiredContextKeys()).containsKey("targeted-flag"); + assertThat(result.getRequiredContextKeys().get("targeted-flag")).contains("email", "targetingKey"); + + // Static flags should not be in required context keys + assertThat(result.getRequiredContextKeys()).doesNotContainKey("static-flag"); + } + + @Test + void testUpdateStateReturnsFlagIndices() throws EvaluatorException { + String config = "{\n" + + " \"flags\": {\n" + + " \"flagB\": {\n" + + " \"state\": \"ENABLED\",\n" + + " \"defaultVariant\": \"on\",\n" + + " \"variants\": { \"on\": true }\n" + + " },\n" + + " \"flagA\": {\n" + + " \"state\": \"ENABLED\",\n" + + " \"defaultVariant\": \"off\",\n" + + " \"variants\": { \"off\": false }\n" + + " }\n" + + " }\n" + + "}"; + + UpdateStateResult result = evaluator.updateState(config); + assertThat(result.isSuccess()).isTrue(); + + // Should have indices for all flags + assertThat(result.getFlagIndices()).isNotNull(); + assertThat(result.getFlagIndices()).containsKey("flagA"); + assertThat(result.getFlagIndices()).containsKey("flagB"); + // Indices should be assigned in sorted order + assertThat(result.getFlagIndices().get("flagA")).isEqualTo(0); + assertThat(result.getFlagIndices().get("flagB")).isEqualTo(1); + } + + @Test + void testFilteredContextEvaluation() throws EvaluatorException { + // This test verifies that filtered context serialization produces correct results + // The flag uses only "email" from the context, but we pass many attributes + String config = "{\n" + + " \"flags\": {\n" + + " \"email-flag\": {\n" + + " \"state\": \"ENABLED\",\n" + + " \"defaultVariant\": \"default\",\n" + + " \"variants\": { \"default\": false, \"premium\": true },\n" + + " \"targeting\": {\n" + + " \"if\": [\n" + + " { \"==\": [{ \"var\": \"email\" }, \"admin@example.com\"] },\n" + + " \"premium\", null\n" + + " ]\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + evaluator.updateState(config); + + // Create a "large" context with many attributes - only email matters + MutableContext context = new MutableContext("user-123"); + context.add("email", "admin@example.com"); + context.add("name", "Admin User"); + context.add("age", 30); + context.add("country", "US"); + context.add("tier", "premium"); + context.add("department", "engineering"); + + // Should match via filtered context path + EvaluationResult result = evaluator.evaluateFlag(Boolean.class, "email-flag", context); + assertThat(result.getValue()).isEqualTo(true); + assertThat(result.getVariant()).isEqualTo("premium"); + assertThat(result.getReason()).isEqualTo("TARGETING_MATCH"); + + // Non-matching email + MutableContext context2 = new MutableContext("user-456"); + context2.add("email", "regular@example.com"); + context2.add("name", "Regular User"); + context2.add("age", 25); + + result = evaluator.evaluateFlag(Boolean.class, "email-flag", context2); + assertThat(result.getValue()).isEqualTo(false); + assertThat(result.getVariant()).isEqualTo("default"); + } + + @Test + void testPreEvaluatedCacheStillWorks() throws EvaluatorException { + // Verify that pre-evaluated (static/disabled) flags still use the cache + String config = "{\n" + + " \"flags\": {\n" + + " \"static-flag\": {\n" + + " \"state\": \"ENABLED\",\n" + + " \"defaultVariant\": \"on\",\n" + + " \"variants\": { \"on\": true, \"off\": false }\n" + + " },\n" + + " \"disabled-flag\": {\n" + + " \"state\": \"DISABLED\",\n" + + " \"defaultVariant\": \"on\",\n" + + " \"variants\": { \"on\": true, \"off\": false }\n" + + " }\n" + + " }\n" + + "}"; + + evaluator.updateState(config); + + // These should be served from cache (no WASM call) + MutableContext context = new MutableContext("user-1"); + context.add("anything", "value"); + + EvaluationResult staticResult = evaluator.evaluateFlag(Boolean.class, "static-flag", context); + assertThat(staticResult.getValue()).isEqualTo(true); + assertThat(staticResult.getReason()).isEqualTo("STATIC"); + + EvaluationResult disabledResult = evaluator.evaluateFlag(Boolean.class, "disabled-flag", context); + assertThat(disabledResult.getValue()).isNull(); + assertThat(disabledResult.getReason()).isEqualTo("DISABLED"); + } } diff --git a/java/src/test/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializerTest.java b/java/src/test/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializerTest.java index b70391f..32500d6 100644 --- a/java/src/test/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializerTest.java +++ b/java/src/test/java/dev/openfeature/flagd/evaluator/jackson/EvaluationContextSerializerTest.java @@ -10,8 +10,10 @@ import org.junit.jupiter.api.Test; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -351,4 +353,74 @@ void testTargetingKeyInContext() throws Exception { assertThat(result).containsEntry("targetingKey", "user-456"); assertThat(result).containsEntry("email", "test@example.com"); } + + @Test + void testSerializeFilteredIncludesOnlyRequiredKeys() throws Exception { + MutableContext context = new MutableContext("user-789") + .add("email", "admin@example.com") + .add("name", "Admin") + .add("age", 30) + .add("country", "US") + .add("department", "engineering"); + + Set requiredKeys = new HashSet<>(Arrays.asList("email", "targetingKey")); + + String json = EvaluationContextSerializer.serializeFiltered(context, requiredKeys, "myFlag"); + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(json, Map.class); + + // Should include only the required keys + assertThat(result).containsEntry("email", "admin@example.com"); + assertThat(result).containsEntry("targetingKey", "user-789"); + + // Should NOT include unrequested keys + assertThat(result).doesNotContainKey("name"); + assertThat(result).doesNotContainKey("age"); + assertThat(result).doesNotContainKey("country"); + assertThat(result).doesNotContainKey("department"); + + // Should include $flagd enrichment + assertThat(result).containsKey("$flagd"); + @SuppressWarnings("unchecked") + Map flagd = (Map) result.get("$flagd"); + assertThat(flagd).containsEntry("flagKey", "myFlag"); + assertThat(flagd).containsKey("timestamp"); + } + + @Test + void testSerializeFilteredAlwaysIncludesTargetingKey() throws Exception { + MutableContext context = new MutableContext("user-abc") + .add("email", "test@example.com"); + + // Required keys don't include targetingKey explicitly + Set requiredKeys = new HashSet<>(Arrays.asList("email")); + + String json = EvaluationContextSerializer.serializeFiltered(context, requiredKeys, "testFlag"); + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(json, Map.class); + + // targetingKey should always be included + assertThat(result).containsEntry("targetingKey", "user-abc"); + assertThat(result).containsEntry("email", "test@example.com"); + assertThat(result).containsKey("$flagd"); + } + + @Test + void testSerializeFilteredWithEmptyTargetingKey() throws Exception { + MutableContext context = new MutableContext() + .add("email", "test@example.com"); + + Set requiredKeys = new HashSet<>(Arrays.asList("email", "targetingKey")); + + String json = EvaluationContextSerializer.serializeFiltered(context, requiredKeys, "testFlag"); + + @SuppressWarnings("unchecked") + Map result = objectMapper.readValue(json, Map.class); + + // targetingKey should default to empty string + assertThat(result).containsEntry("targetingKey", ""); + assertThat(result).containsEntry("email", "test@example.com"); + } } diff --git a/src/evaluator.rs b/src/evaluator.rs index 107ca26..8410bb1 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -8,7 +8,7 @@ use crate::model::{FeatureFlag, ParsingResult, UpdateStateResponse}; use crate::operators::create_evaluator; use crate::types::{ErrorCode, EvaluationResult, ResolutionReason}; use crate::validation::validate_flags_config; -use datalogic_rs::DataLogic; +use datalogic_rs::{CompiledLogic, CompiledNode, DataLogic, OpCode}; use serde_json::{Map, Value as JsonValue, Value}; use std::collections::{HashMap, HashSet}; @@ -54,6 +54,8 @@ pub struct FlagEvaluator { validation_mode: ValidationMode, /// The DataLogic engine with custom operators (created once, reused for all evaluations) logic: DataLogic, + /// Index-to-flag-key mapping for O(1) evaluate_by_index lookups + flag_index_map: Vec, } impl std::fmt::Debug for FlagEvaluator { @@ -62,6 +64,7 @@ impl std::fmt::Debug for FlagEvaluator { .field("state", &self.state) .field("validation_mode", &self.validation_mode) .field("logic", &"") + .field("flag_index_map", &self.flag_index_map) .finish() } } @@ -77,6 +80,7 @@ impl FlagEvaluator { state: None, validation_mode, logic: create_evaluator(), + flag_index_map: Vec::new(), } } @@ -111,6 +115,8 @@ impl FlagEvaluator { error: Some(validation_error.to_json_string()), changed_flags: None, pre_evaluated: None, + required_context_keys: None, + flag_indices: None, }); } } @@ -133,6 +139,8 @@ impl FlagEvaluator { error: Some(e), changed_flags: None, pre_evaluated: None, + required_context_keys: None, + flag_indices: None, }); } }; @@ -143,6 +151,13 @@ impl FlagEvaluator { // Pre-evaluate static and disabled flags (no targeting rules needed) let pre_evaluated = self.pre_evaluate_static_flags(&new_parsing_result); + // Build required_context_keys and flag_indices for targeting flags + let (required_context_keys, flag_indices, index_to_key) = + Self::build_optimization_maps(&new_parsing_result); + + // Store the index-to-key mapping for evaluate_by_index lookups + self.flag_index_map = index_to_key; + // Store the new state self.state = Some(new_parsing_result); @@ -155,6 +170,16 @@ impl FlagEvaluator { } else { Some(pre_evaluated) }, + required_context_keys: if required_context_keys.is_empty() { + None + } else { + Some(required_context_keys) + }, + flag_indices: if flag_indices.is_empty() { + None + } else { + Some(flag_indices) + }, }) } @@ -178,6 +203,7 @@ impl FlagEvaluator { /// Clears the flag state. pub fn clear_state(&mut self) { self.state = None; + self.flag_index_map.clear(); } // ========================================================================= @@ -676,6 +702,257 @@ impl FlagEvaluator { } } + /// Evaluates a flag by its numeric index (from `flag_indices` in `UpdateStateResponse`). + /// + /// This is a fast path that avoids flag key string handling by using O(1) Vec lookup. + /// The context is expected to be pre-enriched with `$flagd.*` and `targetingKey` by the host. + pub fn evaluate_flag_by_index(&self, index: u32, context: &JsonValue) -> EvaluationResult { + let flag_key = match self.flag_index_map.get(index as usize) { + Some(key) => key, + None => { + return EvaluationResult::error( + ErrorCode::FlagNotFound, + format!("No flag at index {}", index), + ); + } + }; + + self.evaluate_flag_pre_enriched(flag_key, context) + } + + /// Evaluates a flag with a pre-enriched context (skips `enrich_context` if `$flagd` is present). + /// + /// The host is expected to have added `$flagd.flagKey`, `$flagd.timestamp`, and `targetingKey`. + pub fn evaluate_flag_pre_enriched( + &self, + flag_key: &str, + context: &JsonValue, + ) -> EvaluationResult { + // If context already has $flagd, skip enrichment + let is_pre_enriched = context + .as_object() + .map(|o| o.contains_key("$flagd")) + .unwrap_or(false); + + if is_pre_enriched { + self.evaluate_with_type_check_pre_enriched(flag_key, context, None) + } else { + self.evaluate_with_type_check(flag_key, context, None) + } + } + + /// Internal evaluation that skips context enrichment (context already has `$flagd`). + fn evaluate_with_type_check_pre_enriched( + &self, + flag_key: &str, + context: &JsonValue, + expected_type: Option, + ) -> EvaluationResult { + let state = match &self.state { + Some(s) => s, + None => { + return EvaluationResult::error(ErrorCode::General, "No flag configuration loaded"); + } + }; + + let flag = match state.flags.get(flag_key) { + Some(f) => f, + None => { + let flag_set_metadata = + Self::merge_metadata(&state.flag_set_metadata, &HashMap::new()); + return EvaluationResult { + value: JsonValue::Null, + variant: None, + reason: ResolutionReason::FlagNotFound, + error_code: Some(ErrorCode::FlagNotFound), + error_message: Some(format!("Flag '{}' not found in configuration", flag_key)), + flag_metadata: flag_set_metadata, + }; + } + }; + + let result = self.evaluate_flag_internal_pre_enriched( + flag, + flag_key, + context, + &state.flag_set_metadata, + ); + + match expected_type { + Some(expected) => self.apply_type_check(result, expected), + None => result, + } + } + + /// Core flag evaluation logic that skips context enrichment. + fn evaluate_flag_internal_pre_enriched( + &self, + flag: &FeatureFlag, + flag_key: &str, + context: &JsonValue, + flag_set_metadata: &HashMap, + ) -> EvaluationResult { + // Check if flag is disabled + if flag.state == "DISABLED" { + let merged_metadata = Self::merge_metadata(flag_set_metadata, &flag.metadata); + return EvaluationResult { + value: JsonValue::Null, + variant: None, + reason: ResolutionReason::Disabled, + error_code: Some(ErrorCode::FlagNotFound), + error_message: Some(format!("flag: {} is disabled", flag_key)), + flag_metadata: merged_metadata, + }; + } + + // Check for empty targeting + let is_empty_targeting = match &flag.targeting { + None => true, + Some(JsonValue::Object(map)) if map.is_empty() => true, + _ => false, + }; + + if is_empty_targeting { + return match flag.default_variant.as_ref() { + None => EvaluationResult::fallback(flag_key), + Some(value) if value.is_empty() => EvaluationResult::fallback(flag_key), + Some(default_variant) => match flag.variants.get(default_variant) { + Some(value) => { + let result = + EvaluationResult::static_result(value.clone(), default_variant.clone()); + Self::with_lazy_metadata(flag_set_metadata, &flag.metadata, result) + } + None => EvaluationResult::error( + ErrorCode::General, + format!( + "Default variant '{}' not found in flag variants", + default_variant + ), + ), + }, + }; + } + + // Use context directly (already enriched by host) + let eval_result = if let Some(ref compiled) = flag.compiled_targeting { + self.logic.evaluate_owned(compiled, context.clone()) + } else { + let targeting = flag.targeting.as_ref().unwrap(); + let rule_str = targeting.to_string(); + let context_str = context.to_string(); + self.logic.evaluate_json(&rule_str, &context_str) + }; + + // Same result processing as evaluate_flag_internal + match eval_result { + Ok(result) => { + if result.is_null() { + return match flag.default_variant.as_ref() { + None => EvaluationResult::fallback(flag_key), + Some(value) if value.is_empty() => EvaluationResult::fallback(flag_key), + Some(default_variant) => match flag.variants.get(default_variant) { + Some(value) => { + let result = EvaluationResult::default_result( + value.clone(), + default_variant.clone(), + ); + Self::with_lazy_metadata(flag_set_metadata, &flag.metadata, result) + } + None => EvaluationResult::error( + ErrorCode::General, + format!( + "Default variant '{}' not found in flag variants", + default_variant + ), + ), + }, + }; + } + + let variant_name = match result { + JsonValue::String(s) => s, + other => match other.as_str() { + Some(s) => s.to_string(), + None => other.to_string().trim_matches('"').to_string(), + }, + }; + + if variant_name.is_empty() { + return match flag.default_variant.as_ref() { + None => EvaluationResult::fallback(flag_key), + Some(default_variant) if default_variant.is_empty() => { + EvaluationResult::fallback(flag_key) + } + Some(_) => EvaluationResult::error( + ErrorCode::General, + format!( + "Targeting rule returned empty variant name for flag '{}'", + flag_key + ), + ), + }; + } + + match flag.variants.get(&variant_name) { + Some(value) => { + let result = EvaluationResult::targeting_match(value.clone(), variant_name); + Self::with_lazy_metadata(flag_set_metadata, &flag.metadata, result) + } + None => EvaluationResult::error( + ErrorCode::General, + format!( + "Targeting rule returned variant '{}' which is not defined in flag variants", + variant_name + ), + ), + } + } + Err(e) => { + EvaluationResult::error(ErrorCode::ParseError, format!("Evaluation error: {}", e)) + } + } + } + + /// Builds required_context_keys and flag_indices maps from parsed flag config. + /// + /// Returns (required_context_keys, flag_indices, index_to_key_vec). + #[allow(clippy::type_complexity)] + fn build_optimization_maps( + parsing_result: &ParsingResult, + ) -> ( + HashMap>, + HashMap, + Vec, + ) { + let mut required_context_keys = HashMap::new(); + let mut flag_indices = HashMap::new(); + let mut index_to_key = Vec::new(); + + // Sort keys for stable index assignment + let mut flag_keys: Vec<&String> = parsing_result.flags.keys().collect(); + flag_keys.sort(); + + for (index, flag_key) in flag_keys.iter().enumerate() { + let flag = &parsing_result.flags[*flag_key]; + + // Assign index to all flags (not just targeting ones) + flag_indices.insert((*flag_key).clone(), index as u32); + index_to_key.push((*flag_key).clone()); + + // Extract required context keys for flags with compiled targeting + if let Some(ref compiled) = flag.compiled_targeting { + if let Some(keys) = extract_required_context_keys(compiled) { + let mut sorted_keys: Vec = keys.into_iter().collect(); + sorted_keys.sort(); + required_context_keys.insert((*flag_key).clone(), sorted_keys); + } + // None means "send all context" (rule uses {"var": ""}) + } + } + + (required_context_keys, flag_indices, index_to_key) + } + /// Helper function to get a human-readable type name from a JSON value. fn type_name(value: &JsonValue) -> &'static str { match value { @@ -710,3 +987,118 @@ enum ExpectedType { Float, Object, } + +/// Extracts the set of user-context keys that a compiled targeting rule references. +/// +/// Returns `None` if the rule uses `{"var": ""}` (entire context access), +/// meaning the host must send the full context. Returns `Some(keys)` otherwise. +/// +/// The returned keys are top-level context field names (first path segment), +/// excluding `$flagd.*` paths (injected by enrichment) and internal prefixes. +/// `targetingKey` is always included since the fractional operator uses it. +pub fn extract_required_context_keys(compiled: &CompiledLogic) -> Option> { + let mut keys = HashSet::new(); + // Always include targetingKey (used by fractional operator) + keys.insert("targetingKey".to_string()); + + if walk_node_for_vars(&compiled.root, &mut keys) { + Some(keys) + } else { + // Rule accesses entire context — host must send everything + None + } +} + +/// Recursively walks a CompiledNode tree to collect referenced variable paths. +/// +/// Returns `false` if we encounter an empty-path var (meaning "send all context"). +fn walk_node_for_vars(node: &CompiledNode, keys: &mut HashSet) -> bool { + match node { + CompiledNode::Value { .. } => true, + + CompiledNode::Array { nodes } => { + for n in nodes.iter() { + if !walk_node_for_vars(n, keys) { + return false; + } + } + true + } + + CompiledNode::BuiltinOperator { opcode, args } => { + // For Var and Exists opcodes, extract the variable path from the first arg + if *opcode == OpCode::Var || *opcode == OpCode::Exists { + if let Some(first_arg) = args.first() { + if let Some(var_path) = extract_var_path(first_arg) { + if var_path.is_empty() { + // {"var": ""} — entire context access + return false; + } + // Extract top-level key (everything before first '.') + let first_key = var_path.split('.').next().unwrap_or(&var_path).to_string(); + // Skip $flagd paths (injected by enrichment, not from user context) + if !first_key.starts_with("$flagd") { + keys.insert(first_key); + } + } + } + } + // Walk all args (including default values in var args[1]) + for arg in args.iter() { + if !walk_node_for_vars(arg, keys) { + return false; + } + } + true + } + + CompiledNode::CustomOperator { args, .. } => { + for arg in args.iter() { + if !walk_node_for_vars(arg, keys) { + return false; + } + } + true + } + + CompiledNode::StructuredObject { fields } => { + for (_, field_node) in fields.iter() { + if !walk_node_for_vars(field_node, keys) { + return false; + } + } + true + } + } +} + +/// Extracts the variable path string from a CompiledNode that represents a var argument. +/// +/// In datalogic-rs 4.0, `{"var": "email"}` compiles to +/// `BuiltinOperator { opcode: Var, args: [Value { value: "email" }] }`. +/// This function extracts "email" from the first argument. +fn extract_var_path(node: &CompiledNode) -> Option { + match node { + CompiledNode::Value { value } => { + // String path: {"var": "email"} or {"var": "user.name"} + if let Some(s) = value.as_str() { + Some(s.to_string()) + } else if value.is_null() { + // {"var": null} is equivalent to {"var": ""} + Some(String::new()) + } else { + // Numeric or array path — treat as needing full context for safety + None + } + } + CompiledNode::Array { nodes } => { + // {"var": ["path"]} — extract from first element + if let Some(first) = nodes.first() { + extract_var_path(first) + } else { + Some(String::new()) + } + } + _ => None, // Dynamic var path — can't determine statically + } +} diff --git a/src/lib.rs b/src/lib.rs index 9d9dc0b..9217590 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -438,6 +438,97 @@ pub extern "C" fn evaluate_reusable( string_to_memory(&result.to_json_string()) } +/// Evaluates a feature flag by numeric index with pre-enriched context. +/// +/// This is a high-performance variant that: +/// - Uses O(1) Vec index lookup instead of string-based HashMap lookup +/// - Expects the context to be pre-enriched with `$flagd.*` and `targetingKey` by the host +/// - Does NOT deallocate input buffers (caller manages them) +/// +/// # Arguments +/// * `flag_index` - Numeric index from the `flagIndices` map returned by `update_state` +/// * `context_ptr` - Pointer to the pre-enriched evaluation context JSON string +/// * `context_len` - Length of the context string +/// +/// # Returns +/// A packed u64 containing the pointer (upper 32 bits) and length (lower 32 bits) +/// of the JSON-encoded EvaluationResult string. +/// +/// # Safety +/// The caller must ensure: +/// - `context_ptr` points to valid memory (or is null with context_len=0) +/// - The memory region is valid UTF-8 +/// - The caller manages the input buffer lifecycle (NOT freed by this function) +/// - The caller will free the returned result memory using `dealloc` +#[no_mangle] +pub extern "C" fn evaluate_by_index( + flag_index: u32, + context_ptr: *const u8, + context_len: u32, +) -> u64 { + let result = evaluate_by_index_internal(flag_index, context_ptr, context_len); + string_to_memory(&result.to_json_string()) +} + +/// Internal implementation of evaluate_by_index. +fn evaluate_by_index_internal( + flag_index: u32, + context_ptr: *const u8, + context_len: u32, +) -> EvaluationResult { + init_panic_hook(); + + let result = std::panic::catch_unwind(|| { + wasm_evaluator::with_evaluator(|eval| { + if eval.get_state().is_none() { + return EvaluationResult::error( + ErrorCode::FlagNotFound, + "Flag state not initialized. Call update_state first.", + ); + } + + // Parse context (pre-enriched by host) + let context: Value = if context_ptr.is_null() || context_len == 0 { + Value::Null + } else { + // SAFETY: The caller guarantees valid memory regions + let context_str = match unsafe { string_from_memory(context_ptr, context_len) } { + Ok(s) => s, + Err(e) => { + return EvaluationResult::error( + ErrorCode::ParseError, + format!("Failed to read context: {}", e), + ) + } + }; + + match serde_json::from_str(&context_str) { + Ok(v) => v, + Err(e) => { + return EvaluationResult::error( + ErrorCode::ParseError, + format!("Failed to parse context JSON: {}", e), + ) + } + } + }; + + eval.evaluate_flag_by_index(flag_index, &context) + }) + }); + + result.unwrap_or_else(|panic_err| { + let msg = if let Some(s) = panic_err.downcast_ref::<&str>() { + format!("Evaluation panic: {}", s) + } else if let Some(s) = panic_err.downcast_ref::() { + format!("Evaluation panic: {}", s) + } else { + "Evaluation panic: unknown error".to_string() + }; + EvaluationResult::error(ErrorCode::General, msg) + }) +} + /// Internal implementation of evaluate. fn evaluate_internal( flag_key_ptr: *const u8, @@ -1298,4 +1389,405 @@ mod wasm_tests { let result = evaluate_wasm("unicodeFlag", "{}"); assert_eq!(result.value, json!("Hello 👋 World 🌍")); } + + #[test] + fn test_wasm_evaluate_by_index() { + reset_wasm_evaluator(); + + let config = r#"{ + "flags": { + "boolFlag": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off", + "targeting": { + "if": [ + {"==": [{"var": "role"}, "admin"]}, + "on", + "off" + ] + } + } + } + }"#; + + let response_json = update_state_wasm(config); + let response: Value = serde_json::from_str(&response_json).unwrap(); + assert!(response["success"].as_bool().unwrap()); + + // Get the flag index from the response + let flag_indices = response["flagIndices"].as_object().unwrap(); + let bool_flag_index = flag_indices["boolFlag"].as_u64().unwrap() as u32; + + // Test with pre-enriched context (matching) + let context = json!({ + "role": "admin", + "targetingKey": "user-1", + "$flagd": { + "flagKey": "boolFlag", + "timestamp": 1234567890 + } + }); + let context_str = context.to_string(); + let context_bytes = context_str.as_bytes(); + + let result = evaluate_by_index_internal( + bool_flag_index, + context_bytes.as_ptr(), + context_bytes.len() as u32, + ); + assert_eq!(result.value, json!(true)); + assert_eq!(result.reason, ResolutionReason::TargetingMatch); + + // Test with non-matching context + let context2 = json!({ + "role": "user", + "targetingKey": "user-2", + "$flagd": { + "flagKey": "boolFlag", + "timestamp": 1234567890 + } + }); + let context2_str = context2.to_string(); + let context2_bytes = context2_str.as_bytes(); + + let result2 = evaluate_by_index_internal( + bool_flag_index, + context2_bytes.as_ptr(), + context2_bytes.len() as u32, + ); + assert_eq!(result2.value, json!(false)); + } + + #[test] + fn test_wasm_evaluate_by_index_invalid_index() { + reset_wasm_evaluator(); + + let config = r#"{ + "flags": { + "flag1": { + "state": "ENABLED", + "variants": {"on": true}, + "defaultVariant": "on" + } + } + }"#; + + update_state_wasm(config); + + let result = evaluate_by_index_internal(999, std::ptr::null(), 0); + assert_eq!(result.reason, ResolutionReason::Error); + assert_eq!(result.error_code, Some(ErrorCode::FlagNotFound)); + } +} + +// ============================================================================ +// Context Key Extraction and Index Mapping Tests +// ============================================================================ + +#[cfg(test)] +mod optimization_tests { + use super::*; + use crate::evaluator::extract_required_context_keys; + use serde_json::json; + + #[test] + fn test_extract_keys_simple_var() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "testFlag": { + "state": "ENABLED", + "variants": {"on": true, "off": false}, + "defaultVariant": "off", + "targeting": { + "if": [ + {"==": [{"var": "email"}, "admin@example.com"]}, + "on", + "off" + ] + } + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let keys = response.required_context_keys.unwrap(); + let flag_keys = keys.get("testFlag").unwrap(); + assert!(flag_keys.contains(&"email".to_string())); + assert!(flag_keys.contains(&"targetingKey".to_string())); + } + + #[test] + fn test_extract_keys_multiple_vars() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "complexFlag": { + "state": "ENABLED", + "variants": {"premium": true, "standard": false, "basic": false}, + "defaultVariant": "basic", + "targeting": { + "if": [ + {"starts_with": [{"var": "email"}, "admin@"]}, + "premium", + { + "if": [ + {"sem_ver": [{"var": "appVersion"}, ">=", "2.0.0"]}, + "standard", + "basic" + ] + } + ] + } + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let keys = response.required_context_keys.unwrap(); + let flag_keys = keys.get("complexFlag").unwrap(); + assert!(flag_keys.contains(&"email".to_string())); + assert!(flag_keys.contains(&"appVersion".to_string())); + assert!(flag_keys.contains(&"targetingKey".to_string())); + } + + #[test] + fn test_extract_keys_ignores_flagd_paths() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "enrichedFlag": { + "state": "ENABLED", + "variants": {"yes": true, "no": false}, + "defaultVariant": "no", + "targeting": { + "if": [ + {"==": [{"var": "$flagd.flagKey"}, "enrichedFlag"]}, + "yes", + "no" + ] + } + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let keys = response.required_context_keys.unwrap(); + let flag_keys = keys.get("enrichedFlag").unwrap(); + // Should only have targetingKey, not $flagd + assert!(!flag_keys.contains(&"$flagd".to_string())); + assert!(flag_keys.contains(&"targetingKey".to_string())); + } + + #[test] + fn test_extract_keys_empty_var_returns_none() { + // When a rule uses {"var": ""}, it accesses the entire context + // so we can't filter keys + let engine = create_evaluator(); + let rule = json!({"var": ""}); + let compiled = engine.compile(&rule).unwrap(); + let result = extract_required_context_keys(&compiled); + assert!(result.is_none()); + } + + #[test] + fn test_flag_indices_assigned() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "flagB": { + "state": "ENABLED", + "variants": {"on": true}, + "defaultVariant": "on" + }, + "flagA": { + "state": "ENABLED", + "variants": {"off": false}, + "defaultVariant": "off" + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let indices = response.flag_indices.unwrap(); + + // Indices should be assigned in sorted order + assert_eq!(*indices.get("flagA").unwrap(), 0); + assert_eq!(*indices.get("flagB").unwrap(), 1); + } + + #[test] + fn test_evaluate_by_index_matches_evaluate_flag() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "targetedFlag": { + "state": "ENABLED", + "variants": {"admin": "admin-value", "user": "user-value"}, + "defaultVariant": "user", + "targeting": { + "if": [ + {"==": [{"var": "role"}, "admin"]}, + "admin", + "user" + ] + } + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let indices = response.flag_indices.unwrap(); + let index = *indices.get("targetedFlag").unwrap(); + + // Create pre-enriched context + let context = json!({ + "role": "admin", + "targetingKey": "user-1", + "$flagd": { + "flagKey": "targetedFlag", + "timestamp": 1234567890 + } + }); + + let result_by_index = evaluator.evaluate_flag_by_index(index, &context); + let result_by_key = evaluator.evaluate_flag("targetedFlag", &json!({"role": "admin"})); + + assert_eq!(result_by_index.value, result_by_key.value); + assert_eq!(result_by_index.variant, result_by_key.variant); + assert_eq!(result_by_index.reason, result_by_key.reason); + } + + #[test] + fn test_evaluate_flag_pre_enriched() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "myFlag": { + "state": "ENABLED", + "variants": {"yes": "found-key", "no": "no-key"}, + "defaultVariant": "no", + "targeting": { + "if": [ + {"!=": [{"var": "targetingKey"}, ""]}, + "yes", + "no" + ] + } + } + } + }"#; + + evaluator.update_state(config).unwrap(); + + // Pre-enriched context (has $flagd) + let context = json!({ + "targetingKey": "user-123", + "$flagd": { + "flagKey": "myFlag", + "timestamp": 1234567890 + } + }); + + let result = evaluator.evaluate_flag_pre_enriched("myFlag", &context); + assert_eq!(result.value, json!("found-key")); + assert_eq!(result.reason, ResolutionReason::TargetingMatch); + } + + #[test] + fn test_evaluate_flag_pre_enriched_falls_back_to_normal() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "myFlag": { + "state": "ENABLED", + "variants": {"yes": "found-key", "no": "no-key"}, + "defaultVariant": "no", + "targeting": { + "if": [ + {"!=": [{"var": "targetingKey"}, ""]}, + "yes", + "no" + ] + } + } + } + }"#; + + evaluator.update_state(config).unwrap(); + + // Context without $flagd — should fall back to normal enrichment + let context = json!({"targetingKey": "user-456"}); + let result = evaluator.evaluate_flag_pre_enriched("myFlag", &context); + assert_eq!(result.value, json!("found-key")); + assert_eq!(result.reason, ResolutionReason::TargetingMatch); + } + + #[test] + fn test_static_flags_not_in_required_context_keys() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "staticFlag": { + "state": "ENABLED", + "variants": {"on": true}, + "defaultVariant": "on" + }, + "targetedFlag": { + "state": "ENABLED", + "variants": {"a": "val-a", "b": "val-b"}, + "defaultVariant": "a", + "targeting": { + "if": [{"==": [{"var": "tier"}, "premium"]}, "b", "a"] + } + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let keys = response.required_context_keys.unwrap(); + + // Static flags should not appear in required_context_keys + assert!(!keys.contains_key("staticFlag")); + // Targeted flags should + assert!(keys.contains_key("targetedFlag")); + let targeted_keys = keys.get("targetedFlag").unwrap(); + assert!(targeted_keys.contains(&"tier".to_string())); + } + + #[test] + fn test_fractional_operator_includes_targeting_key() { + let mut evaluator = FlagEvaluator::new(ValidationMode::Permissive); + + let config = r#"{ + "flags": { + "abTestFlag": { + "state": "ENABLED", + "variants": {"control": "control", "treatment": "treatment"}, + "defaultVariant": "control", + "targeting": { + "fractional": [ + ["control", 50], + ["treatment", 50] + ] + } + } + } + }"#; + + let response = evaluator.update_state(config).unwrap(); + let keys = response.required_context_keys.unwrap(); + let flag_keys = keys.get("abTestFlag").unwrap(); + // targetingKey is always included + assert!(flag_keys.contains(&"targetingKey".to_string())); + } } diff --git a/src/model/mod.rs b/src/model/mod.rs index 850490e..696e0b8 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -35,4 +35,19 @@ pub struct UpdateStateResponse { /// WASM boundary overhead on every evaluation call. #[serde(skip_serializing_if = "Option::is_none")] pub pre_evaluated: Option>, + + /// Per-flag required context keys for host-side filtering. + /// + /// When present, the host should only serialize the listed context keys + /// (plus `$flagd.*` enrichment and `targetingKey`) before calling evaluate. + /// `None` for a flag means "send all context" (e.g., the rule uses `{"var": ""}`). + #[serde(skip_serializing_if = "Option::is_none")] + pub required_context_keys: Option>>, + + /// Flag key to numeric index mapping for `evaluate_by_index`. + /// + /// Allows the host to call `evaluate_by_index(index, ...)` instead of + /// passing flag key strings, avoiding string serialization overhead. + #[serde(skip_serializing_if = "Option::is_none")] + pub flag_indices: Option>, }