Skip to content

Commit a9c96f2

Browse files
committed
feat: implement basic functionality of OFREP provider
Signed-off-by: Federico Bond <[email protected]>
1 parent 00a5a18 commit a9c96f2

File tree

5 files changed

+227
-12
lines changed

5 files changed

+227
-12
lines changed

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ follow_imports = silent
1515

1616
[mypy-grpc]
1717
ignore_missing_imports = True
18+
19+
[mypy-requests.*]
20+
ignore_missing_imports = True

providers/openfeature-provider-ofrep/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ classifiers = [
1818
keywords = []
1919
dependencies = [
2020
"openfeature-sdk>=0.7.0",
21+
"requests"
2122
]
2223
requires-python = ">=3.8"
2324

@@ -30,6 +31,8 @@ Homepage = "https://github.com/open-feature/python-sdk-contrib"
3031
dependencies = [
3132
"coverage[toml]>=6.5",
3233
"pytest",
34+
"requests-mock",
35+
"types-requests",
3336
]
3437

3538
[tool.hatch.envs.default.scripts]
Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,147 @@
1-
from typing import List, Optional, Union
1+
from typing import Any, Dict, List, Optional, Union
2+
from urllib.parse import urljoin
3+
4+
import requests
5+
from requests.exceptions import JSONDecodeError
26

37
from openfeature.evaluation_context import EvaluationContext
4-
from openfeature.flag_evaluation import FlagResolutionDetails
8+
from openfeature.exception import (
9+
ErrorCode,
10+
FlagNotFoundError,
11+
GeneralError,
12+
InvalidContextError,
13+
OpenFeatureError,
14+
ParseError,
15+
TargetingKeyMissingError,
16+
)
17+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
518
from openfeature.hook import Hook
619
from openfeature.provider import AbstractProvider, Metadata
720

21+
__all__ = ["OFREPProvider"]
22+
823

924
class OFREPProvider(AbstractProvider):
25+
def __init__(
26+
self,
27+
base_url: str,
28+
*,
29+
headers: Optional[Dict[str, str]] = None,
30+
timeout: float = 5.0,
31+
):
32+
self.base_url = base_url
33+
self.headers = headers
34+
self.timeout = timeout
35+
self.session = requests.Session()
36+
if headers:
37+
self.session.headers.update(headers)
38+
1039
def get_metadata(self) -> Metadata:
1140
return Metadata(name="OpenFeature Remote Evaluation Protocol Provider")
1241

1342
def get_provider_hooks(self) -> List[Hook]:
1443
return []
1544

16-
def resolve_boolean_details( # type: ignore[empty-body]
45+
def resolve_boolean_details(
1746
self,
1847
flag_key: str,
1948
default_value: bool,
2049
evaluation_context: Optional[EvaluationContext] = None,
21-
) -> FlagResolutionDetails[bool]: ...
50+
) -> FlagResolutionDetails[bool]:
51+
return self._resolve(flag_key, default_value, evaluation_context)
2252

23-
def resolve_string_details( # type: ignore[empty-body]
53+
def resolve_string_details(
2454
self,
2555
flag_key: str,
2656
default_value: str,
2757
evaluation_context: Optional[EvaluationContext] = None,
28-
) -> FlagResolutionDetails[str]: ...
58+
) -> FlagResolutionDetails[str]:
59+
return self._resolve(flag_key, default_value, evaluation_context)
2960

30-
def resolve_integer_details( # type: ignore[empty-body]
61+
def resolve_integer_details(
3162
self,
3263
flag_key: str,
3364
default_value: int,
3465
evaluation_context: Optional[EvaluationContext] = None,
35-
) -> FlagResolutionDetails[int]: ...
66+
) -> FlagResolutionDetails[int]:
67+
return self._resolve(flag_key, default_value, evaluation_context)
3668

37-
def resolve_float_details( # type: ignore[empty-body]
69+
def resolve_float_details(
3870
self,
3971
flag_key: str,
4072
default_value: float,
4173
evaluation_context: Optional[EvaluationContext] = None,
42-
) -> FlagResolutionDetails[float]: ...
74+
) -> FlagResolutionDetails[float]:
75+
return self._resolve(flag_key, default_value, evaluation_context)
4376

44-
def resolve_object_details( # type: ignore[empty-body]
77+
def resolve_object_details(
4578
self,
4679
flag_key: str,
4780
default_value: Union[dict, list],
4881
evaluation_context: Optional[EvaluationContext] = None,
49-
) -> FlagResolutionDetails[Union[dict, list]]: ...
82+
) -> FlagResolutionDetails[Union[dict, list]]:
83+
return self._resolve(flag_key, default_value, evaluation_context)
84+
85+
def _resolve(
86+
self,
87+
flag_key: str,
88+
default_value: Union[bool, str, int, float, dict, list],
89+
evaluation_context: Optional[EvaluationContext] = None,
90+
) -> FlagResolutionDetails[Any]:
91+
try:
92+
response = self.session.post(
93+
urljoin(self.base_url, f"/ofrep/v1/evaluate/flags/{flag_key}"),
94+
json=_build_request_data(evaluation_context),
95+
timeout=self.timeout,
96+
)
97+
response.raise_for_status()
98+
99+
except requests.RequestException as e:
100+
if e.response is None:
101+
raise GeneralError(str(e)) from e
102+
103+
try:
104+
data = e.response.json()
105+
except JSONDecodeError:
106+
raise ParseError(str(e)) from e
107+
108+
if e.response.status_code == 404:
109+
raise FlagNotFoundError(data["errorDetails"]) from e
110+
111+
error_code = ErrorCode(data["errorCode"])
112+
error_details = data["errorDetails"]
113+
114+
if error_code == ErrorCode.PARSE_ERROR:
115+
raise ParseError(error_details) from e
116+
if error_code == ErrorCode.TARGETING_KEY_MISSING:
117+
raise TargetingKeyMissingError(error_details) from e
118+
if error_code == ErrorCode.INVALID_CONTEXT:
119+
raise InvalidContextError(error_details) from e
120+
if error_code == ErrorCode.GENERAL:
121+
raise GeneralError(error_details) from e
122+
123+
raise OpenFeatureError(error_code, error_details) from e
124+
125+
try:
126+
data = response.json()
127+
except JSONDecodeError as e:
128+
raise ParseError(str(e)) from e
129+
130+
return FlagResolutionDetails(
131+
value=data["value"],
132+
reason=Reason[data["reason"]],
133+
variant=data["variant"],
134+
flag_metadata=data["metadata"],
135+
)
136+
137+
138+
def _build_request_data(
139+
evaluation_context: Optional[EvaluationContext],
140+
) -> Dict[str, Any]:
141+
data: Dict[str, Any] = {}
142+
if evaluation_context:
143+
data["context"] = {}
144+
if evaluation_context.targeting_key:
145+
data["context"]["targetingKey"] = evaluation_context.targeting_key
146+
data["context"].update(evaluation_context.attributes)
147+
return data
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import pytest
2+
3+
from openfeature.contrib.provider.ofrep import OFREPProvider
4+
5+
6+
@pytest.fixture
7+
def ofrep_provider():
8+
return OFREPProvider("http://localhost:8080")
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import pytest
2+
3+
from openfeature.contrib.provider.ofrep import OFREPProvider
4+
from openfeature.evaluation_context import EvaluationContext
5+
from openfeature.exception import (
6+
FlagNotFoundError,
7+
InvalidContextError,
8+
ParseError,
9+
)
10+
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
11+
12+
13+
def test_provider_init():
14+
OFREPProvider("http://localhost:8080", headers={"Authorization": "Bearer token"})
15+
16+
17+
def test_provider_successful_resolution(ofrep_provider, requests_mock):
18+
requests_mock.post(
19+
"http://localhost:8080/ofrep/v1/evaluate/flags/flag_key",
20+
json={
21+
"key": "flag_key",
22+
"reason": "TARGETING_MATCH",
23+
"variant": "true",
24+
"metadata": {"foo": "bar"},
25+
"value": True,
26+
},
27+
)
28+
29+
resolution = ofrep_provider.resolve_boolean_details("flag_key", False)
30+
31+
assert resolution == FlagResolutionDetails(
32+
value=True,
33+
reason=Reason.TARGETING_MATCH,
34+
variant="true",
35+
flag_metadata={"foo": "bar"},
36+
)
37+
38+
39+
def test_provider_flag_not_found(ofrep_provider, requests_mock):
40+
requests_mock.post(
41+
"http://localhost:8080/ofrep/v1/evaluate/flags/flag_key",
42+
status_code=404,
43+
json={
44+
"key": "flag_key",
45+
"errorCode": "FLAG_NOT_FOUND",
46+
"errorDetails": "Flag 'flag_key' not found",
47+
},
48+
)
49+
50+
with pytest.raises(FlagNotFoundError):
51+
ofrep_provider.resolve_boolean_details("flag_key", False)
52+
53+
54+
def test_provider_invalid_context(ofrep_provider, requests_mock):
55+
requests_mock.post(
56+
"http://localhost:8080/ofrep/v1/evaluate/flags/flag_key",
57+
status_code=400,
58+
json={
59+
"key": "flag_key",
60+
"errorCode": "INVALID_CONTEXT",
61+
"errorDetails": "Invalid context provided",
62+
},
63+
)
64+
65+
with pytest.raises(InvalidContextError):
66+
ofrep_provider.resolve_boolean_details("flag_key", False)
67+
68+
69+
def test_provider_invalid_response(ofrep_provider, requests_mock):
70+
requests_mock.post(
71+
"http://localhost:8080/ofrep/v1/evaluate/flags/flag_key", text="invalid"
72+
)
73+
74+
with pytest.raises(ParseError):
75+
ofrep_provider.resolve_boolean_details("flag_key", False)
76+
77+
78+
def test_provider_evaluation_context(ofrep_provider, requests_mock):
79+
def match_request_json(request):
80+
return request.json() == {"context": {"targetingKey": "1", "foo": "bar"}}
81+
82+
requests_mock.post(
83+
"http://localhost:8080/ofrep/v1/evaluate/flags/flag_key",
84+
json={
85+
"key": "flag_key",
86+
"reason": "TARGETING_MATCH",
87+
"variant": "true",
88+
"metadata": {},
89+
"value": True,
90+
},
91+
additional_matcher=match_request_json
92+
)
93+
94+
context = EvaluationContext("1", {"foo": "bar"})
95+
resolution = ofrep_provider.resolve_boolean_details(
96+
"flag_key", False, evaluation_context=context
97+
)
98+
99+
assert resolution == FlagResolutionDetails(
100+
value=True,
101+
reason=Reason.TARGETING_MATCH,
102+
variant="true",
103+
)

0 commit comments

Comments
 (0)