Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add flagd provider #3

Merged
merged 16 commits into from
Jul 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,7 @@ coverage.xml
*.pot

# Sphinx documentation
docs/_build/
docs/_build/

# Virtual env directories
.venv
2 changes: 2 additions & 0 deletions .isort.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[settings]
line_length=88
3 changes: 3 additions & 0 deletions open_feature_contrib/providers/flagd/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .provider import FlagdProvider

FlagdProvider
5 changes: 5 additions & 0 deletions open_feature_contrib/providers/flagd/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Defaults:
HOST = "localhost"
PORT = 8013
SCHEMA = "http"
TIMEOUT = 2 # seconds
Original file line number Diff line number Diff line change
@@ -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 {}
9 changes: 9 additions & 0 deletions open_feature_contrib/providers/flagd/flag_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from enum import Enum


class FlagType(Enum):
BOOLEAN = "BOOLEAN"
STRING = "STRING"
FLOAT = "FLOAT"
INTEGER = "INTEGER"
OBJECT = "OBJECT"
186 changes: 186 additions & 0 deletions open_feature_contrib/providers/flagd/provider.py
Original file line number Diff line number Diff line change
@@ -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"],
)
50 changes: 50 additions & 0 deletions open_feature_contrib/providers/flagd/web_api_url_factory.py
Original file line number Diff line number Diff line change
@@ -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")
30 changes: 30 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]" }]
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
1 change: 1 addition & 0 deletions requirements-dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ pre-commit
flake8
pytest-mock
coverage
openfeature-sdk
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
openfeature-sdk
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Empty file added tests/providers/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions tests/providers/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
Loading