diff --git a/open_feature/hooks/__init__.py b/open_feature/hooks/__init__.py index e69de29b..c790bb2e 100644 --- a/open_feature/hooks/__init__.py +++ b/open_feature/hooks/__init__.py @@ -0,0 +1,21 @@ +import typing + +from open_feature.hooks.hook import Hook + + +_hooks: typing.List[Hook] = [] + + +def add_api_hooks(hooks: typing.List[Hook]): + global _hooks + _hooks = _hooks + hooks + + +def clear_api_hooks(): + global _hooks + _hooks = [] + + +def api_hooks() -> typing.List[Hook]: + global _hooks + return _hooks diff --git a/open_feature/open_feature_client.py b/open_feature/open_feature_client.py index 97462368..c73e2f5f 100644 --- a/open_feature/open_feature_client.py +++ b/open_feature/open_feature_client.py @@ -14,6 +14,7 @@ from open_feature.flag_evaluation.flag_type import FlagType from open_feature.flag_evaluation.reason import Reason from open_feature.flag_evaluation.resolution_details import FlagResolutionDetails +from open_feature.hooks import api_hooks from open_feature.hooks.hook import Hook from open_feature.hooks.hook_context import HookContext from open_feature.hooks.hook_support import ( @@ -257,13 +258,14 @@ def evaluate_flag_details( client_metadata=None, provider_metadata=None, ) - # Todo add api level hooks - # https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#requirement-442 # Hooks need to be handled in different orders at different stages # in the flag evaluation # before: API, Client, Invocation, Provider merged_hooks = ( - self.hooks + evaluation_hooks + self.provider.get_provider_hooks() + api_hooks() + + self.hooks + + evaluation_hooks + + self.provider.get_provider_hooks() ) # after, error, finally: Provider, Invocation, Client, API reversed_merged_hooks = merged_hooks[:] diff --git a/readme.md b/readme.md index a3eb3c0e..e9a151dd 100644 --- a/readme.md +++ b/readme.md @@ -137,7 +137,24 @@ See [here](https://openfeature.dev/ecosystem) for a catalog of available provide ### Hooks: -TBD (See Issue [#72](https://github.com/open-feature/python-sdk/issues/72)) +A hook is a mechanism that allows for adding arbitrary behavior at well-defined points of the flag evaluation life-cycle. Use cases include validating the resolved flag value, modifying or adding data to the evaluation context, logging, telemetry, and tracking. + +```python +from open_feature.hooks.hook import Hook + +class MyHook(Hook): + def after(self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict): + print("This runs after the flag has been evaluated") + + +# set global hooks at the API-level +from open_feature.hooks import add_api_hooks +add_api_hooks([MyHook()]) + +# or configure them in the client +client = OpenFeatureClient() +client.add_hooks([MyHook()]) +``` See [here](https://openfeature.dev/ecosystem) for a catalog of available hooks. diff --git a/tests/hooks/test_init.py b/tests/hooks/test_init.py new file mode 100644 index 00000000..21a1948c --- /dev/null +++ b/tests/hooks/test_init.py @@ -0,0 +1,18 @@ +from unittest.mock import MagicMock + +from open_feature.hooks.hook import Hook +from open_feature.hooks import add_api_hooks, clear_api_hooks, api_hooks + + +def test_should_add_hooks_to_api_hooks(): + # Given + hook_1 = MagicMock(spec=Hook) + hook_2 = MagicMock(spec=Hook) + clear_api_hooks() + + # When + add_api_hooks([hook_1]) + add_api_hooks([hook_2]) + + # Then + assert api_hooks() == [hook_1, hook_2] diff --git a/tests/test_open_feature_client.py b/tests/test_open_feature_client.py index 38fc0c14..d69c3cd9 100644 --- a/tests/test_open_feature_client.py +++ b/tests/test_open_feature_client.py @@ -5,6 +5,7 @@ from open_feature.exception.error_code import ErrorCode from open_feature.exception.exceptions import OpenFeatureError from open_feature.flag_evaluation.reason import Reason +from open_feature.hooks import clear_api_hooks, add_api_hooks from open_feature.hooks.hook import Hook from open_feature.open_feature_client import OpenFeatureClient from open_feature.provider.no_op_provider import NoOpProvider @@ -144,3 +145,17 @@ def test_should_return_client_metadata_with_name(): # Then assert metadata is not None assert metadata.name == "my-client" + + +def test_should_call_api_level_hooks(no_op_provider_client): + # Given + clear_api_hooks() + api_hook = MagicMock(spec=Hook) + add_api_hooks([api_hook]) + + # When + no_op_provider_client.get_boolean_details(flag_key="Key", default_value=True) + + # Then + api_hook.before.assert_called_once() + api_hook.after.assert_called_once()