Skip to main content

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}