openfeature-sdk 0.7.0 → 0.8.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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +24 -0
- data/Gemfile.lock +1 -1
- data/README.md +30 -2
- data/lib/open_feature/sdk/api.rb +6 -4
- data/lib/open_feature/sdk/client.rb +23 -17
- data/lib/open_feature/sdk/client_metadata.rb +5 -0
- data/lib/open_feature/sdk/configuration.rb +3 -2
- data/lib/open_feature/sdk/evaluation_context.rb +10 -0
- data/lib/open_feature/sdk/hooks/frozen_copy.rb +14 -0
- data/lib/open_feature/sdk/hooks/hints.rb +2 -2
- data/lib/open_feature/sdk/hooks/hook_context.rb +25 -8
- data/lib/open_feature/sdk/hooks/hook_executor.rb +9 -2
- data/lib/open_feature/sdk/isolated.rb +15 -0
- data/lib/open_feature/sdk/provider/event_emitter.rb +9 -4
- data/lib/open_feature/sdk/provider/no_op_provider.rb +1 -1
- data/lib/open_feature/sdk/provider_initialization_error.rb +2 -2
- data/lib/open_feature/sdk/provider_state_registry.rb +7 -7
- data/lib/open_feature/sdk/thread_local_transaction_context_propagator.rb +7 -2
- data/lib/open_feature/sdk/version.rb +1 -1
- data/sig/open_feature/sdk/api.rbs +5 -2
- data/sig/open_feature/sdk/client.rbs +1 -1
- data/sig/open_feature/sdk/evaluation_context.rbs +4 -0
- data/sig/open_feature/sdk/hooks/frozen_copy.rbs +7 -0
- data/sig/open_feature/sdk/hooks/hook_context.rbs +4 -1
- data/sig/open_feature/sdk/hooks/hook_executor.rbs +1 -1
- data/sig/open_feature/sdk/isolated.rbs +7 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a637edf0b0a85dbfbaf655066912a64a21c1cd4a467b4fe557208c10184df3a9
|
|
4
|
+
data.tar.gz: 6fa0f3f6e534279a8e1b6296e1e8b524decd315c99e4bc6238575f3867a8a428
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d16e851abe52d3bbbac68aa9180a9c1ace7b7cf97ce3508c21e083d7780825b862a608a7f438598d6bcb44c50efeceb84148c2f5351d544cb5584546d2cb2c22
|
|
7
|
+
data.tar.gz: 18c44f236a822a02469a4ed66b297c607e7e59d440428266790ce1a95da21a549e122c605d7d1f9c70af9442d8f6ce8c0c7ff0242e1f69268ce242bb7dbb02b8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0](https://github.com/open-feature/ruby-sdk/compare/v0.7.1...v0.8.0) (2026-10-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* make hook inputs immutable ([#357](https://github.com/open-feature/ruby-sdk/issues/357))
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* do not mutate the provider's ResolutionDetails ([#358](https://github.com/open-feature/ruby-sdk/issues/358)) ([ec657a5](https://github.com/open-feature/ruby-sdk/commit/ec657a5fc7b1514828ea2fa1cb6bfe017029f5af))
|
|
13
|
+
* make hook inputs immutable ([#357](https://github.com/open-feature/ruby-sdk/issues/357)) ([9e166fe](https://github.com/open-feature/ruby-sdk/commit/9e166fe09ec2a10d220295b600141cb351516b24))
|
|
14
|
+
|
|
15
|
+
## [0.7.1](https://github.com/open-feature/ruby-sdk/compare/v0.7.0...v0.7.1) (2026-10-23)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* isolated API instances ([#354](https://github.com/open-feature/ruby-sdk/issues/354)) ([1c92b4a](https://github.com/open-feature/ruby-sdk/commit/1c92b4ad7550a6b345e7471f84cd5a7ff445591e))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* key provider state by identity, default the NoOp reason, freeze client metadata ([#355](https://github.com/open-feature/ruby-sdk/issues/355)) ([d15bd84](https://github.com/open-feature/ruby-sdk/commit/d15bd84e1456b6200e29c12c3463c4f8ddfe693f))
|
|
26
|
+
|
|
3
27
|
## [0.7.0](https://github.com/open-feature/ruby-sdk/compare/v0.6.7...v0.7.0) (2026-10-23)
|
|
4
28
|
|
|
5
29
|
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
</a>
|
|
18
18
|
<!-- x-release-please-start-version -->
|
|
19
19
|
|
|
20
|
-
<a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.
|
|
21
|
-
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.
|
|
20
|
+
<a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.8.0">
|
|
21
|
+
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.8.0&color=blue&style=for-the-badge" />
|
|
22
22
|
</a>
|
|
23
23
|
|
|
24
24
|
<!-- x-release-please-end -->
|
|
@@ -443,6 +443,29 @@ class MyRequestScopedPropagator
|
|
|
443
443
|
end
|
|
444
444
|
```
|
|
445
445
|
|
|
446
|
+
### Isolated API instances
|
|
447
|
+
|
|
448
|
+
`OpenFeature::SDK` is a global singleton. Some applications need more than one independent API in a process, for example separately developed modules that each need their own provider, or tests that run in parallel. Require the factory explicitly to create one:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
require "open_feature/sdk/isolated"
|
|
452
|
+
|
|
453
|
+
api = OpenFeature::SDK::Isolated.create_api
|
|
454
|
+
api.set_provider_and_wait(MyProvider.new)
|
|
455
|
+
client = api.build_client
|
|
456
|
+
client.fetch_boolean_value(flag_key: "my-flag", default_value: false)
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Each instance has its own providers, hooks, evaluation context, event handlers, transaction context propagator and `shutdown`, and shares none of them with `OpenFeature::SDK` or other instances.
|
|
460
|
+
Build its clients with `api.build_client`; `OpenFeature::SDK::Client.new` binds to the global API.
|
|
461
|
+
Prefer the global API unless you need this.
|
|
462
|
+
|
|
463
|
+
A provider instance should be registered with only one API at a time.
|
|
464
|
+
Registering a provider that includes `OpenFeature::SDK::Provider::EventEmitter` with a second API raises `ArgumentError`; other providers can't be detected.
|
|
465
|
+
Once the first API replaces the provider or shuts down, it can be registered with another.
|
|
466
|
+
|
|
467
|
+
A `LoggingHook` created without `logger:` logs to the global API's logger, even when added to an isolated API. Pass `logger:` to use another.
|
|
468
|
+
|
|
446
469
|
## Extending
|
|
447
470
|
|
|
448
471
|
### Develop a provider
|
|
@@ -543,6 +566,11 @@ class MyLoggingHook
|
|
|
543
566
|
end
|
|
544
567
|
```
|
|
545
568
|
|
|
569
|
+
Hooks can read their inputs but not change them:
|
|
570
|
+
|
|
571
|
+
- Only `before` hooks can change the evaluation context: return an `EvaluationContext` for the SDK to merge in, or assign `hook_context.evaluation_context`. By the time `after`, `error` and `finally` run, it's a frozen copy, so assigning it or writing to its `fields` raises `FrozenError`. Nested field values aren't frozen, because they belong to the caller.
|
|
572
|
+
- The flag key, the default value, the hints, and the client and provider metadata are frozen copies. Your own objects aren't frozen.
|
|
573
|
+
|
|
546
574
|
> Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs!
|
|
547
575
|
|
|
548
576
|
<!-- x-hide-in-docs-start -->
|
data/lib/open_feature/sdk/api.rb
CHANGED
|
@@ -32,13 +32,15 @@ module OpenFeature
|
|
|
32
32
|
#
|
|
33
33
|
# client = OpenFeature::SDK::API.instance.build_client(name: 'my-open-feature-client')
|
|
34
34
|
class API
|
|
35
|
-
include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1
|
|
35
|
+
include Singleton # Satisfies Flag Evaluation API Requirement 1.1.1; Isolated.create_api builds others (1.8.1)
|
|
36
36
|
extend Forwardable
|
|
37
37
|
|
|
38
38
|
def_delegators :configuration, :provider, :set_provider, :set_provider_and_wait, :hooks, :add_hooks, :evaluation_context
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
attr_reader :configuration
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@configuration = Configuration.new
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def configure(&block)
|
|
@@ -48,7 +50,7 @@ module OpenFeature
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def build_client(domain: nil, evaluation_context: nil)
|
|
51
|
-
Client.new(domain:, evaluation_context:)
|
|
53
|
+
Client.new(domain:, evaluation_context:, configuration:)
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
def provider_metadata(domain: nil)
|
|
@@ -21,8 +21,9 @@ module OpenFeature
|
|
|
21
21
|
|
|
22
22
|
attr_accessor :hooks, :evaluation_context
|
|
23
23
|
|
|
24
|
-
def initialize(provider: nil, domain: nil, evaluation_context: nil)
|
|
24
|
+
def initialize(provider: nil, domain: nil, evaluation_context: nil, configuration: API.instance.configuration)
|
|
25
25
|
@provider = provider
|
|
26
|
+
@configuration = configuration
|
|
26
27
|
@metadata = ClientMetadata.new(domain:)
|
|
27
28
|
@evaluation_context = evaluation_context
|
|
28
29
|
@hooks = []
|
|
@@ -33,17 +34,17 @@ module OpenFeature
|
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def provider_status
|
|
36
|
-
|
|
37
|
+
@configuration.provider_state(resolve_provider)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def add_handler(event_type, handler = nil, &block)
|
|
40
41
|
actual_handler = handler || block
|
|
41
|
-
|
|
42
|
+
@configuration.add_client_handler(self, event_type, actual_handler)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def remove_handler(event_type, handler = nil, &block)
|
|
45
46
|
actual_handler = handler || block
|
|
46
|
-
|
|
47
|
+
@configuration.remove_client_handler(self, event_type, actual_handler)
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
# Tracking API (spec 6.1.1.1) — dynamic-context paradigm
|
|
@@ -81,13 +82,13 @@ module OpenFeature
|
|
|
81
82
|
|
|
82
83
|
# Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
|
|
83
84
|
provider_hooks = provider.respond_to?(:hooks) ? Array(provider.hooks) : []
|
|
84
|
-
ordered_hooks = [
|
|
85
|
+
ordered_hooks = [*@configuration.hooks, *@hooks, *invocation_hooks, *provider_hooks]
|
|
85
86
|
|
|
86
87
|
# Short-circuit NOT_READY/FATAL providers (required by evaluation_v2.feature). A provider resolved from the
|
|
87
88
|
# configuration is tracked while bound, so one that isn't was replaced mid-call and reads NOT_READY.
|
|
88
89
|
short_circuit_code = nil
|
|
89
|
-
if @provider ?
|
|
90
|
-
short_circuit_code = short_circuit_error_code(
|
|
90
|
+
if @provider ? @configuration.provider_tracked?(provider) : !provider.equal?(@fallback_provider)
|
|
91
|
+
short_circuit_code = short_circuit_error_code(@configuration.provider_state(provider))
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
# Fast path: skip hook ceremony when no hooks are registered
|
|
@@ -121,7 +122,7 @@ module OpenFeature
|
|
|
121
122
|
EMPTY_HINTS
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
executor = Hooks::HookExecutor.new(logger:
|
|
125
|
+
executor = Hooks::HookExecutor.new(logger: @configuration.logger)
|
|
125
126
|
|
|
126
127
|
if short_circuit_code
|
|
127
128
|
resolution = Provider::ResolutionDetails.new(
|
|
@@ -133,7 +134,7 @@ module OpenFeature
|
|
|
133
134
|
executor.run_short_circuit(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, evaluation_details: evaluation_details)
|
|
134
135
|
evaluation_details
|
|
135
136
|
else
|
|
136
|
-
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
|
|
137
|
+
executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints, default_value:) do |hctx|
|
|
137
138
|
evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
|
|
138
139
|
end
|
|
139
140
|
end
|
|
@@ -156,11 +157,16 @@ module OpenFeature
|
|
|
156
157
|
evaluation_context: evaluation_context
|
|
157
158
|
)
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
resolution_details
|
|
163
|
-
|
|
160
|
+
provider_error = resolution_details.error_code
|
|
161
|
+
if provider_error || TYPE_CLASS_MAP[type].none? { |klass| resolution_details.value.is_a?(klass) }
|
|
162
|
+
# A new struct, since a provider may cache and reuse the one it returned
|
|
163
|
+
resolution_details = Provider::ResolutionDetails.new(
|
|
164
|
+
value: default_value,
|
|
165
|
+
reason: Provider::Reason::ERROR,
|
|
166
|
+
error_code: provider_error || Provider::ErrorCode::TYPE_MISMATCH,
|
|
167
|
+
error_message: provider_error ? resolution_details.error_message : "Expected #{type}, got #{resolution_details.value.class}",
|
|
168
|
+
flag_metadata: resolution_details.flag_metadata
|
|
169
|
+
)
|
|
164
170
|
end
|
|
165
171
|
|
|
166
172
|
EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
|
|
@@ -169,7 +175,7 @@ module OpenFeature
|
|
|
169
175
|
# Callers resolve once per operation, so a rebinding mid-evaluation can't mix two providers.
|
|
170
176
|
# The fallback NoOp is never registered: untracked, so not short-circuited, and its status reads NOT_READY.
|
|
171
177
|
def resolve_provider
|
|
172
|
-
@provider ||
|
|
178
|
+
@provider || @configuration.provider(domain: @metadata.domain) || (@fallback_provider ||= Provider::NoOpProvider.new)
|
|
173
179
|
end
|
|
174
180
|
|
|
175
181
|
def short_circuit_error_code(state)
|
|
@@ -180,11 +186,11 @@ module OpenFeature
|
|
|
180
186
|
end
|
|
181
187
|
|
|
182
188
|
def build_evaluation_context(invocation_context)
|
|
183
|
-
propagator =
|
|
189
|
+
propagator = @configuration.transaction_context_propagator
|
|
184
190
|
transaction_context = propagator&.get_transaction_context
|
|
185
191
|
|
|
186
192
|
EvaluationContextBuilder.new.call(
|
|
187
|
-
api_context:
|
|
193
|
+
api_context: @configuration.evaluation_context,
|
|
188
194
|
transaction_context: transaction_context,
|
|
189
195
|
client_context: evaluation_context,
|
|
190
196
|
invocation_context: invocation_context
|
|
@@ -162,9 +162,10 @@ module OpenFeature
|
|
|
162
162
|
needs_init = attempt.nil? || attempt.state == :failed
|
|
163
163
|
|
|
164
164
|
if needs_init
|
|
165
|
+
# First, so a provider registered with another API (1.8.4) leaves no state behind
|
|
166
|
+
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
|
|
165
167
|
attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
|
|
166
168
|
@provider_state_registry.set_initial_state(provider)
|
|
167
|
-
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
|
|
168
169
|
end
|
|
169
170
|
|
|
170
171
|
# Publish last: clients read @providers without the lock, and must not see an untracked provider
|
|
@@ -219,7 +220,7 @@ module OpenFeature
|
|
|
219
220
|
end
|
|
220
221
|
ensure
|
|
221
222
|
# Fail an attempt no init took over (e.g. a Timeout hit first), or it would stay running forever
|
|
222
|
-
finish_init_attempt(attempt, :failed) if needs_init && !init_thread
|
|
223
|
+
finish_init_attempt(attempt, :failed) if needs_init && attempt && !init_thread
|
|
223
224
|
end
|
|
224
225
|
|
|
225
226
|
def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenFeature
|
|
4
|
+
module SDK
|
|
5
|
+
module Hooks
|
|
6
|
+
# A deep-frozen copy of a hook input, so hooks can't change it and the caller's object isn't frozen (spec 4.1.3, 4.2.2)
|
|
7
|
+
def self.frozen_copy(value)
|
|
8
|
+
Ractor.make_shareable(value, copy: true)
|
|
9
|
+
rescue
|
|
10
|
+
value.dup.freeze # shortcut: when something inside can't be copied (a Mutex, Queue or Proc), only the top level is frozen
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "delegate"
|
|
4
|
+
require_relative "frozen_copy"
|
|
4
5
|
|
|
5
6
|
module OpenFeature
|
|
6
7
|
module SDK
|
|
@@ -13,8 +14,7 @@ module OpenFeature
|
|
|
13
14
|
assert_allowed_key(key)
|
|
14
15
|
assert_allowed_value(value)
|
|
15
16
|
end
|
|
16
|
-
|
|
17
|
-
super(@hash)
|
|
17
|
+
super(Hooks.frozen_copy(hash))
|
|
18
18
|
freeze
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -1,28 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "frozen_copy"
|
|
4
|
+
|
|
3
5
|
module OpenFeature
|
|
4
6
|
module SDK
|
|
5
7
|
module Hooks
|
|
6
8
|
# Provides context to hook stages during flag evaluation.
|
|
7
9
|
#
|
|
8
|
-
# Per spec 4.1.1-4.1.5:
|
|
10
|
+
# Per spec 4.1.1-4.1.5 and 4.2.2:
|
|
9
11
|
# - flag_key, flag_value_type, default_value are immutable (4.1.3)
|
|
10
|
-
# - client_metadata, provider_metadata are optional (4.1.2)
|
|
11
|
-
# - evaluation_context is mutable
|
|
12
|
+
# - client_metadata, provider_metadata are optional (4.1.2) and immutable (4.2.2.2, 4.2.2.3)
|
|
13
|
+
# - evaluation_context is mutable only in before hooks; HookExecutor freezes the hook context after them (4.1.4.1)
|
|
12
14
|
class HookContext
|
|
13
|
-
attr_reader :flag_key, :flag_value_type, :
|
|
14
|
-
:client_metadata, :provider_metadata
|
|
15
|
+
attr_reader :flag_key, :flag_value_type, :client_metadata, :provider_metadata
|
|
15
16
|
attr_accessor :evaluation_context
|
|
16
17
|
|
|
17
18
|
def initialize(flag_key:, flag_value_type:, default_value:, evaluation_context:,
|
|
18
19
|
client_metadata: nil, provider_metadata: nil)
|
|
19
|
-
@flag_key = flag_key
|
|
20
|
+
@flag_key = Hooks.frozen_copy(flag_key)
|
|
20
21
|
@flag_value_type = flag_value_type.freeze
|
|
21
|
-
@
|
|
22
|
+
@source_default_value = default_value
|
|
22
23
|
@evaluation_context = evaluation_context
|
|
23
24
|
@client_metadata = client_metadata
|
|
24
|
-
@provider_metadata = provider_metadata
|
|
25
|
+
@provider_metadata = Hooks.frozen_copy(provider_metadata)
|
|
25
26
|
@hook_data = {}
|
|
27
|
+
@copies = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Copied on first read: most hooks never read it, and the copy grows with the value
|
|
31
|
+
def default_value
|
|
32
|
+
@copies.fetch(:default_value) { @copies[:default_value] = Hooks.frozen_copy(@source_default_value) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Swaps in a frozen copy of the evaluation context, so neither the caller's nor a before hook's object is frozen.
|
|
36
|
+
# Top-level strings are copied too, so a hook can't edit a targeting key in place.
|
|
37
|
+
def freeze
|
|
38
|
+
return self if frozen?
|
|
39
|
+
@evaluation_context &&= @evaluation_context.dup.tap do |copy|
|
|
40
|
+
copy.fields.transform_values! { |value| (value.is_a?(String) && !value.frozen?) ? value.dup.freeze : value }
|
|
41
|
+
end.freeze
|
|
42
|
+
super
|
|
26
43
|
end
|
|
27
44
|
|
|
28
45
|
# Returns a mutable hash scoped to the given hook instance.
|
|
@@ -13,6 +13,9 @@ module OpenFeature
|
|
|
13
13
|
# - Before/after hook error → stop remaining hooks, run error hooks, return default
|
|
14
14
|
# - Error hook error → log, continue remaining error hooks
|
|
15
15
|
# - Finally hook error → log, continue remaining finally hooks
|
|
16
|
+
#
|
|
17
|
+
# The hook context is frozen before after, error and finally hooks run, so only before hooks
|
|
18
|
+
# can change the evaluation context (spec 4.1.4.1). Not earlier: some providers write to it.
|
|
16
19
|
class HookExecutor
|
|
17
20
|
def initialize(logger: nil)
|
|
18
21
|
@logger = logger
|
|
@@ -37,9 +40,10 @@ module OpenFeature
|
|
|
37
40
|
# @param ordered_hooks [Array] hooks in before-order (API, Client, Invocation, Provider)
|
|
38
41
|
# @param hook_context [HookContext] the hook context
|
|
39
42
|
# @param hints [Hints] hook hints
|
|
43
|
+
# @param default_value [Object] returned if evaluation raises; the caller's own object, not the hooks' frozen copy
|
|
40
44
|
# @param evaluate_block [Proc] the flag evaluation to wrap
|
|
41
45
|
# @return [EvaluationDetails] the evaluation result
|
|
42
|
-
def execute(ordered_hooks:, hook_context:, hints:, &evaluate_block)
|
|
46
|
+
def execute(ordered_hooks:, hook_context:, hints:, default_value:, &evaluate_block)
|
|
43
47
|
evaluation_details = nil
|
|
44
48
|
|
|
45
49
|
begin
|
|
@@ -60,7 +64,7 @@ module OpenFeature
|
|
|
60
64
|
evaluation_details = EvaluationDetails.new(
|
|
61
65
|
flag_key: hook_context.flag_key,
|
|
62
66
|
resolution_details: Provider::ResolutionDetails.new(
|
|
63
|
-
value:
|
|
67
|
+
value: default_value,
|
|
64
68
|
error_code: EvaluationError.code_for(e),
|
|
65
69
|
reason: Provider::Reason::ERROR,
|
|
66
70
|
error_message: e.message
|
|
@@ -91,6 +95,7 @@ module OpenFeature
|
|
|
91
95
|
|
|
92
96
|
# Spec 4.4.2: After hooks run in reverse order: Provider → Invocation → Client → API
|
|
93
97
|
def run_after_hooks(hooks, hook_context, evaluation_details, hints)
|
|
98
|
+
hook_context.freeze
|
|
94
99
|
hooks.reverse_each do |hook|
|
|
95
100
|
next unless hook.respond_to?(:after)
|
|
96
101
|
hook.after(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
|
|
@@ -100,6 +105,7 @@ module OpenFeature
|
|
|
100
105
|
# Spec 4.4.4: Error hooks run in reverse order.
|
|
101
106
|
# If an error hook itself errors, log and continue remaining error hooks.
|
|
102
107
|
def run_error_hooks(hooks, hook_context, exception, hints)
|
|
108
|
+
hook_context.freeze
|
|
103
109
|
hooks.reverse_each do |hook|
|
|
104
110
|
next unless hook.respond_to?(:error)
|
|
105
111
|
hook.error(hook_context: hook_context, exception: exception, hints: hints)
|
|
@@ -111,6 +117,7 @@ module OpenFeature
|
|
|
111
117
|
# Spec 4.4.3: Finally hooks run in reverse order unconditionally.
|
|
112
118
|
# If a finally hook errors, log and continue remaining finally hooks.
|
|
113
119
|
def run_finally_hooks(hooks, hook_context, evaluation_details, hints)
|
|
120
|
+
hook_context.freeze
|
|
114
121
|
hooks.reverse_each do |hook|
|
|
115
122
|
next unless hook.respond_to?(:finally)
|
|
116
123
|
hook.finally(hook_context: hook_context, evaluation_details: evaluation_details, hints: hints)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sdk"
|
|
4
|
+
|
|
5
|
+
module OpenFeature
|
|
6
|
+
module SDK
|
|
7
|
+
# Spec 1.8.3: a separate require, so isolated instances are an explicit opt-in
|
|
8
|
+
module Isolated
|
|
9
|
+
# Spec 1.8.1: a new API that shares no state with the global one or other isolated APIs
|
|
10
|
+
def self.create_api
|
|
11
|
+
API.send(:new)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -8,7 +8,7 @@ module OpenFeature
|
|
|
8
8
|
# Mixin for providers that emit lifecycle events
|
|
9
9
|
module EventEmitter
|
|
10
10
|
def emit_event(event_type, details = {})
|
|
11
|
-
config = @
|
|
11
|
+
config = @openfeature_configuration
|
|
12
12
|
return unless config
|
|
13
13
|
|
|
14
14
|
unless ::OpenFeature::SDK::ProviderEvent::ALL_EVENTS.include?(event_type)
|
|
@@ -19,17 +19,22 @@ module OpenFeature
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def configuration_attached?
|
|
22
|
-
!@
|
|
22
|
+
!@openfeature_configuration.nil?
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
27
|
def attach(configuration)
|
|
28
|
-
|
|
28
|
+
# Spec 1.8.4: only providers using this mixin know which API they're registered with
|
|
29
|
+
if @openfeature_configuration && !@openfeature_configuration.equal?(configuration)
|
|
30
|
+
raise ArgumentError, "Provider is already registered with another OpenFeature API instance"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@openfeature_configuration = configuration
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def detach
|
|
32
|
-
@
|
|
37
|
+
@openfeature_configuration = nil
|
|
33
38
|
end
|
|
34
39
|
end
|
|
35
40
|
end
|
|
@@ -21,8 +21,8 @@ module OpenFeature
|
|
|
21
21
|
# @param message [String] the error message
|
|
22
22
|
# @param provider [Object] the provider that failed to initialize
|
|
23
23
|
# @param original_error [Exception] the original error that caused the failure
|
|
24
|
-
# @param error_code [String] the OpenFeature error code (defaults to
|
|
25
|
-
def initialize(message, provider: nil, original_error: nil, error_code: Provider::ErrorCode::
|
|
24
|
+
# @param error_code [String] the OpenFeature error code (defaults to GENERAL)
|
|
25
|
+
def initialize(message, provider: nil, original_error: nil, error_code: Provider::ErrorCode::GENERAL)
|
|
26
26
|
super(message)
|
|
27
27
|
@provider = provider
|
|
28
28
|
@original_error = original_error
|
|
@@ -9,7 +9,7 @@ module OpenFeature
|
|
|
9
9
|
# Tracks provider states
|
|
10
10
|
class ProviderStateRegistry
|
|
11
11
|
def initialize
|
|
12
|
-
@states = {}
|
|
12
|
+
@states = {}.compare_by_identity
|
|
13
13
|
@mutex = Mutex.new
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -17,7 +17,7 @@ module OpenFeature
|
|
|
17
17
|
return unless provider
|
|
18
18
|
|
|
19
19
|
@mutex.synchronize do
|
|
20
|
-
@states[provider
|
|
20
|
+
@states[provider] = {state: state, details: {}}
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -29,7 +29,7 @@ module OpenFeature
|
|
|
29
29
|
# Only update state if the event should cause a state change
|
|
30
30
|
if new_state
|
|
31
31
|
@mutex.synchronize do
|
|
32
|
-
@states[provider
|
|
32
|
+
@states[provider] = {state: new_state, details: event_details || {}}
|
|
33
33
|
end
|
|
34
34
|
new_state
|
|
35
35
|
else
|
|
@@ -42,7 +42,7 @@ module OpenFeature
|
|
|
42
42
|
return ProviderState::NOT_READY unless provider
|
|
43
43
|
|
|
44
44
|
@mutex.synchronize do
|
|
45
|
-
entry = @states[provider
|
|
45
|
+
entry = @states[provider]
|
|
46
46
|
entry ? entry[:state] : ProviderState::NOT_READY
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -51,7 +51,7 @@ module OpenFeature
|
|
|
51
51
|
return {} unless provider
|
|
52
52
|
|
|
53
53
|
@mutex.synchronize do
|
|
54
|
-
entry = @states[provider
|
|
54
|
+
entry = @states[provider]
|
|
55
55
|
entry ? entry[:details] : {}
|
|
56
56
|
end
|
|
57
57
|
end
|
|
@@ -60,7 +60,7 @@ module OpenFeature
|
|
|
60
60
|
return unless provider
|
|
61
61
|
|
|
62
62
|
@mutex.synchronize do
|
|
63
|
-
@states.delete(provider
|
|
63
|
+
@states.delete(provider)
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
@@ -68,7 +68,7 @@ module OpenFeature
|
|
|
68
68
|
return false unless provider
|
|
69
69
|
|
|
70
70
|
@mutex.synchronize do
|
|
71
|
-
@states.key?(provider
|
|
71
|
+
@states.key?(provider)
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
@@ -5,14 +5,19 @@ module OpenFeature
|
|
|
5
5
|
class ThreadLocalTransactionContextPropagator
|
|
6
6
|
include TransactionContextPropagator
|
|
7
7
|
|
|
8
|
+
# One context per propagator under this key, so separate APIs never share one (spec 1.8.2)
|
|
9
|
+
# while resetting the key still clears them all. Weak keys let discarded propagators be collected.
|
|
8
10
|
THREAD_KEY = :openfeature_transaction_context
|
|
9
11
|
|
|
10
12
|
def set_transaction_context(evaluation_context)
|
|
11
|
-
Thread.current[THREAD_KEY]
|
|
13
|
+
contexts = Thread.current[THREAD_KEY]
|
|
14
|
+
contexts = Thread.current[THREAD_KEY] = ObjectSpace::WeakKeyMap.new unless contexts.is_a?(ObjectSpace::WeakKeyMap)
|
|
15
|
+
evaluation_context.nil? ? contexts.delete(self) : contexts[self] = evaluation_context
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
def get_transaction_context
|
|
15
|
-
Thread.current[THREAD_KEY]
|
|
19
|
+
contexts = Thread.current[THREAD_KEY]
|
|
20
|
+
contexts[self] if contexts.is_a?(ObjectSpace::WeakKeyMap)
|
|
16
21
|
end
|
|
17
22
|
end
|
|
18
23
|
end
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
module OpenFeature
|
|
2
2
|
module SDK
|
|
3
3
|
class API
|
|
4
|
-
include Singleton
|
|
5
4
|
extend Forwardable
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
include Singleton
|
|
7
|
+
|
|
8
|
+
attr_reader configuration: Configuration
|
|
9
|
+
|
|
10
|
+
def initialize: () -> void
|
|
8
11
|
|
|
9
12
|
def configure: () { (Configuration) -> void } -> void
|
|
10
13
|
|
|
@@ -10,7 +10,7 @@ module OpenFeature
|
|
|
10
10
|
attr_accessor evaluation_context: EvaluationContext?
|
|
11
11
|
attr_accessor hooks: Array[untyped]
|
|
12
12
|
|
|
13
|
-
def initialize: (?provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?) -> void
|
|
13
|
+
def initialize: (?provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?, ?configuration: Configuration) -> void
|
|
14
14
|
|
|
15
15
|
def add_hooks: (*Array[untyped] new_hooks) -> void
|
|
16
16
|
|
|
@@ -4,13 +4,16 @@ module OpenFeature
|
|
|
4
4
|
class HookContext
|
|
5
5
|
attr_reader flag_key: String
|
|
6
6
|
attr_reader flag_value_type: Symbol
|
|
7
|
-
attr_reader default_value: untyped
|
|
8
7
|
attr_reader client_metadata: ClientMetadata?
|
|
9
8
|
attr_reader provider_metadata: Provider::ProviderMetadata?
|
|
10
9
|
attr_accessor evaluation_context: EvaluationContext?
|
|
11
10
|
|
|
12
11
|
def initialize: (flag_key: String, flag_value_type: Symbol, default_value: untyped, evaluation_context: EvaluationContext?, ?client_metadata: ClientMetadata?, ?provider_metadata: Provider::ProviderMetadata?) -> void
|
|
13
12
|
|
|
13
|
+
def default_value: () -> untyped
|
|
14
|
+
|
|
15
|
+
def freeze: () -> self
|
|
16
|
+
|
|
14
17
|
def hook_data_for: (untyped hook) -> Hash[untyped, untyped]
|
|
15
18
|
end
|
|
16
19
|
end
|
|
@@ -6,7 +6,7 @@ module OpenFeature
|
|
|
6
6
|
|
|
7
7
|
def run_short_circuit: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, evaluation_details: EvaluationDetails) -> void
|
|
8
8
|
|
|
9
|
-
def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
|
|
9
|
+
def execute: (ordered_hooks: Array[untyped], hook_context: HookContext, hints: Hints, default_value: untyped) { (HookContext) -> EvaluationDetails } -> EvaluationDetails?
|
|
10
10
|
|
|
11
11
|
private
|
|
12
12
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openfeature-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- OpenFeature Authors
|
|
@@ -50,11 +50,13 @@ files:
|
|
|
50
50
|
- lib/open_feature/sdk/evaluation_error.rb
|
|
51
51
|
- lib/open_feature/sdk/event_dispatcher.rb
|
|
52
52
|
- lib/open_feature/sdk/hooks.rb
|
|
53
|
+
- lib/open_feature/sdk/hooks/frozen_copy.rb
|
|
53
54
|
- lib/open_feature/sdk/hooks/hints.rb
|
|
54
55
|
- lib/open_feature/sdk/hooks/hook.rb
|
|
55
56
|
- lib/open_feature/sdk/hooks/hook_context.rb
|
|
56
57
|
- lib/open_feature/sdk/hooks/hook_executor.rb
|
|
57
58
|
- lib/open_feature/sdk/hooks/logging_hook.rb
|
|
59
|
+
- lib/open_feature/sdk/isolated.rb
|
|
58
60
|
- lib/open_feature/sdk/provider.rb
|
|
59
61
|
- lib/open_feature/sdk/provider/error_code.rb
|
|
60
62
|
- lib/open_feature/sdk/provider/event_emitter.rb
|
|
@@ -87,11 +89,13 @@ files:
|
|
|
87
89
|
- sig/open_feature/sdk/evaluation_details.rbs
|
|
88
90
|
- sig/open_feature/sdk/evaluation_error.rbs
|
|
89
91
|
- sig/open_feature/sdk/event_dispatcher.rbs
|
|
92
|
+
- sig/open_feature/sdk/hooks/frozen_copy.rbs
|
|
90
93
|
- sig/open_feature/sdk/hooks/hints.rbs
|
|
91
94
|
- sig/open_feature/sdk/hooks/hook.rbs
|
|
92
95
|
- sig/open_feature/sdk/hooks/hook_context.rbs
|
|
93
96
|
- sig/open_feature/sdk/hooks/hook_executor.rbs
|
|
94
97
|
- sig/open_feature/sdk/hooks/logging_hook.rbs
|
|
98
|
+
- sig/open_feature/sdk/isolated.rbs
|
|
95
99
|
- sig/open_feature/sdk/provider.rbs
|
|
96
100
|
- sig/open_feature/sdk/provider/error_code.rbs
|
|
97
101
|
- sig/open_feature/sdk/provider/event_emitter.rbs
|