From f97a2b1f8902eacd69bbb298a004d3bbc5b680fd Mon Sep 17 00:00:00 2001 From: Oleksii Shmalko Date: Sat, 11 May 2024 08:17:07 +0300 Subject: [PATCH] Initial implementation (#2) * Initialize crate * chore: add CI configuration * feat: add initial API and noop implementation * test: add Makefile to pull test data * feat: Universal Flag Configuration parser * feat: rules evaluation * feat: sharder * feat: eval * test: integrate sdk-test-data test for eval * feat: configuration store * refactor: Ufc -> UniversalFlagConfig * feat: add configuration poller * feat: add logging and error handling --- .github/workflows/ci.yml | 24 +++ .gitignore | 6 +- Cargo.toml | 30 +++ Makefile | 41 ++++ examples/simple/main.rs | 29 +++ src/assignment_logger.rs | 35 ++++ src/client.rs | 261 +++++++++++++++++++++++++ src/config.rs | 59 ++++++ src/configuration_store.rs | 60 ++++++ src/error.rs | 30 +++ src/eval.rs | 243 +++++++++++++++++++++++ src/lib.rs | 23 +++ src/poller.rs | 184 ++++++++++++++++++ src/rules.rs | 385 +++++++++++++++++++++++++++++++++++++ src/sharder.rs | 41 ++++ src/ufc.rs | 247 ++++++++++++++++++++++++ 16 files changed, 1696 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 examples/simple/main.rs create mode 100644 src/assignment_logger.rs create mode 100644 src/client.rs create mode 100644 src/config.rs create mode 100644 src/configuration_store.rs create mode 100644 src/error.rs create mode 100644 src/eval.rs create mode 100644 src/lib.rs create mode 100644 src/poller.rs create mode 100644 src/rules.rs create mode 100644 src/sharder.rs create mode 100644 src/ufc.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..eeed1e06 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Cargo Build & Test + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + build_and_test: + name: Build & Test + runs-on: ubuntu-latest + strategy: + matrix: + toolchain: + - stable + steps: + - uses: actions/checkout@v3 + - run: make test-data + - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + - run: cargo build --verbose + - run: cargo test --verbose + - run: cargo doc --verbose diff --git a/.gitignore b/.gitignore index 6985cf1b..7c115ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Generated by Cargo # will have compiled files and executables -debug/ -target/ +/debug/ +/target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html @@ -12,3 +12,5 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + +/tests/data/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..78396542 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "eppo" +version = "0.1.0" +edition = "2021" +description = "Eppo SDK for Rust" +homepage = "https://docs.geteppo.com/sdks/server-sdks/rust" +repository = "https://github.com/Eppo-exp/rust-sdk" +license = "MIT" +keywords = ["eppo", "feature-flags"] +categories = ["config"] + +[dependencies] +chrono = { version = "0.4.38", features = ["serde"] } +derive_more = "0.99.17" +log = { version = "0.4.21", features = ["kv", "kv_serde"] } +md5 = "0.7.0" +rand = "0.8.5" +regex = "1.10.4" +reqwest = { version = "0.12.4", features = ["blocking", "json"] } +semver = { version = "1.0.22", features = ["serde"] } +serde = { version = "1.0.198", features = ["derive"] } +serde_json = "1.0.116" +thiserror = "1.0.60" +url = "2.5.0" + +[[example]] +name = "simple" + +[dev-dependencies] +env_logger = { version = "0.11.3", features = ["unstable-kv"] } diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..aa8f0c0a --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# Make settings - @see https://tech.davis-hansson.com/p/make/ +SHELL := bash +.ONESHELL: +.SHELLFLAGS := -eu -o pipefail -c +.DELETE_ON_ERROR: +MAKEFLAGS += --warn-undefined-variables +MAKEFLAGS += --no-builtin-rules + +# Log levels +DEBUG := $(shell printf "\e[2D\e[35m") +INFO := $(shell printf "\e[2D\e[36m🔵 ") +OK := $(shell printf "\e[2D\e[32m🟢 ") +WARN := $(shell printf "\e[2D\e[33m🟡 ") +ERROR := $(shell printf "\e[2D\e[31m🔴 ") +END := $(shell printf "\e[0m") + +.PHONY: default +default: help + +## help - Print help message. +.PHONY: help +help: Makefile + @echo "usage: make " + @sed -n 's/^##//p' $< + +## test-data +testDataDir := tests/data/ +branchName := main +githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git +.PHONY: test-data +test-data: + rm -rf ${testDataDir} + git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${testDataDir} + +${testDataDir}: + rm -rf ${testDataDir} + git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${testDataDir} + +.PHONY: test +test: ${testDataDir} + cargo test diff --git a/examples/simple/main.rs b/examples/simple/main.rs new file mode 100644 index 00000000..649dd40f --- /dev/null +++ b/examples/simple/main.rs @@ -0,0 +1,29 @@ +use std::collections::HashMap; + +pub fn main() -> eppo::Result<()> { + env_logger::Builder::from_env(env_logger::Env::new().default_filter_or("eppo")).init(); + + let api_key = std::env::var("EPPO_API_KEY").unwrap(); + let mut client = eppo::ClientConfig::from_api_key(api_key).to_client(); + + // Start a poller thread to fetch configuration from the server. + let poller = client.start_poller_thread()?; + + // Block waiting for configuration. Until this call returns, the client will return None for all + // assignments. + if let Err(err) = poller.wait_for_configuration() { + println!("error requesting configuration: {:?}", err); + } + + // Get assignment for test-subject. + let assignment = client + .get_assignment("a-boolean-flag", "test-subject", &HashMap::new()) + .unwrap_or_default() + .and_then(|x| x.as_boolean()) + // default assignment + .unwrap_or(false); + + println!("Assignment: {:?}", assignment); + + Ok(()) +} diff --git a/src/assignment_logger.rs b/src/assignment_logger.rs new file mode 100644 index 00000000..0d5ad765 --- /dev/null +++ b/src/assignment_logger.rs @@ -0,0 +1,35 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::SubjectAttributes; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssignmentEvent { + pub feature_flag: String, + pub allocation: String, + pub experiment: String, + pub variation: String, + pub subject: String, + pub subject_attributes: SubjectAttributes, + pub timestamp: String, + pub meta_data: HashMap, + #[serde(flatten)] + pub extra_logging: HashMap, +} + +pub trait AssignmentLogger { + fn log_assignment(&self, event: AssignmentEvent); +} + +pub(crate) struct NoopAssignmentLogger; +impl AssignmentLogger for NoopAssignmentLogger { + fn log_assignment(&self, _event: AssignmentEvent) {} +} + +impl AssignmentLogger for T { + fn log_assignment(&self, event: AssignmentEvent) { + self(event); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 00000000..7d63b4f1 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,261 @@ +use std::{collections::HashMap, sync::Arc}; + +use derive_more::From; +use serde::{Deserialize, Serialize}; + +use crate::{ + configuration_store::ConfigurationStore, + poller::{PollerThread, PollerThreadConfig}, + sharder::Md5Sharder, + ClientConfig, Result, +}; + +/// A client for Eppo API. +/// +/// In order to create a client instance, first create [`ClientConfig`]. +/// +/// # Examples +/// ``` +/// # use eppo::{EppoClient, ClientConfig}; +/// EppoClient::new(ClientConfig::from_api_key("api-key")); +/// ``` +pub struct EppoClient<'a> { + configuration_store: Arc, + config: ClientConfig<'a>, +} + +impl<'a> EppoClient<'a> { + /// Create a new `EppoClient` using the specified configuration. + /// + /// ``` + /// # use eppo::{ClientConfig, EppoClient}; + /// let client = EppoClient::new(ClientConfig::from_api_key("api-key")); + /// ``` + pub fn new(config: ClientConfig<'a>) -> Self { + EppoClient { + configuration_store: Arc::new(ConfigurationStore::new()), + config, + } + } + + #[cfg(test)] + fn new_with_configuration_store( + config: ClientConfig<'a>, + configuration_store: Arc, + ) -> Self { + Self { + configuration_store, + config, + } + } + + /// Get variation assignment for the given subject. + pub fn get_assignment( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + ) -> Result> { + let Some(configuration) = self.configuration_store.get_configuration() else { + log::warn!(target: "eppo", flag_key, subject_key; "evaluating a flag before Eppo configuration has been fetched"); + // We treat missing configuration (the poller has not fetched config) as a normal + // scenario (at least for now). + return Ok(None); + }; + + let evaluation = configuration + .eval_flag(flag_key, subject_key, subject_attributes, &Md5Sharder) + .inspect_err(|err| { + log::warn!(target: "eppo", + flag_key, + subject_key, + subject_attributes:serde; + "error occurred while evaluating a flag: {:?}", err, + ); + })?; + + log::trace!(target: "eppo", + flag_key, + subject_key, + subject_attributes:serde, + assignment:serde = evaluation.as_ref().map(|(value, _event)| value); + "evaluated a flag"); + + let Some((value, event)) = evaluation else { + return Ok(None); + }; + + if let Some(event) = event { + log::trace!(target: "eppo", + event:serde; + "logging assignment"); + self.config.assignment_logger.log_assignment(event); + } + + Ok(Some(value)) + } + + /// Start a poller thread to fetch configuration from the server. + pub fn start_poller_thread(&mut self) -> Result { + PollerThread::start(PollerThreadConfig { + store: self.configuration_store.clone(), + base_url: self.config.base_url.clone(), + api_key: self.config.api_key.clone(), + }) + } +} + +pub type SubjectAttributes = HashMap; + +#[derive(Debug, Serialize, Deserialize, PartialEq, PartialOrd, From, Clone)] +#[serde(untagged)] +pub enum AttributeValue { + String(String), + Number(f64), + Boolean(bool), + Null, +} +impl From<&str> for AttributeValue { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub enum AssignmentValue { + String(String), + Integer(i64), + Numeric(f64), + Boolean(bool), + Json(serde_json::Value), +} + +impl AssignmentValue { + pub fn is_string(&self) -> bool { + self.as_str().is_some() + } + pub fn as_str(&self) -> Option<&str> { + match self { + AssignmentValue::String(s) => Some(s), + _ => None, + } + } + + pub fn is_integer(&self) -> bool { + self.as_integer().is_some() + } + pub fn as_integer(&self) -> Option { + match self { + AssignmentValue::Integer(i) => Some(*i), + _ => None, + } + } + + pub fn is_numeric(&self) -> bool { + self.as_numeric().is_some() + } + pub fn as_numeric(&self) -> Option { + match self { + Self::Numeric(n) => Some(*n), + _ => None, + } + } + + pub fn is_boolean(&self) -> bool { + self.as_boolean().is_some() + } + pub fn as_boolean(&self) -> Option { + match self { + AssignmentValue::Boolean(b) => Some(*b), + _ => None, + } + } + + pub fn is_json(&self) -> bool { + self.as_json().is_some() + } + pub fn as_json(&self) -> Option<&serde_json::Value> { + match self { + Self::Json(v) => Some(v), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Arc}; + + use crate::{ + client::AssignmentValue, + configuration_store::ConfigurationStore, + ufc::{Allocation, Flag, Split, TryParse, UniversalFlagConfig, Variation, VariationType}, + ClientConfig, EppoClient, + }; + + #[test] + fn returns_none_while_no_configuration() { + let configuration_store = Arc::new(ConfigurationStore::new()); + let client = EppoClient::new_with_configuration_store( + ClientConfig::from_api_key("api-key"), + configuration_store.clone(), + ); + + assert_eq!( + client + .get_assignment("flag", "subject", &HashMap::new()) + .unwrap(), + None + ); + } + + #[test] + fn returns_proper_configuration_once_config_is_fetched() { + let configuration_store = Arc::new(ConfigurationStore::new()); + let client = EppoClient::new_with_configuration_store( + ClientConfig::from_api_key("api-key"), + configuration_store.clone(), + ); + + // updating configuration after client is created + configuration_store.set_configuration(UniversalFlagConfig { + flags: [( + "flag".to_owned(), + TryParse::Parsed(Flag { + key: "flag".to_owned(), + enabled: true, + variation_type: VariationType::Boolean, + variations: [( + "variation".to_owned(), + Variation { + key: "variation".to_owned(), + value: true.into(), + }, + )] + .into(), + allocations: vec![Allocation { + key: "allocation".to_owned(), + rules: vec![], + start_at: None, + end_at: None, + splits: vec![Split { + shards: vec![], + variation_key: "variation".to_owned(), + extra_logging: HashMap::new(), + }], + do_log: false, + }], + total_shards: 10_000, + }), + )] + .into(), + }); + + assert_eq!( + client + .get_assignment("flag", "subject", &HashMap::new()) + .unwrap(), + Some(AssignmentValue::Boolean(true)) + ); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..7f7077aa --- /dev/null +++ b/src/config.rs @@ -0,0 +1,59 @@ +use crate::{assignment_logger::NoopAssignmentLogger, AssignmentLogger, EppoClient}; + +/// Configuration for [`EppoClient`]. +pub struct ClientConfig<'a> { + pub(crate) api_key: String, + pub(crate) base_url: String, + pub(crate) assignment_logger: Box, +} + +impl<'a> ClientConfig<'a> { + /// Create a default Eppo configuration using the specified API key. + /// + /// ``` + /// # use eppo::ClientConfig; + /// ClientConfig::from_api_key("api-key"); + /// ``` + pub fn from_api_key(api_key: impl Into) -> Self { + ClientConfig { + api_key: api_key.into(), + base_url: ClientConfig::DEFAULT_BASE_URL.to_owned(), + assignment_logger: Box::new(NoopAssignmentLogger), + } + } + + /// Set assignment logger to pass variation assignments to your data warehouse. + /// + /// ``` + /// # use eppo::ClientConfig; + /// let config = ClientConfig::from_api_key("api-key").assignment_logger(|event| { + /// println!("{:?}", event); + /// }); + /// ``` + pub fn assignment_logger( + &mut self, + assignment_logger: impl AssignmentLogger + Send + Sync + 'a, + ) -> &mut Self { + self.assignment_logger = Box::new(assignment_logger); + self + } + + /// Default base URL for API calls. + pub const DEFAULT_BASE_URL: &'static str = "https://fscdn.eppo.cloud/api"; + + /// Override base URL for API calls. Clients should use the default setting in most cases. + pub fn base_url(&mut self, base_url: impl Into) -> &mut Self { + self.base_url = base_url.into(); + self + } + + /// Create a new [`EppoClient`] using the specified configuration. + /// + /// ``` + /// # use eppo::{ClientConfig, EppoClient}; + /// let client: EppoClient = ClientConfig::from_api_key("api-key").to_client(); + /// ``` + pub fn to_client(self) -> EppoClient<'a> { + EppoClient::new(self) + } +} diff --git a/src/configuration_store.rs b/src/configuration_store.rs new file mode 100644 index 00000000..d5bb92bc --- /dev/null +++ b/src/configuration_store.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, RwLock}; + +use crate::ufc::UniversalFlagConfig; + +/// `ConfigurationStore` provides a Sync storage for feature flags configuration that allows +/// concurrent access for readers and writers. +pub struct ConfigurationStore { + configuration: RwLock>>, +} + +impl ConfigurationStore { + pub fn new() -> Self { + Self { + configuration: RwLock::new(None), + } + } + + pub fn get_configuration(&self) -> Option> { + // self.configuration.read() should always return Ok(). Err() is possible only if the lock + // is poisoned (writer panicked while holding the lock), which should never happen. Still, + // using .ok()? here to not crash the app. + let configuration = self.configuration.read().ok()?; + configuration.clone() + } + + /// Set new configuration, returning the previous one. + pub fn set_configuration(&self, ufc: UniversalFlagConfig) -> Option> { + // Constructing new value before requesting the lock to minimize lock span. + let new_value = Some(Arc::new(ufc)); + + let mut configuration_slot = self.configuration.write().ok()?; + std::mem::replace(&mut configuration_slot, new_value) + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Arc}; + + use crate::ufc::UniversalFlagConfig; + + use super::ConfigurationStore; + + #[test] + fn can_set_configuration_from_another_thread() { + let store = Arc::new(ConfigurationStore::new()); + + { + let store = store.clone(); + let _ = std::thread::spawn(move || { + store.set_configuration(UniversalFlagConfig { + flags: HashMap::new(), + }); + }) + .join(); + } + + assert!(store.get_configuration().is_some()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..119eb61c --- /dev/null +++ b/src/error.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Error, Debug, Clone)] +pub enum Error { + #[error("flag not found")] + FlagNotFound, + #[error("error parsing configuration, try upgrading Eppo SDK")] + ConfigurationParseError, + #[error("configuration error")] + ConfigurationError, + #[error("invalid base_url configuration")] + InvalidBaseUrl(#[source] url::ParseError), + #[error("unauthorized, api_key is likely invalid")] + Unauthorized, + // std::io::Error is not clonable, so we're wrapping it in an Arc. + #[error(transparent)] + Io(Arc), + #[error("poller thread panicked")] + PollerThreadPanicked, +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Self::Io(Arc::new(value)) + } +} diff --git a/src/eval.rs b/src/eval.rs new file mode 100644 index 00000000..8b0b40dc --- /dev/null +++ b/src/eval.rs @@ -0,0 +1,243 @@ +use std::collections::HashMap; + +use chrono::Utc; + +use crate::{ + client::AssignmentValue, + sharder::Sharder, + ufc::{Allocation, Flag, Shard, Split, Timestamp, TryParse, UniversalFlagConfig}, + AssignmentEvent, Error, Result, SubjectAttributes, +}; + +impl UniversalFlagConfig { + pub fn eval_flag( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + sharder: &impl Sharder, + ) -> Result)>> { + let flag = self.flags.get(flag_key).ok_or(Error::FlagNotFound)?; + + match flag { + TryParse::Parsed(flag) => flag.eval(subject_key, subject_attributes, sharder), + TryParse::ParseFailed(_) => Err(Error::ConfigurationParseError), + } + } +} + +impl Flag { + pub fn eval( + &self, + subject_key: &str, + subject_attributes: &SubjectAttributes, + sharder: &impl Sharder, + ) -> Result)>> { + if !self.enabled { + return Ok(None); + } + + let now = Utc::now(); + + // Augmenting subject_attributes with id, so that subject_key can be used in the rules. + let augmented_subject_attributes = { + let mut sa = subject_attributes.clone(); + sa.entry("id".into()).or_insert_with(|| subject_key.into()); + sa + }; + + let Some((allocation, split)) = self.allocations.iter().find_map(|allocation| { + allocation + .get_matching_split( + subject_key, + &augmented_subject_attributes, + sharder, + self.total_shards, + now, + ) + .map(|split| (allocation, split)) + }) else { + return Ok(None); + }; + + let variation = self.variations.get(&split.variation_key).ok_or_else(|| { + log::warn!(target: "eppo", + flag_key:display = self.key, + subject_key, + variation_key:display = split.variation_key; + "internal: unable to find variation"); + Error::ConfigurationError + })?; + + let assignment_value = variation + .value + .to_assignment_value(self.variation_type) + .ok_or_else(|| { + log::warn!(target: "eppo", + flag_key:display = self.key, + subject_key, + variation_key:display = split.variation_key; + "internal: unable to convert Value to AssignmentValue"); + Error::ConfigurationError + })?; + + let event = if allocation.do_log { + Some(AssignmentEvent { + feature_flag: self.key.clone(), + allocation: allocation.key.clone(), + experiment: format!("{}-{}", self.key, allocation.key), + variation: variation.key.clone(), + subject: subject_key.to_owned(), + subject_attributes: subject_attributes.clone(), + timestamp: now.to_rfc3339(), + meta_data: HashMap::from([ + ("sdkLanguage".to_owned(), "rust".to_owned()), + ( + "sdkVersion".to_owned(), + env!("CARGO_PKG_VERSION").to_owned(), + ), + ]), + extra_logging: split.extra_logging.clone(), + }) + } else { + None + }; + + Ok(Some((assignment_value, event))) + } +} + +impl Allocation { + pub fn get_matching_split( + &self, + subject_key: &str, + augmented_subject_attributes: &SubjectAttributes, + sharder: &impl Sharder, + total_shards: u64, + now: Timestamp, + ) -> Option<&Split> { + if self.is_allowed_by_time(now) && self.is_allowed_by_rules(augmented_subject_attributes) { + self.splits + .iter() + .find(|split| split.matches(subject_key, sharder, total_shards)) + } else { + None + } + } + + fn is_allowed_by_time(&self, now: Timestamp) -> bool { + let forbidden = + self.start_at.is_some_and(|t| now < t) || self.end_at.is_some_and(|t| now > t); + !forbidden + } + + fn is_allowed_by_rules(&self, augmented_subject_attributes: &SubjectAttributes) -> bool { + self.rules.is_empty() + || self + .rules + .iter() + .any(|rule| rule.eval(augmented_subject_attributes)) + } +} + +impl Split { + /// Return `true` if `subject_key` matches the given split under the provided `sharder`. + /// + /// To match a split, subject must match all underlying shards. + fn matches(&self, subject_key: &str, sharder: &impl Sharder, total_shards: u64) -> bool { + self.shards + .iter() + .all(|shard| shard.matches(subject_key, sharder, total_shards)) + } +} + +impl Shard { + /// Return `true` if `subject_key` matches the given shard under the provided `sharder`. + fn matches(&self, subject_key: &str, sharder: &impl Sharder, total_shards: u64) -> bool { + let h = sharder.get_shard(&format!("{}-{}", self.salt, subject_key), total_shards); + self.ranges.iter().any(|range| range.contains(h)) + } +} + +#[cfg(test)] +mod tests { + use std::fs::{self, File}; + + use serde::{Deserialize, Serialize}; + + use crate::{ + sharder::Md5Sharder, + ufc::{TryParse, UniversalFlagConfig, Value, VariationType}, + SubjectAttributes, + }; + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct TestFile { + flag: String, + variation_type: VariationType, + default_value: TryParse, + subjects: Vec, + } + + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct TestSubject { + subject_key: String, + subject_attributes: SubjectAttributes, + assignment: TryParse, + } + + // Test files have different representation of Value for JSON. Whereas server returns a string + // that has to be further parsed, test files embed the JSON object directly. + // + // Therefore, if we failed to parse "assignment" field as one of the values, we fallback to + // AttributeValue::Json. + fn to_value(try_parse: TryParse) -> Value { + match try_parse { + TryParse::Parsed(v) => v, + TryParse::ParseFailed(json) => Value::String(serde_json::to_string(&json).unwrap()), + } + } + + #[test] + fn evaluation_sdk_test_data() { + let config: UniversalFlagConfig = + serde_json::from_reader(File::open("tests/data/ufc/flags-v1.json").unwrap()).unwrap(); + + for entry in fs::read_dir("tests/data/ufc/tests/").unwrap() { + let entry = entry.unwrap(); + println!("Processing test file: {:?}", entry.path()); + + let f = File::open(entry.path()).unwrap(); + let test_file: TestFile = serde_json::from_reader(f).unwrap(); + + let default_assignment = to_value(test_file.default_value) + .to_assignment_value(test_file.variation_type) + .unwrap(); + + for subject in test_file.subjects { + print!("test subject {:?} ... ", subject.subject_key); + let result = config + .eval_flag( + &test_file.flag, + &subject.subject_key, + &subject.subject_attributes, + &Md5Sharder, + ) + .unwrap_or(None); + + let result_assingment = result + .as_ref() + .map(|(value, _event)| value) + .unwrap_or(&default_assignment); + let expected_assignment = to_value(subject.assignment) + .to_assignment_value(test_file.variation_type) + .unwrap(); + + assert_eq!(result_assingment, &expected_assignment); + println!("ok"); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..9c1bb9ab --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! Eppo Rust SDK. +//! +//! See [`EppoClient`]. + +#![warn(rustdoc::missing_crate_level_docs)] +#![warn(missing_docs)] + +mod assignment_logger; +mod client; +mod config; +mod configuration_store; +mod error; +mod eval; +mod poller; +mod rules; +mod sharder; +mod ufc; + +pub use assignment_logger::{AssignmentEvent, AssignmentLogger}; +pub use client::{AssignmentValue, AttributeValue, EppoClient, SubjectAttributes}; +pub use config::ClientConfig; +pub use error::{Error, Result}; +pub use poller::PollerThread; diff --git a/src/poller.rs b/src/poller.rs new file mode 100644 index 00000000..81ef0f3c --- /dev/null +++ b/src/poller.rs @@ -0,0 +1,184 @@ +use std::{ + sync::{mpsc::RecvTimeoutError, Arc, Condvar, Mutex}, + time::Duration, +}; + +use rand::{thread_rng, Rng}; +use reqwest::{StatusCode, Url}; + +use crate::{configuration_store::ConfigurationStore, Error, Result}; + +pub(crate) struct PollerThreadConfig { + pub store: Arc, + pub base_url: String, + pub api_key: String, +} + +/// A configuration poller thread. +/// +/// Use [`Client.start_poller_thread`] to get an instance of it. +pub struct PollerThread { + join_handle: std::thread::JoinHandle<()>, + + /// Used to send a stop command to the poller thread. + stop_sender: std::sync::mpsc::Sender<()>, + + /// Holds `None` if configuration hasn't been fetched yet. Holds `Some(Ok(()))` if configuration + /// has been fetches successfully. Holds `Some(Err(...))` if there was an error fetching the + /// first configuration. + result: Arc<(Mutex>>, Condvar)>, +} + +const UFC_ENDPOINT: &'static str = "/flag-config/v1/config"; + +const POLL_INTERVAL: Duration = Duration::from_secs(5 * 60); +const POLL_JITTER: Duration = Duration::from_secs(30); + +impl PollerThread { + pub(crate) fn start(config: PollerThreadConfig) -> Result { + let (stop_sender, stop_receiver) = std::sync::mpsc::channel::<()>(); + + let result = Arc::new((Mutex::new(None), Condvar::new())); + + let join_handle = { + // Cloning Arc for move into thread + let result = Arc::clone(&result); + let update_result = move |value| { + *result.0.lock().unwrap() = Some(value); + result.1.notify_all(); + }; + + let client = reqwest::blocking::Client::new(); + let url = Url::parse_with_params( + &format!("{}{}", config.base_url, UFC_ENDPOINT), + &[ + ("apiKey", &*config.api_key), + ("sdkName", "rust"), + ("sdkVersion", env!("CARGO_PKG_VERSION")), + ], + ) + .map_err(|err| Error::InvalidBaseUrl(err))?; + + std::thread::Builder::new() + .name("eppo-poller".to_owned()) + .spawn(move || { + loop { + log::debug!(target: "eppo", "fetching new configuration"); + match client.get(url.clone()).send() { + Ok(response) => { + match response.status() { + StatusCode::OK => { + match response.json() { + Ok(ufc) => { + log::debug!(target: "eppo", "sucessfully fetched configuration"); + config.store.set_configuration(ufc); + update_result(Ok(())); + } + Err(err) => { + log::warn!(target: "eppo", "failed to parse configuration response body: {:?}", err); + } + } + } + StatusCode::UNAUTHORIZED => { + log::warn!(target: "eppo", "client is not authorized. Check your API key"); + update_result(Err(Error::Unauthorized)); + // Anauthorized means that API key is not valid and thus is not + // recoverable. Stop the poller thread. + return; + } + code => { + // Ignore other errors, we'll try another request later. + log::warn!(target: "eppo", "received non-200 response while fetching new configuration: {:?}", code); + } + } + + }, + Err(err) => { + log::warn!(target: "eppo", "error while fetching new configuration: {:?}", err) + }, + }; + + let timeout = jitter(POLL_INTERVAL, POLL_JITTER); + match stop_receiver.recv_timeout(timeout) { + Err(RecvTimeoutError::Timeout) => { + // Timed out. Loop to fetch new configuration. + } + Ok(()) => { + log::debug!(target: "eppo", "poller thread received stop command"); + // The other end asked us to stop the poller thread. + return; + } + Err(RecvTimeoutError::Disconnected) => { + // When the other end of channel disconnects, calls to + // .recv_timeout() return immediately. Use normal thread sleep in + // this case. + std::thread::sleep(timeout); + } + } + } + })? + }; + + Ok(PollerThread { + join_handle, + stop_sender, + result, + }) + } + + /// Block waiting for the first configuration to get fetched. + pub fn wait_for_configuration(&self) -> Result<()> { + let mut lock = self + .result + .0 + .lock() + .map_err(|_| Error::PollerThreadPanicked)?; + loop { + match &*lock { + Some(result) => { + // The poller has already fetched the configuration. Return Ok(()) or a possible + // error. + return result.clone(); + } + None => { + // Block waiting for configuration to get fetched. + lock = self + .result + .1 + .wait(lock) + .map_err(|_| Error::PollerThreadPanicked)?; + } + } + } + } + + /// Stop the poller thread. + /// + /// This function does not wait for the thread to actually stop. + pub fn stop(&self) { + // Error means that the receiver was dropped (thread exited). Ignoring it as there's nothing + // useful we can do. + let _ = self.stop_sender.send(()); + } + + /// Stop the poller thread and block waiting for it to exit. + /// + /// If you don't need to wait for the thread to exit, use [`PollerThread.stop`] instead. + pub fn shutdown(self) -> Result<()> { + // Send stop signal in case it wasn't sent before. + self.stop(); + + // Error means that the thread has panicked and there's nothing useful we can do in that + // case. + self.join_handle + .join() + .map_err(|_| Error::PollerThreadPanicked)?; + + Ok(()) + } +} + +/// Apply a random jitter to `interval`. +fn jitter(interval: Duration, jitter: Duration) -> Duration { + interval + thread_rng().gen_range(Duration::ZERO..jitter) +} diff --git a/src/rules.rs b/src/rules.rs new file mode 100644 index 00000000..d21a0655 --- /dev/null +++ b/src/rules.rs @@ -0,0 +1,385 @@ +use derive_more::From; +use regex::Regex; +use semver::Version; +use serde::{Deserialize, Serialize}; + +use crate::{client::AttributeValue, ufc::Value, SubjectAttributes}; + +#[derive(Debug, Serialize, Deserialize, From)] +#[serde(rename_all = "camelCase")] +pub struct Rule { + conditions: Vec, +} + +impl Rule { + pub fn eval(&self, attributes: &SubjectAttributes) -> bool { + self.conditions + .iter() + .all(|condition| condition.eval(attributes)) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + operator: Operator, + attribute: String, + value: ConditionValue, +} + +impl Condition { + pub fn eval(&self, attributes: &SubjectAttributes) -> bool { + self.operator + .eval(attributes.get(&self.attribute), &self.value) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ConditionValue { + Multiple(Vec), + Single(Value), +} + +impl> From for ConditionValue { + fn from(value: T) -> Self { + Self::Single(value.into()) + } +} +impl> From> for ConditionValue { + fn from(value: Vec) -> Self { + Self::Multiple(value.into_iter().map(Into::into).collect()) + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum Operator { + Matches, + NotMatches, + Gte, + Gt, + Lte, + Lt, + OneOf, + NotOneOf, + IsNull, +} + +impl Operator { + /// Applying `Operator` to the values. Returns `false` if the operator cannot be applied or + /// there's a misconfiguration. + pub fn eval( + &self, + attribute: Option<&AttributeValue>, + condition_value: &ConditionValue, + ) -> bool { + self.try_eval(attribute, condition_value).unwrap_or(false) + } + + /// Try applying `Operator` to the values, returning `None` if the operator cannot be applied. + fn try_eval( + &self, + attribute: Option<&AttributeValue>, + condition_value: &ConditionValue, + ) -> Option { + match self { + Self::Matches | Self::NotMatches => { + let s = match attribute { + Some(AttributeValue::String(s)) => s, + _ => return None, + }; + let regex = match condition_value { + ConditionValue::Single(Value::String(s)) => Regex::new(s).ok()?, + _ => return None, + }; + let matches = regex.is_match(s); + Some(if matches!(self, Self::Matches) { + matches + } else { + !matches + }) + } + + Self::OneOf | Self::NotOneOf => { + let s = match attribute { + Some(AttributeValue::String(s)) => s.clone(), + Some(AttributeValue::Number(n)) => n.to_string(), + Some(AttributeValue::Boolean(b)) => b.to_string(), + _ => return None, + }; + let values = match condition_value { + ConditionValue::Multiple(v) => v, + _ => return None, + }; + let is_one_of = values.iter().any(|v| { + if let Value::String(v) = v { + v == &s + } else { + false + } + }); + Some(if *self == Self::OneOf { + is_one_of + } else { + !is_one_of + }) + } + + Self::IsNull => { + let is_null = + attribute.is_none() || attribute.is_some_and(|v| v == &AttributeValue::Null); + match condition_value { + ConditionValue::Single(Value::Boolean(true)) => Some(is_null), + ConditionValue::Single(Value::Boolean(false)) => Some(!is_null), + _ => None, + } + } + + Self::Gte | Self::Gt | Self::Lte | Self::Lt => { + let condition_version = match condition_value { + ConditionValue::Single(Value::String(s)) => Version::parse(s).ok(), + _ => None, + }; + + if let Some(condition_version) = condition_version { + // semver comparison + + let attribute_version = match attribute { + Some(AttributeValue::String(s)) => Version::parse(s).ok(), + _ => None, + }?; + + Some(match self { + Self::Gt => attribute_version > condition_version, + Self::Gte => attribute_version >= condition_version, + Self::Lt => attribute_version < condition_version, + Self::Lte => attribute_version <= condition_version, + _ => { + // unreachable + return None; + } + }) + } else { + // numeric comparison + let condition_value = match condition_value { + ConditionValue::Single(Value::Number(n)) => *n, + ConditionValue::Single(Value::String(s)) => s.parse().ok()?, + _ => return None, + }; + + let attribute_value = match attribute { + Some(AttributeValue::Number(n)) => *n, + Some(AttributeValue::String(s)) => s.parse().ok()?, + _ => return None, + }; + + Some(match self { + Self::Gt => attribute_value > condition_value, + Self::Gte => attribute_value >= condition_value, + Self::Lt => attribute_value < condition_value, + Self::Lte => attribute_value <= condition_value, + _ => { + // unreachable + return None; + } + }) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{ + rules::{Condition, Operator}, + ufc::Value, + }; + + use super::Rule; + + #[test] + fn matches_regex() { + assert!(Operator::Matches.eval(Some(&"test@example.com".into()), &"^test.*".into())); + assert!(!Operator::Matches.eval(Some(&"example@test.com".into()), &"^test.*".into())); + } + + #[test] + fn not_matches_regex() { + assert!(!Operator::NotMatches.eval(Some(&"test@example.com".into()), &"^test.*".into())); + assert!(!Operator::NotMatches.eval(None, &"^test.*".into())); + assert!(Operator::NotMatches.eval(Some(&"example@test.com".into()), &"^test.*".into())); + } + + #[test] + fn one_of() { + assert!(Operator::OneOf.eval( + Some(&"alice".into()), + &vec![Value::from("alice"), Value::from("bob")].into() + )); + assert!(Operator::OneOf.eval( + Some(&"bob".into()), + &vec![Value::from("alice"), Value::from("bob")].into() + )); + assert!(!Operator::OneOf.eval( + Some(&"charlie".into()), + &vec![Value::from("alice"), Value::from("bob")].into() + )); + } + + #[test] + fn not_one_of() { + assert!(!Operator::NotOneOf.eval( + Some(&"alice".into()), + &vec![Value::from("alice"), Value::from("bob")].into() + )); + assert!(!Operator::NotOneOf.eval( + Some(&"bob".into()), + &vec![Value::from("alice"), Value::from("bob")].into() + )); + assert!(Operator::NotOneOf.eval( + Some(&"charlie".into()), + &vec![Value::from("alice"), Value::from("bob")].into() + )); + + // NOT_ONE_OF fails when attribute is not specified + assert!( + !Operator::NotOneOf.eval(None, &vec![Value::from("alice"), Value::from("bob")].into()) + ); + } + + #[test] + fn one_of_int() { + assert!(Operator::OneOf.eval(Some(&42.0.into()), &vec![Value::from("42")].into())); + } + + #[test] + fn one_of_bool() { + assert!(Operator::OneOf.eval(Some(&true.into()), &vec![Value::from("true")].into())); + assert!(Operator::OneOf.eval(Some(&false.into()), &vec![Value::from("false")].into())); + assert!(!Operator::OneOf.eval(Some(&1.0.into()), &vec![Value::from("true")].into())); + assert!(!Operator::OneOf.eval(Some(&0.0.into()), &vec![Value::from("false")].into())); + assert!(!Operator::OneOf.eval(None, &vec![Value::from("true")].into())); + assert!(!Operator::OneOf.eval(None, &vec![Value::from("false")].into())); + } + + #[test] + fn is_null() { + assert!(Operator::IsNull.eval(None, &true.into())); + assert!(!Operator::IsNull.eval(Some(&10.0.into()), &true.into())); + } + + #[test] + fn is_not_null() { + assert!(!Operator::IsNull.eval(None, &false.into())); + assert!(Operator::IsNull.eval(Some(&10.0.into()), &false.into())); + } + + #[test] + fn gte() { + assert!(Operator::Gte.eval(Some(&18.0.into()), &18.0.into())); + assert!(!Operator::Gte.eval(Some(&17.0.into()), &18.0.into())); + } + #[test] + fn gt() { + assert!(Operator::Gt.eval(Some(&19.0.into()), &18.0.into())); + assert!(!Operator::Gt.eval(Some(&18.0.into()), &18.0.into())); + } + #[test] + fn lte() { + assert!(Operator::Lte.eval(Some(&18.0.into()), &18.0.into())); + assert!(!Operator::Lte.eval(Some(&19.0.into()), &18.0.into())); + } + #[test] + fn lt() { + assert!(Operator::Lt.eval(Some(&17.0.into()), &18.0.into())); + assert!(!Operator::Lt.eval(Some(&18.0.into()), &18.0.into())); + } + + #[test] + fn semver_gte() { + assert!(Operator::Gte.eval(Some(&"1.0.1".into()), &"1.0.0".into())); + assert!(Operator::Gte.eval(Some(&"1.0.0".into()), &"1.0.0".into())); + assert!(!Operator::Gte.eval(Some(&"1.2.0".into()), &"1.10.0".into())); + assert!(Operator::Gte.eval(Some(&"1.13.0".into()), &"1.5.0".into())); + assert!(!Operator::Gte.eval(Some(&"0.9.9".into()), &"1.0.0".into())); + } + #[test] + fn semver_gt() { + assert!(Operator::Gt.eval(Some(&"1.0.1".into()), &"1.0.0".into())); + assert!(!Operator::Gt.eval(Some(&"1.0.0".into()), &"1.0.0".into())); + assert!(!Operator::Gt.eval(Some(&"1.2.0".into()), &"1.10.0".into())); + assert!(Operator::Gt.eval(Some(&"1.13.0".into()), &"1.5.0".into())); + assert!(!Operator::Gt.eval(Some(&"0.9.9".into()), &"1.0.0".into())); + } + #[test] + fn semver_lte() { + assert!(!Operator::Lte.eval(Some(&"1.0.1".into()), &"1.0.0".into())); + assert!(Operator::Lte.eval(Some(&"1.0.0".into()), &"1.0.0".into())); + assert!(Operator::Lte.eval(Some(&"1.2.0".into()), &"1.10.0".into())); + assert!(!Operator::Lte.eval(Some(&"1.13.0".into()), &"1.5.0".into())); + assert!(Operator::Lte.eval(Some(&"0.9.9".into()), &"1.0.0".into())); + } + #[test] + fn semver_lt() { + assert!(!Operator::Lt.eval(Some(&"1.0.1".into()), &"1.0.0".into())); + assert!(!Operator::Lt.eval(Some(&"1.0.0".into()), &"1.0.0".into())); + assert!(Operator::Lt.eval(Some(&"1.2.0".into()), &"1.10.0".into())); + assert!(!Operator::Lt.eval(Some(&"1.13.0".into()), &"1.5.0".into())); + assert!(Operator::Lt.eval(Some(&"0.9.9".into()), &"1.0.0".into())); + } + + #[test] + fn empty_rule() { + let rule = Rule { conditions: vec![] }; + assert!(rule.eval(&HashMap::from([]))); + } + + #[test] + fn single_condition_rule() { + let rule = Rule { + conditions: vec![Condition { + attribute: "age".into(), + operator: Operator::Gt, + value: 10.0.into(), + }], + }; + assert!(rule.eval(&HashMap::from([("age".into(), 11.0.into())]))); + } + + #[test] + fn two_condition_rule() { + let rule = Rule { + conditions: vec![ + Condition { + attribute: "age".into(), + operator: Operator::Gt, + value: 18.0.into(), + }, + Condition { + attribute: "age".into(), + operator: Operator::Lt, + value: 100.0.into(), + }, + ], + }; + assert!(rule.eval(&HashMap::from([("age".into(), 20.0.into())]))); + assert!(!rule.eval(&HashMap::from([("age".into(), 17.0.into())]))); + assert!(!rule.eval(&HashMap::from([("age".into(), 110.0.into())]))); + } + + #[test] + fn missing_attribute() { + let rule = Rule { + conditions: vec![Condition { + attribute: "age".into(), + operator: Operator::Gt, + value: 10.0.into(), + }], + }; + assert!(!rule.eval(&HashMap::from([("name".into(), "alice".into())]))); + } +} diff --git a/src/sharder.rs b/src/sharder.rs new file mode 100644 index 00000000..7cbcab35 --- /dev/null +++ b/src/sharder.rs @@ -0,0 +1,41 @@ +use md5; + +pub trait Sharder { + fn get_shard(&self, input: &str, total_shards: u64) -> u64; +} + +pub struct Md5Sharder; + +impl Sharder for Md5Sharder { + fn get_shard(&self, input: &str, total_shards: u64) -> u64 { + let hash = md5::compute(input); + let int_from_hash: u64 = (hash[0] as u64) << 24 + | (hash[1] as u64) << 16 + | (hash[2] as u64) << 8 + | (hash[3] as u64) << 0; + int_from_hash % total_shards + } +} + +#[cfg(test)] +pub struct DeterministicSharder(std::collections::HashMap); + +#[cfg(test)] +impl Sharder for DeterministicSharder { + fn get_shard(&self, input: &str, total_shards: u64) -> u64 { + self.0.get(input).copied().unwrap_or(0) % total_shards + } +} + +#[cfg(test)] +mod tests { + use crate::sharder::{Md5Sharder, Sharder}; + + #[test] + fn test_md5_sharder() { + assert_eq!(Md5Sharder.get_shard("test-input", 10_000), 5619); + assert_eq!(Md5Sharder.get_shard("alice", 10_000), 3170); + assert_eq!(Md5Sharder.get_shard("bob", 10_000), 7420); + assert_eq!(Md5Sharder.get_shard("charlie", 10_000), 7497); + } +} diff --git a/src/ufc.rs b/src/ufc.rs new file mode 100644 index 00000000..4ca4d643 --- /dev/null +++ b/src/ufc.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; + +use derive_more::From; +use serde::{Deserialize, Serialize}; + +use crate::{client::AssignmentValue, rules::Rule}; + +/// Universal Flag Configuration. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UniversalFlagConfig { + // Value is wrapped in `TryParse` so that if we fail to parse one flag (e.g., new server + // format), we can still serve other flags. + pub flags: HashMap>, +} + +/// `TryParse` allows the subfield to fail parsing without failing the parsing of the whole +/// structure. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TryParse { + Parsed(T), + ParseFailed(serde_json::Value), +} +impl From> for Result { + fn from(value: TryParse) -> Self { + match value { + TryParse::Parsed(v) => Ok(v), + TryParse::ParseFailed(v) => Err(v), + } + } +} +impl From> for Option { + fn from(value: TryParse) -> Self { + match value { + TryParse::Parsed(v) => Some(v), + TryParse::ParseFailed(_) => None, + } + } +} +impl<'a, T> From<&'a TryParse> for Option<&'a T> { + fn from(value: &TryParse) -> Option<&T> { + match value { + TryParse::Parsed(v) => Some(v), + TryParse::ParseFailed(_) => None, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Flag { + pub key: String, + pub enabled: bool, + pub variation_type: VariationType, + pub variations: HashMap, + pub allocations: Vec, + #[serde(default = "default_total_shards")] + pub total_shards: u64, +} + +fn default_total_shards() -> u64 { + 10_000 +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum VariationType { + String, + Integer, + Numeric, + Boolean, + Json, +} + +/// Subset of [`serde_json::Value`]. +/// +/// Unlike [`AssignmentValue`], `Value` is untagged, so we don't know the exact type until we +/// combine it with [`VariationType`]. +#[derive(Debug, Serialize, Deserialize, PartialEq, From)] +#[serde(untagged)] +pub enum Value { + Boolean(bool), + /// Number maps to either [`AssignmentValue::Integer`] or [`AssignmentValue::Numeric`]. + Number(f64), + /// String maps to either [`AssignmentValue::String`] or [`AssignmentValue::Json`]. + String(String), +} + +impl Value { + pub fn to_assignment_value(&self, ty: VariationType) -> Option { + Some(match ty { + VariationType::String => AssignmentValue::String(self.as_string()?.to_owned()), + VariationType::Integer => AssignmentValue::Integer(self.as_integer()?), + VariationType::Numeric => AssignmentValue::Numeric(self.as_number()?), + VariationType::Boolean => AssignmentValue::Boolean(self.as_boolean()?), + VariationType::Json => AssignmentValue::Json(self.as_json()?), + }) + } + + pub fn as_boolean(&self) -> Option { + match self { + Self::Boolean(value) => Some(*value), + _ => None, + } + } + + pub fn as_number(&self) -> Option { + match self { + Self::Number(value) => Some(*value), + _ => None, + } + } + + fn as_integer(&self) -> Option { + let f = self.as_number()?; + let i = f as i64; + if i as f64 == f { + Some(i) + } else { + None + } + } + + pub fn as_string(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn as_json(&self) -> Option { + let s = self.as_string()?; + serde_json::from_str(s).ok()? + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Variation { + pub key: String, + pub value: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Allocation { + pub key: String, + #[serde(default)] + pub rules: Vec, + #[serde(default)] + pub start_at: Option, + #[serde(default)] + pub end_at: Option, + pub splits: Vec, + #[serde(default = "default_do_log")] + pub do_log: bool, +} + +fn default_do_log() -> bool { + true +} + +pub type Timestamp = chrono::DateTime; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Split { + pub shards: Vec, + pub variation_key: String, + #[serde(default = "HashMap::new")] + pub extra_logging: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Shard { + pub salt: String, + pub ranges: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Range { + pub start: u64, + pub end: u64, +} +impl Range { + pub fn contains(&self, v: u64) -> bool { + self.start <= v && v < self.end + } +} + +#[cfg(test)] +mod tests { + use std::{fs::File, io::BufReader}; + + use super::{TryParse, UniversalFlagConfig}; + + #[test] + fn parse_flags_v1() { + let f = File::open("tests/data/ufc/flags-v1.json") + .expect("Failed to open tests/data/ufc/flags-v1.json"); + let _ufc: UniversalFlagConfig = serde_json::from_reader(BufReader::new(f)).unwrap(); + } + + #[test] + fn parse_partially_if_unexpected() { + let ufc: UniversalFlagConfig = serde_json::from_str( + &r#" + { + "flags": { + "success": { + "key": "success", + "enabled": true, + "variationType": "BOOLEAN", + "variations": {}, + "allocations": [] + }, + "fail_parsing": { + "key": "fail_parsing", + "enabled": true, + "variationType": "NEW_TYPE", + "variations": {}, + "allocations": [] + } + } + } + "#, + ) + .unwrap(); + assert!(matches!( + ufc.flags.get("success").unwrap(), + TryParse::Parsed(_) + )); + assert!(matches!( + ufc.flags.get("fail_parsing").unwrap(), + TryParse::ParseFailed(_) + )); + } +}