diff --git a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py index 17763615..44bb5a7a 100644 --- a/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py +++ b/providers/openfeature-provider-flagd/src/openfeature/contrib/provider/flagd/resolvers/process/custom_ops.py @@ -1,5 +1,6 @@ import logging import typing +from dataclasses import dataclass import mmh3 import semver @@ -10,6 +11,12 @@ logger = logging.getLogger("openfeature.contrib") +@dataclass +class Fraction: + variant: str + weight: int = 1 + + def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]: if not args: logger.error("No arguments provided to fractional operator.") @@ -32,28 +39,51 @@ def fractional(data: dict, *args: JsonLogicArg) -> typing.Optional[str]: return None hash_ratio = abs(mmh3.hash(bucket_by)) / (2**31 - 1) - bucket = int(hash_ratio * 100) + bucket = hash_ratio * 100 + total_weight = 0 + fractions = [] for arg in args: - if ( - not isinstance(arg, (tuple, list)) - or len(arg) != 2 - or not isinstance(arg[0], str) - or not isinstance(arg[1], int) - ): - logger.error("Fractional variant weights must be (str, int) tuple") - return None - variant_weights: typing.Tuple[typing.Tuple[str, int]] = args # type: ignore[assignment] - - range_end = 0 - for variant, weight in variant_weights: - range_end += weight + fraction = _parse_fraction(arg) + if fraction: + fractions.append(fraction) + total_weight += fraction.weight + + range_end: float = 0 + for fraction in fractions: + range_end += fraction.weight * 100 / total_weight if bucket < range_end: - return variant + return fraction.variant return None +def _parse_fraction(arg: JsonLogicArg) -> typing.Optional[Fraction]: + if not isinstance(arg, (tuple, list)) or not arg: + logger.error( + "Fractional variant weights must be (str, int) tuple or [str] list" + ) + return None + + if not isinstance(arg[0], str): + logger.error( + "Fractional variant identifier (first element) isn't of type 'str'" + ) + return None + + if len(arg) >= 2 and not isinstance(arg[1], int): + logger.error( + "Fractional variant weight value (second element) isn't of type 'int'" + ) + return None + + fraction = Fraction(variant=arg[0]) + if len(arg) >= 2: + fraction.weight = arg[1] + + return fraction + + def starts_with(data: dict, *args: JsonLogicArg) -> typing.Optional[bool]: def f(s1: str, s2: str) -> bool: return s1.startswith(s2) diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args-wrong-content.json b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args-wrong-content.json new file mode 100644 index 00000000..a40e34ba --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-args-wrong-content.json @@ -0,0 +1,16 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "fractional": [[]] + } + } + } +} diff --git a/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights-strings.json b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights-strings.json new file mode 100644 index 00000000..2f62796e --- /dev/null +++ b/providers/openfeature-provider-flagd/tests/flags/invalid-fractional-weights-strings.json @@ -0,0 +1,19 @@ +{ + "flags": { + "basic-flag": { + "state": "ENABLED", + "variants": { + "default": "default", + "true": "true", + "false": "false" + }, + "defaultVariant": "default", + "targeting": { + "fractional": [ + ["a", "one"], + ["b", "one"] + ] + } + } + } +} diff --git a/providers/openfeature-provider-flagd/tests/test_errors.py b/providers/openfeature-provider-flagd/tests/test_errors.py index 4adb332e..3e576e8a 100644 --- a/providers/openfeature-provider-flagd/tests/test_errors.py +++ b/providers/openfeature-provider-flagd/tests/test_errors.py @@ -48,7 +48,9 @@ def test_file_load_errors(file_name: str): "invalid-semver-args.json", "invalid-stringcomp-args.json", "invalid-fractional-args.json", + "invalid-fractional-args-wrong-content.json", "invalid-fractional-weights.json", + "invalid-fractional-weights-strings.json", ], ) def test_json_logic_parse_errors(file_name: str):