diff --git a/Cargo.toml b/Cargo.toml index 446eae0..bdcdd05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,5 +12,6 @@ edition = "2021" members = [ "crates/env-var", "crates/flagd", - "crates/flipt" -] \ No newline at end of file + "crates/flipt", + "crates/ofrep" +] diff --git a/crates/ofrep/Cargo.toml b/crates/ofrep/Cargo.toml new file mode 100644 index 0000000..06f7bec --- /dev/null +++ b/crates/ofrep/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "open-feature-ofrep" +version = "0.0.1" +edition = "2024" + +[dev-dependencies] +wiremock = "0.6.3" +test-log = { version = "0.2", features = ["trace"] } +serial_test = "3.2.0" + +[dependencies] +async-trait = "0.1.88" +open-feature = "0.2.5" +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", + "rustls-tls", +] } +serde_json = "1.0.140" +tracing = "0.1.41" +thiserror = "2.0" +anyhow = "1.0.98" +chrono = "0.4" +once_cell = "1.18" +tokio = { version = "1.45", features = ["full"] } +url = "2.5.4" diff --git a/crates/ofrep/README.md b/crates/ofrep/README.md new file mode 100644 index 0000000..a1e18bd --- /dev/null +++ b/crates/ofrep/README.md @@ -0,0 +1,56 @@ +[Generated by cargo-readme: `cargo readme --no-title --no-license > README.md`]:: + # OFREP Provider for OpenFeature + +A Rust implementation of the OpenFeature OFREP provider, enabling dynamic +feature flag evaluation in your applications. + +This provider allows to connect to any feature flag management system that supports OFREP. + +### Installation +Add the dependency in your `Cargo.toml`: +```bash +cargo add open-feature-ofrep +cargo add open-feature +``` +Then integrate it into your application: + +```rust +use std::time::Duration; +use open_feature::provider::FeatureProvider; +use open_feature::EvaluationContext; +use open_feature_ofrep::{OfrepProvider, OfrepOptions}; +use reqwest::header::{HeaderMap, HeaderValue}; + +#[tokio::main] +async fn main() { + let mut headers = HeaderMap::new(); + headers.insert("color", HeaderValue::from_static("yellow")); + + let provider = OfrepProvider::new(OfrepOptions { + base_url: "http://localhost:8016".to_string(), + headers: headers.clone(), + connect_timeout: Duration::from_secs(4), + ..Default::default() + }).await.unwrap(); + + let context = EvaluationContext::default() + .with_targeting_key("user-123") + .with_custom_field("color", "yellow"); + + let result = provider.resolve_bool_value("isColorYellow", &context).await.unwrap(); + println!("Flag value: {}", result.value); +} +``` + +### Configuration Options +Configurations can be provided as constructor options. The following options are supported: + +| Option | Type / Supported Value | Default | +|-----------------------------------------|-----------------------------------|-------------------------------------| +| base_url | string | http://localhost:8016 | +| headers | HeaderMap | Empty Map | +| connect_timeout | Duration | 10 seconds | + +### License +Apache 2.0 - See [LICENSE](./../../LICENSE) for more information. + diff --git a/crates/ofrep/src/error.rs b/crates/ofrep/src/error.rs new file mode 100644 index 0000000..9d80a5f --- /dev/null +++ b/crates/ofrep/src/error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum OfrepError { + #[error("Provider error: {0}")] + Provider(String), + #[error("Connection error: {0}")] + Connection(String), + #[error("Invalid configuration: {0}")] + Config(String), +} + +// Add implementations for error conversion +impl From> for OfrepError { + fn from(error: Box) -> Self { + OfrepError::Provider(error.to_string()) + } +} + +impl From> for OfrepError { + fn from(error: Box) -> Self { + OfrepError::Provider(error.to_string()) + } +} + +impl From for OfrepError { + fn from(error: anyhow::Error) -> Self { + OfrepError::Provider(error.to_string()) + } +} diff --git a/crates/ofrep/src/lib.rs b/crates/ofrep/src/lib.rs new file mode 100644 index 0000000..8b358e9 --- /dev/null +++ b/crates/ofrep/src/lib.rs @@ -0,0 +1,150 @@ +mod error; +mod resolver; + +use error::OfrepError; +use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::{EvaluationContext, EvaluationError, StructValue}; +use reqwest::header::HeaderMap; +use resolver::Resolver; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; +use tracing::debug; +use tracing::instrument; +use url::Url; + +use async_trait::async_trait; + +const DEFAULT_BASE_URL: &str = "http://localhost:8016"; +const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone)] +pub struct OfrepOptions { + pub base_url: String, + pub headers: HeaderMap, + pub connect_timeout: Duration, +} + +impl Default for OfrepOptions { + fn default() -> Self { + OfrepOptions { + base_url: DEFAULT_BASE_URL.to_string(), + headers: HeaderMap::new(), + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + } + } +} + +pub struct OfrepProvider { + provider: Arc, +} + +impl fmt::Debug for OfrepProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OfrepProvider") + .field("provider", &"") + .finish() + } +} + +impl OfrepProvider { + #[instrument(skip(options))] + pub async fn new(options: OfrepOptions) -> Result { + debug!("Initializing OfrepProvider with options: {:?}", options); + + let url = Url::parse(&options.base_url).map_err(|e| { + OfrepError::Config(format!("Invalid base url: '{}' ({})", options.base_url, e)) + })?; + + if !matches!(url.scheme(), "http" | "https") { + return Err(OfrepError::Config(format!( + "Invalid base url: '{}' (unsupported scheme)", + url.scheme() + ))); + } + + Ok(Self { + provider: Arc::new(Resolver::new(&options)), + }) + } +} + +#[async_trait] +impl FeatureProvider for OfrepProvider { + fn metadata(&self) -> &ProviderMetadata { + self.provider.metadata() + } + + async fn resolve_bool_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + self.provider.resolve_bool_value(flag_key, context).await + } + + async fn resolve_int_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + self.provider.resolve_int_value(flag_key, context).await + } + + async fn resolve_float_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + self.provider.resolve_float_value(flag_key, context).await + } + + async fn resolve_string_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + self.provider.resolve_string_value(flag_key, context).await + } + + async fn resolve_struct_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + self.provider.resolve_struct_value(flag_key, context).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_log::test; + + #[test(tokio::test)] + async fn test_ofrep_options_validation() { + let provider_with_empty_host = OfrepProvider::new(OfrepOptions { + base_url: "http://".to_string(), + ..Default::default() + }) + .await; + + let provider_with_invalid_scheme = OfrepProvider::new(OfrepOptions { + base_url: "invalid://".to_string(), + ..Default::default() + }) + .await; + + assert!(provider_with_empty_host.is_err()); + assert!(provider_with_invalid_scheme.is_err()); + + assert_eq!( + provider_with_empty_host.unwrap_err(), + OfrepError::Config("Invalid base url: 'http://' (empty host)".to_string()) + ); + assert_eq!( + provider_with_invalid_scheme.unwrap_err(), + OfrepError::Config("Invalid base url: 'invalid' (unsupported scheme)".to_string()) + ); + } +} diff --git a/crates/ofrep/src/resolver.rs b/crates/ofrep/src/resolver.rs new file mode 100644 index 0000000..2479dc1 --- /dev/null +++ b/crates/ofrep/src/resolver.rs @@ -0,0 +1,752 @@ +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use once_cell::sync::Lazy; +use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::{ + EvaluationContext, EvaluationContextFieldValue, EvaluationError, EvaluationErrorCode, + EvaluationResult, StructValue, Value, +}; +use reqwest::Client; +use reqwest::StatusCode; +use reqwest::header::RETRY_AFTER; +use std::any; +use tokio::sync::Mutex; +use tracing::{debug, error, instrument}; + +use crate::OfrepOptions; + +static CURRENT_RETRY_AFTER: Lazy>> = Lazy::new(|| Mutex::new(Utc::now())); + +#[derive(Debug)] +pub struct Resolver { + base_url: String, + metadata: ProviderMetadata, + client: Client, +} + +impl Resolver { + pub fn new(options: &OfrepOptions) -> Self { + Self { + base_url: options.base_url.clone(), + metadata: ProviderMetadata::new("ofrep"), + client: Client::builder() + .default_headers(options.headers.clone()) + .connect_timeout(options.connect_timeout) + .build() + .expect("Failed to build HTTP client"), + } + } + + async fn parse_retry_after(retry_after: &str) -> DateTime { + let now = Utc::now(); + + if retry_after.trim().is_empty() { + return now; + } + + if let Ok(seconds) = retry_after.trim().parse::() { + return now + Duration::seconds(seconds); + } + + if let Ok(parsed_date) = retry_after.trim().parse::>() { + return parsed_date.with_timezone(&Utc); + } + + debug!("Failed to parse Retry-After header : {}", retry_after); + now + } + + async fn update_retry_after(new_retry_after: DateTime) { + let mut retry_after = CURRENT_RETRY_AFTER.lock().await; + *retry_after = new_retry_after; + } + + async fn is_rate_limit_exceeded() -> bool { + let retry_after = CURRENT_RETRY_AFTER.lock().await; + Utc::now() < *retry_after + } + + #[instrument(skip(self, evaluation_context), fields(flag_key = %flag_key))] + async fn resolve_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + convertor: fn(serde_json::Value) -> Option, + ) -> EvaluationResult> { + if Resolver::is_rate_limit_exceeded().await { + return Err(EvaluationError { + code: EvaluationErrorCode::General("Rate limit exceeded".to_string()), + message: Some( + "Rate limit exceeded. Please wait before making another request.".to_string(), + ), + }); + } + + debug!("Resolving {} flag", std::any::type_name::()); + let payload = serde_json::json!({ + "context": context_to_json(evaluation_context) + }); + + let response = self + .client + .post(format!( + "{}/ofrep/v1/evaluate/flags/{}", + self.base_url, flag_key + )) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .map_err(|e| { + error!(error = %e, "Failed to parse response {} value", any::type_name::()); + EvaluationError { + code: EvaluationErrorCode::General(format!( + "Failed to resolve {} value ", + std::any::type_name::() + )), + message: Some(e.to_string()), + } + })?; + + debug!(status = response.status().as_u16(), "Received response"); + + match response.status() { + StatusCode::BAD_REQUEST => { + return Err(EvaluationError { + code: EvaluationErrorCode::InvalidContext, + message: Some("Invalid context".to_string()), + }); + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + return Err(EvaluationError { + code: EvaluationErrorCode::General( + "authentication/authorization error".to_string(), + ), + message: Some("authentication/authorization error".to_string()), + }); + } + StatusCode::NOT_FOUND => { + return Err(EvaluationError { + code: EvaluationErrorCode::FlagNotFound, + message: Some(format!("Flag: {flag_key} not found")), + }); + } + StatusCode::TOO_MANY_REQUESTS => { + let header_retry_after: Option<&str> = response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()); + + if let Some(header_retry_after) = header_retry_after { + let new_retry_after: DateTime = + Resolver::parse_retry_after(header_retry_after).await; + Resolver::update_retry_after(new_retry_after).await; + } else { + debug!("Couldn't parse the retry-after header."); + let mut retry_after = CURRENT_RETRY_AFTER.lock().await; + *retry_after = Utc::now(); + } + + let retry_after = CURRENT_RETRY_AFTER.lock().await; + return Err(EvaluationError { + code: EvaluationErrorCode::General("Rate limit exceeded".to_string()), + message: Some(format!("Rate limit exceeded. Retry after {}", *retry_after)), + }); + } + _ => { + let result = response.json::().await.map_err(|e| { + error!(error = %e, "Failed to parse {} response", any::type_name::()); + EvaluationError { + code: EvaluationErrorCode::ParseError, + message: Some(e.to_string()), + } + })?; + let value = convertor(result["value"].clone()).ok_or_else(|| { + error!("Invalid {} value in response", any::type_name::()); + EvaluationError { + code: EvaluationErrorCode::ParseError, + message: Some(format!("Invalid value {}", std::any::type_name::())), + } + })?; + + debug!(value = ?value, variant = ?result["variant"], "Flag evaluated"); + Ok(ResolutionDetails { + value, + variant: result["variant"].as_str().map(String::from), + reason: Some(open_feature::EvaluationReason::Static), + flag_metadata: Default::default(), + }) + } + } + } +} + +#[async_trait] +impl FeatureProvider for Resolver { + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn resolve_bool_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_value(flag_key, evaluation_context, |value| value.as_bool()) + .await + } + + async fn resolve_string_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_value(flag_key, evaluation_context, |value| { + value.as_str().map(|s| s.to_string()) + }) + .await + } + + async fn resolve_float_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_value(flag_key, evaluation_context, |value| value.as_f64()) + .await + } + + async fn resolve_int_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_value(flag_key, evaluation_context, |value| value.as_i64()) + .await + } + + async fn resolve_struct_value( + &self, + flag_key: &str, + evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + self.resolve_value(flag_key, evaluation_context, |value| { + value.into_feature_value().as_struct().cloned() + }) + .await + } +} + +fn context_to_json(context: &EvaluationContext) -> serde_json::Value { + let mut fields = serde_json::Map::new(); + + if let Some(targeting_key) = &context.targeting_key { + fields.insert( + "targetingKey".to_string(), + serde_json::Value::String(targeting_key.clone()), + ); + } + + for (key, value) in &context.custom_fields { + let json_value = match value { + EvaluationContextFieldValue::String(s) => serde_json::Value::String(s.clone()), + EvaluationContextFieldValue::Bool(b) => serde_json::Value::Bool(*b), + EvaluationContextFieldValue::Int(i) => serde_json::Value::Number((*i).into()), + EvaluationContextFieldValue::Float(f) => { + if let Some(n) = serde_json::Number::from_f64(*f) { + serde_json::Value::Number(n) + } else { + serde_json::Value::Null + } + } + EvaluationContextFieldValue::DateTime(dt) => serde_json::Value::String(dt.to_string()), + EvaluationContextFieldValue::Struct(s) => serde_json::Value::String(format!("{s:?}")), + }; + fields.insert(key.clone(), json_value); + } + + serde_json::Value::Object(fields) +} + +/// Trait for converting JSON values into OpenFeature values +trait IntoFeatureValue { + /// Converts a JSON value into an OpenFeature value + fn into_feature_value(self) -> Value; +} + +impl IntoFeatureValue for serde_json::Value { + fn into_feature_value(self) -> Value { + match self { + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Int(i) + } else if let Some(f) = n.as_f64() { + Value::Float(f) + } else { + Value::Int(0) + } + } + serde_json::Value::String(s) => Value::String(s), + serde_json::Value::Array(arr) => { + Value::Array(arr.into_iter().map(|v| v.into_feature_value()).collect()) + } + serde_json::Value::Object(obj) => { + let mut struct_value = StructValue::default(); + for (k, v) in obj { + struct_value.add_field(k, v.into_feature_value()); + } + Value::Struct(struct_value) + } + serde_json::Value::Null => Value::String("".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use test_log::test; + use tokio::time::{Duration, sleep}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn reset_states() { + let mut retry_after = CURRENT_RETRY_AFTER.lock().await; + *retry_after = Utc::now(); + } + + async fn setup_mock_server() -> (MockServer, Resolver) { + let mock_server = MockServer::start().await; + let options = OfrepOptions { + base_url: mock_server.uri(), + ..Default::default() + }; + let resolver = Resolver::new(&options); + (mock_server, resolver) + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_resolve_bool_value() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": true, + "variant": "on", + "reason": "STATIC" + }))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default().with_targeting_key("test-user"); + let result = resolver + .resolve_bool_value("test-flag", &context) + .await + .unwrap(); + + assert_eq!(result.value, true); + assert_eq!(result.variant, Some("on".to_string())); + assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static)); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_resolve_string_value() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": "test-value", + "variant": "key1", + "reason": "STATIC" + }))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default().with_targeting_key("test-user"); + let result = resolver + .resolve_string_value("test-flag", &context) + .await + .unwrap(); + + assert_eq!(result.value, "test-value"); + assert_eq!(result.variant, Some("key1".to_string())); + assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static)); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_resolve_float_value() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": 1.23, + "variant": "one", + "reason": "STATIC" + }))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default().with_targeting_key("test-user"); + let result = resolver + .resolve_float_value("test-flag", &context) + .await + .unwrap(); + + assert_eq!(result.value, 1.23); + assert_eq!(result.variant, Some("one".to_string())); + assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static)); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_resolve_int_value() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": 42, + "variant": "one", + "reason": "STATIC" + }))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default().with_targeting_key("test-user"); + let result = resolver + .resolve_int_value("test-flag", &context) + .await + .unwrap(); + + assert_eq!(result.value, 42); + assert_eq!(result.variant, Some("one".to_string())); + assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static)); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_resolve_struct_value() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value": { + "key": "val", + "number": 42, + "boolean": true, + "nested": { + "inner": "value" + } + }, + "variant": "object1", + "reason": "STATIC" + }))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default().with_targeting_key("test-user"); + let result = resolver + .resolve_struct_value("test-flag", &context) + .await + .unwrap(); + + let value = &result.value; + assert_eq!(value.fields.get("key").unwrap().as_str().unwrap(), "val"); + assert_eq!(value.fields.get("number").unwrap().as_i64().unwrap(), 42); + assert_eq!( + value.fields.get("boolean").unwrap().as_bool().unwrap(), + true + ); + + let nested = value.fields.get("nested").unwrap().as_struct().unwrap(); + assert_eq!( + nested.fields.get("inner").unwrap().as_str().unwrap(), + "value" + ); + + assert_eq!(result.variant, Some("object1".to_string())); + assert_eq!(result.reason, Some(open_feature::EvaluationReason::Static)); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_error_400() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({}))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default(); + let result_bool = resolver.resolve_bool_value("test-flag", &context).await; + let result_int = resolver.resolve_int_value("test-flag", &context).await; + let result_float = resolver.resolve_float_value("test-flag", &context).await; + let result_string = resolver.resolve_string_value("test-flag", &context).await; + let result_struct = resolver.resolve_struct_value("test-flag", &context).await; + + assert!(result_bool.is_err()); + assert!(result_int.is_err()); + assert!(result_float.is_err()); + assert!(result_string.is_err()); + assert!(result_struct.is_err()); + + assert_eq!( + result_bool.unwrap_err().code, + EvaluationErrorCode::InvalidContext + ); + assert_eq!( + result_int.unwrap_err().code, + EvaluationErrorCode::InvalidContext + ); + assert_eq!( + result_float.unwrap_err().code, + EvaluationErrorCode::InvalidContext + ); + assert_eq!( + result_string.unwrap_err().code, + EvaluationErrorCode::InvalidContext + ); + assert_eq!( + result_struct.unwrap_err().code, + EvaluationErrorCode::InvalidContext + ); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_error_401() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({}))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default(); + + let result_bool = resolver.resolve_bool_value("test-flag", &context).await; + let result_int = resolver.resolve_int_value("test-flag", &context).await; + let result_float = resolver.resolve_float_value("test-flag", &context).await; + let result_string = resolver.resolve_string_value("test-flag", &context).await; + let result_struct = resolver.resolve_struct_value("test-flag", &context).await; + + assert!(result_bool.is_err()); + assert!(result_int.is_err()); + assert!(result_float.is_err()); + assert!(result_string.is_err()); + assert!(result_struct.is_err()); + + assert_eq!( + result_bool.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_int.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_float.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_string.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_struct.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_error_403() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(403).set_body_json(json!({}))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default(); + + let result_bool = resolver.resolve_bool_value("test-flag", &context).await; + let result_int = resolver.resolve_int_value("test-flag", &context).await; + let result_float = resolver.resolve_float_value("test-flag", &context).await; + let result_string = resolver.resolve_string_value("test-flag", &context).await; + let result_struct = resolver.resolve_struct_value("test-flag", &context).await; + + assert!(result_bool.is_err()); + assert!(result_int.is_err()); + assert!(result_float.is_err()); + assert!(result_string.is_err()); + assert!(result_struct.is_err()); + + assert_eq!( + result_bool.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_int.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_float.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_string.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + assert_eq!( + result_struct.unwrap_err().code, + EvaluationErrorCode::General("authentication/authorization error".to_string()) + ); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_error_404() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({}))) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default(); + + let result_bool = resolver.resolve_bool_value("test-flag", &context).await; + let result_int = resolver.resolve_int_value("test-flag", &context).await; + let result_float = resolver.resolve_float_value("test-flag", &context).await; + let result_string = resolver.resolve_string_value("test-flag", &context).await; + let result_struct = resolver.resolve_struct_value("test-flag", &context).await; + + assert!(result_bool.is_err()); + assert!(result_int.is_err()); + assert!(result_float.is_err()); + assert!(result_string.is_err()); + assert!(result_struct.is_err()); + + let result_bool_error = result_bool.unwrap_err(); + assert_eq!(result_bool_error.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + result_bool_error.message.unwrap(), + "Flag: test-flag not found" + ); + + let result_int_error = result_int.unwrap_err(); + assert_eq!(result_int_error.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + result_int_error.message.unwrap(), + "Flag: test-flag not found" + ); + + let result_float_error = result_float.unwrap_err(); + assert_eq!(result_float_error.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + result_float_error.message.unwrap(), + "Flag: test-flag not found" + ); + + let result_string_error = result_string.unwrap_err(); + assert_eq!(result_string_error.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + result_string_error.message.unwrap(), + "Flag: test-flag not found" + ); + + let result_struct_error = result_struct.unwrap_err(); + assert_eq!(result_struct_error.code, EvaluationErrorCode::FlagNotFound); + assert_eq!( + result_struct_error.message.unwrap(), + "Flag: test-flag not found" + ); + } + + #[test(tokio::test)] + #[serial_test::serial] + async fn test_error_429() { + reset_states().await; + let (mock_server, resolver) = setup_mock_server().await; + + Mock::given(method("POST")) + .and(path("/ofrep/v1/evaluate/flags/test-flag")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("Retry-After", "3") + .set_body_json(json!({})), + ) + .mount(&mock_server) + .await; + + let context = EvaluationContext::default(); + + let result_bool = resolver.resolve_bool_value("test-flag", &context).await; + let result_bool_2 = resolver.resolve_bool_value("test-flag", &context).await; + + assert!(result_bool.is_err()); + let result_bool_error = result_bool.unwrap_err(); + assert_eq!( + result_bool_error.code, + EvaluationErrorCode::General("Rate limit exceeded".to_string()) + ); + assert!( + result_bool_error + .message + .unwrap() + .starts_with("Rate limit exceeded. Retry after") + ); + + assert!(result_bool_2.is_err()); + let result_bool_error_2 = result_bool_2.unwrap_err(); + assert_eq!( + result_bool_error_2.code, + EvaluationErrorCode::General("Rate limit exceeded".to_string()) + ); + assert_eq!( + result_bool_error_2.message.unwrap(), + "Rate limit exceeded. Please wait before making another request." + ); + + sleep(Duration::from_secs(3)).await; + + let result_bool_3 = resolver.resolve_bool_value("test-flag", &context).await; + assert!(result_bool_3.is_err()); + + let result_bool_error_3 = result_bool_3.unwrap_err(); + assert_eq!( + result_bool_error_3.code, + EvaluationErrorCode::General("Rate limit exceeded".to_string()) + ); + assert!( + result_bool_error_3 + .message + .unwrap() + .starts_with("Rate limit exceeded. Retry after") + ); + } +}