Skip to content

Commit 81d17d2

Browse files
authored
feat: regex constraints (#338)
1 parent 7ea9f17 commit 81d17d2

File tree

6 files changed

+62
-2
lines changed

6 files changed

+62
-2
lines changed

.github/workflows/sarif-and-test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
uses: actions/checkout@v3
5252
with:
5353
repository: Unleash/client-specification
54-
ref: v5.2.2
54+
ref: v6.0.0
5555
path: client-specification
5656
- name: Run tests
5757
run: |

unleash-yggdrasil/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,14 @@ pest_derive = "2.8.3"
2121
lazy_static = "1.5.0"
2222
semver = "1.0.27"
2323
convert_case = "0.8.0"
24-
unleash-types = {version = "0.15.21", default-features = false}
24+
unleash-types = {version = "0.15.22", default-features = false}
2525
chrono = { version = "0.4.42", default-features = false, features = ["serde", "std"] }
2626
dashmap = "6.1.0"
2727
hostname = { version = "0.4.1", optional = true }
2828
ipnetwork = "0.21.0"
2929
ahash = "0.8.12"
3030
hashbrown = "0.16.0"
31+
regex = "1.12.3"
3132

3233
[dependencies.serde]
3334
features = ["derive"]

unleash-yggdrasil/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,7 @@ mod test {
953953
#[test_case("18-utf8-flag-names.json"; "UTF-8 tests")]
954954
#[test_case("19-delta-api-hydration.json"; "Delta hydration tests")]
955955
#[test_case("20-delta-api-events.json"; "Delta events tests")]
956+
#[test_case("21-regex-constraint-operators.json"; "Regex constraint operators")]
956957

957958
fn run_client_spec(spec_name: &str) {
958959
let spec = load_spec(spec_name);

unleash-yggdrasil/src/strategy_grammar.pest

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ string_list_operation_without_case = {
5656
ends_with_ignore_case = { "ends_with_any_ignore_case" }
5757
contains_any_ignore_case = { "contains_any_ignore_case" }
5858

59+
regex_operation = { "matches_regex_ignoring_case" | "matches_regex" }
60+
5961
list_operation = { "in" | "not_in" }
6062

6163
invert_operation = { "!" }
@@ -71,6 +73,7 @@ constraint = {
7173
| numeric_constraint
7274
| default_strategy_constraint
7375
| string_fragment_constraint
76+
| regex_constraint
7477
| list_constraint
7578
| external_value
7679
)
@@ -82,6 +85,7 @@ constraint = {
8285
ip_constraint = { context_value ~ ip_contains_operation ~ string_list }
8386
ip_contains_operation = _{ "contains_ip" }
8487
string_fragment_constraint = { context_value ~ ( string_list_operation_without_case | string_list_operation ) ~ string_list }
88+
regex_constraint = { context_value ~ regex_operation ~ string }
8589
list_constraint = { context_value ~ list_operation ~ ( numeric_list | string_list | empty_list ) }
8690
date_constraint = { context_value ~ ordinal_operation ~ date }
8791
numeric_constraint = { context_value ~ ordinal_operation ~ num }

unleash-yggdrasil/src/strategy_parsing.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use pest::iterators::{Pair, Pairs};
1818
use pest::pratt_parser::{Assoc, Op, PrattParser};
1919
use pest::Parser;
2020
use rand::Rng;
21+
use regex::RegexBuilder;
2122
use semver::Version;
2223

2324
#[cfg(feature = "hostname")]
@@ -594,6 +595,30 @@ fn string_fragment_constraint(node: Pairs<Rule>) -> CompileResult<RuleFragment>
594595
}))
595596
}
596597

598+
fn regex_constraint(node: Pairs<Rule>) -> CompileResult<RuleFragment> {
599+
let [context_getter_node, constraint_type_node, regex_pattern_node] = drain(node)?;
600+
let context_getter = context_value(context_getter_node.into_inner());
601+
let regex_pattern = string(regex_pattern_node);
602+
603+
let mut regex_builder = RegexBuilder::new(&regex_pattern);
604+
605+
if constraint_type_node.as_str() == "matches_regex_ignoring_case" {
606+
regex_builder.case_insensitive(true);
607+
}
608+
609+
let Ok(regex) = regex_builder.build() else {
610+
return Ok(Box::new(move |_context: &Context| false));
611+
};
612+
613+
Ok(Box::new(move |context: &Context| {
614+
if let Some(value) = context_getter(context) {
615+
regex.is_match(value.as_ref())
616+
} else {
617+
false
618+
}
619+
}))
620+
}
621+
597622
fn constraint(mut node: Pairs<Rule>) -> CompileResult<RuleFragment> {
598623
let first = node.next();
599624
let second = node.next();
@@ -607,6 +632,7 @@ fn constraint(mut node: Pairs<Rule>) -> CompileResult<RuleFragment> {
607632
let constraint = match child.as_rule() {
608633
Rule::date_constraint => date_constraint(child.into_inner()),
609634
Rule::numeric_constraint => numeric_constraint(child.into_inner()),
635+
Rule::regex_constraint => regex_constraint(child.into_inner()),
610636
Rule::semver_constraint => semver_constraint(child.into_inner()),
611637
Rule::rollout_constraint => rollout_constraint(child.into_inner()), //TODO: Do we need to support inversion here?
612638
Rule::default_strategy_constraint => default_strategy_constraint(child.into_inner()),
@@ -1097,6 +1123,22 @@ mod tests {
10971123
assert!(rule(&context));
10981124
}
10991125

1126+
#[test]
1127+
fn evaluate_regex_match() {
1128+
let rule = compile_rule("user_id matches_regex \"^[^@]+@[^@]+$\"").unwrap();
1129+
let context = context_from_user_id("test@example.com");
1130+
1131+
assert!(rule(&context));
1132+
}
1133+
1134+
#[test]
1135+
fn evaluate_regex_match_ignoring_case() {
1136+
let rule = compile_rule("user_id matches_regex_ignoring_case \"^[^@]+@[^@]+$\"").unwrap();
1137+
let context = context_from_user_id("TEST@EXAMPLE.COM");
1138+
1139+
assert!(rule(&context));
1140+
}
1141+
11001142
#[cfg(feature = "hostname")]
11011143
mod hostname_tests {
11021144
use serial_test::serial;

unleash-yggdrasil/src/strategy_upgrade.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,11 @@ fn upgrade_constraint(constraint: &Constraint) -> String {
326326
})
327327
.unwrap_or_default();
328328
format!("[{values}]")
329+
} else if constraint.operator == Operator::RegexMatch {
330+
format!(
331+
"\"{}\"",
332+
escape_quotes(constraint.value.as_ref().unwrap_or(&"".to_string()))
333+
)
329334
} else {
330335
if constraint.operator == Operator::SemverEq
331336
|| constraint.operator == Operator::SemverLt
@@ -370,6 +375,13 @@ fn upgrade_operator(op: &Operator, case_insensitive: bool) -> Option<String> {
370375
Some("contains_any".into())
371376
}
372377
}
378+
Operator::RegexMatch => {
379+
if case_insensitive {
380+
Some("matches_regex_ignoring_case".into())
381+
} else {
382+
Some("matches_regex".into())
383+
}
384+
}
373385
Operator::NumEq => Some("==".into()),
374386
Operator::NumGt => Some(">".into()),
375387
Operator::NumGte => Some(">=".into()),

0 commit comments

Comments
 (0)