From 2fd80ae4ab0adebc0de40550192e9793f577c72d Mon Sep 17 00:00:00 2001 From: Maxim Fischuk <37367711+MaximFischuk@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:27:54 +0200 Subject: [PATCH] feat: Add hooks implementation (#95) Signed-off-by: Maxim Fischuk --- Cargo.toml | 13 +- README.md | 142 +++- examples/hooks.rs | 155 +++++ examples/logging.rs | 32 + src/api/api.rs | 34 +- src/api/client.rs | 420 +++++++++--- src/api/global_hooks.rs | 18 + src/api/mod.rs | 1 + src/evaluation/context_field_value.rs | 2 +- src/evaluation/details.rs | 31 + src/evaluation/error.rs | 3 + src/evaluation/mod.rs | 2 +- src/evaluation/options.rs | 25 +- src/evaluation/value.rs | 38 ++ src/hooks/logging.rs | 208 ++++++ src/hooks/mod.rs | 894 ++++++++++++++++++++++++++ src/lib.rs | 4 + src/provider/feature_provider.rs | 8 +- 18 files changed, 1924 insertions(+), 106 deletions(-) create mode 100644 examples/hooks.rs create mode 100644 examples/logging.rs create mode 100644 src/api/global_hooks.rs create mode 100644 src/hooks/logging.rs create mode 100644 src/hooks/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 4867da4..d07a4ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,13 +21,18 @@ lazy_static = "1.5" mockall = { version = "0.13.0", optional = true } serde_json = { version = "1.0.116", optional = true } time = "0.3.36" -tokio = { version = "1.40", features = [ "full" ] } +tokio = { version = "1.40", features = ["full"] } typed-builder = "0.20.0" +log = { package = "log", version = "0.4", optional = true } + [dev-dependencies] +env_logger = "0.11.5" +structured-logger = "1.0.3" spec = { path = "spec" } [features] -default = [ "test-util" ] -test-util = [ "dep:mockall" ] -serde_json = [ "dep:serde_json" ] \ No newline at end of file +default = ["test-util", "dep:log"] +test-util = ["dep:mockall"] +serde_json = ["dep:serde_json"] +structured-logging = ["log?/kv"] diff --git a/README.md b/README.md index 1ec044c..bd3ab9c 100644 --- a/README.md +++ b/README.md @@ -151,12 +151,12 @@ See [here](https://docs.rs/open-feature/latest/open_feature/index.html) for the | ------ | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | ✅ | [Providers](#providers) | Integrate with a commercial, open source, or in-house feature management tool. | | ✅ | [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). | -| ❌ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | -| ❌ | [Logging](#logging) | Integrate with popular logging packages. | +| ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | +| ✅ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Named clients](#named-clients) | Utilize multiple providers in a single application. | | ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | -| ❌ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | +| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ @@ -211,21 +211,89 @@ client.get_int_value("flag", Some(&evaluation_context), None); ### Hooks -Hooks are not yet available in the Rust SDK. - - - +```rust +let mut api = OpenFeature::singleton_mut().await; + +// Set a global hook. +api.set_hook(MyHook::default()).await; + +// Create a client and set a client level hook. +let client = api.create_client(); +client.set_hook(MyHook::default()); + +// Get a flag value with a hook. +let eval = EvaluationOptions::default().with_hook(MyHook::default()); +client.get_int_value("key", None, Some(&eval)).await; +``` + +Example of a hook implementation you can find in [examples/hooks.rs](https://github.com/open-feature/rust-sdk/blob/main/examples/hooks.rs). + +To run the example, execute the following command: + +```shell +cargo run --example hooks +``` ### Logging -Logging customization is not yet available in the Rust SDK. +Note that in accordance with the OpenFeature specification, the SDK doesn't generally log messages during flag evaluation. + +#### Logging hook + +The Rust SDK provides a logging hook that can be used to log messages during flag evaluation. +This hook is not enabled by default and must be explicitly set. + +```rust +let mut api = OpenFeature::singleton_mut().await; + +let client = api.create_client().with_logging_hook(false); + +... + +// Note: You can include evaluation context to log output. +let client = api.create_client().with_logging_hook(true); +``` + +Both **text** and **structured** logging are supported. +To enable **structured** logging, enable feature `structured-logging` in your `Cargo.toml`: + +```toml +open-feature = { version = "0.2.4", features = ["structured-logging"] } +``` + +Example of a logging hook usage you can find in [examples/logging.rs](https://github.com/open-feature/rust-sdk/blob/main/examples/logging.rs). + +To run the example, execute the following command: + +```shell +cargo run --example logging +``` + +**Output**: + +```text +[2025-01-10T18:53:11Z DEBUG open_feature::hooks::logging] Before stage: domain=, provider_name=Dummy Provider, flag_key=my_feature, default_value=Some(Bool(false)), evaluation_context=EvaluationContext { targeting_key: None, custom_fields: {} } +[2025-01-10T18:53:11Z DEBUG open_feature::hooks::logging] After stage: domain=, provider_name=Dummy Provider, flag_key=my_feature, default_value=Some(Bool(false)), reason=None, variant=None, value=Bool(true), evaluation_context=EvaluationContext { targeting_key: None, custom_fields: {} } +``` + +or with structured logging: + +```shell +cargo run --example logging --features structured-logging +``` + +**Output**: + +```jsonl +{"default_value":"Some(Bool(false))","domain":"","evaluation_context":"EvaluationContext { targeting_key: None, custom_fields: {} }","flag_key":"my_feature","level":"DEBUG","message":"Before stage","provider_name":"No-op Provider","target":"open_feature","timestamp":1736537120828} +{"default_value":"Some(Bool(false))","domain":"","error_message":"Some(\"No-op provider is never ready\")","evaluation_context":"EvaluationContext { targeting_key: None, custom_fields: {} }","file":"src/hooks/logging.rs","flag_key":"my_feature","level":"ERROR","line":162,"message":"Error stage","module":"open_feature::hooks::logging::structured","provider_name":"No-op Provider","target":"open_feature","timestamp":1736537120828} +``` ### Named clients @@ -281,21 +349,59 @@ Check the source of [`NoOpProvider`](https://github.com/open-feature/rust-sdk/bl ### Develop a hook -Hooks are not yet available in the Rust SDK. - - +To satisfy the interface, all methods (`before`/`after`/`finally`/`error`) need to be defined. - +```rust +use open_feature::{ + EvaluationContext, EvaluationDetails, EvaluationError, + Hook, HookContext, HookHints, Value, +}; + +struct MyHook; + +#[async_trait::async_trait] +impl Hook for MyHook { + async fn before<'a>( + &self, + context: &HookContext<'a>, + hints: Option<&'a HookHints>, + ) -> Result, EvaluationError> { + todo!() + } + + async fn after<'a>( + &self, + context: &HookContext<'a>, + details: &EvaluationDetails, + hints: Option<&'a HookHints>, + ) -> Result<(), EvaluationError> { + todo!() + } + + async fn error<'a>( + &self, + context: &HookContext<'a>, + error: &EvaluationError, + hints: Option<&'a HookHints>, + ) { + todo!() + } + + async fn finally<'a>( + &self, + context: &HookContext<'a>, + detaild: &EvaluationDetails, + hints: Option<&'a HookHints>, + ) { + todo!() + } +} +``` - ## ⭐️ Support the project diff --git a/examples/hooks.rs b/examples/hooks.rs new file mode 100644 index 0000000..d23b2fb --- /dev/null +++ b/examples/hooks.rs @@ -0,0 +1,155 @@ +use open_feature::{ + provider::{FeatureProvider, ProviderMetadata, ProviderStatus, ResolutionDetails}, + EvaluationContext, EvaluationDetails, EvaluationError, EvaluationOptions, EvaluationResult, + Hook, HookContext, HookHints, OpenFeature, StructValue, Value, +}; + +struct DummyProvider(ProviderMetadata); + +impl Default for DummyProvider { + fn default() -> Self { + Self(ProviderMetadata::new("Dummy Provider")) + } +} + +#[async_trait::async_trait] +impl FeatureProvider for DummyProvider { + fn metadata(&self) -> &ProviderMetadata { + &self.0 + } + + fn status(&self) -> ProviderStatus { + ProviderStatus::Ready + } + + async fn resolve_bool_value( + &self, + _flag_key: &str, + _evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + Ok(ResolutionDetails::new(true)) + } + + async fn resolve_int_value( + &self, + _flag_key: &str, + _evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + unimplemented!() + } + + async fn resolve_float_value( + &self, + _flag_key: &str, + _evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + unimplemented!() + } + + async fn resolve_string_value( + &self, + _flag_key: &str, + _evaluation_context: &EvaluationContext, + ) -> EvaluationResult> { + unimplemented!() + } + + async fn resolve_struct_value( + &self, + _flag_key: &str, + _evaluation_context: &EvaluationContext, + ) -> Result, EvaluationError> { + unimplemented!() + } +} + +struct DummyLoggingHook(String); + +#[async_trait::async_trait] +impl Hook for DummyLoggingHook { + async fn before<'a>( + &self, + context: &HookContext<'a>, + _hints: Option<&'a HookHints>, + ) -> Result, EvaluationError> { + log::info!( + "Evaluating({}) flag {} of type {}", + self.0, + context.flag_key, + context.flag_type + ); + + Ok(None) + } + + async fn after<'a>( + &self, + context: &HookContext<'a>, + details: &EvaluationDetails, + _hints: Option<&'a HookHints>, + ) -> Result<(), EvaluationError> { + log::info!( + "Flag({}) {} of type {} evaluated to {:?}", + self.0, + context.flag_key, + context.flag_type, + details.value + ); + + Ok(()) + } + + async fn error<'a>( + &self, + context: &HookContext<'a>, + error: &EvaluationError, + _hints: Option<&'a HookHints>, + ) { + log::error!( + "Error({}) evaluating flag {} of type {}: {:?}", + self.0, + context.flag_key, + context.flag_type, + error + ); + } + + async fn finally<'a>( + &self, + context: &HookContext<'a>, + _: &EvaluationDetails, + _hints: Option<&'a HookHints>, + ) { + log::info!( + "Finally({}) evaluating flag {} of type {}", + self.0, + context.flag_key, + context.flag_type + ); + } +} + +#[tokio::main] +async fn main() { + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); + + let mut api = OpenFeature::singleton_mut().await; + api.set_provider(DummyProvider::default()).await; + api.add_hook(DummyLoggingHook("global".to_string())).await; + drop(api); + + let client = OpenFeature::singleton() + .await + .create_client() + .with_hook(DummyLoggingHook("client".to_string())); // Add a client-level hook + + let eval = EvaluationOptions::default().with_hook(DummyLoggingHook("eval".to_string())); + let feature = client + .get_bool_details("my_feature", None, Some(&eval)) + .await + .unwrap(); + + println!("Feature value: {}", feature.value); +} diff --git a/examples/logging.rs b/examples/logging.rs new file mode 100644 index 0000000..0312912 --- /dev/null +++ b/examples/logging.rs @@ -0,0 +1,32 @@ +use open_feature::{provider::NoOpProvider, EvaluationOptions, OpenFeature}; + +#[tokio::main] +async fn main() { + init_logger(); + + let mut api = OpenFeature::singleton_mut().await; + api.set_provider(NoOpProvider::default()).await; + drop(api); + + let client = OpenFeature::singleton() + .await + .create_client() + .with_logging_hook(true); // Add a client-level hook + + let eval = EvaluationOptions::default(); + let _ = client + .get_bool_details("my_feature", None, Some(&eval)) + .await; +} + +#[cfg(not(feature = "structured-logging"))] +fn init_logger() { + env_logger::builder() + .filter_level(log::LevelFilter::Debug) + .init(); +} + +#[cfg(feature = "structured-logging")] +fn init_logger() { + structured_logger::Builder::with_level("debug").init(); +} diff --git a/src/api/api.rs b/src/api/api.rs index eb7f7b5..3344be7 100644 --- a/src/api/api.rs +++ b/src/api/api.rs @@ -3,11 +3,12 @@ use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use crate::{ provider::{FeatureProvider, ProviderMetadata}, - Client, EvaluationContext, + Client, EvaluationContext, Hook, HookWrapper, }; use super::{ - global_evaluation_context::GlobalEvaluationContext, provider_registry::ProviderRegistry, + global_evaluation_context::GlobalEvaluationContext, global_hooks::GlobalHooks, + provider_registry::ProviderRegistry, }; lazy_static! { @@ -21,6 +22,7 @@ lazy_static! { #[derive(Default)] pub struct OpenFeature { evaluation_context: GlobalEvaluationContext, + hooks: GlobalHooks, provider_registry: ProviderRegistry, } @@ -54,6 +56,12 @@ impl OpenFeature { self.provider_registry.set_named(name, provider).await; } + /// Add a new hook to the global list of hooks. + pub async fn add_hook(&mut self, hook: T) { + let mut lock = self.hooks.get_mut().await; + lock.push(HookWrapper::new(hook)); + } + /// Return the metadata of default (unnamed) provider. pub async fn provider_metadata(&self) -> ProviderMetadata { self.provider_registry @@ -77,6 +85,7 @@ impl OpenFeature { Client::new( String::default(), self.evaluation_context.clone(), + self.hooks.clone(), self.provider_registry.clone(), ) } @@ -87,6 +96,7 @@ impl OpenFeature { Client::new( name.to_string(), self.evaluation_context.clone(), + self.hooks.clone(), self.provider_registry.clone(), ) } @@ -156,6 +166,10 @@ mod tests { // Set the new provider and ensure the value comes from it. let mut provider = MockFeatureProvider::new(); provider.expect_initialize().returning(|_| {}); + provider.expect_hooks().return_const(vec![]); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); provider .expect_resolve_int_value() .return_const(Ok(ResolutionDetails::new(200))); @@ -203,6 +217,10 @@ mod tests { // Bind provider to the same name. let mut provider = MockFeatureProvider::new(); provider.expect_initialize().returning(|_| {}); + provider.expect_hooks().return_const(vec![]); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); provider .expect_resolve_int_value() .return_const(Ok(ResolutionDetails::new(30))); @@ -246,12 +264,20 @@ mod tests { let mut default_provider = MockFeatureProvider::new(); default_provider.expect_initialize().returning(|_| {}); + default_provider.expect_hooks().return_const(vec![]); + default_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); default_provider .expect_resolve_int_value() .return_const(Ok(ResolutionDetails::new(100))); let mut named_provider = MockFeatureProvider::new(); named_provider.expect_initialize().returning(|_| {}); + named_provider.expect_hooks().return_const(vec![]); + named_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); named_provider .expect_resolve_int_value() .return_const(Ok(ResolutionDetails::new(200))); @@ -314,6 +340,10 @@ mod tests { // Setup expectations for different evaluation contexts. let mut provider = MockFeatureProvider::new(); provider.expect_initialize().returning(|_| {}); + provider.expect_hooks().return_const(vec![]); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); provider .expect_resolve_int_value() diff --git a/src/api/client.rs b/src/api/client.rs index 9808fa6..3b9cbe1 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,16 +1,18 @@ -use std::sync::Arc; +use std::{future::Future, pin::Pin, sync::Arc}; use crate::{ provider::{FeatureProvider, ResolutionDetails}, EvaluationContext, EvaluationDetails, EvaluationError, EvaluationErrorCode, EvaluationOptions, - EvaluationResult, StructValue, + EvaluationResult, Hook, HookContext, HookHints, HookWrapper, StructValue, Value, }; use super::{ - global_evaluation_context::GlobalEvaluationContext, provider_registry::ProviderRegistry, + global_evaluation_context::GlobalEvaluationContext, global_hooks::GlobalHooks, + provider_registry::ProviderRegistry, }; /// The metadata of OpenFeature client. +#[derive(Clone, Default, PartialEq, Debug)] pub struct ClientMetadata { /// The name of client. pub name: String, @@ -23,6 +25,9 @@ pub struct Client { provider_registry: ProviderRegistry, evaluation_context: EvaluationContext, global_evaluation_context: GlobalEvaluationContext, + global_hooks: GlobalHooks, + + client_hooks: Vec, } impl Client { @@ -30,13 +35,16 @@ impl Client { pub fn new( name: impl Into, global_evaluation_context: GlobalEvaluationContext, + global_hooks: GlobalHooks, provider_registry: ProviderRegistry, ) -> Self { Self { metadata: ClientMetadata { name: name.into() }, global_evaluation_context, + global_hooks, provider_registry, evaluation_context: EvaluationContext::default(), + client_hooks: Vec::new(), } } @@ -52,104 +60,75 @@ impl Client { /// Evaluate given `flag_key` with corresponding `evaluation_context` and `evaluation_options` /// as a bool value. - #[allow(unused_variables)] pub async fn get_bool_value( &self, flag_key: &str, evaluation_context: Option<&EvaluationContext>, evaluation_options: Option<&EvaluationOptions>, ) -> EvaluationResult { - let context = self.merge_evaluation_context(evaluation_context).await; - - Ok(self - .get_provider() + self.get_bool_details(flag_key, evaluation_context, evaluation_options) .await - .resolve_bool_value(flag_key, &context) - .await? - .value) + .map(|details| details.value) } /// Evaluate given `flag_key` with corresponding `evaluation_context` and `evaluation_options` /// as an int (i64) value. - #[allow(unused_variables)] pub async fn get_int_value( &self, flag_key: &str, evaluation_context: Option<&EvaluationContext>, evaluation_options: Option<&EvaluationOptions>, ) -> EvaluationResult { - let context = self.merge_evaluation_context(evaluation_context).await; - - Ok(self - .get_provider() + self.get_int_details(flag_key, evaluation_context, evaluation_options) .await - .resolve_int_value(flag_key, &context) - .await? - .value) + .map(|details| details.value) } /// Evaluate given `flag_key` with corresponding `evaluation_context` and `evaluation_options` /// as a float (f64) value. /// If the resolution fails, the `default_value` is returned. - #[allow(unused_variables)] pub async fn get_float_value( &self, flag_key: &str, evaluation_context: Option<&EvaluationContext>, evaluation_options: Option<&EvaluationOptions>, ) -> EvaluationResult { - let context = self.merge_evaluation_context(evaluation_context).await; - - Ok(self - .get_provider() + self.get_float_details(flag_key, evaluation_context, evaluation_options) .await - .resolve_float_value(flag_key, &context) - .await? - .value) + .map(|details| details.value) } /// Evaluate given `flag_key` with corresponding `evaluation_context` and `evaluation_options` /// as a string value. /// If the resolution fails, the `default_value` is returned. - #[allow(unused_variables)] pub async fn get_string_value( &self, flag_key: &str, evaluation_context: Option<&EvaluationContext>, evaluation_options: Option<&EvaluationOptions>, ) -> EvaluationResult { - let context = self.merge_evaluation_context(evaluation_context).await; - - Ok(self - .get_provider() + self.get_string_details(flag_key, evaluation_context, evaluation_options) .await - .resolve_string_value(flag_key, &context) - .await? - .value) + .map(|details| details.value) } /// Evaluate given `flag_key` with corresponding `evaluation_context` and `evaluation_options` /// as a struct. /// If the resolution fails, the `default_value` is returned. /// The required type should implement [`From`] trait. - #[allow(unused_variables)] pub async fn get_struct_value>( &self, flag_key: &str, evaluation_context: Option<&EvaluationContext>, evaluation_options: Option<&EvaluationOptions>, ) -> EvaluationResult { - let context = self.merge_evaluation_context(evaluation_context).await; - let result = self - .get_provider() - .await - .resolve_struct_value(flag_key, &context) + .get_struct_details(flag_key, evaluation_context, evaluation_options) .await?; match T::try_from(result.value) { Ok(t) => Ok(t), - Err(error) => Err(EvaluationError { + Err(_) => Err(EvaluationError { code: EvaluationErrorCode::TypeMismatch, message: Some("Unable to cast value to required type".to_string()), }), @@ -158,7 +137,6 @@ impl Client { /// Return the [`EvaluationDetails`] with given `flag_key`, `evaluation_context` and /// `evaluation_options`. - #[allow(unused_variables)] pub async fn get_bool_details( &self, flag_key: &str, @@ -167,17 +145,17 @@ impl Client { ) -> EvaluationResult> { let context = self.merge_evaluation_context(evaluation_context).await; - Ok(self - .get_provider() - .await - .resolve_bool_value(flag_key, &context) - .await? - .into_evaluation_details(flag_key)) + self.evaluate( + flag_key, + &context, + evaluation_options, + call_resolve_bool_value, + ) + .await } /// Return the [`EvaluationDetails`] with given `flag_key`, `evaluation_context` and /// `evaluation_options`. - #[allow(unused_variables)] pub async fn get_int_details( &self, flag_key: &str, @@ -186,17 +164,17 @@ impl Client { ) -> EvaluationResult> { let context = self.merge_evaluation_context(evaluation_context).await; - Ok(self - .get_provider() - .await - .resolve_int_value(flag_key, &context) - .await? - .into_evaluation_details(flag_key)) + self.evaluate( + flag_key, + &context, + evaluation_options, + call_resolve_int_value, + ) + .await } /// Return the [`EvaluationDetails`] with given `flag_key`, `evaluation_context` and /// `evaluation_options`. - #[allow(unused_variables)] pub async fn get_float_details( &self, flag_key: &str, @@ -205,17 +183,17 @@ impl Client { ) -> EvaluationResult> { let context = self.merge_evaluation_context(evaluation_context).await; - Ok(self - .get_provider() - .await - .resolve_float_value(flag_key, &context) - .await? - .into_evaluation_details(flag_key)) + self.evaluate( + flag_key, + &context, + evaluation_options, + call_resolve_float_value, + ) + .await } /// Return the [`EvaluationDetails`] with given `flag_key`, `evaluation_context` and /// `evaluation_options`. - #[allow(unused_variables)] pub async fn get_string_details( &self, flag_key: &str, @@ -224,17 +202,17 @@ impl Client { ) -> EvaluationResult> { let context = self.merge_evaluation_context(evaluation_context).await; - Ok(self - .get_provider() - .await - .resolve_string_value(flag_key, &context) - .await? - .into_evaluation_details(flag_key)) + self.evaluate( + flag_key, + &context, + evaluation_options, + call_resolve_string_value, + ) + .await } /// Return the [`EvaluationDetails`] with given `flag_key`, `evaluation_context` and /// `evaluation_options`. - #[allow(unused_variables)] pub async fn get_struct_details>( &self, flag_key: &str, @@ -244,9 +222,12 @@ impl Client { let context = self.merge_evaluation_context(evaluation_context).await; let result = self - .get_provider() - .await - .resolve_struct_value(flag_key, &context) + .evaluate( + flag_key, + &context, + evaluation_options, + call_resolve_struct_value, + ) .await?; match T::try_from(result.value) { @@ -255,9 +236,9 @@ impl Client { value, reason: result.reason, variant: result.variant, - flag_metadata: result.flag_metadata.unwrap_or_default(), + flag_metadata: result.flag_metadata, }), - Err(error) => Err(EvaluationError { + Err(_) => Err(EvaluationError { code: EvaluationErrorCode::TypeMismatch, message: Some("Unable to cast value to required type".to_string()), }), @@ -289,6 +270,210 @@ impl Client { } } +impl Client { + /// Add a hook to the client. + #[must_use] + pub fn with_hook(mut self, hook: T) -> Self { + self.client_hooks.push(HookWrapper::new(hook)); + self + } + + /// Add logging hook to the client. + #[must_use] + pub fn with_logging_hook(self, include_evaluation_context: bool) -> Self { + self.with_hook(crate::LoggingHook { + include_evaluation_context, + }) + } + + async fn evaluate( + &self, + flag_key: &str, + context: &EvaluationContext, + evaluation_options: Option<&EvaluationOptions>, // INFO: Invocation + resolve: impl for<'a> FnOnce( + &'a dyn FeatureProvider, + &'a str, + &'a EvaluationContext, + ) -> Pin< + Box>> + Send + 'a>, + >, + ) -> EvaluationResult> + where + T: Into + Clone + Default, + { + let provider = self.get_provider().await; + let hints = evaluation_options.map(|options| &options.hints); + + let default: Value = T::default().into(); + + let mut hook_context = HookContext { + flag_key, + flag_type: default.get_type(), + client_metadata: self.metadata.clone(), + provider_metadata: provider.metadata().clone(), + evaluation_context: context, + + default_value: Some(default), + }; + + let global_hooks = self.global_hooks.get().await; + let client_hooks = &self.client_hooks[..]; + let invocation_hooks: &[HookWrapper] = evaluation_options + .map(|options| options.hooks.as_ref()) + .unwrap_or_default(); + let provider_hooks = provider.hooks(); + + // INFO: API(global), Client, Invocation, Provider + // https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#requirement-442 + let before_hooks = global_hooks + .iter() + .chain(client_hooks.iter()) + .chain(invocation_hooks.iter()) + .chain(provider_hooks.iter()); + + // INFO: Hooks called after the resolution are in reverse order + // Provider, Invocation, Client, API(global) + let after_hooks = before_hooks.clone().rev(); + + let (context, result) = self + .before_hooks(before_hooks.into_iter(), &hook_context, hints) + .await; + hook_context.evaluation_context = &context; + + // INFO: Result of the resolution or error reason with default value + // This bind is defined here to minimize cloning of the `Value` + let evaluation_details; + + if let Err(error) = result { + self.error_hooks(after_hooks.clone(), &hook_context, &error, hints) + .await; + evaluation_details = EvaluationDetails::error_reason(flag_key, T::default()); + self.finally_hooks( + after_hooks.into_iter(), + &hook_context, + &evaluation_details, + hints, + ) + .await; + + return Err(error); + } + + // INFO: Run the resolution + let result = resolve(&*provider, flag_key, &context) + .await + .map(|details| details.into_evaluation_details(flag_key)); + + // INFO: Run the after hooks + match result { + Ok(ref details) => { + let details = details.clone().into_value(); + if let Err(error) = self + .after_hooks(after_hooks.clone(), &hook_context, &details, hints) + .await + { + evaluation_details = EvaluationDetails::error_reason(flag_key, T::default()); + self.error_hooks(after_hooks.clone(), &hook_context, &error, hints) + .await; + } else { + evaluation_details = details; + } + } + Err(ref error) => { + evaluation_details = EvaluationDetails::error_reason(flag_key, T::default()); + self.error_hooks(after_hooks.clone(), &hook_context, error, hints) + .await; + } + } + + self.finally_hooks( + after_hooks.into_iter(), + &hook_context, + &evaluation_details, + hints, + ) + .await; + + result + } + + async fn before_hooks<'a, I>( + &self, + hooks: I, + hook_context: &HookContext<'_>, + hints: Option<&HookHints>, + ) -> (EvaluationContext, EvaluationResult<()>) + where + I: Iterator, + { + let mut context = hook_context.evaluation_context.clone(); + for hook in hooks { + let invoke_hook_context = HookContext { + evaluation_context: &context, + ..hook_context.clone() + }; + match hook.before(&invoke_hook_context, hints).await { + Ok(Some(output)) => context = output, + Ok(None) => { /* INFO: just continue execution */ } + Err(error) => { + drop(invoke_hook_context); + context.merge_missing(hook_context.evaluation_context); + return (context, Err(error)); + } + } + } + + context.merge_missing(hook_context.evaluation_context); + (context, Ok(())) + } + + async fn after_hooks<'a, I>( + &self, + hooks: I, + hook_context: &HookContext<'_>, + details: &EvaluationDetails, + hints: Option<&HookHints>, + ) -> EvaluationResult<()> + where + I: Iterator, + { + for hook in hooks { + hook.after(hook_context, details, hints).await?; + } + + Ok(()) + } + + async fn error_hooks<'a, I>( + &self, + hooks: I, + hook_context: &HookContext<'_>, + error: &EvaluationError, + hints: Option<&HookHints>, + ) where + I: Iterator, + { + for hook in hooks { + hook.error(hook_context, error, hints).await; + } + } + + async fn finally_hooks<'a, I>( + &self, + hooks: I, + hook_context: &HookContext<'_>, + evaluation_details: &EvaluationDetails, + hints: Option<&HookHints>, + ) where + I: Iterator, + { + for hook in hooks { + hook.finally(hook_context, evaluation_details, hints).await; + } + } +} + impl ResolutionDetails { fn into_evaluation_details(self, flag_key: impl Into) -> EvaluationDetails { EvaluationDetails { @@ -301,6 +486,46 @@ impl ResolutionDetails { } } +fn call_resolve_bool_value<'a>( + provider: &'a dyn FeatureProvider, + flag_key: &'a str, + context: &'a EvaluationContext, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { provider.resolve_bool_value(flag_key, context).await }) +} + +fn call_resolve_int_value<'a>( + provider: &'a dyn FeatureProvider, + flag_key: &'a str, + context: &'a EvaluationContext, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { provider.resolve_int_value(flag_key, context).await }) +} + +fn call_resolve_float_value<'a>( + provider: &'a dyn FeatureProvider, + flag_key: &'a str, + context: &'a EvaluationContext, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { provider.resolve_float_value(flag_key, context).await }) +} + +fn call_resolve_string_value<'a>( + provider: &'a dyn FeatureProvider, + flag_key: &'a str, + context: &'a EvaluationContext, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { provider.resolve_string_value(flag_key, context).await }) +} + +fn call_resolve_struct_value<'a>( + provider: &'a dyn FeatureProvider, + flag_key: &'a str, + context: &'a EvaluationContext, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { provider.resolve_struct_value(flag_key, context).await }) +} + #[cfg(test)] mod tests { @@ -308,9 +533,10 @@ mod tests { use crate::{ api::{ - global_evaluation_context::GlobalEvaluationContext, provider_registry::ProviderRegistry, + global_evaluation_context::GlobalEvaluationContext, global_hooks::GlobalHooks, + provider_registry::ProviderRegistry, }, - provider::{FeatureProvider, MockFeatureProvider, ResolutionDetails}, + provider::{FeatureProvider, MockFeatureProvider, ProviderMetadata, ResolutionDetails}, Client, EvaluationReason, FlagMetadata, StructValue, Value, }; @@ -364,6 +590,10 @@ mod tests { // Test bool. let mut provider = MockFeatureProvider::new(); provider.expect_initialize().returning(|_| {}); + provider.expect_hooks().return_const(vec![]); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); provider .expect_resolve_bool_value() @@ -464,6 +694,10 @@ mod tests { async fn get_details() { let mut provider = MockFeatureProvider::new(); provider.expect_initialize().returning(|_| {}); + provider.expect_hooks().return_const(vec![]); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); provider .expect_resolve_int_value() .return_const(Ok(ResolutionDetails::builder() @@ -516,6 +750,10 @@ mod tests { async fn get_details_flag_metadata() { let mut provider = MockFeatureProvider::new(); provider.expect_initialize().returning(|_| {}); + provider.expect_hooks().return_const(vec![]); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); provider .expect_resolve_bool_value() .return_const(Ok(ResolutionDetails::builder() @@ -544,10 +782,35 @@ mod tests { #[test] fn static_context_not_applicable() {} + #[tokio::test] + async fn with_hook() { + let mut provider = MockFeatureProvider::new(); + provider.expect_initialize().returning(|_| {}); + + let client = create_client(provider).await; + + let client = client.with_hook(crate::LoggingHook::default()); + + assert_eq!(client.client_hooks.len(), 1); + } + + #[tokio::test] + async fn with_logging_hook() { + let mut provider = MockFeatureProvider::new(); + provider.expect_initialize().returning(|_| {}); + + let client = create_client(provider).await; + + let client = client.with_logging_hook(false); + + assert_eq!(client.client_hooks.len(), 1); + } + fn create_default_client() -> Client { Client::new( "no_op", GlobalEvaluationContext::default(), + GlobalHooks::default(), ProviderRegistry::default(), ) } @@ -559,6 +822,7 @@ mod tests { Client::new( "custom", GlobalEvaluationContext::default(), + GlobalHooks::default(), provider_registry, ) } diff --git a/src/api/global_hooks.rs b/src/api/global_hooks.rs new file mode 100644 index 0000000..8007a27 --- /dev/null +++ b/src/api/global_hooks.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; + +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::HookWrapper; + +#[derive(Clone, Default)] +pub struct GlobalHooks(Arc>>); + +impl GlobalHooks { + pub async fn get(&self) -> RwLockReadGuard> { + self.0.read().await + } + + pub async fn get_mut(&self) -> RwLockWriteGuard> { + self.0.write().await + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 6677c6d..ee9f0cc 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,3 +7,4 @@ pub use client::{Client, ClientMetadata}; mod provider_registry; mod global_evaluation_context; +mod global_hooks; diff --git a/src/evaluation/context_field_value.rs b/src/evaluation/context_field_value.rs index 6617570..10d1488 100644 --- a/src/evaluation/context_field_value.rs +++ b/src/evaluation/context_field_value.rs @@ -11,7 +11,7 @@ pub enum EvaluationContextFieldValue { Float(f64), String(String), DateTime(OffsetDateTime), - Struct(Arc), + Struct(Arc), // TODO: better to make structure in similar way as serde_json::Value } impl EvaluationContextFieldValue { diff --git a/src/evaluation/details.rs b/src/evaluation/details.rs index bebb7f8..5d517d6 100644 --- a/src/evaluation/details.rs +++ b/src/evaluation/details.rs @@ -3,6 +3,8 @@ use std::fmt::{Display, Formatter}; use crate::EvaluationError; +use super::Value; + /// The result of evaluation. pub type EvaluationResult = Result; @@ -31,6 +33,35 @@ pub struct EvaluationDetails { pub flag_metadata: FlagMetadata, } +impl EvaluationDetails { + /// Creates a new `EvaluationDetails` instance with an error reason. + pub fn error_reason(flag_key: impl Into, value: impl Into) -> Self { + Self { + value: value.into(), + flag_key: flag_key.into(), + reason: Some(EvaluationReason::Error), + variant: None, + flag_metadata: FlagMetadata::default(), + } + } +} + +impl EvaluationDetails +where + T: Into, +{ + /// Convert the evaluation result of type `T` to `Value`. + pub fn into_value(self) -> EvaluationDetails { + EvaluationDetails { + flag_key: self.flag_key, + value: self.value.into(), + reason: self.reason, + variant: self.variant, + flag_metadata: self.flag_metadata, + } + } +} + // ============================================================ // EvaluationReason // ============================================================ diff --git a/src/evaluation/error.rs b/src/evaluation/error.rs index 85c0980..006ed7f 100644 --- a/src/evaluation/error.rs +++ b/src/evaluation/error.rs @@ -2,6 +2,7 @@ // EvaluationError // ============================================================ +use std::error::Error as StdError; use std::fmt::{Display, Formatter}; use typed_builder::TypedBuilder; @@ -59,3 +60,5 @@ impl Display for EvaluationErrorCode { write!(f, "{code}") } } + +impl StdError for EvaluationErrorCode {} diff --git a/src/evaluation/mod.rs b/src/evaluation/mod.rs index 0555491..7a5ddb3 100644 --- a/src/evaluation/mod.rs +++ b/src/evaluation/mod.rs @@ -13,7 +13,7 @@ mod context_field_value; pub use context_field_value::EvaluationContextFieldValue; mod value; -pub use value::{StructValue, Value}; +pub use value::{StructValue, Type, Value}; mod options; pub use options::EvaluationOptions; diff --git a/src/evaluation/options.rs b/src/evaluation/options.rs index 6474bb1..503a336 100644 --- a/src/evaluation/options.rs +++ b/src/evaluation/options.rs @@ -1,2 +1,25 @@ +use crate::Hook; + /// Contain hooks. -pub struct EvaluationOptions {} +#[derive(Default, Clone)] +pub struct EvaluationOptions { + /// The hooks to be used during evaluation. + pub hooks: Vec, + + /// Hints to be passed to the hooks. + pub hints: crate::hooks::HookHints, +} + +impl EvaluationOptions { + /// Create a new instance of `EvaluationOptions`. + pub fn new(hooks: Vec, hints: crate::hooks::HookHints) -> Self { + Self { hooks, hints } + } + + /// Add a hook to the evaluation options. + #[must_use] + pub fn with_hook(mut self, hook: T) -> Self { + self.hooks.push(crate::hooks::HookWrapper::new(hook)); + self + } +} diff --git a/src/evaluation/value.rs b/src/evaluation/value.rs index 23695c0..8db6a74 100644 --- a/src/evaluation/value.rs +++ b/src/evaluation/value.rs @@ -12,6 +12,19 @@ pub enum Value { Struct(StructValue), } +/// Supported types of values. +/// [spec](https://openfeature.dev/specification/types). +#[derive(Clone, PartialEq, Debug)] +#[allow(missing_docs)] +pub enum Type { + Bool, + Int, + Float, + String, + Array, + Struct, +} + /// Represent a structure value as defined in the /// [spec](https://openfeature.dev/specification/types#structure). #[derive(Clone, Default, PartialEq, Debug)] @@ -98,6 +111,18 @@ impl Value { _ => None, } } + + /// Return the type of the value. + pub fn get_type(&self) -> Type { + match self { + Self::Bool(_) => Type::Bool, + Self::Int(_) => Type::Int, + Self::Float(_) => Type::Float, + Self::String(_) => Type::String, + Self::Array(_) => Type::Array, + Self::Struct(_) => Type::Struct, + } + } } impl From for Value { @@ -201,6 +226,19 @@ impl StructValue { } } +impl std::fmt::Display for Type { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bool => write!(f, "bool"), + Self::Int => write!(f, "int"), + Self::Float => write!(f, "float"), + Self::String => write!(f, "string"), + Self::Array => write!(f, "array"), + Self::Struct => write!(f, "struct"), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/hooks/logging.rs b/src/hooks/logging.rs new file mode 100644 index 0000000..27c2bf6 --- /dev/null +++ b/src/hooks/logging.rs @@ -0,0 +1,208 @@ +use crate::{EvaluationContext, EvaluationDetails, EvaluationError, Value}; + +use super::{Hook, HookContext, HookHints}; + +use log::Level; + +/// A hook that logs the evaluation lifecycle of a flag. +/// See the [spec](https://github.com/open-feature/spec/blob/main/specification/appendix-a-included-utilities.md#logging-hook) +#[derive(Default)] +pub struct LoggingHook { + pub(crate) include_evaluation_context: bool, +} + +#[async_trait::async_trait] +impl Hook for LoggingHook { + async fn before<'a>( + &self, + context: &HookContext<'a>, + _: Option<&'a HookHints>, + ) -> Result, EvaluationError> { + self.log_before(context, Level::Debug); + + Ok(None) + } + async fn after<'a>( + &self, + context: &HookContext<'a>, + value: &EvaluationDetails, + _: Option<&'a HookHints>, + ) -> Result<(), EvaluationError> { + self.log_after(context, value, Level::Debug); + + Ok(()) + } + async fn error<'a>( + &self, + context: &HookContext<'a>, + error: &EvaluationError, + _: Option<&'a HookHints>, + ) { + self.log_error(context, error); + } + async fn finally<'a>( + &self, + _: &HookContext<'a>, + _: &EvaluationDetails, + _: Option<&'a HookHints>, + ) { + } +} + +#[cfg(not(feature = "structured-logging"))] +impl LoggingHook { + fn log_args( + &self, + msg: &str, + context: &HookContext, + level: Level, + additional_args: std::fmt::Arguments, + ) { + log::log!( + level, + "{}: domain={}, provider_name={}, flag_key={}, default_value={:?}{additional_args}{}", + msg, + context.client_metadata.name, + context.provider_metadata.name, + context.flag_key, + context.default_value, + if self.include_evaluation_context { + format!(", evaluation_context={:?}", context.evaluation_context) + } else { + String::new() + }, + ); + } + + fn log_before(&self, context: &HookContext, level: Level) { + self.log_args("Before stage", context, level, format_args!("")); + } + + fn log_after(&self, context: &HookContext, value: &EvaluationDetails, level: Level) { + self.log_args( + "After stage", + context, + level, + format_args!( + ", reason={:?}, variant={:?}, value={:?}", + value.reason, value.variant, value.value + ), + ); + } + + fn log_error(&self, context: &HookContext, error: &EvaluationError) { + self.log_args( + "Error stage", + context, + Level::Error, + format_args!(", error_message={:?}", error.message), + ); + } +} + +#[cfg(feature = "structured-logging")] +mod structured { + use super::*; + use log::{kv::Value as LogValue, Level, Record}; + + const DOMAIN_KEY: &str = "domain"; + const PROVIDER_NAME_KEY: &str = "provider_name"; + const FLAG_KEY_KEY: &str = "flag_key"; + const DEFAULT_VALUE_KEY: &str = "default_value"; + const EVALUATION_CONTEXT_KEY: &str = "evaluation_context"; + const ERROR_MESSAGE_KEY: &str = "error_message"; + const REASON_KEY: &str = "reason"; + const VARIANT_KEY: &str = "variant"; + const VALUE_KEY: &str = "value"; + + impl LoggingHook { + fn log_args( + &self, + msg: &str, + context: &HookContext, + level: Level, + additional_kvs: Vec<(&str, LogValue)>, + ) { + let mut kvs = vec![ + ( + DOMAIN_KEY, + LogValue::from_display(&context.client_metadata.name), + ), + ( + PROVIDER_NAME_KEY, + LogValue::from_display(&context.provider_metadata.name), + ), + (FLAG_KEY_KEY, LogValue::from_display(&context.flag_key)), + ( + DEFAULT_VALUE_KEY, + LogValue::from_debug(&context.default_value), + ), + ]; + + kvs.extend(additional_kvs); + + if self.include_evaluation_context { + kvs.push(( + EVALUATION_CONTEXT_KEY, + LogValue::from_debug(&context.evaluation_context), + )); + } + + let kvs = kvs.as_slice(); + + // Single statement to avoid borrowing issues + // See issue https://github.com/rust-lang/rust/issues/92698 + log::logger().log( + &Record::builder() + .args(format_args!("{}", msg)) + .level(level) + .target("open_feature") + .module_path_static(Some(module_path!())) + .file_static(Some(file!())) + .line(Some(line!())) + .key_values(&kvs) + .build(), + ); + } + + pub(super) fn log_before(&self, context: &HookContext, level: Level) { + self.log_args("Before stage", context, level, vec![]); + } + + pub(super) fn log_after( + &self, + context: &HookContext, + value: &EvaluationDetails, + level: Level, + ) { + self.log_args( + "After stage", + context, + level, + evaluation_details_to_kvs(value), + ); + } + + pub(super) fn log_error(&self, context: &HookContext, error: &EvaluationError) { + self.log_args("Error stage", context, Level::Error, error_to_kvs(error)); + } + } + + fn evaluation_details_to_kvs<'a>( + details: &'a EvaluationDetails, + ) -> Vec<(&'static str, LogValue<'a>)> { + let kvs = vec![ + (REASON_KEY, LogValue::from_debug(&details.reason)), + (VARIANT_KEY, LogValue::from_debug(&details.variant)), + (VALUE_KEY, LogValue::from_debug(&details.value)), + ]; + + kvs + } + + fn error_to_kvs<'a>(error: &'a EvaluationError) -> Vec<(&'static str, LogValue<'a>)> { + let kvs = vec![(ERROR_MESSAGE_KEY, LogValue::from_debug(&error.message))]; + + kvs + } +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 0000000..5a8666e --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,894 @@ +use std::{collections::HashMap, ops::Deref, sync::Arc}; + +use crate::{ + provider::ProviderMetadata, ClientMetadata, EvaluationContext, EvaluationDetails, + EvaluationError, Type, Value, +}; + +mod logging; +pub use logging::LoggingHook; + +// ============================================================ +// Hook +// ============================================================ + +/// Hook allows application developers to add arbitrary behavior to the flag evaluation lifecycle. +/// They operate similarly to middleware in many web frameworks. +/// +/// https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md +#[cfg_attr( + feature = "test-util", + mockall::automock, + allow(clippy::ref_option_ref) +)] // Specified lifetimes manually to make it work with mockall +#[async_trait::async_trait] +pub trait Hook: Send + Sync + 'static { + /// This method is called before the flag evaluation. + async fn before<'a>( + &self, + context: &HookContext<'a>, + hints: Option<&'a HookHints>, + ) -> Result, EvaluationError>; + + /// This method is called after the successful flag evaluation. + async fn after<'a>( + &self, + context: &HookContext<'a>, + details: &EvaluationDetails, + hints: Option<&'a HookHints>, + ) -> Result<(), EvaluationError>; + + /// This method is called on error during flag evaluation or error in before hook or after hook. + async fn error<'a>( + &self, + context: &HookContext<'a>, + error: &EvaluationError, + hints: Option<&'a HookHints>, + ); + + /// This method is called after the flag evaluation, regardless of the result. + async fn finally<'a>( + &self, + context: &HookContext<'a>, + evaluation_details: &EvaluationDetails, + hints: Option<&'a HookHints>, + ); +} + +// ============================================================ +// HookWrapper +// ============================================================ + +#[allow(missing_docs)] +#[derive(Clone)] +pub struct HookWrapper(Arc); + +impl HookWrapper { + #[allow(missing_docs)] + pub fn new(hook: impl Hook) -> Self { + Self(Arc::new(hook)) + } +} + +impl Deref for HookWrapper { + type Target = dyn Hook; + + fn deref(&self) -> &Self::Target { + &*self.0 + } +} + +// ============================================================ +// HookHints +// ============================================================ + +#[allow(missing_docs)] +#[derive(Clone, Default, PartialEq, Debug)] +pub struct HookHints { + hints: HashMap, +} + +// ============================================================ +// HookContext +// ============================================================ + +/// Context for hooks. +#[allow(missing_docs)] +#[derive(Clone, PartialEq, Debug)] +pub struct HookContext<'a> { + pub flag_key: &'a str, + pub flag_type: Type, + pub evaluation_context: &'a EvaluationContext, + pub provider_metadata: ProviderMetadata, + pub default_value: Option, + pub client_metadata: ClientMetadata, +} + +#[cfg(test)] +mod tests { + + use spec::spec; + + use crate::{ + provider::{MockFeatureProvider, ResolutionDetails}, + EvaluationErrorCode, EvaluationOptions, EvaluationReason, OpenFeature, StructValue, + }; + + use super::*; + + #[spec( + number = "4.1.1", + text = "Hook context MUST provide: the flag key, flag value type, evaluation context, and the default value." + )] + #[spec( + number = "4.1.2", + text = "The hook context SHOULD provide: access to the client metadata and the provider metadata fields." + )] + #[spec( + number = "4.1.3", + text = "The flag key, flag type, and default value properties MUST be immutable. If the language does not support immutability, the hook MUST NOT modify these properties." + )] + #[test] + fn hook_context() { + let context = HookContext { + flag_key: "flag_key", + flag_type: Type::Bool, + evaluation_context: &EvaluationContext::default(), + provider_metadata: ProviderMetadata::default(), + default_value: Some(Value::Bool(true)), + client_metadata: ClientMetadata::default(), + }; + + assert_eq!(context.flag_key, "flag_key"); + assert_eq!(context.flag_type, Type::Bool); + assert_eq!(context.evaluation_context, &EvaluationContext::default()); + assert_eq!(context.provider_metadata, ProviderMetadata::default()); + assert_eq!(context.default_value, Some(Value::Bool(true))); + assert_eq!(context.client_metadata, ClientMetadata::default()); + } + + #[spec( + number = "4.2.1", + text = "hook hints MUST be a structure supports definition of arbitrary properties, with keys of type string, and values of type boolean | string | number | datetime | structure." + )] + #[test] + fn hook_hints() { + let mut hints = HookHints::default(); + hints.hints.insert("key".to_string(), Value::Bool(true)); + hints + .hints + .insert("key2".to_string(), Value::String("value".to_string())); + hints.hints.insert("key3".to_string(), Value::Int(42)); + hints.hints.insert("key4".to_string(), Value::Float(3.14)); + hints.hints.insert("key5".to_string(), Value::Array(vec![])); + hints + .hints + .insert("key6".to_string(), Value::Struct(StructValue::default())); + + assert_eq!(hints.hints.len(), 6); + assert_eq!(hints.hints.get("key"), Some(&Value::Bool(true))); + assert_eq!( + hints.hints.get("key2"), + Some(&Value::String("value".to_string())) + ); + assert_eq!(hints.hints.get("key3"), Some(&Value::Int(42))); + assert_eq!(hints.hints.get("key4"), Some(&Value::Float(3.14))); + assert_eq!(hints.hints.get("key5"), Some(&Value::Array(vec![]))); + assert_eq!( + hints.hints.get("key6"), + Some(&Value::Struct(StructValue::default())) + ); + } + + #[spec(number = "4.2.2.1", text = "Hook hints MUST be immutable.")] + #[test] + fn hook_hints_mutability_checked_by_type_system() {} + + #[spec( + number = "4.2.2.2", + text = "The client metadata field in the hook context MUST be immutable." + )] + #[test] + fn client_metadata_mutability_checked_by_type_system() {} + + #[spec( + number = "4.2.2.3", + text = "The provider metadata field in the hook context MUST be immutable." + )] + #[test] + fn provider_metadata_mutability_checked_by_type_system() {} + + #[spec(number = "4.3.1", text = "Hooks MUST specify at least one stage.")] + #[test] + fn hook_interface_implementation_checked_by_type_system() {} + + #[spec( + number = "4.3.2.1", + text = "The before stage MUST run before flag resolution occurs. It accepts a hook context (required) and hook hints (optional) as parameters and returns either an evaluation context or nothing." + )] + #[test] + fn hook_before_function_interface_implementation_checked_by_type_system() {} + + #[spec( + number = "4.3.4", + text = "Any evaluation context returned from a before hook MUST be passed to subsequent before hooks (via HookContext)." + )] + #[tokio::test] + async fn before_hook_context_passing() { + let mut mock_hook_1 = MockHook::new(); + let mut mock_hook_2 = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_named_client("test"); + let mut mock_provider = MockFeatureProvider::default(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + mock_provider + .expect_resolve_bool_value() + .withf(|_, ctx| { + assert_eq!( + ctx, + &EvaluationContext::default() + .with_targeting_key("mock_hook_1") + .with_custom_field("is", "a test") + ); + true + }) + .return_const(Ok(ResolutionDetails::new(true))); + + api.set_provider(mock_provider).await; + drop(api); + + let flag_key = "flag"; + + let eval_ctx = EvaluationContext::default().with_custom_field("is", "a test"); + + let expected_eval_ctx = eval_ctx.clone(); + let client_metadata = client.metadata().clone(); + mock_hook_1 + .expect_before() + .withf(move |ctx, _| { + let hook_ctx_1 = HookContext { + flag_key, + flag_type: Type::Bool, + evaluation_context: &expected_eval_ctx, + default_value: Some(Value::Bool(false)), + provider_metadata: ProviderMetadata::default(), + client_metadata: client_metadata.clone(), + }; + + assert_eq!(ctx, &hook_ctx_1); + true + }) + .once() + .returning(move |_, _| { + Ok(Some( + EvaluationContext::default().with_targeting_key("mock_hook_1"), + )) + }); + + let expected_eval_ctx_2 = EvaluationContext::default().with_targeting_key("mock_hook_1"); + let client_metadata = client.metadata().clone(); + mock_hook_2 + .expect_before() + .withf(move |ctx, _| { + let hook_ctx_1 = HookContext { + flag_key, + flag_type: Type::Bool, + evaluation_context: &expected_eval_ctx_2, + default_value: Some(Value::Bool(false)), + provider_metadata: ProviderMetadata::default(), + client_metadata: client_metadata.clone(), + }; + + assert_eq!(ctx, &hook_ctx_1); + true + }) + .once() + .returning(move |_, _| Ok(None)); + + mock_hook_1.expect_after().return_const(Ok(())); + mock_hook_2.expect_after().return_const(Ok(())); + mock_hook_1.expect_finally().return_const(()); + mock_hook_2.expect_finally().return_const(()); + + // evaluation + client = client.with_hook(mock_hook_1).with_hook(mock_hook_2); + + let result = client.get_bool_value(flag_key, Some(&eval_ctx), None).await; + + assert!(result.is_ok()); + } + + #[spec( + number = "4.3.5", + text = "When before hooks have finished executing, any resulting evaluation context MUST be merged with the existing evaluation context." + )] + #[tokio::test] + async fn before_hook_context_merging() { + let mut mock_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + api.set_evaluation_context( + EvaluationContext::default() + .with_custom_field("key", "api context") + .with_custom_field("lowestPriority", true), + ) + .await; + + let mut client = api.create_named_client("test"); + client.set_evaluation_context( + EvaluationContext::default() + .with_custom_field("key", "client context") + .with_custom_field("lowestPriority", false) + .with_custom_field("beatsClient", false), + ); + + mock_hook.expect_before().once().returning(move |_, _| { + Ok(Some( + EvaluationContext::default() + .with_custom_field("key", "hook value") + .with_custom_field("multiplier", 3), + )) + }); + + mock_hook.expect_after().return_const(Ok(())); + mock_hook.expect_finally().return_const(()); + + let flag_key = "flag"; + let eval_ctx = EvaluationContext::default() + .with_custom_field("key", "invocation context") + .with_custom_field("on", true) + .with_custom_field("beatsClient", true); + + let expected_ctx = EvaluationContext::default() + .with_custom_field("key", "hook value") + .with_custom_field("multiplier", 3) + .with_custom_field("on", true) + .with_custom_field("lowestPriority", false) + .with_custom_field("beatsClient", true); + + let mut mock_provider = MockFeatureProvider::default(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + mock_provider + .expect_resolve_string_value() + .withf(move |_, ctx| { + assert_eq!(ctx, &expected_ctx); + true + }) + .return_const(Ok(ResolutionDetails::new("value"))); + + api.set_provider(mock_provider).await; + drop(api); + + client = client.with_hook(mock_hook); + + let result = client + .get_string_value(flag_key, Some(&eval_ctx), None) + .await; + + assert!(result.is_ok()); + } + + #[spec( + number = "4.3.6", + text = "The after stage MUST run after flag resolution occurs. It accepts a hook context (required), evaluation details (required) and hook hints (optional). It has no return value." + )] + #[tokio::test] + async fn after_hook() { + let mut mock_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut mock_provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + mock_provider + .expect_resolve_bool_value() + .once() + .in_sequence(&mut seq) + .return_const(Ok(ResolutionDetails::new(true))); + + api.set_provider(mock_provider).await; + drop(api); + + mock_hook.expect_before().returning(|_, _| Ok(None)); + + mock_hook + .expect_after() + .once() + .in_sequence(&mut seq) + .return_const(Ok(())); + + mock_hook.expect_finally().return_const(()); + + // evaluation + client = client.with_hook(mock_hook); + + let flag_key = "flag"; + let eval_ctx = EvaluationContext::default().with_custom_field("is", "a test"); + + let result = client.get_bool_value(flag_key, Some(&eval_ctx), None).await; + + assert!(result.is_ok()); + } + + #[spec( + number = "4.3.7", + text = "The error hook MUST run when errors are encountered in the before stage, the after stage or during flag resolution. It accepts hook context (required), exception representing what went wrong (required), and hook hints (optional). It has no return value." + )] + #[tokio::test] + async fn error_hook() { + // error on `before` hook + { + let mut mock_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut mock_provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider.expect_resolve_bool_value().never(); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + + api.set_provider(mock_provider).await; + drop(api); + + mock_hook.expect_before().returning(|_, _| error()); + + mock_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .return_const(()); + + mock_hook + .expect_finally() + .withf(|ctx, details, _| { + assert_eq!(ctx.flag_key, "flag"); + assert_eq!(ctx.flag_type, Type::Bool); + assert_eq!( + ctx.evaluation_context, + &EvaluationContext::default().with_custom_field("is", "a test") + ); + assert_eq!(ctx.default_value, Some(Value::Bool(false))); + assert_eq!(details.flag_key, "flag"); + assert_eq!(details.value, Value::Bool(false)); + assert_eq!(details.reason, Some(EvaluationReason::Error)); + true + }) + .return_const(()); + + // evaluation + client = client.with_hook(mock_hook); + + let flag_key = "flag"; + let eval_ctx = EvaluationContext::default().with_custom_field("is", "a test"); + + let result = client.get_bool_value(flag_key, Some(&eval_ctx), None).await; + + assert!(result.is_err()); + } + + // error on evaluation + { + let mut mock_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut mock_provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + + mock_hook.expect_before().returning(|_, _| Ok(None)); + + mock_provider + .expect_resolve_bool_value() + .once() + .in_sequence(&mut seq) + .return_const(error()); + + mock_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .return_const(()); + + mock_hook.expect_finally().return_const(()); + + api.set_provider(mock_provider).await; + drop(api); + + // evaluation + client = client.with_hook(mock_hook); + + let flag_key = "flag"; + let eval_ctx = EvaluationContext::default().with_custom_field("is", "a test"); + + let result = client.get_bool_value(flag_key, Some(&eval_ctx), None).await; + + assert!(result.is_err()); + } + } + + #[spec( + number = "4.3.8", + text = "The finally hook MUST run after the before, after, and error stages. It accepts a hook context (required), evaluation details (required) and hook hints (optional). It has no return value." + )] + #[tokio::test] + async fn finally_hook() { + let mut mock_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut mock_provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + mock_provider + .expect_resolve_bool_value() + .return_const(Ok(ResolutionDetails::new(true))); + + api.set_provider(mock_provider).await; + + mock_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| Ok(None)); + mock_hook + .expect_after() + .once() + .in_sequence(&mut seq) + .return_const(Ok(())); + + mock_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .withf(|ctx, details, _| { + assert_eq!(ctx.flag_key, "flag"); + assert_eq!(ctx.flag_type, Type::Bool); + assert_eq!( + ctx.evaluation_context, + &EvaluationContext::default().with_custom_field("is", "a test") + ); + assert_eq!(ctx.default_value, Some(Value::Bool(false))); + assert_eq!(details.flag_key, "flag"); + assert_eq!(details.value, Value::Bool(true)); + true + }) + .return_const(()); + + // evaluation + client = client.with_hook(mock_hook); + + let flag_key = "flag"; + let eval_ctx = EvaluationContext::default().with_custom_field("is", "a test"); + + let result = client.get_bool_value(flag_key, Some(&eval_ctx), None).await; + + assert!(result.is_ok()); + } + + #[spec( + number = "4.4.1", + text = "The API, Client, Provider, and invocation MUST have a method for registering hooks." + )] + #[spec( + number = "4.4.2", + text = "Hooks MUST be evaluated in the following order -> before: API, Client, Invocation, Provider. after: Provider, Invocation, Client, API. error(if applicable): Provider, Invocation, Client, API. finally: Provider, Invocation, Client, API." + )] + #[tokio::test] + async fn hook_evaluation_order() { + let mut mock_api_hook = MockHook::new(); + let mut mock_client_hook = MockHook::new(); + let mut mock_provider_hook = MockHook::new(); + let mut mock_invocation_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + // before: API, Client, Invocation, Provider + mock_api_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| Ok(None)); + mock_client_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| Ok(None)); + mock_invocation_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| Ok(None)); + mock_provider_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| Ok(None)); + + // evaluation + provider + .expect_resolve_bool_value() + .once() + .in_sequence(&mut seq) + .return_const(Ok(ResolutionDetails::new(true))); + + // after: Provider, Invocation, Client, API + mock_provider_hook + .expect_after() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| Ok(())); + mock_invocation_hook + .expect_after() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| Ok(())); + mock_client_hook + .expect_after() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| Ok(())); + mock_api_hook + .expect_after() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| Ok(())); + + // finally: Provider, Invocation, Client, API + mock_provider_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_invocation_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_client_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_api_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + + provider + .expect_hooks() + .return_const(vec![HookWrapper::new(mock_provider_hook)]); + provider.expect_initialize().return_const(()); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + + api.set_provider(provider).await; + api.add_hook(mock_api_hook).await; + client = client.with_hook(mock_client_hook); + + let eval = EvaluationOptions::default().with_hook(mock_invocation_hook); + let _ = client.get_bool_value("flag", None, Some(&eval)).await; + } + + #[spec( + number = "4.4.3", + text = "If a finally hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining finally hooks." + )] + #[test] + fn finally_hook_not_throw_checked_by_type_system() {} + + #[spec( + number = "4.4.4", + text = "If an error hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining error hooks." + )] + #[test] + fn error_hook_not_throw_checked_by_type_system() {} + + #[spec( + number = "4.4.5", + text = "If an error occurs in the before or after hooks, the error hooks MUST be invoked." + )] + #[tokio::test] + async fn error_hook_invoked_on_error() { + let mut mock_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut mock_provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + mock_provider.expect_hooks().return_const(vec![]); + mock_provider.expect_initialize().return_const(()); + mock_provider.expect_resolve_bool_value().never(); + mock_provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + + api.set_provider(mock_provider).await; + + mock_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| error()); + + mock_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .return_const(()); + + mock_hook.expect_finally().return_const(()); + + // evaluation + client = client.with_hook(mock_hook); + + let flag_key = "flag"; + let eval_ctx = EvaluationContext::default().with_custom_field("is", "a test"); + + let result = client.get_bool_value(flag_key, Some(&eval_ctx), None).await; + + assert!(result.is_err()); + } + + #[spec( + number = "4.4.6", + text = "If an error occurs during the evaluation of before or after hooks, any remaining hooks in the before or after stages MUST NOT be invoked." + )] + #[tokio::test] + async fn do_not_evaluate_remaining_hooks_on_error() { + let mut mock_api_hook = MockHook::new(); + let mut mock_client_hook = MockHook::new(); + let mut mock_provider_hook = MockHook::new(); + let mut mock_invocation_hook = MockHook::new(); + + let mut api = OpenFeature::default(); + let mut client = api.create_client(); + let mut provider = MockFeatureProvider::default(); + + let mut seq = mockall::Sequence::new(); + + // before: API, Client, Invocation, Provider + mock_api_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| Ok(None)); + mock_client_hook + .expect_before() + .once() + .in_sequence(&mut seq) + .returning(|_, _| error()); + + // Remaining `before` and `after` hooks should not be called + mock_invocation_hook.expect_before().never(); + mock_provider_hook.expect_before().never(); + + // evaluation should not be called + provider.expect_resolve_bool_value().never(); + + // after: Provider, Invocation, Client, API + mock_provider_hook.expect_after().never(); + mock_invocation_hook.expect_after().never(); + mock_client_hook.expect_after().never(); + mock_api_hook.expect_after().never(); + + // error: Provider, Invocation, Client, API + mock_provider_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_invocation_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_client_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_api_hook + .expect_error() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + + // finally: Provider, Invocation, Client, API + mock_provider_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_invocation_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_client_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + mock_api_hook + .expect_finally() + .once() + .in_sequence(&mut seq) + .returning(|_, _, _| {}); + + provider + .expect_hooks() + .return_const(vec![HookWrapper::new(mock_provider_hook)]); + provider.expect_initialize().return_const(()); + provider + .expect_metadata() + .return_const(ProviderMetadata::default()); + + api.set_provider(provider).await; + api.add_hook(mock_api_hook).await; + client = client.with_hook(mock_client_hook); + + let eval = EvaluationOptions::default().with_hook(mock_invocation_hook); + let result = client.get_bool_value("flag", None, Some(&eval)).await; + + assert!(result.is_err()); + } + + #[spec( + number = "4.4.7", + text = "If an error occurs in the before hooks, the default value MUST be returned." + )] + #[test] + fn default_value_covered_by_implementing_default_trait() {} + + fn error() -> Result { + Err(EvaluationError { + code: EvaluationErrorCode::General("error".to_string()), + message: None, + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index 01e14ca..e153777 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,10 @@ pub use api::*; mod evaluation; pub use evaluation::*; +/// Hooks related. +mod hooks; +pub use hooks::*; + /// Feature provider related. pub mod provider; pub use async_trait::async_trait; diff --git a/src/provider/feature_provider.rs b/src/provider/feature_provider.rs index c1019c8..ecf2261 100644 --- a/src/provider/feature_provider.rs +++ b/src/provider/feature_provider.rs @@ -49,6 +49,12 @@ pub trait FeatureProvider: Send + Sync + 'static { /// or accessor of type string, which identifies the provider implementation. fn metadata(&self) -> &ProviderMetadata; + /// The provider MAY define a hooks field or accessor which returns a list of hooks that + /// the provider supports. + fn hooks(&self) -> &[crate::hooks::HookWrapper] { + &[] + } + /// Resolve given `flag_key` as a bool value. async fn resolve_bool_value( &self, @@ -90,7 +96,7 @@ pub trait FeatureProvider: Send + Sync + 'static { // ============================================================ /// The metadata of a feature provider. -#[derive(Clone, Default, Debug)] +#[derive(Clone, Default, Debug, PartialEq)] pub struct ProviderMetadata { /// The name of provider. pub name: String,