use crate::hashmap::HashMap;
use derive_more::From;
use regex::Regex;
use semver::Version;
use serde::{Deserialize, Serialize};
use crate::{Error, EvaluationError, Str};
use super::AssignmentValue;
#[allow(missing_docs)]
pub type Timestamp = crate::timestamp::Timestamp;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UniversalFlagConfigWire {
pub created_at: Timestamp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<ConfigurationFormat>,
pub environment: Environment,
pub flags: HashMap<Str, TryParse<FlagWire>>,
#[serde(default)]
pub bandits: HashMap<Str, Vec<BanditVariationWire>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ConfigurationFormat {
Client,
Server,
Precomputed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Environment {
pub name: Str,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum TryParse<T> {
Parsed(T),
ParseFailed(serde_json::Value),
}
impl<T> From<T> for TryParse<T> {
fn from(value: T) -> TryParse<T> {
TryParse::Parsed(value)
}
}
impl<T> From<TryParse<T>> for Option<T> {
fn from(value: TryParse<T>) -> Self {
match value {
TryParse::Parsed(v) => Some(v),
TryParse::ParseFailed(_) => None,
}
}
}
impl<'a, T> From<&'a TryParse<T>> for Option<&'a T> {
fn from(value: &TryParse<T>) -> Option<&T> {
match value {
TryParse::Parsed(v) => Some(v),
TryParse::ParseFailed(_) => None,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct FlagWire {
pub key: Str,
pub enabled: bool,
pub variation_type: VariationType,
pub variations: HashMap<String, VariationWire>,
pub allocations: Vec<AllocationWire>,
pub total_shards: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "rustler", derive(rustler::NifUnitEnum))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[allow(missing_docs)]
pub enum VariationType {
String,
Integer,
Numeric,
Boolean,
Json,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, From, Clone)]
#[serde(untagged)]
pub(crate) enum ValueWire {
Boolean(bool),
Number(f64),
String(Str),
}
impl ValueWire {
pub(crate) fn into_assignment_value(self, ty: VariationType) -> Option<AssignmentValue> {
Some(match ty {
VariationType::String => AssignmentValue::String(self.into_string()?),
VariationType::Integer => AssignmentValue::Integer(self.as_integer()?),
VariationType::Numeric => AssignmentValue::Numeric(self.as_number()?),
VariationType::Boolean => AssignmentValue::Boolean(self.as_boolean()?),
VariationType::Json => {
let raw = self.into_string()?;
let parsed = serde_json::from_str(&raw).ok()?;
AssignmentValue::Json { raw, parsed }
}
})
}
fn as_boolean(&self) -> Option<bool> {
match self {
Self::Boolean(value) => Some(*value),
_ => None,
}
}
fn as_number(&self) -> Option<f64> {
match self {
Self::Number(value) => Some(*value),
_ => None,
}
}
fn as_integer(&self) -> Option<i64> {
let f = self.as_number()?;
let i = f as i64;
if i as f64 == f {
Some(i)
} else {
None
}
}
fn into_string(self) -> Option<Str> {
match self {
Self::String(value) => Some(value),
_ => None,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct VariationWire {
pub key: Str,
pub value: ValueWire,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct AllocationWire {
pub key: Str,
#[serde(default)]
pub rules: Box<[RuleWire]>,
#[serde(default)]
pub start_at: Option<Timestamp>,
#[serde(default)]
pub end_at: Option<Timestamp>,
pub splits: Vec<SplitWire>,
#[serde(default = "default_do_log")]
pub do_log: bool,
}
fn default_do_log() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct RuleWire {
pub conditions: Vec<TryParse<Condition>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "ConditionWire", into = "ConditionWire")]
pub(crate) struct Condition {
pub attribute: Box<str>,
pub check: ConditionCheck,
}
#[derive(Debug, Clone)]
pub(crate) enum ConditionCheck {
Comparison {
operator: ComparisonOperator,
comparand: Comparand,
},
Regex {
expected_match: bool,
regex: Regex,
},
Membership {
expected_membership: bool,
values: Box<[Box<str>]>,
},
Null {
expected_null: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum ComparisonOperator {
Gte,
Gt,
Lte,
Lt,
}
impl From<ComparisonOperator> for ConditionOperator {
fn from(value: ComparisonOperator) -> ConditionOperator {
match value {
ComparisonOperator::Gte => ConditionOperator::Gte,
ComparisonOperator::Gt => ConditionOperator::Gt,
ComparisonOperator::Lte => ConditionOperator::Lte,
ComparisonOperator::Lt => ConditionOperator::Lt,
}
}
}
#[derive(Debug, Clone, PartialEq, PartialOrd, From)]
pub(crate) enum Comparand {
Version(Version),
Number(f64),
}
impl From<Comparand> for ConditionValue {
fn from(value: Comparand) -> ConditionValue {
let s = match value {
Comparand::Version(v) => v.to_string(),
Comparand::Number(n) => n.to_string(),
};
ConditionValue::Single(ValueWire::String(s.into()))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct ConditionWire {
pub attribute: Box<str>,
pub operator: ConditionOperator,
pub value: ConditionValue,
}
impl From<Condition> for ConditionWire {
fn from(condition: Condition) -> Self {
let (operator, value) = match condition.check {
ConditionCheck::Comparison {
operator,
comparand,
} => (operator.into(), comparand.into()),
ConditionCheck::Regex {
expected_match,
regex,
} => (
if expected_match {
ConditionOperator::Matches
} else {
ConditionOperator::NotMatches
},
ConditionValue::Single(ValueWire::String(Str::from(regex.as_str()))),
),
ConditionCheck::Membership {
expected_membership,
values,
} => (
if expected_membership {
ConditionOperator::OneOf
} else {
ConditionOperator::NotOneOf
},
ConditionValue::Multiple(values),
),
ConditionCheck::Null { expected_null } => {
(ConditionOperator::IsNull, expected_null.into())
}
};
ConditionWire {
attribute: condition.attribute,
operator,
value,
}
}
}
impl From<ConditionWire> for Option<Condition> {
fn from(value: ConditionWire) -> Self {
Condition::try_from(value).ok()
}
}
impl TryFrom<ConditionWire> for Condition {
type Error = Error;
fn try_from(condition: ConditionWire) -> Result<Self, Self::Error> {
let attribute = condition.attribute;
let check = match condition.operator {
ConditionOperator::Matches | ConditionOperator::NotMatches => {
let expected_match = condition.operator == ConditionOperator::Matches;
let regex_string = match condition.value {
ConditionValue::Single(ValueWire::String(s)) => s,
_ => {
log::warn!(
"failed to parse condition: {:?} condition with non-string condition value",
condition.operator
);
return Err(Error::EvaluationError(
EvaluationError::UnexpectedConfigurationParseError,
));
}
};
let regex = match Regex::new(®ex_string) {
Ok(regex) => regex,
Err(err) => {
log::warn!("failed to parse condition: failed to compile regex {regex_string:?}: {err:?}");
return Err(Error::EvaluationError(
EvaluationError::UnexpectedConfigurationParseError,
));
}
};
ConditionCheck::Regex {
expected_match,
regex,
}
}
ConditionOperator::Gte
| ConditionOperator::Gt
| ConditionOperator::Lte
| ConditionOperator::Lt => {
let operator = match condition.operator {
ConditionOperator::Gte => ComparisonOperator::Gte,
ConditionOperator::Gt => ComparisonOperator::Gt,
ConditionOperator::Lte => ComparisonOperator::Lte,
ConditionOperator::Lt => ComparisonOperator::Lt,
_ => unreachable!(),
};
let condition_version = match &condition.value {
ConditionValue::Single(ValueWire::String(s)) => Version::parse(s).ok(),
_ => None,
};
if let Some(condition_version) = condition_version {
ConditionCheck::Comparison {
operator,
comparand: Comparand::Version(condition_version),
}
} else {
let condition_value = match &condition.value {
ConditionValue::Single(ValueWire::Number(n)) => Some(*n),
ConditionValue::Single(ValueWire::String(s)) => s.parse().ok(),
_ => None,
};
let Some(condition_value) = condition_value else {
log::warn!("failed to parse condition: comparision value is neither regex, nor number: {:?}", condition.value);
return Err(Error::EvaluationError(
EvaluationError::UnexpectedConfigurationParseError,
));
};
ConditionCheck::Comparison {
operator,
comparand: Comparand::Number(condition_value),
}
}
}
ConditionOperator::OneOf | ConditionOperator::NotOneOf => {
let expected_membership = condition.operator == ConditionOperator::OneOf;
let values = match condition.value {
ConditionValue::Multiple(v) => v,
_ => {
log::warn!("failed to parse condition: membership condition with non-array value: {:?}", condition.value);
return Err(Error::EvaluationError(
EvaluationError::UnexpectedConfigurationParseError,
));
}
};
ConditionCheck::Membership {
expected_membership,
values,
}
}
ConditionOperator::IsNull => {
let ConditionValue::Single(ValueWire::Boolean(expected_null)) = condition.value
else {
log::warn!("failed to parse condition: IS_NULL condition with non-boolean condition value");
return Err(Error::EvaluationError(
EvaluationError::UnexpectedConfigurationParseError,
));
};
ConditionCheck::Null { expected_null }
}
};
Ok(Condition { attribute, check })
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub(crate) enum ConditionOperator {
Matches,
NotMatches,
Gte,
Gt,
Lte,
Lt,
OneOf,
NotOneOf,
IsNull,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
#[allow(missing_docs)]
pub(crate) enum ConditionValue {
Single(ValueWire),
Multiple(Box<[Box<str>]>),
}
impl<T: Into<ValueWire>> From<T> for ConditionValue {
fn from(value: T) -> Self {
Self::Single(value.into())
}
}
impl From<Vec<String>> for ConditionValue {
fn from(value: Vec<String>) -> Self {
Self::Multiple(value.into_iter().map(|it| it.into()).collect())
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct SplitWire {
pub shards: Vec<ShardWire>,
pub variation_key: Str,
#[serde(default)]
pub extra_logging: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub(crate) struct ShardWire {
pub salt: String,
pub ranges: Box<[ShardRange]>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(missing_docs)]
pub struct ShardRange {
pub start: u32,
pub end: u32,
}
impl ShardRange {
pub(crate) fn contains(&self, v: u32) -> bool {
self.start <= v && v < self.end
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BanditVariationWire {
pub key: Str,
pub flag_key: Str,
pub variation_key: Str,
pub variation_value: Str,
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
use super::{TryParse, UniversalFlagConfigWire};
#[test]
fn parse_flags_v1() {
let f = File::open("../sdk-test-data/ufc/flags-v1.json")
.expect("Failed to open ../sdk-test-data/ufc/flags-v1.json");
let _ufc: UniversalFlagConfigWire = serde_json::from_reader(BufReader::new(f)).unwrap();
}
#[test]
fn parse_partially_if_unexpected() {
let ufc: UniversalFlagConfigWire = serde_json::from_str(
&r#"
{
"createdAt": "2024-07-18T00:00:00Z",
"format": "SERVER",
"environment": {"name": "test"},
"flags": {
"success": {
"key": "success",
"enabled": true,
"variationType": "BOOLEAN",
"variations": {},
"allocations": [],
"totalShards": 10000
},
"fail_parsing": {
"key": "fail_parsing",
"enabled": true,
"variationType": "NEW_TYPE",
"variations": {},
"allocations": [],
"totalShards": 10000
}
}
}
"#,
)
.unwrap();
assert!(
matches!(ufc.flags.get("success").unwrap(), TryParse::Parsed(_)),
"{:?} should match TryParse::Parsed(_)",
ufc.flags.get("success").unwrap()
);
assert!(
matches!(
ufc.flags.get("fail_parsing").unwrap(),
TryParse::ParseFailed(_)
),
"{:?} should match TryParse::ParseFailed(_)",
ufc.flags.get("fail_parsing").unwrap()
);
}
}