Skip to content

Commit 6dc72c0

Browse files
chrfwowgruebel
andauthored
feat: add support for flagd flag metadata (#215)
* add support for metadata in flagd Signed-off-by: christian.lutnik <[email protected]> * reformatting Signed-off-by: christian.lutnik <[email protected]> * fix type errors Signed-off-by: christian.lutnik <[email protected]> * fix type errors and fmt Signed-off-by: christian.lutnik <[email protected]> * fix type errors and fmt Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * fix format, add tests Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed v2 Signed-off-by: christian.lutnik <[email protected]> * fix zero value errors Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed v3 Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed v3 Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed v4 Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed v5 Signed-off-by: christian.lutnik <[email protected]> * switch to new version of flagd testbed v6 Signed-off-by: christian.lutnik <[email protected]> * minor improvements, adjust to workaround for fladg issue Signed-off-by: christian.lutnik <[email protected]> * update test harness v10000 Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * attempt to fix tests Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * fix failing tests, upgrade test harness Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> * Update providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py Co-authored-by: Anton Grübel <[email protected]> Signed-off-by: chrfwow <[email protected]> * Update providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py Co-authored-by: Anton Grübel <[email protected]> Signed-off-by: chrfwow <[email protected]> * Update providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py Co-authored-by: Anton Grübel <[email protected]> Signed-off-by: chrfwow <[email protected]> * fix string format Signed-off-by: christian.lutnik <[email protected]> * fix format Signed-off-by: christian.lutnik <[email protected]> --------- Signed-off-by: christian.lutnik <[email protected]> Signed-off-by: chrfwow <[email protected]> Co-authored-by: Anton Grübel <[email protected]>
1 parent 9bf2e42 commit 6dc72c0

File tree

19 files changed

+442
-22
lines changed

19 files changed

+442
-22
lines changed

.gitmodules

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
path = providers/openfeature-provider-flagd/openfeature/schemas
33
url = https://github.com/open-feature/schemas
44
branch = protobuf-v0.6.1
5-
[submodule "providers/openfeature-provider-flagd/test-harness"]
6-
path = providers/openfeature-provider-flagd/openfeature/test-harness
7-
url = [email protected]:open-feature/flagd-testbed.git
8-
branch = v2.5.0
95
[submodule "providers/openfeature-provider-flagd/spec"]
106
path = providers/openfeature-provider-flagd/openfeature/spec
117
url = https://github.com/open-feature/spec
8+
[submodule "providers/openfeature-provider-flagd/openfeature/test-harness"]
9+
path = providers/openfeature-provider-flagd/openfeature/test-harness
10+
url = https://github.com/open-feature/flagd-testbed.git
11+
branch = v2.7.0

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/in_process.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@
1717
T = typing.TypeVar("T")
1818

1919

20+
def _merge_metadata(
21+
flag_metadata: typing.Optional[
22+
typing.Mapping[str, typing.Union[float, int, str, bool]]
23+
],
24+
flag_set_metadata: typing.Optional[
25+
typing.Mapping[str, typing.Union[float, int, str, bool]]
26+
],
27+
) -> typing.Mapping[str, typing.Union[float, int, str, bool]]:
28+
metadata = {} if flag_set_metadata is None else dict(flag_set_metadata)
29+
30+
if flag_metadata is not None:
31+
for key, value in flag_metadata.items():
32+
metadata[key] = value
33+
34+
return metadata
35+
36+
2037
class InProcessResolver:
2138
def __init__(
2239
self,
@@ -103,18 +120,26 @@ def _resolve(
103120
if not flag:
104121
raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
105122

123+
metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata)
124+
106125
if flag.state == "DISABLED":
107-
return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
126+
return FlagResolutionDetails(
127+
default_value, flag_metadata=metadata, reason=Reason.DISABLED
128+
)
108129

109130
if not flag.targeting:
110131
variant, value = flag.default
111-
return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
132+
return FlagResolutionDetails(
133+
value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC
134+
)
112135

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

115138
if variant is None:
116139
variant, value = flag.default
117-
return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
140+
return FlagResolutionDetails(
141+
value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT
142+
)
118143
if not isinstance(variant, (str, bool)):
119144
raise ParseError(
120145
"Parsed JSONLogic targeting did not return a string or bool"
@@ -128,4 +153,5 @@ def _resolve(
128153
value,
129154
variant=variant,
130155
reason=Reason.TARGETING_MATCH,
156+
flag_metadata=metadata,
131157
)

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/connector/file_watcher.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from openfeature.contrib.provider.flagd.resolvers.process.flags import FlagStore
1515
from openfeature.evaluation_context import EvaluationContext
1616
from openfeature.event import ProviderEventDetails
17-
from openfeature.exception import ParseError, ProviderNotReadyError
17+
from openfeature.exception import ErrorCode, ParseError, ProviderNotReadyError
1818

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

@@ -76,8 +76,15 @@ def safe_load_data(self) -> None:
7676
self.handle_error("Could not parse JSON flag data from file")
7777
except yaml.error.YAMLError:
7878
self.handle_error("Could not parse YAML flag data from file")
79-
except ParseError:
80-
self.handle_error("Could not parse flag data using flagd syntax")
79+
except ParseError as e:
80+
self.handle_error(
81+
"Could not parse flag data using flagd syntax: "
82+
+ (
83+
"no error message provided"
84+
if e is None or e.error_message is None
85+
else e.error_message
86+
)
87+
)
8188
except Exception:
8289
self.handle_error("Could not read flags from file")
8390

@@ -104,4 +111,8 @@ def _load_data(self, modified_time: typing.Optional[float] = None) -> None:
104111
def handle_error(self, error_message: str) -> None:
105112
logger.exception(error_message)
106113
self.should_emit_ready_on_success = True
107-
self.emit_provider_error(ProviderEventDetails(message=error_message))
114+
self.emit_provider_error(
115+
ProviderEventDetails(
116+
message=error_message, error_code=ErrorCode.PARSE_ERROR
117+
)
118+
)

providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/flags.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@
77
from openfeature.exception import ParseError
88

99

10+
def _validate_metadata(key: str, value: typing.Union[float, int, str, bool]) -> None:
11+
if key is None:
12+
raise ParseError("Metadata key must be set")
13+
elif not isinstance(key, str):
14+
raise ParseError(f"Metadata key {key} must be of type str, but is {type(key)}")
15+
elif not key:
16+
raise ParseError("key must not be empty")
17+
if value is None:
18+
raise ParseError(f"Metadata value for key {key} must be set")
19+
elif not isinstance(value, (float, int, str, bool)):
20+
raise ParseError(
21+
f"Metadata value {value} for key {key} must be of type float, int, str or bool, but is {type(value)}"
22+
)
23+
24+
1025
class FlagStore:
1126
def __init__(
1227
self,
@@ -16,12 +31,16 @@ def __init__(
1631
):
1732
self.emit_provider_configuration_changed = emit_provider_configuration_changed
1833
self.flags: typing.Mapping[str, Flag] = {}
34+
self.flag_set_metadata: typing.Mapping[
35+
str, typing.Union[float, int, str, bool]
36+
] = {}
1937

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

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

3453
if not isinstance(flags, dict):
3554
raise ParseError("`flags` key of configuration must be a dictionary")
55+
if not isinstance(metadata, dict):
56+
raise ParseError("`metadata` key of configuration must be a dictionary")
57+
for key, value in metadata.items():
58+
_validate_metadata(key, value)
59+
3660
self.flags = {key: Flag.from_dict(key, data) for key, data in flags.items()}
61+
self.flag_set_metadata = metadata
3762

3863
self.emit_provider_configuration_changed(
39-
ProviderEventDetails(flags_changed=list(self.flags.keys()))
64+
ProviderEventDetails(
65+
flags_changed=list(self.flags.keys()), metadata=metadata
66+
)
4067
)
4168

4269

@@ -47,6 +74,9 @@ class Flag:
4774
variants: typing.Mapping[str, typing.Any]
4875
default_variant: typing.Union[bool, str]
4976
targeting: typing.Optional[dict] = None
77+
metadata: typing.Optional[
78+
typing.Mapping[str, typing.Union[float, int, str, bool]]
79+
] = None
5080

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

99+
if self.metadata:
100+
if not isinstance(self.metadata, dict):
101+
raise ParseError("Flag metadata is not a valid json object")
102+
for key, value in self.metadata.items():
103+
_validate_metadata(key, value)
104+
69105
@classmethod
70106
def from_dict(cls, key: str, data: dict) -> "Flag":
71107
if "defaultVariant" in data:
@@ -77,6 +113,8 @@ def from_dict(cls, key: str, data: dict) -> "Flag":
77113
try:
78114
flag = cls(key=key, **data)
79115
return flag
116+
except ParseError as parseError:
117+
raise parseError
80118
except Exception as err:
81119
raise ParseError from err
82120

providers/openfeature-provider-flagd/tests/e2e/rpc/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from tests.e2e.testfilter import TestFilter
55

66
resolver = ResolverType.RPC
7-
feature_list = ["~targetURI", "~unixsocket", "~sync"]
7+
feature_list = ["~targetURI", "~unixsocket", "~sync", "~metadata"]
88

99

1010
def pytest_collection_modifyitems(config, items):

providers/openfeature-provider-flagd/tests/e2e/step/event_steps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def handler(event):
4444

4545

4646
def assert_handlers(handles, event_type: str, max_wait: int = 2):
47-
poll_interval = 1
47+
poll_interval = 0.2
4848
while max_wait > 0:
4949
found = any(h["type"] == event_type for h in handles)
5050
if not found:

providers/openfeature-provider-flagd/tests/e2e/step/flag_step.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,16 @@ def resolve_details_reason(
9494
reason: str,
9595
):
9696
assert_equal(details.reason, Reason(reason))
97+
98+
99+
@then(parsers.cfparse("the resolved metadata should contain"))
100+
def metadata_contains(details: FlagEvaluationDetails[JsonPrimitive], datatable):
101+
assert_equal(len(details.flag_metadata), len(datatable) - 1) # skip table header
102+
for i in range(1, len(datatable)):
103+
key, metadata_type, expected = datatable[i]
104+
assert_equal(details.flag_metadata[key], type_cast[metadata_type](expected))
105+
106+
107+
@then("the resolved metadata is empty")
108+
def empty_metadata(details: FlagEvaluationDetails[JsonPrimitive]):
109+
assert_equal(len(details.flag_metadata), 0)

providers/openfeature-provider-flagd/tests/e2e/step/provider_steps.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class TestProviderType(Enum):
3131
UNSTABLE = "unstable"
3232
SSL = "ssl"
3333
SOCKET = "socket"
34+
METADATA = "metadata"
3435

3536

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

4445

4546
def get_default_options_for_provider(
46-
provider_type: str, resolver_type: ResolverType, container
47+
provider_type: str, resolver_type: ResolverType, container, option_values: dict
4748
) -> tuple[dict, bool]:
4849
launchpad = "default"
4950
t = TestProviderType(provider_type)
@@ -68,11 +69,20 @@ def get_default_options_for_provider(
6869
launchpad = "ssl"
6970
elif t == TestProviderType.SOCKET:
7071
return options, True
72+
elif t == TestProviderType.METADATA:
73+
launchpad = "metadata"
7174

7275
if resolver_type == ResolverType.FILE:
73-
options["offline_flag_source_path"] = os.path.join(
74-
container.flagDir.name, "allFlags.json"
75-
)
76+
if "selector" in option_values:
77+
path = option_values["selector"]
78+
path = path.replace("rawflags/", "")
79+
options["offline_flag_source_path"] = os.path.join(
80+
Path(__file__).parents[3], "openfeature", "test-harness", "flags", path
81+
)
82+
else:
83+
options["offline_flag_source_path"] = os.path.join(
84+
container.flagDir.name, "allFlags.json"
85+
)
7686

7787
requests.post(
7888
f"{container.get_launchpad_url()}/start?config={launchpad}", timeout=1
@@ -91,7 +101,7 @@ def setup_provider(
91101
option_values: dict,
92102
) -> OpenFeatureClient:
93103
default_options, wait = get_default_options_for_provider(
94-
provider_type, resolver_type, container
104+
provider_type, resolver_type, container, option_values
95105
)
96106

97107
combined_options = {**default_options, **option_values}
@@ -120,7 +130,8 @@ def flagd_restart(
120130
resolver_type: ResolverType,
121131
):
122132
requests.post(
123-
f"{container.get_launchpad_url()}/restart?seconds={seconds}", timeout=2
133+
f"{container.get_launchpad_url()}/restart?seconds={seconds}",
134+
timeout=float(seconds) + 2,
124135
)
125136
pass
126137

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"flags": {
3+
"basic-flag": {
4+
"state": "ENABLED",
5+
"variants": {
6+
"true": true,
7+
"false": false
8+
},
9+
"defaultVariant": "false",
10+
"targeting": {},
11+
"metadata": {
12+
"string": "a",
13+
"integer": 1,
14+
"float": 1.2,
15+
"bool": true
16+
}
17+
}
18+
},
19+
"metadata": {
20+
"string": "b",
21+
"integer": 2,
22+
"float": 2.2,
23+
"bool": false,
24+
"flag-set-string": "c",
25+
"flag-set-integer": 3,
26+
"flag-set-float": 3.2,
27+
"flag-set-bool": false
28+
}
29+
}

0 commit comments

Comments
 (0)