bext_plugin_api/feature_flag.rs
1//! Feature-flag capability trait and types.
2//!
3//! A `FeatureFlagPlugin` answers the question *"what does flag `X` evaluate
4//! to for this caller, right now?"*. Backends fall into two broad groups:
5//!
6//! 1. **Local** (`@bext/flags-static`) — flag definitions live in a config
7//! file loaded at construction time; evaluation is a pure lookup.
8//! 2. **Remote** (`@bext/flags-openfeature`, `@bext/flags-unleash`,
9//! `@bext/flags-launchdarkly`) — flag state is fetched from an external
10//! provider; `refresh` pulls a fresh snapshot.
11//!
12//! The trait is deliberately sync to match the rest of `bext-plugin-api`.
13//! Backends that need network I/O (OpenFeature providers, LaunchDarkly SDK)
14//! either use a blocking client or block on a runtime handle the same way
15//! the JWKS fetcher in the JWT middleware does — plugins cannot expose
16//! native async across the WASM boundary, so the host-facing shape stays
17//! sync.
18//!
19//! `FlagValue` is kept small and typed so that the host-function shims
20//! (`flags.bool`, `flags.string`, `flags.int`, `flags.json`) can return
21//! the same data without reaching back into the plugin for re-decoding.
22//! JSON-shaped values are carried as a JSON-encoded `String` — this keeps
23//! the ABI flat and matches the way `session.rs` carries session data and
24//! the way `lifecycle.rs` carries event payloads.
25
26use std::collections::HashMap;
27
28/// A single flag value. Four concrete shapes cover every flag provider the
29/// plan lists (OpenFeature, Unleash, LaunchDarkly, Statsig) without making
30/// any of them special.
31///
32/// `Json` carries a JSON-encoded string rather than a `serde_json::Value`
33/// so the whole enum round-trips through the WASM ABI as plain bytes. The
34/// caller parses the string in its own code if it needs structured access.
35#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
36#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
37pub enum FlagValue {
38 /// Boolean flag. Used for kill-switches, simple on/off rollouts.
39 Bool(bool),
40 /// String flag. Used for A/B/C-style variants ("control", "treatment").
41 String(String),
42 /// Integer flag. Used for numeric thresholds (batch sizes, timeouts).
43 Int(i64),
44 /// JSON-encoded structured flag. The string is always a valid JSON
45 /// document; the backend guarantees this.
46 Json(String),
47}
48
49impl FlagValue {
50 /// Convenience: `true` if this is a `Bool(true)`. For every other
51 /// variant, including `Bool(false)`, returns `false`.
52 pub fn as_bool(&self) -> bool {
53 matches!(self, Self::Bool(true))
54 }
55
56 /// Convenience accessor for string variants. Returns `None` for every
57 /// other shape — callers decide whether to coerce or treat as a miss.
58 pub fn as_str(&self) -> Option<&str> {
59 match self {
60 Self::String(s) => Some(s.as_str()),
61 _ => None,
62 }
63 }
64
65 /// Convenience accessor for integer variants.
66 pub fn as_int(&self) -> Option<i64> {
67 match self {
68 Self::Int(n) => Some(*n),
69 _ => None,
70 }
71 }
72}
73
74/// Evaluation context handed to a flag provider on every call.
75///
76/// Pure data, no framework types — matches the `AuthRequestContext` and
77/// `RequestContext` conventions so the shape travels across the sandbox
78/// boundary. `attributes` is the same escape-hatch map as
79/// `AuthUser::attributes`: arbitrary provider-specific targeting inputs
80/// (country, device class, plan tier, cohort) land here as flat string
81/// pairs so the trait itself never grows vendor-specific fields.
82#[derive(Debug, Clone, Default)]
83pub struct FlagContext {
84 /// Stable user identifier for per-user rollouts / bucketing. `None`
85 /// for anonymous evaluation — providers that require a subject should
86 /// fall back to their default value rather than erroring.
87 pub user_id: Option<String>,
88 /// Free-form targeting attributes. Keys are provider-defined
89 /// (`"country"`, `"plan"`, `"device_class"`, ...); values are plain
90 /// strings so the map round-trips through any serialisation format.
91 pub attributes: HashMap<String, String>,
92}
93
94impl FlagContext {
95 /// Construct an empty, anonymous context. Every field defaults.
96 pub fn anonymous() -> Self {
97 Self::default()
98 }
99
100 /// Construct a context bound to a specific user id, with no
101 /// attributes. Callers add attributes with `.with_attribute(..)`.
102 pub fn for_user(user_id: impl Into<String>) -> Self {
103 Self {
104 user_id: Some(user_id.into()),
105 attributes: HashMap::new(),
106 }
107 }
108
109 /// Builder-style helper for adding a single attribute.
110 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
111 self.attributes.insert(key.into(), value.into());
112 self
113 }
114}
115
116/// A feature-flag provider.
117///
118/// The runtime holds one instance per configured backend and dispatches
119/// `flags.bool` / `flags.string` / `flags.int` / `flags.json` host calls
120/// through it. `evaluate` is the core: callers pass a flag key and a
121/// context, the provider returns the current value.
122///
123/// `evaluate_batch` exists for the common "render-time prefetch" pattern
124/// where a page needs to resolve a dozen flags before it can render. A
125/// default implementation walks the keys and calls `evaluate` one at a
126/// time — backends with native batch APIs (LaunchDarkly, OpenFeature's
127/// `ProviderEvaluation` list) override for efficiency.
128///
129/// `refresh` exists for backends that cache flag state (virtually all
130/// remote providers). Local providers like `@bext/flags-static` override
131/// it to re-read the config file off disk; on-change detection is out of
132/// scope for the trait — the runtime schedules `refresh` itself.
133pub trait FeatureFlagPlugin: Send + Sync {
134 /// Unique identifier for this backend (e.g. `"static"`, `"openfeature"`).
135 fn name(&self) -> &str;
136
137 /// Evaluate a single flag. Returns the current value, or `Err` on
138 /// provider failure (network, malformed config, unknown value type).
139 ///
140 /// A missing flag is *not* an error — backends return their configured
141 /// default variant. This matches the OpenFeature semantic and keeps
142 /// callers from having to distinguish "flag not found" from "flag is
143 /// off" at call sites, which is almost never what they actually want.
144 fn evaluate(&self, key: &str, ctx: &FlagContext) -> Result<FlagValue, String>;
145
146 /// Evaluate several flags at once. The returned map contains one
147 /// entry per input key. Default implementation walks `evaluate`;
148 /// backends with native batch APIs should override.
149 fn evaluate_batch(
150 &self,
151 keys: &[&str],
152 ctx: &FlagContext,
153 ) -> Result<HashMap<String, FlagValue>, String> {
154 let mut out = HashMap::with_capacity(keys.len());
155 for key in keys {
156 out.insert((*key).to_string(), self.evaluate(key, ctx)?);
157 }
158 Ok(out)
159 }
160
161 /// Pull a fresh snapshot from the backing store. Local providers
162 /// re-read the config file; remote providers fetch the latest ruleset.
163 /// Default: no-op for providers that do not cache.
164 fn refresh(&self) -> Result<(), String> {
165 Ok(())
166 }
167
168 /// Health check. Default: always healthy. Remote providers should
169 /// override to ping their transport.
170 fn is_healthy(&self) -> bool {
171 true
172 }
173}