Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add typed versions of get_assignment() #7

Merged
merged 1 commit into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/simple/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

.unwrap_or_default()
.and_then(|x| x.as_boolean())
// default assignment
.unwrap_or(false);

Expand Down
309 changes: 295 additions & 14 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::{
configuration_store::ConfigurationStore,
poller::{PollerThread, PollerThreadConfig},
sharder::Md5Sharder,
ufc::VariationType,
ClientConfig, Result,
};

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -103,26 +117,293 @@ impl<'a> Client<'a> {
subject_key: &str,
subject_attributes: &SubjectAttributes,
) -> Result<Option<AssignmentValue>> {
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a default_value: String parameter, or whatever the type is returned, to all public get_*_assignment methods. This will match our APIs in other SDKs: https://github.com/Eppo-exp/js-client-sdk-common/blob/main/src/client/eppo-client.ts#L45-L78

) -> Result<Option<String>> {
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<Option<i64>> {
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<Option<f64>> {
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could happen if someone adds the get_*_assignment function to their code but hasn't yet configured a flag in eppo.cloud. This is a normal flow and from the perspective of the client, should not throw an error.

What I want to happen, from the client's perspective, is the default_value be returned. However the FlagNotFound can be returned in some way to inform users of the reason for the value.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's almost exactly what happens in the example.

client.get_boolean_assignment("non-existent", ...) // Err(Error::FlagNotFound)
    // log error (or skip next line and rely on logger)
    .inspect_err(|err| eprintln!("error: {:?}", err)
    .unwrap_or_default() // ignoring errors. Err(_) -> None
    .unwrap_or(false); // default value. None -> false

The functions doesn't "throw" and error, it returns Err(Error::FlagNotFound) to indicate that some attention might be needed, and the user can easily ignore it and continue processing with default value.

/// - [`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<Option<bool>> {
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<Option<serde_json::Value>> {
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<T>(
&self,
flag_key: &str,
subject_key: &str,
subject_attributes: &SubjectAttributes,
expected_type: Option<VariationType>,
convert: impl FnOnce(AssignmentValue) -> T,
) -> Result<Option<T>> {
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 = match configuration
.eval_flag(flag_key, subject_key, subject_attributes, &Md5Sharder) {
Ok(result) => result,
Err(err) => {
log::warn!(target: "eppo",
flag_key,
subject_key,
subject_attributes:serde;
"error occurred while evaluating a flag: {:?}", err,
);
return Err(err);
},
};
let evaluation = match configuration.eval_flag(
flag_key,
subject_key,
subject_attributes,
&Md5Sharder,
expected_type,
) {
Ok(result) => result,
Err(err) => {
log::warn!(target: "eppo",
flag_key,
subject_key,
subject_attributes:serde;
"error occurred while evaluating a flag: {:?}", err,
);
return Err(err);
}
};

log::trace!(target: "eppo",
flag_key,
Expand All @@ -142,7 +423,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.
Expand Down
11 changes: 11 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")]
Expand Down
Loading
Loading