From a9c96f24081c253e98f0bdf58684240de4520e21 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Mon, 6 May 2024 13:32:46 +1000 Subject: [PATCH] feat: implement basic functionality of OFREP provider Signed-off-by: Federico Bond --- mypy.ini | 3 + .../openfeature-provider-ofrep/pyproject.toml | 3 + .../contrib/provider/ofrep/__init__.py | 122 ++++++++++++++++-- .../tests/conftest.py | 8 ++ .../tests/test_provider.py | 103 +++++++++++++++ 5 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 providers/openfeature-provider-ofrep/tests/conftest.py create mode 100644 providers/openfeature-provider-ofrep/tests/test_provider.py diff --git a/mypy.ini b/mypy.ini index 4c24b416..0e0fc449 100644 --- a/mypy.ini +++ b/mypy.ini @@ -15,3 +15,6 @@ follow_imports = silent [mypy-grpc] ignore_missing_imports = True + +[mypy-requests.*] +ignore_missing_imports = True diff --git a/providers/openfeature-provider-ofrep/pyproject.toml b/providers/openfeature-provider-ofrep/pyproject.toml index 42418256..693ec7bf 100644 --- a/providers/openfeature-provider-ofrep/pyproject.toml +++ b/providers/openfeature-provider-ofrep/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ keywords = [] dependencies = [ "openfeature-sdk>=0.7.0", + "requests" ] requires-python = ">=3.8" @@ -30,6 +31,8 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib" dependencies = [ "coverage[toml]>=6.5", "pytest", + "requests-mock", + "types-requests", ] [tool.hatch.envs.default.scripts] diff --git a/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py b/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py index 5201dd5d..58c9e7e8 100644 --- a/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py +++ b/providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py @@ -1,49 +1,147 @@ -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urljoin + +import requests +from requests.exceptions import JSONDecodeError from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagResolutionDetails +from openfeature.exception import ( + ErrorCode, + FlagNotFoundError, + GeneralError, + InvalidContextError, + OpenFeatureError, + ParseError, + TargetingKeyMissingError, +) +from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata +__all__ = ["OFREPProvider"] + class OFREPProvider(AbstractProvider): + def __init__( + self, + base_url: str, + *, + headers: Optional[Dict[str, str]] = None, + timeout: float = 5.0, + ): + self.base_url = base_url + self.headers = headers + self.timeout = timeout + self.session = requests.Session() + if headers: + self.session.headers.update(headers) + def get_metadata(self) -> Metadata: return Metadata(name="OpenFeature Remote Evaluation Protocol Provider") def get_provider_hooks(self) -> List[Hook]: return [] - def resolve_boolean_details( # type: ignore[empty-body] + def resolve_boolean_details( self, flag_key: str, default_value: bool, evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[bool]: ... + ) -> FlagResolutionDetails[bool]: + return self._resolve(flag_key, default_value, evaluation_context) - def resolve_string_details( # type: ignore[empty-body] + def resolve_string_details( self, flag_key: str, default_value: str, evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[str]: ... + ) -> FlagResolutionDetails[str]: + return self._resolve(flag_key, default_value, evaluation_context) - def resolve_integer_details( # type: ignore[empty-body] + def resolve_integer_details( self, flag_key: str, default_value: int, evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[int]: ... + ) -> FlagResolutionDetails[int]: + return self._resolve(flag_key, default_value, evaluation_context) - def resolve_float_details( # type: ignore[empty-body] + def resolve_float_details( self, flag_key: str, default_value: float, evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[float]: ... + ) -> FlagResolutionDetails[float]: + return self._resolve(flag_key, default_value, evaluation_context) - def resolve_object_details( # type: ignore[empty-body] + def resolve_object_details( self, flag_key: str, default_value: Union[dict, list], evaluation_context: Optional[EvaluationContext] = None, - ) -> FlagResolutionDetails[Union[dict, list]]: ... + ) -> FlagResolutionDetails[Union[dict, list]]: + return self._resolve(flag_key, default_value, evaluation_context) + + def _resolve( + self, + flag_key: str, + default_value: Union[bool, str, int, float, dict, list], + evaluation_context: Optional[EvaluationContext] = None, + ) -> FlagResolutionDetails[Any]: + try: + response = self.session.post( + urljoin(self.base_url, f"/ofrep/v1/evaluate/flags/{flag_key}"), + json=_build_request_data(evaluation_context), + timeout=self.timeout, + ) + response.raise_for_status() + + except requests.RequestException as e: + if e.response is None: + raise GeneralError(str(e)) from e + + try: + data = e.response.json() + except JSONDecodeError: + raise ParseError(str(e)) from e + + if e.response.status_code == 404: + raise FlagNotFoundError(data["errorDetails"]) from e + + error_code = ErrorCode(data["errorCode"]) + error_details = data["errorDetails"] + + if error_code == ErrorCode.PARSE_ERROR: + raise ParseError(error_details) from e + if error_code == ErrorCode.TARGETING_KEY_MISSING: + raise TargetingKeyMissingError(error_details) from e + if error_code == ErrorCode.INVALID_CONTEXT: + raise InvalidContextError(error_details) from e + if error_code == ErrorCode.GENERAL: + raise GeneralError(error_details) from e + + raise OpenFeatureError(error_code, error_details) from e + + try: + data = response.json() + except JSONDecodeError as e: + raise ParseError(str(e)) from e + + return FlagResolutionDetails( + value=data["value"], + reason=Reason[data["reason"]], + variant=data["variant"], + flag_metadata=data["metadata"], + ) + + +def _build_request_data( + evaluation_context: Optional[EvaluationContext], +) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if evaluation_context: + data["context"] = {} + if evaluation_context.targeting_key: + data["context"]["targetingKey"] = evaluation_context.targeting_key + data["context"].update(evaluation_context.attributes) + return data diff --git a/providers/openfeature-provider-ofrep/tests/conftest.py b/providers/openfeature-provider-ofrep/tests/conftest.py new file mode 100644 index 00000000..33dae884 --- /dev/null +++ b/providers/openfeature-provider-ofrep/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from openfeature.contrib.provider.ofrep import OFREPProvider + + +@pytest.fixture +def ofrep_provider(): + return OFREPProvider("http://localhost:8080") diff --git a/providers/openfeature-provider-ofrep/tests/test_provider.py b/providers/openfeature-provider-ofrep/tests/test_provider.py new file mode 100644 index 00000000..9c2cccae --- /dev/null +++ b/providers/openfeature-provider-ofrep/tests/test_provider.py @@ -0,0 +1,103 @@ +import pytest + +from openfeature.contrib.provider.ofrep import OFREPProvider +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ( + FlagNotFoundError, + InvalidContextError, + ParseError, +) +from openfeature.flag_evaluation import FlagResolutionDetails, Reason + + +def test_provider_init(): + OFREPProvider("http://localhost:8080", headers={"Authorization": "Bearer token"}) + + +def test_provider_successful_resolution(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + json={ + "key": "flag_key", + "reason": "TARGETING_MATCH", + "variant": "true", + "metadata": {"foo": "bar"}, + "value": True, + }, + ) + + resolution = ofrep_provider.resolve_boolean_details("flag_key", False) + + assert resolution == FlagResolutionDetails( + value=True, + reason=Reason.TARGETING_MATCH, + variant="true", + flag_metadata={"foo": "bar"}, + ) + + +def test_provider_flag_not_found(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=404, + json={ + "key": "flag_key", + "errorCode": "FLAG_NOT_FOUND", + "errorDetails": "Flag 'flag_key' not found", + }, + ) + + with pytest.raises(FlagNotFoundError): + ofrep_provider.resolve_boolean_details("flag_key", False) + + +def test_provider_invalid_context(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + status_code=400, + json={ + "key": "flag_key", + "errorCode": "INVALID_CONTEXT", + "errorDetails": "Invalid context provided", + }, + ) + + with pytest.raises(InvalidContextError): + ofrep_provider.resolve_boolean_details("flag_key", False) + + +def test_provider_invalid_response(ofrep_provider, requests_mock): + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", text="invalid" + ) + + with pytest.raises(ParseError): + ofrep_provider.resolve_boolean_details("flag_key", False) + + +def test_provider_evaluation_context(ofrep_provider, requests_mock): + def match_request_json(request): + return request.json() == {"context": {"targetingKey": "1", "foo": "bar"}} + + requests_mock.post( + "http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", + json={ + "key": "flag_key", + "reason": "TARGETING_MATCH", + "variant": "true", + "metadata": {}, + "value": True, + }, + additional_matcher=match_request_json + ) + + context = EvaluationContext("1", {"foo": "bar"}) + resolution = ofrep_provider.resolve_boolean_details( + "flag_key", False, evaluation_context=context + ) + + assert resolution == FlagResolutionDetails( + value=True, + reason=Reason.TARGETING_MATCH, + variant="true", + )