diff --git a/Cargo.toml b/Cargo.toml index ab5854d..7b1fc8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ log = "0.4.14" murmur3 = "0.5.1" rand = "0.8.4" rustversion = "1.0.7" +semver = "1.0.18" serde_json = "1.0.68" serde_plain = "1.0.0" surf = { version = "2.3.1", optional = true } diff --git a/README.md b/README.md index 1093f49..d4cff54 100644 --- a/README.md +++ b/README.md @@ -107,4 +107,4 @@ UNLEASH_API_URL=http://127.0.0.1:4242/api \ or similar. The functional test suite looks for a manually setup set of features. E.g. log into the Unleash UI on port 4242 and create a feature called -`default`. +`default` & `semver` with a `SEMVER_EQ` strategy constraint. diff --git a/docker-compose.yml b/docker-compose.yml index 3510ec1..0771287 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.3" services: web: - image: unleashorg/unleash-server:4.12.6 + image: unleashorg/unleash-server:4.16.0 ports: - "4242:4242" environment: diff --git a/src/api.rs b/src/api.rs index a0babdb..078ccf5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -50,13 +50,29 @@ pub struct Constraint { } #[derive(Clone, Serialize, Deserialize, Debug)] -#[serde(tag = "operator", content = "values")] +#[serde(tag = "operator")] #[cfg_attr(feature = "strict", serde(deny_unknown_fields))] pub enum ConstraintExpression { #[serde(rename = "IN")] - In(Vec), + In(MultiValueExpression), #[serde(rename = "NOT_IN")] - NotIn(Vec), + NotIn(MultiValueExpression), + #[serde(rename = "SEMVER_EQ")] + SemverEq(SingleValueExpression), + #[serde(rename = "SEMVER_GT")] + SemverGt(SingleValueExpression), + #[serde(rename = "SEMVER_LT")] + SemverLt(SingleValueExpression), +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct MultiValueExpression { + pub values: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct SingleValueExpression { + pub value: String, } #[derive(Clone, Serialize, Deserialize, Debug)] diff --git a/src/client.rs b/src/client.rs index 654f22a..7f6ecea 100644 --- a/src/client.rs +++ b/src/client.rs @@ -777,10 +777,9 @@ where self.polling.store(true, Ordering::Relaxed); loop { debug!("poll: retrieving features"); - let res = self.http.get_json(&endpoint).await; - if let Ok(res) = res { - let features: Features = res; - match self.memoize(features.features) { + let res: Result = self.http.get_json(&endpoint).await; + match res { + Ok(features) => match self.memoize(features.features) { Ok(None) => {} Ok(Some(metrics)) => { if !self.disable_metric_submission { @@ -800,9 +799,10 @@ where Err(_) => { warn!("poll: failed to memoize features"); } + }, + Err(err) => { + warn!("poll: failed to retrieve features {}", err); } - } else { - warn!("poll: failed to retrieve features"); } let duration = Duration::from_millis(self.interval); diff --git a/src/strategy.rs b/src/strategy.rs index b8d82ca..b4caa71 100644 --- a/src/strategy.rs +++ b/src/strategy.rs @@ -10,8 +10,9 @@ use ipnet::IpNet; use log::{trace, warn}; use murmur3::murmur3_32; use rand::Rng; +use semver::{Version, VersionReq}; -use crate::api::{Constraint, ConstraintExpression}; +use crate::api::{Constraint, ConstraintExpression, MultiValueExpression, SingleValueExpression}; use crate::context::Context; /// Memoise feature state for a strategy. @@ -296,13 +297,13 @@ where F: Fn(&Context) -> Option<&String> + Clone + Sync + Send + 'static, { match &expression { - ConstraintExpression::In(values) => { + ConstraintExpression::In(MultiValueExpression { values, .. }) => { let as_set: HashSet = values.iter().cloned().collect(); Box::new(move |context: &Context| { getter(context).map(|v| as_set.contains(v)).unwrap_or(false) }) } - ConstraintExpression::NotIn(values) => { + ConstraintExpression::NotIn(MultiValueExpression { values, .. }) => { if values.is_empty() { Box::new(|_| true) } else { @@ -314,6 +315,39 @@ where }) } } + ConstraintExpression::SemverEq(SingleValueExpression { value, .. }) => { + evaluate_semver_expression(getter, &format!("={value}")) + } + ConstraintExpression::SemverGt(SingleValueExpression { value, .. }) => { + evaluate_semver_expression(getter, &format!(">{value}")) + } + ConstraintExpression::SemverLt(SingleValueExpression { value, .. }) => { + evaluate_semver_expression(getter, &format!("<{value}")) + } + } +} + +fn evaluate_semver_expression( + getter: F, + version_req: &str, +) -> Box +where + F: Fn(&Context) -> Option<&String> + Clone + Sync + Send + 'static, +{ + let parsed_request = VersionReq::parse(version_req); + match parsed_request { + Ok(req) => Box::new(move |context: &Context| { + getter(context) + .map(|v: &String| { + let parsed = Version::parse(v); + match parsed { + Ok(version) => req.matches(&version), + _ => false, + } + }) + .unwrap_or(false) + }), + _ => Box::new(|_| false), } } @@ -336,7 +370,7 @@ where F: Fn(&Context) -> Option<&crate::context::IPAddress> + Clone + Sync + Send + 'static, { match &expression { - ConstraintExpression::In(values) => { + ConstraintExpression::In(MultiValueExpression { values, .. }) => { let ips = _ip_to_vec(values); Box::new(move |context: &Context| { getter(context) @@ -351,7 +385,7 @@ where .unwrap_or(false) }) } - ConstraintExpression::NotIn(values) => { + ConstraintExpression::NotIn(MultiValueExpression { values }) => { if values.is_empty() { Box::new(|_| false) } else { @@ -373,6 +407,7 @@ where }) } } + _ => Box::new(|_| false), } } @@ -452,7 +487,7 @@ mod tests { use maplit::hashmap; - use crate::api::{Constraint, ConstraintExpression}; + use crate::api::{Constraint, ConstraintExpression, MultiValueExpression}; use crate::context::{Context, IPAddress}; fn parse_ip(addr: &str) -> Option { @@ -479,7 +514,7 @@ mod tests { assert!(!super::constrain( Some(vec![Constraint { context_name: "".into(), - expression: ConstraintExpression::In(vec![]), + expression: ConstraintExpression::In(MultiValueExpression { values: vec![] }), }]), &super::default, None @@ -493,7 +528,9 @@ mod tests { assert!(!super::constrain( Some(vec![Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec!["development".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["development".into()] + }), }]), &super::default, None @@ -507,7 +544,9 @@ mod tests { assert!(!super::constrain( Some(vec![Constraint { context_name: "environment".into(), - expression: ConstraintExpression::NotIn(vec!["development".into()]), + expression: ConstraintExpression::NotIn(MultiValueExpression { + values: vec!["development".into()] + }), }]), &super::default, None @@ -521,7 +560,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec!["development".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["development".into()] + }), }]), &super::default, None @@ -534,7 +575,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec!["staging".into(), "development".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["staging".into(), "development".into()] + }), }]), &super::default, None @@ -548,10 +591,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "environment".into(), - expression: ConstraintExpression::NotIn(vec![ - "staging".into(), - "development".into() - ]), + expression: ConstraintExpression::NotIn(MultiValueExpression { + values: vec!["staging".into(), "development".into()] + }), }]), &super::default, None @@ -567,7 +609,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "userId".into(), - expression: ConstraintExpression::In(vec!["fred".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["fred".into()] + }), }]), &super::default, None @@ -581,7 +625,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "sessionId".into(), - expression: ConstraintExpression::In(vec!["qwerty".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["qwerty".into()] + }), }]), &super::default, None @@ -595,7 +641,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "remoteAddress".into(), - expression: ConstraintExpression::In(vec!["10.0.0.0/8".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["10.0.0.0/8".into()] + }), }]), &super::default, None @@ -607,7 +655,9 @@ mod tests { assert!(super::constrain( Some(vec![Constraint { context_name: "remoteAddress".into(), - expression: ConstraintExpression::NotIn(vec!["10.0.0.0/8".into()]), + expression: ConstraintExpression::NotIn(MultiValueExpression { + values: vec!["10.0.0.0/8".into()] + }), }]), &super::default, None @@ -623,11 +673,15 @@ mod tests { Some(vec![ Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec!["development".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["development".into()] + }), }, Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec!["development".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["development".into()] + }), }, ]), &super::default, @@ -637,11 +691,13 @@ mod tests { Some(vec![ Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec!["development".into()]), + expression: ConstraintExpression::In(MultiValueExpression { + values: vec!["development".into()] + }), }, Constraint { context_name: "environment".into(), - expression: ConstraintExpression::In(vec![]), + expression: ConstraintExpression::In(MultiValueExpression { values: vec![] }), } ]), &super::default, @@ -649,6 +705,106 @@ mod tests { )(&context)); } + #[test] + fn test_semver_constrain() { + let context = Context { + properties: hashmap! { + "version".to_string() => "1.1.1".to_string() + }, + ..Default::default() + }; + assert!(super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverEq(crate::api::SingleValueExpression { + value: "1.1.1".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(!super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverEq(crate::api::SingleValueExpression { + value: "1.0.1".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverGt(crate::api::SingleValueExpression { + value: "1.0.0".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverGt(crate::api::SingleValueExpression { + value: "1.1.0".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(!super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverGt(crate::api::SingleValueExpression { + value: "1.1.1".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverLt(crate::api::SingleValueExpression { + value: "1.1.2".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverLt(crate::api::SingleValueExpression { + value: "1.2.0".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverLt(crate::api::SingleValueExpression { + value: "2.0.0".to_string() + }), + }]), + &super::default, + None + )(&context)); + assert!(!super::constrain( + Some(vec![Constraint { + context_name: "version".into(), + expression: ConstraintExpression::SemverLt(crate::api::SingleValueExpression { + value: "1.0.0".to_string() + }), + }]), + &super::default, + None + )(&context)); + } + #[test] fn test_user_with_id() { let params: HashMap = hashmap! { diff --git a/tests/functional.rs b/tests/functional.rs index 4c08665..c1bba96 100644 --- a/tests/functional.rs +++ b/tests/functional.rs @@ -19,6 +19,7 @@ mod tests { use futures_timer::Delay; use serde::{Deserialize, Serialize}; + use unleash_api_client::Context; use unleash_api_client::{client, config::EnvironmentConfig, http::HttpClient}; #[cfg(not(any(feature = "surf", feature = "reqwest")))] @@ -28,6 +29,7 @@ mod tests { #[derive(Debug, Deserialize, Serialize, Enum, Clone)] enum UserFeatures { default, + semver } #[async_trait] @@ -120,6 +122,7 @@ mod tests { // Ensure we have features Delay::new(Duration::from_millis(500)).await; assert!(client.is_enabled(UserFeatures::default, None, false)); + assert!(client.is_enabled(UserFeatures::semver, Some(&Context { app_name: "1.0.0".to_string(), ..Context::default() }), false)); // Ensure the metrics get up-loaded Delay::new(Duration::from_millis(500)).await; client.stop_poll().await; @@ -179,7 +182,7 @@ mod tests { A::sleep(Duration::from_millis(500)).await; assert!(client.is_enabled(UserFeatures::default, None, false)); // Ensure the metrics get up-loaded - A::sleep(Duration::from_millis(500)); + let _ = A::sleep(Duration::from_millis(500)); client.stop_poll().await; handler.await;