bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Feature-flag capability trait and types.
//!
//! A `FeatureFlagPlugin` answers the question *"what does flag `X` evaluate
//! to for this caller, right now?"*. Backends fall into two broad groups:
//!
//! 1. **Local** (`@bext/flags-static`) — flag definitions live in a config
//!    file loaded at construction time; evaluation is a pure lookup.
//! 2. **Remote** (`@bext/flags-openfeature`, `@bext/flags-unleash`,
//!    `@bext/flags-launchdarkly`) — flag state is fetched from an external
//!    provider; `refresh` pulls a fresh snapshot.
//!
//! The trait is deliberately sync to match the rest of `bext-plugin-api`.
//! Backends that need network I/O (OpenFeature providers, LaunchDarkly SDK)
//! either use a blocking client or block on a runtime handle the same way
//! the JWKS fetcher in the JWT middleware does — plugins cannot expose
//! native async across the WASM boundary, so the host-facing shape stays
//! sync.
//!
//! `FlagValue` is kept small and typed so that the host-function shims
//! (`flags.bool`, `flags.string`, `flags.int`, `flags.json`) can return
//! the same data without reaching back into the plugin for re-decoding.
//! JSON-shaped values are carried as a JSON-encoded `String` — this keeps
//! the ABI flat and matches the way `session.rs` carries session data and
//! the way `lifecycle.rs` carries event payloads.

use std::collections::HashMap;

/// A single flag value. Four concrete shapes cover every flag provider the
/// plan lists (OpenFeature, Unleash, LaunchDarkly, Statsig) without making
/// any of them special.
///
/// `Json` carries a JSON-encoded string rather than a `serde_json::Value`
/// so the whole enum round-trips through the WASM ABI as plain bytes. The
/// caller parses the string in its own code if it needs structured access.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum FlagValue {
    /// Boolean flag. Used for kill-switches, simple on/off rollouts.
    Bool(bool),
    /// String flag. Used for A/B/C-style variants ("control", "treatment").
    String(String),
    /// Integer flag. Used for numeric thresholds (batch sizes, timeouts).
    Int(i64),
    /// JSON-encoded structured flag. The string is always a valid JSON
    /// document; the backend guarantees this.
    Json(String),
}

impl FlagValue {
    /// Convenience: `true` if this is a `Bool(true)`. For every other
    /// variant, including `Bool(false)`, returns `false`.
    pub fn as_bool(&self) -> bool {
        matches!(self, Self::Bool(true))
    }

    /// Convenience accessor for string variants. Returns `None` for every
    /// other shape — callers decide whether to coerce or treat as a miss.
    pub fn as_str(&self) -> Option<&str> {
        match self {
            Self::String(s) => Some(s.as_str()),
            _ => None,
        }
    }

    /// Convenience accessor for integer variants.
    pub fn as_int(&self) -> Option<i64> {
        match self {
            Self::Int(n) => Some(*n),
            _ => None,
        }
    }
}

/// Evaluation context handed to a flag provider on every call.
///
/// Pure data, no framework types — matches the `AuthRequestContext` and
/// `RequestContext` conventions so the shape travels across the sandbox
/// boundary. `attributes` is the same escape-hatch map as
/// `AuthUser::attributes`: arbitrary provider-specific targeting inputs
/// (country, device class, plan tier, cohort) land here as flat string
/// pairs so the trait itself never grows vendor-specific fields.
#[derive(Debug, Clone, Default)]
pub struct FlagContext {
    /// Stable user identifier for per-user rollouts / bucketing. `None`
    /// for anonymous evaluation — providers that require a subject should
    /// fall back to their default value rather than erroring.
    pub user_id: Option<String>,
    /// Free-form targeting attributes. Keys are provider-defined
    /// (`"country"`, `"plan"`, `"device_class"`, ...); values are plain
    /// strings so the map round-trips through any serialisation format.
    pub attributes: HashMap<String, String>,
}

impl FlagContext {
    /// Construct an empty, anonymous context. Every field defaults.
    pub fn anonymous() -> Self {
        Self::default()
    }

    /// Construct a context bound to a specific user id, with no
    /// attributes. Callers add attributes with `.with_attribute(..)`.
    pub fn for_user(user_id: impl Into<String>) -> Self {
        Self {
            user_id: Some(user_id.into()),
            attributes: HashMap::new(),
        }
    }

    /// Builder-style helper for adding a single attribute.
    pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.attributes.insert(key.into(), value.into());
        self
    }
}

/// A feature-flag provider.
///
/// The runtime holds one instance per configured backend and dispatches
/// `flags.bool` / `flags.string` / `flags.int` / `flags.json` host calls
/// through it. `evaluate` is the core: callers pass a flag key and a
/// context, the provider returns the current value.
///
/// `evaluate_batch` exists for the common "render-time prefetch" pattern
/// where a page needs to resolve a dozen flags before it can render. A
/// default implementation walks the keys and calls `evaluate` one at a
/// time — backends with native batch APIs (LaunchDarkly, OpenFeature's
/// `ProviderEvaluation` list) override for efficiency.
///
/// `refresh` exists for backends that cache flag state (virtually all
/// remote providers). Local providers like `@bext/flags-static` override
/// it to re-read the config file off disk; on-change detection is out of
/// scope for the trait — the runtime schedules `refresh` itself.
pub trait FeatureFlagPlugin: Send + Sync {
    /// Unique identifier for this backend (e.g. `"static"`, `"openfeature"`).
    fn name(&self) -> &str;

    /// Evaluate a single flag. Returns the current value, or `Err` on
    /// provider failure (network, malformed config, unknown value type).
    ///
    /// A missing flag is *not* an error — backends return their configured
    /// default variant. This matches the OpenFeature semantic and keeps
    /// callers from having to distinguish "flag not found" from "flag is
    /// off" at call sites, which is almost never what they actually want.
    fn evaluate(&self, key: &str, ctx: &FlagContext) -> Result<FlagValue, String>;

    /// Evaluate several flags at once. The returned map contains one
    /// entry per input key. Default implementation walks `evaluate`;
    /// backends with native batch APIs should override.
    fn evaluate_batch(
        &self,
        keys: &[&str],
        ctx: &FlagContext,
    ) -> Result<HashMap<String, FlagValue>, String> {
        let mut out = HashMap::with_capacity(keys.len());
        for key in keys {
            out.insert((*key).to_string(), self.evaluate(key, ctx)?);
        }
        Ok(out)
    }

    /// Pull a fresh snapshot from the backing store. Local providers
    /// re-read the config file; remote providers fetch the latest ruleset.
    /// Default: no-op for providers that do not cache.
    fn refresh(&self) -> Result<(), String> {
        Ok(())
    }

    /// Health check. Default: always healthy. Remote providers should
    /// override to ping their transport.
    fn is_healthy(&self) -> bool {
        true
    }
}