Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/component_owners.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ components:
crates/ofrep:
- erenatas
- Rahul-Baradol
crates/flagsmith:
- matthewelwell
- zaimwa9

ignored-authors:
- renovate-bot
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ edition = "2024"
members = [
"crates/env-var",
"crates/flagd",
"crates/flagsmith",
"crates/flipt",
"crates/ofrep"
]
51 changes: 51 additions & 0 deletions crates/flagsmith/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions crates/flagsmith/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
[package]
name = "open-feature-flagsmith"
version = "0.1.0"
authors = ["OpenFeature Maintainers"]
edition = "2024"
license = "Apache-2.0"
description = "Flagsmith provider for OpenFeature"
homepage = "https://openfeature.dev"
repository = "https://github.com/open-feature/rust-sdk-contrib"
readme = "README.md"
categories = ["config", "api-bindings"]
keywords = ["openfeature", "feature-flags", "flagsmith"]

[dependencies]
# OpenFeature SDK
open-feature = "0.2"

# Flagsmith SDK
flagsmith = "2.1"

# Async runtime
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
async-trait = "0.1"

# Error handling
thiserror = "1.0"

# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

# Logging
tracing = "0.1"

# URL validation
url = "2.0"

# HTTP client (for HeaderMap type)
reqwest = { version = "0.11", default-features = false }

# Flagsmith flag engine types (must match flagsmith version)
flagsmith-flag-engine = "0.5"

[dev-dependencies]
# Testing
test-log = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
159 changes: 159 additions & 0 deletions crates/flagsmith/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Flagsmith Provider for OpenFeature

A Rust implementation of the OpenFeature provider for Flagsmith, enabling dynamic feature flag evaluation using the Flagsmith platform.

This provider integrates the [Flagsmith Rust SDK](https://github.com/Flagsmith/flagsmith-rust-client) with [OpenFeature](https://openfeature.dev/), supporting both environment-level and identity-specific flag evaluation with trait-based targeting.

## Features

- **Environment-level evaluation**: Evaluate flags at the environment level without user context
- **Identity-specific evaluation**: Target users with personalized flag values based on traits
- **Type safety**: Full support for boolean, string, integer, float, and structured (JSON) flag types
- **Local evaluation**: Optional local evaluation mode for improved performance and offline support
- **Async support**: Built on Tokio with non-blocking flag evaluations

## Installation

Add the dependency in your `Cargo.toml`:
```bash
cargo add open-feature-flagsmith
cargo add open-feature
```

## Basic Usage

```rust
use open_feature::OpenFeature;
use open_feature::EvaluationContext;
use open_feature_flagsmith::{FlagsmithProvider, FlagsmithOptions};

#[tokio::main]
async fn main() {
// Initialize the provider
let provider = FlagsmithProvider::new(
"your-environment-key".to_string(),
FlagsmithOptions::default()
).await.unwrap();

// Set up OpenFeature API
let mut api = OpenFeature::singleton_mut().await;
api.set_provider(provider).await;
let client = api.create_client();

// Evaluate a flag
let context = EvaluationContext::default();
let enabled = client
.get_bool_value("my-feature", &context, None)
.await
.unwrap_or(false);

println!("Feature enabled: {}", enabled);
}
```

## Identity-Specific Evaluation

```rust
use open_feature::EvaluationContext;

// Create context with targeting key and user traits
let context = EvaluationContext::default()
.with_targeting_key("user-123")
.with_custom_field("email", "user@example.com")
.with_custom_field("plan", "premium")
.with_custom_field("age", 25);

let enabled = client
.get_bool_value("premium-feature", &context, None)
.await
.unwrap_or(false);
```

## Flag Types

```rust
// Assuming you have set up the client as shown in the Basic Usage section
let context = EvaluationContext::default();

// Boolean flags
let enabled = client.get_bool_value("feature-toggle", &context, None).await.unwrap();

// String flags
let theme = client.get_string_value("theme", &context, None).await.unwrap();

// Integer flags
let max_items = client.get_int_value("max-items", &context, None).await.unwrap();

// Float flags
let multiplier = client.get_float_value("price-multiplier", &context, None).await.unwrap();

// Structured flags (JSON objects)
let config = client.get_object_value("config", &context, None).await.unwrap();
```

## Local Evaluation

Local evaluation mode downloads the environment configuration and evaluates flags locally for better performance:

```rust
use open_feature_flagsmith::FlagsmithOptions;

// Requires a server-side environment key (starts with "ser.")
let provider = FlagsmithProvider::new(
"ser.your-server-key".to_string(),
FlagsmithOptions::default()
.with_local_evaluation(true)
).await.unwrap();
```

**Benefits:**
- Lower latency (no API calls per evaluation)
- Works offline (uses cached environment)
- Reduced API load

**Requirements:**
- Server-side environment key (starts with `ser.`)
- Initial API call to fetch environment
- Periodic polling to refresh (default: 60s)

## Configuration Options

Configurations can be provided as constructor options:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api_url` | `Option<String>` | Flagsmith Edge API | Custom Flagsmith API endpoint |
| `request_timeout_seconds` | `Option<u64>` | 10 | Request timeout in seconds |
| `enable_local_evaluation` | `bool` | `false` | Enable local evaluation mode |
| `environment_refresh_interval_mills` | `Option<u64>` | 60000 | Polling interval for local mode (ms) |
| `enable_analytics` | `bool` | `false` | Enable analytics tracking |
| `custom_headers` | `Option<HeaderMap>` | 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.
86 changes: 86 additions & 0 deletions crates/flagsmith/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use thiserror::Error;

/// Error message returned by Flagsmith SDK when a flag is not found.
///
/// This constant matches the hardcoded error message in the Flagsmith Rust SDK v2.0
/// (flagsmith/src/flagsmith/models.rs, Flags::get_flag method).
/// When a flag key doesn't exist in the flags HashMap and no default_flag_handler
/// is configured, the SDK returns a FlagsmithAPIError with this exact message.
///
/// Note: This is a known limitation of the current SDK error reporting. A more robust
/// approach would be for the SDK to provide a structured error variant (e.g.,
/// ErrorKind::FlagNotFound), but until that's available, we must rely on string matching.
/// This matching approach is used by other Flagsmith provider implementations as well.
const FLAGSMITH_FLAG_NOT_FOUND_MSG: &str = "API returned invalid response";

/// Custom error types for the Flagsmith provider.
#[derive(Error, Debug, PartialEq)]
pub enum FlagsmithError {
/// Configuration error (invalid options during initialization)
#[error("Configuration error: {0}")]
Config(String),

/// API or network error (connection issues, timeouts, etc.)
#[error("API error: {0}")]
Api(String),

/// Flag evaluation error (flag not found, type mismatch, etc.)
#[error("Evaluation error: {0}")]
Evaluation(String),

/// Flag not found error
#[error("Flag not found: {0}")]
FlagNotFound(String),
}

/// Convert Flagsmith SDK errors to FlagsmithError
impl From<flagsmith::error::Error> for FlagsmithError {
fn from(error: flagsmith::error::Error) -> Self {
match error.kind {
flagsmith::error::ErrorKind::FlagsmithAPIError => {
// Check if this is a "flag not found" error by matching the SDK's error message
if error.msg == FLAGSMITH_FLAG_NOT_FOUND_MSG {
FlagsmithError::FlagNotFound(error.msg)
} else {
FlagsmithError::Api(error.msg)
}
}
flagsmith::error::ErrorKind::FlagsmithClientError => {
FlagsmithError::Evaluation(error.msg)
}
}
}
}

/// Convert URL parse errors to FlagsmithError
impl From<url::ParseError> for FlagsmithError {
fn from(error: url::ParseError) -> Self {
FlagsmithError::Config(format!("Invalid URL: {}", error))
}
}

/// Map FlagsmithError to OpenFeature EvaluationError
impl From<FlagsmithError> for open_feature::EvaluationError {
fn from(error: FlagsmithError) -> Self {
use open_feature::EvaluationErrorCode;

match error {
FlagsmithError::Config(msg) => open_feature::EvaluationError {
code: EvaluationErrorCode::General("Configuration error".to_string()),
message: Some(msg),
},
FlagsmithError::Api(msg) => open_feature::EvaluationError {
code: EvaluationErrorCode::ProviderNotReady,
message: Some(msg),
},
FlagsmithError::Evaluation(msg) => open_feature::EvaluationError {
code: EvaluationErrorCode::General("Evaluation error".to_string()),
message: Some(msg),
},
FlagsmithError::FlagNotFound(msg) => open_feature::EvaluationError {
code: EvaluationErrorCode::FlagNotFound,
message: Some(msg),
},
}
}
}
Loading