flagnetic-openfeature 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: 1b8443b9c799dd20694cccb040452648192b3584cbcf6469e9868afe72b71fde
4
+ data.tar.gz: c34b9f5a3fc371ee2c43172c11c2b2b458790b8a1dd112d46b831e3918d3c86a
5
+ SHA512:
6
+ metadata.gz: 42e444d782b790644d79c2f6c0beae51e8dc84ef56d63ba117ca3cecbef3baf720eee4bff2a5f9054d0836f63432d3d4e9ec2b46b091320e62ae5469d983778f
7
+ data.tar.gz: 04541702d6479b529b2bf999afe1a01298fe0c295c3ea8ecb4ecff58b258b0241411e9ad22fd288771ab1d36144a209a4454eab568cea9321414256b2edf3bc5
data/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # Flagnetic OpenFeature provider for Ruby
2
+
3
+ `flagnetic-openfeature` is an [OpenFeature](https://openfeature.dev) provider over the [Flagnetic Ruby SDK](../ruby/README.md), for a service already standardised on OpenFeature. It is a gem of its own, so `flagnetic` itself stays free of the OpenFeature dependency.
4
+
5
+ Requires Ruby 3.4 or later and `openfeature-sdk` 0.6.1 or a later 0.x release.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bundle add flagnetic-openfeature
11
+ ```
12
+
13
+ ## Wiring
14
+
15
+ ```ruby
16
+ require "flagnetic/openfeature"
17
+
18
+ provider = Flagnetic::OpenFeature::Provider.new(
19
+ ENV.fetch("FLAGNETIC_SDK_KEY"),
20
+ base_url: "https://flags.example.com",
21
+ unit_type: "user", # the unit type of the targeting key
22
+ ready_timeout: 2, # seconds init waits for the first ruleset
23
+ store: "/var/lib/myservice/flagnetic-ruleset.json" # any further option goes to Flagnetic::Client.new
24
+ )
25
+ OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) }
26
+ client = OpenFeature::SDK.build_client
27
+
28
+ context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "u-1001", plan: "pro")
29
+ client.fetch_boolean_value(flag_key: "new_checkout", default_value: false, evaluation_context: context)
30
+ ```
31
+
32
+ - Boolean, string, number, integer, float and object evaluations go through the client's `detail`. Integers follow its `int` getter: a number that is not whole serves the default with `TYPE_MISMATCH`. Floats serve a whole number as a `Float`.
33
+ - The targeting key is the unit ID of `unit_type` (`user` by default); further unit types travel in the evaluation context's `units` attribute, as `units: { "anonymous" => "d-8f1c" }`. Every other attribute is an attribute of the Flagnetic context.
34
+ - `init` waits for the first ruleset up to `ready_timeout` and never fails: a provider at built-in defaults still serves them. `shutdown` closes the client, flushing what is queued.
35
+ - `provider.client` returns the wrapped `Flagnetic::Client`: local overrides, the recorder of an offline client (`offline:` for tests), flush and status.
36
+ - A `logger:` keeps receiving every log event of the client.
37
+
38
+ ## Events
39
+
40
+ | When | Event |
41
+ |---|---|
42
+ | `init` returns | `PROVIDER_READY`, or `PROVIDER_ERROR` when the SDK key was refused; `PROVIDER_STALE` follows when only a last good ruleset is held |
43
+ | A ruleset is accepted, or a local override changes | `PROVIDER_CONFIGURATION_CHANGED` with the keys of the flags that may have changed |
44
+ | The client falls back to its last good ruleset | `PROVIDER_STALE` |
45
+ | The client is fresh again | `PROVIDER_READY` |
46
+ | The SDK key is refused | `PROVIDER_ERROR` (`GENERAL`), kept until the key is accepted again |
47
+
48
+ With `openfeature-sdk` releases before 0.7 the SDK itself sends `PROVIDER_READY` once `init` returns, so a key refused before then reads as ready until the client recovers.
49
+
50
+ ## Reasons
51
+
52
+ Flagnetic reasons map to OpenFeature reasons the same way in every Flagnetic provider, in every language, fixed by [`conformance/openfeature/reasons.json`](../../conformance/openfeature/reasons.json):
53
+
54
+ | Flagnetic | OpenFeature |
55
+ |---|---|
56
+ | `rule`, `prerequisite_failed`, `targeting_match`, `override` | `TARGETING_MATCH` |
57
+ | `rollout`, `experiment` | `SPLIT` |
58
+ | `fallthrough` | `DEFAULT` |
59
+ | `off` | `DISABLED` |
60
+ | `error`, `fallback` | `ERROR` |
61
+ | `local_override` | `STATIC` |
62
+
63
+ A fallback is an `ERROR` whose error code is `FLAG_NOT_FOUND`, `TYPE_MISMATCH` or `GENERAL`, with the Flagnetic error code as its message. The Flagnetic reason itself travels in the flag metadata under `flagnetic_reason`, beside `stage`, `ruleset_version`, `variation` and `overridden` (`ruleset_version` and `variation` only when there is one). An evaluation an experiment decided is a `SPLIT` whose metadata carries the experiment block: `experiment_key`, `experiment_variation`, `experiment_unit_type`, `experiment_unit_id`, `experiment_inclusion_bucket`, `experiment_variation_bucket` and `experiment_override`.
64
+
65
+ ## Track
66
+
67
+ ```ruby
68
+ client.track("exposure", evaluation_context: context,
69
+ tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(flag: "checkout_v2"))
70
+ client.track("alias", tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(
71
+ fromUnitType: "anonymous", fromUnitId: "d-8f1c", toUnitType: "user", toUnitId: "u-1001"
72
+ ))
73
+ ```
74
+
75
+ `exposure` records the exposure of the most recent evaluation of that flag for that context, as the client's `exposure` does, and `alias` links two unit IDs, as `alias` does. Any other event name, or one missing an attribute, does nothing. An evaluation alone never records an exposure, unless the flag asks for automatic exposure.
76
+
77
+ ## Development
78
+
79
+ The provider is developed against the `flagnetic` gem in `../ruby`, whatever its version, through `tree.gemfile`; the plain `Gemfile` resolves `flagnetic` from RubyGems, as a consumer's bundle does.
80
+
81
+ ```bash
82
+ export BUNDLE_GEMFILE=tree.gemfile
83
+ bundle install
84
+ bundle exec rake test # every conformance case through the provider, the shared OpenFeature reasons, events and track
85
+ bundle exec rubocop
86
+ ```
87
+
88
+ `OPENFEATURE_SDK_VERSION=0.6.1` pins `openfeature-sdk` to the oldest release the gem supports, in either bundle.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ # The mapping from Flagnetic to OpenFeature every Flagnetic provider
5
+ # shares: reasons, error codes, flag metadata and the evaluation context.
6
+ module OpenFeature
7
+ # REASON_METADATA_KEY is the flag metadata key carrying the Flagnetic
8
+ # evaluation reason, which OpenFeature's coarser reason cannot tell
9
+ # apart: a rule and a failed prerequisite are both TARGETING_MATCH.
10
+ REASON_METADATA_KEY = "flagnetic_reason"
11
+
12
+ # reason_of returns the OpenFeature reason of a Flagnetic evaluation
13
+ # reason (a Symbol or String). A reason a newer server adds is UNKNOWN
14
+ # rather than passed off as a known one; the Flagnetic reason still
15
+ # travels in the flag metadata.
16
+ def self.reason_of(reason)
17
+ Mapping::REASONS.fetch(reason.to_s, ::OpenFeature::SDK::Provider::Reason::UNKNOWN)
18
+ end
19
+
20
+ # Mapping turns what the client serves into what OpenFeature expects;
21
+ # Provider is its one user.
22
+ module Mapping
23
+ # REASONS maps every Flagnetic evaluation reason to its OpenFeature
24
+ # reason, identically in every provider of every language, so
25
+ # dashboards agree across services. targeting_match is what a client
26
+ # key is told in place of the reasons targeting decided, and error is
27
+ # the evaluator's reason a getter turns into fallback.
28
+ REASONS = {
29
+ "rule" => ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH,
30
+ "prerequisite_failed" => ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH,
31
+ "targeting_match" => ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH,
32
+ "override" => ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH,
33
+ "rollout" => ::OpenFeature::SDK::Provider::Reason::SPLIT,
34
+ "experiment" => ::OpenFeature::SDK::Provider::Reason::SPLIT,
35
+ "fallthrough" => ::OpenFeature::SDK::Provider::Reason::DEFAULT,
36
+ "off" => ::OpenFeature::SDK::Provider::Reason::DISABLED,
37
+ "error" => ::OpenFeature::SDK::Provider::Reason::ERROR,
38
+ "fallback" => ::OpenFeature::SDK::Provider::Reason::ERROR,
39
+ "local_override" => ::OpenFeature::SDK::Provider::Reason::STATIC
40
+ }.freeze
41
+
42
+ # ERROR_CODES names the two Flagnetic error codes OpenFeature has an
43
+ # identity for; every other reason for a fallback is GENERAL.
44
+ ERROR_CODES = {
45
+ flag_not_found: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND,
46
+ type_mismatch: ::OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH
47
+ }.freeze
48
+
49
+ # The attribute of an evaluation context carrying further unit IDs by
50
+ # unit type, beside the targeting key.
51
+ UNITS_ATTRIBUTE = "units"
52
+
53
+ module_function
54
+
55
+ # resolution turns a served Flagnetic::Detail into OpenFeature
56
+ # ResolutionDetails: a fallback is an ERROR carrying the mapped error
57
+ # code and the Flagnetic one as its message, anything else the mapped
58
+ # reason with its variation as the variant.
59
+ def resolution(detail)
60
+ if detail.reason == :fallback
61
+ return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
62
+ value: detail.value, reason: ::OpenFeature::SDK::Provider::Reason::ERROR,
63
+ error_code: ERROR_CODES.fetch(detail.error_code, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL),
64
+ error_message: detail.error_code.to_s, flag_metadata: flag_metadata(detail)
65
+ )
66
+ end
67
+
68
+ ::OpenFeature::SDK::Provider::ResolutionDetails.new(
69
+ value: detail.value, variant: detail.variation, reason: OpenFeature.reason_of(detail.reason),
70
+ flag_metadata: flag_metadata(detail)
71
+ )
72
+ end
73
+
74
+ # flag_metadata carries a detail's Flagnetic reason, stage, ruleset
75
+ # version, variation and platform override, and its experiment block
76
+ # without the salts, flattened to the scalar values flag metadata
77
+ # holds. What the detail has none of is left out: a scalar has no
78
+ # value meaning absent.
79
+ def flag_metadata(detail)
80
+ metadata = { REASON_METADATA_KEY => detail.reason.to_s, "stage" => detail.stage.to_s,
81
+ "ruleset_version" => detail.ruleset_version, "variation" => detail.variation,
82
+ "overridden" => detail.overridden }.compact
83
+ experiment = detail.experiment
84
+ return metadata unless experiment
85
+
86
+ metadata.merge(
87
+ "experiment_key" => experiment.key, "experiment_variation" => experiment.variation,
88
+ "experiment_unit_type" => experiment.unit_type, "experiment_unit_id" => experiment.unit_id,
89
+ "experiment_inclusion_bucket" => experiment.inclusion_bucket,
90
+ "experiment_variation_bucket" => experiment.variation_bucket, "experiment_override" => experiment.override
91
+ )
92
+ end
93
+
94
+ # context turns an OpenFeature EvaluationContext, or nil, into a
95
+ # Flagnetic context: the targeting key is the unit ID of unit_type,
96
+ # the units attribute (a Hash of unit type to unit ID) adds further
97
+ # units, and every other attribute is an attribute.
98
+ def context(evaluation_context, unit_type)
99
+ units = {}
100
+ attributes = {}
101
+ evaluation_context&.fields&.each do |key, value|
102
+ next if key == ::OpenFeature::SDK::EvaluationContext::TARGETING_KEY
103
+
104
+ if key == UNITS_ATTRIBUTE
105
+ units.merge!(unit_ids(value))
106
+ else
107
+ attributes[key] = value
108
+ end
109
+ end
110
+ targeting_key = evaluation_context&.targeting_key
111
+ units[unit_type] = targeting_key if targeting_key.is_a?(String) && !targeting_key.empty?
112
+ { units: units, attributes: attributes }
113
+ end
114
+
115
+ def unit_ids(value)
116
+ return {} unless value.is_a?(Hash)
117
+
118
+ value.each_with_object({}) do |(unit_type, unit_id), out|
119
+ out[unit_type.to_s] = unit_id if unit_id.is_a?(String)
120
+ end
121
+ end
122
+ end
123
+ private_constant :Mapping
124
+ end
125
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ module OpenFeature
5
+ # Provider serves the values of a Flagnetic::Client it builds and owns
6
+ # to the OpenFeature API.
7
+ #
8
+ # provider = Flagnetic::OpenFeature::Provider.new(ENV.fetch("FLAGNETIC_SDK_KEY"),
9
+ # base_url: "https://flags.example.com")
10
+ # OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) }
11
+ # client = OpenFeature::SDK.build_client
12
+ # client.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
13
+ # evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u-1001"))
14
+ #
15
+ # Reasons map as every Flagnetic provider maps them (reason_of), the
16
+ # Flagnetic reason itself in the flag metadata under
17
+ # REASON_METADATA_KEY. Exposures and aliases go through track:
18
+ #
19
+ # client.track("exposure", evaluation_context: context,
20
+ # tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(flag: "pricing"))
21
+ # client.track("alias", tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(
22
+ # fromUnitType: "anonymous", fromUnitId: "d-1", toUnitType: "user", toUnitId: "u-1001"
23
+ # ))
24
+ #
25
+ # Events: CONFIGURATION_CHANGED with the keys of the flags that may have
26
+ # changed; STALE when the client moves to stage last_good; READY when it
27
+ # is fresh again; ERROR when the SDK key is revoked, kept until the key
28
+ # is accepted again.
29
+ class Provider
30
+ include ::OpenFeature::SDK::Provider::EventEmitter
31
+
32
+ NAME = "flagnetic"
33
+ DEFAULT_UNIT_TYPE = "user"
34
+ DEFAULT_READY_TIMEOUT = 10
35
+
36
+ # The tracking event names every Flagnetic provider accepts, and the
37
+ # attributes alias reads.
38
+ TRACK_EXPOSURE = "exposure"
39
+ TRACK_ALIAS = "alias"
40
+ ALIAS_ATTRIBUTES = %w[fromUnitType fromUnitId toUnitType toUnitId].freeze
41
+ private_constant :TRACK_EXPOSURE, :TRACK_ALIAS, :ALIAS_ATTRIBUTES
42
+
43
+ # Whether the OpenFeature SDK lets a provider announce its own state
44
+ # after init (0.7 and later). Older releases send READY themselves
45
+ # once init returns, whatever the provider found.
46
+ LIFECYCLE_EVENTS = ::OpenFeature::SDK::Configuration.private_method_defined?(:emits_lifecycle_events?)
47
+ private_constant :LIFECYCLE_EVENTS
48
+
49
+ # client is the wrapped Flagnetic::Client: local overrides, the
50
+ # recorder of an offline client, flush and status.
51
+ attr_reader :client, :metadata
52
+
53
+ # new builds a Flagnetic::Client for sdk_key and base_url with every
54
+ # further option as Flagnetic::Client.new takes it (offline for tests
55
+ # included), and fails exactly when that does.
56
+ #
57
+ # - unit_type: the unit type the targeting key is a unit ID of;
58
+ # default "user". The units attribute, a Hash of unit type to unit
59
+ # ID, adds further units; every other attribute is an attribute of
60
+ # the context.
61
+ # - ready_timeout: seconds init waits for the client's first fetch;
62
+ # default 10. init never fails: at stage built_in the fallback
63
+ # chain still serves the built-in defaults.
64
+ # - logger: as Flagnetic::Client.new; it keeps receiving every log
65
+ # event.
66
+ def initialize(sdk_key = nil, base_url: nil, unit_type: DEFAULT_UNIT_TYPE,
67
+ ready_timeout: DEFAULT_READY_TIMEOUT, logger: nil, **client_options)
68
+ @unit_type = unit_type.to_s
69
+ @ready_timeout = ready_timeout
70
+ @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: NAME).freeze
71
+ @log = Flagnetic::Logging.adapt(logger)
72
+ @initialized = false
73
+ @closed = false
74
+ @revoked = false
75
+ @client = Flagnetic::Client.new(sdk_key, base_url: base_url, logger: method(:observe), **client_options)
76
+ @unsubscribe = [@client.on_change { |keys| changed(keys) }, @client.on_stage { |stage| staged(stage) }]
77
+ end
78
+
79
+ # emits_lifecycle_events? tells the OpenFeature SDK that init
80
+ # announces the provider's state itself.
81
+ def emits_lifecycle_events? = true
82
+
83
+ # init waits for the client's first fetch, at most ready_timeout
84
+ # seconds, and never raises. Until it returns, stage changes and a
85
+ # revoked key send no event; init then announces what the client
86
+ # reached.
87
+ def init(_evaluation_context = nil)
88
+ @client.ready(timeout: @ready_timeout)
89
+ @initialized = true
90
+ announce if LIFECYCLE_EVENTS
91
+ nil
92
+ end
93
+
94
+ # shutdown closes the wrapped client, which reports what is queued
95
+ # one last time; no event is sent after it.
96
+ def shutdown
97
+ @closed = true
98
+ @unsubscribe.each(&:call)
99
+ @client.close
100
+ nil
101
+ end
102
+
103
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
104
+ Mapping.resolution(detail(flag_key, default_value, evaluation_context))
105
+ end
106
+
107
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
108
+ Mapping.resolution(detail(flag_key, default_value, evaluation_context))
109
+ end
110
+
111
+ # fetch_number_value serves a number flag as the ruleset has it, an
112
+ # Integer or a Float.
113
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
114
+ Mapping.resolution(detail(flag_key, default_value, evaluation_context))
115
+ end
116
+
117
+ # fetch_integer_value serves a number flag as an Integer, as the
118
+ # client's int getter does: a number that is not whole serves
119
+ # default_value with TYPE_MISMATCH rather than a truncated value.
120
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
121
+ Mapping.resolution(integral(detail(flag_key, default_value, evaluation_context), default_value))
122
+ end
123
+
124
+ # fetch_float_value serves a number flag as a Float.
125
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
126
+ served = detail(flag_key, default_value, evaluation_context)
127
+ served = Flagnetic::Detail.new(**served.to_h, value: served.value.to_f) if served.reason != :fallback
128
+ Mapping.resolution(served)
129
+ end
130
+
131
+ # fetch_object_value serves a JSON flag's value, parsed and frozen.
132
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
133
+ Mapping.resolution(detail(flag_key, default_value, evaluation_context))
134
+ end
135
+
136
+ # track reports the exposure of the flag named by the flag attribute
137
+ # for evaluation_context, as Flagnetic::Client#exposure does, for the
138
+ # event "exposure"; for "alias" it records the alias named by the
139
+ # fromUnitType, fromUnitId, toUnitType and toUnitId attributes. An
140
+ # event naming neither, or missing what its mapping needs, does
141
+ # nothing.
142
+ def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
143
+ fields = tracking_event_details&.fields || {}
144
+ case tracking_event_name
145
+ when TRACK_EXPOSURE
146
+ flag = fields["flag"]
147
+ @client.exposure(flag, context: context(evaluation_context)) if present?(flag)
148
+ when TRACK_ALIAS
149
+ units = fields.values_at(*ALIAS_ATTRIBUTES)
150
+ @client.alias(*units) if units.all? { |unit| present?(unit) }
151
+ end
152
+ nil
153
+ end
154
+
155
+ private
156
+
157
+ def detail(flag_key, default_value, evaluation_context)
158
+ @client.detail(flag_key, default: default_value, context: context(evaluation_context))
159
+ end
160
+
161
+ def context(evaluation_context) = Mapping.context(evaluation_context, @unit_type)
162
+
163
+ def present?(value) = value.is_a?(String) && !value.empty?
164
+
165
+ # integral narrows a served number to an Integer; one that is not
166
+ # whole is a type mismatch, served as the fallback the client's own
167
+ # int getter serves.
168
+ def integral(served, default_value)
169
+ return served if served.reason == :fallback || served.value.is_a?(Integer)
170
+
171
+ value = served.value
172
+ if value.is_a?(Float) && value.finite? && value.to_r.denominator == 1
173
+ return Flagnetic::Detail.new(**served.to_h, value: value.to_i)
174
+ end
175
+
176
+ Flagnetic::Detail.new(value: default_value, reason: :fallback, error_code: :type_mismatch,
177
+ ruleset_version: served.ruleset_version, received_at: served.received_at,
178
+ stage: served.stage)
179
+ end
180
+
181
+ # announce sends the state init found: ERROR on a revoked key, else
182
+ # READY, followed by STALE when only a last good ruleset is held.
183
+ def announce
184
+ return error if @revoked
185
+
186
+ emit(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
187
+ stale if @client.stage == :last_good
188
+ end
189
+
190
+ def changed(keys)
191
+ emit(::OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, flags_changed: keys)
192
+ end
193
+
194
+ # staged sends READY on every move to fresh, so the OpenFeature status
195
+ # leaves STALE or ERROR when the client recovers, and STALE on a move
196
+ # to last_good. A revoked key moves a fresh client to last_good right
197
+ # after the ERROR it sent: STALE would hide the ERROR, so none is sent
198
+ # while the key stays revoked. A move to fresh clears the revoked key
199
+ # even before init returns, so init does not announce an ERROR the
200
+ # client has already recovered from.
201
+ def staged(stage)
202
+ @revoked = false if stage == :fresh
203
+ return unless @initialized
204
+
205
+ case stage
206
+ when :fresh
207
+ emit(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
208
+ when :last_good
209
+ stale unless @revoked
210
+ end
211
+ end
212
+
213
+ def stale
214
+ emit(::OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "flagnetic: ruleset stage is last_good")
215
+ end
216
+
217
+ def error
218
+ emit(::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR,
219
+ error_code: ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: "flagnetic: sdk key revoked")
220
+ end
221
+
222
+ # observe is the client's logger: the application's own, plus ERROR
223
+ # when the SDK key is revoked, the one fetch failure stage last_good
224
+ # alone does not tell from an ordinary outage. The event goes first:
225
+ # the client ignores a logger that raises, which would otherwise lose
226
+ # the event as well.
227
+ def observe(level, event, fields)
228
+ if event == :key_revoked
229
+ @revoked = true
230
+ error if @initialized
231
+ end
232
+ @log.call(level, event, fields)
233
+ end
234
+
235
+ def emit(event, details = {})
236
+ return if @closed
237
+
238
+ emit_event(event, details)
239
+ rescue StandardError => e
240
+ @log.call(:error, :listener_failed, { cause: "#{e.class}: #{e.message}" })
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ module OpenFeature
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flagnetic"
4
+ require "open_feature/sdk"
5
+
6
+ require_relative "openfeature/version"
7
+ require_relative "openfeature/mapping"
8
+ require_relative "openfeature/provider"
9
+
10
+ module Flagnetic
11
+ # OpenFeature serves Flagnetic flags through the OpenFeature API. The
12
+ # flagnetic gem stays free of the OpenFeature dependency; an application
13
+ # on OpenFeature adds this gem beside it. Start with Provider.
14
+ module OpenFeature
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagnetic-openfeature
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anatolii Zverev
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: flagnetic
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: openfeature-sdk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.6'
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 0.6.1
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '0.6'
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 0.6.1
46
+ description: Serves Flagnetic flags through the OpenFeature API, with exposures and
47
+ aliases through track.
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - LICENSE
53
+ - README.md
54
+ - lib/flagnetic/openfeature.rb
55
+ - lib/flagnetic/openfeature/mapping.rb
56
+ - lib/flagnetic/openfeature/provider.rb
57
+ - lib/flagnetic/openfeature/version.rb
58
+ homepage: https://github.com/azverinus/flagnetic
59
+ licenses:
60
+ - Apache-2.0
61
+ metadata:
62
+ source_code_uri: https://github.com/azverinus/flagnetic/tree/main/sdks/ruby-openfeature
63
+ changelog_uri: https://github.com/azverinus/flagnetic/blob/main/sdks/ruby-openfeature/CHANGELOG.md
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.4'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.20
80
+ specification_version: 4
81
+ summary: OpenFeature provider for Flagnetic
82
+ test_files: []