subflag-rails 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +245 -0
- data/lib/generators/subflag/install_generator.rb +56 -0
- data/lib/generators/subflag/templates/initializer.rb +28 -0
- data/lib/subflag/rails/client.rb +143 -0
- data/lib/subflag/rails/configuration.rb +83 -0
- data/lib/subflag/rails/context_builder.rb +62 -0
- data/lib/subflag/rails/evaluation_result.rb +97 -0
- data/lib/subflag/rails/flag_accessor.rb +129 -0
- data/lib/subflag/rails/helpers.rb +108 -0
- data/lib/subflag/rails/railtie.rb +45 -0
- data/lib/subflag/rails/request_cache.rb +80 -0
- data/lib/subflag/rails/test_helpers.rb +149 -0
- data/lib/subflag/rails/version.rb +7 -0
- data/lib/subflag/rails.rb +96 -0
- data/lib/subflag-rails.rb +3 -0
- metadata +221 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1796467214a7c83cd0187601d5298c6b5aae3397b6b0f48122d0daecae7a78fa
|
|
4
|
+
data.tar.gz: 58ca3b9ba465a7ce5f4be64df8a37dc88dc98a51ee9f7411d7e35856cd419beb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2abda5d0854287dbada9fd0205552525baa8e5d27a7924660656009cf516c2c38a3b37aba246a39dc688896a5fec053c2d32544eda6d83aef05824893472a2a1
|
|
7
|
+
data.tar.gz: f08bbb6c48fc8f61f24c3e9846aa6aa53276f5cbdb4ef4b165b230eeffd948e26e7f5477bee2c63b4b1b65b2d09decf4be0c5999db2f2afd81e06bc8e35d7abb
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2025-11-30
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Initial release
|
|
10
|
+
- `Subflag.flags` DSL with method_missing for clean flag access
|
|
11
|
+
- Boolean flags with `?` suffix (e.g., `flags.new_checkout?`)
|
|
12
|
+
- Typed value flags with required defaults
|
|
13
|
+
- `subflag_enabled?` and `subflag_value` helpers for controllers and views
|
|
14
|
+
- `subflag_for` helper to get a flag accessor
|
|
15
|
+
- Auto-scoping to `current_user` in controllers and views
|
|
16
|
+
- User context configuration for targeting
|
|
17
|
+
- Rails generator (`rails g subflag:install`)
|
|
18
|
+
- Auto-configuration from Rails credentials and ENV
|
|
19
|
+
- Bracket access for exact flag names
|
|
20
|
+
- `evaluate` method for full evaluation details
|
|
21
|
+
- Logging support with configurable levels
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Subflag
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# Subflag Rails
|
|
2
|
+
|
|
3
|
+
Typed feature flags for Rails. Booleans, strings, numbers, and JSON — all targetable by user.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'subflag-rails'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run the generator:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
rails generate subflag:install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Add your API key to Rails credentials:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
rails credentials:edit
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
subflag:
|
|
27
|
+
api_key: sdk-production-your-key-here
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or set the `SUBFLAG_API_KEY` environment variable.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Controllers & Views
|
|
35
|
+
|
|
36
|
+
Helpers are automatically available and scoped to `current_user`:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Controller
|
|
40
|
+
class ProjectsController < ApplicationController
|
|
41
|
+
def index
|
|
42
|
+
if subflag_enabled?(:new_dashboard)
|
|
43
|
+
# show new dashboard
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@max_projects = subflag_value(:max_projects, default: 3)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```erb
|
|
52
|
+
<!-- View -->
|
|
53
|
+
<% if subflag_enabled?(:new_checkout) %>
|
|
54
|
+
<%= render "new_checkout" %>
|
|
55
|
+
<% end %>
|
|
56
|
+
|
|
57
|
+
<h1><%= subflag_value(:headline, default: "Welcome") %></h1>
|
|
58
|
+
|
|
59
|
+
<p>You can create <%= subflag_value(:max_projects, default: 3) %> projects</p>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Flag Accessor DSL
|
|
63
|
+
|
|
64
|
+
For multiple flag checks, use the flag accessor:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
flags = subflag_for # auto-scoped to current_user
|
|
68
|
+
|
|
69
|
+
if flags.beta_feature?
|
|
70
|
+
headline = flags.welcome_message(default: "Hello!")
|
|
71
|
+
max = flags.max_projects(default: 3)
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or use `Subflag.flags` directly:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# With user context
|
|
79
|
+
flags = Subflag.flags(user: current_user)
|
|
80
|
+
flags.new_checkout? # => true/false
|
|
81
|
+
flags.max_projects(default: 3) # => 100
|
|
82
|
+
|
|
83
|
+
# Bracket access for exact flag names
|
|
84
|
+
flags["my-exact-flag", default: "value"]
|
|
85
|
+
|
|
86
|
+
# Full evaluation details
|
|
87
|
+
result = flags.evaluate(:max_projects, default: 3)
|
|
88
|
+
result.value # => 100
|
|
89
|
+
result.variant # => "premium"
|
|
90
|
+
result.reason # => :targeting_match
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Flag Types
|
|
94
|
+
|
|
95
|
+
The default value determines the expected type:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
# Boolean (? suffix, default optional)
|
|
99
|
+
subflag_enabled?(:new_checkout) # default: false
|
|
100
|
+
subflag_enabled?(:new_checkout, default: true)
|
|
101
|
+
|
|
102
|
+
# String
|
|
103
|
+
subflag_value(:headline, default: "Welcome")
|
|
104
|
+
|
|
105
|
+
# Integer
|
|
106
|
+
subflag_value(:max_projects, default: 3)
|
|
107
|
+
|
|
108
|
+
# Float
|
|
109
|
+
subflag_value(:tax_rate, default: 0.08)
|
|
110
|
+
|
|
111
|
+
# Hash/Object
|
|
112
|
+
subflag_value(:feature_limits, default: { max_items: 10 })
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### User Targeting
|
|
116
|
+
|
|
117
|
+
Configure how to extract context from user objects:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# config/initializers/subflag.rb
|
|
121
|
+
Subflag::Rails.configure do |config|
|
|
122
|
+
config.user_context do |user|
|
|
123
|
+
{
|
|
124
|
+
targeting_key: user.id.to_s,
|
|
125
|
+
email: user.email,
|
|
126
|
+
plan: user.subscription&.plan_name || "free",
|
|
127
|
+
admin: user.admin?
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Now flags can return different values based on user attributes:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
# In Subflag dashboard: max-projects returns 3 for "free", 100 for "premium"
|
|
137
|
+
subflag_value(:max_projects, default: 3) # => 3 or 100 based on user's plan
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Override User Context
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# No user context
|
|
144
|
+
subflag_enabled?(:public_feature, user: nil)
|
|
145
|
+
|
|
146
|
+
# Different user
|
|
147
|
+
subflag_value(:max_projects, user: admin_user, default: 3)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Flag Naming
|
|
151
|
+
|
|
152
|
+
Flag names use lowercase letters, numbers, and dashes:
|
|
153
|
+
- Valid: `new-checkout`, `max-api-requests`, `feature1`
|
|
154
|
+
- Invalid: `new_checkout`, `NewCheckout`, `my flag`
|
|
155
|
+
|
|
156
|
+
In Ruby, use underscores — they're automatically converted to dashes:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
subflag_enabled?(:new_checkout) # looks up "new-checkout"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Testing
|
|
163
|
+
|
|
164
|
+
Stub flags in your tests:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# spec/rails_helper.rb (RSpec)
|
|
168
|
+
require "subflag/rails/test_helpers"
|
|
169
|
+
RSpec.configure do |config|
|
|
170
|
+
config.include Subflag::Rails::TestHelpers
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# test/test_helper.rb (Minitest)
|
|
174
|
+
require "subflag/rails/test_helpers"
|
|
175
|
+
class ActiveSupport::TestCase
|
|
176
|
+
include Subflag::Rails::TestHelpers
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
# In your specs/tests
|
|
182
|
+
it "shows new checkout when enabled" do
|
|
183
|
+
stub_subflag(:new_checkout, true)
|
|
184
|
+
stub_subflag(:max_projects, 100)
|
|
185
|
+
|
|
186
|
+
visit checkout_path
|
|
187
|
+
expect(page).to have_content("New Checkout")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Stub multiple at once
|
|
191
|
+
stub_subflags(
|
|
192
|
+
new_checkout: true,
|
|
193
|
+
max_projects: 100,
|
|
194
|
+
headline: "Welcome!"
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Request Caching
|
|
199
|
+
|
|
200
|
+
Enable per-request caching to avoid multiple API calls for the same flag:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# config/application.rb
|
|
204
|
+
config.middleware.use Subflag::Rails::RequestCache::Middleware
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Now multiple checks for the same flag in one request hit the API only once:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# Without caching: 3 API calls
|
|
211
|
+
# With caching: 1 API call (cached for subsequent checks)
|
|
212
|
+
subflag_enabled?(:new_checkout) # API call
|
|
213
|
+
subflag_enabled?(:new_checkout) # Cache hit
|
|
214
|
+
subflag_enabled?(:new_checkout) # Cache hit
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Configuration
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
Subflag::Rails.configure do |config|
|
|
221
|
+
# API key (auto-loaded from credentials/ENV)
|
|
222
|
+
config.api_key = "sdk-production-..."
|
|
223
|
+
|
|
224
|
+
# API URL (default: https://api.subflag.com)
|
|
225
|
+
config.api_url = "https://api.subflag.com"
|
|
226
|
+
|
|
227
|
+
# Logging
|
|
228
|
+
config.logging_enabled = Rails.env.development?
|
|
229
|
+
config.log_level = :debug # :debug, :info, :warn
|
|
230
|
+
|
|
231
|
+
# User context
|
|
232
|
+
config.user_context do |user|
|
|
233
|
+
{ targeting_key: user.id.to_s, plan: user.plan }
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Documentation
|
|
239
|
+
|
|
240
|
+
- [Subflag Docs](https://docs.subflag.com)
|
|
241
|
+
- [Rails Guide](https://docs.subflag.com/rails)
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Subflag
|
|
6
|
+
module Generators
|
|
7
|
+
# Generator for setting up Subflag in a Rails application
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# rails generate subflag:install
|
|
11
|
+
#
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Creates a Subflag initializer and provides setup instructions"
|
|
16
|
+
|
|
17
|
+
def create_initializer
|
|
18
|
+
template "initializer.rb", "config/initializers/subflag.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show_instructions
|
|
22
|
+
say ""
|
|
23
|
+
say "Subflag installed!", :green
|
|
24
|
+
say ""
|
|
25
|
+
say "Next steps:"
|
|
26
|
+
say ""
|
|
27
|
+
say "1. Add your API key to Rails credentials:"
|
|
28
|
+
say " $ rails credentials:edit"
|
|
29
|
+
say ""
|
|
30
|
+
say " subflag:"
|
|
31
|
+
say " api_key: sdk-production-your-key-here"
|
|
32
|
+
say ""
|
|
33
|
+
say " Or set SUBFLAG_API_KEY environment variable."
|
|
34
|
+
say ""
|
|
35
|
+
say "2. Configure user context in config/initializers/subflag.rb"
|
|
36
|
+
say ""
|
|
37
|
+
say "3. Use flags in your code:"
|
|
38
|
+
say ""
|
|
39
|
+
say " # Controller (auto-scoped to current_user)"
|
|
40
|
+
say " if subflag_enabled?(:new_checkout)"
|
|
41
|
+
say " # ..."
|
|
42
|
+
say " end"
|
|
43
|
+
say ""
|
|
44
|
+
say " max = subflag_value(:max_projects, default: 3)"
|
|
45
|
+
say ""
|
|
46
|
+
say " # View"
|
|
47
|
+
say " <% if subflag_enabled?(:new_checkout) %>"
|
|
48
|
+
say " <%= render 'new_checkout' %>"
|
|
49
|
+
say " <% end %>"
|
|
50
|
+
say ""
|
|
51
|
+
say "Docs: https://docs.subflag.com/rails"
|
|
52
|
+
say ""
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Subflag configuration
|
|
4
|
+
#
|
|
5
|
+
# API key is automatically loaded from:
|
|
6
|
+
# 1. Rails credentials (subflag.api_key or subflag_api_key)
|
|
7
|
+
# 2. SUBFLAG_API_KEY environment variable
|
|
8
|
+
|
|
9
|
+
Subflag::Rails.configure do |config|
|
|
10
|
+
# Uncomment to manually set API key
|
|
11
|
+
# config.api_key = Rails.application.credentials.dig(:subflag, :api_key)
|
|
12
|
+
|
|
13
|
+
# Enable logging in development
|
|
14
|
+
config.logging_enabled = Rails.env.development?
|
|
15
|
+
config.log_level = :debug
|
|
16
|
+
|
|
17
|
+
# Configure user context for targeting
|
|
18
|
+
# This enables per-user flag values (e.g., different limits by plan)
|
|
19
|
+
#
|
|
20
|
+
# config.user_context do |user|
|
|
21
|
+
# {
|
|
22
|
+
# targeting_key: user.id.to_s,
|
|
23
|
+
# email: user.email,
|
|
24
|
+
# plan: user.subscription&.plan_name || "free",
|
|
25
|
+
# admin: user.admin?
|
|
26
|
+
# }
|
|
27
|
+
# end
|
|
28
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Client for evaluating feature flags
|
|
6
|
+
#
|
|
7
|
+
# This is the low-level client used by FlagAccessor.
|
|
8
|
+
# Most users should use `Subflag.flags` instead.
|
|
9
|
+
#
|
|
10
|
+
class Client
|
|
11
|
+
# Check if a boolean flag is enabled
|
|
12
|
+
#
|
|
13
|
+
# @param flag_key [String] The flag key (already normalized)
|
|
14
|
+
# @param user [Object, nil] The user object for targeting
|
|
15
|
+
# @param context [Hash, nil] Additional context attributes
|
|
16
|
+
# @param default [Boolean] Default value if flag not found (defaults to false)
|
|
17
|
+
# @return [Boolean]
|
|
18
|
+
def enabled?(flag_key, user: nil, context: nil, default: false)
|
|
19
|
+
ctx = ContextBuilder.build(user: user, context: context)
|
|
20
|
+
cache_key = build_cache_key(flag_key, ctx, :boolean)
|
|
21
|
+
|
|
22
|
+
result = RequestCache.fetch(cache_key) do
|
|
23
|
+
openfeature_client.fetch_boolean_value(flag_key, default, ctx)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
log_evaluation(flag_key, result, default)
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get a flag value - default is required to determine type
|
|
31
|
+
#
|
|
32
|
+
# @param flag_key [String] The flag key (already normalized)
|
|
33
|
+
# @param user [Object, nil] The user object for targeting
|
|
34
|
+
# @param context [Hash, nil] Additional context attributes
|
|
35
|
+
# @param default [Object] Default value (required - determines expected type)
|
|
36
|
+
# @return [Object] The flag value
|
|
37
|
+
# @raise [ArgumentError] If default is nil
|
|
38
|
+
def value(flag_key, user: nil, context: nil, default:)
|
|
39
|
+
ctx = ContextBuilder.build(user: user, context: context)
|
|
40
|
+
cache_key = build_cache_key(flag_key, ctx, default.class)
|
|
41
|
+
|
|
42
|
+
result = RequestCache.fetch(cache_key) do
|
|
43
|
+
fetch_value_by_type(flag_key, default, ctx)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
log_evaluation(flag_key, result, default)
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Evaluate a flag and get full details - default is required
|
|
51
|
+
#
|
|
52
|
+
# @param flag_key [String] The flag key (already normalized)
|
|
53
|
+
# @param user [Object, nil] The user object for targeting
|
|
54
|
+
# @param context [Hash, nil] Additional context attributes
|
|
55
|
+
# @param default [Object] Default value (required - determines expected type)
|
|
56
|
+
# @return [EvaluationResult] Full evaluation result
|
|
57
|
+
# @raise [ArgumentError] If default is nil
|
|
58
|
+
def evaluate(flag_key, user: nil, context: nil, default:)
|
|
59
|
+
ctx = ContextBuilder.build(user: user, context: context)
|
|
60
|
+
cache_key = build_cache_key(flag_key, ctx, "details:#{default.class}")
|
|
61
|
+
|
|
62
|
+
details = RequestCache.fetch(cache_key) do
|
|
63
|
+
fetch_details_by_type(flag_key, default, ctx)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
log_evaluation(flag_key, details[:value], default)
|
|
67
|
+
EvaluationResult.from_openfeature(details, flag_key: flag_key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def build_cache_key(flag_key, ctx, type)
|
|
73
|
+
context_hash = ctx ? ctx.hash : "no_context"
|
|
74
|
+
"subflag:#{flag_key}:#{context_hash}:#{type}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def openfeature_client
|
|
78
|
+
@openfeature_client ||= OpenFeature::SDK.build_client
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fetch_value_by_type(flag_key, default, ctx)
|
|
82
|
+
case default
|
|
83
|
+
when String
|
|
84
|
+
openfeature_client.fetch_string_value(flag_key, default, ctx)
|
|
85
|
+
when Integer
|
|
86
|
+
openfeature_client.fetch_integer_value(flag_key, default, ctx)
|
|
87
|
+
when Float
|
|
88
|
+
openfeature_client.fetch_float_value(flag_key, default, ctx)
|
|
89
|
+
when TrueClass, FalseClass
|
|
90
|
+
openfeature_client.fetch_boolean_value(flag_key, default, ctx)
|
|
91
|
+
when Hash
|
|
92
|
+
openfeature_client.fetch_object_value(flag_key, default, ctx)
|
|
93
|
+
when NilClass
|
|
94
|
+
raise ArgumentError, "default is required for value flags (it determines the expected type)"
|
|
95
|
+
else
|
|
96
|
+
raise ArgumentError, "Unsupported default type: #{default.class}. Use String, Integer, Float, Boolean, or Hash."
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fetch_details_by_type(flag_key, default, ctx)
|
|
101
|
+
case default
|
|
102
|
+
when String
|
|
103
|
+
openfeature_client.fetch_string_details(flag_key, default, ctx)
|
|
104
|
+
when Integer
|
|
105
|
+
openfeature_client.fetch_integer_details(flag_key, default, ctx)
|
|
106
|
+
when Float
|
|
107
|
+
openfeature_client.fetch_float_details(flag_key, default, ctx)
|
|
108
|
+
when TrueClass, FalseClass
|
|
109
|
+
openfeature_client.fetch_boolean_details(flag_key, default, ctx)
|
|
110
|
+
when Hash
|
|
111
|
+
openfeature_client.fetch_object_details(flag_key, default, ctx)
|
|
112
|
+
when NilClass
|
|
113
|
+
raise ArgumentError, "default is required for evaluate (it determines the expected type)"
|
|
114
|
+
else
|
|
115
|
+
raise ArgumentError, "Unsupported default type: #{default.class}. Use String, Integer, Float, Boolean, or Hash."
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def configuration
|
|
120
|
+
Subflag::Rails.configuration
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def log_evaluation(flag_key, result, default)
|
|
124
|
+
return unless configuration.logging_enabled
|
|
125
|
+
|
|
126
|
+
logger = defined?(::Rails.logger) ? ::Rails.logger : nil
|
|
127
|
+
return unless logger
|
|
128
|
+
|
|
129
|
+
message = "[Subflag] #{flag_key} = #{result.inspect}"
|
|
130
|
+
message += " (default)" if result == default
|
|
131
|
+
|
|
132
|
+
case configuration.log_level
|
|
133
|
+
when :info
|
|
134
|
+
logger.info(message)
|
|
135
|
+
when :warn
|
|
136
|
+
logger.warn(message)
|
|
137
|
+
else
|
|
138
|
+
logger.debug(message)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Configuration for Subflag Rails integration
|
|
6
|
+
#
|
|
7
|
+
# @example Basic configuration
|
|
8
|
+
# Subflag::Rails.configure do |config|
|
|
9
|
+
# config.api_key = Rails.application.credentials.subflag_api_key
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# @example With user context
|
|
13
|
+
# Subflag::Rails.configure do |config|
|
|
14
|
+
# config.api_key = Rails.application.credentials.subflag_api_key
|
|
15
|
+
# config.user_context do |user|
|
|
16
|
+
# {
|
|
17
|
+
# targeting_key: user.id.to_s,
|
|
18
|
+
# email: user.email,
|
|
19
|
+
# plan: user.subscription&.plan_name,
|
|
20
|
+
# admin: user.admin?
|
|
21
|
+
# }
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class Configuration
|
|
26
|
+
# @return [String, nil] The Subflag API key
|
|
27
|
+
attr_accessor :api_key
|
|
28
|
+
|
|
29
|
+
# @return [String] The Subflag API URL (defaults to production)
|
|
30
|
+
attr_accessor :api_url
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] Whether to log flag evaluations
|
|
33
|
+
attr_accessor :logging_enabled
|
|
34
|
+
|
|
35
|
+
# @return [Symbol] Log level for flag evaluations (:debug, :info, :warn)
|
|
36
|
+
attr_accessor :log_level
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@api_key = nil
|
|
40
|
+
@api_url = "https://api.subflag.com"
|
|
41
|
+
@user_context_block = nil
|
|
42
|
+
@logging_enabled = false
|
|
43
|
+
@log_level = :debug
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Configure how to extract context from a user object
|
|
47
|
+
#
|
|
48
|
+
# @yield [user] Block that receives a user object and returns context hash
|
|
49
|
+
# @yieldparam user [Object] The user object passed to flag methods
|
|
50
|
+
# @yieldreturn [Hash] Context hash with targeting_key and attributes
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# config.user_context do |user|
|
|
54
|
+
# {
|
|
55
|
+
# targeting_key: user.id.to_s,
|
|
56
|
+
# email: user.email,
|
|
57
|
+
# plan: user.plan
|
|
58
|
+
# }
|
|
59
|
+
# end
|
|
60
|
+
def user_context(&block)
|
|
61
|
+
@user_context_block = block if block_given?
|
|
62
|
+
@user_context_block
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if user context is configured
|
|
66
|
+
#
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def user_context_configured?
|
|
69
|
+
!@user_context_block.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Build context from a user object using the configured block
|
|
73
|
+
#
|
|
74
|
+
# @param user [Object] The user object
|
|
75
|
+
# @return [Hash, nil] The context hash or nil if no user/block
|
|
76
|
+
def build_user_context(user)
|
|
77
|
+
return nil unless user && @user_context_block
|
|
78
|
+
|
|
79
|
+
@user_context_block.call(user)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Subflag
|
|
4
|
+
module Rails
|
|
5
|
+
# Builds OpenFeature evaluation context from user objects and additional attributes
|
|
6
|
+
class ContextBuilder
|
|
7
|
+
# Build an OpenFeature context hash
|
|
8
|
+
#
|
|
9
|
+
# @param user [Object, nil] The user object for targeting
|
|
10
|
+
# @param context [Hash, nil] Additional context attributes
|
|
11
|
+
# @return [Hash, nil] The combined context or nil if empty
|
|
12
|
+
def self.build(user: nil, context: nil)
|
|
13
|
+
new(user: user, context: context).build
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(user: nil, context: nil)
|
|
17
|
+
@user = user
|
|
18
|
+
@context = context || {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Build the context hash
|
|
22
|
+
#
|
|
23
|
+
# @return [Hash, nil]
|
|
24
|
+
def build
|
|
25
|
+
result = {}
|
|
26
|
+
|
|
27
|
+
# Add user context if configured
|
|
28
|
+
if @user
|
|
29
|
+
user_context = configuration.build_user_context(@user)
|
|
30
|
+
result.merge!(user_context) if user_context
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Merge in additional context (overrides user context)
|
|
34
|
+
result.merge!(@context) if @context.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
# Return nil if empty (no context to send)
|
|
37
|
+
result.empty? ? nil : normalize_context(result)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def configuration
|
|
43
|
+
Subflag::Rails.configuration
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Normalize context to OpenFeature format
|
|
47
|
+
#
|
|
48
|
+
# @param ctx [Hash] The raw context
|
|
49
|
+
# @return [Hash] Normalized context with targeting_key at top level
|
|
50
|
+
def normalize_context(ctx)
|
|
51
|
+
# Ensure targeting_key is a string
|
|
52
|
+
if ctx[:targeting_key]
|
|
53
|
+
ctx[:targeting_key] = ctx[:targeting_key].to_s
|
|
54
|
+
elsif ctx["targeting_key"]
|
|
55
|
+
ctx[:targeting_key] = ctx.delete("targeting_key").to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
ctx.transform_keys(&:to_sym)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|