subflag-openfeature-provider 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40e8537098d52a561735926123fc60c1ad200f3762151588e101f815447cf822
4
+ data.tar.gz: 42821e1d8a10786803898a2d17d1c82002f24a314351cb3b61aa7b43c0cca2ba
5
+ SHA512:
6
+ metadata.gz: 8ae9b2658c93632d4cbcbee3af96c27fccda6c099f8f144a225b79961298f5851f7757c7404583bc235fc643f510ce0175fb579def7ebf285f9d97bb3f14cf67
7
+ data.tar.gz: e59f3c5b24a9a3f18fad1ceb09fb1d0dda9084f355abfd8d2862f3da81fcf4dc564dbf7143cbfa4fc948482a0efe69c8a94444f1b47c19d076ba5c0605fd0cd4
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --require spec_helper
2
+ --format documentation
3
+ --color
data/.rspec_status ADDED
@@ -0,0 +1,70 @@
1
+ example_id | status | run_time |
2
+ ------------------------------------------------ | ------ | --------------- |
3
+ ./spec/subflag/client_spec.rb[1:1:1] | passed | 0.00269 seconds |
4
+ ./spec/subflag/client_spec.rb[1:1:2] | passed | 0.0005 seconds |
5
+ ./spec/subflag/client_spec.rb[1:1:3] | passed | 0.00036 seconds |
6
+ ./spec/subflag/client_spec.rb[1:1:4] | passed | 0.00034 seconds |
7
+ ./spec/subflag/client_spec.rb[1:1:5] | passed | 0.00028 seconds |
8
+ ./spec/subflag/client_spec.rb[1:2:1:1] | passed | 0.03835 seconds |
9
+ ./spec/subflag/client_spec.rb[1:2:1:2] | passed | 0.01594 seconds |
10
+ ./spec/subflag/client_spec.rb[1:2:1:3] | passed | 0.01299 seconds |
11
+ ./spec/subflag/client_spec.rb[1:2:1:4] | passed | 0.01766 seconds |
12
+ ./spec/subflag/client_spec.rb[1:2:1:5] | passed | 0.03152 seconds |
13
+ ./spec/subflag/client_spec.rb[1:2:2:1] | passed | 0.02146 seconds |
14
+ ./spec/subflag/client_spec.rb[1:2:3:1] | passed | 0.01918 seconds |
15
+ ./spec/subflag/client_spec.rb[1:2:4:1] | passed | 0.01256 seconds |
16
+ ./spec/subflag/client_spec.rb[1:2:4:2] | passed | 0.02079 seconds |
17
+ ./spec/subflag/client_spec.rb[1:2:5:1] | passed | 0.01744 seconds |
18
+ ./spec/subflag/client_spec.rb[1:2:6:1] | passed | 0.01392 seconds |
19
+ ./spec/subflag/client_spec.rb[1:2:6:2] | passed | 0.01303 seconds |
20
+ ./spec/subflag/client_spec.rb[1:2:7:1] | passed | 0.01581 seconds |
21
+ ./spec/subflag/client_spec.rb[1:3:1:1] | passed | 0.01796 seconds |
22
+ ./spec/subflag/client_spec.rb[1:3:1:2] | passed | 0.01345 seconds |
23
+ ./spec/subflag/client_spec.rb[1:3:2:1] | passed | 0.01236 seconds |
24
+ ./spec/subflag/errors_spec.rb[1:1] | passed | 0.00544 seconds |
25
+ ./spec/subflag/errors_spec.rb[2:1] | passed | 0.0002 seconds |
26
+ ./spec/subflag/evaluation_context_spec.rb[1:1:1] | passed | 0.00274 seconds |
27
+ ./spec/subflag/evaluation_context_spec.rb[1:1:2] | passed | 0.00038 seconds |
28
+ ./spec/subflag/evaluation_context_spec.rb[1:1:3] | passed | 0.00025 seconds |
29
+ ./spec/subflag/evaluation_context_spec.rb[1:1:4] | passed | 0.00016 seconds |
30
+ ./spec/subflag/evaluation_context_spec.rb[1:1:5] | passed | 0.00014 seconds |
31
+ ./spec/subflag/evaluation_context_spec.rb[1:2:1] | passed | 0.00012 seconds |
32
+ ./spec/subflag/evaluation_context_spec.rb[1:2:2] | passed | 0.00277 seconds |
33
+ ./spec/subflag/evaluation_context_spec.rb[1:2:3] | passed | 0.00018 seconds |
34
+ ./spec/subflag/evaluation_context_spec.rb[1:3:1] | passed | 0.00013 seconds |
35
+ ./spec/subflag/evaluation_context_spec.rb[1:3:2] | passed | 0.00011 seconds |
36
+ ./spec/subflag/evaluation_context_spec.rb[1:3:3] | passed | 0.00011 seconds |
37
+ ./spec/subflag/evaluation_context_spec.rb[1:3:4] | passed | 0.00012 seconds |
38
+ ./spec/subflag/evaluation_context_spec.rb[1:3:5] | passed | 0.02665 seconds |
39
+ ./spec/subflag/evaluation_context_spec.rb[1:3:6] | passed | 0.00018 seconds |
40
+ ./spec/subflag/evaluation_context_spec.rb[1:3:7] | passed | 0.00014 seconds |
41
+ ./spec/subflag/evaluation_result_spec.rb[1:1:1] | passed | 0.00012 seconds |
42
+ ./spec/subflag/evaluation_result_spec.rb[1:2:1] | passed | 0.0001 seconds |
43
+ ./spec/subflag/evaluation_result_spec.rb[1:2:2] | passed | 0.0001 seconds |
44
+ ./spec/subflag/provider_spec.rb[1:1:1] | passed | 0.0005 seconds |
45
+ ./spec/subflag/provider_spec.rb[1:2:1] | passed | 0.00039 seconds |
46
+ ./spec/subflag/provider_spec.rb[1:3:1] | passed | 0.00038 seconds |
47
+ ./spec/subflag/provider_spec.rb[1:4:1:1] | passed | 0.0132 seconds |
48
+ ./spec/subflag/provider_spec.rb[1:4:1:2] | passed | 0.01269 seconds |
49
+ ./spec/subflag/provider_spec.rb[1:4:2:1] | passed | 0.01343 seconds |
50
+ ./spec/subflag/provider_spec.rb[1:4:3:1] | passed | 0.01265 seconds |
51
+ ./spec/subflag/provider_spec.rb[1:4:4:1] | passed | 0.01326 seconds |
52
+ ./spec/subflag/provider_spec.rb[1:4:5:1] | passed | 0.0197 seconds |
53
+ ./spec/subflag/provider_spec.rb[1:5:1] | passed | 0.01297 seconds |
54
+ ./spec/subflag/provider_spec.rb[1:5:2] | passed | 0.01226 seconds |
55
+ ./spec/subflag/provider_spec.rb[1:6:1] | passed | 0.01201 seconds |
56
+ ./spec/subflag/provider_spec.rb[1:6:2] | passed | 0.01698 seconds |
57
+ ./spec/subflag/provider_spec.rb[1:7:1] | passed | 0.01183 seconds |
58
+ ./spec/subflag/provider_spec.rb[1:7:2] | passed | 0.01746 seconds |
59
+ ./spec/subflag/provider_spec.rb[1:8:1] | passed | 0.01125 seconds |
60
+ ./spec/subflag/provider_spec.rb[1:8:2] | passed | 0.0119 seconds |
61
+ ./spec/subflag/provider_spec.rb[1:9:1] | passed | 0.01764 seconds |
62
+ ./spec/subflag/provider_spec.rb[1:9:2] | passed | 0.01244 seconds |
63
+ ./spec/subflag/provider_spec.rb[1:10:1] | passed | 0.01167 seconds |
64
+ ./spec/subflag/provider_spec.rb[1:10:2] | passed | 0.01133 seconds |
65
+ ./spec/subflag/provider_spec.rb[1:10:3] | passed | 0.01184 seconds |
66
+ ./spec/subflag/provider_spec.rb[1:10:4] | passed | 0.01362 seconds |
67
+ ./spec/subflag/provider_spec.rb[1:10:5] | passed | 0.01628 seconds |
68
+ ./spec/subflag/provider_spec.rb[1:10:6] | passed | 0.0151 seconds |
69
+ ./spec/subflag/provider_spec.rb[1:11:1] | passed | 0.01314 seconds |
70
+ ./spec/subflag/provider_spec.rb[1:11:2] | passed | 0.01556 seconds |
data/.rubocop.yml ADDED
@@ -0,0 +1,21 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Style/Documentation:
7
+ Enabled: false
8
+
9
+ Style/FrozenStringLiteralComment:
10
+ Enabled: true
11
+
12
+ Metrics/BlockLength:
13
+ Exclude:
14
+ - 'spec/**/*'
15
+ - '*.gemspec'
16
+
17
+ Metrics/MethodLength:
18
+ Max: 15
19
+
20
+ Layout/LineLength:
21
+ Max: 120
data/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # Subflag OpenFeature Provider for Ruby
2
+
3
+ Ruby provider for [OpenFeature](https://openfeature.dev) that integrates with [Subflag](https://subflag.com) feature flag management.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem 'subflag-openfeature-provider'
9
+ ```
10
+
11
+ Or with Bundler:
12
+
13
+ ```bash
14
+ bundle add subflag-openfeature-provider
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### With OpenFeature SDK
20
+
21
+ ```ruby
22
+ require "openfeature/sdk"
23
+ require "subflag"
24
+
25
+ # Configure the provider
26
+ provider = Subflag::Provider.new(
27
+ api_url: ENV["SUBFLAG_API_URL"],
28
+ api_key: ENV["SUBFLAG_API_KEY"]
29
+ )
30
+
31
+ OpenFeature::SDK.configure do |config|
32
+ config.set_provider(provider)
33
+ end
34
+
35
+ # Get a client and evaluate flags
36
+ client = OpenFeature::SDK.build_client
37
+
38
+ # Boolean flag
39
+ if client.fetch_boolean_value(flag_key: "dark-mode", default_value: false)
40
+ enable_dark_mode
41
+ end
42
+
43
+ # String flag
44
+ theme = client.fetch_string_value(flag_key: "theme", default_value: "light")
45
+
46
+ # Number flag
47
+ limit = client.fetch_integer_value(flag_key: "rate-limit", default_value: 100)
48
+
49
+ # Object flag
50
+ config = client.fetch_object_value(flag_key: "feature-config", default_value: {})
51
+ ```
52
+
53
+ ### With Evaluation Context
54
+
55
+ ```ruby
56
+ context = {
57
+ targeting_key: "user-123",
58
+ plan: "premium",
59
+ country: "US"
60
+ }
61
+
62
+ enabled = client.fetch_boolean_value(
63
+ flag_key: "premium-feature",
64
+ default_value: false,
65
+ evaluation_context: context
66
+ )
67
+ ```
68
+
69
+ ### Direct Client Usage
70
+
71
+ You can also use the Subflag client directly without OpenFeature:
72
+
73
+ ```ruby
74
+ require "subflag"
75
+
76
+ client = Subflag::Client.new(
77
+ api_url: "https://api.subflag.com",
78
+ api_key: "sdk-production-abc123"
79
+ )
80
+
81
+ result = client.evaluate("my-flag")
82
+ puts result.value # => true
83
+ puts result.variant # => "enabled"
84
+ puts result.reason # => "DEFAULT"
85
+
86
+ # Bulk evaluation
87
+ results = client.evaluate_all
88
+ ```
89
+
90
+ ## Configuration
91
+
92
+ | Option | Description | Default |
93
+ |--------|-------------|---------|
94
+ | `api_url` | Subflag API base URL | Required |
95
+ | `api_key` | SDK API key (`sdk-{env}-{random}`) | Required |
96
+ | `timeout` | Request timeout in seconds | 5 |
97
+
98
+ ## Error Handling
99
+
100
+ The provider returns default values on errors, following OpenFeature conventions:
101
+
102
+ ```ruby
103
+ result = client.fetch_boolean_value(flag_key: "unknown", default_value: false)
104
+ # result[:value] => false (default)
105
+ # result[:reason] => :error
106
+ # result[:error_code] => :flag_not_found
107
+ ```
108
+
109
+ ## Requirements
110
+
111
+ - Ruby >= 3.1
112
+ - openfeature-sdk >= 0.3
113
+
114
+ ## License
115
+
116
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ RuboCop::RakeTask.new
10
+
11
+ task default: %i[spec rubocop]
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Subflag
7
+ # HTTP client for communicating with the Subflag API
8
+ #
9
+ # @example Basic usage
10
+ # client = Client.new(api_url: "https://api.subflag.com", api_key: "sdk-dev-abc123")
11
+ # result = client.evaluate("my-flag")
12
+ #
13
+ # @example With evaluation context
14
+ # context = EvaluationContext.new(targeting_key: "user-123", attributes: { plan: "premium" })
15
+ # result = client.evaluate("my-flag", context: context)
16
+ class Client
17
+ DEFAULT_TIMEOUT = 5 # seconds
18
+
19
+ attr_reader :api_url, :api_key, :timeout
20
+
21
+ # @param api_url [String] The base URL of the Subflag API
22
+ # @param api_key [String] The SDK API key (format: sdk-{env}-{random})
23
+ # @param timeout [Integer] Request timeout in seconds (default: 5)
24
+ def initialize(api_url:, api_key:, timeout: DEFAULT_TIMEOUT)
25
+ @api_url = api_url.chomp("/")
26
+ @api_key = api_key
27
+ @timeout = timeout
28
+ @connection = build_connection
29
+ end
30
+
31
+ # Evaluate a single flag
32
+ #
33
+ # @param flag_key [String] The key of the flag to evaluate
34
+ # @param context [EvaluationContext, nil] Optional evaluation context
35
+ # @return [EvaluationResult] The evaluation result
36
+ # @raise [FlagNotFoundError] If the flag doesn't exist
37
+ # @raise [AuthenticationError] If the API key is invalid
38
+ # @raise [ApiError] For other API errors
39
+ def evaluate(flag_key, context: nil)
40
+ response = post("/sdk/evaluate/#{encode_uri_component(flag_key)}", context&.to_h)
41
+ EvaluationResult.from_response(response)
42
+ end
43
+
44
+ # Evaluate all flags in the environment
45
+ #
46
+ # @param context [EvaluationContext, nil] Optional evaluation context
47
+ # @return [Array<EvaluationResult>] Array of evaluation results
48
+ # @raise [AuthenticationError] If the API key is invalid
49
+ # @raise [ApiError] For other API errors
50
+ def evaluate_all(context: nil)
51
+ response = post("/sdk/evaluate-all", context&.to_h)
52
+ response.map { |data| EvaluationResult.from_response(data) }
53
+ end
54
+
55
+ private
56
+
57
+ def build_connection
58
+ Faraday.new(url: @api_url) do |conn|
59
+ conn.request :json
60
+ conn.response :json
61
+ conn.options.timeout = @timeout
62
+ conn.options.open_timeout = @timeout
63
+ conn.headers["Content-Type"] = "application/json"
64
+ conn.headers["X-Subflag-API-Key"] = @api_key
65
+ conn.adapter Faraday.default_adapter
66
+ end
67
+ end
68
+
69
+ def post(path, body)
70
+ response = @connection.post(path) do |req|
71
+ req.body = body.to_json if body && !body.empty?
72
+ end
73
+
74
+ handle_response(response, path)
75
+ rescue Faraday::TimeoutError => e
76
+ raise TimeoutError, "Request timed out after #{@timeout}s: #{e.message}"
77
+ rescue Faraday::ConnectionFailed => e
78
+ raise ConnectionError, "Failed to connect to #{@api_url}: #{e.message}"
79
+ rescue Faraday::Error => e
80
+ raise ApiError.new("HTTP request failed: #{e.message}")
81
+ end
82
+
83
+ def handle_response(response, path)
84
+ body = parse_body(response.body)
85
+
86
+ case response.status
87
+ when 200, 201
88
+ body
89
+ when 401, 403
90
+ raise AuthenticationError.new(
91
+ extract_message(body) || "Authentication failed",
92
+ status: response.status,
93
+ details: body
94
+ )
95
+ when 404
96
+ flag_key = path.split("/").last
97
+ raise FlagNotFoundError.new(flag_key, status: 404, details: body)
98
+ else
99
+ raise ApiError.new(
100
+ extract_message(body) || "API request failed with status #{response.status}",
101
+ status: response.status,
102
+ details: body
103
+ )
104
+ end
105
+ end
106
+
107
+ def parse_body(body)
108
+ return body if body.is_a?(Hash) || body.is_a?(Array)
109
+ return {} if body.nil? || body.empty?
110
+
111
+ JSON.parse(body)
112
+ rescue JSON::ParserError
113
+ { "message" => body }
114
+ end
115
+
116
+ def extract_message(body)
117
+ body["message"] if body.is_a?(Hash)
118
+ end
119
+
120
+ def encode_uri_component(str)
121
+ URI.encode_www_form_component(str.to_s)
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ # Base error class for all Subflag errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when API request fails
8
+ class ApiError < Error
9
+ attr_reader :status, :details
10
+
11
+ def initialize(message, status: nil, details: nil)
12
+ @status = status
13
+ @details = details
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ # Raised when authentication fails (401/403)
19
+ class AuthenticationError < ApiError
20
+ def initialize(message = "Invalid or missing API key", **kwargs)
21
+ super(message, **kwargs)
22
+ end
23
+ end
24
+
25
+ # Raised when a flag is not found (404)
26
+ class FlagNotFoundError < ApiError
27
+ attr_reader :flag_key
28
+
29
+ def initialize(flag_key, **kwargs)
30
+ @flag_key = flag_key
31
+ super("Flag not found: #{flag_key}", **kwargs)
32
+ end
33
+ end
34
+
35
+ # Raised when flag value type doesn't match requested type
36
+ class TypeMismatchError < Error
37
+ attr_reader :flag_key, :expected_type, :actual_type
38
+
39
+ def initialize(flag_key, expected_type:, actual_type:)
40
+ @flag_key = flag_key
41
+ @expected_type = expected_type
42
+ @actual_type = actual_type
43
+ super("Type mismatch for flag '#{flag_key}': expected #{expected_type}, got #{actual_type}")
44
+ end
45
+ end
46
+
47
+ # Raised when network/connection fails
48
+ class ConnectionError < Error
49
+ def initialize(message = "Failed to connect to Subflag API")
50
+ super(message)
51
+ end
52
+ end
53
+
54
+ # Raised when request times out
55
+ class TimeoutError < Error
56
+ def initialize(message = "Request to Subflag API timed out")
57
+ super(message)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ # Represents the context for flag evaluation, including targeting information
5
+ # and custom attributes for segment matching and percentage rollouts.
6
+ #
7
+ # @example Basic usage with targeting key
8
+ # context = EvaluationContext.new(targeting_key: "user-123")
9
+ #
10
+ # @example With custom attributes
11
+ # context = EvaluationContext.new(
12
+ # targeting_key: "user-123",
13
+ # kind: "user",
14
+ # attributes: { plan: "premium", country: "US" }
15
+ # )
16
+ class EvaluationContext
17
+ attr_reader :targeting_key, :kind, :attributes
18
+
19
+ # @param targeting_key [String, nil] Unique identifier for targeting (user ID, session ID, etc.)
20
+ # @param kind [String, nil] The kind of context ("user", "organization", "device", etc.)
21
+ # @param attributes [Hash, nil] Custom attributes for targeting rules
22
+ def initialize(targeting_key: nil, kind: nil, attributes: nil)
23
+ @targeting_key = targeting_key
24
+ @kind = kind
25
+ @attributes = attributes || {}
26
+ end
27
+
28
+ # Convert to hash for API request
29
+ # @return [Hash] The context as a hash
30
+ def to_h
31
+ {
32
+ targetingKey: @targeting_key,
33
+ kind: @kind,
34
+ attributes: @attributes.empty? ? nil : @attributes
35
+ }.compact
36
+ end
37
+
38
+ # Create from OpenFeature evaluation context
39
+ # @param openfeature_context [OpenFeature::SDK::EvaluationContext, Hash, nil]
40
+ # @return [EvaluationContext]
41
+ def self.from_openfeature(openfeature_context)
42
+ return new if openfeature_context.nil?
43
+
44
+ # Handle Hash-like context (OpenFeature context is typically a hash-like object)
45
+ if openfeature_context.respond_to?(:to_h)
46
+ ctx = openfeature_context.to_h
47
+ elsif openfeature_context.is_a?(Hash)
48
+ ctx = openfeature_context
49
+ else
50
+ return new
51
+ end
52
+
53
+ # Extract targeting_key (OpenFeature standard)
54
+ targeting_key = ctx[:targeting_key] || ctx["targeting_key"]
55
+
56
+ # Extract other attributes (excluding targeting_key)
57
+ attributes = ctx.reject { |k, _| [:targeting_key, "targeting_key"].include?(k) }
58
+
59
+ new(
60
+ targeting_key: targeting_key,
61
+ kind: "user", # Default to "user" kind for OpenFeature contexts
62
+ attributes: attributes.empty? ? nil : symbolize_keys(attributes)
63
+ )
64
+ end
65
+
66
+ private
67
+
68
+ def self.symbolize_keys(hash)
69
+ hash.transform_keys(&:to_sym)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ # Represents the result of a flag evaluation from the Subflag API
5
+ class EvaluationResult
6
+ # Valid evaluation reasons
7
+ REASONS = %w[
8
+ DEFAULT
9
+ OVERRIDE
10
+ SEGMENT_MATCH
11
+ PERCENTAGE_ROLLOUT
12
+ TARGETING_MATCH
13
+ ERROR
14
+ ].freeze
15
+
16
+ attr_reader :flag_key, :value, :variant, :reason
17
+
18
+ # @param flag_key [String] The key of the evaluated flag
19
+ # @param value [Object] The evaluated value (type depends on flag configuration)
20
+ # @param variant [String] The name of the selected variant
21
+ # @param reason [String] Why this value was selected (one of REASONS)
22
+ def initialize(flag_key:, value:, variant:, reason:)
23
+ @flag_key = flag_key
24
+ @value = value
25
+ @variant = variant
26
+ @reason = reason
27
+ end
28
+
29
+ # Create from API response hash
30
+ # @param data [Hash] The API response data
31
+ # @return [EvaluationResult]
32
+ def self.from_response(data)
33
+ new(
34
+ flag_key: fetch_key(data, "flagKey"),
35
+ value: fetch_key(data, "value"),
36
+ variant: fetch_key(data, "variant"),
37
+ reason: fetch_key(data, "reason")
38
+ )
39
+ end
40
+
41
+ # Fetch a key from hash, checking both string and symbol keys
42
+ # Can't use || because false values would be skipped
43
+ def self.fetch_key(data, key)
44
+ data.key?(key) ? data[key] : data[key.to_sym]
45
+ end
46
+
47
+ # Check if evaluation was successful (not an error)
48
+ # @return [Boolean]
49
+ def success?
50
+ reason != "ERROR"
51
+ end
52
+
53
+ # Convert to hash
54
+ # @return [Hash]
55
+ def to_h
56
+ {
57
+ flag_key: @flag_key,
58
+ value: @value,
59
+ variant: @variant,
60
+ reason: @reason
61
+ }
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ # OpenFeature provider for Subflag feature flag management.
5
+ #
6
+ # This provider implements the OpenFeature provider interface using duck-typing,
7
+ # following the on-demand evaluation pattern (like the Node.js provider).
8
+ # Each flag evaluation makes an HTTP request to the Subflag API.
9
+ #
10
+ # @example Basic usage with OpenFeature
11
+ # require "openfeature/sdk"
12
+ # require "subflag"
13
+ #
14
+ # provider = Subflag::Provider.new(
15
+ # api_url: "https://api.subflag.com",
16
+ # api_key: "sdk-production-abc123"
17
+ # )
18
+ #
19
+ # OpenFeature::SDK.configure do |config|
20
+ # config.set_provider(provider)
21
+ # end
22
+ #
23
+ # client = OpenFeature::SDK.build_client
24
+ # enabled = client.fetch_boolean_value(flag_key: "dark-mode", default_value: false)
25
+ #
26
+ # @example With evaluation context
27
+ # context = { targeting_key: "user-123", plan: "premium" }
28
+ # enabled = client.fetch_boolean_value(
29
+ # flag_key: "premium-feature",
30
+ # default_value: false,
31
+ # evaluation_context: context
32
+ # )
33
+ class Provider
34
+ # Provider metadata for OpenFeature SDK
35
+ # @return [Hash] Provider metadata
36
+ def metadata
37
+ { name: "Subflag Ruby Provider" }
38
+ end
39
+
40
+ # @param api_url [String] The base URL of the Subflag API
41
+ # @param api_key [String] The SDK API key (format: sdk-{env}-{random})
42
+ # @param timeout [Integer] Request timeout in seconds (default: 5)
43
+ def initialize(api_url:, api_key:, timeout: Client::DEFAULT_TIMEOUT)
44
+ @client = Client.new(api_url: api_url, api_key: api_key, timeout: timeout)
45
+ end
46
+
47
+ # Called when provider is registered with OpenFeature
48
+ # Named `init` instead of `initialize` to avoid Ruby constructor conflict
49
+ def init
50
+ # No-op for on-demand evaluation pattern
51
+ # Could be used for connection validation or pre-warming in the future
52
+ end
53
+
54
+ # Called when provider is unregistered or SDK is shutdown
55
+ def shutdown
56
+ # No-op for stateless HTTP client
57
+ # Could be used for connection pool cleanup in the future
58
+ end
59
+
60
+ # Evaluate a boolean flag
61
+ #
62
+ # @param flag_key [String] The flag key to evaluate
63
+ # @param default_value [Boolean] Value to return if evaluation fails
64
+ # @param evaluation_context [Hash, nil] Optional targeting context
65
+ # @return [Hash] Resolution details with :value, :reason, :variant, :error_code, :error_message
66
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
67
+ evaluate_flag(flag_key, default_value, evaluation_context, :boolean)
68
+ end
69
+
70
+ # Evaluate a string flag
71
+ #
72
+ # @param flag_key [String] The flag key to evaluate
73
+ # @param default_value [String] Value to return if evaluation fails
74
+ # @param evaluation_context [Hash, nil] Optional targeting context
75
+ # @return [Hash] Resolution details
76
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
77
+ evaluate_flag(flag_key, default_value, evaluation_context, :string)
78
+ end
79
+
80
+ # Evaluate a number flag (returns Float)
81
+ #
82
+ # @param flag_key [String] The flag key to evaluate
83
+ # @param default_value [Numeric] Value to return if evaluation fails
84
+ # @param evaluation_context [Hash, nil] Optional targeting context
85
+ # @return [Hash] Resolution details
86
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
87
+ evaluate_flag(flag_key, default_value, evaluation_context, :number)
88
+ end
89
+
90
+ # Evaluate an integer flag
91
+ #
92
+ # @param flag_key [String] The flag key to evaluate
93
+ # @param default_value [Integer] Value to return if evaluation fails
94
+ # @param evaluation_context [Hash, nil] Optional targeting context
95
+ # @return [Hash] Resolution details
96
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
97
+ evaluate_flag(flag_key, default_value, evaluation_context, :integer)
98
+ end
99
+
100
+ # Evaluate a float flag
101
+ #
102
+ # @param flag_key [String] The flag key to evaluate
103
+ # @param default_value [Float] Value to return if evaluation fails
104
+ # @param evaluation_context [Hash, nil] Optional targeting context
105
+ # @return [Hash] Resolution details
106
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
107
+ evaluate_flag(flag_key, default_value, evaluation_context, :float)
108
+ end
109
+
110
+ # Evaluate an object/hash flag
111
+ #
112
+ # @param flag_key [String] The flag key to evaluate
113
+ # @param default_value [Hash] Value to return if evaluation fails
114
+ # @param evaluation_context [Hash, nil] Optional targeting context
115
+ # @return [Hash] Resolution details
116
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
117
+ evaluate_flag(flag_key, default_value, evaluation_context, :object)
118
+ end
119
+
120
+ private
121
+
122
+ # Core evaluation logic shared by all type-specific methods
123
+ def evaluate_flag(flag_key, default_value, evaluation_context, expected_type)
124
+ context = EvaluationContext.from_openfeature(evaluation_context)
125
+ result = @client.evaluate(flag_key, context: context)
126
+
127
+ # Validate type matches
128
+ unless type_matches?(result.value, expected_type)
129
+ return error_result(
130
+ default_value,
131
+ error_code: :type_mismatch,
132
+ error_message: "Flag '#{flag_key}' value type doesn't match requested type #{expected_type}"
133
+ )
134
+ end
135
+
136
+ # Convert value if needed (e.g., number -> integer)
137
+ converted_value = convert_value(result.value, expected_type)
138
+
139
+ {
140
+ value: converted_value,
141
+ reason: map_reason(result.reason),
142
+ variant: result.variant,
143
+ flag_metadata: { flag_key: result.flag_key }
144
+ }
145
+ rescue FlagNotFoundError => e
146
+ error_result(default_value, error_code: :flag_not_found, error_message: e.message)
147
+ rescue AuthenticationError => e
148
+ error_result(default_value, error_code: :invalid_context, error_message: e.message)
149
+ rescue TypeMismatchError => e
150
+ error_result(default_value, error_code: :type_mismatch, error_message: e.message)
151
+ rescue TimeoutError, ConnectionError => e
152
+ error_result(default_value, error_code: :general, error_message: e.message)
153
+ rescue StandardError => e
154
+ error_result(default_value, error_code: :general, error_message: "Unexpected error: #{e.message}")
155
+ end
156
+
157
+ # Check if value matches expected type
158
+ def type_matches?(value, expected_type)
159
+ case expected_type
160
+ when :boolean
161
+ value == true || value == false
162
+ when :string
163
+ value.is_a?(String)
164
+ when :number, :integer, :float
165
+ value.is_a?(Numeric)
166
+ when :object
167
+ value.is_a?(Hash)
168
+ else
169
+ true
170
+ end
171
+ end
172
+
173
+ # Convert value to specific type if needed
174
+ def convert_value(value, expected_type)
175
+ case expected_type
176
+ when :integer
177
+ value.to_i
178
+ when :float
179
+ value.to_f
180
+ else
181
+ value
182
+ end
183
+ end
184
+
185
+ # Map Subflag reason to OpenFeature reason
186
+ def map_reason(subflag_reason)
187
+ case subflag_reason
188
+ when "DEFAULT"
189
+ :default
190
+ when "TARGETING_MATCH", "SEGMENT_MATCH"
191
+ :targeting_match
192
+ when "OVERRIDE"
193
+ :static
194
+ when "PERCENTAGE_ROLLOUT"
195
+ :split
196
+ when "ERROR"
197
+ :error
198
+ else
199
+ :unknown
200
+ end
201
+ end
202
+
203
+ # Build error result hash
204
+ def error_result(default_value, error_code:, error_message:)
205
+ {
206
+ value: default_value,
207
+ reason: :error,
208
+ error_code: error_code,
209
+ error_message: error_message
210
+ }
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Subflag
4
+ VERSION = "0.1.0"
5
+ end
data/lib/subflag.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "subflag/version"
4
+ require_relative "subflag/errors"
5
+ require_relative "subflag/evaluation_context"
6
+ require_relative "subflag/evaluation_result"
7
+ require_relative "subflag/client"
8
+ require_relative "subflag/provider"
9
+
10
+ # Subflag Ruby SDK for OpenFeature
11
+ #
12
+ # This module provides integration with Subflag feature flag management
13
+ # through the OpenFeature standard.
14
+ #
15
+ # @example Quick start with OpenFeature
16
+ # require "openfeature/sdk"
17
+ # require "subflag"
18
+ #
19
+ # # Configure the provider
20
+ # provider = Subflag::Provider.new(
21
+ # api_url: ENV["SUBFLAG_API_URL"],
22
+ # api_key: ENV["SUBFLAG_API_KEY"]
23
+ # )
24
+ #
25
+ # OpenFeature::SDK.configure do |config|
26
+ # config.set_provider(provider)
27
+ # end
28
+ #
29
+ # # Use the client
30
+ # client = OpenFeature::SDK.build_client
31
+ #
32
+ # if client.fetch_boolean_value(flag_key: "new-checkout", default_value: false)
33
+ # # New checkout flow
34
+ # else
35
+ # # Legacy checkout flow
36
+ # end
37
+ #
38
+ # @example Direct client usage (without OpenFeature)
39
+ # client = Subflag::Client.new(
40
+ # api_url: "https://api.subflag.com",
41
+ # api_key: "sdk-production-abc123"
42
+ # )
43
+ #
44
+ # result = client.evaluate("my-flag")
45
+ # puts result.value # => true
46
+ # puts result.variant # => "enabled"
47
+ # puts result.reason # => "DEFAULT"
48
+ #
49
+ module Subflag
50
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/subflag/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "subflag-openfeature-provider"
7
+ spec.version = Subflag::VERSION
8
+ spec.authors = ["Subflag"]
9
+ spec.email = ["support@subflag.com"]
10
+
11
+ spec.summary = "OpenFeature provider for Subflag feature flag management"
12
+ spec.description = "A Ruby provider for OpenFeature that integrates with Subflag's feature flag management system. Supports boolean, string, number, and object flag types with evaluation context."
13
+ spec.homepage = "https://github.com/subflag/subflag"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.1.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/subflag/subflag/tree/main/sdk/packages/openfeature-ruby-provider"
19
+ spec.metadata["changelog_uri"] = "https://github.com/subflag/subflag/blob/main/sdk/packages/openfeature-ruby-provider/CHANGELOG.md"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
21
+
22
+ spec.files = Dir.chdir(__dir__) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (File.expand_path(f) == __FILE__) ||
25
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
26
+ end
27
+ end
28
+ spec.require_paths = ["lib"]
29
+
30
+ # Runtime dependencies
31
+ spec.add_dependency "faraday", ">= 2.0", "< 3.0"
32
+ spec.add_dependency "openfeature-sdk", ">= 0.3", "< 1.0"
33
+
34
+ # Development dependencies
35
+ spec.add_development_dependency "bundler", "~> 2.0"
36
+ spec.add_development_dependency "rake", "~> 13.0"
37
+ spec.add_development_dependency "rspec", "~> 3.12"
38
+ spec.add_development_dependency "rubocop", "~> 1.50"
39
+ spec.add_development_dependency "webmock", "~> 3.18"
40
+ end
metadata ADDED
@@ -0,0 +1,172 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: subflag-openfeature-provider
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Subflag
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-11-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: openfeature-sdk
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.3'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '1.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0.3'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: bundler
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '2.0'
67
+ - !ruby/object:Gem::Dependency
68
+ name: rake
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '13.0'
74
+ type: :development
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '13.0'
81
+ - !ruby/object:Gem::Dependency
82
+ name: rspec
83
+ requirement: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.12'
88
+ type: :development
89
+ prerelease: false
90
+ version_requirements: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '3.12'
95
+ - !ruby/object:Gem::Dependency
96
+ name: rubocop
97
+ requirement: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.50'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '1.50'
109
+ - !ruby/object:Gem::Dependency
110
+ name: webmock
111
+ requirement: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '3.18'
116
+ type: :development
117
+ prerelease: false
118
+ version_requirements: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '3.18'
123
+ description: A Ruby provider for OpenFeature that integrates with Subflag's feature
124
+ flag management system. Supports boolean, string, number, and object flag types
125
+ with evaluation context.
126
+ email:
127
+ - support@subflag.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".rspec"
133
+ - ".rspec_status"
134
+ - ".rubocop.yml"
135
+ - README.md
136
+ - Rakefile
137
+ - lib/subflag.rb
138
+ - lib/subflag/client.rb
139
+ - lib/subflag/errors.rb
140
+ - lib/subflag/evaluation_context.rb
141
+ - lib/subflag/evaluation_result.rb
142
+ - lib/subflag/provider.rb
143
+ - lib/subflag/version.rb
144
+ - subflag-openfeature-provider.gemspec
145
+ homepage: https://github.com/subflag/subflag
146
+ licenses:
147
+ - MIT
148
+ metadata:
149
+ homepage_uri: https://github.com/subflag/subflag
150
+ source_code_uri: https://github.com/subflag/subflag/tree/main/sdk/packages/openfeature-ruby-provider
151
+ changelog_uri: https://github.com/subflag/subflag/blob/main/sdk/packages/openfeature-ruby-provider/CHANGELOG.md
152
+ rubygems_mfa_required: 'true'
153
+ post_install_message:
154
+ rdoc_options: []
155
+ require_paths:
156
+ - lib
157
+ required_ruby_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: 3.1.0
162
+ required_rubygems_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ requirements: []
168
+ rubygems_version: 3.5.22
169
+ signing_key:
170
+ specification_version: 4
171
+ summary: OpenFeature provider for Subflag feature flag management
172
+ test_files: []