Skip to content

feat: add support for flagd flag metadata #215

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

Merged
merged 34 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0521bd0
add support for metadata in flagd
chrfwow Mar 18, 2025
a76bcf6
reformatting
chrfwow Mar 18, 2025
eab778b
fix type errors
chrfwow Mar 18, 2025
390144d
fix type errors and fmt
chrfwow Mar 18, 2025
7bf74e1
fix type errors and fmt
chrfwow Mar 18, 2025
ed1f91f
fix format
chrfwow Mar 18, 2025
a6d8715
fix format, add tests
chrfwow Mar 18, 2025
3cc2048
fix format
chrfwow Mar 18, 2025
c9e5ed7
fix format
chrfwow Mar 18, 2025
94967fb
switch to new version of flagd testbed
chrfwow Mar 18, 2025
11666ff
switch to new version of flagd testbed v2
chrfwow Mar 18, 2025
69da51c
fix zero value errors
chrfwow Mar 18, 2025
7487c29
Merge branch 'main' into flag-metadata
chrfwow Mar 18, 2025
d9c1c4f
Merge branch 'main' into flag-metadata
chrfwow Mar 19, 2025
3eff272
switch to new version of flagd testbed v3
chrfwow Mar 19, 2025
850ce81
switch to new version of flagd testbed v3
chrfwow Mar 19, 2025
f2b37b5
switch to new version of flagd testbed v4
chrfwow Mar 19, 2025
503d3e5
switch to new version of flagd testbed v5
chrfwow Mar 19, 2025
257a421
switch to new version of flagd testbed v6
chrfwow Mar 19, 2025
761f650
minor improvements, adjust to workaround for fladg issue
chrfwow Mar 19, 2025
494fb74
update test harness v10000
chrfwow Mar 19, 2025
2a991b9
fix format
chrfwow Mar 19, 2025
0c4f28f
attempt to fix tests
chrfwow Mar 20, 2025
ed60818
fix format
chrfwow Mar 20, 2025
b2ad90c
fix failing tests, upgrade test harness
chrfwow Mar 21, 2025
8822464
fix format
chrfwow Mar 21, 2025
519c3a5
fix format
chrfwow Mar 21, 2025
5c9fe24
Merge branch 'main' into flag-metadata
chrfwow Mar 24, 2025
e054a4f
Update providers/openfeature-provider-flagd/src/openfeature/contrib/p…
chrfwow Mar 25, 2025
21e52b7
Update providers/openfeature-provider-flagd/src/openfeature/contrib/p…
chrfwow Mar 25, 2025
f5a376b
Update providers/openfeature-provider-flagd/src/openfeature/contrib/p…
chrfwow Mar 25, 2025
aaff161
fix string format
chrfwow Mar 25, 2025
85b70f6
fix format
chrfwow Mar 25, 2025
d99ad2f
Merge branch 'main' into flag-metadata
gruebel Mar 25, 2025
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
8 changes: 4 additions & 4 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
path = providers/openfeature-provider-flagd/openfeature/schemas
url = https://github.com/open-feature/schemas
branch = protobuf-v0.6.1
[submodule "providers/openfeature-provider-flagd/test-harness"]
path = providers/openfeature-provider-flagd/openfeature/test-harness
url = git@github.com:open-feature/flagd-testbed.git
branch = v2.5.0
[submodule "providers/openfeature-provider-flagd/spec"]
path = providers/openfeature-provider-flagd/openfeature/spec
url = https://github.com/open-feature/spec
[submodule "providers/openfeature-provider-flagd/openfeature/test-harness"]
path = providers/openfeature-provider-flagd/openfeature/test-harness
url = https://github.com/open-feature/flagd-testbed.git
branch = v2.7.0
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@
T = typing.TypeVar("T")


def _merge_metadata(
flag_metadata: typing.Optional[
typing.Mapping[str, typing.Union[float, int, str, bool]]
],
flag_set_metadata: typing.Optional[
typing.Mapping[str, typing.Union[float, int, str, bool]]
],
) -> typing.Mapping[str, typing.Union[float, int, str, bool]]:
metadata = {} if flag_set_metadata is None else dict(flag_set_metadata)

if flag_metadata is not None:
for key, value in flag_metadata.items():
metadata[key] = value

return metadata


class InProcessResolver:
def __init__(
self,
Expand Down Expand Up @@ -103,18 +120,26 @@ def _resolve(
if not flag:
raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")

metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata)

if flag.state == "DISABLED":
return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
return FlagResolutionDetails(
default_value, flag_metadata=metadata, reason=Reason.DISABLED
)

if not flag.targeting:
variant, value = flag.default
return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
return FlagResolutionDetails(
value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC
)

variant = targeting(flag.key, flag.targeting, evaluation_context)

if variant is None:
variant, value = flag.default
return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
return FlagResolutionDetails(
value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT
)
if not isinstance(variant, (str, bool)):
raise ParseError(
"Parsed JSONLogic targeting did not return a string or bool"
Expand All @@ -128,4 +153,5 @@ def _resolve(
value,
variant=variant,
reason=Reason.TARGETING_MATCH,
flag_metadata=metadata,
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from openfeature.contrib.provider.flagd.resolvers.process.flags import FlagStore
from openfeature.evaluation_context import EvaluationContext
from openfeature.event import ProviderEventDetails
from openfeature.exception import ParseError, ProviderNotReadyError
from openfeature.exception import ErrorCode, ParseError, ProviderNotReadyError

logger = logging.getLogger("openfeature.contrib")

Expand Down Expand Up @@ -76,8 +76,15 @@ def safe_load_data(self) -> None:
self.handle_error("Could not parse JSON flag data from file")
except yaml.error.YAMLError:
self.handle_error("Could not parse YAML flag data from file")
except ParseError:
self.handle_error("Could not parse flag data using flagd syntax")
except ParseError as e:
self.handle_error(
"Could not parse flag data using flagd syntax: "
+ (
"no error message provided"
if e is None or e.error_message is None
else e.error_message
)
)
except Exception:
self.handle_error("Could not read flags from file")

Expand All @@ -104,4 +111,8 @@ def _load_data(self, modified_time: typing.Optional[float] = None) -> None:
def handle_error(self, error_message: str) -> None:
logger.exception(error_message)
self.should_emit_ready_on_success = True
self.emit_provider_error(ProviderEventDetails(message=error_message))
self.emit_provider_error(
ProviderEventDetails(
message=error_message, error_code=ErrorCode.PARSE_ERROR
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@
from openfeature.exception import ParseError


def _validate_metadata(key: str, value: typing.Union[float, int, str, bool]) -> None:
if key is None:
raise ParseError("Metadata key must be set")
elif not isinstance(key, str):
raise ParseError(f"Metadata key {key} must be of type str, but is {type(key)}")
elif not key:
raise ParseError("key must not be empty")
if value is None:
raise ParseError(f"Metadata value for key {key} must be set")
elif not isinstance(value, (float, int, str, bool)):
raise ParseError(
f"Metadata value {value} for key {key} must be of type float, int, str or bool, but is {type(value)}"
)


class FlagStore:
def __init__(
self,
Expand All @@ -16,12 +31,16 @@ def __init__(
):
self.emit_provider_configuration_changed = emit_provider_configuration_changed
self.flags: typing.Mapping[str, Flag] = {}
self.flag_set_metadata: typing.Mapping[
str, typing.Union[float, int, str, bool]
] = {}

def get_flag(self, key: str) -> typing.Optional["Flag"]:
return self.flags.get(key)

def update(self, flags_data: dict) -> None:
flags = flags_data.get("flags", {})
metadata = flags_data.get("metadata", {})
evaluators: typing.Optional[dict] = flags_data.get("$evaluators")
if evaluators:
transposed = json.dumps(flags)
Expand All @@ -33,10 +52,18 @@ def update(self, flags_data: dict) -> None:

if not isinstance(flags, dict):
raise ParseError("`flags` key of configuration must be a dictionary")
if not isinstance(metadata, dict):
raise ParseError("`metadata` key of configuration must be a dictionary")
for key, value in metadata.items():
_validate_metadata(key, value)

self.flags = {key: Flag.from_dict(key, data) for key, data in flags.items()}
self.flag_set_metadata = metadata

self.emit_provider_configuration_changed(
ProviderEventDetails(flags_changed=list(self.flags.keys()))
ProviderEventDetails(
flags_changed=list(self.flags.keys()), metadata=metadata
)
)


Expand All @@ -47,6 +74,9 @@ class Flag:
variants: typing.Mapping[str, typing.Any]
default_variant: typing.Union[bool, str]
targeting: typing.Optional[dict] = None
metadata: typing.Optional[
typing.Mapping[str, typing.Union[float, int, str, bool]]
] = None

def __post_init__(self) -> None:
if not self.state or not isinstance(self.state, str):
Expand All @@ -66,6 +96,12 @@ def __post_init__(self) -> None:
if self.default_variant not in self.variants:
raise ParseError("Default variant does not match set of variants")

if self.metadata:
if not isinstance(self.metadata, dict):
raise ParseError("Flag metadata is not a valid json object")
for key, value in self.metadata.items():
_validate_metadata(key, value)

@classmethod
def from_dict(cls, key: str, data: dict) -> "Flag":
if "defaultVariant" in data:
Expand All @@ -77,6 +113,8 @@ def from_dict(cls, key: str, data: dict) -> "Flag":
try:
flag = cls(key=key, **data)
return flag
except ParseError as parseError:
raise parseError
except Exception as err:
raise ParseError from err

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from tests.e2e.testfilter import TestFilter

resolver = ResolverType.RPC
feature_list = ["~targetURI", "~unixsocket", "~sync"]
feature_list = ["~targetURI", "~unixsocket", "~sync", "~metadata"]


def pytest_collection_modifyitems(config, items):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def handler(event):


def assert_handlers(handles, event_type: str, max_wait: int = 2):
poll_interval = 1
poll_interval = 0.2
while max_wait > 0:
found = any(h["type"] == event_type for h in handles)
if not found:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,16 @@ def resolve_details_reason(
reason: str,
):
assert_equal(details.reason, Reason(reason))


@then(parsers.cfparse("the resolved metadata should contain"))
def metadata_contains(details: FlagEvaluationDetails[JsonPrimitive], datatable):
assert_equal(len(details.flag_metadata), len(datatable) - 1) # skip table header
for i in range(1, len(datatable)):
key, metadata_type, expected = datatable[i]
assert_equal(details.flag_metadata[key], type_cast[metadata_type](expected))


@then("the resolved metadata is empty")
def empty_metadata(details: FlagEvaluationDetails[JsonPrimitive]):
assert_equal(len(details.flag_metadata), 0)
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class TestProviderType(Enum):
UNSTABLE = "unstable"
SSL = "ssl"
SOCKET = "socket"
METADATA = "metadata"


@given("a provider is registered", target_fixture="client")
Expand All @@ -43,7 +44,7 @@ def setup_provider_old(


def get_default_options_for_provider(
provider_type: str, resolver_type: ResolverType, container
provider_type: str, resolver_type: ResolverType, container, option_values: dict
) -> tuple[dict, bool]:
launchpad = "default"
t = TestProviderType(provider_type)
Expand All @@ -68,11 +69,20 @@ def get_default_options_for_provider(
launchpad = "ssl"
elif t == TestProviderType.SOCKET:
return options, True
elif t == TestProviderType.METADATA:
launchpad = "metadata"

if resolver_type == ResolverType.FILE:
options["offline_flag_source_path"] = os.path.join(
container.flagDir.name, "allFlags.json"
)
if "selector" in option_values:
path = option_values["selector"]
path = path.replace("rawflags/", "")
options["offline_flag_source_path"] = os.path.join(
Path(__file__).parents[3], "openfeature", "test-harness", "flags", path
)
else:
options["offline_flag_source_path"] = os.path.join(
container.flagDir.name, "allFlags.json"
)

requests.post(
f"{container.get_launchpad_url()}/start?config={launchpad}", timeout=1
Expand All @@ -91,7 +101,7 @@ def setup_provider(
option_values: dict,
) -> OpenFeatureClient:
default_options, wait = get_default_options_for_provider(
provider_type, resolver_type, container
provider_type, resolver_type, container, option_values
)

combined_options = {**default_options, **option_values}
Expand Down Expand Up @@ -120,7 +130,8 @@ def flagd_restart(
resolver_type: ResolverType,
):
requests.post(
f"{container.get_launchpad_url()}/restart?seconds={seconds}", timeout=2
f"{container.get_launchpad_url()}/restart?seconds={seconds}",
timeout=float(seconds) + 2,
)
pass

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"flags": {
"basic-flag": {
"state": "ENABLED",
"variants": {
"true": true,
"false": false
},
"defaultVariant": "false",
"targeting": {},
"metadata": {
"string": "a",
"integer": 1,
"float": 1.2,
"bool": true
}
}
},
"metadata": {
"string": "b",
"integer": 2,
"float": 2.2,
"bool": false,
"flag-set-string": "c",
"flag-set-integer": 3,
"flag-set-float": 3.2,
"flag-set-bool": false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"flags": {
"basic-flag": {
"state": "ENABLED",
"variants": {
"true": true,
"false": false
},
"defaultVariant": "false",
"targeting": {},
"metadata": {
"string": "a",
"integer": 1,
"float": 1.2,
"bool": true
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"flags": {
"basic-flag": {
"state": "ENABLED",
"variants": {
"true": true,
"false": false
},
"defaultVariant": "false",
"targeting": {}
}
},
"metadata": {
"string": "a",
"integer": 1,
"float": 1.2,
"bool": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"flags": {
"basic-flag": {
"state": "ENABLED",
"variants": {
"true": true,
"false": false
},
"defaultVariant": "false",
"targeting": {},
"metadata": ["a"]
}
}
}
Loading