diff --git a/.github/component_owners.yml b/.github/component_owners.yml index beb36d0..fc47a69 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -11,6 +11,9 @@ components: crates/ofrep: - erenatas - Rahul-Baradol + crates/flagsmith: + - matthewelwell + - zaimwa9 ignored-authors: - renovate-bot diff --git a/Cargo.toml b/Cargo.toml index 7b4ec5d..2b5f0f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ edition = "2024" members = [ "crates/env-var", "crates/flagd", + "crates/flagsmith", "crates/flipt", "crates/ofrep" ] diff --git a/crates/flagsmith/CHANGELOG.md b/crates/flagsmith/CHANGELOG.md new file mode 100644 index 0000000..1ca3135 --- /dev/null +++ b/crates/flagsmith/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2025-11-19 + +### Added + +- Initial release of the Flagsmith OpenFeature provider +- Support for all five OpenFeature flag types: + - Boolean flags via `resolve_bool_value` + - String flags via `resolve_string_value` + - Integer flags via `resolve_int_value` + - Float flags via `resolve_float_value` + - Structured (JSON) flags via `resolve_struct_value` +- Environment-level flag evaluation (without targeting) +- Identity-specific flag evaluation (with targeting key and traits) +- Automatic conversion of OpenFeature context to Flagsmith traits +- Local evaluation mode support (requires server-side key) +- Comprehensive error handling and mapping: + - `FlagNotFound` for missing flags + - `ProviderNotReady` for API/network errors + - `TypeMismatch` for type conversion errors + - `ParseError` for JSON/value parsing errors +- OpenFeature reason code support: + - `Static` for environment-level evaluation + - `TargetingMatch` for identity-specific evaluation + - `Disabled` for disabled flags +- Configuration options: + - Custom API URL + - Request timeout + - Local evaluation mode + - Analytics tracking + - Custom HTTP headers +- Comprehensive unit tests +- Full documentation and examples + +### Dependencies + +- `open-feature` 0.2.x +- `flagsmith` (local path to Rust SDK) +- `tokio` 1.x +- `async-trait` 0.1.x +- `thiserror` 2.0.x +- `serde_json` 1.0.x +- `tracing` 0.1.x + +[0.1.0]: https://github.com/open-feature/rust-sdk-contrib/releases/tag/open-feature-flagsmith-v0.1.0 diff --git a/crates/flagsmith/Cargo.toml b/crates/flagsmith/Cargo.toml new file mode 100644 index 0000000..48fa41a --- /dev/null +++ b/crates/flagsmith/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "open-feature-flagsmith" +version = "0.1.0" +authors = ["OpenFeature Maintainers"] +edition = "2024" +license = "Apache-2.0" +description = "Flagsmith provider for OpenFeature" +homepage = "https://openfeature.dev" +repository = "https://github.com/open-feature/rust-sdk-contrib" +readme = "README.md" +categories = ["config", "api-bindings"] +keywords = ["openfeature", "feature-flags", "flagsmith"] + +[dependencies] +# OpenFeature SDK +open-feature = "0.2" + +# Flagsmith SDK +flagsmith = "2.1" + +# Async runtime +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } +async-trait = "0.1" + +# Error handling +thiserror = "1.0" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Logging +tracing = "0.1" + +# URL validation +url = "2.0" + +# HTTP client (for HeaderMap type) +reqwest = { version = "0.11", default-features = false } + +# Flagsmith flag engine types (must match flagsmith version) +flagsmith-flag-engine = "0.5" + +[dev-dependencies] +# Testing +test-log = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/flagsmith/README.md b/crates/flagsmith/README.md new file mode 100644 index 0000000..1708b10 --- /dev/null +++ b/crates/flagsmith/README.md @@ -0,0 +1,159 @@ +# Flagsmith Provider for OpenFeature + +A Rust implementation of the OpenFeature provider for Flagsmith, enabling dynamic feature flag evaluation using the Flagsmith platform. + +This provider integrates the [Flagsmith Rust SDK](https://github.com/Flagsmith/flagsmith-rust-client) with [OpenFeature](https://openfeature.dev/), supporting both environment-level and identity-specific flag evaluation with trait-based targeting. + +## Features + +- **Environment-level evaluation**: Evaluate flags at the environment level without user context +- **Identity-specific evaluation**: Target users with personalized flag values based on traits +- **Type safety**: Full support for boolean, string, integer, float, and structured (JSON) flag types +- **Local evaluation**: Optional local evaluation mode for improved performance and offline support +- **Async support**: Built on Tokio with non-blocking flag evaluations + +## Installation + +Add the dependency in your `Cargo.toml`: +```bash +cargo add open-feature-flagsmith +cargo add open-feature +``` + +## Basic Usage + +```rust +use open_feature::OpenFeature; +use open_feature::EvaluationContext; +use open_feature_flagsmith::{FlagsmithProvider, FlagsmithOptions}; + +#[tokio::main] +async fn main() { + // Initialize the provider + let provider = FlagsmithProvider::new( + "your-environment-key".to_string(), + FlagsmithOptions::default() + ).await.unwrap(); + + // Set up OpenFeature API + let mut api = OpenFeature::singleton_mut().await; + api.set_provider(provider).await; + let client = api.create_client(); + + // Evaluate a flag + let context = EvaluationContext::default(); + let enabled = client + .get_bool_value("my-feature", &context, None) + .await + .unwrap_or(false); + + println!("Feature enabled: {}", enabled); +} +``` + +## Identity-Specific Evaluation + +```rust +use open_feature::EvaluationContext; + +// Create context with targeting key and user traits +let context = EvaluationContext::default() + .with_targeting_key("user-123") + .with_custom_field("email", "user@example.com") + .with_custom_field("plan", "premium") + .with_custom_field("age", 25); + +let enabled = client + .get_bool_value("premium-feature", &context, None) + .await + .unwrap_or(false); +``` + +## Flag Types + +```rust +// Assuming you have set up the client as shown in the Basic Usage section +let context = EvaluationContext::default(); + +// Boolean flags +let enabled = client.get_bool_value("feature-toggle", &context, None).await.unwrap(); + +// String flags +let theme = client.get_string_value("theme", &context, None).await.unwrap(); + +// Integer flags +let max_items = client.get_int_value("max-items", &context, None).await.unwrap(); + +// Float flags +let multiplier = client.get_float_value("price-multiplier", &context, None).await.unwrap(); + +// Structured flags (JSON objects) +let config = client.get_object_value("config", &context, None).await.unwrap(); +``` + +## Local Evaluation + +Local evaluation mode downloads the environment configuration and evaluates flags locally for better performance: + +```rust +use open_feature_flagsmith::FlagsmithOptions; + +// Requires a server-side environment key (starts with "ser.") +let provider = FlagsmithProvider::new( + "ser.your-server-key".to_string(), + FlagsmithOptions::default() + .with_local_evaluation(true) +).await.unwrap(); +``` + +**Benefits:** +- Lower latency (no API calls per evaluation) +- Works offline (uses cached environment) +- Reduced API load + +**Requirements:** +- Server-side environment key (starts with `ser.`) +- Initial API call to fetch environment +- Periodic polling to refresh (default: 60s) + +## Configuration Options + +Configurations can be provided as constructor options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api_url` | `Option` | Flagsmith Edge API | Custom Flagsmith API endpoint | +| `request_timeout_seconds` | `Option` | 10 | Request timeout in seconds | +| `enable_local_evaluation` | `bool` | `false` | Enable local evaluation mode | +| `environment_refresh_interval_mills` | `Option` | 60000 | Polling interval for local mode (ms) | +| `enable_analytics` | `bool` | `false` | Enable analytics tracking | +| `custom_headers` | `Option` | None | Custom HTTP headers | + +### Example Configuration + +```rust +use open_feature_flagsmith::FlagsmithOptions; + +let options = FlagsmithOptions::default() + .with_local_evaluation(true) + .with_analytics(true) + .with_timeout(15); + +let provider = FlagsmithProvider::new( + "ser.your-key".to_string(), + options +).await.unwrap(); +``` + +## Evaluation Context Transformation + +OpenFeature standardizes the evaluation context with a `targeting_key` and arbitrary custom fields. For Flagsmith: + +- **`targeting_key`** → Flagsmith identity identifier +- **`custom_fields`** → Flagsmith traits for segmentation + +When a `targeting_key` is present, the provider performs identity-specific evaluation. Otherwise, it evaluates at the environment level. + +## License + +Apache 2.0 - See [LICENSE](./../../LICENSE) for more information. diff --git a/crates/flagsmith/src/error.rs b/crates/flagsmith/src/error.rs new file mode 100644 index 0000000..53395bd --- /dev/null +++ b/crates/flagsmith/src/error.rs @@ -0,0 +1,86 @@ +use thiserror::Error; + +/// Error message returned by Flagsmith SDK when a flag is not found. +/// +/// This constant matches the hardcoded error message in the Flagsmith Rust SDK v2.0 +/// (flagsmith/src/flagsmith/models.rs, Flags::get_flag method). +/// When a flag key doesn't exist in the flags HashMap and no default_flag_handler +/// is configured, the SDK returns a FlagsmithAPIError with this exact message. +/// +/// Note: This is a known limitation of the current SDK error reporting. A more robust +/// approach would be for the SDK to provide a structured error variant (e.g., +/// ErrorKind::FlagNotFound), but until that's available, we must rely on string matching. +/// This matching approach is used by other Flagsmith provider implementations as well. +const FLAGSMITH_FLAG_NOT_FOUND_MSG: &str = "API returned invalid response"; + +/// Custom error types for the Flagsmith provider. +#[derive(Error, Debug, PartialEq)] +pub enum FlagsmithError { + /// Configuration error (invalid options during initialization) + #[error("Configuration error: {0}")] + Config(String), + + /// API or network error (connection issues, timeouts, etc.) + #[error("API error: {0}")] + Api(String), + + /// Flag evaluation error (flag not found, type mismatch, etc.) + #[error("Evaluation error: {0}")] + Evaluation(String), + + /// Flag not found error + #[error("Flag not found: {0}")] + FlagNotFound(String), +} + +/// Convert Flagsmith SDK errors to FlagsmithError +impl From for FlagsmithError { + fn from(error: flagsmith::error::Error) -> Self { + match error.kind { + flagsmith::error::ErrorKind::FlagsmithAPIError => { + // Check if this is a "flag not found" error by matching the SDK's error message + if error.msg == FLAGSMITH_FLAG_NOT_FOUND_MSG { + FlagsmithError::FlagNotFound(error.msg) + } else { + FlagsmithError::Api(error.msg) + } + } + flagsmith::error::ErrorKind::FlagsmithClientError => { + FlagsmithError::Evaluation(error.msg) + } + } + } +} + +/// Convert URL parse errors to FlagsmithError +impl From for FlagsmithError { + fn from(error: url::ParseError) -> Self { + FlagsmithError::Config(format!("Invalid URL: {}", error)) + } +} + +/// Map FlagsmithError to OpenFeature EvaluationError +impl From for open_feature::EvaluationError { + fn from(error: FlagsmithError) -> Self { + use open_feature::EvaluationErrorCode; + + match error { + FlagsmithError::Config(msg) => open_feature::EvaluationError { + code: EvaluationErrorCode::General("Configuration error".to_string()), + message: Some(msg), + }, + FlagsmithError::Api(msg) => open_feature::EvaluationError { + code: EvaluationErrorCode::ProviderNotReady, + message: Some(msg), + }, + FlagsmithError::Evaluation(msg) => open_feature::EvaluationError { + code: EvaluationErrorCode::General("Evaluation error".to_string()), + message: Some(msg), + }, + FlagsmithError::FlagNotFound(msg) => open_feature::EvaluationError { + code: EvaluationErrorCode::FlagNotFound, + message: Some(msg), + }, + } + } +} diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs new file mode 100644 index 0000000..19a3a7b --- /dev/null +++ b/crates/flagsmith/src/lib.rs @@ -0,0 +1,641 @@ +//! Flagsmith Provider for OpenFeature +//! +//! A Rust implementation of the OpenFeature provider for Flagsmith, enabling dynamic +//! feature flag evaluation using the Flagsmith platform. +//! +//! # Overview +//! +//! This provider integrates the Flagsmith Rust SDK with OpenFeature, supporting both +//! environment-level and identity-specific flag evaluation. +//! +//! # Installation +//! +//! Add the dependency in your `Cargo.toml`: +//! ```toml +//! [dependencies] +//! open-feature-flagsmith = "0.1" +//! open-feature = "0.2" +//! ``` +//! +//! # Example Usage +//! +//! ```rust,no_run +//! use open_feature::provider::FeatureProvider; +//! use open_feature::EvaluationContext; +//! use open_feature_flagsmith::{FlagsmithProvider, FlagsmithOptions}; +//! +//! #[tokio::main] +//! async fn main() { +//! // Create provider with environment key +//! let provider = FlagsmithProvider::new( +//! "your-environment-key".to_string(), +//! FlagsmithOptions::default() +//! ).await.unwrap(); +//! +//! // Environment-level evaluation (no targeting) +//! let context = EvaluationContext::default(); +//! let result = provider.resolve_bool_value("my-feature", &context).await; +//! println!("Feature enabled: {}", result.unwrap().value); +//! +//! // Identity-specific evaluation (with targeting) +//! let context = EvaluationContext::default() +//! .with_targeting_key("user-123") +//! .with_custom_field("email", "user@example.com") +//! .with_custom_field("plan", "premium"); +//! +//! let result = provider.resolve_bool_value("my-feature", &context).await; +//! println!("Feature for user: {}", result.unwrap().value); +//! } +//! ``` + +pub mod error; + +use async_trait::async_trait; +use flagsmith::{Flagsmith, FlagsmithOptions as FlagsmithSDKOptions}; +use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; +use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::{ + EvaluationContext, EvaluationContextFieldValue, EvaluationError, EvaluationReason as Reason, + StructValue, Value, +}; +use serde_json::Value as JsonValue; +use std::fmt; +use std::sync::Arc; +use tracing::{debug, instrument}; + +// Re-export for convenience +pub use error::FlagsmithError; +pub use error::FlagsmithError as Error; + +/// Trait for Flagsmith client operations, enabling mockability in tests. +pub trait FlagsmithClient: Send + Sync { + fn get_environment_flags( + &self, + ) -> Result; + fn get_identity_flags( + &self, + identifier: &str, + traits: Option>, + transient: Option, + ) -> Result; +} + +impl FlagsmithClient for Flagsmith { + fn get_environment_flags( + &self, + ) -> Result { + self.get_environment_flags() + } + + fn get_identity_flags( + &self, + identifier: &str, + traits: Option>, + transient: Option, + ) -> Result { + self.get_identity_flags(identifier, traits, transient) + } +} + +/// Configuration options for the Flagsmith provider. +#[derive(Debug, Clone, Default)] +pub struct FlagsmithOptions { + /// Custom API URL (defaults to Flagsmith Edge API) + pub api_url: Option, + + /// Custom HTTP headers + pub custom_headers: Option, + + /// Request timeout in seconds + pub request_timeout_seconds: Option, + + /// Enable local evaluation mode (requires server-side key) + pub enable_local_evaluation: bool, + + /// Environment refresh interval in milliseconds (for local evaluation) + pub environment_refresh_interval_mills: Option, + + /// Enable analytics tracking + pub enable_analytics: bool, +} + +impl FlagsmithOptions { + /// Create a new FlagsmithOptions with default values + pub fn new() -> Self { + Self::default() + } + + /// Set a custom API URL + pub fn with_api_url(mut self, api_url: String) -> Self { + self.api_url = Some(api_url); + self + } + + /// Enable local evaluation mode + pub fn with_local_evaluation(mut self, enable: bool) -> Self { + self.enable_local_evaluation = enable; + self + } + + /// Enable analytics tracking + pub fn with_analytics(mut self, enable: bool) -> Self { + self.enable_analytics = enable; + self + } + + /// Set request timeout in seconds + pub fn with_timeout(mut self, seconds: u64) -> Self { + self.request_timeout_seconds = Some(seconds); + self + } +} + +/// The Flagsmith OpenFeature provider. +/// +/// This provider wraps the Flagsmith Rust SDK and implements the OpenFeature +/// `FeatureProvider` trait, enabling feature flag evaluation with OpenFeature. +pub struct FlagsmithProvider { + metadata: ProviderMetadata, + client: Arc, +} + +impl fmt::Debug for FlagsmithProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FlagsmithProvider") + .field("metadata", &self.metadata) + .field("client", &"") + .finish() + } +} + +impl FlagsmithProvider { + /// Creates a new Flagsmith provider instance. + /// + /// # Arguments + /// + /// * `environment_key` - Your Flagsmith environment API key + /// * `options` - Configuration options for the provider + /// + /// # Errors + /// + /// Returns `FlagsmithError::Config` if: + /// - The environment key is empty + /// - The API URL is invalid + /// - Local evaluation is enabled without a server-side key + /// + /// # Example + /// + /// ```rust,no_run + /// use open_feature_flagsmith::{FlagsmithProvider, FlagsmithOptions}; + /// + /// #[tokio::main] + /// async fn main() { + /// let provider = FlagsmithProvider::new( + /// "your-environment-key".to_string(), + /// FlagsmithOptions::default() + /// ).await.unwrap(); + /// } + /// ``` + #[instrument(skip(environment_key, options))] + pub async fn new( + environment_key: String, + options: FlagsmithOptions, + ) -> Result { + debug!("Initializing FlagsmithProvider"); + + // Validate environment key + if environment_key.is_empty() { + return Err(FlagsmithError::Config( + "Environment key cannot be empty".to_string(), + )); + } + + // Validate local evaluation requirements + if options.enable_local_evaluation && !environment_key.starts_with("ser.") { + return Err(FlagsmithError::Config( + "Local evaluation requires a server-side environment key (starts with 'ser.')" + .to_string(), + )); + } + + // Validate API URL if provided + if let Some(ref url_str) = options.api_url { + let parsed_url = url::Url::parse(url_str)?; + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err(FlagsmithError::Config(format!( + "Invalid API URL scheme '{}'. Only http and https are supported", + parsed_url.scheme() + ))); + } + } + + // Build Flagsmith SDK options + let mut sdk_options = FlagsmithSDKOptions::default(); + + if let Some(api_url) = options.api_url { + sdk_options.api_url = api_url; + } + + if let Some(custom_headers) = options.custom_headers { + sdk_options.custom_headers = custom_headers; + } + + if let Some(timeout) = options.request_timeout_seconds { + sdk_options.request_timeout_seconds = timeout; + } + + sdk_options.enable_local_evaluation = options.enable_local_evaluation; + sdk_options.enable_analytics = options.enable_analytics; + + if let Some(interval) = options.environment_refresh_interval_mills { + sdk_options.environment_refresh_interval_mills = interval; + } + + // Initialize Flagsmith client + // Use spawn_blocking because Flagsmith::new() creates threads and can conflict with tokio runtime + let client = + tokio::task::spawn_blocking(move || Flagsmith::new(environment_key, sdk_options)) + .await + .map_err(|e| { + error::FlagsmithError::Config(format!( + "Failed to initialize Flagsmith client: {}", + e + )) + })?; + + Ok(Self::from_client(Arc::new(client))) + } + + /// Creates a provider from an existing Flagsmith client. + /// + /// * `client` - An Arc-wrapped Flagsmith client instance + pub fn from_client(client: Arc) -> Self { + Self { + metadata: ProviderMetadata::new("flagsmith"), + client, + } + } + + /// Fetches flags from the Flagsmith client. + /// + /// This helper function handles both environment-level and identity-specific flag fetching + /// based on whether a targeting key is present in the evaluation context. + /// + /// # Arguments + /// + /// * `context` - The evaluation context containing targeting information + /// + /// # Returns + /// + /// Returns the flags object from Flagsmith, or an evaluation error if the operation fails. + async fn get_flags( + &self, + context: &EvaluationContext, + ) -> Result { + let client = Arc::clone(&self.client); + let targeting_key = context.targeting_key.clone(); + let traits = if targeting_key.is_some() { + Some(context_to_traits(context)) + } else { + None + }; + + Ok(tokio::task::spawn_blocking(move || { + if let Some(key) = targeting_key { + client.get_identity_flags(&key, traits, None) + } else { + client.get_environment_flags() + } + }) + .await + .map_err(|e| EvaluationError { + code: open_feature::EvaluationErrorCode::General("Task execution error".to_string()), + message: Some(format!("Failed to execute blocking task: {}", e)), + })? + .map_err(FlagsmithError::from)?) + } +} + +#[async_trait] +impl FeatureProvider for FlagsmithProvider { + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + #[instrument(skip(self, context))] + async fn resolve_bool_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + validate_flag_key(flag_key)?; + debug!("Resolving boolean flag: {}", flag_key); + + let flags = self.get_flags(context).await?; + + let enabled = flags + .is_feature_enabled(flag_key) + .map_err(FlagsmithError::from)?; + + let reason = determine_reason(context, enabled); + + Ok(ResolutionDetails { + value: enabled, + reason: Some(reason), + variant: None, + flag_metadata: None, + }) + } + + #[instrument(skip(self, context))] + async fn resolve_string_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + validate_flag_key(flag_key)?; + debug!("Resolving string flag: {}", flag_key); + + let flags = self.get_flags(context).await?; + + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; + + // Since all Flagsmith values are stored as strings internally, we can always + // return the value as a string regardless of the declared type + let value = flag.value.value.clone(); + let reason = determine_reason(context, flag.enabled); + + Ok(ResolutionDetails { + value, + reason: Some(reason), + variant: None, + flag_metadata: None, + }) + } + + #[instrument(skip(self, context))] + async fn resolve_int_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + validate_flag_key(flag_key)?; + debug!("Resolving integer flag: {}", flag_key); + + let flags = self.get_flags(context).await?; + + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; + + // Flagsmith stores all values as strings, so we try to parse regardless of value_type + // First check the declared type, then fall back to attempting string parsing + let value = match flag.value.value_type { + FlagsmithValueType::Integer | FlagsmithValueType::String => flag + .value + .value + .parse::() + .map_err(|e| EvaluationError { + code: open_feature::EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Failed to parse integer value '{}': {}", + flag.value.value, e + )), + })?, + _ => { + return Err(EvaluationError { + code: open_feature::EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Expected integer type, but got {:?}", + flag.value.value_type + )), + }); + } + }; + + let reason = determine_reason(context, flag.enabled); + + Ok(ResolutionDetails { + value, + reason: Some(reason), + variant: None, + flag_metadata: None, + }) + } + + #[instrument(skip(self, context))] + async fn resolve_float_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + validate_flag_key(flag_key)?; + debug!("Resolving float flag: {}", flag_key); + + let flags = self.get_flags(context).await?; + + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; + + // Flagsmith stores all values as strings, so we try to parse regardless of value_type + // First check the declared type, then fall back to attempting string parsing + let value = match flag.value.value_type { + FlagsmithValueType::Float | FlagsmithValueType::String => flag + .value + .value + .parse::() + .map_err(|e| EvaluationError { + code: open_feature::EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Failed to parse float value '{}': {}", + flag.value.value, e + )), + })?, + _ => { + return Err(EvaluationError { + code: open_feature::EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Expected float type, but got {:?}", + flag.value.value_type + )), + }); + } + }; + + let reason = determine_reason(context, flag.enabled); + + Ok(ResolutionDetails { + value, + reason: Some(reason), + variant: None, + flag_metadata: None, + }) + } + + #[instrument(skip(self, context))] + async fn resolve_struct_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + validate_flag_key(flag_key)?; + debug!("Resolving struct flag: {}", flag_key); + + let flags = self.get_flags(context).await?; + + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; + + if !matches!(flag.value.value_type, FlagsmithValueType::String) { + return Err(EvaluationError { + code: open_feature::EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Expected string type for JSON, but flag '{}' has type {:?}", + flag_key, flag.value.value_type + )), + }); + } + + let json_value: JsonValue = + serde_json::from_str(&flag.value.value).map_err(|e| EvaluationError { + code: open_feature::EvaluationErrorCode::ParseError, + message: Some(format!("Failed to parse JSON: {}", e)), + })?; + + let struct_value = match json_value { + JsonValue::Object(map) => { + let mut struct_map = std::collections::HashMap::new(); + for (key, json_val) in map { + // Filter out null values - absent fields are more semantically correct than empty strings + if !json_val.is_null() { + let of_value = json_to_open_feature_value(json_val); + struct_map.insert(key, of_value); + } + } + StructValue { fields: struct_map } + } + _ => { + return Err(EvaluationError { + code: open_feature::EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Expected JSON object, but got: {}", + flag.value.value + )), + }); + } + }; + + let reason = determine_reason(context, flag.enabled); + + Ok(ResolutionDetails { + value: struct_value, + reason: Some(reason), + variant: None, + flag_metadata: None, + }) + } +} + +/// Convert serde_json::Value to open_feature::Value. +pub fn json_to_open_feature_value(json_val: JsonValue) -> Value { + match json_val { + JsonValue::Null => Value::String(String::new()), + JsonValue::Bool(b) => Value::Bool(b), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Int(i) + } else if let Some(f) = n.as_f64() { + Value::Float(f) + } else { + Value::String(n.to_string()) + } + } + JsonValue::String(s) => Value::String(s), + JsonValue::Array(arr) => { + let values: Vec = arr.into_iter().map(json_to_open_feature_value).collect(); + Value::Array(values) + } + JsonValue::Object(map) => { + let mut fields = std::collections::HashMap::new(); + for (k, v) in map { + // Filter out null values - absent fields are more semantically correct than empty strings + if !v.is_null() { + fields.insert(k, json_to_open_feature_value(v)); + } + } + Value::Struct(StructValue { fields }) + } + } +} + +/// Validate that a flag key is not empty. +pub fn validate_flag_key(flag_key: &str) -> Result<(), EvaluationError> { + if flag_key.is_empty() { + return Err(EvaluationError { + code: open_feature::EvaluationErrorCode::General("Invalid flag key".to_string()), + message: Some("Flag key cannot be empty".to_string()), + }); + } + Ok(()) +} + +/// Convert OpenFeature EvaluationContext to Flagsmith traits. +/// +/// Maps custom_fields from the context into Flagsmith trait format, +/// converting each field value to the appropriate Flagsmith type. +/// +/// Note: Struct fields are not supported by Flagsmith traits and will be +/// filtered out with a warning logged. +pub fn context_to_traits( + context: &EvaluationContext, +) -> Vec { + context + .custom_fields + .iter() + .filter_map(|(key, value)| { + let flagsmith_value = match value { + EvaluationContextFieldValue::Bool(b) => Some(FlagsmithValue { + value: b.to_string(), + value_type: FlagsmithValueType::Bool, + }), + EvaluationContextFieldValue::String(s) => Some(FlagsmithValue { + value: s.clone(), + value_type: FlagsmithValueType::String, + }), + EvaluationContextFieldValue::Int(i) => Some(FlagsmithValue { + value: i.to_string(), + value_type: FlagsmithValueType::Integer, + }), + EvaluationContextFieldValue::Float(f) => Some(FlagsmithValue { + value: f.to_string(), + value_type: FlagsmithValueType::Float, + }), + EvaluationContextFieldValue::DateTime(dt) => Some(FlagsmithValue { + value: dt.to_string(), + value_type: FlagsmithValueType::String, + }), + EvaluationContextFieldValue::Struct(_) => { + tracing::warn!( + "Trait '{}': Struct values in evaluation context are not supported as Flagsmith traits and will be ignored.", + key + ); + None + } + }; + + flagsmith_value.map(|fv| flagsmith::flagsmith::models::SDKTrait::new(key.clone(), fv)) + }) + .collect() +} + +/// Determine the OpenFeature reason based on the evaluation context and flag state. +/// +/// Maps Flagsmith evaluation scenarios to OpenFeature reason codes: +/// - Identity evaluation (has targeting_key) � TargetingMatch +/// - Environment evaluation (no targeting_key) � Static +/// - Flag disabled � Disabled +pub fn determine_reason(context: &EvaluationContext, enabled: bool) -> Reason { + if !enabled { + Reason::Disabled + } else if context.targeting_key.is_some() { + Reason::TargetingMatch + } else { + Reason::Static + } +} diff --git a/crates/flagsmith/tests/error_tests.rs b/crates/flagsmith/tests/error_tests.rs new file mode 100644 index 0000000..5f8e6b3 --- /dev/null +++ b/crates/flagsmith/tests/error_tests.rs @@ -0,0 +1,91 @@ +use open_feature_flagsmith::error::FlagsmithError; + +/// This test validates that our FLAGSMITH_FLAG_NOT_FOUND_MSG constant matches +/// the actual error message returned by the Flagsmith SDK when a flag is not found. +/// +/// If this test fails, it means the Flagsmith SDK has changed its error message, +/// and we need to update our constant accordingly. +/// +/// This provides compile-time-like safety by ensuring that any SDK updates that +/// change the error message will be caught during CI/CD testing. +#[test] +fn test_flag_not_found_error_message_matches_sdk() { + use flagsmith::flagsmith::models::Flags; + + // Create an empty Flags object with no default handler + let flags = Flags::from_api_flags(&vec![], None, None).unwrap(); + + // Try to get a non-existent flag - this should return the SDK's "flag not found" error + let result = flags.get_flag("non_existent_flag"); + + // Verify the error is returned + assert!(result.is_err(), "Expected error for non-existent flag"); + + let error = result.unwrap_err(); + + // Verify the error kind is FlagsmithAPIError + assert_eq!( + error.kind, + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Expected FlagsmithAPIError for flag not found" + ); + + // Verify the error message is what we expect + assert_eq!( + error.msg, "API returned invalid response", + "SDK error message changed. Expected: 'API returned invalid response', got: '{}'", + error.msg + ); + + // Convert to our error type and verify it's FlagNotFound + let our_error: FlagsmithError = error.into(); + assert!( + matches!(our_error, FlagsmithError::FlagNotFound(_)), + "SDK flag not found error should convert to FlagsmithError::FlagNotFound" + ); +} + +/// Test that our FlagsmithError conversion correctly identifies flag not found errors +#[test] +fn test_flagsmith_error_conversion_flag_not_found() { + let sdk_error = flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "API returned invalid response".to_string(), + ); + + let our_error: FlagsmithError = sdk_error.into(); + + assert!( + matches!(our_error, FlagsmithError::FlagNotFound(_)), + "SDK flag not found error should convert to FlagsmithError::FlagNotFound" + ); +} + +/// Test that other API errors are not mistaken for flag not found +#[test] +fn test_flagsmith_error_conversion_other_api_errors() { + let sdk_error = flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Some other API error".to_string(), + ); + + let our_error: FlagsmithError = sdk_error.into(); + + assert!( + matches!(our_error, FlagsmithError::Api(_)), + "Other API errors should convert to FlagsmithError::Api" + ); +} + +/// Test OpenFeature error code mapping for flag not found +#[test] +fn test_open_feature_error_mapping_flag_not_found() { + let our_error = FlagsmithError::FlagNotFound("test message".to_string()); + let of_error: open_feature::EvaluationError = our_error.into(); + + assert_eq!( + of_error.code, + open_feature::EvaluationErrorCode::FlagNotFound, + "FlagNotFound should map to EvaluationErrorCode::FlagNotFound" + ); +} diff --git a/crates/flagsmith/tests/integration_tests.rs b/crates/flagsmith/tests/integration_tests.rs new file mode 100644 index 0000000..a6222f6 --- /dev/null +++ b/crates/flagsmith/tests/integration_tests.rs @@ -0,0 +1,366 @@ +use flagsmith::flagsmith::models::Flags; +use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; +use open_feature::provider::FeatureProvider; +use open_feature::{EvaluationContext, EvaluationReason as Reason}; +use open_feature_flagsmith::{FlagsmithClient, FlagsmithProvider}; +use serde_json; +use std::collections::HashMap; +use std::sync::Arc; + +struct MockFlagsmithClient { + environment_flags: Option>, + identity_flags: Option>, + should_error: bool, +} + +impl MockFlagsmithClient { + fn new() -> Self { + Self { + environment_flags: None, + identity_flags: None, + should_error: false, + } + } + + fn with_environment_flags(mut self, flags: HashMap) -> Self { + self.environment_flags = Some(flags); + self + } + + fn with_identity_flags(mut self, flags: HashMap) -> Self { + self.identity_flags = Some(flags); + self + } + + fn with_error(mut self) -> Self { + self.should_error = true; + self + } + + fn build_flags(&self, flag_map: &HashMap) -> Flags { + let api_flags: Vec = flag_map + .iter() + .map(|(name, (value, enabled))| { + let json_value = match value.value_type { + FlagsmithValueType::Integer => { + serde_json::Value::Number(value.value.parse::().unwrap().into()) + } + FlagsmithValueType::Float => serde_json::Value::Number( + serde_json::Number::from_f64(value.value.parse::().unwrap()).unwrap(), + ), + FlagsmithValueType::Bool => { + serde_json::Value::Bool(value.value.parse::().unwrap()) + } + _ => serde_json::Value::String(value.value.clone()), + }; + + serde_json::json!({ + "id": 1, + "feature": { + "id": 1, + "name": name, + "type": "STANDARD" + }, + "feature_state_value": json_value, + "enabled": enabled, + "environment": 1, + "identity": null, + "feature_segment": null + }) + }) + .collect(); + + Flags::from_api_flags(&api_flags, None, None).expect("Failed to create mock flags") + } +} + +impl FlagsmithClient for MockFlagsmithClient { + fn get_environment_flags(&self) -> Result { + if self.should_error { + return Err(flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Mock API error".to_string(), + )); + } + + if let Some(ref flag_map) = self.environment_flags { + Ok(self.build_flags(flag_map)) + } else { + Err(flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Not configured".to_string(), + )) + } + } + + fn get_identity_flags( + &self, + _identifier: &str, + _traits: Option>, + _transient: Option, + ) -> Result { + if self.should_error { + return Err(flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Mock API error".to_string(), + )); + } + + if let Some(ref flag_map) = self.identity_flags { + Ok(self.build_flags(flag_map)) + } else { + Err(flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Not configured".to_string(), + )) + } + } +} + +fn create_mock_flags( + configs: Vec<(&str, FlagsmithValue, bool)>, +) -> HashMap { + configs + .into_iter() + .map(|(name, value, enabled)| (name.to_string(), (value, enabled))) + .collect() +} + +#[tokio::test] +async fn test_resolve_bool_value_enabled() { + let flags = create_mock_flags(vec![( + "test-flag", + FlagsmithValue { + value: "true".to_string(), + value_type: FlagsmithValueType::Bool, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_bool_value("test-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert_eq!(details.value, true); + assert_eq!(details.reason, Some(Reason::Static)); +} + +#[tokio::test] +async fn test_resolve_bool_value_disabled() { + let flags = create_mock_flags(vec![( + "test-flag", + FlagsmithValue { + value: "false".to_string(), + value_type: FlagsmithValueType::Bool, + }, + false, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_bool_value("test-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert_eq!(details.value, false); + assert_eq!(details.reason, Some(Reason::Disabled)); +} + +#[tokio::test] +async fn test_resolve_bool_with_targeting() { + let flags = create_mock_flags(vec![( + "test-flag", + FlagsmithValue { + value: "true".to_string(), + value_type: FlagsmithValueType::Bool, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_identity_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default() + .with_targeting_key("user-123") + .with_custom_field("email", "user@example.com"); + + let result = provider.resolve_bool_value("test-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert_eq!(details.value, true); + assert_eq!(details.reason, Some(Reason::TargetingMatch)); +} + +#[tokio::test] +async fn test_resolve_string_value() { + let flags = create_mock_flags(vec![( + "color-flag", + FlagsmithValue { + value: "blue".to_string(), + value_type: FlagsmithValueType::String, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_string_value("color-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert_eq!(details.value, "blue"); +} + +#[tokio::test] +async fn test_resolve_int_value() { + let flags = create_mock_flags(vec![( + "limit-flag", + FlagsmithValue { + value: "42".to_string(), + value_type: FlagsmithValueType::Integer, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_int_value("limit-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert_eq!(details.value, 42); +} + +#[tokio::test] +async fn test_resolve_float_value() { + let flags = create_mock_flags(vec![( + "rate-flag", + FlagsmithValue { + value: "3.14".to_string(), + value_type: FlagsmithValueType::Float, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_float_value("rate-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert!((details.value - 3.14).abs() < 0.001); +} + +#[tokio::test] +async fn test_resolve_struct_value() { + let flags = create_mock_flags(vec![( + "config-flag", + FlagsmithValue { + value: r#"{"name": "test", "count": 10, "active": true}"#.to_string(), + value_type: FlagsmithValueType::String, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_struct_value("config-flag", &context).await; + + assert!(result.is_ok()); + let details = result.unwrap(); + assert_eq!(details.value.fields.len(), 3); + assert!(details.value.fields.contains_key("name")); + assert!(details.value.fields.contains_key("count")); + assert!(details.value.fields.contains_key("active")); + assert_eq!(details.reason, Some(Reason::Static)); +} + +#[tokio::test] +async fn test_resolve_struct_value_type_mismatch() { + // Test that resolve_struct_value rejects non-String types + let flags = create_mock_flags(vec![( + "int-flag", + FlagsmithValue { + value: "42".to_string(), + value_type: FlagsmithValueType::Integer, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_struct_value("int-flag", &context).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.code, open_feature::EvaluationErrorCode::TypeMismatch); + assert!( + error + .message + .unwrap() + .contains("Expected string type for JSON") + ); +} + +#[tokio::test] +async fn test_resolve_flag_not_found() { + let flags = create_mock_flags(vec![]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider + .resolve_bool_value("non-existent-flag", &context) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_resolve_type_mismatch() { + let flags = create_mock_flags(vec![( + "test-flag", + FlagsmithValue { + value: "not-a-number".to_string(), + value_type: FlagsmithValueType::String, + }, + true, + )]); + + let mock_client = MockFlagsmithClient::new().with_environment_flags(flags); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_int_value("test-flag", &context).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_resolve_api_error() { + let mock_client = MockFlagsmithClient::new().with_error(); + let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); + + let context = EvaluationContext::default(); + let result = provider.resolve_bool_value("test-flag", &context).await; + + assert!(result.is_err()); +} diff --git a/crates/flagsmith/tests/unit_tests.rs b/crates/flagsmith/tests/unit_tests.rs new file mode 100644 index 0000000..a8eff0f --- /dev/null +++ b/crates/flagsmith/tests/unit_tests.rs @@ -0,0 +1,256 @@ +use flagsmith::{Flagsmith, FlagsmithOptions as FlagsmithSDKOptions}; +use open_feature::provider::FeatureProvider; +use open_feature::{ + EvaluationContext, EvaluationContextFieldValue, EvaluationReason as Reason, StructValue, Value, +}; +use open_feature_flagsmith::{FlagsmithError, FlagsmithProvider}; +use std::collections::HashMap; +use std::sync::Arc; +use test_log::test; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_empty_environment_key_fails() { + use open_feature_flagsmith::FlagsmithOptions; + + let result = FlagsmithProvider::new("".to_string(), FlagsmithOptions::default()).await; + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + FlagsmithError::Config("Environment key cannot be empty".to_string()) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_local_evaluation_without_server_key_fails() { + use open_feature_flagsmith::FlagsmithOptions; + + let result = FlagsmithProvider::new( + "regular-key".to_string(), + FlagsmithOptions::default().with_local_evaluation(true), + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + FlagsmithError::Config(msg) => { + assert!(msg.contains("server-side environment key")); + } + _ => panic!("Expected Config error"), + } +} + +#[test] +fn test_context_to_traits() { + let context = EvaluationContext::default() + .with_custom_field("email", "user@example.com") + .with_custom_field("age", 25) + .with_custom_field("premium", true) + .with_custom_field("score", 98.5); + + let traits = open_feature_flagsmith::context_to_traits(&context); + + assert_eq!(traits.len(), 4); + + let trait_keys: Vec = traits.iter().map(|t| t.trait_key.clone()).collect(); + assert!(trait_keys.contains(&"email".to_string())); + assert!(trait_keys.contains(&"age".to_string())); + assert!(trait_keys.contains(&"premium".to_string())); + assert!(trait_keys.contains(&"score".to_string())); +} + +#[test] +fn test_context_to_traits_filters_struct_fields() { + let mut struct_fields = HashMap::new(); + struct_fields.insert( + "nested_field".to_string(), + Value::String("value".to_string()), + ); + let struct_value = StructValue { + fields: struct_fields, + }; + + let mut context = EvaluationContext::default() + .with_custom_field("email", "user@example.com") + .with_custom_field("age", 25); + + context.custom_fields.insert( + "metadata".to_string(), + EvaluationContextFieldValue::Struct(Arc::new(struct_value)), + ); + + let traits = open_feature_flagsmith::context_to_traits(&context); + + assert_eq!(traits.len(), 2); + + let trait_keys: Vec = traits.iter().map(|t| t.trait_key.clone()).collect(); + assert!(trait_keys.contains(&"email".to_string())); + assert!(trait_keys.contains(&"age".to_string())); + assert!(!trait_keys.contains(&"metadata".to_string())); +} + +#[test] +fn test_determine_reason_disabled() { + let context = EvaluationContext::default(); + let reason = open_feature_flagsmith::determine_reason(&context, false); + assert_eq!(reason, Reason::Disabled); +} + +#[test] +fn test_determine_reason_targeting_match() { + let context = EvaluationContext::default().with_targeting_key("user-123"); + let reason = open_feature_flagsmith::determine_reason(&context, true); + assert_eq!(reason, Reason::TargetingMatch); +} + +#[test] +fn test_determine_reason_static() { + let context = EvaluationContext::default(); + let reason = open_feature_flagsmith::determine_reason(&context, true); + assert_eq!(reason, Reason::Static); +} + +#[test] +fn test_metadata() { + let provider = FlagsmithProvider::from_client(Arc::new(Flagsmith::new( + "test-key".to_string(), + FlagsmithSDKOptions::default(), + ))); + + assert_eq!(provider.metadata().name, "flagsmith"); +} + +#[test] +fn test_validate_flag_key_empty() { + let result = open_feature_flagsmith::validate_flag_key(""); + assert!(result.is_err()); + if let Err(err) = result { + assert!(err.message.unwrap().contains("empty")); + } +} + +#[test] +fn test_validate_flag_key_valid() { + let result = open_feature_flagsmith::validate_flag_key("my-flag"); + assert!(result.is_ok()); +} + +#[test] +fn test_json_to_open_feature_value_primitives() { + let json_null = serde_json::json!(null); + let json_bool = serde_json::json!(true); + let json_int = serde_json::json!(42); + let json_float = serde_json::json!(3.14); + let json_string = serde_json::json!("hello"); + + assert!(matches!( + open_feature_flagsmith::json_to_open_feature_value(json_null), + Value::String(_) + )); + assert!(matches!( + open_feature_flagsmith::json_to_open_feature_value(json_bool), + Value::Bool(true) + )); + assert!(matches!( + open_feature_flagsmith::json_to_open_feature_value(json_int), + Value::Int(42) + )); + + if let Value::Float(f) = open_feature_flagsmith::json_to_open_feature_value(json_float) { + assert!((f - 3.14).abs() < 0.001); + } else { + panic!("Expected Float value"); + } + + if let Value::String(s) = open_feature_flagsmith::json_to_open_feature_value(json_string) { + assert_eq!(s, "hello"); + } else { + panic!("Expected String value"); + } +} + +#[test] +fn test_json_to_open_feature_value_array() { + let json_array = serde_json::json!([1, 2, 3]); + + if let Value::Array(arr) = open_feature_flagsmith::json_to_open_feature_value(json_array) { + assert_eq!(arr.len(), 3); + assert!(matches!(arr[0], Value::Int(1))); + assert!(matches!(arr[1], Value::Int(2))); + assert!(matches!(arr[2], Value::Int(3))); + } else { + panic!("Expected Array value"); + } +} + +#[test] +fn test_json_to_open_feature_value_object() { + let json_object = serde_json::json!({ + "name": "test", + "count": 10, + "active": true + }); + + if let Value::Struct(s) = open_feature_flagsmith::json_to_open_feature_value(json_object) { + assert_eq!(s.fields.len(), 3); + assert!(s.fields.contains_key("name")); + assert!(s.fields.contains_key("count")); + assert!(s.fields.contains_key("active")); + } else { + panic!("Expected Struct value"); + } +} + +#[test] +fn test_json_to_open_feature_value_object_filters_nulls() { + // Test that null values in objects are filtered out + let json_object = serde_json::json!({ + "name": "test", + "email": null, + "count": 10, + "phone": null, + "active": true + }); + + if let Value::Struct(s) = open_feature_flagsmith::json_to_open_feature_value(json_object) { + // Should only have 3 fields (email and phone filtered out) + assert_eq!(s.fields.len(), 3); + assert!(s.fields.contains_key("name")); + assert!(s.fields.contains_key("count")); + assert!(s.fields.contains_key("active")); + // Null fields should not be present + assert!(!s.fields.contains_key("email")); + assert!(!s.fields.contains_key("phone")); + } else { + panic!("Expected Struct value"); + } +} + +#[test] +fn test_json_to_open_feature_value_nested() { + let json_nested = serde_json::json!({ + "user": { + "name": "Alice", + "age": 30 + }, + "tags": ["admin", "user"] + }); + + if let Value::Struct(s) = open_feature_flagsmith::json_to_open_feature_value(json_nested) { + assert_eq!(s.fields.len(), 2); + + if let Some(Value::Struct(user)) = s.fields.get("user") { + assert_eq!(user.fields.len(), 2); + } else { + panic!("Expected nested struct for user"); + } + + if let Some(Value::Array(tags)) = s.fields.get("tags") { + assert_eq!(tags.len(), 2); + } else { + panic!("Expected array for tags"); + } + } else { + panic!("Expected Struct value"); + } +}