1@Service
2public class FlagEvaluator {
3 private volatile Map<String, FeatureFlag> flags = Map.of();
4
5 public void updateFlags(List<FeatureFlag> flagList) {
6 this.flags = flagList.stream()
7 .collect(Collectors.toUnmodifiableMap(FeatureFlag::getKey, Function.identity()));
8 }
9
10 public EvaluationResult evaluate(String flagKey, EvaluationContext context) {
11 var flag = flags.get(flagKey);
12 if (flag == null) return EvaluationResult.notFound(flagKey);
13 if (!flag.isEnabled()) return EvaluationResult.disabled(flagKey);
14
15 for (var rule : flag.getRules().stream()
16 .sorted(Comparator.comparingInt(TargetingRule::priority).reversed())
17 .toList()) {
18 if (matchesAll(rule.conditions(), context)) {
19 return EvaluationResult.enabled(flagKey, rule.variant(), "rule_match");
20 }
21 }
22
23 if (flag.getPercentage() > 0 && flag.getPercentage() < 100) {
24 double bucket = computeBucket(flagKey, context.userId());
25 if (bucket < flag.getPercentage()) {
26 return EvaluationResult.enabled(flagKey, flag.getDefaultVariant(), "percentage");
27 }
28 return EvaluationResult.disabled(flagKey, "percentage_excluded");
29 }
30
31 return EvaluationResult.enabled(flagKey, flag.getDefaultVariant(), "default");
32 }
33
34 public boolean isEnabled(String flagKey, EvaluationContext context) {
35 return evaluate(flagKey, context).enabled();
36 }
37
38 private double computeBucket(String flagKey, String userId) {
39 try {
40 var digest = MessageDigest.getInstance("SHA-256");
41 byte[] hash = digest.digest((flagKey + ":" + userId).getBytes(StandardCharsets.UTF_8));
42 long value = Integer.toUnsignedLong(
43 (hash[0] & 0xFF) << 24 | (hash[1] & 0xFF) << 16 |
44 (hash[2] & 0xFF) << 8 | (hash[3] & 0xFF));
45 return (value / (double) 0xFFFFFFFFL) * 100;
46 } catch (NoSuchAlgorithmException e) {
47 throw new RuntimeException(e);
48 }
49 }
50
51 private boolean matchesAll(List<Condition> conditions, EvaluationContext ctx) {
52 return conditions.stream().allMatch(c -> matchCondition(c, ctx));
53 }
54
55 private boolean matchCondition(Condition cond, EvaluationContext ctx) {
56 String value = getAttribute(cond.attribute(), ctx);
57 return switch (cond.operator()) {
58 case "eq" -> !cond.values().isEmpty() && cond.values().get(0).equals(value);
59 case "neq" -> !cond.values().isEmpty() && !cond.values().get(0).equals(value);
60 case "in" -> cond.values().contains(value);
61 case "contains" -> !cond.values().isEmpty() && value.contains(cond.values().get(0));
62 case "starts_with" -> !cond.values().isEmpty() && value.startsWith(cond.values().get(0));
63 default -> false;
64 };
65 }
66
67 private String getAttribute(String attr, EvaluationContext ctx) {
68 return switch (attr) {
69 case "user_id" -> ctx.userId();
70 case "email" -> ctx.email();
71 case "plan" -> ctx.plan();
72 case "country" -> ctx.country();
73 default -> ctx.properties().getOrDefault(attr, "");
74 };
75 }
76}
77
78public record EvaluationContext(
79 String userId,
80 String email,
81 String plan,
82 String country,
83 Map<String, String> properties
84) {
85 public EvaluationContext(String userId) {
86 this(userId, "", "", "", Map.of());
87 }
88}
89
90public record EvaluationResult(String flagKey, boolean enabled, String variant, String reason) {
91 public static EvaluationResult enabled(String key, String variant, String reason) {
92 return new EvaluationResult(key, true, variant, reason);
93 }
94 public static EvaluationResult disabled(String key) {
95 return new EvaluationResult(key, false, "", "disabled");
96 }
97 public static EvaluationResult disabled(String key, String reason) {
98 return new EvaluationResult(key, false, "", reason);
99 }
100 public static EvaluationResult notFound(String key) {
101 return new EvaluationResult(key, false, "", "not_found");
102 }
103}
104