diff --git a/examples/simple/main.rs b/examples/simple/main.rs index 8696f493..d12a3751 100644 --- a/examples/simple/main.rs +++ b/examples/simple/main.rs @@ -23,9 +23,8 @@ pub fn main() -> eppo::Result<()> { // Get assignment for test-subject. let assignment = client - .get_assignment("a-boolean-flag", "test-subject", &HashMap::new()) + .get_boolean_assignment("a-boolean-flag", "test-subject", &HashMap::new()) .unwrap_or_default() - .and_then(|x| x.as_boolean()) // default assignment .unwrap_or(false); diff --git a/src/client.rs b/src/client.rs index 2bf7f730..8002adcb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -9,6 +9,7 @@ use crate::{ configuration_store::ConfigurationStore, poller::{PollerThread, PollerThreadConfig}, sharder::Md5Sharder, + ufc::VariationType, ClientConfig, Result, }; @@ -72,6 +73,19 @@ impl<'a> Client<'a> { /// It is recommended to wait for the Eppo configuration to get fetched with /// [`PollerThread::wait_for_configuration()`]. /// + /// # Typed versions + /// + /// There are typed versions of this function: + /// - [`Client::get_string_assignment()`] + /// - [`Client::get_integer_assignment()`] + /// - [`Client::get_numeric_assignment()`] + /// - [`Client::get_boolean_assignment()`] + /// - [`Client::get_json_assignment()`] + /// + /// It is recommended to use typed versions of this function as they provide additional type + /// safety. They can catch type errors even _before_ evaluating the assignment, which helps to + /// detect errors if subject is not eligible for the flag allocation. + /// /// # Errors /// /// Returns an error in the following cases: @@ -103,6 +117,268 @@ impl<'a> Client<'a> { subject_key: &str, subject_attributes: &SubjectAttributes, ) -> Result> { + self.get_assignment_inner(flag_key, subject_key, subject_attributes, None, |x| x) + } + + /// Retrieves the assignment value for a given feature flag and subject. + /// + /// If the subject is not eligible for any allocation, returns `Ok(None)`. + /// + /// If the configuration has not been fetched yet, returns `Ok(None)`. + /// You should call [`Client::start_poller_thread`] before any call to + /// `get_string_assignment()`. Otherwise, the client will always return `None`. + /// + /// It is recommended to wait for the Eppo configuration to get fetched with + /// [`PollerThread::wait_for_configuration()`]. + /// + /// # Errors + /// + /// Returns an error in the following cases: + /// - [`Error::FlagNotFound`] if the requested flag configuration was not found. + /// - [`Error::InvalidType`] if the requested flag has an invalid type or type conversion fails. + /// - [`Error::ConfigurationParseError`] or [`Error::ConfigurationError`] if the configuration + /// received from the server is invalid. + /// + /// # Examples + /// + /// ``` + /// # fn test(client: &eppo::Client) { + /// let assignment = client + /// .get_string_assignment("a-string-flag", "user-id", &[ + /// ("language".into(), "en".into()) + /// ].into_iter().collect()) + /// .unwrap_or_default() + /// .unwrap_or("default_value".to_owned()); + /// # } + /// ``` + pub fn get_string_assignment( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + ) -> Result> { + self.get_assignment_inner( + flag_key, + subject_key, + subject_attributes, + Some(VariationType::String), + |x| { + x.to_string() + // The unwrap cannot fail because the type is checked during evaluation. + .unwrap() + }, + ) + } + + /// Retrieves the assignment value for a given feature flag and subject as an integer value. + /// + /// If the subject is not eligible for any allocation, returns `Ok(None)`. + /// + /// If the configuration has not been fetched yet, returns `Ok(None)`. + /// You should call [`Client::start_poller_thread`] before any call to + /// `get_integer_assignment()`. Otherwise, the client will always return `None`. + /// + /// It is recommended to wait for the Eppo configuration to get fetched with + /// [`PollerThread::wait_for_configuration()`]. + /// + /// # Errors + /// + /// Returns an error in the following cases: + /// - [`Error::FlagNotFound`] if the requested flag configuration was not found. + /// - [`Error::InvalidType`] if the requested flag has an invalid type or type conversion fails. + /// - [`Error::ConfigurationParseError`] or [`Error::ConfigurationError`] if the configuration + /// received from the server is invalid. + /// + /// # Examples + /// + /// ``` + /// # fn test(client: &eppo::Client) { + /// let assignment = client + /// .get_integer_assignment("an-int-flag", "user-id", &[ + /// ("age".to_owned(), 42.0.into()) + /// ].into_iter().collect()) + /// .unwrap_or_default() + /// .unwrap_or(0); + /// # } + /// ``` + pub fn get_integer_assignment( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + ) -> Result> { + self.get_assignment_inner( + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Integer), + |x| { + x.as_integer() + // The unwrap cannot fail because the type is checked during evaluation. + .unwrap() + }, + ) + } + + /// Retrieves the assignment value for a given feature flag and subject as a numeric value. + /// + /// If the subject is not eligible for any allocation, returns `Ok(None)`. + /// + /// If the configuration has not been fetched yet, returns `Ok(None)`. + /// You should call [`Client::start_poller_thread`] before any call to + /// `get_numeric_assignment()`. Otherwise, the client will always return `None`. + /// + /// It is recommended to wait for the Eppo configuration to get fetched with + /// [`PollerThread::wait_for_configuration()`]. + /// + /// # Errors + /// + /// Returns an error in the following cases: + /// - [`Error::FlagNotFound`] if the requested flag configuration was not found. + /// - [`Error::InvalidType`] if the requested flag has an invalid type or type conversion fails. + /// - [`Error::ConfigurationParseError`] or [`Error::ConfigurationError`] if the configuration + /// received from the server is invalid. + /// + /// # Examples + /// + /// ``` + /// # fn test(client: &eppo::Client) { + /// let assignment = client + /// .get_numeric_assignment("a-num-flag", "user-id", &[ + /// ("age".to_owned(), 42.0.into()) + /// ].iter().cloned().collect()) + /// .unwrap_or_default() + /// .unwrap_or(0.0); + /// # } + /// ``` + pub fn get_numeric_assignment( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + ) -> Result> { + self.get_assignment_inner( + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Numeric), + |x| { + x.as_numeric() + // The unwrap cannot fail because the type is checked during evaluation. + .unwrap() + }, + ) + } + + /// Retrieves the assignment value for a given feature flag and subject as a boolean value. + /// + /// If the subject is not eligible for any allocation, returns `Ok(None)`. + /// + /// If the configuration has not been fetched yet, returns `Ok(None)`. + /// You should call [`Client::start_poller_thread`] before any call to + /// `get_boolean_assignment()`. Otherwise, the client will always return `None`. + /// + /// It is recommended to wait for the Eppo configuration to get fetched with + /// [`PollerThread::wait_for_configuration()`]. + /// + /// # Errors + /// + /// Returns an error in the following cases: + /// - [`Error::FlagNotFound`] if the requested flag configuration was not found. + /// - [`Error::InvalidType`] if the requested flag has an invalid type or type conversion fails. + /// - [`Error::ConfigurationParseError`] or [`Error::ConfigurationError`] if the configuration + /// received from the server is invalid. + /// + /// # Examples + /// + /// ``` + /// # fn test(client: &eppo::Client) { + /// let assignment = client + /// .get_boolean_assignment("a-bool-flag", "user-id", &[ + /// ("age".to_owned(), 42.0.into()) + /// ].into_iter().collect()) + /// .unwrap_or_default() + /// .unwrap_or(false); + /// # } + /// ``` + pub fn get_boolean_assignment( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + ) -> Result> { + self.get_assignment_inner( + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Boolean), + |x| { + x.as_boolean() + // The unwrap cannot fail because the type is checked during evaluation. + .unwrap() + }, + ) + } + + /// Retrieves the assignment value for a given feature flag and subject as a JSON value. + /// + /// If the subject is not eligible for any allocation, returns `Ok(None)`. + /// + /// If the configuration has not been fetched yet, returns `Ok(None)`. + /// You should call [`Client::start_poller_thread`] before any call to + /// `get_json_assignment()`. Otherwise, the client will always return `None`. + /// + /// It is recommended to wait for the Eppo configuration to get fetched with + /// [`PollerThread::wait_for_configuration()`]. + /// + /// # Errors + /// + /// Returns an error in the following cases: + /// - [`Error::FlagNotFound`] if the requested flag configuration was not found. + /// - [`Error::InvalidType`] if the requested flag has an invalid type or type conversion fails. + /// - [`Error::ConfigurationParseError`] or [`Error::ConfigurationError`] if the configuration + /// received from the server is invalid. + /// + /// # Examples + /// + /// ``` + /// # use serde_json::json; + /// # fn test(client: &eppo::Client) { + /// let assignment = client + /// .get_json_assignment("a-json-flag", "user-id", &[ + /// ("language".into(), "en".into()) + /// ].into_iter().collect()) + /// .unwrap_or_default() + /// .unwrap_or(json!({})); + /// # } + /// ``` + pub fn get_json_assignment( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + ) -> Result> { + self.get_assignment_inner( + flag_key, + subject_key, + subject_attributes, + Some(VariationType::Json), + |x| { + x.to_json() + // The unwrap cannot fail because the type is checked during evaluation. + .unwrap() + }, + ) + } + + fn get_assignment_inner( + &self, + flag_key: &str, + subject_key: &str, + subject_attributes: &SubjectAttributes, + expected_type: Option, + convert: impl FnOnce(AssignmentValue) -> T, + ) -> 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 @@ -111,7 +387,13 @@ impl<'a> Client<'a> { }; let evaluation = configuration - .eval_flag(flag_key, subject_key, subject_attributes, &Md5Sharder) + .eval_flag( + flag_key, + subject_key, + subject_attributes, + &Md5Sharder, + expected_type, + ) .inspect_err(|err| { log::warn!(target: "eppo", flag_key, @@ -139,7 +421,7 @@ impl<'a> Client<'a> { self.config.assignment_logger.log_assignment(event); } - Ok(Some(value)) + Ok(Some(convert(value))) } /// Start a poller thread to fetch configuration from the server. diff --git a/src/error.rs b/src/error.rs index e15e8c3c..d93ff9c9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use crate::ufc::VariationType; + /// Represents a result type for operations in the Eppo SDK. /// /// This type alias is used throughout the SDK to indicate the result of operations that may return @@ -17,6 +19,15 @@ pub enum Error { #[error("flag not found")] FlagNotFound, + /// Requested flag has invalid type. + #[error("invalid flag type (expected: {expected:?}, found: {found:?})")] + InvalidType { + /// Expected type of the flag. + expected: VariationType, + /// Actual type of the flag. + found: VariationType, + }, + /// An error occurred while parsing the configuration (server sent unexpected response). It is /// recommended to upgrade the Eppo SDK. #[error("error parsing configuration, try upgrading Eppo SDK")] diff --git a/src/eval.rs b/src/eval.rs index 8b0b40dc..40f0b924 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -5,7 +5,9 @@ use chrono::Utc; use crate::{ client::AssignmentValue, sharder::Sharder, - ufc::{Allocation, Flag, Shard, Split, Timestamp, TryParse, UniversalFlagConfig}, + ufc::{ + Allocation, Flag, Shard, Split, Timestamp, TryParse, UniversalFlagConfig, VariationType, + }, AssignmentEvent, Error, Result, SubjectAttributes, }; @@ -16,17 +18,39 @@ impl UniversalFlagConfig { subject_key: &str, subject_attributes: &SubjectAttributes, sharder: &impl Sharder, + expected_type: Option, ) -> Result)>> { + let flag = self.get_flag(flag_key)?; + + if let Some(ty) = expected_type { + flag.verify_type(ty)?; + } + + flag.eval(subject_key, subject_attributes, sharder) + } + + pub fn get_flag<'a>(&'a self, flag_key: &str) -> Result<&'a Flag> { let flag = self.flags.get(flag_key).ok_or(Error::FlagNotFound)?; match flag { - TryParse::Parsed(flag) => flag.eval(subject_key, subject_attributes, sharder), + TryParse::Parsed(flag) => Ok(flag), TryParse::ParseFailed(_) => Err(Error::ConfigurationParseError), } } } impl Flag { + pub fn verify_type(&self, ty: VariationType) -> Result<()> { + if self.variation_type == ty { + Ok(()) + } else { + Err(Error::InvalidType { + expected: ty, + found: self.variation_type, + }) + } + } + pub fn eval( &self, subject_key: &str, @@ -224,6 +248,7 @@ mod tests { &subject.subject_key, &subject.subject_attributes, &Md5Sharder, + Some(test_file.variation_type), ) .unwrap_or(None); diff --git a/src/lib.rs b/src/lib.rs index cd288f37..15852551 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,14 +2,37 @@ //! //! # Overview //! -//! The SDK revolves around a [`Client`] that evaluates feature flag values for `subjects`, where each +//! The SDK revolves around a [`Client`] that evaluates feature flag values for "subjects", where each //! subject has a unique key and key-value attributes associated with it. Feature flag evaluation //! results in an [`AssignmentValue`] being returned, representing a specific feature flag value assigned //! to the subject. //! +//! # Typed assignments +//! +//! Every Eppo flag has a return type that is set once on creation in the dashboard. Once a flag is +//! created, assignments in code should be made using the corresponding typed function: +//! - [`Client::get_string_assignment()`] +//! - [`Client::get_integer_assignment()`] +//! - [`Client::get_numeric_assignment()`] +//! - [`Client::get_boolean_assignment()`] +//! - [`Client::get_json_assignment()`] +//! +//! These functions provide additional type safety over [`Client::get_assignment()`] as they can +//! detect type mismatch even before evaluating the feature, so the error is returned even if +//! subject is otherwise uneligible (`get_assignment()` return `Ok(None)` in that case). +//! +//! # Assignment logger +//! //! An [`AssignmentLogger`] should be provided to save assignment events to your storage, //! facilitating tracking of which user received which feature flag values. //! +//! ``` +//! # use eppo::ClientConfig; +//! let config = ClientConfig::from_api_key("api-key").assignment_logger(|assignment| { +//! println!("{:?}", assignment); +//! }); +//! ``` +//! //! # Error Handling //! //! Errors are represented by the [`Error`] enum. diff --git a/src/ufc.rs b/src/ufc.rs index 4ca4d643..8652a575 100644 --- a/src/ufc.rs +++ b/src/ufc.rs @@ -63,7 +63,7 @@ fn default_total_shards() -> u64 { 10_000 } -#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum VariationType { String,