-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement basic functionality of OFREP provider
Signed-off-by: Federico Bond <federicobond@gmail.com>
- Loading branch information
1 parent
00a5a18
commit a9c96f2
Showing
5 changed files
with
227 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 110 additions & 12 deletions
122
providers/openfeature-provider-ofrep/src/openfeature/contrib/provider/ofrep/__init__.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import pytest | ||
|
||
from openfeature.contrib.provider.ofrep import OFREPProvider | ||
|
||
|
||
@pytest.fixture | ||
def ofrep_provider(): | ||
return OFREPProvider("http://localhost:8080") |
103 changes: 103 additions & 0 deletions
103
providers/openfeature-provider-ofrep/tests/test_provider.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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", | ||
) |