diff --git a/.gitignore b/.gitignore index 5abdfe09..6e19874f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ coverage.xml *.pot # Sphinx documentation -docs/_build/ \ No newline at end of file +docs/_build/ + +# Virtual env directories +.venv \ No newline at end of file diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000..9762fcdf --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +line_length=88 diff --git a/open_feature_contrib/providers/flagd/__init__.py b/open_feature_contrib/providers/flagd/__init__.py index e69de29b..0b3333f0 100644 --- a/open_feature_contrib/providers/flagd/__init__.py +++ b/open_feature_contrib/providers/flagd/__init__.py @@ -0,0 +1,3 @@ +from .provider import FlagdProvider + +FlagdProvider diff --git a/open_feature_contrib/providers/flagd/defaults.py b/open_feature_contrib/providers/flagd/defaults.py new file mode 100644 index 00000000..ca14bc1f --- /dev/null +++ b/open_feature_contrib/providers/flagd/defaults.py @@ -0,0 +1,5 @@ +class Defaults: + HOST = "localhost" + PORT = 8013 + SCHEMA = "http" + TIMEOUT = 2 # seconds diff --git a/open_feature_contrib/providers/flagd/evaluation_context_serializer.py b/open_feature_contrib/providers/flagd/evaluation_context_serializer.py new file mode 100644 index 00000000..13e04221 --- /dev/null +++ b/open_feature_contrib/providers/flagd/evaluation_context_serializer.py @@ -0,0 +1,18 @@ +import typing + +from open_feature.evaluation_context.evaluation_context import EvaluationContext + + +class EvaluationContextSerializer: + def to_dict(ctx: typing.Optional[EvaluationContext]): + return ( + { + **ctx.attributes, + **(EvaluationContextSerializer.__extract_key(ctx)), + } + if ctx + else {} + ) + + def __extract_key(ctx: EvaluationContext): + return {"targetingKey": ctx.targeting_key} if ctx.targeting_key is str else {} diff --git a/open_feature_contrib/providers/flagd/flag_type.py b/open_feature_contrib/providers/flagd/flag_type.py new file mode 100644 index 00000000..3956e5c4 --- /dev/null +++ b/open_feature_contrib/providers/flagd/flag_type.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class FlagType(Enum): + BOOLEAN = "BOOLEAN" + STRING = "STRING" + FLOAT = "FLOAT" + INTEGER = "INTEGER" + OBJECT = "OBJECT" diff --git a/open_feature_contrib/providers/flagd/provider.py b/open_feature_contrib/providers/flagd/provider.py new file mode 100644 index 00000000..185ed76b --- /dev/null +++ b/open_feature_contrib/providers/flagd/provider.py @@ -0,0 +1,186 @@ +""" +# This is a Python Provider to interact with flagd +# +# -- Usage -- +# open_feature_api.set_provider(flagd_provider.FlagdProvider()) +# flag_value = open_feature_client.get_string_value( +# key="foo", +# default_value="missingflag" +# ) +# print(f"Flag Value is: {flag_value}") +# OR the more verbose option +# flag = open_feature_client.get_string_details(key="foo", default_value="missingflag") +# print(f"Flag is: {flag.value}") +# OR +# print(f"Flag Details: {vars(flag)}"") +# +# -- Customisation -- +# Follows flagd defaults: 'http' protocol on 'localhost' on port '8013' +# But can be overridden: +# provider = open_feature_api.get_provider() +# provider.initialise(schema="https",endpoint="example.com",port=1234,timeout=10) +""" + +import typing +from numbers import Number + +import requests +from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.exception.error_code import ErrorCode +from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.provider.provider import AbstractProvider + +from .defaults import Defaults +from .evaluation_context_serializer import EvaluationContextSerializer +from .flag_type import FlagType +from .web_api_url_factory import WebApiUrlFactory + + +class FlagdProvider(AbstractProvider): + """Flagd OpenFeature Provider""" + + def __init__( + self, + name: str = "flagd", + schema: str = Defaults.SCHEMA, + host: str = Defaults.HOST, + port: int = Defaults.PORT, + timeout: int = Defaults.TIMEOUT, + ): + """ + Create an instance of the FlagdProvider + + :param name: the name of the provider to be stored in metadata + :param schema: the schema for the transport protocol, e.g. 'http', 'https' + :param host: the host to make requests to + :param port: the port the flagd service is available on + :param timeout: the maximum to wait before a request times out + """ + self.provider_name = name + self.schema = schema + self.host = host + self.port = port + self.timeout = timeout + + self.url_factory = WebApiUrlFactory(self.schema, self.host, self.port) + + def get_metadata(self): + """Returns provider metadata""" + return { + "name": self.get_name(), + "schema": self.schema, + "host": self.host, + "port": self.port, + "timeout": self.timeout, + } + + def get_name(self) -> str: + """Returns provider name""" + return self.provider_name + + def get_boolean_details( + self, + key: str, + default_value: bool, + evaluation_context: EvaluationContext = None, + ): + return self.__resolve(key, FlagType.BOOLEAN, default_value, evaluation_context) + + def get_string_details( + self, + key: str, + default_value: str, + evaluation_context: EvaluationContext = None, + ): + return self.__resolve(key, FlagType.STRING, default_value, evaluation_context) + + def get_float_details( + self, + key: str, + default_value: Number, + evaluation_context: EvaluationContext = None, + ): + return self.__resolve(key, FlagType.FLOAT, default_value, evaluation_context) + + def get_int_details( + self, + key: str, + default_value: Number, + evaluation_context: EvaluationContext = None, + ): + return self.__resolve(key, FlagType.INTEGER, default_value, evaluation_context) + + def get_object_details( + self, + key: str, + default_value: typing.Union[dict, list], + evaluation_context: EvaluationContext = None, + ): + return self.__resolve(key, FlagType.OBJECT, default_value, evaluation_context) + + def __resolve( + self, + flag_key: str, + flag_type: FlagType, + default_value: typing.Any, + evaluation_context: EvaluationContext, + ): + """ + This method is equivalent to: + curl -X POST http://localhost:8013/{path} \ + -H "Content-Type: application/json" \ + -d '{"flagKey": key, "context": evaluation_context}' + """ + + payload = { + "flagKey": flag_key, + "context": EvaluationContextSerializer.to_dict(evaluation_context), + } + + try: + url_endpoint = self.url_factory.get_path_for(flag_type) + + response = requests.post( + url=url_endpoint, timeout=self.timeout, json=payload + ) + + except Exception: + # Perhaps a timeout? Return the default as an error. + # The return above and this are separate because in the case of a timeout, + # the JSON is not available + # So return a stock, generic answer. + + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=ErrorCode.PROVIDER_NOT_READY, + variant=default_value, + ) + + json_content = response.json() + + # If lookup worked (200 response) get flag (or empty) + # This is the "ideal" case. + if response.status_code == 200: + + # Got a valid flag and valid type. Return it. + if "value" in json_content: + # Got a valid flag value for key: {key} of: {json_content['value']} + return FlagEvaluationDetails( + flag_key=flag_key, + value=json_content["value"], + reason=json_content["reason"], + variant=json_content["variant"], + ) + + # Otherwise HTTP call worked + # However, flag either doesn't exist or doesn't match the type + # eg. Expecting a string but this value is a boolean. + # Return whatever we got from the backend. + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=json_content["code"], + variant=default_value, + error_code=json_content["message"], + ) diff --git a/open_feature_contrib/providers/flagd/web_api_url_factory.py b/open_feature_contrib/providers/flagd/web_api_url_factory.py new file mode 100644 index 00000000..8690e056 --- /dev/null +++ b/open_feature_contrib/providers/flagd/web_api_url_factory.py @@ -0,0 +1,50 @@ +from .flag_type import FlagType + + +class WebApiUrlFactory: + BOOLEAN = "schema.v1.Service/ResolveBoolean" + STRING = "schema.v1.Service/ResolveString" + FLOAT = "schema.v1.Service/ResolveFloat" + INTEGER = "schema.v1.Service/ResolveInteger" + OBJECT = "schema.v1.Service/ResolveObject" + + # provides dynamic dictionary-based resolution by flag type + __mapping = { + FlagType.BOOLEAN: "get_boolean_path", + FlagType.STRING: "get_string_path", + FlagType.FLOAT: "get_float_path", + FlagType.INTEGER: "get_integer_path", + FlagType.OBJECT: "get_object_path", + } + __default_mapping_key = "_invalid_flag_type_method" + + def __init__(self, schema, host, port): + self.root = f"{schema}://{host}:{port}" + + def get_boolean_path(self): + return self._format_url(self.BOOLEAN) + + def get_string_path(self): + return self._format_url(self.STRING) + + def get_float_path(self): + return self._format_url(self.FLOAT) + + def get_integer_path(self): + return self._format_url(self.INTEGER) + + def get_object_path(self): + return self._format_url(self.OBJECT) + + def get_path_for(self, flag_type: FlagType): + return self[ + WebApiUrlFactory.__mapping.get( + flag_type, WebApiUrlFactory.__default_mapping_key + ) + ]() + + def _format_url(self, path: str): + return f"{self.root}/{path}" + + def _invalid_flag_type_method(self): + raise Exception("Invalid flag type passed to factory") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a4c22c28 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +# pyproject.toml +[build-system] +requires = ["setuptools>=61.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openfeature_sdk_contrib" +version = "0.1.0" +description = "Contributions around the Python OpenFeature SDK, such as providers and hooks" +readme = "readme.md" +authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +keywords = [] +dependencies = [] +requires-python = ">=3.8" + +[project.optional-dependencies] +dev = ["black", "flake8", "isort", "pip-tools", "pytest", "pre-commit"] + +[project.urls] +Homepage = "https://github.com/open-feature/python-sdk-contrib" + +[tool.isort] +profile = "black" +multi_line_output = 3 diff --git a/requirements-dev.in b/requirements-dev.in index 3b2327e2..fd83b247 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -7,3 +7,4 @@ pre-commit flake8 pytest-mock coverage +openfeature-sdk diff --git a/requirements-dev.txt b/requirements-dev.txt index 4f5af3c9..93e3d58d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -42,6 +42,8 @@ mypy-extensions==0.4.3 # via black nodeenv==1.6.0 # via pre-commit +openfeature-sdk==0.0.9 + # via -r requirements-dev.in packaging==21.3 # via pytest pathspec==0.9.0 diff --git a/requirements.in b/requirements.in index e69de29b..b10cee8b 100644 --- a/requirements.in +++ b/requirements.in @@ -0,0 +1 @@ +openfeature-sdk \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29b..43276cb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,8 @@ +# +# This file is autogenerated by pip-compile with python 3.10 +# To update, run: +# +# pip-compile --output-file=./requirements.txt ./requirements.in +# +openfeature-sdk==0.0.9 + # via -r ./requirements.in diff --git a/tests/providers/__init__.py b/tests/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/providers/conftest.py b/tests/providers/conftest.py new file mode 100644 index 00000000..bb616429 --- /dev/null +++ b/tests/providers/conftest.py @@ -0,0 +1,10 @@ +import pytest +from open_feature import open_feature_api as api + +from open_feature_contrib.providers.flagd import FlagdProvider + + +@pytest.fixture() +def flagd_provider_client(): + api.set_provider(FlagdProvider()) + return api.get_client() diff --git a/tests/providers/test_flagd.py b/tests/providers/test_flagd.py new file mode 100644 index 00000000..1edc638d --- /dev/null +++ b/tests/providers/test_flagd.py @@ -0,0 +1,81 @@ +from numbers import Number + +from open_feature import open_feature_api as api + +from open_feature_contrib.providers.flagd import FlagdProvider + + +def setup(): + api.set_provider(FlagdProvider()) + provider = api.get_provider() + assert isinstance(provider, FlagdProvider) + + +def test_should_get_boolean_flag_from_flagd(flagd_provider_client): + # Given + client = flagd_provider_client + + # When + flag = client.get_boolean_details(flag_key="Key", default_value=True) + + # Then + assert flag is not None + assert flag.value + assert isinstance(flag.value, bool) + + +def test_should_get_integer_flag_from_flagd(flagd_provider_client): + # Given + client = flagd_provider_client + + # When + flag = client.get_integer_details(flag_key="Key", default_value=100) + + # Then + assert flag is not None + assert flag.value == 100 + assert isinstance(flag.value, Number) + + +def test_should_get_float_flag_from_flagd(flagd_provider_client): + # Given + client = flagd_provider_client + + # When + flag = client.get_float_details(flag_key="Key", default_value=100) + + # Then + assert flag is not None + assert flag.value == 100 + assert isinstance(flag.value, Number) + + +def test_should_get_string_flag_from_flagd(flagd_provider_client): + # Given + client = flagd_provider_client + + # When + flag = client.get_string_details(flag_key="Key", default_value="String") + + # Then + assert flag is not None + assert flag.value == "String" + assert isinstance(flag.value, str) + + +def test_should_get_object_flag_from_flagd(flagd_provider_client): + # Given + client = flagd_provider_client + return_value = { + "String": "string", + "Number": 2, + "Boolean": True, + } + + # When + flag = client.get_string_details(flag_key="Key", default_value=return_value) + + # Then + assert flag is not None + assert flag.value == return_value + assert isinstance(flag.value, dict) diff --git a/tests/test_hooks.py b/tests/test_hooks.py deleted file mode 100644 index c20e191d..00000000 --- a/tests/test_hooks.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): # remove this once we have real tests - pass