Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "tests/engine_tests/engine-test-data"]
path = tests/engine_tests/engine-test-data
url = https://github.com/flagsmith/engine-test-data.git
branch = v3.7.0
branch = v3.12.1
310 changes: 257 additions & 53 deletions src/engine.rs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/engine_eval/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ fn default_feature_type() -> String {
/// Represents a multivariate value for a feature flag.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FeatureValue {
/// A stable identifier for the variant, returned as the flag result's variant when selected.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
/// The value of the feature.
pub value: FlagsmithValue,
/// The weight of the feature value variant, as a percentage number (i.e. 100.0).
Expand Down
1 change: 1 addition & 0 deletions src/engine_eval/mappers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ fn map_multivariate_values_to_variants(
mv_values
.iter()
.map(|mv| FeatureValue {
key: None,
value: mv.multivariate_feature_option.value.clone(),
weight: mv.percentage_allocation as f64,
priority: None,
Expand Down
6 changes: 5 additions & 1 deletion src/engine_eval/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@ pub struct FlagResult {
/// The name of the feature.
pub name: String,

/// The reason for this evaluation result (e.g., "DEFAULT", "TARGETING_MATCH; segment=name", "SPLIT; weight=50").
/// The reason for this evaluation result (e.g., "STATIC", "DEFAULT", "TARGETING_MATCH; segment=name", "SPLIT; weight=50").
pub reason: String,

/// The value of the feature flag.
pub value: FlagsmithValue,

/// The key of the multivariate variant selected, "control" for the control bucket, or `None` when no split applied.
#[serde(default)]
pub variant: Option<String>,

/// Metadata about the feature.
#[serde(default)]
pub metadata: FeatureMetadata,
Expand Down
178 changes: 153 additions & 25 deletions src/engine_eval/segment_evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,76 @@ use super::context::{
Condition, ConditionOperator, EngineEvaluationContext, SegmentContext, SegmentRule,
SegmentRuleType,
};
use super::result::FlagResult;
use crate::types::FlagsmithValue;
use crate::utils::hashing;
use regex::Regex;
use semver::Version;
use serde_json_path::JsonPath;
use std::cell::OnceCell;

const FLAGS_PATH_PREFIX: &str = "$.flags";

/// Provides what segment conditions read through JSONPath properties.
pub(crate) trait ConditionInputs {
/// Gets the evaluation context as JSON, or `None` if it cannot be serialised.
fn get_context_json(&self) -> Option<&serde_json::Value>;

/// Gets the evaluated flag for a feature, or `None` if it cannot be resolved.
fn get_flag(&self, feature_name: &str) -> Option<FlagResult>;
}

/// The evaluation context as JSON, serialised at most once however many conditions read it
pub(crate) struct ContextJson<'a> {
ec: &'a EngineEvaluationContext,
json: OnceCell<Option<serde_json::Value>>,
}

impl<'a> ContextJson<'a> {
pub(crate) fn new(ec: &'a EngineEvaluationContext) -> Self {
Self {
ec,
json: OnceCell::new(),
}
}

pub(crate) fn get(&self) -> Option<&serde_json::Value> {
self.json
.get_or_init(|| serde_json::to_value(self.ec).ok())
.as_ref()
}
}

/// Reads flags as unresolvable, for segments evaluated outside a whole evaluation
impl ConditionInputs for ContextJson<'_> {
fn get_context_json(&self) -> Option<&serde_json::Value> {
self.get()
}

fn get_flag(&self, _feature_name: &str) -> Option<FlagResult> {
None
}
}

/// Determines if the given evaluation context matches the segment rules
pub fn is_context_in_segment(ec: &EngineEvaluationContext, segment: &SegmentContext) -> bool {
is_context_in_segment_with_inputs(ec, segment, &ContextJson::new(ec))
}

/// Determines if the given evaluation context matches the segment rules, reading JSONPath
/// properties from `inputs`
pub(crate) fn is_context_in_segment_with_inputs(
ec: &EngineEvaluationContext,
segment: &SegmentContext,
inputs: &dyn ConditionInputs,
) -> bool {
if segment.rules.is_empty() {
return false;
}

// All top-level rules must match
for rule in &segment.rules {
if !context_matches_segment_rule(ec, rule, &segment.key) {
if !context_matches_segment_rule(ec, rule, &segment.key, inputs) {
return false;
}
}
Expand All @@ -29,10 +84,17 @@ fn context_matches_segment_rule(
ec: &EngineEvaluationContext,
rule: &SegmentRule,
segment_key: &str,
inputs: &dyn ConditionInputs,
) -> bool {
// Check conditions if present
if !rule.conditions.is_empty()
&& !matches_conditions_by_rule_type(ec, &rule.conditions, &rule.rule_type, segment_key)
&& !matches_conditions_by_rule_type(
ec,
&rule.conditions,
&rule.rule_type,
segment_key,
inputs,
)
{
return false;
}
Expand All @@ -42,18 +104,19 @@ fn context_matches_segment_rule(
return true;
}

matches_sub_rules_by_rule_type(ec, &rule.rules, &rule.rule_type, segment_key)
matches_sub_rules_by_rule_type(ec, &rule.rules, &rule.rule_type, segment_key, inputs)
}

fn matches_sub_rules_by_rule_type(
ec: &EngineEvaluationContext,
sub_rules: &[SegmentRule],
rule_type: &SegmentRuleType,
segment_key: &str,
inputs: &dyn ConditionInputs,
) -> bool {
let mut results = sub_rules
.iter()
.map(|sr| context_matches_segment_rule(ec, sr, segment_key));
.map(|sr| context_matches_segment_rule(ec, sr, segment_key, inputs));
match rule_type {
SegmentRuleType::All => results.all(|r| r),
SegmentRuleType::Any => results.any(|r| r),
Expand All @@ -67,9 +130,10 @@ fn matches_conditions_by_rule_type(
conditions: &[Condition],
rule_type: &SegmentRuleType,
segment_key: &str,
inputs: &dyn ConditionInputs,
) -> bool {
for condition in conditions {
let condition_matches = context_matches_condition(ec, condition, segment_key);
let condition_matches = context_matches_condition(ec, condition, segment_key, inputs);

match rule_type {
SegmentRuleType::All => {
Expand Down Expand Up @@ -99,9 +163,10 @@ fn context_matches_condition(
ec: &EngineEvaluationContext,
condition: &Condition,
segment_key: &str,
inputs: &dyn ConditionInputs,
) -> bool {
let context_value = if !condition.property.is_empty() {
get_context_value(ec, &condition.property)
get_context_value(ec, &condition.property, inputs)
} else {
None
};
Expand All @@ -124,10 +189,14 @@ fn context_matches_condition(
}

/// Gets a value from the context by property name or JSONPath
fn get_context_value(ec: &EngineEvaluationContext, property: &str) -> Option<FlagsmithValue> {
fn get_context_value(
ec: &EngineEvaluationContext,
property: &str,
inputs: &dyn ConditionInputs,
) -> Option<FlagsmithValue> {
// If property starts with $., try to parse it as a JSONPath expression
if property.starts_with("$.") {
if let Some(value) = get_value_from_jsonpath(ec, property) {
if let Some(value) = get_value_from_jsonpath(property, inputs) {
return Some(value);
}
// If JSONPath parsing fails, fall through to treat it as a trait name
Expand All @@ -144,30 +213,89 @@ fn get_context_value(ec: &EngineEvaluationContext, property: &str) -> Option<Fla
}

/// Gets a value from the context using JSONPath
fn get_value_from_jsonpath(ec: &EngineEvaluationContext, path: &str) -> Option<FlagsmithValue> {
// Parse the JSONPath expression
let json_path = match JsonPath::parse(path) {
Ok(p) => p,
Err(_) => return None,
};
fn get_value_from_jsonpath(path: &str, inputs: &dyn ConditionInputs) -> Option<FlagsmithValue> {
let json_path = JsonPath::parse(path).ok()?;

if let Some((feature_name, flag_path)) = split_flag_path(path) {
let flag = inputs.get_flag(&feature_name)?;
let flag_json = serde_json::to_value(flag).ok()?;
let flag_json_path = JsonPath::parse(&flag_path).ok()?;
return query_scalar(&flag_json_path, &flag_json);
}

// Serialize the context to JSON
let context_json = match serde_json::to_value(ec) {
Ok(v) => v,
Err(_) => return None,
query_scalar(&json_path, inputs.get_context_json()?)
}

/// Splits a path reading a single flag, e.g. `$.flags['my feature'].enabled`, into the
/// feature name and a path into that flag's result, e.g. `$.enabled`.
/// Expects a valid JSONPath; returns `None` for any path not selecting one flag by name.
fn split_flag_path(path: &str) -> Option<(String, String)> {
let is_blank = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r');
let rest = path
.strip_prefix(FLAGS_PATH_PREFIX)?
.trim_start_matches(is_blank);

let (feature_name, flag_path) = if let Some(dot_name) = rest.strip_prefix('.') {
let name_end = dot_name
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii()))
.unwrap_or(dot_name.len());
if name_end == 0 {
return None;
}
(dot_name[..name_end].to_string(), &dot_name[name_end..])
} else {
let selector = rest.strip_prefix('[')?.trim_start_matches(is_blank);
let quote = selector
.chars()
.next()
.filter(|c| *c == '\'' || *c == '"')?;
let literal = &selector[1..];
let mut escaped = false;
let literal_end = literal.find(|c: char| {
let is_end = !escaped && c == quote;
escaped = !escaped && c == '\\';
is_end
})?;
let flag_path = literal[literal_end + 1..]
.trim_start_matches(is_blank)
.strip_prefix(']')?;
(
unescape_string_literal(&literal[..literal_end], quote)?,
flag_path,
)
};

// Query the JSON using the path
let result = json_path.query(&context_json);
Some((feature_name, format!("${flag_path}")))
}

// Get the first match (if any)
let node_list = result.all();
if node_list.is_empty() {
return None;
/// Unescapes the contents of a JSONPath string literal, which share JSON's escapes plus `\'`
fn unescape_string_literal(contents: &str, quote: char) -> Option<String> {
let mut json_string = String::with_capacity(contents.len() + 2);
json_string.push('"');
let mut chars = contents.chars();
while let Some(c) = chars.next() {
match c {
'\\' => match chars.next()? {
'\'' => json_string.push('\''),
escaped => {
json_string.push('\\');
json_string.push(escaped);
}
},
'"' if quote == '\'' => json_string.push_str("\\\""),
_ => json_string.push(c),
}
}
json_string.push('"');
serde_json::from_str(&json_string).ok()
}

/// Gets the first scalar value a JSONPath selects in a JSON document
fn query_scalar(json_path: &JsonPath, json: &serde_json::Value) -> Option<FlagsmithValue> {
let node_list = json_path.query(json).all();

// Extract the value from the first match
let value = node_list[0];
let value = *node_list.first()?;

// Convert to FlagsmithValue based on the JSON type
match value {
Expand Down
8 changes: 6 additions & 2 deletions tests/engine_eval_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,21 @@ fn test_engine_evaluation() {
.unwrap_or_else(|e| panic!("Failed to deserialize context for {}: {:?}", test_name, e));

// Get the evaluation result
let result = get_evaluation_result(&context);
let mut result = get_evaluation_result(&context);

// Deserialize the expected result
let expected: EvaluationResult = serde_json::from_value(test_json["result"].clone())
let mut expected: EvaluationResult = serde_json::from_value(test_json["result"].clone())
.unwrap_or_else(|e| {
panic!(
"Failed to deserialize expected result for {}: {:?}",
test_name, e
)
});

// Matched segments are unordered, as are the segments of the context
result.segments.sort_by(|a, b| a.name.cmp(&b.name));
expected.segments.sort_by(|a, b| a.name.cmp(&b.name));

// Compare results - panic immediately on mismatch
assert_eq!(
result.flags, expected.flags,
Expand Down
2 changes: 1 addition & 1 deletion tests/engine_tests/engine-test-data
Submodule engine-test-data updated 177 files
Loading
Loading