Skip to content

Commit 0521bd0

Browse files
committed
add support for metadata in flagd
Signed-off-by: christian.lutnik <[email protected]>
1 parent 2f85057 commit 0521bd0

File tree

14 files changed

+360
-13
lines changed

14 files changed

+360
-13
lines changed

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

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

1919

20+
def _merge_metadata(
21+
flag_metadata: typing.Mapping[str, typing.Union[float, int, str, bool]],
22+
flag_set_metadata: typing.Mapping[str, typing.Union[float, int, str, bool]]
23+
) -> typing.Mapping[str, typing.Union[float, int, str, bool]]:
24+
metadata = {}
25+
if flag_set_metadata is not None:
26+
for key, value in flag_set_metadata.items():
27+
metadata[key] = value
28+
29+
if flag_metadata is not None:
30+
for key, value in flag_metadata.items():
31+
metadata[key] = value
32+
33+
return metadata
34+
35+
2036
class InProcessResolver:
2137
def __init__(
2238
self,
@@ -103,18 +119,20 @@ def _resolve(
103119
if not flag:
104120
raise FlagNotFoundError(f"Flag with key {key} not present in flag store.")
105121

122+
metadata = _merge_metadata(flag.metadata, self.flag_store.flag_set_metadata)
123+
106124
if flag.state == "DISABLED":
107-
return FlagResolutionDetails(default_value, reason=Reason.DISABLED)
125+
return FlagResolutionDetails(default_value, flag_metadata=metadata, reason=Reason.DISABLED)
108126

109127
if not flag.targeting:
110128
variant, value = flag.default
111-
return FlagResolutionDetails(value, variant=variant, reason=Reason.STATIC)
129+
return FlagResolutionDetails(value, variant=variant, flag_metadata=metadata, reason=Reason.STATIC)
112130

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

115133
if variant is None:
116134
variant, value = flag.default
117-
return FlagResolutionDetails(value, variant=variant, reason=Reason.DEFAULT)
135+
return FlagResolutionDetails(value, variant=variant, flag_metadata=metadata, reason=Reason.DEFAULT)
118136
if not isinstance(variant, (str, bool)):
119137
raise ParseError(
120138
"Parsed JSONLogic targeting did not return a string or bool"
@@ -128,4 +146,5 @@ def _resolve(
128146
value,
129147
variant=variant,
130148
reason=Reason.TARGETING_MATCH,
149+
flag_metadata=metadata
131150
)

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

Lines changed: 5 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 ParseError, ProviderNotReadyError, ErrorCode
1818

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

@@ -76,8 +76,9 @@ 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("Could not parse flag data using flagd syntax: " + (
81+
"no error message provided" if e is None or e.error_message is None else e.error_message))
8182
except Exception:
8283
self.handle_error("Could not read flags from file")
8384

@@ -104,4 +105,4 @@ def _load_data(self, modified_time: typing.Optional[float] = None) -> None:
104105
def handle_error(self, error_message: str) -> None:
105106
logger.exception(error_message)
106107
self.should_emit_ready_on_success = True
107-
self.emit_provider_error(ProviderEventDetails(message=error_message))
108+
self.emit_provider_error(ProviderEventDetails(message=error_message, error_code=ErrorCode.PARSE_ERROR))

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

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

99

10+
def _validate_metadata(key, value):
11+
if key is None:
12+
raise ParseError("Metadata key must be set")
13+
elif not isinstance(key, str):
14+
raise ParseError("Metadata key " + str(key) + " must be of type str, but is " + str(type(key)))
15+
if value is None:
16+
raise ParseError("Metadata value for key " + str(key) + " must be set")
17+
elif not isinstance(value, typing.Union[float, int, str, bool]):
18+
raise ParseError("Metadata value " + str(value) +
19+
" for key " + str(key) +
20+
" must be of type float, int, str or bool, but is " + str(type(value)))
21+
22+
1023
class FlagStore:
1124
def __init__(
1225
self,
@@ -16,12 +29,14 @@ def __init__(
1629
):
1730
self.emit_provider_configuration_changed = emit_provider_configuration_changed
1831
self.flags: typing.Mapping[str, Flag] = {}
32+
self.flag_set_metadata: typing.Mapping[str, typing.Union[float, int, str, bool]] = {}
1933

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

2337
def update(self, flags_data: dict) -> None:
2438
flags = flags_data.get("flags", {})
39+
metadata = flags_data.get("metadata", {})
2540
evaluators: typing.Optional[dict] = flags_data.get("$evaluators")
2641
if evaluators:
2742
transposed = json.dumps(flags)
@@ -33,10 +48,16 @@ def update(self, flags_data: dict) -> None:
3348

3449
if not isinstance(flags, dict):
3550
raise ParseError("`flags` key of configuration must be a dictionary")
51+
if not isinstance(metadata, dict):
52+
raise ParseError("`metadata` key of configuration must be a dictionary")
53+
for key, value in metadata.items():
54+
_validate_metadata(key, value)
55+
3656
self.flags = {key: Flag.from_dict(key, data) for key, data in flags.items()}
57+
self.flag_set_metadata = metadata
3758

3859
self.emit_provider_configuration_changed(
39-
ProviderEventDetails(flags_changed=list(self.flags.keys()))
60+
ProviderEventDetails(flags_changed=list(self.flags.keys()), metadata=metadata)
4061
)
4162

4263

@@ -47,6 +68,7 @@ class Flag:
4768
variants: typing.Mapping[str, typing.Any]
4869
default_variant: typing.Union[bool, str]
4970
targeting: typing.Optional[dict] = None
71+
metadata: typing.Optional[typing.Mapping[str, typing.Union[float, int, str, bool]]] = None
5072

5173
def __post_init__(self) -> None:
5274
if not self.state or not isinstance(self.state, str):
@@ -66,6 +88,13 @@ def __post_init__(self) -> None:
6688
if self.default_variant not in self.variants:
6789
raise ParseError("Default variant does not match set of variants")
6890

91+
if self.metadata:
92+
if not isinstance(self.metadata, dict):
93+
raise ParseError("Flag metadata is not a valid json object")
94+
for key, value in self.metadata.items():
95+
_validate_metadata(key, value)
96+
97+
6998
@classmethod
7099
def from_dict(cls, key: str, data: dict) -> "Flag":
71100
if "defaultVariant" in data:
@@ -79,6 +108,8 @@ def from_dict(cls, key: str, data: dict) -> "Flag":
79108
try:
80109
flag = cls(key=key, **data)
81110
return flag
111+
except ParseError as parseError:
112+
raise parseError
82113
except Exception as err:
83114
raise ParseError from err
84115

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

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

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def setup_provider_old(
4141

4242

4343
def get_default_options_for_provider(
44-
provider_type: str, resolver_type: ResolverType, container
44+
provider_type: str, resolver_type: ResolverType, container, option_values: dict
4545
) -> tuple[dict, bool]:
4646
launchpad = "default"
4747
t = TestProviderType(provider_type)
@@ -68,9 +68,16 @@ def get_default_options_for_provider(
6868
return options, True
6969

7070
if resolver_type == ResolverType.FILE:
71-
options["offline_flag_source_path"] = os.path.join(
72-
container.flagDir.name, "allFlags.json"
73-
)
71+
if "selector" in option_values:
72+
path = option_values["selector"]
73+
path = path.replace("rawflags/", "")
74+
options["offline_flag_source_path"] = os.path.join(
75+
"..", "openfeature", "test-harness", "flags", path
76+
)
77+
else:
78+
options["offline_flag_source_path"] = os.path.join(
79+
container.flagDir.name, "allFlags.json"
80+
)
7481

7582
requests.post(
7683
f"{container.get_launchpad_url()}/start?config={launchpad}", timeout=1
@@ -89,7 +96,7 @@ def setup_provider(
8996
option_values: dict,
9097
) -> OpenFeatureClient:
9198
default_options, wait = get_default_options_for_provider(
92-
provider_type, resolver_type, container
99+
provider_type, resolver_type, container, option_values
93100
)
94101

95102
combined_options = {**default_options, **option_values}
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+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"flags": {
3+
"basic-flag": {
4+
"state": "ENABLED",
5+
"variants": {
6+
"true": true,
7+
"false": false
8+
},
9+
"defaultVariant": "false",
10+
"targeting": {}
11+
}
12+
},
13+
"metadata": {
14+
"string": "a",
15+
"integer": 1,
16+
"float": 1.2,
17+
"bool": true
18+
}
19+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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": ["a"]
12+
}
13+
}
14+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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": {
13+
"a": "a"
14+
},
15+
"integer": 1,
16+
"float": 1.2,
17+
"bool": true
18+
}
19+
}
20+
},
21+
"metadata": {
22+
"bool": true
23+
}
24+
}

0 commit comments

Comments
 (0)