openfeature-flagsmith-provider 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # OpenFeature Flagsmith Provider for Ruby
2
+
3
+ This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature flags, implementing the [OpenFeature](https://openfeature.dev/) standard.
4
+
5
+ ## Features
6
+
7
+ | Status | Feature | Description |
8
+ |--------|---------|-------------|
9
+ | ✅ | Flag Evaluation | Support for all OpenFeature flag types |
10
+ | ✅ | Boolean Flags | Evaluate boolean feature flags |
11
+ | ✅ | String Flags | Evaluate string feature flags |
12
+ | ✅ | Number Flags | Evaluate numeric feature flags (int, float) |
13
+ | ✅ | Object Flags | Evaluate JSON object/array flags |
14
+ | ✅ | Evaluation Context | Support for user identity and traits |
15
+ | ✅ | Environment Flags | Evaluate flags at environment level |
16
+ | ✅ | Identity Flags | Evaluate flags for specific users |
17
+ | ✅ | Remote Evaluation | Default remote evaluation mode |
18
+ | ✅ | Local Evaluation | Optional local evaluation mode |
19
+ | ✅ | Error Handling | Comprehensive error handling |
20
+ | ✅ | Type Validation | Strict type checking |
21
+
22
+ ## Installation
23
+
24
+ Add this line to your application's Gemfile:
25
+
26
+ ```ruby
27
+ gem 'openfeature-flagsmith-provider'
28
+ ```
29
+
30
+ And then execute:
31
+
32
+ ```bash
33
+ bundle install
34
+ ```
35
+
36
+ Or install it yourself as:
37
+
38
+ ```bash
39
+ gem install openfeature-flagsmith-provider
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Basic Setup
45
+
46
+ ```ruby
47
+ require 'open_feature/sdk'
48
+ require 'openfeature/flagsmith/provider'
49
+ require 'openfeature/flagsmith/options'
50
+
51
+ # Configure the Flagsmith provider
52
+ options = OpenFeature::Flagsmith::Options.new(
53
+ environment_key: 'your_flagsmith_environment_key'
54
+ )
55
+
56
+ provider = OpenFeature::Flagsmith::Provider.new(options: options)
57
+
58
+ # Set the provider in OpenFeature
59
+ OpenFeature::SDK.configure do |config|
60
+ config.provider = provider
61
+ end
62
+
63
+ # Get a client
64
+ client = OpenFeature::SDK.build_client
65
+ ```
66
+
67
+ ### Evaluating Flags
68
+
69
+ #### Boolean Flags
70
+
71
+ ```ruby
72
+ # Simple boolean flag
73
+ enabled = client.fetch_boolean_value(
74
+ flag_key: 'new_feature',
75
+ default_value: false
76
+ )
77
+
78
+ # With user context
79
+ evaluation_context = OpenFeature::SDK::EvaluationContext.new(
80
+ targeting_key: 'user_123',
81
+ email: 'user@example.com',
82
+ age: 30
83
+ )
84
+
85
+ enabled = client.fetch_boolean_value(
86
+ flag_key: 'new_feature',
87
+ default_value: false,
88
+ evaluation_context: evaluation_context
89
+ )
90
+ ```
91
+
92
+ #### String Flags
93
+
94
+ ```ruby
95
+ theme = client.fetch_string_value(
96
+ flag_key: 'theme',
97
+ default_value: 'light',
98
+ evaluation_context: evaluation_context
99
+ )
100
+ ```
101
+
102
+ #### Number Flags
103
+
104
+ ```ruby
105
+ max_items = client.fetch_integer_value(
106
+ flag_key: 'max_items',
107
+ default_value: 10,
108
+ evaluation_context: evaluation_context
109
+ )
110
+
111
+ rate_limit = client.fetch_float_value(
112
+ flag_key: 'rate_limit',
113
+ default_value: 1.5,
114
+ evaluation_context: evaluation_context
115
+ )
116
+ ```
117
+
118
+ #### Object Flags
119
+
120
+ ```ruby
121
+ config = client.fetch_object_value(
122
+ flag_key: 'app_config',
123
+ default_value: {timeout: 30},
124
+ evaluation_context: evaluation_context
125
+ )
126
+ ```
127
+
128
+ ## Configuration Options
129
+
130
+ The `Options` class accepts the following configuration parameters:
131
+
132
+ | Option | Type | Default | Required | Description |
133
+ |--------|------|---------|----------|-------------|
134
+ | `environment_key` | String | - | **Yes** | Your Flagsmith environment key |
135
+ | `api_url` | String | `https://edge.api.flagsmith.com/api/v1/` | No | Custom Flagsmith API URL (for self-hosting) |
136
+ | `enable_local_evaluation` | Boolean | `false` | No | Enable local evaluation mode |
137
+ | `request_timeout_seconds` | Integer | `10` | No | HTTP request timeout in seconds |
138
+ | `enable_analytics` | Boolean | `false` | No | Enable Flagsmith analytics |
139
+ | `environment_refresh_interval_seconds` | Integer | `60` | No | Polling interval for local evaluation mode |
140
+
141
+ ### Configuration Examples
142
+
143
+ #### Default Configuration
144
+
145
+ ```ruby
146
+ options = OpenFeature::Flagsmith::Options.new(
147
+ environment_key: 'your_key'
148
+ )
149
+ ```
150
+
151
+ #### Custom API URL (Self-Hosted)
152
+
153
+ ```ruby
154
+ options = OpenFeature::Flagsmith::Options.new(
155
+ environment_key: 'your_key',
156
+ api_url: 'https://flagsmith.yourcompany.com/api/v1/'
157
+ )
158
+ ```
159
+
160
+ #### Local Evaluation Mode
161
+
162
+ ```ruby
163
+ options = OpenFeature::Flagsmith::Options.new(
164
+ environment_key: 'your_key',
165
+ enable_local_evaluation: true,
166
+ environment_refresh_interval_seconds: 30
167
+ )
168
+ ```
169
+
170
+ #### With Analytics
171
+
172
+ ```ruby
173
+ options = OpenFeature::Flagsmith::Options.new(
174
+ environment_key: 'your_key',
175
+ enable_analytics: true
176
+ )
177
+ ```
178
+
179
+ ## Evaluation Context
180
+
181
+ The provider supports OpenFeature evaluation contexts to pass user information and traits to Flagsmith:
182
+
183
+ ### Targeting Key → Identity
184
+
185
+ 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.
186
+
187
+ ```ruby
188
+ evaluation_context = OpenFeature::SDK::EvaluationContext.new(
189
+ targeting_key: 'user@example.com'
190
+ )
191
+ ```
192
+
193
+ ### Context Fields → Traits
194
+
195
+ All other context fields are passed as Flagsmith traits:
196
+
197
+ ```ruby
198
+ evaluation_context = OpenFeature::SDK::EvaluationContext.new(
199
+ targeting_key: 'user_123',
200
+ email: 'user@example.com',
201
+ plan: 'premium',
202
+ age: 30
203
+ )
204
+ ```
205
+
206
+ This will evaluate flags for identity `user_123` with traits:
207
+ - `email`: "user@example.com"
208
+ - `plan`: "premium"
209
+ - `age`: 30
210
+
211
+ ### Environment-Level vs Identity-Specific
212
+
213
+ **Without `targeting_key` (Environment-level):**
214
+ ```ruby
215
+ # Evaluates flags at environment level
216
+ client.fetch_boolean_value(
217
+ flag_key: 'feature',
218
+ default_value: false
219
+ )
220
+ ```
221
+
222
+ **With `targeting_key` (Identity-specific):**
223
+ ```ruby
224
+ # Evaluates flags for specific user identity
225
+ evaluation_context = OpenFeature::SDK::EvaluationContext.new(
226
+ targeting_key: 'user_123'
227
+ )
228
+
229
+ client.fetch_boolean_value(
230
+ flag_key: 'feature',
231
+ default_value: false,
232
+ evaluation_context: evaluation_context
233
+ )
234
+ ```
235
+
236
+ ## Error Handling
237
+
238
+ The provider handles errors gracefully and returns the default value with appropriate error codes:
239
+
240
+ ```ruby
241
+ result = client.fetch_boolean_details(
242
+ flag_key: 'unknown_flag',
243
+ default_value: false
244
+ )
245
+
246
+ puts result.value # => false (default value)
247
+ puts result.error_code # => FLAG_NOT_FOUND
248
+ puts result.error_message # => "Flag 'unknown_flag' not found"
249
+ puts result.reason # => DEFAULT
250
+ ```
251
+
252
+ ### Error Codes
253
+
254
+ | Error Code | Description |
255
+ |------------|-------------|
256
+ | `FLAG_NOT_FOUND` | The requested flag does not exist |
257
+ | `TYPE_MISMATCH` | The flag value type doesn't match the requested type |
258
+ | `PROVIDER_NOT_READY` | The Flagsmith client is not properly initialized |
259
+ | `PARSE_ERROR` | Failed to parse the flag value |
260
+ | `INVALID_CONTEXT` | The evaluation context is invalid |
261
+ | `GENERAL` | A general error occurred |
262
+
263
+ ## Reasons
264
+
265
+ The provider returns appropriate reasons for flag evaluations:
266
+
267
+ | Reason | Description |
268
+ |--------|-------------|
269
+ | `TARGETING_MATCH` | Flag evaluated with user identity (targeting_key provided) |
270
+ | `STATIC` | Flag evaluated at environment level (no targeting_key) |
271
+ | `DEFAULT` | Default value returned due to flag not found |
272
+ | `ERROR` | An error occurred during evaluation |
273
+ | `DISABLED` | The flag was disabled, and the default value was returned |
274
+
275
+ **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.
276
+
277
+ ## Development
278
+
279
+ ### Running Tests
280
+
281
+ ```bash
282
+ bundle install
283
+ bundle exec rspec
284
+ ```
285
+
286
+ ### Running Linter
287
+
288
+ ```bash
289
+ bundle exec rubocop
290
+ ```
291
+
292
+ ### Building the Gem
293
+
294
+ ```bash
295
+ gem build openfeature-flagsmith-provider.gemspec
296
+ ```
297
+
298
+ ## Contributing
299
+
300
+ Contributions are welcome! Please feel free to submit a Pull Request.
301
+
302
+ 1. Fork the repository
303
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
304
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
305
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
306
+ 5. Open a Pull Request
307
+
308
+ ## License
309
+
310
+ Apache 2.0 - See [LICENSE](LICENSE) for more information.
311
+
312
+ ## Links
313
+
314
+ - [Flagsmith Documentation](https://docs.flagsmith.com/)
315
+ - [OpenFeature Documentation](https://openfeature.dev/)
316
+ - [OpenFeature Ruby SDK](https://github.com/open-feature/ruby-sdk)
317
+ - [Ruby SDK Contrib Repository](https://github.com/open-feature/ruby-sdk-contrib)
318
+
319
+ ## Support
320
+
321
+ For issues related to:
322
+ - **This provider**: [GitHub Issues](https://github.com/open-feature/ruby-sdk-contrib/issues)
323
+ - **Flagsmith**: [Flagsmith Support](https://www.flagsmith.com/contact-us)
324
+ - **OpenFeature**: [OpenFeature Community](https://openfeature.dev/community/)
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open_feature/sdk/provider/error_code"
4
+
5
+ module OpenFeature
6
+ module Flagsmith
7
+ # Base error class for Flagsmith provider
8
+ class FlagsmithError < StandardError
9
+ attr_reader :error_code, :error_message
10
+
11
+ def initialize(error_code, error_message)
12
+ @error_code = error_code
13
+ @error_message = error_message
14
+ super(error_message)
15
+ end
16
+ end
17
+
18
+ # Raised when a flag is not found in Flagsmith
19
+ class FlagNotFoundError < FlagsmithError
20
+ def initialize(flag_key)
21
+ super(
22
+ SDK::Provider::ErrorCode::FLAG_NOT_FOUND,
23
+ "Flag not found: #{flag_key}"
24
+ )
25
+ end
26
+ end
27
+
28
+ # Raised when there's a type mismatch between expected and actual flag value
29
+ class TypeMismatchError < FlagsmithError
30
+ def initialize(expected_types, actual_type)
31
+ super(
32
+ SDK::Provider::ErrorCode::TYPE_MISMATCH,
33
+ "Expected type #{expected_types}, but got #{actual_type}"
34
+ )
35
+ end
36
+ end
37
+
38
+ # Raised when the Flagsmith client is not ready or properly initialized
39
+ class ProviderNotReadyError < FlagsmithError
40
+ def initialize(message = "Flagsmith provider is not ready")
41
+ super(
42
+ SDK::Provider::ErrorCode::PROVIDER_NOT_READY,
43
+ message
44
+ )
45
+ end
46
+ end
47
+
48
+ # Raised when there's an error parsing flag values
49
+ class ParseError < FlagsmithError
50
+ def initialize(message)
51
+ super(
52
+ SDK::Provider::ErrorCode::PARSE_ERROR,
53
+ "Failed to parse flag value: #{message}"
54
+ )
55
+ end
56
+ end
57
+
58
+ # Raised for general Flagsmith SDK errors
59
+ class FlagsmithClientError < FlagsmithError
60
+ def initialize(message)
61
+ super(
62
+ SDK::Provider::ErrorCode::GENERAL,
63
+ "Flagsmith client error: #{message}"
64
+ )
65
+ end
66
+ end
67
+
68
+ # Raised when evaluation context is invalid
69
+ class InvalidContextError < FlagsmithError
70
+ def initialize(message)
71
+ super(
72
+ SDK::Provider::ErrorCode::INVALID_CONTEXT,
73
+ "Invalid evaluation context: #{message}"
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module OpenFeature
6
+ module Flagsmith
7
+ # Configuration options for the Flagsmith OpenFeature provider
8
+ class Options
9
+ attr_reader :environment_key, :api_url, :enable_local_evaluation,
10
+ :request_timeout_seconds, :enable_analytics,
11
+ :environment_refresh_interval_seconds
12
+
13
+ DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/"
14
+ DEFAULT_REQUEST_TIMEOUT = 10
15
+ DEFAULT_REFRESH_INTERVAL = 60
16
+
17
+ def initialize(
18
+ environment_key:,
19
+ api_url: DEFAULT_API_URL,
20
+ enable_local_evaluation: false,
21
+ request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT,
22
+ enable_analytics: false,
23
+ environment_refresh_interval_seconds: DEFAULT_REFRESH_INTERVAL
24
+ )
25
+ validate_environment_key(environment_key: environment_key)
26
+ validate_api_url(api_url: api_url)
27
+ validate_timeout(timeout: request_timeout_seconds)
28
+ validate_refresh_interval(interval: environment_refresh_interval_seconds)
29
+
30
+ @environment_key = environment_key
31
+ @api_url = api_url
32
+ @enable_local_evaluation = enable_local_evaluation
33
+ @request_timeout_seconds = request_timeout_seconds
34
+ @enable_analytics = enable_analytics
35
+ @environment_refresh_interval_seconds = environment_refresh_interval_seconds
36
+ end
37
+
38
+ def local_evaluation?
39
+ @enable_local_evaluation
40
+ end
41
+
42
+ def analytics_enabled?
43
+ @enable_analytics
44
+ end
45
+
46
+ private
47
+
48
+ def validate_environment_key(environment_key: nil)
49
+ if environment_key.nil? || environment_key.to_s.strip.empty?
50
+ raise ArgumentError, "environment_key is required and cannot be empty"
51
+ end
52
+ end
53
+
54
+ def validate_api_url(api_url: nil)
55
+ return if api_url.nil?
56
+
57
+ uri = URI.parse(api_url)
58
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
59
+ raise ArgumentError, "Invalid URL for api_url: #{api_url}"
60
+ end
61
+ rescue URI::InvalidURIError
62
+ raise ArgumentError, "Invalid URL for api_url: #{api_url}"
63
+ end
64
+
65
+ def validate_timeout(timeout: nil)
66
+ return if timeout.nil?
67
+
68
+ unless timeout.is_a?(Integer) && timeout.positive?
69
+ raise ArgumentError, "request_timeout_seconds must be a positive integer"
70
+ end
71
+ end
72
+
73
+ def validate_refresh_interval(interval: nil)
74
+ return if interval.nil?
75
+
76
+ unless interval.is_a?(Integer) && interval.positive?
77
+ raise ArgumentError, "environment_refresh_interval_seconds must be a positive integer"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end