diff --git a/.github/component_owners.yml b/.github/component_owners.yml index d017be9..a59ce1f 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -9,6 +9,8 @@ components: - thomaspoignant providers/openfeature-meta_provider: - maxveldink + providers/openfeature-flagsmith-provider: + - zaimwa9 ignored-authors: - renovate-bot diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f49fdf3..e900e3e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,6 @@ "providers/openfeature-flagd-provider": "0.1.2", "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", - "providers/openfeature-go-feature-flag-provider": "0.1.7" + "providers/openfeature-go-feature-flag-provider": "0.1.7", + "providers/openfeature-flagsmith-provider": "0.1.0" } diff --git a/providers/openfeature-flagsmith-provider/.context.md b/providers/openfeature-flagsmith-provider/.context.md new file mode 100644 index 0000000..b499f7a --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.context.md @@ -0,0 +1,316 @@ +# Flagsmith OpenFeature Provider - Context + +**Started:** 2025-11-17 +**Full Design Doc:** `./FLAGSMITH_PROVIDER_DESIGN.md` + +--- + +## Quick Facts + +**What we're building:** OpenFeature provider for Flagsmith Ruby SDK +**Target Flagsmith version:** Upcoming release (TBD) + +--- + +## Key Design Decisions ✅ + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Evaluation mode | Remote (default) | Simpler, no polling. Local available via config | +| No targeting_key | Fall back to environment flags | `get_environment_flags()` vs `get_identity_flags()` | +| Analytics | Opt-in (disabled default) | Privacy-first approach | +| Default values | Use OpenFeature's default_value | Match other providers, no custom handler | +| Configuration | Options object pattern | Clear validation, like GO Feature Flag | + +--- + +## Architecture Map + +### Directory Structure +``` +openfeature-flagsmith-provider/ +├── lib/openfeature/flagsmith/ +│ ├── provider.rb # Main provider class +│ ├── configuration.rb # Options/config with validation +│ ├── error/errors.rb # Custom exceptions → ErrorCode mapping +│ └── version.rb # VERSION constant +├── spec/ # RSpec tests with WebMock +├── openfeature-flagsmith-provider.gemspec +└── README.md +``` + +### Key Classes + +**Configuration** (lib/openfeature/flagsmith/configuration.rb): +```ruby +OpenFeature::Flagsmith::Configuration.new( + environment_key: "required", + api_url: "https://edge.api.flagsmith.com/api/v1/", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false +) +``` + +**Provider** (lib/openfeature/flagsmith/provider.rb): +```ruby +class Provider + attr_reader :metadata + + # Required methods: + def fetch_boolean_value(flag_key:, default_value:, evaluation_context:) + def fetch_string_value(flag_key:, default_value:, evaluation_context:) + def fetch_number_value(flag_key:, default_value:, evaluation_context:) + def fetch_integer_value(flag_key:, default_value:, evaluation_context:) + def fetch_float_value(flag_key:, default_value:, evaluation_context:) + def fetch_object_value(flag_key:, default_value:, evaluation_context:) +end +``` + +--- + +## Critical Mappings + +### 1. Context → Identity/Traits +```ruby +# OpenFeature → Flagsmith +evaluation_context.targeting_key → identifier (for get_identity_flags) +evaluation_context.fields → traits (as keyword args) + +# If no targeting_key → use get_environment_flags() +``` + +### 2. Flag Types +| Flagsmith | OpenFeature Method | Implementation | +|-----------|-------------------|----------------| +| `is_feature_enabled()` → Boolean | `fetch_boolean_value` | Direct mapping | +| `get_feature_value()` → String | `fetch_string_value` | Direct return | +| `get_feature_value()` → Number | `fetch_number_value` | Parse & validate type | +| `get_feature_value()` → JSON | `fetch_object_value` | `JSON.parse()` | + +### 3. Reason Mapping +| Situation | OpenFeature Reason | +|-----------|-------------------| +| Flag evaluated with identity | `TARGETING_MATCH` | +| Flag evaluated at environment level | `STATIC` | +| Flag not found | `DEFAULT` | +| Flag exists but disabled | `DISABLED` | +| Error occurred | `ERROR` | + +### 4. Error Handling +```ruby +# Pattern: Always return ResolutionDetails with default_value on error +SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: , + error_message: "description", + reason: SDK::Provider::Reason::ERROR +) +``` + +**ErrorCode mapping:** +- Flag not found → `FLAG_NOT_FOUND` +- Wrong type → `TYPE_MISMATCH` +- Network error → `PROVIDER_NOT_READY` or `GENERAL` +- Invalid context → `INVALID_CONTEXT` + +--- + +## Flagsmith SDK API Reference + +### Initialization +```ruby +require "flagsmith" +client = Flagsmith::Client.new( + environment_key: "key", + api_url: "url", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false +) +``` + +### Evaluation Methods +```ruby +# Environment-level (no user) +flags = client.get_environment_flags() +flags.is_feature_enabled('flag_key') # → Boolean +flags.get_feature_value('flag_key') # → String/value + +# Identity-specific (with user context) +flags = client.get_identity_flags('user@example.com', trait1: 'value', age: 30) +flags.is_feature_enabled('flag_key') +flags.get_feature_value('flag_key') +``` + +--- + +## OpenFeature Provider Contract + +### Required Interface +```ruby +# Metadata +attr_reader :metadata # ProviderMetadata.new(name: "Flagsmith Provider") + +# Lifecycle (optional) +def init # Optional initialization +def shutdown # Optional cleanup + +# Evaluation methods (required) +def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) +``` + +### Return Type +```ruby +OpenFeature::SDK::Provider::ResolutionDetails.new( + value: , # Required + reason: , # Required + variant: "variant_key", # Optional + flag_metadata: {key: "value"}, # Optional + error_code: , # If error + error_message: "details" # If error +) +``` + +--- + +## Code Patterns to Follow + +### Type Validation Pattern +```ruby +def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context:) + # ... get value from Flagsmith ... + + unless allowed_classes.include?(value.class) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "Expected #{allowed_classes}, got #{value.class}", + reason: SDK::Provider::Reason::ERROR + ) + end + + # return success ResolutionDetails +end +``` + +### Error Rescue Pattern +```ruby +begin + # Flagsmith evaluation +rescue SomeError => e + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::GENERAL, + error_message: e.message, + reason: SDK::Provider::Reason::ERROR + ) +end +``` + +--- + +## Dependencies + +### Runtime +```ruby +spec.add_runtime_dependency "openfeature-sdk", "~> 0.3.1" +spec.add_runtime_dependency "flagsmith", "~> " +``` + +### Development +```ruby +spec.add_development_dependency "rake", "~> 13.0" +spec.add_development_dependency "rspec", "~> 3.12.0" +spec.add_development_dependency "webmock", "~> 3.0" # Mock Flagsmith calls +spec.add_development_dependency "standard" +spec.add_development_dependency "rubocop" +spec.add_development_dependency "simplecov" +``` + +--- + +## Testing Strategy + +### Mock Flagsmith Responses +```ruby +# Use WebMock to stub Flagsmith API calls +# OR stub Flagsmith::Client methods directly + +allow(flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) +allow(mock_flags).to receive(:is_feature_enabled).and_return(true) +allow(mock_flags).to receive(:get_feature_value).and_return("value") +``` + +### Test Coverage Areas +- ✅ Metadata verification +- ✅ Configuration validation +- ✅ Each flag type evaluation (boolean, string, number, integer, float, object) +- ✅ Type mismatches +- ✅ Missing flags (return default) +- ✅ Error handling +- ✅ Context mapping (with/without targeting_key) +- ✅ Environment vs Identity evaluation + +--- + +## Implementation Checklist + +- [ ] Directory structure created +- [ ] Gemspec with dependencies +- [ ] Configuration class with validation +- [ ] Provider skeleton with metadata +- [ ] Context → Identity/Traits mapping helper +- [ ] `fetch_boolean_value` implementation +- [ ] `fetch_string_value` implementation +- [ ] `fetch_number_value` implementation +- [ ] `fetch_integer_value` implementation +- [ ] `fetch_float_value` implementation +- [ ] `fetch_object_value` implementation +- [ ] Error handling and custom exceptions +- [ ] Type validation +- [ ] RSpec test suite +- [ ] README with examples +- [ ] Release configuration + +--- + +## Open Items / Notes + +- **Flagsmith version**: Waiting for upcoming release - update gemspec when available +- **Variant support**: Flagsmith doesn't have explicit variants - TBD how to handle +- **Flag metadata**: Flagsmith has limited metadata - may need to extract from traits/response + +--- + +## Quick Commands + +```bash +# Run tests +bundle exec rspec + +# Run linter +bundle exec rubocop + +# Install locally for testing +gem build openfeature-flagsmith-provider.gemspec +gem install ./openfeature-flagsmith-provider-.gem + +# Test with real Flagsmith +# (add example script in spec/manual_test.rb) +``` + +--- + +## References + +- Full design doc: `../FLAGSMITH_PROVIDER_DESIGN.md` +- Flagsmith docs: https://docs.flagsmith.com/clients/server-side +- OpenFeature spec: https://openfeature.dev/specification/ +- GO Feature Flag provider: `../openfeature-go-feature-flag-provider/` +- flagd provider: `../openfeature-flagd-provider/` diff --git a/providers/openfeature-flagsmith-provider/.gitignore b/providers/openfeature-flagsmith-provider/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/providers/openfeature-flagsmith-provider/.rspec b/providers/openfeature-flagsmith-provider/.rspec new file mode 100644 index 0000000..44b132b --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.rspec @@ -0,0 +1,4 @@ +-I lib +--format documentation +--color +--require spec_helper diff --git a/providers/openfeature-flagsmith-provider/.rubocop.yml b/providers/openfeature-flagsmith-provider/.rubocop.yml new file mode 100644 index 0000000..feec135 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.rubocop.yml @@ -0,0 +1,5 @@ +inherit_from: ../../shared_config/.rubocop.yml + +inherit_mode: + merge: + - Exclude diff --git a/providers/openfeature-flagsmith-provider/.ruby-version b/providers/openfeature-flagsmith-provider/.ruby-version new file mode 100644 index 0000000..0aec50e --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.ruby-version @@ -0,0 +1 @@ +3.1.4 diff --git a/providers/openfeature-flagsmith-provider/CHANGELOG.md b/providers/openfeature-flagsmith-provider/CHANGELOG.md new file mode 100644 index 0000000..5cacd95 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/CHANGELOG.md @@ -0,0 +1,15 @@ +# 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). + +## [Unreleased] + +### Added +- Initial implementation of Flagsmith OpenFeature provider +- Support for all OpenFeature flag types (boolean, string, number, integer, float, object) +- Remote and local evaluation modes +- Environment-level and identity-specific flag evaluation +- Comprehensive error handling and type validation diff --git a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md new file mode 100644 index 0000000..c0461f8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md @@ -0,0 +1,393 @@ +# Flagsmith OpenFeature Provider - Design Document + +**Created:** 2025-11-17 +**Status:** Design Phase +**Target:** OpenFeature Ruby SDK integration with Flagsmith + +--- + +## 1. Research Summary + +### 1.1 Flagsmith Ruby SDK Details + +**Gem Name:** `flagsmith` +**Latest Version:** v4.3.0 (as of December 2024) +**Ruby Version:** Requires Ruby 2.4+ +**GitHub:** https://github.com/Flagsmith/flagsmith-ruby-client +**Documentation:** https://docs.flagsmith.com/clients/server-side + +#### Installation +```ruby +gem install flagsmith +``` + +#### Basic Initialization +```ruby +require "flagsmith" +$flagsmith = Flagsmith::Client.new( + environment_key: 'FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY' +) +``` + +#### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `environment_key` | String | **Required** | Server-side authentication token | +| `api_url` | String | "https://edge.api.flagsmith.com/api/v1/" | Custom self-hosted endpoint | +| `enable_local_evaluation` | Boolean | false | Local vs. remote flag evaluation mode | +| `request_timeout_seconds` | Integer | 10 | Network request timeout | +| `environment_refresh_interval_seconds` | Integer | 60 | Polling interval in local mode | +| `enable_analytics` | Boolean | false | Send usage analytics to Flagsmith | +| `default_flag_handler` | Lambda | nil | Fallback for missing/failed flags | + +#### Flag Evaluation Methods + +**Environment-level (no user context):** +```ruby +flags = $flagsmith.get_environment_flags() +show_button = flags.is_feature_enabled('secret_button') +button_data = flags.get_feature_value('secret_button') +``` + +**Identity-specific (with user context):** +```ruby +identifier = 'user@example.com' +traits = {'car_type': 'sedan', 'age': 30} +flags = $flagsmith.get_identity_flags(identifier, **traits) +show_button = flags.is_feature_enabled('secret_button') +value = flags.get_feature_value('secret_button') +``` + +#### Evaluation Modes +- **Remote Evaluation** (default): Blocking HTTP requests per flag fetch +- **Local Evaluation**: Asynchronous polling (~60 sec intervals) +- **Offline Mode**: Requires custom `offline_handler` + +#### Default Flag Handler Pattern +```ruby +$flagsmith = Flagsmith::Client.new( + environment_key: '', + default_flag_handler: lambda { |feature_name| + Flagsmith::Flags::DefaultFlag.new( + enabled: false, + value: {'colour': '#ababab'}.to_json + ) + } +) +``` + +--- + +## 2. OpenFeature Provider Patterns (from repo analysis) + +### 2.1 Required Provider Interface + +All providers must implement: +```ruby +class Provider + attr_reader :metadata # Returns ProviderMetadata with name + + # Lifecycle (optional) + def init + def shutdown + + # Required evaluation methods + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) +end +``` + +### 2.2 Return Type: ResolutionDetails + +All fetch_* methods must return: +```ruby +OpenFeature::SDK::Provider::ResolutionDetails.new( + value: , # The flag value + reason: , # TARGETING_MATCH, DEFAULT, DISABLED, ERROR, etc. + variant: "variant_key", # Optional variant identifier + flag_metadata: { ... }, # Optional metadata + error_code: , # If error occurred + error_message: "Error details" # If error occurred +) +``` + +#### OpenFeature Reason Constants +- `TARGETING_MATCH` - Flag evaluated with targeting rules +- `DEFAULT` - Default value used +- `DISABLED` - Feature is disabled +- `ERROR` - Error during evaluation +- `STATIC` - Static value + +#### OpenFeature ErrorCode Constants +- `PROVIDER_NOT_READY` +- `FLAG_NOT_FOUND` +- `TYPE_MISMATCH` +- `PARSE_ERROR` +- `TARGETING_KEY_MISSING` +- `INVALID_CONTEXT` +- `GENERAL` + +### 2.3 Configuration Patterns Used in Repo + +**Pattern 1: Options Object** (Used by GO Feature Flag provider) +```ruby +class Options + def initialize(endpoint:, headers: {}, ...) + validate_endpoint(endpoint) + @endpoint = endpoint + @headers = headers + end +end +``` + +**Pattern 2: Block-Based Configuration** (Used by flagd provider) +```ruby +OpenFeature::Flagd::Provider.configure do |config| + config.host = "localhost" + config.port = 8013 +end +``` + +### 2.4 Error Handling Pattern + +Create custom exception hierarchy: +```ruby +class FlagsmithError < StandardError + attr_reader :error_code, :error_message + + def initialize(error_code, error_message) + @error_code = error_code # Maps to SDK::Provider::ErrorCode + @error_message = error_message + super(error_message) + end +end + +class FlagNotFoundError < FlagsmithError +class TypeMismatchError < FlagsmithError +class ConfigurationError < FlagsmithError +``` + +### 2.5 Type Validation Pattern + +```ruby +def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context: nil) + # ... evaluation logic ... + + unless allowed_classes.include?(value.class) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type #{value.class} does not match allowed types #{allowed_classes}", + reason: SDK::Provider::Reason::ERROR + ) + end +end +``` + +--- + +## 3. Proposed Flagsmith Provider Architecture + +### 3.1 Directory Structure + +``` +providers/openfeature-flagsmith-provider/ +├── lib/ +│ └── openfeature/ +│ └── flagsmith/ +│ ├── provider.rb # Main provider class +│ ├── configuration.rb # Configuration/options class +│ ├── error/ +│ │ └── errors.rb # Custom exception hierarchy +│ └── version.rb # Version constant +├── spec/ +│ ├── spec_helper.rb +│ ├── provider_spec.rb +│ ├── configuration_spec.rb +│ └── fixtures/ # Mock responses +├── openfeature-flagsmith-provider.gemspec +├── README.md +├── CHANGELOG.md +├── Gemfile +└── Rakefile +``` + +### 3.2 Key Design Decisions + +#### Configuration Strategy +**Chosen: Options Object Pattern** + +Reasoning: +- Flagsmith has many configuration options (api_url, timeouts, evaluation mode, etc.) +- Options object provides clear validation +- Aligns with GO Feature Flag provider pattern (most similar use case) + +```ruby +options = OpenFeature::Flagsmith::Configuration.new( + environment_key: "your_key", + api_url: "https://edge.api.flagsmith.com/api/v1/", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false +) + +provider = OpenFeature::Flagsmith::Provider.new(configuration: options) +``` + +#### Evaluation Context Mapping + +OpenFeature EvaluationContext → Flagsmith Identity + Traits: +- `evaluation_context.targeting_key` → Flagsmith identity identifier +- All other `evaluation_context.fields` → Flagsmith traits + +```ruby +def map_context_to_identity(evaluation_context) + return [nil, {}] if evaluation_context.nil? + + identifier = evaluation_context.targeting_key + traits = evaluation_context.fields.reject { |k, _v| k == :targeting_key } + + [identifier, traits] +end +``` + +#### Flag Type Mapping + +| Flagsmith Type | OpenFeature Type | Notes | +|----------------|------------------|-------| +| Boolean enabled | `fetch_boolean_value` | Use `is_feature_enabled` | +| String value | `fetch_string_value` | Use `get_feature_value` | +| Numeric value | `fetch_number_value` | Parse and validate | +| JSON value | `fetch_object_value` | Parse JSON string | + +#### Error Handling Strategy + +1. **Flagsmith errors** → Map to OpenFeature ErrorCodes +2. **Network errors** → `PROVIDER_NOT_READY` or `GENERAL` +3. **Type mismatches** → `TYPE_MISMATCH` +4. **Missing flags** → Return default with `FLAG_NOT_FOUND` + +#### Reason Mapping + +| Flagsmith State | OpenFeature Reason | +|-----------------|-------------------| +| Flag evaluated with identity | `TARGETING_MATCH` | +| Flag evaluated at environment level | `STATIC` | +| Flag not found | `DEFAULT` | +| Flag disabled | `DISABLED` | +| Error occurred | `ERROR` | + +--- + +## 4. Implementation Plan + +### Phase 1: Core Structure +1. Create directory structure +2. Setup gemspec with dependencies +3. Create Configuration class with validation +4. Create Provider class skeleton with metadata + +### Phase 2: Flag Evaluation +5. Implement `fetch_boolean_value` (simplest case) +6. Implement context → identity/traits mapping +7. Add error handling for boolean evaluation +8. Implement remaining fetch_* methods + +### Phase 3: Advanced Features +9. Handle default_flag_handler integration +10. Support local evaluation mode +11. Add proper lifecycle management (init/shutdown) + +### Phase 4: Testing & Documentation +12. Create RSpec test suite with mocked Flagsmith responses +13. Write comprehensive README +14. Add usage examples +15. Configure release automation + +--- + +## 5. Open Questions & Decisions Needed + +### 5.1 Design Decisions - RESOLVED ✅ + +1. **Evaluation Mode Preference** + - ✅ **Default to remote evaluation** (simpler, no polling overhead) + - Configurable via `enable_local_evaluation` option + +2. **Analytics** + - ✅ **Opt-in** (`enable_analytics: false` by default) + +3. **Default Flag Handler** + - ✅ **Use OpenFeature's default_value** (matches other providers) + - Do NOT implement Flagsmith's `default_flag_handler` + - Return `default_value` with appropriate error_code/reason on failures + +4. **Targeting Key Requirement** + - ✅ **Fall back to environment-level flags** if no targeting_key + - Use `get_environment_flags()` when targeting_key is nil/empty + - Use `get_identity_flags()` when targeting_key is present + +5. **Version Compatibility** + - ✅ **Target upcoming version** (will be released soon) + - Update dependency when new version is available + +### 5.2 Technical Considerations + +**Type Detection Challenge:** +Flagsmith's `get_feature_value` returns values as strings/JSON. We need to: +- Parse JSON for objects +- Detect numeric types +- Handle type mismatches gracefully + +**Variant Support:** +Flagsmith doesn't have explicit "variants" like some systems. Options: +- Use feature key as variant +- Leave variant nil +- Use enabled/disabled as variant + +**Metadata:** +Flagsmith flags don't inherently have metadata beyond enabled/value. We could: +- Include trait data as flag_metadata +- Leave empty +- Add custom metadata extraction + +--- + +## 6. Dependencies + +### Runtime +- `openfeature-sdk` (~> 0.3.1) +- `flagsmith` (~> 4.3.0) + +### Development +- `rake` (~> 13.0) +- `rspec` (~> 3.12.0) +- `webmock` (~> 3.0) - for mocking Flagsmith HTTP calls +- `standard` - Ruby linter +- `rubocop` - Code style +- `simplecov` - Test coverage + +--- + +## 7. Next Steps + +1. **User Decisions** - Get answers to open questions above +2. **Proof of Concept** - Build minimal provider with boolean support +3. **Validate Approach** - Test with real Flagsmith instance +4. **Expand** - Add remaining types and features +5. **Polish** - Tests, docs, release config + +--- + +## 8. References + +- OpenFeature Specification: https://openfeature.dev/specification/ +- Flagsmith Docs: https://docs.flagsmith.com/clients/server-side +- Flagsmith Ruby Client: https://github.com/Flagsmith/flagsmith-ruby-client +- GO Feature Flag Provider (reference impl): `providers/openfeature-go-feature-flag-provider/` +- flagd Provider (reference impl): `providers/openfeature-flagd-provider/` diff --git a/providers/openfeature-flagsmith-provider/Gemfile b/providers/openfeature-flagsmith-provider/Gemfile new file mode 100644 index 0000000..d42b4e0 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/Gemfile @@ -0,0 +1,11 @@ +source "https://rubygems.org" + +gemspec + +gem "flagsmith", "~> 4.3" +gem "rake", "~> 13.0" +gem "rspec", "~> 3.12.0" +gem "webmock", "~> 3.0" +gem "standard", "~> 1.0" +gem "rubocop", "~> 1.0" +gem "simplecov", "~> 0.22" diff --git a/providers/openfeature-flagsmith-provider/Gemfile.lock b/providers/openfeature-flagsmith-provider/Gemfile.lock new file mode 100644 index 0000000..7f25610 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/Gemfile.lock @@ -0,0 +1,126 @@ +PATH + remote: . + specs: + openfeature-flagsmith-provider (0.1.0) + flagsmith (~> 4.3) + openfeature-sdk (~> 0.3.1) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + bigdecimal (3.3.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + docile (1.4.1) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.3.2) + faraday (~> 2.0) + flagsmith (4.3.0) + faraday (>= 2.0.1) + faraday-retry + semantic + hashdiff (1.2.1) + json (2.16.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + net-http (0.8.0) + uri (>= 0.11.1) + openfeature-sdk (0.3.1) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + prism (1.6.0) + public_suffix (6.0.2) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + rexml (3.4.4) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.3) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.4) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.2) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (1.13.0) + semantic (1.6.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + standard (1.35.0.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.62) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + flagsmith (~> 4.3) + openfeature-flagsmith-provider! + rake (~> 13.0) + rspec (~> 3.12.0) + rubocop (~> 1.0) + simplecov (~> 0.22) + standard (~> 1.0) + webmock (~> 3.0) + +BUNDLED WITH + 2.6.9 diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md new file mode 100644 index 0000000..4d35b5a --- /dev/null +++ b/providers/openfeature-flagsmith-provider/README.md @@ -0,0 +1,324 @@ +# OpenFeature Flagsmith Provider for Ruby + +This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature flags, implementing the [OpenFeature](https://openfeature.dev/) standard. + +## Features + +| Status | Feature | Description | +|--------|---------|-------------| +| ✅ | Flag Evaluation | Support for all OpenFeature flag types | +| ✅ | Boolean Flags | Evaluate boolean feature flags | +| ✅ | String Flags | Evaluate string feature flags | +| ✅ | Number Flags | Evaluate numeric feature flags (int, float) | +| ✅ | Object Flags | Evaluate JSON object/array flags | +| ✅ | Evaluation Context | Support for user identity and traits | +| ✅ | Environment Flags | Evaluate flags at environment level | +| ✅ | Identity Flags | Evaluate flags for specific users | +| ✅ | Remote Evaluation | Default remote evaluation mode | +| ✅ | Local Evaluation | Optional local evaluation mode | +| ✅ | Error Handling | Comprehensive error handling | +| ✅ | Type Validation | Strict type checking | + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'openfeature-flagsmith-provider' +``` + +And then execute: + +```bash +bundle install +``` + +Or install it yourself as: + +```bash +gem install openfeature-flagsmith-provider +``` + +## Usage + +### Basic Setup + +```ruby +require 'open_feature/sdk' +require 'openfeature/flagsmith/provider' +require 'openfeature/flagsmith/options' + +# Configure the Flagsmith provider +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_flagsmith_environment_key' +) + +provider = OpenFeature::Flagsmith::Provider.new(options: options) + +# Set the provider in OpenFeature +OpenFeature::SDK.configure do |config| + config.provider = provider +end + +# Get a client +client = OpenFeature::SDK.build_client +``` + +### Evaluating Flags + +#### Boolean Flags + +```ruby +# Simple boolean flag +enabled = client.fetch_boolean_value( + flag_key: 'new_feature', + default_value: false +) + +# With user context +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user_123', + email: 'user@example.com', + age: 30 +) + +enabled = client.fetch_boolean_value( + flag_key: 'new_feature', + default_value: false, + evaluation_context: evaluation_context +) +``` + +#### String Flags + +```ruby +theme = client.fetch_string_value( + flag_key: 'theme', + default_value: 'light', + evaluation_context: evaluation_context +) +``` + +#### Number Flags + +```ruby +max_items = client.fetch_integer_value( + flag_key: 'max_items', + default_value: 10, + evaluation_context: evaluation_context +) + +rate_limit = client.fetch_float_value( + flag_key: 'rate_limit', + default_value: 1.5, + evaluation_context: evaluation_context +) +``` + +#### Object Flags + +```ruby +config = client.fetch_object_value( + flag_key: 'app_config', + default_value: {timeout: 30}, + evaluation_context: evaluation_context +) +``` + +## Configuration Options + +The `Options` class accepts the following configuration parameters: + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `environment_key` | String | - | **Yes** | Your Flagsmith environment key | +| `api_url` | String | `https://edge.api.flagsmith.com/api/v1/` | No | Custom Flagsmith API URL (for self-hosting) | +| `enable_local_evaluation` | Boolean | `false` | No | Enable local evaluation mode | +| `request_timeout_seconds` | Integer | `10` | No | HTTP request timeout in seconds | +| `enable_analytics` | Boolean | `false` | No | Enable Flagsmith analytics | +| `environment_refresh_interval_seconds` | Integer | `60` | No | Polling interval for local evaluation mode | + +### Configuration Examples + +#### Default Configuration + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key' +) +``` + +#### Custom API URL (Self-Hosted) + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key', + api_url: 'https://flagsmith.yourcompany.com/api/v1/' +) +``` + +#### Local Evaluation Mode + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key', + enable_local_evaluation: true, + environment_refresh_interval_seconds: 30 +) +``` + +#### With Analytics + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key', + enable_analytics: true +) +``` + +## Evaluation Context + +The provider supports OpenFeature evaluation contexts to pass user information and traits to Flagsmith: + +### Targeting Key → Identity + +The `targeting_key` maps to Flagsmith's identity identifier. **Note:** Flagsmith requires an identity to evaluate traits, so if you provide traits without a `targeting_key`, they will be ignored and evaluation falls back to environment-level flags. + +```ruby +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user@example.com' +) +``` + +### Context Fields → Traits + +All other context fields are passed as Flagsmith traits: + +```ruby +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user_123', + email: 'user@example.com', + plan: 'premium', + age: 30 +) +``` + +This will evaluate flags for identity `user_123` with traits: +- `email`: "user@example.com" +- `plan`: "premium" +- `age`: 30 + +### Environment-Level vs Identity-Specific + +**Without `targeting_key` (Environment-level):** +```ruby +# Evaluates flags at environment level +client.fetch_boolean_value( + flag_key: 'feature', + default_value: false +) +``` + +**With `targeting_key` (Identity-specific):** +```ruby +# Evaluates flags for specific user identity +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user_123' +) + +client.fetch_boolean_value( + flag_key: 'feature', + default_value: false, + evaluation_context: evaluation_context +) +``` + +## Error Handling + +The provider handles errors gracefully and returns the default value with appropriate error codes: + +```ruby +result = client.fetch_boolean_details( + flag_key: 'unknown_flag', + default_value: false +) + +puts result.value # => false (default value) +puts result.error_code # => FLAG_NOT_FOUND +puts result.error_message # => "Flag 'unknown_flag' not found" +puts result.reason # => DEFAULT +``` + +### Error Codes + +| Error Code | Description | +|------------|-------------| +| `FLAG_NOT_FOUND` | The requested flag does not exist | +| `TYPE_MISMATCH` | The flag value type doesn't match the requested type | +| `PROVIDER_NOT_READY` | The Flagsmith client is not properly initialized | +| `PARSE_ERROR` | Failed to parse the flag value | +| `INVALID_CONTEXT` | The evaluation context is invalid | +| `GENERAL` | A general error occurred | + +## Reasons + +The provider returns appropriate reasons for flag evaluations: + +| Reason | Description | +|--------|-------------| +| `TARGETING_MATCH` | Flag evaluated with user identity (targeting_key provided) | +| `STATIC` | Flag evaluated at environment level (no targeting_key) | +| `DEFAULT` | Default value returned due to flag not found | +| `ERROR` | An error occurred during evaluation | +| `DISABLED` | The flag was disabled, and the default value was returned | + +**Note**: Both remote and local evaluation modes use the same reason mapping (STATIC/TARGETING_MATCH). Local evaluation performs flag evaluation locally but still evaluates the flag state, it doesn't return cached results. + +## Development + +### Running Tests + +```bash +bundle install +bundle exec rspec +``` + +### Running Linter + +```bash +bundle exec rubocop +``` + +### Building the Gem + +```bash +gem build openfeature-flagsmith-provider.gemspec +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +Apache 2.0 - See [LICENSE](LICENSE) for more information. + +## Links + +- [Flagsmith Documentation](https://docs.flagsmith.com/) +- [OpenFeature Documentation](https://openfeature.dev/) +- [OpenFeature Ruby SDK](https://github.com/open-feature/ruby-sdk) +- [Ruby SDK Contrib Repository](https://github.com/open-feature/ruby-sdk-contrib) + +## Support + +For issues related to: +- **This provider**: [GitHub Issues](https://github.com/open-feature/ruby-sdk-contrib/issues) +- **Flagsmith**: [Flagsmith Support](https://www.flagsmith.com/contact-us) +- **OpenFeature**: [OpenFeature Community](https://openfeature.dev/community/) diff --git a/providers/openfeature-flagsmith-provider/Rakefile b/providers/openfeature-flagsmith-provider/Rakefile new file mode 100644 index 0000000..c92b11e --- /dev/null +++ b/providers/openfeature-flagsmith-provider/Rakefile @@ -0,0 +1,6 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb new file mode 100644 index 0000000..f1f9c73 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "open_feature/sdk/provider/error_code" + +module OpenFeature + module Flagsmith + # Base error class for Flagsmith provider + class FlagsmithError < StandardError + attr_reader :error_code, :error_message + + def initialize(error_code, error_message) + @error_code = error_code + @error_message = error_message + super(error_message) + end + end + + # Raised when a flag is not found in Flagsmith + class FlagNotFoundError < FlagsmithError + def initialize(flag_key) + super( + SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + "Flag not found: #{flag_key}" + ) + end + end + + # Raised when there's a type mismatch between expected and actual flag value + class TypeMismatchError < FlagsmithError + def initialize(expected_types, actual_type) + super( + SDK::Provider::ErrorCode::TYPE_MISMATCH, + "Expected type #{expected_types}, but got #{actual_type}" + ) + end + end + + # Raised when the Flagsmith client is not ready or properly initialized + class ProviderNotReadyError < FlagsmithError + def initialize(message = "Flagsmith provider is not ready") + super( + SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + message + ) + end + end + + # Raised when there's an error parsing flag values + class ParseError < FlagsmithError + def initialize(message) + super( + SDK::Provider::ErrorCode::PARSE_ERROR, + "Failed to parse flag value: #{message}" + ) + end + end + + # Raised for general Flagsmith SDK errors + class FlagsmithClientError < FlagsmithError + def initialize(message) + super( + SDK::Provider::ErrorCode::GENERAL, + "Flagsmith client error: #{message}" + ) + end + end + + # Raised when evaluation context is invalid + class InvalidContextError < FlagsmithError + def initialize(message) + super( + SDK::Provider::ErrorCode::INVALID_CONTEXT, + "Invalid evaluation context: #{message}" + ) + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb new file mode 100644 index 0000000..ee087e8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "uri" + +module OpenFeature + module Flagsmith + # Configuration options for the Flagsmith OpenFeature provider + class Options + attr_reader :environment_key, :api_url, :enable_local_evaluation, + :request_timeout_seconds, :enable_analytics, + :environment_refresh_interval_seconds + + DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/" + DEFAULT_REQUEST_TIMEOUT = 10 + DEFAULT_REFRESH_INTERVAL = 60 + + def initialize( + environment_key:, + api_url: DEFAULT_API_URL, + enable_local_evaluation: false, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT, + enable_analytics: false, + environment_refresh_interval_seconds: DEFAULT_REFRESH_INTERVAL + ) + validate_environment_key(environment_key: environment_key) + validate_api_url(api_url: api_url) + validate_timeout(timeout: request_timeout_seconds) + validate_refresh_interval(interval: environment_refresh_interval_seconds) + + @environment_key = environment_key + @api_url = api_url + @enable_local_evaluation = enable_local_evaluation + @request_timeout_seconds = request_timeout_seconds + @enable_analytics = enable_analytics + @environment_refresh_interval_seconds = environment_refresh_interval_seconds + end + + def local_evaluation? + @enable_local_evaluation + end + + def analytics_enabled? + @enable_analytics + end + + private + + def validate_environment_key(environment_key: nil) + if environment_key.nil? || environment_key.to_s.strip.empty? + raise ArgumentError, "environment_key is required and cannot be empty" + end + end + + def validate_api_url(api_url: nil) + return if api_url.nil? + + uri = URI.parse(api_url) + unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + raise ArgumentError, "Invalid URL for api_url: #{api_url}" + end + rescue URI::InvalidURIError + raise ArgumentError, "Invalid URL for api_url: #{api_url}" + end + + def validate_timeout(timeout: nil) + return if timeout.nil? + + unless timeout.is_a?(Integer) && timeout.positive? + raise ArgumentError, "request_timeout_seconds must be a positive integer" + end + end + + def validate_refresh_interval(interval: nil) + return if interval.nil? + + unless interval.is_a?(Integer) && interval.positive? + raise ArgumentError, "environment_refresh_interval_seconds must be a positive integer" + end + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb new file mode 100644 index 0000000..7c8d0f8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require "open_feature/sdk" +require "flagsmith" +require "json" +require_relative "options" +require_relative "error/errors" + +module OpenFeature + module Flagsmith + # OpenFeature provider for Flagsmith + class Provider + PROVIDER_NAME = "Flagsmith Provider" + attr_reader :metadata, :options + + def initialize(options:) + @metadata = SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME) + @options = options + @flagsmith_client = nil + end + + def init + # Initialize Flagsmith client + @flagsmith_client = create_flagsmith_client + end + + def shutdown + # Cleanup Flagsmith client resources + # Note: Flagsmith Ruby SDK doesn't require explicit cleanup as of version 4.3 + # If future versions add cleanup methods, they should be called here + @flagsmith_client = nil + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + evaluate_boolean(flag_key, default_value, evaluation_context) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + evaluate_value(flag_key, default_value, evaluation_context, [String]) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + evaluate_value(flag_key, default_value, evaluation_context, [Integer, Float, Numeric]) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + evaluate_value(flag_key, default_value, evaluation_context, [Integer]) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + evaluate_value(flag_key, default_value, evaluation_context, [Float]) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + evaluate_value(flag_key, default_value, evaluation_context, [Hash, Array]) + end + + private + + def create_flagsmith_client + ::Flagsmith::Client.new( + environment_key: @options.environment_key, + api_url: @options.api_url, + enable_local_evaluation: @options.local_evaluation?, + request_timeout_seconds: @options.request_timeout_seconds, + enable_analytics: @options.analytics_enabled?, + environment_refresh_interval_seconds: @options.environment_refresh_interval_seconds + ) + rescue => e + raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.class}: #{e.message}" + end + + def evaluate_boolean(flag_key, default_value, evaluation_context) + return provider_not_ready_result(default_value) if @flagsmith_client.nil? + return invalid_flag_key_result(default_value) if flag_key.nil? || flag_key.to_s.empty? + + flags = get_flags(evaluation_context) + value = flags.is_feature_enabled(flag_key) + + success_result(value, evaluation_context) + rescue FlagsmithError => e + error_result(default_value, e.error_code, e.error_message) + rescue => e + error_result(default_value, SDK::Provider::ErrorCode::GENERAL, "Unexpected error: #{e.class}: #{e.message}") + end + + def evaluate_value(flag_key, default_value, evaluation_context, allowed_type_classes) + return provider_not_ready_result(default_value) if @flagsmith_client.nil? + return invalid_flag_key_result(default_value) if flag_key.nil? || flag_key.to_s.empty? + + flags = get_flags(evaluation_context) + found_flag = flags.all_flags.find { |f| f.feature_name == flag_key } + + return flag_not_found_result(default_value, flag_key) if found_flag.nil? + return flag_disabled_result(default_value, flag_key) unless found_flag.enabled + + raw_value = found_flag.value + value = if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } + parse_json_value(raw_value) + elsif [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } + parse_numeric_value(raw_value, allowed_type_classes) + else + raw_value + end + + return type_mismatch_result(default_value, value, allowed_type_classes) unless type_matches?(value, allowed_type_classes) + + success_result(value, evaluation_context) + rescue FlagsmithError => e + error_result(default_value, e.error_code, e.error_message) + rescue => e + error_result(default_value, SDK::Provider::ErrorCode::GENERAL, "Unexpected error: #{e.class}: #{e.message}") + end + + def provider_not_ready_result(default_value) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + error_message: "Provider not initialized. Call init() first." + ) + end + + def invalid_flag_key_result(default_value) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag key cannot be empty or nil" + ) + end + + def flag_not_found_result(default_value, flag_key) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag '#{flag_key}' not found" + ) + end + + def flag_disabled_result(default_value, flag_key) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DISABLED, + error_message: "Flag '#{flag_key}' is disabled" + ) + end + + def type_mismatch_result(default_value, value, allowed_type_classes) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "Expected type #{allowed_type_classes}, got #{value.class}" + ) + end + + def error_result(default_value, error_code, error_message) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: error_code, + error_message: error_message + ) + end + + def success_result(value, evaluation_context) + SDK::Provider::ResolutionDetails.new( + value: value, + reason: determine_reason(evaluation_context), + variant: nil, + flag_metadata: {} + ) + end + + def get_flags(evaluation_context) + raise ProviderNotReadyError, "Flagsmith client not initialized" if @flagsmith_client.nil? + + if evaluation_context.nil? + return @flagsmith_client.get_environment_flags + end + + targeting_key = evaluation_context.targeting_key + if targeting_key.nil? || targeting_key.to_s.strip.empty? + @flagsmith_client.get_environment_flags + else + traits = evaluation_context.fields.transform_keys(&:to_sym).reject { |k, _v| k == :targeting_key } + @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) + end + rescue => e + raise FlagsmithClientError, "#{e.class}: #{e.message}" + end + + def parse_json_value(value) + return value if value.is_a?(Hash) || value.is_a?(Array) + return nil if value.nil? + + JSON.parse(value.to_s) + rescue JSON::ParserError => e + raise ParseError, e.message + end + + def parse_numeric_value(value, allowed_type_classes) + # If already the right type, return it + return value if allowed_type_classes.any? { |klass| value.is_a?(klass) } + return nil if value.nil? + + # Try to parse string to numeric + if value.is_a?(String) + if allowed_type_classes.include?(Numeric) + # For Numeric, try Integer first, then Float + begin + return Integer(value) + rescue ArgumentError, TypeError + return Float(value) + end + elsif allowed_type_classes.include?(Integer) + return Integer(value) + elsif allowed_type_classes.include?(Float) + return Float(value) + end + end + + # Safe numeric type conversions (following Flipt provider pattern) + if value.is_a?(Numeric) + # Integer → Float: always safe (no precision loss) + if value.is_a?(Integer) && allowed_type_classes == [Float] + return value.to_f + end + + # Float → Integer: only if it's a whole number (prevents data loss) + # Example: 3.0 → 3 (OK), but 3.99 → fails type check (ERROR) + if value.is_a?(Float) && allowed_type_classes == [Integer] + return value.to_i if value.to_i == value + end + + # For generic fetch_number_value (accepts any numeric type), return as-is + # This handles [Integer, Float, Numeric] case + end + + value + rescue ArgumentError, TypeError => e + raise ParseError, "Cannot convert '#{value}' to numeric type: #{e.message}" + end + + def type_matches?(value, allowed_type_classes) + allowed_type_classes.any? { |klass| value.is_a?(klass) } + end + + def determine_reason(evaluation_context) + # Use TARGETING_MATCH if we have targeting_key (identity-specific) + # Use STATIC for environment-level flags + return SDK::Provider::Reason::STATIC if evaluation_context.nil? + + targeting_key = evaluation_context.targeting_key + if targeting_key.nil? || targeting_key.to_s.strip.empty? + SDK::Provider::Reason::STATIC + else + SDK::Provider::Reason::TARGETING_MATCH + end + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb new file mode 100644 index 0000000..2e9a9a8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module OpenFeature + module Flagsmith + VERSION = "0.1.0" + end +end diff --git a/providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec b/providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec new file mode 100644 index 0000000..aa6552c --- /dev/null +++ b/providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec @@ -0,0 +1,38 @@ +require_relative "lib/openfeature/flagsmith/version" + +Gem::Specification.new do |spec| + spec.name = "openfeature-flagsmith-provider" + spec.version = OpenFeature::Flagsmith::VERSION + spec.authors = ["OpenFeature Contributors"] + spec.email = ["cncf-openfeature-contributors@lists.cncf.io"] + + spec.summary = "OpenFeature provider for Flagsmith" + spec.description = "Flagsmith provider for the OpenFeature Ruby SDK" + spec.homepage = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-flagsmith-provider" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.1" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/issues" + spec.metadata["documentation_uri"] = "#{spec.homepage}/README.md" + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + + spec.require_paths = ["lib"] + + # Runtime dependencies + spec.add_runtime_dependency "openfeature-sdk", "~> 0.3.1" + spec.add_runtime_dependency "flagsmith", "~> 4.3" + + # Development dependencies + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.12.0" + spec.add_development_dependency "webmock", "~> 3.0" + spec.add_development_dependency "standard", "~> 1.0" + spec.add_development_dependency "rubocop", "~> 1.0" + spec.add_development_dependency "simplecov", "~> 0.22" +end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb new file mode 100644 index 0000000..860e66d --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb @@ -0,0 +1,72 @@ +require "spec_helper" +require "openfeature/flagsmith/error/errors" + +RSpec.describe "Flagsmith Errors" do + describe OpenFeature::Flagsmith::FlagNotFoundError do + it "should create error with FLAG_NOT_FOUND error code" do + error = OpenFeature::Flagsmith::FlagNotFoundError.new("my_flag") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + expect(error.error_message).to eq("Flag not found: my_flag") + expect(error.message).to eq("Flag not found: my_flag") + end + + it "should be a StandardError" do + error = OpenFeature::Flagsmith::FlagNotFoundError.new("test") + expect(error).to be_a(StandardError) + end + end + + describe OpenFeature::Flagsmith::TypeMismatchError do + it "should create error with TYPE_MISMATCH error code" do + error = OpenFeature::Flagsmith::TypeMismatchError.new([String], Integer) + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + expect(error.error_message).to include("Expected type") + expect(error.error_message).to include("Integer") + end + end + + describe OpenFeature::Flagsmith::ProviderNotReadyError do + it "should create error with PROVIDER_NOT_READY error code" do + error = OpenFeature::Flagsmith::ProviderNotReadyError.new + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) + expect(error.error_message).to eq("Flagsmith provider is not ready") + end + + it "should accept custom message" do + error = OpenFeature::Flagsmith::ProviderNotReadyError.new("Client not initialized") + expect(error.error_message).to eq("Client not initialized") + end + end + + describe OpenFeature::Flagsmith::ParseError do + it "should create error with PARSE_ERROR error code" do + error = OpenFeature::Flagsmith::ParseError.new("invalid JSON") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + expect(error.error_message).to eq("Failed to parse flag value: invalid JSON") + end + end + + describe OpenFeature::Flagsmith::FlagsmithClientError do + it "should create error with GENERAL error code" do + error = OpenFeature::Flagsmith::FlagsmithClientError.new("connection timeout") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(error.error_message).to eq("Flagsmith client error: connection timeout") + end + end + + describe OpenFeature::Flagsmith::InvalidContextError do + it "should create error with INVALID_CONTEXT error code" do + error = OpenFeature::Flagsmith::InvalidContextError.new("missing required field") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::INVALID_CONTEXT) + expect(error.error_message).to eq("Invalid evaluation context: missing required field") + end + end + + describe OpenFeature::Flagsmith::FlagsmithError do + it "should be the base class for all Flagsmith errors" do + expect(OpenFeature::Flagsmith::FlagNotFoundError.new("test")).to be_a(OpenFeature::Flagsmith::FlagsmithError) + expect(OpenFeature::Flagsmith::TypeMismatchError.new([], nil)).to be_a(OpenFeature::Flagsmith::FlagsmithError) + expect(OpenFeature::Flagsmith::ProviderNotReadyError.new).to be_a(OpenFeature::Flagsmith::FlagsmithError) + end + end +end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb new file mode 100644 index 0000000..7d44694 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb @@ -0,0 +1,183 @@ +require "spec_helper" + +RSpec.describe OpenFeature::Flagsmith::Options do + describe "#initialize" do + context "with valid environment_key" do + it "should create options with required environment_key" do + options = OpenFeature::Flagsmith::Options.new(environment_key: "test_key_123") + expect(options.environment_key).to eq("test_key_123") + end + + it "should use default api_url when not provided" do + options = OpenFeature::Flagsmith::Options.new(environment_key: "test_key") + expect(options.api_url).to eq("https://edge.api.flagsmith.com/api/v1/") + end + + it "should use default values for optional parameters" do + options = OpenFeature::Flagsmith::Options.new(environment_key: "test_key") + expect(options.enable_local_evaluation).to be false + expect(options.request_timeout_seconds).to eq(10) + expect(options.enable_analytics).to be false + expect(options.environment_refresh_interval_seconds).to eq(60) + end + + it "should accept custom values for all parameters" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "https://custom.flagsmith.com/api/v1/", + enable_local_evaluation: true, + request_timeout_seconds: 30, + enable_analytics: true, + environment_refresh_interval_seconds: 120 + ) + expect(options.environment_key).to eq("test_key") + expect(options.api_url).to eq("https://custom.flagsmith.com/api/v1/") + expect(options.enable_local_evaluation).to be true + expect(options.request_timeout_seconds).to eq(30) + expect(options.enable_analytics).to be true + expect(options.environment_refresh_interval_seconds).to eq(120) + end + end + + context "environment_key validation" do + it "should raise error when environment_key is nil" do + expect { + OpenFeature::Flagsmith::Options.new(environment_key: nil) + }.to raise_error(ArgumentError, "environment_key is required and cannot be empty") + end + + it "should raise error when environment_key is empty string" do + expect { + OpenFeature::Flagsmith::Options.new(environment_key: "") + }.to raise_error(ArgumentError, "environment_key is required and cannot be empty") + end + + it "should raise error when environment_key is whitespace only" do + expect { + OpenFeature::Flagsmith::Options.new(environment_key: " ") + }.to raise_error(ArgumentError, "environment_key is required and cannot be empty") + end + end + + context "api_url validation" do + it "should accept valid http url" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "http://localhost:8000/api/v1/" + ) + expect(options.api_url).to eq("http://localhost:8000/api/v1/") + end + + it "should accept valid https url" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "https://custom.flagsmith.com/api/v1/" + ) + expect(options.api_url).to eq("https://custom.flagsmith.com/api/v1/") + end + + it "should raise error for invalid url" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "not_a_url" + ) + }.to raise_error(ArgumentError, "Invalid URL for api_url: not_a_url") + end + + it "should raise error for non-http(s) url" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "ftp://flagsmith.com" + ) + }.to raise_error(ArgumentError, "Invalid URL for api_url: ftp://flagsmith.com") + end + end + + context "request_timeout_seconds validation" do + it "should raise error for non-integer timeout" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + request_timeout_seconds: "10" + ) + }.to raise_error(ArgumentError, "request_timeout_seconds must be a positive integer") + end + + it "should raise error for negative timeout" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + request_timeout_seconds: -5 + ) + }.to raise_error(ArgumentError, "request_timeout_seconds must be a positive integer") + end + + it "should raise error for zero timeout" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + request_timeout_seconds: 0 + ) + }.to raise_error(ArgumentError, "request_timeout_seconds must be a positive integer") + end + end + + context "environment_refresh_interval_seconds validation" do + it "should raise error for non-integer interval" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + environment_refresh_interval_seconds: "60" + ) + }.to raise_error(ArgumentError, "environment_refresh_interval_seconds must be a positive integer") + end + + it "should raise error for negative interval" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + environment_refresh_interval_seconds: -10 + ) + }.to raise_error(ArgumentError, "environment_refresh_interval_seconds must be a positive integer") + end + end + end + + describe "#local_evaluation?" do + it "should return false when local evaluation is disabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_local_evaluation: false + ) + expect(options.local_evaluation?).to be false + end + + it "should return true when local evaluation is enabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_local_evaluation: true + ) + expect(options.local_evaluation?).to be true + end + end + + describe "#analytics_enabled?" do + it "should return false when analytics is disabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_analytics: false + ) + expect(options.analytics_enabled?).to be false + end + + it "should return true when analytics is enabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_analytics: true + ) + expect(options.analytics_enabled?).to be true + end + end +end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb new file mode 100644 index 0000000..749d939 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -0,0 +1,731 @@ +require "spec_helper" +require "openfeature/flagsmith/provider" + +RSpec.describe OpenFeature::Flagsmith::Provider do + let(:options) do + OpenFeature::Flagsmith::Options.new(environment_key: "test_key_123") + end + + let(:provider) { described_class.new(options: options) } + + let(:mock_flagsmith_client) { instance_double("Flagsmith::Client") } + let(:mock_flags) { double("Flags") } + + # Helper to create a mock flag object for all_flags + def mock_flag(feature_name:, enabled:, value:) + double("Flag", feature_name: feature_name, enabled: enabled, value: value) + end + + before do + # Mock Flagsmith::Client creation + allow(::Flagsmith::Client).to receive(:new).and_return(mock_flagsmith_client) + end + + describe "#initialize" do + it "should create provider with options" do + expect(provider.options).to eq(options) + end + + it "should set metadata with provider name" do + expect(provider.metadata).to be_a(OpenFeature::SDK::Provider::ProviderMetadata) + expect(provider.metadata.name).to eq("Flagsmith Provider") + end + end + + describe "#metadata" do + it "should return provider metadata" do + expect(provider.metadata.name).to eq("Flagsmith Provider") + end + end + + describe "#init" do + it "should initialize Flagsmith client" do + expect(::Flagsmith::Client).to receive(:new).with( + environment_key: "test_key_123", + api_url: "https://edge.api.flagsmith.com/api/v1/", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false, + environment_refresh_interval_seconds: 60 + ).and_return(mock_flagsmith_client) + + expect { provider.init }.not_to raise_error + end + end + + describe "#shutdown" do + it "should shutdown without error" do + expect { provider.shutdown }.not_to raise_error + end + end + + describe "fetch methods" do + let(:flag_key) { "test_flag" } + let(:evaluation_context) do + OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + end + + before do + provider.init + end + + describe "#fetch_boolean_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should return actual flag value when flag exists" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return("something") + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(true) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + expect(result.error_code).to be_nil + end + + it "should return false for disabled boolean flags" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: true, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + expect(result.error_code).to be_nil + end + + it "should work without evaluation context" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: true + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) + end + + it "should handle non-string targeting_key gracefully" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 12345) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + end + + describe "#fetch_string_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should return actual string value when flag exists" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "hello_world") + ]) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + expect(result.value).to eq("hello_world") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default_string", + evaluation_context: evaluation_context + ) + expect(result.value).to eq("default_string") + end + end + + describe "#fetch_number_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 42, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should parse numeric string value" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "123") + ]) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 0, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(123) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return actual numeric value" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: 456) + ]) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 0, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(456) + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 123, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(123) + end + end + + describe "#fetch_integer_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_integer_value( + flag_key: flag_key, + default_value: 42, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + end + + describe "#fetch_float_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_float_value( + flag_key: flag_key, + default_value: 3.14, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + end + + describe "#fetch_object_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {key: "value"}, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should parse JSON string to hash" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: '{"color":"red","size":42}') + ]) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {}, + evaluation_context: evaluation_context + ) + expect(result.value).to eq({"color" => "red", "size" => 42}) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return hash value directly" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: {foo: "bar"}) + ]) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {}, + evaluation_context: evaluation_context + ) + expect(result.value).to eq({foo: "bar"}) + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([]) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {default: true}, + evaluation_context: evaluation_context + ) + expect(result.value).to eq({default: true}) + end + end + end + + describe "reason determination" do + before do + provider.init + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + end + + it "should use STATIC reason for environment-level flags (no targeting_key)" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + + evaluation_context = OpenFeature::SDK::EvaluationContext.new + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: true, + evaluation_context: evaluation_context + ) + # Boolean flags always return their value with STATIC/TARGETING_MATCH reason + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) + end + + it "should use TARGETING_MATCH reason for identity-specific flags" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + + evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: true, + evaluation_context: evaluation_context + ) + # Flagsmith treats non-existent flags as disabled flags + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + end + + describe "error handling scenarios" do + let(:flag_key) { "test_flag" } + let(:evaluation_context) do + OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + end + + before do + provider.init + end + + describe "when provider is not initialized" do + it "should return error when client is nil" do + provider_uninit = described_class.new(options: options) + # Don't call init + + result = provider_uninit.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) + expect(result.error_message).to include("Provider not initialized") + end + end + + describe "when Flagsmith client raises errors" do + it "should handle network errors from get_identity_flags" do + allow(mock_flagsmith_client).to receive(:get_identity_flags) + .and_raise(StandardError.new("Network timeout")) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.error_message).to include("Network timeout") + end + + it "should handle network errors from get_environment_flags" do + allow(mock_flagsmith_client).to receive(:get_environment_flags) + .and_raise(StandardError.new("Connection refused")) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: nil + ) + + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + end + + it "should handle errors when getting flags object itself" do + # Test error when get_identity_flags itself fails (not the flag methods) + allow(mock_flagsmith_client).to receive(:get_identity_flags) + .and_raise(StandardError.new("API error")) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + + expect(result.value).to eq("default") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + end + end + + describe "JSON parsing errors" do + it "should handle malformed JSON in object values" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "{invalid json") + ]) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {default: true}, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq({default: true}) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + end + end + + describe "type mismatch errors" do + it "should return error when boolean flag returns non-boolean value" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "not_a_boolean") + ]) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + + expect(result.value).to eq("not_a_boolean") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return error when string value cannot be converted to integer" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "not_a_number") + ]) + + result = provider.fetch_integer_value( + flag_key: flag_key, + default_value: 42, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(42) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + end + end + + describe "Flagsmith client initialization errors" do + it "should raise ProviderNotReadyError when Flagsmith::Client.new fails" do + allow(::Flagsmith::Client).to receive(:new).and_raise(StandardError.new("Invalid API key")) + + provider_new = described_class.new(options: options) + + expect { + provider_new.init + }.to raise_error(OpenFeature::Flagsmith::ProviderNotReadyError, /Failed to create Flagsmith client/) + end + end + end + + describe "edge cases" do + let(:evaluation_context) do + OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + end + + before do + provider.init + end + + describe "empty or nil flag keys" do + it "should handle empty string flag key" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with("").and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with("").and_return(false) + + result = provider.fetch_boolean_value( + flag_key: "", + default_value: true, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(true) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + end + end + + describe "special characters in flag keys" do + it "should handle flag keys with special characters" do + special_key = "flag-with-dashes_and_underscores.and.dots" + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: special_key, enabled: true, value: "value") + ]) + + result = provider.fetch_string_value( + flag_key: special_key, + default_value: "default", + evaluation_context: evaluation_context + ) + + expect(result.value).to eq("value") + end + + end + + describe "evaluation context edge cases" do + it "should handle empty string targeting_key" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "") + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: context + ) + + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should handle whitespace-only targeting_key" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: " ") + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: context + ) + + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should handle nil values in context fields (traits)" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + + context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: "user_123", + email: nil, + age: nil + ) + + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: context + ) + + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should handle unicode in trait values" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: "value") + ]) + + context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: "user_123", + name: "François", + location: "Montréal 🇨🇦" + ) + + result = provider.fetch_string_value( + flag_key: "test", + default_value: "default", + evaluation_context: context + ) + + expect(result.value).to eq("value") + end + end + + describe "numeric type edge cases" do + it "should handle zero values" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: 0) + ]) + + result = provider.fetch_integer_value( + flag_key: "test", + default_value: 42, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(0) + end + + it "should handle negative numbers" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: -999) + ]) + + result = provider.fetch_integer_value( + flag_key: "test", + default_value: 0, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(-999) + end + + it "should handle very large numbers" do + large_num = 999_999_999_999 + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: large_num) + ]) + + result = provider.fetch_integer_value( + flag_key: "test", + default_value: 0, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(large_num) + end + + it "should handle scientific notation in strings" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: "1.5e2") + ]) + + result = provider.fetch_float_value( + flag_key: "test", + default_value: 0.0, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(150.0) + end + end + + describe "object/array edge cases" do + it "should handle empty objects" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: {}) + ]) + + result = provider.fetch_object_value( + flag_key: "test", + default_value: {default: true}, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq({}) + end + + it "should handle empty arrays" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: []) + ]) + + result = provider.fetch_object_value( + flag_key: "test", + default_value: [], + evaluation_context: evaluation_context + ) + + expect(result.value).to eq([]) + end + + it "should handle nested objects" do + nested = {outer: {inner: {deep: "value"}}} + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: nested) + ]) + + result = provider.fetch_object_value( + flag_key: "test", + default_value: {}, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(nested) + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/spec/spec_helper.rb b/providers/openfeature-flagsmith-provider/spec/spec_helper.rb new file mode 100644 index 0000000..95b7996 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/spec_helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "rspec" +require "open_feature/sdk" +require "webmock/rspec" +require "flagsmith" + +# Require our provider files +require_relative "../lib/openfeature/flagsmith/version" +require_relative "../lib/openfeature/flagsmith/options" +require_relative "../lib/openfeature/flagsmith/error/errors" +require_relative "../lib/openfeature/flagsmith/provider" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.disable_monkey_patching! + config.warnings = true + config.order = :random + Kernel.srand config.seed +end diff --git a/release-please-config.json b/release-please-config.json index d8cc709..a14d836 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -41,6 +41,16 @@ "extra-files": [ "README.md" ] + }, + "providers/openfeature-flagsmith-provider": { + "package-name": "openfeature-flagsmith-provider", + "version-file": "lib/openfeature/flagsmith/version.rb", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] } }, "changelog-sections": [