From b156c5c532cc1dc150f2f9be42ba38b1487202a8 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 20 Nov 2025 16:07:32 +0100 Subject: [PATCH 01/20] feat: add-flagsmith-crates Signed-off-by: wadii --- Cargo.toml | 1 + context.md | 782 ++++++++++++++++++++ crates/flagsmith/CHANGELOG.md | 51 ++ crates/flagsmith/Cargo.toml | 47 ++ crates/flagsmith/README.md | 156 ++++ crates/flagsmith/src/error.rs | 83 +++ crates/flagsmith/src/lib.rs | 871 +++++++++++++++++++++++ crates/flagsmith/tests/provider_tests.rs | 335 +++++++++ 8 files changed, 2326 insertions(+) create mode 100644 context.md create mode 100644 crates/flagsmith/CHANGELOG.md create mode 100644 crates/flagsmith/Cargo.toml create mode 100644 crates/flagsmith/README.md create mode 100644 crates/flagsmith/src/error.rs create mode 100644 crates/flagsmith/src/lib.rs create mode 100644 crates/flagsmith/tests/provider_tests.rs 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/context.md b/context.md new file mode 100644 index 0000000..23b1447 --- /dev/null +++ b/context.md @@ -0,0 +1,782 @@ +# OpenFeature Rust SDK Contributions - Repository Context + +**Repository**: https://github.com/open-feature/rust-sdk-contrib +**Purpose**: OpenFeature provider implementations for Rust +**Last Updated**: 2025-11-19 + +--- + +## 1. Repository Structure + +### 1.1 Project Organization + +This is the OpenFeature Rust SDK contributions repository. Each provider lives in its own crate under `crates/`: + +``` +rust-sdk-contrib/ +├── crates/ +│ ├── env-var/ # Environment variable provider +│ ├── flagd/ # flagd provider (comprehensive reference) +│ ├── flipt/ # Flipt provider (simpler reference) +│ └── ofrep/ # OFREP provider (HTTP-based reference) +├── Cargo.toml # Workspace definition +├── CONTRIBUTING.md # Contribution guidelines +├── README.md +├── release-please-config.json +└── renovate.json +``` + +### 1.2 Workspace Configuration + +The root `Cargo.toml` defines a workspace with all provider crates as members: + +```toml +[workspace] +members = [ + "crates/env-var", + "crates/flagd", + "crates/flipt", + "crates/ofrep" +] +``` + +**Key settings**: +- **Edition**: Rust 2024 +- **License**: Apache 2.0 + +--- + +## 2. Contributing Guidelines + +From `CONTRIBUTING.md`: + +### 2.1 Project Hierarchy + +Each contrib must be placed under `crates/`: +- Create a new directory: `crates//` +- Add to workspace in root `Cargo.toml` +- Follow standard Rust crate structure + +### 2.2 Coding Style + +**Requirements**: +1. Add comments and tests for publicly exposed APIs +2. Follow Clippy rules from [rust-sdk/src/lib.rs](https://github.com/open-feature/rust-sdk/blob/main/src/lib.rs) + +**Best practices** (from existing providers): +- Use `tracing` for logging (not `log`) +- Use `thiserror` for error types +- Use `async-trait` for async trait implementations +- Document public APIs with doc comments +- Include usage examples in doc comments + +--- + +## 3. Development Setup + +Based on `crates/flagd/docs/contributing.md`: + +### 3.1 Building + +```bash +# Navigate to your crate directory +cd crates/ + +# Build the crate +cargo build + +# Build entire workspace (from root) +cargo build --workspace +``` + +### 3.2 Testing + +**Unit tests** (no external dependencies): +```bash +# Run unit tests only +cargo test --lib + +# Run with full logging +RUST_LOG_SPAN_EVENTS=full RUST_LOG=debug cargo test -- --nocapture +``` + +**Integration tests** (may require Docker): +```bash +# Run all tests (including E2E) +cargo test + +# Note: E2E tests use testcontainers-rs +# Docker is required (podman not currently supported) +``` + +### 3.3 Development Dependencies + +Common dev dependencies across providers: +- `test-log = "0.2"` - For tracing logs in tests +- `tracing-subscriber` - For test logging +- `testcontainers` - For E2E tests with Docker (optional) + +--- + +## 4. OpenFeature Provider Interface + +### 4.1 Required Trait Implementation + +All providers must implement the `FeatureProvider` trait from `open-feature` crate: + +```rust +use async_trait::async_trait; +use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::{EvaluationContext, EvaluationError, StructValue}; + +#[async_trait] +pub trait FeatureProvider { + // Required: Provider metadata + fn metadata(&self) -> &ProviderMetadata; + + // Required: Five evaluation methods for different types + async fn resolve_bool_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError>; + + async fn resolve_int_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError>; + + async fn resolve_float_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError>; + + async fn resolve_string_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError>; + + async fn resolve_struct_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError>; +} +``` + +### 4.2 Key Types + +**EvaluationContext**: +```rust +pub struct EvaluationContext { + pub targeting_key: Option, // User identifier + pub custom_fields: HashMap, // Additional attributes +} + +// Usage +let context = EvaluationContext::default() + .with_targeting_key("user-123") + .with_custom_field("email", "user@example.com") + .with_custom_field("plan", "premium"); +``` + +**ResolutionDetails**: +```rust +pub struct ResolutionDetails { + pub value: T, // The evaluated flag value + pub reason: Option, // Why this value was returned + pub variant: Option, // Variant identifier + pub error_code: Option, // Error code if applicable + pub error_message: Option, // Error message if applicable + pub flag_metadata: Option>, // Additional metadata +} + +// Simple construction +ResolutionDetails::new(true) // Just the value + +// With reason +ResolutionDetails { + value: true, + reason: Some(Reason::TargetingMatch), + ..Default::default() +} +``` + +**EvaluationError**: +```rust +pub struct EvaluationError { + pub code: EvaluationErrorCode, + pub message: Option, +} + +pub enum EvaluationErrorCode { + FlagNotFound, // Flag doesn't exist + ParseError, // Failed to parse value + TypeMismatch, // Wrong type returned + TargetingKeyMissing, // Required targeting_key not provided + InvalidContext, // Invalid evaluation context + ProviderNotReady, // Provider not initialized or unavailable + General(String), // Other errors +} +``` + +**Reason** (from OpenFeature spec): +```rust +pub enum Reason { + Static, // Flag evaluated without targeting + TargetingMatch, // Flag evaluated with targeting rules + Disabled, // Flag is disabled + Cached, // Value returned from cache + Default, // Default value returned (error case) + Error, // Error occurred during evaluation + Unknown, // Reason unknown +} +``` + +--- + +## 5. Common Provider Patterns + +### 5.1 Standard Crate Structure + +``` +crates// +├── Cargo.toml +├── README.md +├── CHANGELOG.md +├── src/ +│ ├── lib.rs # Main provider + re-exports +│ ├── error.rs # Custom error types (optional) +│ ├── resolver.rs # Core evaluation logic (optional) +│ └── utils.rs # Helper functions (optional) +├── tests/ +│ ├── integration_test.rs +│ └── fixtures/ +└── examples/ + └── basic_usage.rs +``` + +### 5.2 Provider Implementation Pattern + +**Basic structure** (from OFREP and Flipt providers): + +```rust +// lib.rs +mod error; // Optional: Custom error types + +use async_trait::async_trait; +use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; +use open_feature::{EvaluationContext, EvaluationError}; + +// Configuration struct +#[derive(Debug, Clone)] +pub struct ProviderOptions { + pub required_field: String, + pub optional_field: Option, +} + +impl Default for ProviderOptions { + fn default() -> Self { + ProviderOptions { + required_field: "default_value".to_string(), + optional_field: None, + } + } +} + +// Main provider struct +pub struct MyProvider { + metadata: ProviderMetadata, + client: SdkClient, // Feature flag SDK client +} + +impl MyProvider { + /// Creates a new provider instance + pub async fn new(options: ProviderOptions) -> Result { + // 1. Validate configuration + // 2. Initialize SDK client + // 3. Return provider instance + Ok(Self { + metadata: ProviderMetadata::new("my-provider"), + client: SdkClient::new(options)?, + }) + } +} + +#[async_trait] +impl FeatureProvider for MyProvider { + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn resolve_bool_value( + &self, + flag_key: &str, + context: &EvaluationContext, + ) -> Result, EvaluationError> { + // Implementation + } + + // ... other resolve methods +} +``` + +### 5.3 Configuration Patterns + +**Pattern 1 - Simple struct (OFREP)**: +```rust +#[derive(Debug, Clone)] +pub struct OfrepOptions { + pub base_url: String, + pub headers: HeaderMap, + pub connect_timeout: Duration, +} + +impl Default for OfrepOptions { + fn default() -> Self { + OfrepOptions { + base_url: "http://localhost:8016".to_string(), + headers: HeaderMap::new(), + connect_timeout: Duration::from_secs(10), + } + } +} +``` + +**Pattern 2 - Generic config (Flipt)**: +```rust +pub struct Config +where + A: AuthenticationStrategy, +{ + pub url: String, + pub authentication_strategy: A, + pub timeout: u64, +} +``` + +**Pattern 3 - Builder pattern (flagd)**: +```rust +let options = FlagdOptions::builder() + .host("localhost") + .port(8013) + .resolver_type(ResolverType::Rpc) + .build()?; +``` + +### 5.4 Error Handling Pattern + +**Define custom errors with thiserror**: +```rust +// error.rs +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum MyProviderError { + #[error("Provider error: {0}")] + Provider(String), + + #[error("Connection error: {0}")] + Connection(String), + + #[error("Invalid configuration: {0}")] + Config(String), + + #[error("Flag not found: {0}")] + FlagNotFound(String), +} +``` + +**Map to OpenFeature errors**: +```rust +fn map_error(err: SdkError) -> EvaluationError { + match err { + SdkError::NotFound(flag) => EvaluationError { + code: EvaluationErrorCode::FlagNotFound, + message: Some(format!("Flag '{}' not found", flag)), + }, + SdkError::NetworkError(e) => EvaluationError { + code: EvaluationErrorCode::ProviderNotReady, + message: Some(format!("Network error: {}", e)), + }, + SdkError::ParseError(e) => EvaluationError { + code: EvaluationErrorCode::ParseError, + message: Some(e), + }, + _ => EvaluationError { + code: EvaluationErrorCode::General("Unknown error".to_string()), + message: Some(err.to_string()), + }, + } +} +``` + +**Error handling principles**: +- Never panic from evaluation methods +- Always return `Result` with appropriate error code +- Include descriptive error messages +- Use `tracing` for debug logging +- Map SDK-specific errors to OpenFeature error codes + +### 5.5 Context Mapping Pattern + +Feature flag SDKs often have their own context format. Map OpenFeature context to SDK format: + +```rust +// Example from Flipt provider +fn translate_context(ctx: &EvaluationContext) -> HashMap { + ctx.custom_fields + .iter() + .map(|(k, v)| { + let value = match v { + EvaluationContextFieldValue::Bool(b) => b.to_string(), + EvaluationContextFieldValue::String(s) => s.clone(), + EvaluationContextFieldValue::Int(i) => i.to_string(), + EvaluationContextFieldValue::Float(f) => f.to_string(), + // ... handle other types + }; + (k.clone(), value) + }) + .collect() +} +``` + +### 5.6 Type Conversion Patterns + +**Boolean** (direct mapping): +```rust +async fn resolve_bool_value( + &self, + flag_key: &str, + ctx: &EvaluationContext, +) -> Result, EvaluationError> { + self.client + .evaluate_boolean(flag_key, ctx) + .await + .map_err(map_error) + .map(|result| ResolutionDetails::new(result.enabled)) +} +``` + +**Integer/Float** (with parsing): +```rust +async fn resolve_int_value( + &self, + flag_key: &str, + ctx: &EvaluationContext, +) -> Result, EvaluationError> { + let result = self.client.get_value(flag_key, ctx).await?; + + result.value + .parse::() + .map(ResolutionDetails::new) + .map_err(|e| EvaluationError { + code: EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Expected i64, but got '{}': {}", + result.value, e + )), + }) +} +``` + +**String** (direct or conversion): +```rust +async fn resolve_string_value( + &self, + flag_key: &str, + ctx: &EvaluationContext, +) -> Result, EvaluationError> { + self.client + .get_value(flag_key, ctx) + .await + .map_err(map_error) + .map(|result| ResolutionDetails::new(result.value)) +} +``` + +**Struct** (JSON parsing): +```rust +async fn resolve_struct_value( + &self, + flag_key: &str, + ctx: &EvaluationContext, +) -> Result, EvaluationError> { + let result = self.client.get_value(flag_key, ctx).await?; + + // Parse JSON string to Value + let value: Value = serde_json::from_str(&result.value) + .map_err(|e| EvaluationError { + code: EvaluationErrorCode::ParseError, + message: Some(format!("Failed to parse JSON: {}", e)), + })?; + + // Ensure it's a struct/object + match value { + Value::Struct(struct_value) => { + Ok(ResolutionDetails::new(struct_value)) + } + _ => Err(EvaluationError { + code: EvaluationErrorCode::TypeMismatch, + message: Some(format!( + "Expected object, but got: {}", + result.value + )), + }), + } +} +``` + +--- + +## 6. Testing Patterns + +### 6.1 Test Structure + +```rust +#[cfg(test)] +mod tests { + use super::*; + use test_log::test; // For tracing logs in tests + + #[test(tokio::test)] + async fn test_bool_evaluation() { + // Arrange + let provider = MyProvider::new(options).await.unwrap(); + let context = EvaluationContext::default() + .with_targeting_key("user-123"); + + // Act + let result = provider + .resolve_bool_value("my-flag", &context) + .await; + + // Assert + assert!(result.is_ok()); + assert_eq!(result.unwrap().value, true); + } + + #[test(tokio::test)] + async fn test_flag_not_found() { + let provider = MyProvider::new(options).await.unwrap(); + let context = EvaluationContext::default(); + + let result = provider + .resolve_bool_value("non-existent", &context) + .await; + + assert!(result.is_err()); + match result.unwrap_err().code { + EvaluationErrorCode::FlagNotFound => {}, + _ => panic!("Expected FlagNotFound error"), + } + } +} +``` + +### 6.2 Test Logging + +Enable detailed logs during tests: + +```bash +# Full tracing output +RUST_LOG_SPAN_EVENTS=full RUST_LOG=debug cargo test -- --nocapture + +# Specific module +RUST_LOG=my_provider=debug cargo test -- --nocapture +``` + +### 6.3 Integration Testing + +Use `testcontainers-rs` for E2E tests with Docker: + +```rust +#[cfg(test)] +mod integration_tests { + use testcontainers::{clients, images}; + + #[tokio::test] + async fn test_against_real_service() { + let docker = clients::Cli::default(); + let container = docker.run(images::generic::GenericImage::new( + "my-service", + "latest" + )); + + let port = container.get_host_port_ipv4(8080); + let provider = MyProvider::new(ProviderOptions { + host: "localhost".to_string(), + port, + ..Default::default() + }).await.unwrap(); + + // Run tests against real service + } +} +``` + +--- + +## 7. Documentation Standards + +### 7.1 README Structure + +Each provider should have a README with: + +1. **Title and brief description** +2. **Installation instructions** +3. **Basic usage example** +4. **Configuration options table** +5. **Advanced usage examples** (optional) +6. **Testing instructions** +7. **License** + +### 7.2 Inline Documentation + +Use doc comments for public APIs: + +```rust +/// A feature flag provider for [Service Name]. +/// +/// This provider enables dynamic feature flag evaluation using the +/// [Service Name] platform. +/// +/// # Example +/// +/// ```rust +/// use my_provider::{MyProvider, MyOptions}; +/// +/// #[tokio::main] +/// async fn main() { +/// let provider = MyProvider::new(MyOptions { +/// api_key: "your-key".to_string(), +/// ..Default::default() +/// }).await.unwrap(); +/// } +/// ``` +pub struct MyProvider { + // ... +} +``` + +### 7.3 cargo-readme + +Consider using `cargo-readme` to generate README from doc comments: + +```bash +cargo install cargo-readme +cargo readme --no-title --no-license > README.md +``` + +Pattern seen in OFREP provider: +```rust +//! [Generated by cargo-readme: `cargo readme --no-title --no-license > README.md`]:: +//! # OFREP Provider for OpenFeature +//! +//! A Rust implementation of... +``` + +--- + +## 8. Common Dependencies + +### 8.1 Runtime Dependencies + +```toml +[dependencies] +# OpenFeature SDK +open-feature = "0.x.x" + +# Async runtime +tokio = { version = "1", features = ["full"] } +async-trait = "0.1" + +# Error handling +thiserror = "1.0" +anyhow = "1.0" # Optional + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Logging +tracing = "0.1" + +# HTTP client (if needed) +reqwest = { version = "0.11", features = ["json"] } + +# URL parsing (if needed) +url = "2.0" +``` + +### 8.2 Development Dependencies + +```toml +[dev-dependencies] +# Testing +test-log = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Integration testing +testcontainers = "0.15" # Optional, if using Docker + +# Mocking +mockito = "1.0" # Optional, for HTTP mocking +``` + +--- + +## 9. References + +### 9.1 OpenFeature Resources + +- **OpenFeature Specification**: https://openfeature.dev/specification/ +- **Provider Specification**: https://openfeature.dev/specification/sections/providers +- **Rust SDK Repository**: https://github.com/open-feature/rust-sdk +- **Rust SDK Documentation**: https://docs.rs/open-feature/ + +### 9.2 Repository Resources + +- **Contributing Guide**: `CONTRIBUTING.md` +- **flagd Provider** (comprehensive): `crates/flagd/` +- **OFREP Provider** (HTTP-based): `crates/ofrep/` +- **Flipt Provider** (simple): `crates/flipt/` +- **env-var Provider** (basic): `crates/env-var/` + +### 9.3 Rust Resources + +- **async-trait**: https://docs.rs/async-trait/ +- **thiserror**: https://docs.rs/thiserror/ +- **tracing**: https://docs.rs/tracing/ +- **tokio**: https://docs.rs/tokio/ + +--- + +## 10. Quick Start Checklist + +When creating a new provider: + +- [ ] Create directory: `crates//` +- [ ] Setup `Cargo.toml` with dependencies +- [ ] Add to workspace in root `Cargo.toml` +- [ ] Create basic structure: `src/lib.rs`, `src/error.rs` +- [ ] Define configuration struct with `Default` trait +- [ ] Implement provider constructor with validation +- [ ] Implement `FeatureProvider` trait (5 methods) +- [ ] Add error mapping function +- [ ] Write unit tests for each method +- [ ] Create usage example in `examples/` +- [ ] Write README with installation and examples +- [ ] Add CHANGELOG.md +- [ ] Test with `cargo test --lib` +- [ ] Test with full suite: `cargo test` +- [ ] Run clippy: `cargo clippy --all-targets` +- [ ] Format code: `cargo fmt` + +--- + +**Last Updated**: 2025-11-19 +**Maintainers**: OpenFeature Contributors 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..42ce068 --- /dev/null +++ b/crates/flagsmith/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "open-feature-flagsmith" +version = "0.1.0" +authors = ["OpenFeature Maintainers"] +edition = "2021" +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.0" + +# Async runtime +tokio = { version = "1", features = ["full"] } +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.4" + +[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..28d00ef --- /dev/null +++ b/crates/flagsmith/README.md @@ -0,0 +1,156 @@ +# 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 +// Boolean flags +let enabled = client.get_bool_value("feature-toggle", &context, None).await?; + +// String flags +let theme = client.get_string_value("theme", &context, None).await?; + +// Integer flags +let max_items = client.get_int_value("max-items", &context, None).await?; + +// Float flags +let multiplier = client.get_float_value("price-multiplier", &context, None).await?; + +// Structured flags (JSON objects) +let config = client.get_object_value("config", &context, None).await?; +``` + +## 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..b508552 --- /dev/null +++ b/crates/flagsmith/src/error.rs @@ -0,0 +1,83 @@ +use thiserror::Error; + +/// Known error messages from Flagsmith SDK +const FLAGSMITH_FLAG_NOT_FOUND: &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), +} + +/// Convert Flagsmith SDK errors to FlagsmithError +impl From for FlagsmithError { + fn from(error: flagsmith::error::Error) -> Self { + match error.kind { + flagsmith::error::ErrorKind::FlagsmithAPIError => { + 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)) + } +} + +/// Convert serde_json errors to FlagsmithError +impl From for FlagsmithError { + fn from(error: serde_json::Error) -> Self { + FlagsmithError::Evaluation(format!("JSON parse error: {}", 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) => { + if msg == FLAGSMITH_FLAG_NOT_FOUND { + open_feature::EvaluationError { + code: EvaluationErrorCode::FlagNotFound, + message: Some(msg), + } + } else { + open_feature::EvaluationError { + code: EvaluationErrorCode::General( + "Evaluation error".to_string() + ), + message: Some(msg), + } + } + } + } + } +} diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs new file mode 100644 index 0000000..72e2bfe --- /dev/null +++ b/crates/flagsmith/src/lib.rs @@ -0,0 +1,871 @@ +//! 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); +//! } +//! ``` + +mod error; + +use async_trait::async_trait; +use error::FlagsmithError; +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 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 + let client = Flagsmith::new(environment_key, sdk_options); + + 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, + } + } +} + +#[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 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 + }; + let flag_key_owned = flag_key.to_string(); + + let flags = 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)?; + + let enabled = flags + .is_feature_enabled(&flag_key_owned) + .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 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 + }; + let flag_key_owned = flag_key.to_string(); + + let flags = 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)?; + + let flag = flags.get_flag(&flag_key_owned).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, but flag '{}' has type {:?}", + flag_key_owned, flag.value.value_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 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 + }; + let flag_key_owned = flag_key.to_string(); + + let flags = 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)?; + + let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + + let value = match flag.value.value_type { + FlagsmithValueType::Integer => 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 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 + }; + let flag_key_owned = flag_key.to_string(); + + let flags = 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)?; + + let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + + let value = match flag.value.value_type { + FlagsmithValueType::Float => 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 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 + }; + let flag_key_owned = flag_key.to_string(); + + let flags = 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)?; + + let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + + 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 { + 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. +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 { + fields.insert(k, json_to_open_feature_value(v)); + } + Value::Struct(StructValue { fields }) + } + } +} + +/// Validate that a flag key is not empty. +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. +fn context_to_traits(context: &EvaluationContext) -> Vec { + context + .custom_fields + .iter() + .map(|(key, value)| { + let flagsmith_value = match value { + EvaluationContextFieldValue::Bool(b) => FlagsmithValue { + value: b.to_string(), + value_type: FlagsmithValueType::Bool, + }, + EvaluationContextFieldValue::String(s) => FlagsmithValue { + value: s.clone(), + value_type: FlagsmithValueType::String, + }, + EvaluationContextFieldValue::Int(i) => FlagsmithValue { + value: i.to_string(), + value_type: FlagsmithValueType::Integer, + }, + EvaluationContextFieldValue::Float(f) => FlagsmithValue { + value: f.to_string(), + value_type: FlagsmithValueType::Float, + }, + EvaluationContextFieldValue::DateTime(dt) => FlagsmithValue { + value: dt.to_string(), + value_type: FlagsmithValueType::String, + }, + EvaluationContextFieldValue::Struct(_) => FlagsmithValue { + value: String::new(), + value_type: FlagsmithValueType::String, + }, + }; + + flagsmith::flagsmith::models::SDKTrait::new(key.clone(), flagsmith_value) + }) + .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 +fn determine_reason(context: &EvaluationContext, enabled: bool) -> Reason { + if !enabled { + Reason::Disabled + } else if context.targeting_key.is_some() { + Reason::TargetingMatch + } else { + Reason::Static + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_log::test; + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_empty_environment_key_fails() { + 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() { + 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 = context_to_traits(&context); + + assert_eq!(traits.len(), 4); + + // Check that all traits were created + 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_determine_reason_disabled() { + let context = EvaluationContext::default(); + let reason = 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 = determine_reason(&context, true); + assert_eq!(reason, Reason::TargetingMatch); + } + + #[test] + fn test_determine_reason_static() { + let context = EvaluationContext::default(); + let reason = determine_reason(&context, true); + assert_eq!(reason, Reason::Static); + } + + #[test] + fn test_metadata() { + let provider = FlagsmithProvider { + metadata: ProviderMetadata::new("flagsmith"), + 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 = 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 = 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!(json_to_open_feature_value(json_null), Value::String(_))); + assert!(matches!(json_to_open_feature_value(json_bool), Value::Bool(true))); + assert!(matches!(json_to_open_feature_value(json_int), Value::Int(42))); + + if let Value::Float(f) = json_to_open_feature_value(json_float) { + assert!((f - 3.14).abs() < 0.001); + } else { + panic!("Expected Float value"); + } + + if let Value::String(s) = 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) = 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) = 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_nested() { + let json_nested = serde_json::json!({ + "user": { + "name": "Alice", + "age": 30 + }, + "tags": ["admin", "user"] + }); + + if let Value::Struct(s) = 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"); + } + } +} + diff --git a/crates/flagsmith/tests/provider_tests.rs b/crates/flagsmith/tests/provider_tests.rs new file mode 100644 index 0000000..74b815d --- /dev/null +++ b/crates/flagsmith/tests/provider_tests.rs @@ -0,0 +1,335 @@ +use open_feature::provider::FeatureProvider; +use open_feature::{EvaluationContext, EvaluationReason as Reason}; +use open_feature_flagsmith::{FlagsmithClient, FlagsmithProvider}; +use flagsmith::flagsmith::models::Flags; +use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; +use std::collections::HashMap; +use std::sync::Arc; +use serde_json; + +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_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()); +} From 8d86acc7a480fa7aa2261c1963a83b5e9cb2e9aa Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 20 Nov 2025 16:12:00 +0100 Subject: [PATCH 02/20] feat: updated-context Signed-off-by: wadii --- context.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/context.md b/context.md index 23b1447..cd556af 100644 --- a/context.md +++ b/context.md @@ -2,7 +2,7 @@ **Repository**: https://github.com/open-feature/rust-sdk-contrib **Purpose**: OpenFeature provider implementations for Rust -**Last Updated**: 2025-11-19 +**Last Updated**: 2025-11-20 --- @@ -17,6 +17,7 @@ rust-sdk-contrib/ ├── crates/ │ ├── env-var/ # Environment variable provider │ ├── flagd/ # flagd provider (comprehensive reference) +│ ├── flagsmith/ # Flagsmith provider │ ├── flipt/ # Flipt provider (simpler reference) │ └── ofrep/ # OFREP provider (HTTP-based reference) ├── Cargo.toml # Workspace definition @@ -35,6 +36,7 @@ The root `Cargo.toml` defines a workspace with all provider crates as members: members = [ "crates/env-var", "crates/flagd", + "crates/flagsmith", "crates/flipt", "crates/ofrep" ] @@ -742,6 +744,7 @@ mockito = "1.0" # Optional, for HTTP mocking - **Contributing Guide**: `CONTRIBUTING.md` - **flagd Provider** (comprehensive): `crates/flagd/` +- **Flagsmith Provider**: `crates/flagsmith/` - **OFREP Provider** (HTTP-based): `crates/ofrep/` - **Flipt Provider** (simple): `crates/flipt/` - **env-var Provider** (basic): `crates/env-var/` @@ -778,5 +781,5 @@ When creating a new provider: --- -**Last Updated**: 2025-11-19 +**Last Updated**: 2025-11-20 **Maintainers**: OpenFeature Contributors From 545882af0c6d6714b4456688b92426e68b2aefe7 Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Fri, 21 Nov 2025 11:42:59 +0100 Subject: [PATCH 03/20] Update crates/flagsmith/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Zaimwa9 --- crates/flagsmith/README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/flagsmith/README.md b/crates/flagsmith/README.md index 28d00ef..15ac05e 100644 --- a/crates/flagsmith/README.md +++ b/crates/flagsmith/README.md @@ -73,20 +73,19 @@ let enabled = client ```rust // Boolean flags -let enabled = client.get_bool_value("feature-toggle", &context, None).await?; +let enabled = client.get_bool_value("feature-toggle", &context, None).await.unwrap(); // String flags -let theme = client.get_string_value("theme", &context, None).await?; +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?; +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?; +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?; -``` +let config = client.get_object_value("config", &context, None).await.unwrap(); ## Local Evaluation From e727d9fed2a8a6d3075b6415aededd22cbf0c948 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 11:43:33 +0100 Subject: [PATCH 04/20] feat: run-linter Signed-off-by: wadii --- crates/flagsmith/src/error.rs | 12 +-- crates/flagsmith/src/lib.rs | 101 +++++++++++++---------- crates/flagsmith/tests/provider_tests.rs | 22 ++--- 3 files changed, 73 insertions(+), 62 deletions(-) diff --git a/crates/flagsmith/src/error.rs b/crates/flagsmith/src/error.rs index b508552..4158fab 100644 --- a/crates/flagsmith/src/error.rs +++ b/crates/flagsmith/src/error.rs @@ -23,9 +23,7 @@ pub enum FlagsmithError { impl From for FlagsmithError { fn from(error: flagsmith::error::Error) -> Self { match error.kind { - flagsmith::error::ErrorKind::FlagsmithAPIError => { - FlagsmithError::Api(error.msg) - } + flagsmith::error::ErrorKind::FlagsmithAPIError => FlagsmithError::Api(error.msg), flagsmith::error::ErrorKind::FlagsmithClientError => { FlagsmithError::Evaluation(error.msg) } @@ -54,9 +52,7 @@ impl From for open_feature::EvaluationError { match error { FlagsmithError::Config(msg) => open_feature::EvaluationError { - code: EvaluationErrorCode::General( - "Configuration error".to_string() - ), + code: EvaluationErrorCode::General("Configuration error".to_string()), message: Some(msg), }, FlagsmithError::Api(msg) => open_feature::EvaluationError { @@ -71,9 +67,7 @@ impl From for open_feature::EvaluationError { } } else { open_feature::EvaluationError { - code: EvaluationErrorCode::General( - "Evaluation error".to_string() - ), + code: EvaluationErrorCode::General("Evaluation error".to_string()), message: Some(msg), } } diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index 72e2bfe..b8b115e 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -55,7 +55,10 @@ use error::FlagsmithError; 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 open_feature::{ + EvaluationContext, EvaluationContextFieldValue, EvaluationError, EvaluationReason as Reason, + StructValue, Value, +}; use serde_json::Value as JsonValue; use std::fmt; use std::sync::Arc; @@ -66,7 +69,9 @@ 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_environment_flags( + &self, + ) -> Result; fn get_identity_flags( &self, identifier: &str, @@ -76,7 +81,9 @@ pub trait FlagsmithClient: Send + Sync { } impl FlagsmithClient for Flagsmith { - fn get_environment_flags(&self) -> Result { + fn get_environment_flags( + &self, + ) -> Result { self.get_environment_flags() } @@ -206,7 +213,8 @@ impl FlagsmithProvider { // 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(), + "Local evaluation requires a server-side environment key (starts with 'ser.')" + .to_string(), )); } @@ -214,12 +222,10 @@ impl FlagsmithProvider { 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() - ) - )); + return Err(FlagsmithError::Config(format!( + "Invalid API URL scheme '{}'. Only http and https are supported", + parsed_url.scheme() + ))); } } @@ -346,7 +352,9 @@ impl FeatureProvider for FlagsmithProvider { })? .map_err(FlagsmithError::from)?; - let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + let flag = flags + .get_flag(&flag_key_owned) + .map_err(FlagsmithError::from)?; if !matches!(flag.value.value_type, FlagsmithValueType::String) { return Err(EvaluationError { @@ -401,22 +409,23 @@ impl FeatureProvider for FlagsmithProvider { })? .map_err(FlagsmithError::from)?; - let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + let flag = flags + .get_flag(&flag_key_owned) + .map_err(FlagsmithError::from)?; let value = match flag.value.value_type { - FlagsmithValueType::Integer => flag - .value - .value - .parse::() - .map_err(|e| { - EvaluationError { + FlagsmithValueType::Integer => { + 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, @@ -470,22 +479,23 @@ impl FeatureProvider for FlagsmithProvider { })? .map_err(FlagsmithError::from)?; - let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + let flag = flags + .get_flag(&flag_key_owned) + .map_err(FlagsmithError::from)?; let value = match flag.value.value_type { - FlagsmithValueType::Float => flag - .value - .value - .parse::() - .map_err(|e| { - EvaluationError { + FlagsmithValueType::Float => { + 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, @@ -539,10 +549,12 @@ impl FeatureProvider for FlagsmithProvider { })? .map_err(FlagsmithError::from)?; - let flag = flags.get_flag(&flag_key_owned).map_err(FlagsmithError::from)?; + let flag = flags + .get_flag(&flag_key_owned) + .map_err(FlagsmithError::from)?; - let json_value: JsonValue = serde_json::from_str(&flag.value.value) - .map_err(|e| EvaluationError { + 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)), })?; @@ -682,11 +694,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_empty_environment_key_fails() { - let result = FlagsmithProvider::new( - "".to_string(), - FlagsmithOptions::default(), - ) - .await; + let result = FlagsmithProvider::new("".to_string(), FlagsmithOptions::default()).await; assert!(result.is_err()); assert_eq!( @@ -741,8 +749,7 @@ mod tests { #[test] fn test_determine_reason_targeting_match() { - let context = EvaluationContext::default() - .with_targeting_key("user-123"); + let context = EvaluationContext::default().with_targeting_key("user-123"); let reason = determine_reason(&context, true); assert_eq!(reason, Reason::TargetingMatch); } @@ -790,9 +797,18 @@ mod tests { let json_float = serde_json::json!(3.14); let json_string = serde_json::json!("hello"); - assert!(matches!(json_to_open_feature_value(json_null), Value::String(_))); - assert!(matches!(json_to_open_feature_value(json_bool), Value::Bool(true))); - assert!(matches!(json_to_open_feature_value(json_int), Value::Int(42))); + assert!(matches!( + json_to_open_feature_value(json_null), + Value::String(_) + )); + assert!(matches!( + json_to_open_feature_value(json_bool), + Value::Bool(true) + )); + assert!(matches!( + json_to_open_feature_value(json_int), + Value::Int(42) + )); if let Value::Float(f) = json_to_open_feature_value(json_float) { assert!((f - 3.14).abs() < 0.001); @@ -868,4 +884,3 @@ mod tests { } } } - diff --git a/crates/flagsmith/tests/provider_tests.rs b/crates/flagsmith/tests/provider_tests.rs index 74b815d..9c56793 100644 --- a/crates/flagsmith/tests/provider_tests.rs +++ b/crates/flagsmith/tests/provider_tests.rs @@ -1,11 +1,11 @@ +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 flagsmith::flagsmith::models::Flags; -use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; +use serde_json; use std::collections::HashMap; use std::sync::Arc; -use serde_json; struct MockFlagsmithClient { environment_flags: Option>, @@ -45,11 +45,9 @@ impl MockFlagsmithClient { 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::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()) } @@ -119,7 +117,9 @@ impl FlagsmithClient for MockFlagsmithClient { } } -fn create_mock_flags(configs: Vec<(&str, FlagsmithValue, bool)>) -> HashMap { +fn create_mock_flags( + configs: Vec<(&str, FlagsmithValue, bool)>, +) -> HashMap { configs .into_iter() .map(|(name, value, enabled)| (name.to_string(), (value, enabled))) @@ -298,7 +298,9 @@ async fn test_resolve_flag_not_found() { let provider = FlagsmithProvider::from_client(Arc::new(mock_client)); let context = EvaluationContext::default(); - let result = provider.resolve_bool_value("non-existent-flag", &context).await; + let result = provider + .resolve_bool_value("non-existent-flag", &context) + .await; assert!(result.is_err()); } From cff5d478fca3ace6cb52763461415d6775a1de17 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 11:47:13 +0100 Subject: [PATCH 05/20] feat: removed-context-file Signed-off-by: wadii --- context.md | 785 ----------------------------------------------------- 1 file changed, 785 deletions(-) delete mode 100644 context.md diff --git a/context.md b/context.md deleted file mode 100644 index cd556af..0000000 --- a/context.md +++ /dev/null @@ -1,785 +0,0 @@ -# OpenFeature Rust SDK Contributions - Repository Context - -**Repository**: https://github.com/open-feature/rust-sdk-contrib -**Purpose**: OpenFeature provider implementations for Rust -**Last Updated**: 2025-11-20 - ---- - -## 1. Repository Structure - -### 1.1 Project Organization - -This is the OpenFeature Rust SDK contributions repository. Each provider lives in its own crate under `crates/`: - -``` -rust-sdk-contrib/ -├── crates/ -│ ├── env-var/ # Environment variable provider -│ ├── flagd/ # flagd provider (comprehensive reference) -│ ├── flagsmith/ # Flagsmith provider -│ ├── flipt/ # Flipt provider (simpler reference) -│ └── ofrep/ # OFREP provider (HTTP-based reference) -├── Cargo.toml # Workspace definition -├── CONTRIBUTING.md # Contribution guidelines -├── README.md -├── release-please-config.json -└── renovate.json -``` - -### 1.2 Workspace Configuration - -The root `Cargo.toml` defines a workspace with all provider crates as members: - -```toml -[workspace] -members = [ - "crates/env-var", - "crates/flagd", - "crates/flagsmith", - "crates/flipt", - "crates/ofrep" -] -``` - -**Key settings**: -- **Edition**: Rust 2024 -- **License**: Apache 2.0 - ---- - -## 2. Contributing Guidelines - -From `CONTRIBUTING.md`: - -### 2.1 Project Hierarchy - -Each contrib must be placed under `crates/`: -- Create a new directory: `crates//` -- Add to workspace in root `Cargo.toml` -- Follow standard Rust crate structure - -### 2.2 Coding Style - -**Requirements**: -1. Add comments and tests for publicly exposed APIs -2. Follow Clippy rules from [rust-sdk/src/lib.rs](https://github.com/open-feature/rust-sdk/blob/main/src/lib.rs) - -**Best practices** (from existing providers): -- Use `tracing` for logging (not `log`) -- Use `thiserror` for error types -- Use `async-trait` for async trait implementations -- Document public APIs with doc comments -- Include usage examples in doc comments - ---- - -## 3. Development Setup - -Based on `crates/flagd/docs/contributing.md`: - -### 3.1 Building - -```bash -# Navigate to your crate directory -cd crates/ - -# Build the crate -cargo build - -# Build entire workspace (from root) -cargo build --workspace -``` - -### 3.2 Testing - -**Unit tests** (no external dependencies): -```bash -# Run unit tests only -cargo test --lib - -# Run with full logging -RUST_LOG_SPAN_EVENTS=full RUST_LOG=debug cargo test -- --nocapture -``` - -**Integration tests** (may require Docker): -```bash -# Run all tests (including E2E) -cargo test - -# Note: E2E tests use testcontainers-rs -# Docker is required (podman not currently supported) -``` - -### 3.3 Development Dependencies - -Common dev dependencies across providers: -- `test-log = "0.2"` - For tracing logs in tests -- `tracing-subscriber` - For test logging -- `testcontainers` - For E2E tests with Docker (optional) - ---- - -## 4. OpenFeature Provider Interface - -### 4.1 Required Trait Implementation - -All providers must implement the `FeatureProvider` trait from `open-feature` crate: - -```rust -use async_trait::async_trait; -use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; -use open_feature::{EvaluationContext, EvaluationError, StructValue}; - -#[async_trait] -pub trait FeatureProvider { - // Required: Provider metadata - fn metadata(&self) -> &ProviderMetadata; - - // Required: Five evaluation methods for different types - async fn resolve_bool_value( - &self, - flag_key: &str, - context: &EvaluationContext, - ) -> Result, EvaluationError>; - - async fn resolve_int_value( - &self, - flag_key: &str, - context: &EvaluationContext, - ) -> Result, EvaluationError>; - - async fn resolve_float_value( - &self, - flag_key: &str, - context: &EvaluationContext, - ) -> Result, EvaluationError>; - - async fn resolve_string_value( - &self, - flag_key: &str, - context: &EvaluationContext, - ) -> Result, EvaluationError>; - - async fn resolve_struct_value( - &self, - flag_key: &str, - context: &EvaluationContext, - ) -> Result, EvaluationError>; -} -``` - -### 4.2 Key Types - -**EvaluationContext**: -```rust -pub struct EvaluationContext { - pub targeting_key: Option, // User identifier - pub custom_fields: HashMap, // Additional attributes -} - -// Usage -let context = EvaluationContext::default() - .with_targeting_key("user-123") - .with_custom_field("email", "user@example.com") - .with_custom_field("plan", "premium"); -``` - -**ResolutionDetails**: -```rust -pub struct ResolutionDetails { - pub value: T, // The evaluated flag value - pub reason: Option, // Why this value was returned - pub variant: Option, // Variant identifier - pub error_code: Option, // Error code if applicable - pub error_message: Option, // Error message if applicable - pub flag_metadata: Option>, // Additional metadata -} - -// Simple construction -ResolutionDetails::new(true) // Just the value - -// With reason -ResolutionDetails { - value: true, - reason: Some(Reason::TargetingMatch), - ..Default::default() -} -``` - -**EvaluationError**: -```rust -pub struct EvaluationError { - pub code: EvaluationErrorCode, - pub message: Option, -} - -pub enum EvaluationErrorCode { - FlagNotFound, // Flag doesn't exist - ParseError, // Failed to parse value - TypeMismatch, // Wrong type returned - TargetingKeyMissing, // Required targeting_key not provided - InvalidContext, // Invalid evaluation context - ProviderNotReady, // Provider not initialized or unavailable - General(String), // Other errors -} -``` - -**Reason** (from OpenFeature spec): -```rust -pub enum Reason { - Static, // Flag evaluated without targeting - TargetingMatch, // Flag evaluated with targeting rules - Disabled, // Flag is disabled - Cached, // Value returned from cache - Default, // Default value returned (error case) - Error, // Error occurred during evaluation - Unknown, // Reason unknown -} -``` - ---- - -## 5. Common Provider Patterns - -### 5.1 Standard Crate Structure - -``` -crates// -├── Cargo.toml -├── README.md -├── CHANGELOG.md -├── src/ -│ ├── lib.rs # Main provider + re-exports -│ ├── error.rs # Custom error types (optional) -│ ├── resolver.rs # Core evaluation logic (optional) -│ └── utils.rs # Helper functions (optional) -├── tests/ -│ ├── integration_test.rs -│ └── fixtures/ -└── examples/ - └── basic_usage.rs -``` - -### 5.2 Provider Implementation Pattern - -**Basic structure** (from OFREP and Flipt providers): - -```rust -// lib.rs -mod error; // Optional: Custom error types - -use async_trait::async_trait; -use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; -use open_feature::{EvaluationContext, EvaluationError}; - -// Configuration struct -#[derive(Debug, Clone)] -pub struct ProviderOptions { - pub required_field: String, - pub optional_field: Option, -} - -impl Default for ProviderOptions { - fn default() -> Self { - ProviderOptions { - required_field: "default_value".to_string(), - optional_field: None, - } - } -} - -// Main provider struct -pub struct MyProvider { - metadata: ProviderMetadata, - client: SdkClient, // Feature flag SDK client -} - -impl MyProvider { - /// Creates a new provider instance - pub async fn new(options: ProviderOptions) -> Result { - // 1. Validate configuration - // 2. Initialize SDK client - // 3. Return provider instance - Ok(Self { - metadata: ProviderMetadata::new("my-provider"), - client: SdkClient::new(options)?, - }) - } -} - -#[async_trait] -impl FeatureProvider for MyProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - - async fn resolve_bool_value( - &self, - flag_key: &str, - context: &EvaluationContext, - ) -> Result, EvaluationError> { - // Implementation - } - - // ... other resolve methods -} -``` - -### 5.3 Configuration Patterns - -**Pattern 1 - Simple struct (OFREP)**: -```rust -#[derive(Debug, Clone)] -pub struct OfrepOptions { - pub base_url: String, - pub headers: HeaderMap, - pub connect_timeout: Duration, -} - -impl Default for OfrepOptions { - fn default() -> Self { - OfrepOptions { - base_url: "http://localhost:8016".to_string(), - headers: HeaderMap::new(), - connect_timeout: Duration::from_secs(10), - } - } -} -``` - -**Pattern 2 - Generic config (Flipt)**: -```rust -pub struct Config -where - A: AuthenticationStrategy, -{ - pub url: String, - pub authentication_strategy: A, - pub timeout: u64, -} -``` - -**Pattern 3 - Builder pattern (flagd)**: -```rust -let options = FlagdOptions::builder() - .host("localhost") - .port(8013) - .resolver_type(ResolverType::Rpc) - .build()?; -``` - -### 5.4 Error Handling Pattern - -**Define custom errors with thiserror**: -```rust -// error.rs -use thiserror::Error; - -#[derive(Error, Debug, PartialEq)] -pub enum MyProviderError { - #[error("Provider error: {0}")] - Provider(String), - - #[error("Connection error: {0}")] - Connection(String), - - #[error("Invalid configuration: {0}")] - Config(String), - - #[error("Flag not found: {0}")] - FlagNotFound(String), -} -``` - -**Map to OpenFeature errors**: -```rust -fn map_error(err: SdkError) -> EvaluationError { - match err { - SdkError::NotFound(flag) => EvaluationError { - code: EvaluationErrorCode::FlagNotFound, - message: Some(format!("Flag '{}' not found", flag)), - }, - SdkError::NetworkError(e) => EvaluationError { - code: EvaluationErrorCode::ProviderNotReady, - message: Some(format!("Network error: {}", e)), - }, - SdkError::ParseError(e) => EvaluationError { - code: EvaluationErrorCode::ParseError, - message: Some(e), - }, - _ => EvaluationError { - code: EvaluationErrorCode::General("Unknown error".to_string()), - message: Some(err.to_string()), - }, - } -} -``` - -**Error handling principles**: -- Never panic from evaluation methods -- Always return `Result` with appropriate error code -- Include descriptive error messages -- Use `tracing` for debug logging -- Map SDK-specific errors to OpenFeature error codes - -### 5.5 Context Mapping Pattern - -Feature flag SDKs often have their own context format. Map OpenFeature context to SDK format: - -```rust -// Example from Flipt provider -fn translate_context(ctx: &EvaluationContext) -> HashMap { - ctx.custom_fields - .iter() - .map(|(k, v)| { - let value = match v { - EvaluationContextFieldValue::Bool(b) => b.to_string(), - EvaluationContextFieldValue::String(s) => s.clone(), - EvaluationContextFieldValue::Int(i) => i.to_string(), - EvaluationContextFieldValue::Float(f) => f.to_string(), - // ... handle other types - }; - (k.clone(), value) - }) - .collect() -} -``` - -### 5.6 Type Conversion Patterns - -**Boolean** (direct mapping): -```rust -async fn resolve_bool_value( - &self, - flag_key: &str, - ctx: &EvaluationContext, -) -> Result, EvaluationError> { - self.client - .evaluate_boolean(flag_key, ctx) - .await - .map_err(map_error) - .map(|result| ResolutionDetails::new(result.enabled)) -} -``` - -**Integer/Float** (with parsing): -```rust -async fn resolve_int_value( - &self, - flag_key: &str, - ctx: &EvaluationContext, -) -> Result, EvaluationError> { - let result = self.client.get_value(flag_key, ctx).await?; - - result.value - .parse::() - .map(ResolutionDetails::new) - .map_err(|e| EvaluationError { - code: EvaluationErrorCode::TypeMismatch, - message: Some(format!( - "Expected i64, but got '{}': {}", - result.value, e - )), - }) -} -``` - -**String** (direct or conversion): -```rust -async fn resolve_string_value( - &self, - flag_key: &str, - ctx: &EvaluationContext, -) -> Result, EvaluationError> { - self.client - .get_value(flag_key, ctx) - .await - .map_err(map_error) - .map(|result| ResolutionDetails::new(result.value)) -} -``` - -**Struct** (JSON parsing): -```rust -async fn resolve_struct_value( - &self, - flag_key: &str, - ctx: &EvaluationContext, -) -> Result, EvaluationError> { - let result = self.client.get_value(flag_key, ctx).await?; - - // Parse JSON string to Value - let value: Value = serde_json::from_str(&result.value) - .map_err(|e| EvaluationError { - code: EvaluationErrorCode::ParseError, - message: Some(format!("Failed to parse JSON: {}", e)), - })?; - - // Ensure it's a struct/object - match value { - Value::Struct(struct_value) => { - Ok(ResolutionDetails::new(struct_value)) - } - _ => Err(EvaluationError { - code: EvaluationErrorCode::TypeMismatch, - message: Some(format!( - "Expected object, but got: {}", - result.value - )), - }), - } -} -``` - ---- - -## 6. Testing Patterns - -### 6.1 Test Structure - -```rust -#[cfg(test)] -mod tests { - use super::*; - use test_log::test; // For tracing logs in tests - - #[test(tokio::test)] - async fn test_bool_evaluation() { - // Arrange - let provider = MyProvider::new(options).await.unwrap(); - let context = EvaluationContext::default() - .with_targeting_key("user-123"); - - // Act - let result = provider - .resolve_bool_value("my-flag", &context) - .await; - - // Assert - assert!(result.is_ok()); - assert_eq!(result.unwrap().value, true); - } - - #[test(tokio::test)] - async fn test_flag_not_found() { - let provider = MyProvider::new(options).await.unwrap(); - let context = EvaluationContext::default(); - - let result = provider - .resolve_bool_value("non-existent", &context) - .await; - - assert!(result.is_err()); - match result.unwrap_err().code { - EvaluationErrorCode::FlagNotFound => {}, - _ => panic!("Expected FlagNotFound error"), - } - } -} -``` - -### 6.2 Test Logging - -Enable detailed logs during tests: - -```bash -# Full tracing output -RUST_LOG_SPAN_EVENTS=full RUST_LOG=debug cargo test -- --nocapture - -# Specific module -RUST_LOG=my_provider=debug cargo test -- --nocapture -``` - -### 6.3 Integration Testing - -Use `testcontainers-rs` for E2E tests with Docker: - -```rust -#[cfg(test)] -mod integration_tests { - use testcontainers::{clients, images}; - - #[tokio::test] - async fn test_against_real_service() { - let docker = clients::Cli::default(); - let container = docker.run(images::generic::GenericImage::new( - "my-service", - "latest" - )); - - let port = container.get_host_port_ipv4(8080); - let provider = MyProvider::new(ProviderOptions { - host: "localhost".to_string(), - port, - ..Default::default() - }).await.unwrap(); - - // Run tests against real service - } -} -``` - ---- - -## 7. Documentation Standards - -### 7.1 README Structure - -Each provider should have a README with: - -1. **Title and brief description** -2. **Installation instructions** -3. **Basic usage example** -4. **Configuration options table** -5. **Advanced usage examples** (optional) -6. **Testing instructions** -7. **License** - -### 7.2 Inline Documentation - -Use doc comments for public APIs: - -```rust -/// A feature flag provider for [Service Name]. -/// -/// This provider enables dynamic feature flag evaluation using the -/// [Service Name] platform. -/// -/// # Example -/// -/// ```rust -/// use my_provider::{MyProvider, MyOptions}; -/// -/// #[tokio::main] -/// async fn main() { -/// let provider = MyProvider::new(MyOptions { -/// api_key: "your-key".to_string(), -/// ..Default::default() -/// }).await.unwrap(); -/// } -/// ``` -pub struct MyProvider { - // ... -} -``` - -### 7.3 cargo-readme - -Consider using `cargo-readme` to generate README from doc comments: - -```bash -cargo install cargo-readme -cargo readme --no-title --no-license > README.md -``` - -Pattern seen in OFREP provider: -```rust -//! [Generated by cargo-readme: `cargo readme --no-title --no-license > README.md`]:: -//! # OFREP Provider for OpenFeature -//! -//! A Rust implementation of... -``` - ---- - -## 8. Common Dependencies - -### 8.1 Runtime Dependencies - -```toml -[dependencies] -# OpenFeature SDK -open-feature = "0.x.x" - -# Async runtime -tokio = { version = "1", features = ["full"] } -async-trait = "0.1" - -# Error handling -thiserror = "1.0" -anyhow = "1.0" # Optional - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Logging -tracing = "0.1" - -# HTTP client (if needed) -reqwest = { version = "0.11", features = ["json"] } - -# URL parsing (if needed) -url = "2.0" -``` - -### 8.2 Development Dependencies - -```toml -[dev-dependencies] -# Testing -test-log = "0.2" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Integration testing -testcontainers = "0.15" # Optional, if using Docker - -# Mocking -mockito = "1.0" # Optional, for HTTP mocking -``` - ---- - -## 9. References - -### 9.1 OpenFeature Resources - -- **OpenFeature Specification**: https://openfeature.dev/specification/ -- **Provider Specification**: https://openfeature.dev/specification/sections/providers -- **Rust SDK Repository**: https://github.com/open-feature/rust-sdk -- **Rust SDK Documentation**: https://docs.rs/open-feature/ - -### 9.2 Repository Resources - -- **Contributing Guide**: `CONTRIBUTING.md` -- **flagd Provider** (comprehensive): `crates/flagd/` -- **Flagsmith Provider**: `crates/flagsmith/` -- **OFREP Provider** (HTTP-based): `crates/ofrep/` -- **Flipt Provider** (simple): `crates/flipt/` -- **env-var Provider** (basic): `crates/env-var/` - -### 9.3 Rust Resources - -- **async-trait**: https://docs.rs/async-trait/ -- **thiserror**: https://docs.rs/thiserror/ -- **tracing**: https://docs.rs/tracing/ -- **tokio**: https://docs.rs/tokio/ - ---- - -## 10. Quick Start Checklist - -When creating a new provider: - -- [ ] Create directory: `crates//` -- [ ] Setup `Cargo.toml` with dependencies -- [ ] Add to workspace in root `Cargo.toml` -- [ ] Create basic structure: `src/lib.rs`, `src/error.rs` -- [ ] Define configuration struct with `Default` trait -- [ ] Implement provider constructor with validation -- [ ] Implement `FeatureProvider` trait (5 methods) -- [ ] Add error mapping function -- [ ] Write unit tests for each method -- [ ] Create usage example in `examples/` -- [ ] Write README with installation and examples -- [ ] Add CHANGELOG.md -- [ ] Test with `cargo test --lib` -- [ ] Test with full suite: `cargo test` -- [ ] Run clippy: `cargo clippy --all-targets` -- [ ] Format code: `cargo fmt` - ---- - -**Last Updated**: 2025-11-20 -**Maintainers**: OpenFeature Contributors From ec1d504fa22ce2bd5d0db2199c599d8c22b4601c Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Fri, 21 Nov 2025 11:49:03 +0100 Subject: [PATCH 06/20] Update crates/flagsmith/Cargo.toml Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Zaimwa9 --- crates/flagsmith/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/flagsmith/Cargo.toml b/crates/flagsmith/Cargo.toml index 42ce068..c77dc09 100644 --- a/crates/flagsmith/Cargo.toml +++ b/crates/flagsmith/Cargo.toml @@ -2,7 +2,7 @@ name = "open-feature-flagsmith" version = "0.1.0" authors = ["OpenFeature Maintainers"] -edition = "2021" +edition = "2024" license = "Apache-2.0" description = "Flagsmith provider for OpenFeature" homepage = "https://openfeature.dev" From f2e89bb97300519e379326f89e246bd41b532553 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 12:01:40 +0100 Subject: [PATCH 07/20] feat: deduplicated-code-to-get-flags Signed-off-by: wadii --- crates/flagsmith/src/lib.rs | 156 +++++++++++------------------------- 1 file changed, 45 insertions(+), 111 deletions(-) diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index b8b115e..590cc1e 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -266,23 +266,23 @@ impl FlagsmithProvider { client, } } -} - -#[async_trait] -impl FeatureProvider for FlagsmithProvider { - fn metadata(&self) -> &ProviderMetadata { - &self.metadata - } - #[instrument(skip(self, context))] - async fn resolve_bool_value( + /// 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, - flag_key: &str, context: &EvaluationContext, - ) -> Result, EvaluationError> { - validate_flag_key(flag_key)?; - debug!("Resolving boolean flag: {}", flag_key); - + ) -> Result { let client = Arc::clone(&self.client); let targeting_key = context.targeting_key.clone(); let traits = if targeting_key.is_some() { @@ -290,9 +290,8 @@ impl FeatureProvider for FlagsmithProvider { } else { None }; - let flag_key_owned = flag_key.to_string(); - let flags = tokio::task::spawn_blocking(move || { + Ok(tokio::task::spawn_blocking(move || { if let Some(key) = targeting_key { client.get_identity_flags(&key, traits, None) } else { @@ -304,10 +303,29 @@ impl FeatureProvider for FlagsmithProvider { code: open_feature::EvaluationErrorCode::General("Task execution error".to_string()), message: Some(format!("Failed to execute blocking task: {}", e)), })? - .map_err(FlagsmithError::from)?; + .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_owned) + .is_feature_enabled(flag_key) .map_err(FlagsmithError::from)?; let reason = determine_reason(context, enabled); @@ -329,31 +347,10 @@ impl FeatureProvider for FlagsmithProvider { validate_flag_key(flag_key)?; debug!("Resolving string flag: {}", flag_key); - 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 - }; - let flag_key_owned = flag_key.to_string(); - - let flags = 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)?; + let flags = self.get_flags(context).await?; let flag = flags - .get_flag(&flag_key_owned) + .get_flag(flag_key) .map_err(FlagsmithError::from)?; if !matches!(flag.value.value_type, FlagsmithValueType::String) { @@ -361,7 +358,7 @@ impl FeatureProvider for FlagsmithProvider { code: open_feature::EvaluationErrorCode::TypeMismatch, message: Some(format!( "Expected string type, but flag '{}' has type {:?}", - flag_key_owned, flag.value.value_type + flag_key, flag.value.value_type )), }); } @@ -386,31 +383,10 @@ impl FeatureProvider for FlagsmithProvider { validate_flag_key(flag_key)?; debug!("Resolving integer flag: {}", flag_key); - 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 - }; - let flag_key_owned = flag_key.to_string(); - - let flags = 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)?; + let flags = self.get_flags(context).await?; let flag = flags - .get_flag(&flag_key_owned) + .get_flag(flag_key) .map_err(FlagsmithError::from)?; let value = match flag.value.value_type { @@ -456,31 +432,10 @@ impl FeatureProvider for FlagsmithProvider { validate_flag_key(flag_key)?; debug!("Resolving float flag: {}", flag_key); - 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 - }; - let flag_key_owned = flag_key.to_string(); - - let flags = 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)?; + let flags = self.get_flags(context).await?; let flag = flags - .get_flag(&flag_key_owned) + .get_flag(flag_key) .map_err(FlagsmithError::from)?; let value = match flag.value.value_type { @@ -526,31 +481,10 @@ impl FeatureProvider for FlagsmithProvider { validate_flag_key(flag_key)?; debug!("Resolving struct flag: {}", flag_key); - 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 - }; - let flag_key_owned = flag_key.to_string(); - - let flags = 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)?; + let flags = self.get_flags(context).await?; let flag = flags - .get_flag(&flag_key_owned) + .get_flag(flag_key) .map_err(FlagsmithError::from)?; let json_value: JsonValue = From 4e9180d01dbdf4e86c26aeabfd24a0623bf63291 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 12:13:53 +0100 Subject: [PATCH 08/20] feat: added-tests-to-catch-error-sdk-changes Signed-off-by: wadii --- crates/flagsmith/src/error.rs | 143 ++++++++++++++++++++++++++++++---- crates/flagsmith/src/lib.rs | 16 +--- 2 files changed, 131 insertions(+), 28 deletions(-) diff --git a/crates/flagsmith/src/error.rs b/crates/flagsmith/src/error.rs index 4158fab..71c5e8b 100644 --- a/crates/flagsmith/src/error.rs +++ b/crates/flagsmith/src/error.rs @@ -1,7 +1,17 @@ use thiserror::Error; -/// Known error messages from Flagsmith SDK -const FLAGSMITH_FLAG_NOT_FOUND: &str = "API returned invalid response"; +/// 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)] @@ -17,13 +27,24 @@ pub enum FlagsmithError { /// 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 => FlagsmithError::Api(error.msg), + 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) } @@ -59,19 +80,109 @@ impl From for open_feature::EvaluationError { code: EvaluationErrorCode::ProviderNotReady, message: Some(msg), }, - FlagsmithError::Evaluation(msg) => { - if msg == FLAGSMITH_FLAG_NOT_FOUND { - open_feature::EvaluationError { - code: EvaluationErrorCode::FlagNotFound, - message: Some(msg), - } - } else { - open_feature::EvaluationError { - code: EvaluationErrorCode::General("Evaluation error".to_string()), - 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), + }, } } } + +#[cfg(test)] +mod tests { + use super::*; + use flagsmith::flagsmith::models::Flags; + + /// 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() { + // 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 matches our constant + assert_eq!( + error.msg, FLAGSMITH_FLAG_NOT_FOUND_MSG, + "FLAGSMITH_FLAG_NOT_FOUND_MSG constant does not match SDK error message. \ + SDK returned: '{}', but our constant is: '{}'. \ + Please update FLAGSMITH_FLAG_NOT_FOUND_MSG to match the SDK.", + error.msg, FLAGSMITH_FLAG_NOT_FOUND_MSG + ); + } + + /// Test that our FlagsmithError conversion correctly identifies flag not found errors + #[test] + fn test_flagsmith_error_conversion_flag_not_found() { + // Create a flag not found error from the SDK + let sdk_error = flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + FLAGSMITH_FLAG_NOT_FOUND_MSG.to_string(), + ); + + // Convert to our error type + let our_error: FlagsmithError = sdk_error.into(); + + // Verify it's converted to FlagNotFound variant + 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() { + // Create a different API error + let sdk_error = flagsmith::error::Error::new( + flagsmith::error::ErrorKind::FlagsmithAPIError, + "Some other API error".to_string(), + ); + + // Convert to our error type + let our_error: FlagsmithError = sdk_error.into(); + + // Verify it's converted to Api variant, not FlagNotFound + 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/src/lib.rs b/crates/flagsmith/src/lib.rs index 590cc1e..de5be9d 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -349,9 +349,7 @@ impl FeatureProvider for FlagsmithProvider { let flags = self.get_flags(context).await?; - let flag = flags - .get_flag(flag_key) - .map_err(FlagsmithError::from)?; + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; if !matches!(flag.value.value_type, FlagsmithValueType::String) { return Err(EvaluationError { @@ -385,9 +383,7 @@ impl FeatureProvider for FlagsmithProvider { let flags = self.get_flags(context).await?; - let flag = flags - .get_flag(flag_key) - .map_err(FlagsmithError::from)?; + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; let value = match flag.value.value_type { FlagsmithValueType::Integer => { @@ -434,9 +430,7 @@ impl FeatureProvider for FlagsmithProvider { let flags = self.get_flags(context).await?; - let flag = flags - .get_flag(flag_key) - .map_err(FlagsmithError::from)?; + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; let value = match flag.value.value_type { FlagsmithValueType::Float => { @@ -483,9 +477,7 @@ impl FeatureProvider for FlagsmithProvider { let flags = self.get_flags(context).await?; - let flag = flags - .get_flag(flag_key) - .map_err(FlagsmithError::from)?; + let flag = flags.get_flag(flag_key).map_err(FlagsmithError::from)?; let json_value: JsonValue = serde_json::from_str(&flag.value.value).map_err(|e| EvaluationError { From 027636a8e4ee434a3ffd101ad44f3c4847855307 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 12:32:21 +0100 Subject: [PATCH 09/20] feat: handle-non-supported-traits-in-context Signed-off-by: wadii --- crates/flagsmith/src/lib.rs | 66 ++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index de5be9d..54a23d6 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -560,39 +560,45 @@ fn validate_flag_key(flag_key: &str) -> Result<(), EvaluationError> { /// /// 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. fn context_to_traits(context: &EvaluationContext) -> Vec { context .custom_fields .iter() - .map(|(key, value)| { + .filter_map(|(key, value)| { let flagsmith_value = match value { - EvaluationContextFieldValue::Bool(b) => FlagsmithValue { + EvaluationContextFieldValue::Bool(b) => Some(FlagsmithValue { value: b.to_string(), value_type: FlagsmithValueType::Bool, - }, - EvaluationContextFieldValue::String(s) => FlagsmithValue { + }), + EvaluationContextFieldValue::String(s) => Some(FlagsmithValue { value: s.clone(), value_type: FlagsmithValueType::String, - }, - EvaluationContextFieldValue::Int(i) => FlagsmithValue { + }), + EvaluationContextFieldValue::Int(i) => Some(FlagsmithValue { value: i.to_string(), value_type: FlagsmithValueType::Integer, - }, - EvaluationContextFieldValue::Float(f) => FlagsmithValue { + }), + EvaluationContextFieldValue::Float(f) => Some(FlagsmithValue { value: f.to_string(), value_type: FlagsmithValueType::Float, - }, - EvaluationContextFieldValue::DateTime(dt) => FlagsmithValue { + }), + EvaluationContextFieldValue::DateTime(dt) => Some(FlagsmithValue { value: dt.to_string(), value_type: FlagsmithValueType::String, - }, - EvaluationContextFieldValue::Struct(_) => FlagsmithValue { - value: String::new(), - 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::flagsmith::models::SDKTrait::new(key.clone(), flagsmith_value) + flagsmith_value.map(|fv| flagsmith::flagsmith::models::SDKTrait::new(key.clone(), fv)) }) .collect() } @@ -666,6 +672,34 @@ mod tests { assert!(trait_keys.contains(&"score".to_string())); } + #[test] + fn test_context_to_traits_filters_struct_fields() { + use std::collections::HashMap; + use std::sync::Arc; + + 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 = 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(); From 88dead03a66ce55e42e4e2ef430dd682eae52407 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 12:45:51 +0100 Subject: [PATCH 10/20] feat: restructured-tests Signed-off-by: wadii --- crates/flagsmith/src/error.rs | 95 ------- crates/flagsmith/src/lib.rs | 238 +----------------- crates/flagsmith/tests/error_tests.rs | 91 +++++++ ...provider_tests.rs => integration_tests.rs} | 0 crates/flagsmith/tests/unit_tests.rs | 224 +++++++++++++++++ 5 files changed, 321 insertions(+), 327 deletions(-) create mode 100644 crates/flagsmith/tests/error_tests.rs rename crates/flagsmith/tests/{provider_tests.rs => integration_tests.rs} (100%) create mode 100644 crates/flagsmith/tests/unit_tests.rs diff --git a/crates/flagsmith/src/error.rs b/crates/flagsmith/src/error.rs index 71c5e8b..c7c3d32 100644 --- a/crates/flagsmith/src/error.rs +++ b/crates/flagsmith/src/error.rs @@ -91,98 +91,3 @@ impl From for open_feature::EvaluationError { } } } - -#[cfg(test)] -mod tests { - use super::*; - use flagsmith::flagsmith::models::Flags; - - /// 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() { - // 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 matches our constant - assert_eq!( - error.msg, FLAGSMITH_FLAG_NOT_FOUND_MSG, - "FLAGSMITH_FLAG_NOT_FOUND_MSG constant does not match SDK error message. \ - SDK returned: '{}', but our constant is: '{}'. \ - Please update FLAGSMITH_FLAG_NOT_FOUND_MSG to match the SDK.", - error.msg, FLAGSMITH_FLAG_NOT_FOUND_MSG - ); - } - - /// Test that our FlagsmithError conversion correctly identifies flag not found errors - #[test] - fn test_flagsmith_error_conversion_flag_not_found() { - // Create a flag not found error from the SDK - let sdk_error = flagsmith::error::Error::new( - flagsmith::error::ErrorKind::FlagsmithAPIError, - FLAGSMITH_FLAG_NOT_FOUND_MSG.to_string(), - ); - - // Convert to our error type - let our_error: FlagsmithError = sdk_error.into(); - - // Verify it's converted to FlagNotFound variant - 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() { - // Create a different API error - let sdk_error = flagsmith::error::Error::new( - flagsmith::error::ErrorKind::FlagsmithAPIError, - "Some other API error".to_string(), - ); - - // Convert to our error type - let our_error: FlagsmithError = sdk_error.into(); - - // Verify it's converted to Api variant, not FlagNotFound - 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/src/lib.rs b/crates/flagsmith/src/lib.rs index 54a23d6..04147c1 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -48,10 +48,9 @@ //! } //! ``` -mod error; +pub mod error; use async_trait::async_trait; -use error::FlagsmithError; use flagsmith::{Flagsmith, FlagsmithOptions as FlagsmithSDKOptions}; use flagsmith_flag_engine::types::{FlagsmithValue, FlagsmithValueType}; use open_feature::provider::{FeatureProvider, ProviderMetadata, ResolutionDetails}; @@ -65,6 +64,7 @@ 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. @@ -517,7 +517,7 @@ impl FeatureProvider for FlagsmithProvider { } /// Convert serde_json::Value to open_feature::Value. -fn json_to_open_feature_value(json_val: JsonValue) -> 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), @@ -546,7 +546,7 @@ fn json_to_open_feature_value(json_val: JsonValue) -> Value { } /// Validate that a flag key is not empty. -fn validate_flag_key(flag_key: &str) -> Result<(), EvaluationError> { +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()), @@ -563,7 +563,7 @@ fn validate_flag_key(flag_key: &str) -> Result<(), EvaluationError> { /// /// Note: Struct fields are not supported by Flagsmith traits and will be /// filtered out with a warning logged. -fn context_to_traits(context: &EvaluationContext) -> Vec { +pub fn context_to_traits(context: &EvaluationContext) -> Vec { context .custom_fields .iter() @@ -609,7 +609,7 @@ fn context_to_traits(context: &EvaluationContext) -> Vec Reason { +pub fn determine_reason(context: &EvaluationContext, enabled: bool) -> Reason { if !enabled { Reason::Disabled } else if context.targeting_key.is_some() { @@ -618,229 +618,3 @@ fn determine_reason(context: &EvaluationContext, enabled: bool) -> Reason { Reason::Static } } - -#[cfg(test)] -mod tests { - use super::*; - use test_log::test; - - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn test_empty_environment_key_fails() { - 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() { - 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 = context_to_traits(&context); - - assert_eq!(traits.len(), 4); - - // Check that all traits were created - 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() { - use std::collections::HashMap; - use std::sync::Arc; - - 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 = 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 = 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 = determine_reason(&context, true); - assert_eq!(reason, Reason::TargetingMatch); - } - - #[test] - fn test_determine_reason_static() { - let context = EvaluationContext::default(); - let reason = determine_reason(&context, true); - assert_eq!(reason, Reason::Static); - } - - #[test] - fn test_metadata() { - let provider = FlagsmithProvider { - metadata: ProviderMetadata::new("flagsmith"), - 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 = 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 = 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!( - json_to_open_feature_value(json_null), - Value::String(_) - )); - assert!(matches!( - json_to_open_feature_value(json_bool), - Value::Bool(true) - )); - assert!(matches!( - json_to_open_feature_value(json_int), - Value::Int(42) - )); - - if let Value::Float(f) = json_to_open_feature_value(json_float) { - assert!((f - 3.14).abs() < 0.001); - } else { - panic!("Expected Float value"); - } - - if let Value::String(s) = 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) = 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) = 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_nested() { - let json_nested = serde_json::json!({ - "user": { - "name": "Alice", - "age": 30 - }, - "tags": ["admin", "user"] - }); - - if let Value::Struct(s) = 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"); - } - } -} 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/provider_tests.rs b/crates/flagsmith/tests/integration_tests.rs similarity index 100% rename from crates/flagsmith/tests/provider_tests.rs rename to crates/flagsmith/tests/integration_tests.rs diff --git a/crates/flagsmith/tests/unit_tests.rs b/crates/flagsmith/tests/unit_tests.rs new file mode 100644 index 0000000..5c735cc --- /dev/null +++ b/crates/flagsmith/tests/unit_tests.rs @@ -0,0 +1,224 @@ +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_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"); + } +} From a3fa60bb06883093439f6124331d13716d497244 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 12:46:22 +0100 Subject: [PATCH 11/20] feat: linter Signed-off-by: wadii --- crates/flagsmith/src/lib.rs | 4 +++- crates/flagsmith/tests/unit_tests.rs | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index 04147c1..681925e 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -563,7 +563,9 @@ pub fn validate_flag_key(flag_key: &str) -> Result<(), EvaluationError> { /// /// 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 { +pub fn context_to_traits( + context: &EvaluationContext, +) -> Vec { context .custom_fields .iter() diff --git a/crates/flagsmith/tests/unit_tests.rs b/crates/flagsmith/tests/unit_tests.rs index 5c735cc..e07276e 100644 --- a/crates/flagsmith/tests/unit_tests.rs +++ b/crates/flagsmith/tests/unit_tests.rs @@ -1,6 +1,8 @@ use flagsmith::{Flagsmith, FlagsmithOptions as FlagsmithSDKOptions}; use open_feature::provider::FeatureProvider; -use open_feature::{EvaluationContext, EvaluationContextFieldValue, EvaluationReason as Reason, StructValue, Value}; +use open_feature::{ + EvaluationContext, EvaluationContextFieldValue, EvaluationReason as Reason, StructValue, Value, +}; use open_feature_flagsmith::{FlagsmithError, FlagsmithProvider}; use std::collections::HashMap; use std::sync::Arc; @@ -60,8 +62,13 @@ fn test_context_to_traits() { #[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 }; + 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") From 6b1b75ff23cb99061dc26ca0ab2051240c673298 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 15:56:08 +0100 Subject: [PATCH 12/20] feat: use-spawn-blocking-for-client-init Signed-off-by: wadii --- crates/flagsmith/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index 681925e..c4ae217 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -252,7 +252,12 @@ impl FlagsmithProvider { } // Initialize Flagsmith client - let client = Flagsmith::new(environment_key, sdk_options); + // 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))) } @@ -385,8 +390,10 @@ impl FeatureProvider for FlagsmithProvider { 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::Integer | FlagsmithValueType::String => { flag.value .value .parse::() @@ -432,8 +439,10 @@ impl FeatureProvider for FlagsmithProvider { 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::Float | FlagsmithValueType::String => { flag.value .value .parse::() From 16e9e619198c9cca5312b7b4b29ab1b4ccfeb347 Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Fri, 21 Nov 2025 15:56:26 +0100 Subject: [PATCH 13/20] Update crates/flagsmith/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Zaimwa9 --- crates/flagsmith/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/flagsmith/README.md b/crates/flagsmith/README.md index 15ac05e..aca4c9d 100644 --- a/crates/flagsmith/README.md +++ b/crates/flagsmith/README.md @@ -86,7 +86,6 @@ let multiplier = client.get_float_value("price-multiplier", &context, None).awai // 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: From 7621b0b43ac652f162b44c127158a4b9da607cbd Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Fri, 21 Nov 2025 15:56:31 +0100 Subject: [PATCH 14/20] Update crates/flagsmith/Cargo.toml Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Zaimwa9 --- crates/flagsmith/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/flagsmith/Cargo.toml b/crates/flagsmith/Cargo.toml index c77dc09..8f1b5ce 100644 --- a/crates/flagsmith/Cargo.toml +++ b/crates/flagsmith/Cargo.toml @@ -19,7 +19,7 @@ open-feature = "0.2" flagsmith = "2.0" # Async runtime -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } async-trait = "0.1" # Error handling From 1769943d4c705b0a07d53624002e8846d8e5e432 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 16:04:13 +0100 Subject: [PATCH 15/20] feat: check-struct-type Signed-off-by: wadii --- crates/flagsmith/README.md | 5 ++ crates/flagsmith/src/lib.rs | 70 ++++++++++++--------- crates/flagsmith/tests/integration_tests.rs | 30 +++++++++ 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/crates/flagsmith/README.md b/crates/flagsmith/README.md index aca4c9d..1708b10 100644 --- a/crates/flagsmith/README.md +++ b/crates/flagsmith/README.md @@ -72,6 +72,9 @@ let enabled = client ## 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(); @@ -86,6 +89,8 @@ let multiplier = client.get_float_value("price-multiplier", &context, None).awai // 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: diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index c4ae217..3cd49ae 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -253,11 +253,15 @@ impl FlagsmithProvider { // 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)) - })?; + 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))) } @@ -393,18 +397,17 @@ impl FeatureProvider for FlagsmithProvider { // 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 - )), - })? - } + 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, @@ -442,18 +445,17 @@ impl FeatureProvider for FlagsmithProvider { // 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 - )), - })? - } + 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, @@ -488,6 +490,16 @@ impl FeatureProvider for FlagsmithProvider { 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, diff --git a/crates/flagsmith/tests/integration_tests.rs b/crates/flagsmith/tests/integration_tests.rs index 9c56793..34b104d 100644 --- a/crates/flagsmith/tests/integration_tests.rs +++ b/crates/flagsmith/tests/integration_tests.rs @@ -290,6 +290,36 @@ async fn test_resolve_struct_value() { 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![]); From af6eebfe5f8c5ce53f7e693961e26caf840c33f9 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 16:05:52 +0100 Subject: [PATCH 16/20] feat: linter Signed-off-by: wadii --- crates/flagsmith/tests/integration_tests.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/flagsmith/tests/integration_tests.rs b/crates/flagsmith/tests/integration_tests.rs index 34b104d..a6222f6 100644 --- a/crates/flagsmith/tests/integration_tests.rs +++ b/crates/flagsmith/tests/integration_tests.rs @@ -310,14 +310,13 @@ async fn test_resolve_struct_value_type_mismatch() { assert!(result.is_err()); let error = result.unwrap_err(); - assert_eq!( - error.code, - open_feature::EvaluationErrorCode::TypeMismatch + assert_eq!(error.code, open_feature::EvaluationErrorCode::TypeMismatch); + assert!( + error + .message + .unwrap() + .contains("Expected string type for JSON") ); - assert!(error - .message - .unwrap() - .contains("Expected string type for JSON")); } #[tokio::test] From 8d8a12898f0005f05081c39b98a3477ac9da4da5 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 16:21:28 +0100 Subject: [PATCH 17/20] feat: fixed-null-versus-empty-strings Signed-off-by: wadii --- crates/flagsmith/src/error.rs | 7 ------- crates/flagsmith/src/lib.rs | 24 +++++++++++------------- crates/flagsmith/tests/unit_tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/crates/flagsmith/src/error.rs b/crates/flagsmith/src/error.rs index c7c3d32..53395bd 100644 --- a/crates/flagsmith/src/error.rs +++ b/crates/flagsmith/src/error.rs @@ -59,13 +59,6 @@ impl From for FlagsmithError { } } -/// Convert serde_json errors to FlagsmithError -impl From for FlagsmithError { - fn from(error: serde_json::Error) -> Self { - FlagsmithError::Evaluation(format!("JSON parse error: {}", error)) - } -} - /// Map FlagsmithError to OpenFeature EvaluationError impl From for open_feature::EvaluationError { fn from(error: FlagsmithError) -> Self { diff --git a/crates/flagsmith/src/lib.rs b/crates/flagsmith/src/lib.rs index 3cd49ae..19a3a7b 100644 --- a/crates/flagsmith/src/lib.rs +++ b/crates/flagsmith/src/lib.rs @@ -360,16 +360,8 @@ impl FeatureProvider for FlagsmithProvider { 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, but flag '{}' has type {:?}", - flag_key, flag.value.value_type - )), - }); - } - + // 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); @@ -510,8 +502,11 @@ impl FeatureProvider for FlagsmithProvider { JsonValue::Object(map) => { let mut struct_map = std::collections::HashMap::new(); for (key, json_val) in map { - let of_value = json_to_open_feature_value(json_val); - struct_map.insert(key, of_value); + // 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 } } @@ -559,7 +554,10 @@ pub fn json_to_open_feature_value(json_val: JsonValue) -> Value { JsonValue::Object(map) => { let mut fields = std::collections::HashMap::new(); for (k, v) in map { - fields.insert(k, json_to_open_feature_value(v)); + // 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 }) } diff --git a/crates/flagsmith/tests/unit_tests.rs b/crates/flagsmith/tests/unit_tests.rs index e07276e..a8eff0f 100644 --- a/crates/flagsmith/tests/unit_tests.rs +++ b/crates/flagsmith/tests/unit_tests.rs @@ -201,6 +201,31 @@ fn test_json_to_open_feature_value_object() { } } +#[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!({ From 8ff1949fac8c03573e55f38cc3d823700ad0bdac Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 27 Nov 2025 09:34:42 +0100 Subject: [PATCH 18/20] feat: added-component-owner Signed-off-by: wadii --- .github/component_owners.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/component_owners.yml b/.github/component_owners.yml index beb36d0..c6bf0bf 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -11,6 +11,8 @@ components: crates/ofrep: - erenatas - Rahul-Baradol + crates/flagsmith: + - zaimwa9 ignored-authors: - renovate-bot From b2eee75c47fda086406f6080678cecc9834734db Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 1 Dec 2025 10:25:32 +0100 Subject: [PATCH 19/20] feat: added-component-owner Signed-off-by: wadii --- .github/component_owners.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/component_owners.yml b/.github/component_owners.yml index c6bf0bf..fc47a69 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -12,6 +12,7 @@ components: - erenatas - Rahul-Baradol crates/flagsmith: + - matthewelwell - zaimwa9 ignored-authors: From 9ce227f605fb480feb5acc33fa6b0bf9365d9a35 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 4 Dec 2025 10:47:02 +0100 Subject: [PATCH 20/20] feat: bumped-version-to-flagsmith-sdk-2.1 Signed-off-by: wadii --- crates/flagsmith/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/flagsmith/Cargo.toml b/crates/flagsmith/Cargo.toml index 8f1b5ce..48fa41a 100644 --- a/crates/flagsmith/Cargo.toml +++ b/crates/flagsmith/Cargo.toml @@ -16,7 +16,7 @@ keywords = ["openfeature", "feature-flags", "flagsmith"] open-feature = "0.2" # Flagsmith SDK -flagsmith = "2.0" +flagsmith = "2.1" # Async runtime tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } @@ -39,7 +39,7 @@ url = "2.0" reqwest = { version = "0.11", default-features = false } # Flagsmith flag engine types (must match flagsmith version) -flagsmith-flag-engine = "0.4" +flagsmith-flag-engine = "0.5" [dev-dependencies] # Testing