diff --git a/openfeature/BUILD b/openfeature/BUILD index 0cd195d..37d52d3 100644 --- a/openfeature/BUILD +++ b/openfeature/BUILD @@ -4,6 +4,12 @@ package( default_visibility = ["//visibility:public"], ) +cc_library( + name = "base_hook", + hdrs = ["base_hook.h"], + include_prefix = "openfeature", +) + cc_library( name = "client", hdrs = ["client.h"], @@ -50,6 +56,16 @@ cc_library( include_prefix = "openfeature", ) +cc_library( + name = "evaluation_options", + hdrs = ["evaluation_options.h"], + include_prefix = "openfeature", + deps = [ + ":base_hook", + ":hook_hints", + ], +) + cc_library( name = "feature_provider_status_manager", srcs = ["feature_provider_status_manager.cpp"], @@ -74,6 +90,20 @@ cc_library( ], ) +cc_library( + name = "flag_evaluation_details", + srcs = ["flag_evaluation_details.cpp"], + hdrs = ["flag_evaluation_details.h"], + include_prefix = "openfeature", + deps = [ + ":error_code", + ":flag_metadata", + ":reason", + ":resolution_details", + ":value" + ], +) + cc_library( name = "flag_metadata", hdrs = ["flag_metadata.h"], @@ -124,6 +154,21 @@ cc_library( include_prefix = "openfeature", ) +cc_library( + name = "hook", + srcs = ["hook.cpp"], + hdrs = ["hook.h"], + include_prefix = "openfeature", + deps = [ + ":base_hook", + ":evaluation_context", + ":flag_evaluation_details", + ":hook_context", + ":hook_hints", + ":value", + ], +) + cc_library( name = "metadata", hdrs = ["metadata.h"], @@ -153,6 +198,7 @@ cc_library( hdrs = ["noop_provider.h"], include_prefix = "openfeature", deps = [ + ":base_hook", ":evaluation_context", ":metadata", ":provider", @@ -201,6 +247,7 @@ cc_library( deps = [ "@abseil-cpp//absl/status", "@abseil-cpp//absl/status:statusor", + ":base_hook", ":evaluation_context", ":metadata", ":resolution_details", diff --git a/openfeature/base_hook.h b/openfeature/base_hook.h new file mode 100644 index 0000000..8264426 --- /dev/null +++ b/openfeature/base_hook.h @@ -0,0 +1,21 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_BASE_HOOK_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_BASE_HOOK_H_ + +namespace openfeature { + +// Non-templated base class for all hooks. This will allow storing different +// hook types (e.g., BoolHook, +// StringHook) inside evaluation options. +class BaseHook { + public: + BaseHook() = default; + BaseHook(const BaseHook&) = delete; + BaseHook(BaseHook&&) = delete; + BaseHook& operator=(const BaseHook&) = delete; + BaseHook& operator=(BaseHook&&) = delete; + virtual ~BaseHook() = default; +}; + +} // namespace openfeature + +#endif // CPP_SDK_INCLUDE_OPENFEATURE_BASE_HOOK_H_ \ No newline at end of file diff --git a/openfeature/evaluation_options.h b/openfeature/evaluation_options.h new file mode 100644 index 0000000..647de94 --- /dev/null +++ b/openfeature/evaluation_options.h @@ -0,0 +1,14 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_EVALUATION_OPTIONS_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_EVALUATION_OPTIONS_H_ +#include +#include + +#include "openfeature/base_hook.h" +#include "openfeature/hook_hints.h" +namespace openfeature { +struct EvaluationOptions { + std::vector> hooks; + HookHints hook_hints; +}; +} // namespace openfeature +#endif // CPP_SDK_INCLUDE_OPENFEATURE_EVALUATION_OPTIONS_H_ \ No newline at end of file diff --git a/openfeature/flag_evaluation_details.cpp b/openfeature/flag_evaluation_details.cpp new file mode 100644 index 0000000..16a693d --- /dev/null +++ b/openfeature/flag_evaluation_details.cpp @@ -0,0 +1,42 @@ +#include "openfeature/flag_evaluation_details.h" + +#include +#include +#include + +#include "openfeature/error_code.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/reason.h" +#include "openfeature/resolution_details.h" +#include "openfeature/value.h" + +namespace openfeature { + +template +FlagEvaluationDetails::FlagEvaluationDetails( + std::string flag_key, T value, Reason reason, + std::optional variant, const FlagMetadata& flag_metadata, + std::optional error_code, + std::optional error_message) + : ResolutionDetails(std::move(value), reason, std::move(variant), + flag_metadata, error_code, std::move(error_message)), + flag_key_(std::move(flag_key)) {} + +template +FlagEvaluationDetails::FlagEvaluationDetails( + std::string flag_key, const ResolutionDetails& resolution_details) + : ResolutionDetails(resolution_details), + flag_key_(std::move(flag_key)) {} + +template +std::string_view FlagEvaluationDetails::GetFlagKey() const { + return flag_key_; +} + +template class FlagEvaluationDetails; +template class FlagEvaluationDetails; +template class FlagEvaluationDetails; +template class FlagEvaluationDetails; +template class FlagEvaluationDetails; + +} // namespace openfeature diff --git a/openfeature/flag_evaluation_details.h b/openfeature/flag_evaluation_details.h new file mode 100644 index 0000000..94ef1de --- /dev/null +++ b/openfeature/flag_evaluation_details.h @@ -0,0 +1,48 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_FLAG_EVALUATION_DETAILS_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_FLAG_EVALUATION_DETAILS_H_ + +#include +#include +#include + +#include "openfeature/error_code.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/reason.h" +#include "openfeature/resolution_details.h" +#include "openfeature/value.h" + +namespace openfeature { + +template +class FlagEvaluationDetails : public ResolutionDetails { + public: + FlagEvaluationDetails( + std::string flag_key, T value, Reason reason, + std::optional variant, const FlagMetadata& flag_metadata, + std::optional error_code = std::nullopt, + std::optional error_message = std::nullopt); + + FlagEvaluationDetails(std::string flag_key, + const ResolutionDetails& resolution_details); + + FlagEvaluationDetails(const FlagEvaluationDetails&) = default; + FlagEvaluationDetails& operator=(const FlagEvaluationDetails&) = default; + FlagEvaluationDetails(FlagEvaluationDetails&&) noexcept = default; + FlagEvaluationDetails& operator=(FlagEvaluationDetails&&) noexcept = default; + ~FlagEvaluationDetails() = default; + std::string_view GetFlagKey() const; + + private: + std::string flag_key_; +}; + +// Type aliases for common types. +using BoolFlagEvaluationDetails = FlagEvaluationDetails; +using StringFlagEvaluationDetails = FlagEvaluationDetails; +using IntFlagEvaluationDetails = FlagEvaluationDetails; +using DoubleFlagEvaluationDetails = FlagEvaluationDetails; +using ObjectFlagEvaluationDetails = FlagEvaluationDetails; + +} // namespace openfeature + +#endif // CPP_SDK_INCLUDE_OPENFEATURE_FLAG_EVALUATION_DETAILS_H_ diff --git a/openfeature/hook.cpp b/openfeature/hook.cpp new file mode 100644 index 0000000..21af598 --- /dev/null +++ b/openfeature/hook.cpp @@ -0,0 +1,12 @@ +#include "openfeature/hook.h" + +namespace openfeature { + +// Explicit template instantiations for common hook types. +template class Hook; +template class Hook; +template class Hook; +template class Hook; +template class Hook; + +} // namespace openfeature diff --git a/openfeature/hook.h b/openfeature/hook.h new file mode 100644 index 0000000..09774fd --- /dev/null +++ b/openfeature/hook.h @@ -0,0 +1,66 @@ +#ifndef CPP_SDK_INCLUDE_OPENFEATURE_HOOK_H_ +#define CPP_SDK_INCLUDE_OPENFEATURE_HOOK_H_ + +#include +#include +#include +#include + +#include "openfeature/base_hook.h" +#include "openfeature/evaluation_context.h" +#include "openfeature/flag_evaluation_details.h" +#include "openfeature/hook_context.h" +#include "openfeature/hook_hints.h" +#include "openfeature/value.h" + +namespace openfeature { + +// Hook allows application developers to add arbitrary behavior to the +// flag evaluation lifecycle. Hooks operate similarly to middleware in web +// frameworks. They are executed stack-wise with respect to flag resolution, +// prioritizing increasing specificity (API, Client, Invocation, Provider) +// first, and the order in which they were added second. +/// https://openfeature.dev/specification/sections/hooks +template +class Hook : public BaseHook { + public: + Hook() = default; + Hook(const Hook&) = delete; + Hook& operator=(const Hook&) = delete; + Hook(Hook&&) = delete; + Hook& operator=(Hook&&) = delete; + ~Hook() override = default; + + // Runs before the flag evaluation occurs. + virtual std::optional Before(HookContext& ctx, + const HookHints& hints) { + return std::nullopt; + } + + // Runs immediately after successful flag evaluation occurs. + virtual void After(const HookContext& ctx, + const FlagEvaluationDetails& details, + const HookHints& hints) {} + + // Runs if an error occurs during flag evaluation or in `Before`/`After` + // stages. + virtual void Error(const HookContext& ctx, const std::exception& error, + const HookHints& hints) {} + + // Runs after the flag evaluation occurs, regardless of whether it was + // successful or not. + virtual void Finally(const HookContext& ctx, + const FlagEvaluationDetails& details, + const HookHints& hints) {} +}; + +// Type aliases for common hook specializations. +using BoolHook = Hook; +using StringHook = Hook; +using IntHook = Hook; +using DoubleHook = Hook; +using ObjectHook = Hook; + +} // namespace openfeature + +#endif // CPP_SDK_INCLUDE_OPENFEATURE_HOOK_H_ diff --git a/openfeature/memory_provider/BUILD b/openfeature/memory_provider/BUILD index 99dbd0b..7f601b1 100644 --- a/openfeature/memory_provider/BUILD +++ b/openfeature/memory_provider/BUILD @@ -21,6 +21,7 @@ cc_library( hdrs = ["in_memory_provider.h"], include_prefix = "openfeature", deps = [ + "//openfeature:base_hook", "//openfeature:error_code", "//openfeature:evaluation_context", ":flag", diff --git a/openfeature/memory_provider/in_memory_provider.cpp b/openfeature/memory_provider/in_memory_provider.cpp index a48df05..4e43baa 100644 --- a/openfeature/memory_provider/in_memory_provider.cpp +++ b/openfeature/memory_provider/in_memory_provider.cpp @@ -5,6 +5,7 @@ #include #include "absl/status/statusor.h" +#include "openfeature/base_hook.h" #include "openfeature/error_code.h" #include "openfeature/memory_provider/flag.h" #include "openfeature/reason.h" @@ -21,6 +22,10 @@ Metadata InMemoryProvider::GetMetadata() const { return Metadata{std::string(kName)}; } +std::vector> InMemoryProvider::GetHooks() const { + return {}; +} + absl::Status InMemoryProvider::Init(const EvaluationContext& ctx) { { std::unique_lock lock(mutex_); diff --git a/openfeature/memory_provider/in_memory_provider.h b/openfeature/memory_provider/in_memory_provider.h index 484d0a3..9394ee8 100644 --- a/openfeature/memory_provider/in_memory_provider.h +++ b/openfeature/memory_provider/in_memory_provider.h @@ -10,6 +10,7 @@ #include "absl/status/status.h" #include "absl/status/statusor.h" +#include "openfeature/base_hook.h" #include "openfeature/evaluation_context.h" #include "openfeature/metadata.h" #include "openfeature/provider.h" @@ -44,6 +45,8 @@ class InMemoryProvider : public FeatureProvider { // will be added to the configuration. void UpdateFlag(std::string key, std::any new_flag); + std::vector> GetHooks() const override; + absl::StatusOr> GetBooleanEvaluation( std::string_view key, bool default_value, const EvaluationContext& ctx) override; diff --git a/openfeature/noop_provider.cpp b/openfeature/noop_provider.cpp index 5b067f0..97fd9f0 100644 --- a/openfeature/noop_provider.cpp +++ b/openfeature/noop_provider.cpp @@ -4,6 +4,10 @@ namespace openfeature { Metadata NoopProvider::GetMetadata() const { return Metadata{name_}; } +std::vector> NoopProvider::GetHooks() const { + return {}; +} + absl::StatusOr> NoopProvider::GetBooleanEvaluation(std::string_view flag, bool default_value, const EvaluationContext& ctx) { diff --git a/openfeature/noop_provider.h b/openfeature/noop_provider.h index a8ebf6b..1a1ac63 100644 --- a/openfeature/noop_provider.h +++ b/openfeature/noop_provider.h @@ -6,6 +6,7 @@ #include #include "absl/status/statusor.h" +#include "openfeature/base_hook.h" #include "openfeature/evaluation_context.h" #include "openfeature/metadata.h" #include "openfeature/provider.h" @@ -23,6 +24,9 @@ class NoopProvider : public FeatureProvider { // Metadata returns the metadata of the provider. Metadata GetMetadata() const override; + // GetHooks returns an empty vector of hooks. + std::vector> GetHooks() const override; + // BooleanEvaluation returns a boolean flag. absl::StatusOr> GetBooleanEvaluation( std::string_view flag, bool default_value, diff --git a/openfeature/provider.h b/openfeature/provider.h index 50fcdcb..1b458ef 100644 --- a/openfeature/provider.h +++ b/openfeature/provider.h @@ -6,6 +6,7 @@ #include "absl/status/status.h" #include "absl/status/statusor.h" +#include "openfeature/base_hook.h" #include "openfeature/evaluation_context.h" #include "openfeature/metadata.h" #include "openfeature/resolution_details.h" @@ -24,6 +25,7 @@ class FeatureProvider { public: virtual ~FeatureProvider() = default; virtual Metadata GetMetadata() const = 0; + virtual std::vector> GetHooks() const = 0; virtual absl::StatusOr> GetBooleanEvaluation(std::string_view flag, bool default_value, const EvaluationContext& ctx) = 0; diff --git a/test/BUILD b/test/BUILD index d4a5b39..e6e4248 100644 --- a/test/BUILD +++ b/test/BUILD @@ -8,6 +8,7 @@ cc_library( name = "mock_feature_provider", hdrs = ["mocks/mock_feature_provider.h"], deps = [ + "//openfeature:base_hook", "//openfeature:provider", "@googletest//:gtest", ], @@ -42,6 +43,15 @@ cc_test( ], ) +cc_test( + name = "flag_evaluation_details_test", + srcs = ["flag_evaluation_details_test.cpp"], + deps = [ + "//openfeature:flag_evaluation_details", + "@googletest//:gtest_main", + ], +) + cc_test( name = "noop_provider_test", srcs = ["noop_provider_test.cpp"], @@ -112,12 +122,37 @@ cc_test( srcs = ["hook_context_test.cpp"], deps = [ "//openfeature:evaluation_context", - "//openfeature:flag_metadata", - "//openfeature:flag_type_value", "//openfeature:hook_context", "//openfeature:hook_data", - "//openfeature:metadata", "//openfeature:value", "@googletest//:gtest_main", ], +) + +cc_test( + name = "hook_test", + srcs = ["hook_test.cpp"], + deps = [ + "//openfeature:base_hook", + "//openfeature:evaluation_context", + "//openfeature:flag_evaluation_details", + "//openfeature:flag_metadata", + "//openfeature:hook", + "//openfeature:hook_context", + "//openfeature:hook_hints", + "//openfeature:value", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "evaluation_options_test", + srcs = ["evaluation_options_test.cpp"], + deps = [ + "//openfeature:base_hook", + "//openfeature:evaluation_options", + "//openfeature:hook", + "//openfeature:hook_hints", + "@googletest//:gtest_main", + ], ) \ No newline at end of file diff --git a/test/evaluation_options_test.cpp b/test/evaluation_options_test.cpp new file mode 100644 index 0000000..03fd31f --- /dev/null +++ b/test/evaluation_options_test.cpp @@ -0,0 +1,121 @@ +#include "openfeature/evaluation_options.h" + +#include + +#include +#include +#include +#include + +#include "openfeature/base_hook.h" +#include "openfeature/hook.h" +#include "openfeature/hook_hints.h" + +namespace openfeature { + +class CustomTestHook : public BoolHook {}; +class AnotherTestHook : public StringHook {}; + +TEST(EvaluationOptionsTest, DefaultConstructorInitializesEmptyContainers) { + EvaluationOptions options; + EXPECT_TRUE(options.hooks.empty()); + EXPECT_TRUE(options.hook_hints.empty()); +} + +TEST(EvaluationOptionsTest, StoresAndPreservesMultipleHooksInOrder) { + EvaluationOptions options; + + auto hook1 = std::make_shared(); + auto hook2 = std::make_shared(); + auto hook3 = std::make_shared(); + + options.hooks.push_back(hook1); + options.hooks.push_back(hook2); + options.hooks.push_back(hook3); + + ASSERT_EQ(options.hooks.size(), 3); + EXPECT_EQ(options.hooks[0], hook1); + EXPECT_EQ(options.hooks[1], hook2); + EXPECT_EQ(options.hooks[2], hook3); +} + +TEST(EvaluationOptionsTest, CanDowncastBaseHookPointersToConcreteTypes) { + EvaluationOptions options; + + auto bool_hook = std::make_shared(); + auto string_hook = std::make_shared(); + + options.hooks.push_back(bool_hook); + options.hooks.push_back(string_hook); + + ASSERT_EQ(options.hooks.size(), 2); + + auto retrieved_bool_hook = + std::dynamic_pointer_cast(options.hooks[0]); + ASSERT_NE(retrieved_bool_hook, nullptr); + EXPECT_EQ(retrieved_bool_hook, bool_hook); + + auto retrieved_string_hook = + std::dynamic_pointer_cast(options.hooks[1]); + ASSERT_NE(retrieved_string_hook, nullptr); + EXPECT_EQ(retrieved_string_hook, string_hook); + + // Cross-type downcast should return nullptr safely + auto invalid_cast = std::dynamic_pointer_cast(options.hooks[0]); + EXPECT_EQ(invalid_cast, nullptr); +} + +TEST(EvaluationOptionsTest, StoresAndRetrievesHookHints) { + const std::string trace_id_key = "trace_id"; + const std::string timeout_key = "timeout_ms"; + const std::string is_debug_key = "is_debug"; + const std::string trace_id_value = "trace-12345"; + const int timeout_value = 500; + const bool is_debug_value = true; + EvaluationOptions options; + + options.hook_hints[trace_id_key] = std::string(trace_id_value); + options.hook_hints[timeout_key] = timeout_value; + options.hook_hints[is_debug_key] = is_debug_value; + + EXPECT_EQ(options.hook_hints.size(), 3); + + auto it_trace = options.hook_hints.find(trace_id_key); + ASSERT_NE(it_trace, options.hook_hints.end()); + EXPECT_EQ(std::any_cast(it_trace->second), trace_id_value); + + auto it_timeout = options.hook_hints.find(timeout_key); + ASSERT_NE(it_timeout, options.hook_hints.end()); + EXPECT_EQ(std::any_cast(it_timeout->second), timeout_value); + + auto it_debug = options.hook_hints.find(is_debug_key); + ASSERT_NE(it_debug, options.hook_hints.end()); + EXPECT_TRUE(std::any_cast(it_debug->second)); +} + +TEST(EvaluationOptionsTest, SupportsCopyAndMoveSemantics) { + const std::string trace_id_key = "trace_id"; + const std::string trace_id_value = "value"; + EvaluationOptions original; + auto hook = std::make_shared(); + original.hooks.push_back(hook); + original.hook_hints[trace_id_key] = trace_id_value; + + // Copy construction + EvaluationOptions copy_constructed(original); + EXPECT_EQ(copy_constructed.hooks.size(), 1); + EXPECT_EQ(copy_constructed.hooks[0], hook); + EXPECT_EQ( + std::any_cast(copy_constructed.hook_hints[trace_id_key]), + trace_id_value); + + // Move construction + EvaluationOptions moved_constructed(std::move(original)); + EXPECT_EQ(moved_constructed.hooks.size(), 1); + EXPECT_EQ(moved_constructed.hooks[0], hook); + EXPECT_EQ( + std::any_cast(moved_constructed.hook_hints[trace_id_key]), + trace_id_value); +} + +} // namespace openfeature diff --git a/test/flag_evaluation_details_test.cpp b/test/flag_evaluation_details_test.cpp new file mode 100644 index 0000000..645f5be --- /dev/null +++ b/test/flag_evaluation_details_test.cpp @@ -0,0 +1,224 @@ +#include "openfeature/flag_evaluation_details.h" + +#include + +#include +#include +#include + +#include "openfeature/error_code.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/reason.h" +#include "openfeature/resolution_details.h" +#include "openfeature/value.h" + +namespace openfeature { + +TEST(FlagEvaluationDetailsTest, DirectConstructorAccessesFieldsForBoolean) { + const std::string expected_flag_key = "bool-flag"; + const bool expected_value = true; + const Reason expected_reason = Reason::kTargetingMatch; + const std::optional expected_variant = "on-variant"; + const FlagMetadata expected_flag_metadata{}; + const std::optional expected_error_code = ErrorCode::kParseError; + const std::optional expected_error_message = + "Failed to parse data"; + + BoolFlagEvaluationDetails details( + expected_flag_key, expected_value, expected_reason, expected_variant, + expected_flag_metadata, expected_error_code, expected_error_message); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), expected_error_code); + ASSERT_EQ(details.GetErrorMessage(), expected_error_message); + ASSERT_NO_THROW(details.GetFlagMetadata()); +} + +TEST(FlagEvaluationDetailsTest, ResolutionDetailsConstructorForBoolean) { + const std::string expected_flag_key = "bool-flag-wrapped"; + const bool expected_value = false; + const Reason expected_reason = Reason::kDefault; + const std::optional expected_variant = "off-variant"; + const FlagMetadata expected_flag_metadata{}; + const std::optional expected_error_code = std::nullopt; + const std::optional expected_error_message = std::nullopt; + + BoolResolutionDetails res_details( + expected_value, expected_reason, expected_variant, expected_flag_metadata, + expected_error_code, expected_error_message); + + BoolFlagEvaluationDetails details(expected_flag_key, res_details); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), expected_error_code); + ASSERT_EQ(details.GetErrorMessage(), expected_error_message); + ASSERT_NO_THROW(details.GetFlagMetadata()); +} + +TEST(FlagEvaluationDetailsTest, DirectConstructorAccessesFieldsForString) { + const std::string expected_flag_key = "string-flag"; + const std::string expected_value = "expected-string"; + const Reason expected_reason = Reason::kTargetingMatch; + const std::optional expected_variant = "on-variant"; + const FlagMetadata expected_flag_metadata{}; + const std::optional expected_error_code = ErrorCode::kTypeMismatch; + const std::optional expected_error_message = + "Type mismatch error"; + + StringFlagEvaluationDetails details( + expected_flag_key, expected_value, expected_reason, expected_variant, + expected_flag_metadata, expected_error_code, expected_error_message); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), expected_error_code); + ASSERT_EQ(details.GetErrorMessage(), expected_error_message); + ASSERT_NO_THROW(details.GetFlagMetadata()); +} + +TEST(FlagEvaluationDetailsTest, ResolutionDetailsConstructorForString) { + const std::string expected_flag_key = "string-flag-wrapped"; + const std::string expected_value = "wrapped-string"; + const Reason expected_reason = Reason::kStatic; + const std::optional expected_variant = std::nullopt; + const FlagMetadata expected_flag_metadata{}; + + StringResolutionDetails res_details(expected_value, expected_reason, + expected_variant, expected_flag_metadata); + + StringFlagEvaluationDetails details(expected_flag_key, res_details); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), std::nullopt); + ASSERT_EQ(details.GetErrorMessage(), std::nullopt); +} + +TEST(FlagEvaluationDetailsTest, DirectConstructorAccessesFieldsForInteger) { + const std::string expected_flag_key = "int-flag"; + const int64_t expected_value = 123456789LL; + const Reason expected_reason = Reason::kTargetingMatch; + const std::optional expected_variant = "v1"; + const FlagMetadata expected_flag_metadata{}; + + IntFlagEvaluationDetails details(expected_flag_key, expected_value, + expected_reason, expected_variant, + expected_flag_metadata); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), std::nullopt); + ASSERT_EQ(details.GetErrorMessage(), std::nullopt); +} + +TEST(FlagEvaluationDetailsTest, ResolutionDetailsConstructorForInteger) { + const std::string expected_flag_key = "int-flag-wrapped"; + const int64_t expected_value = 42; + const Reason expected_reason = Reason::kCached; + const std::optional expected_variant = "v2"; + const FlagMetadata expected_flag_metadata{}; + + IntResolutionDetails res_details(expected_value, expected_reason, + expected_variant, expected_flag_metadata); + + IntFlagEvaluationDetails details(expected_flag_key, res_details); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), std::nullopt); + ASSERT_EQ(details.GetErrorMessage(), std::nullopt); +} + +TEST(FlagEvaluationDetailsTest, DirectConstructorAccessesFieldsForDouble) { + const std::string expected_flag_key = "double-flag"; + const double expected_value = 123.456; + const Reason expected_reason = Reason::kTargetingMatch; + const std::optional expected_variant = "on-variant"; + const FlagMetadata expected_flag_metadata{}; + + DoubleFlagEvaluationDetails details(expected_flag_key, expected_value, + expected_reason, expected_variant, + expected_flag_metadata); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_DOUBLE_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), std::nullopt); + ASSERT_EQ(details.GetErrorMessage(), std::nullopt); +} + +TEST(FlagEvaluationDetailsTest, ResolutionDetailsConstructorForDouble) { + const std::string expected_flag_key = "double-flag-wrapped"; + const double expected_value = 3.14159265359; + const Reason expected_reason = Reason::kSplit; + const std::optional expected_variant = "pi-variant"; + const FlagMetadata expected_flag_metadata{}; + + DoubleResolutionDetails res_details(expected_value, expected_reason, + expected_variant, expected_flag_metadata); + + DoubleFlagEvaluationDetails details(expected_flag_key, res_details); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_DOUBLE_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); +} + +TEST(FlagEvaluationDetailsTest, DirectConstructorAccessesFieldsForObject) { + const std::string expected_flag_key = "object-flag"; + const Value expected_value = Value(std::map{ + {"key1", Value("value1")}, {"key2", Value(42)}}); + const Reason expected_reason = Reason::kTargetingMatch; + const std::optional expected_variant = "object-variant"; + const FlagMetadata expected_flag_metadata{}; + + ObjectFlagEvaluationDetails details(expected_flag_key, expected_value, + expected_reason, expected_variant, + expected_flag_metadata); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), std::nullopt); + ASSERT_EQ(details.GetErrorMessage(), std::nullopt); +} + +TEST(FlagEvaluationDetailsTest, ResolutionDetailsConstructorForObject) { + const std::string expected_flag_key = "object-flag-wrapped"; + const Value expected_value = Value(std::map{ + {"nested", Value(true)}, {"score", Value(99.5)}}); + const Reason expected_reason = Reason::kTargetingMatch; + const std::optional expected_variant = "complex-variant"; + const FlagMetadata expected_flag_metadata{}; + + ObjectResolutionDetails res_details(expected_value, expected_reason, + expected_variant, expected_flag_metadata); + + ObjectFlagEvaluationDetails details(expected_flag_key, res_details); + + EXPECT_EQ(details.GetFlagKey(), expected_flag_key); + EXPECT_EQ(details.GetValue(), expected_value); + ASSERT_EQ(details.GetReason(), expected_reason); + ASSERT_EQ(details.GetVariant(), expected_variant); + ASSERT_EQ(details.GetErrorCode(), std::nullopt); + ASSERT_EQ(details.GetErrorMessage(), std::nullopt); +} + +} // namespace openfeature diff --git a/test/hook_test.cpp b/test/hook_test.cpp new file mode 100644 index 0000000..042b4c1 --- /dev/null +++ b/test/hook_test.cpp @@ -0,0 +1,250 @@ +#include "openfeature/hook.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include "openfeature/base_hook.h" +#include "openfeature/evaluation_context.h" +#include "openfeature/flag_evaluation_details.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/hook_context.h" +#include "openfeature/hook_hints.h" +#include "openfeature/value.h" + +namespace openfeature { +namespace { + +template +class TrackingHook : public Hook { + public: + TrackingHook() = default; + + std::optional Before(HookContext& ctx, + const HookHints& hints) override { + before_called = true; + last_flag_key = ctx.GetFlagKey(); + if (auto it_hints = hints.find("before_hint"); it_hints != hints.end()) { + last_hint_value = std::any_cast(it_hints->second); + } + if (return_context.has_value()) { + return return_context; + } + return std::nullopt; + } + + void After(const HookContext& ctx, const FlagEvaluationDetails& details, + const HookHints& hints) override { + after_called = true; + last_flag_key = ctx.GetFlagKey(); + last_reason = details.GetReason(); + if (auto it_hints = hints.find("after_hint"); it_hints != hints.end()) { + last_hint_value = std::any_cast(it_hints->second); + } + } + + void Error(const HookContext& ctx, const std::exception& error, + const HookHints& hints) override { + error_called = true; + last_flag_key = ctx.GetFlagKey(); + last_error_message = error.what(); + if (auto it_hints = hints.find("error_hint"); it_hints != hints.end()) { + last_hint_value = std::any_cast(it_hints->second); + } + } + + void Finally(const HookContext& ctx, + const FlagEvaluationDetails& details, + const HookHints& hints) override { + finally_called = true; + last_flag_key = ctx.GetFlagKey(); + last_reason = details.GetReason(); + if (auto it_hints = hints.find("finally_hint"); it_hints != hints.end()) { + last_hint_value = std::any_cast(it_hints->second); + } + } + + void SetReturnContext(std::optional ctx) { + return_context = std::move(ctx); + } + + bool before_called = false; + bool after_called = false; + bool error_called = false; + bool finally_called = false; + std::string last_flag_key; + std::string last_hint_value; + std::string last_error_message; + Reason last_reason = Reason::kUnknown; + std::optional return_context; +}; + +class HookTest : public ::testing::Test { + protected: + HookTest() + : initial_ctx_( + EvaluationContext::Builder().WithTargetingKey("user-123").build()), + hook_data_(std::make_shared()) {} + + EvaluationContext initial_ctx_; + Metadata client_metadata_{"client-id"}; + Metadata provider_metadata_{"provider-id"}; + std::shared_ptr hook_data_; +}; + +} // namespace + +TEST_F(HookTest, DefaultBeforeReturnsNulloptForAllSpecializations) { + constexpr bool kBoolValue = true; + constexpr int kIntValue = 100; + constexpr double kDoubleValue = 3.14; + const std::string string_value = "val"; + const Value object_value = Value("obj"); + HookHints hints; + + BoolHook bool_hook; + BoolHookContext bool_ctx("bool-flag", FlagValueType::kBoolean, kBoolValue, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + EXPECT_FALSE(bool_hook.Before(bool_ctx, hints).has_value()); + + StringHook string_hook; + StringHookContext string_ctx("string-flag", FlagValueType::kString, + string_value, initial_ctx_, client_metadata_, + provider_metadata_, hook_data_); + EXPECT_FALSE(string_hook.Before(string_ctx, hints).has_value()); + + IntHook int_hook; + IntHookContext int_ctx("int-flag", FlagValueType::kInteger, kIntValue, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + EXPECT_FALSE(int_hook.Before(int_ctx, hints).has_value()); + + DoubleHook double_hook; + DoubleHookContext double_ctx("double-flag", FlagValueType::kDouble, + kDoubleValue, initial_ctx_, client_metadata_, + provider_metadata_, hook_data_); + EXPECT_FALSE(double_hook.Before(double_ctx, hints).has_value()); + + ObjectHook object_hook; + ObjectHookContext object_ctx("object-flag", FlagValueType::kObject, + object_value, initial_ctx_, client_metadata_, + provider_metadata_, hook_data_); + EXPECT_FALSE(object_hook.Before(object_ctx, hints).has_value()); +} + +TEST_F(HookTest, DefaultAfterErrorAndFinallyAreNoOpsWithoutThrowing) { + constexpr bool kBoolValue = true; + BoolHook hook; + + BoolHookContext ctx("bool-flag", FlagValueType::kBoolean, kBoolValue, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + BoolFlagEvaluationDetails details("bool-flag", kBoolValue, Reason::kStatic, + std::nullopt, FlagMetadata()); + HookHints hints; + std::runtime_error error("simulated error"); + + EXPECT_NO_THROW(hook.After(ctx, details, hints)); + EXPECT_NO_THROW(hook.Error(ctx, error, hints)); + EXPECT_NO_THROW(hook.Finally(ctx, details, hints)); +} + +TEST_F(HookTest, OverriddenBeforeCanModifyAndReturnEvaluationContext) { + constexpr bool kBoolValue = true; + TrackingHook hook; + BoolHookContext ctx("bool-flag", FlagValueType::kBoolean, kBoolValue, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + HookHints hints{{"before_hint", std::any(std::string("hint-val"))}}; + + EvaluationContext modified_ctx = + EvaluationContext::Builder() + .WithTargetingKey("mutated-user") + .WithAttribute("region", std::string("us-east")) + .build(); + hook.SetReturnContext(modified_ctx); + + std::optional result = hook.Before(ctx, hints); + + EXPECT_TRUE(hook.before_called); + EXPECT_EQ(hook.last_flag_key, "bool-flag"); + EXPECT_EQ(hook.last_hint_value, "hint-val"); + ASSERT_TRUE(result.has_value()); + ASSERT_TRUE(result->GetTargetingKey().has_value()); + EXPECT_EQ(result->GetTargetingKey().value(), "mutated-user"); +} + +TEST_F(HookTest, OverriddenAfterReceivesContextDetailsAndHints) { + const std::string string_value = "default"; + TrackingHook hook; + StringHookContext ctx("string-flag", FlagValueType::kString, string_value, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + StringFlagEvaluationDetails details("string-flag", "variant-val", + Reason::kTargetingMatch, "v1", + FlagMetadata()); + HookHints hints{{"after_hint", std::any(std::string("after-data"))}}; + + hook.After(ctx, details, hints); + + EXPECT_TRUE(hook.after_called); + EXPECT_EQ(hook.last_flag_key, "string-flag"); + EXPECT_EQ(hook.last_reason, Reason::kTargetingMatch); + EXPECT_EQ(hook.last_hint_value, "after-data"); +} + +TEST_F(HookTest, OverriddenErrorReceivesExceptionAndHints) { + constexpr int64_t kIntValue = 42; + TrackingHook hook; + IntHookContext ctx("int-flag", FlagValueType::kInteger, kIntValue, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + std::runtime_error error("provider timeout error"); + HookHints hints{{"error_hint", std::any(std::string("error-data"))}}; + + hook.Error(ctx, error, hints); + + EXPECT_TRUE(hook.error_called); + EXPECT_EQ(hook.last_flag_key, "int-flag"); + EXPECT_EQ(hook.last_error_message, "provider timeout error"); + EXPECT_EQ(hook.last_hint_value, "error-data"); +} + +TEST_F(HookTest, OverriddenFinallyReceivesContextDetailsAndHints) { + constexpr double kDoubleValue = 1.0; + constexpr double kSecondDoubleValue = 2.718; + TrackingHook hook; + DoubleHookContext ctx("double-flag", FlagValueType::kDouble, kDoubleValue, + initial_ctx_, client_metadata_, provider_metadata_, + hook_data_); + DoubleFlagEvaluationDetails details("double-flag", kSecondDoubleValue, + Reason::kCached, std::nullopt, + FlagMetadata()); + HookHints hints{{"finally_hint", std::any(std::string("finally-data"))}}; + + hook.Finally(ctx, details, hints); + + EXPECT_TRUE(hook.finally_called); + EXPECT_EQ(hook.last_flag_key, "double-flag"); + EXPECT_EQ(hook.last_reason, Reason::kCached); + EXPECT_EQ(hook.last_hint_value, "finally-data"); +} + +TEST_F(HookTest, PolymorphicDestructionViaBaseHookPointer) { + std::vector> hooks; + hooks.push_back(std::make_unique()); + hooks.push_back(std::make_unique()); + hooks.push_back(std::make_unique()); + hooks.push_back(std::make_unique()); + hooks.push_back(std::make_unique()); + + EXPECT_EQ(hooks.size(), 5); +} + +} // namespace openfeature diff --git a/test/mocks/mock_feature_provider.h b/test/mocks/mock_feature_provider.h index 74e7ad7..cfad1e8 100644 --- a/test/mocks/mock_feature_provider.h +++ b/test/mocks/mock_feature_provider.h @@ -3,6 +3,9 @@ #include +#include + +#include "openfeature/base_hook.h" #include "openfeature/provider.h" namespace openfeature { @@ -11,6 +14,8 @@ namespace openfeature { class MockFeatureProvider : public FeatureProvider { public: MOCK_METHOD(Metadata, GetMetadata, (), (const, override)); + MOCK_METHOD(std::vector>, GetHooks, (), + (const, override)); MOCK_METHOD(absl::StatusOr>, GetBooleanEvaluation, (std::string_view flag, bool default_value,