Skip to content

Commit

Permalink
feat: implement basic functionality of OFREP provider
Browse files Browse the repository at this point in the history
Signed-off-by: Federico Bond <federicobond@gmail.com>
  • Loading branch information
federicobond committed May 7, 2024
1 parent 00a5a18 commit a9c96f2
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 12 deletions.
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ follow_imports = silent

[mypy-grpc]
ignore_missing_imports = True

[mypy-requests.*]
ignore_missing_imports = True
3 changes: 3 additions & 0 deletions providers/openfeature-provider-ofrep/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ classifiers = [
keywords = []
dependencies = [
"openfeature-sdk>=0.7.0",
"requests"
]
requires-python = ">=3.8"

Expand All @@ -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]
Expand Down
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
8 changes: 8 additions & 0 deletions providers/openfeature-provider-ofrep/tests/conftest.py
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 providers/openfeature-provider-ofrep/tests/test_provider.py
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",
)

0 comments on commit a9c96f2

Please sign in to comment.