diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1ce09b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + ruby: ['3.4', '4.0'] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake + - run: gem build featbit-openfeature-provider.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..659eddb --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.bundle/ +/vendor/ +/Gemfile.lock +/*.gem +/pkg/ +/coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ef9d397 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## Unreleased + +- Add FeatBit evaluation through OpenFeature, including typed values, context mapping, + resolution details, lifecycle events, and experiment tracking. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..58b62cb --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +gem "rake", "~> 13.0" +gem "rspec", "~> 3.13" diff --git a/README.md b/README.md new file mode 100644 index 0000000..36446e9 --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# FeatBit OpenFeature Provider for Ruby + +Use [FeatBit](https://featbit.co) feature flags through the +[OpenFeature Ruby SDK](https://openfeature.dev/docs/reference/sdks/server/ruby/). +Evaluation and analytics are handled by the [FeatBit Ruby Server SDK](https://github.com/featbit/featbit-ruby-server-sdk). + +## Requirements and installation + +Ruby 3.4 or newer. This provider uses `openfeature-sdk ~> 0.6.5` and +`featbit-server-sdk ~> 0.1.0`. + +Until the first RubyGems release, add the repository to your Gemfile: + +```ruby +gem "featbit-openfeature-provider", git: "https://github.com/featbit/openfeature-provider-ruby-server" +``` + +Run `bundle install`. For local development, use `path:` pointing at this checkout. + +## Quick start + +```ruby +require "featbit/openfeature" + +provider = FeatBit::OpenFeature::Provider.new( + FeatBit::Options.new( + env_secret: ENV.fetch("FEATBIT_ENV_SECRET"), + streaming_url: ENV.fetch("FEATBIT_STREAMING_URL", "wss://app-eval.featbit.co"), + event_url: ENV.fetch("FEATBIT_EVENT_URL", "https://app-eval.featbit.co"), + start_wait: 5 + ) +) + +# Raises OpenFeature::SDK::ProviderInitializationError if FeatBit is not ready. +OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) } +client = OpenFeature::SDK.build_client +context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: "user-123", name: "Alice", country: "FR", plan: "pro" +) + +enabled = client.fetch_boolean_value( + flag_key: "new-checkout", default_value: false, evaluation_context: context +) + +details = client.fetch_boolean_details( + flag_key: "new-checkout", default_value: false, evaluation_context: context +) +puts "value=#{enabled} variant=#{details.variant} reason=#{details.reason} error=#{details.error_code}" + +# Call during graceful application shutdown to close connections and flush events. +OpenFeature::SDK.shutdown +``` + +Create one provider per application process and reuse the OpenFeature client. +For prefork servers, create the provider in each worker after fork. +The FeatBit client starts during provider construction and waits up to `start_wait`. +OpenFeature calls `init` to check readiness; `config.set_provider(provider)` offers +nonblocking OpenFeature registration if you do not need to wait for registration. + +An existing client can be supplied with +`FeatBit::OpenFeature::Provider.new(client: featbit_client)`. The provider takes +ownership: replacement or OpenFeature shutdown closes that client. Do not share +one FeatBit client between independently managed providers. A closed provider +cannot restart; construct a new instance. + +## Evaluation context + +| OpenFeature field | FeatBit user field | +| --- | --- | +| `targeting_key` | Required non-empty string user key | +| `name` | Optional string user name | +| Other fields | Custom attributes, with their values preserved | + +Missing or empty targeting keys return `TARGETING_KEY_MISSING`. Non-string keys, +non-string names, and unsupported context objects return `INVALID_CONTEXT`. +The provider does not invent anonymous identities or use `key` as a fallback. +Built-in FeatBit user properties retain the SDK's precedence over custom attributes. +Contexts are not modified. Custom attribute matching and analytics serialization +follow the FeatBit SDK's behavior; use scalar attributes for targeting rules. + +OpenFeature merges API, client, transaction, and invocation contexts before calling +the provider. A targeting key may therefore be set at any of those scopes. + +## Values and resolution details + +The provider supports `fetch_boolean_value`, `fetch_string_value`, +`fetch_number_value`, `fetch_integer_value`, `fetch_float_value`, and +`fetch_object_value`, plus OpenFeature's corresponding `fetch_*_details` methods. +Objects may be hashes or arrays. Integer evaluation truncates numeric values toward +zero; float evaluation converts numeric values to Float. Incompatible types return +the caller's default with `TYPE_MISMATCH`. + +Successful details include the FeatBit variation ID as `variant`, plus +`featbit.reason`, `featbit.flag_name` (when available), and `featbit.in_experiment` +in flag metadata. + +| FeatBit reason/error | OpenFeature reason/error | +| --- | --- | +| Flag off | `DISABLED` | +| User target or rule match | `TARGETING_MATCH` | +| Fallthrough | `DEFAULT` | +| Client not ready | `ERROR` / `PROVIDER_NOT_READY` | +| Flag not found | `ERROR` / `FLAG_NOT_FOUND` | +| Wrong type | `ERROR` / `TYPE_MISMATCH` | +| User not specified | `ERROR` / `TARGETING_KEY_MISSING` | +| Other evaluation errors | `ERROR` / `GENERAL` | + +Errors return the caller's default without a variant. FeatBit does not expose a +separate parse error category, so malformed flag evaluation errors map to `GENERAL`. +Evaluation analytics are produced by FeatBit's `variation_detail`; a successful +FeatBit evaluation can record an event even if the provider subsequently rejects +its type. + +## Events and tracking + +The provider forwards FeatBit readiness, interrupted synchronization, and flag +changes as `PROVIDER_READY`, `PROVIDER_STALE`, and +`PROVIDER_CONFIGURATION_CHANGED` (with `flags_changed`). Failed or closed clients +emit `PROVIDER_ERROR`. Cached flags remain available during synchronization +interruptions. Shutdown removes the provider's listeners. + +```ruby +client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) do |event| + puts event[:flags_changed].inspect +end + +client.track( + "purchase", + evaluation_context: context, + tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(value: 19.95) +) +``` + +Tracking requires a valid targeting key. Omitted metric values default to `1.0`. +Extra tracking fields are ignored because the FeatBit tracking API accepts only a +user, event name, and numeric value. Invalid tracking contexts produce no event. + +## Offline use + +```ruby +require "json" + +provider = FeatBit::OpenFeature::Provider.new( + FeatBit::Options.new(offline: true, bootstrap: JSON.parse(File.read("bootstrap.json"))) +) +``` + +Supply a full FeatBit data-sync payload containing feature flags and segments. +Offline mode disables networking and analytics. Without initialized bootstrap +data the provider reports `PROVIDER_NOT_READY`. + +## Console example + +The console example evaluates a boolean flag against a FeatBit environment: + +```sh +FEATBIT_ENV_SECRET="your-environment-secret" bundle exec ruby examples/console.rb +``` + +Enter a boolean flag key at the prompt to evaluate it. The prompt repeats so +multiple flags can be evaluated in one session. Enter `exit`, `quit`, or `q` +to stop the application. + +Set `FEATBIT_TARGETING_KEY` to evaluate for a different user. The streaming and +event URLs default to FeatBit Cloud and can be overridden with +`FEATBIT_STREAMING_URL` and `FEATBIT_EVENT_URL`. + +## Development + +```sh +bundle install +bundle exec rake +gem build featbit-openfeature-provider.gemspec +``` + +Tests run against the released SDK gems with real offline FeatBit evaluation, +including OpenFeature context merging, defaults, details, events, and tracking. +No FeatBit service or environment secret is required. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..209fdbd --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) +task default: :spec diff --git a/examples/console.rb b/examples/console.rb new file mode 100644 index 0000000..6c14b1b --- /dev/null +++ b/examples/console.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "featbit/openfeature" + +env_secret = ENV.fetch("FEATBIT_ENV_SECRET") + +provider = FeatBit::OpenFeature::Provider.new( + FeatBit::Options.new( + env_secret: env_secret, + streaming_url: ENV.fetch("FEATBIT_STREAMING_URL", "wss://app-eval.featbit.co"), + event_url: ENV.fetch("FEATBIT_EVENT_URL", "https://app-eval.featbit.co"), + start_wait: 5 + ) +) + +begin + OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) } + client = OpenFeature::SDK.build_client + context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: ENV.fetch("FEATBIT_TARGETING_KEY", "console-user"), + name: ENV.fetch("FEATBIT_USER_NAME", "Console User") + ) + + loop do + print "Enter a boolean flag key (or 'exit' to quit): " + input = $stdin.gets + break if input.nil? + + flag_key = input.strip + break if %w[exit quit q].include?(flag_key.downcase) + + if flag_key.empty? + puts "Flag key cannot be empty." + next + end + + details = client.fetch_boolean_details( + flag_key: flag_key, + default_value: false, + evaluation_context: context + ) + + puts "value: #{details.value.inspect}" + puts "variant: #{details.variant.inspect}" + puts "reason: #{details.reason.inspect}" + puts "error_code: #{details.error_code.inspect}" + puts "error_message: #{details.error_message.inspect}" + puts "flag_metadata: #{details.flag_metadata.inspect}" + puts + end +ensure + OpenFeature::SDK.shutdown +end diff --git a/featbit-openfeature-provider.gemspec b/featbit-openfeature-provider.gemspec new file mode 100644 index 0000000..bbca038 --- /dev/null +++ b/featbit-openfeature-provider.gemspec @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_relative "lib/featbit/openfeature/version" + +source_code_uri = "https://github.com/featbit/openfeature-provider-ruby-server" + +Gem::Specification.new do |spec| + spec.name = "featbit-openfeature-provider" + spec.version = FeatBit::OpenFeature::VERSION + spec.authors = ["FeatBit"] + spec.email = ["contact@featbit.co"] + spec.summary = "FeatBit OpenFeature provider for Ruby server applications" + spec.description = "Evaluate FeatBit feature flags through the OpenFeature Ruby SDK." + spec.homepage = "https://www.featbit.co/" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.4" + spec.metadata["source_code_uri"] = source_code_uri + spec.metadata["bug_tracker_uri"] = "#{source_code_uri}/issues" + spec.metadata["rubygems_mfa_required"] = "true" + spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"] + spec.require_paths = ["lib"] + spec.add_dependency "featbit-server-sdk", "~> 0.1.0" + spec.add_dependency "openfeature-sdk", "~> 0.6.5" +end diff --git a/lib/featbit/openfeature.rb b/lib/featbit/openfeature.rb new file mode 100644 index 0000000..4f4f4fe --- /dev/null +++ b/lib/featbit/openfeature.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "featbit" +require "open_feature/sdk" +require_relative "openfeature/version" +require_relative "openfeature/context_converter" +require_relative "openfeature/details_converter" +require_relative "openfeature/provider" diff --git a/lib/featbit/openfeature/context_converter.rb b/lib/featbit/openfeature/context_converter.rb new file mode 100644 index 0000000..ad3a9d4 --- /dev/null +++ b/lib/featbit/openfeature/context_converter.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module FeatBit + module OpenFeature + class ContextConverter + class InvalidContext < StandardError + attr_reader :error_code + + def initialize(error_code, message) + @error_code = error_code + super(message) + end + end + + def self.convert(context) + codes = ::OpenFeature::SDK::Provider::ErrorCode + unless context.nil? || context.is_a?(::OpenFeature::SDK::EvaluationContext) + raise InvalidContext.new(codes::INVALID_CONTEXT, "Expected an OpenFeature evaluation context") + end + + key = context&.targeting_key + if key.nil? || key == "" + raise InvalidContext.new(codes::TARGETING_KEY_MISSING, "A non-empty targeting_key is required") + end + unless key.is_a?(String) + raise InvalidContext.new(codes::INVALID_CONTEXT, "targeting_key must be a string") + end + + name = context.field("name") + unless name.nil? || name.is_a?(String) + raise InvalidContext.new(codes::INVALID_CONTEXT, "name must be a string") + end + + custom = context.fields.reject { |field, _| %w[targeting_key name].include?(field) } + FeatBit::User.new(key, name: name, custom: custom) + end + end + end +end diff --git a/lib/featbit/openfeature/details_converter.rb b/lib/featbit/openfeature/details_converter.rb new file mode 100644 index 0000000..5fce151 --- /dev/null +++ b/lib/featbit/openfeature/details_converter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module FeatBit + module OpenFeature + class DetailsConverter + API = ::OpenFeature::SDK::Provider + ERROR_CODES = { + client_not_ready: API::ErrorCode::PROVIDER_NOT_READY, + flag_not_found: API::ErrorCode::FLAG_NOT_FOUND, + user_not_specified: API::ErrorCode::TARGETING_KEY_MISSING, + wrong_type: API::ErrorCode::TYPE_MISMATCH + }.freeze + REASONS = { + "flag off" => API::Reason::DISABLED, + "target match" => API::Reason::TARGETING_MATCH, + "rule match" => API::Reason::TARGETING_MATCH, + "fall through all rules" => API::Reason::DEFAULT + }.freeze + TYPES = { + boolean: [TrueClass, FalseClass], string: [String], number: [Numeric], + integer: [Numeric], float: [Numeric], object: [Hash, Array] + }.freeze + + def self.error(default_value, code, message) + API::ResolutionDetails.new(value: default_value, reason: API::Reason::ERROR, + error_code: code, error_message: message) + end + + def self.convert(detail, type, default_value) + unless detail.success? + return error(default_value, ERROR_CODES.fetch(detail.error_kind, API::ErrorCode::GENERAL), + detail.error_message || detail.reason) + end + + value = detail.value + unless TYPES.fetch(type).any? { |klass| value.is_a?(klass) } + return error(default_value, API::ErrorCode::TYPE_MISMATCH, "Flag value is not of type #{type}") + end + + value = value.to_i if type == :integer + value = value.to_f if type == :float + metadata = { "featbit.reason" => detail.reason, "featbit.in_experiment" => detail.send_to_experiment == true } + metadata["featbit.flag_name"] = detail.flag_name unless detail.flag_name.nil? + API::ResolutionDetails.new(value: value, reason: REASONS.fetch(detail.reason, API::Reason::UNKNOWN), + variant: detail.variation_id&.to_s, flag_metadata: metadata) + end + end + end +end diff --git a/lib/featbit/openfeature/provider.rb b/lib/featbit/openfeature/provider.rb new file mode 100644 index 0000000..edd6ba2 --- /dev/null +++ b/lib/featbit/openfeature/provider.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module FeatBit + module OpenFeature + # Owns the supplied or newly created client and closes it on shutdown. + class Provider + include ::OpenFeature::SDK::Provider::EventEmitter + + attr_reader :client, :metadata + + def initialize(options = nil, client: nil) + raise ArgumentError, "Provide options or client, not both" if options && client + unless client || options.is_a?(FeatBit::Options) + raise ArgumentError, "FeatBit::Options or a FeatBit client is required" + end + + @client = client || FeatBit::Client.new(options) + @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: "FeatBit").freeze + @status_listener = @client.status_provider.add_listener { |status, message| status_changed(status, message) } + @flag_listener = @client.add_flag_change_listener do |key| + emit_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, flags_changed: [key]) + end + end + + def init(_evaluation_context = nil) + return if client.initialized? && [FeatBit::Status::READY, FeatBit::Status::INTERRUPTED, + FeatBit::Status::OFFLINE].include?(client.status_provider.status) + + raise ContextConverter::InvalidContext.new( + ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY, "FeatBit client is not ready" + ) + end + + def shutdown + client.status_provider.remove_listener(@status_listener) + client.remove_flag_change_listener(@flag_listener) + detach + client.close + end + + %i[boolean string number integer float object].each do |type| + define_method("fetch_#{type}_value") do |flag_key:, default_value:, evaluation_context: nil| + resolve(type, flag_key, default_value, evaluation_context) + end + end + + def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil) + user = ContextConverter.convert(evaluation_context) + client.track(user, tracking_event_name, tracking_event_details&.value || 1.0) + rescue ContextConverter::InvalidContext + false + end + + private + + def resolve(type, key, default_value, context) + user = ContextConverter.convert(context) + DetailsConverter.convert(client.variation_detail(key, user, default_value), type, default_value) + rescue ContextConverter::InvalidContext => e + DetailsConverter.error(default_value, e.error_code, e.message) + rescue StandardError + DetailsConverter.error(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, + "FeatBit evaluation failed") + end + + def status_changed(status, message) + events = ::OpenFeature::SDK::ProviderEvent + if status == FeatBit::Status::OFFLINE && client.initialized? + status = FeatBit::Status::READY + end + case status + when FeatBit::Status::READY + emit_event(events::PROVIDER_READY) + when FeatBit::Status::INTERRUPTED + emit_event(events::PROVIDER_STALE, message: message) + when FeatBit::Status::STARTING, FeatBit::Status::OFFLINE, FeatBit::Status::FAILED, FeatBit::Status::CLOSED + emit_event(events::PROVIDER_ERROR, error_code: ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + message: message || "FeatBit client is not ready") + end + end + end + end +end diff --git a/lib/featbit/openfeature/version.rb b/lib/featbit/openfeature/version.rb new file mode 100644 index 0000000..7ab55b0 --- /dev/null +++ b/lib/featbit/openfeature/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module FeatBit + module OpenFeature + VERSION = "0.1.0" + end +end diff --git a/spec/details_converter_spec.rb b/spec/details_converter_spec.rb new file mode 100644 index 0000000..11b9ff7 --- /dev/null +++ b/spec/details_converter_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FeatBit::OpenFeature::DetailsConverter do + { client_not_ready: "PROVIDER_NOT_READY", flag_not_found: "FLAG_NOT_FOUND", + user_not_specified: "TARGETING_KEY_MISSING", wrong_type: "TYPE_MISMATCH", + error: "GENERAL", future_error: "GENERAL" }.each do |kind, code| + it "maps #{kind} before checking the fallback type" do + detail = FeatBit::EvaluationDetail.new(value: nil, error_kind: kind, error_message: "failure") + result = described_class.convert(detail, :boolean, false) + expect(result.value).to be(false) + expect(result.error_code).to eq(code) + expect(result.error_message).to eq("failure") + expect(result.variant).to be_nil + end + end + + it "preserves unknown reasons as metadata" do + detail = FeatBit::EvaluationDetail.new(value: "value", reason: "future reason", variation_id: "v") + result = described_class.convert(detail, :string, "") + expect(result.reason).to eq("UNKNOWN") + expect(result.flag_metadata["featbit.reason"]).to eq("future reason") + end +end diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb new file mode 100644 index 0000000..e03d2ab --- /dev/null +++ b/spec/provider_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe FeatBit::OpenFeature::Provider do + let(:context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-1", name: "Alice", country: "FR") } + let(:provider) do + described_class.new(offline_options( + flag("bool", "boolean", "true"), flag("string", "string", "hello"), + flag("number", "number", "12.75"), flag("object", "json", '{"enabled":true}'), + flag("array", "json", '[1,"two"]'), flag("disabled", "boolean", "false", isEnabled: false), + flag("target", "string", "targeted", targetUsers: [{ "keyIds" => ["user-1"], "variationId" => "on" }]), + flag("rule", "string", "matched", rules: [{ "conditions" => [{ "property" => "country", "op" => "Equal", "value" => "FR" }], + "variations" => [{ "id" => "on", "rollout" => [0, 1] }] }]) + )) + end + let(:client) do + OpenFeature::SDK.configure { |config| config.set_provider_and_wait(provider) } + OpenFeature::SDK.build_client(evaluation_context: context) + end + + after { provider.shutdown } + + { boolean: ["bool", false, true], string: ["string", "fallback", "hello"], + number: ["number", 0, 12.75], integer: ["number", 0, 12], float: ["number", 0.0, 12.75], + object: ["object", {}, { "enabled" => true }] }.each do |type, (key, fallback, expected)| + it "evaluates #{type} through the real OpenFeature and FeatBit SDKs" do + detail = client.public_send("fetch_#{type}_details", flag_key: key, default_value: fallback) + expect(detail.value).to eq(expected) + expect(detail.error_code).to be_nil + expect(detail.variant).to eq("on") + expect(detail.reason).to eq("DEFAULT") + end + + it "rejects a mismatched #{type} result" do + wrong_key = type == :string ? "bool" : "string" + detail = client.public_send("fetch_#{type}_details", flag_key: wrong_key, default_value: fallback) + expect(detail.value).to eq(fallback) + expect(detail.error_code).to eq("TYPE_MISMATCH") + expect(detail.reason).to eq("ERROR") + expect(detail.variant).to be_nil + end + end + + it "supports JSON arrays" do + expect(client.fetch_object_value(flag_key: "array", default_value: [])).to eq([1, "two"]) + end + + { "disabled" => "DISABLED", "target" => "TARGETING_MATCH", "rule" => "TARGETING_MATCH" }.each do |key, reason| + it "maps the #{key} reason" do + detail = provider.fetch_string_value(flag_key: key == "disabled" ? "string" : key, + default_value: "", evaluation_context: context) + detail = provider.fetch_boolean_value(flag_key: key, default_value: true, evaluation_context: context) if key == "disabled" + expect(detail.reason).to eq(reason) + end + end + + it "preserves flag metadata" do + detail = client.fetch_string_details(flag_key: "string", default_value: "") + expect(detail.flag_metadata).to include("featbit.reason" => "fall through all rules", "featbit.flag_name" => "string") + end + + it "returns the caller default for missing flags" do + detail = client.fetch_boolean_details(flag_key: "missing", default_value: false) + expect(detail.value).to be(false) + expect(detail.error_code).to eq("FLAG_NOT_FOUND") + end + + [nil, OpenFeature::SDK::EvaluationContext.new, OpenFeature::SDK::EvaluationContext.new(targeting_key: "")].each do |invalid| + it "requires a non-empty targeting key (#{invalid.inspect})" do + result = provider.fetch_boolean_value(flag_key: "bool", default_value: false, evaluation_context: invalid) + expect(result.error_code).to eq("TARGETING_KEY_MISSING") + end + end + + [42, OpenFeature::SDK::EvaluationContext.new(targeting_key: 42), + OpenFeature::SDK::EvaluationContext.new(targeting_key: "u", name: false)].each do |invalid| + it "rejects invalid contexts (#{invalid.inspect})" do + result = provider.fetch_boolean_value(flag_key: "bool", default_value: false, evaluation_context: invalid) + expect(result.error_code).to eq("INVALID_CONTEXT") + end + end + + it "maps context fields without mutating the context" do + user = FeatBit::OpenFeature::ContextConverter.convert(context) + expect(user.key).to eq("user-1") + expect(user.name).to eq("Alice") + expect(user.custom).to eq("country" => "FR") + expect(context.fields).to include("targeting_key" => "user-1", "name" => "Alice") + end + + it "merges invocation context through OpenFeature" do + override = OpenFeature::SDK::EvaluationContext.new(targeting_key: "someone-else") + expect(client.fetch_string_details(flag_key: "target", default_value: "", evaluation_context: override).reason).to eq("DEFAULT") + end + + it "reports uninitialized clients" do + empty = described_class.new(FeatBit::Options.new(offline: true)) + expect { empty.init }.to raise_error(FeatBit::OpenFeature::ContextConverter::InvalidContext) + expect(empty.fetch_boolean_value(flag_key: "bool", default_value: false, evaluation_context: context).error_code).to eq("PROVIDER_NOT_READY") + ensure + empty&.shutdown + end + + it "closes an injected client and cannot be initialized again" do + sdk_client = FeatBit::Client.new(offline_options) + wrapper = described_class.new(client: sdk_client) + expect(wrapper.client).to equal(sdk_client) + expect(wrapper.shutdown).to be(true) + expect(wrapper.shutdown).to be(true) + expect { wrapper.init }.to raise_error(FeatBit::OpenFeature::ContextConverter::InvalidContext) + end + + it "rejects ambiguous construction" do + expect { described_class.new }.to raise_error(ArgumentError) + expect { described_class.new(offline_options, client: provider.client) }.to raise_error(ArgumentError) + end + + it "contains unexpected evaluation errors" do + allow(provider.client).to receive(:variation_detail).and_raise("internal failure") + detail = provider.fetch_boolean_value(flag_key: "bool", default_value: false, evaluation_context: context) + expect(detail.error_code).to eq("GENERAL") + expect(detail.value).to be(false) + end + + it "forwards tracking values including zero" do + expect(provider.client).to receive(:track).with(have_attributes(key: "user-1"), "purchase", 0) + client.track("purchase", tracking_event_details: OpenFeature::SDK::TrackingEventDetails.new(value: 0)) + end + + it "defaults tracking value to one and rejects missing context" do + expect(provider.client).to receive(:track).with(have_attributes(key: "user-1"), "purchase", 1.0) + provider.track("purchase", evaluation_context: context) + expect(provider.track("purchase")).to be(false) + end + + it "reports initialized offline clients as ready and keeps evaluation available" do + ready_events = [] + error_events = [] + client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) { |details| ready_events << details } + client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) { |details| error_events << details } + ready_events.clear + + provider.client.status_provider.update(FeatBit::Status::OFFLINE) + + expect { provider.init }.not_to raise_error + expect(client.provider_status).to eq(OpenFeature::SDK::ProviderState::READY) + expect(ready_events.size).to eq(1) + expect(error_events).to be_empty + expect(client.fetch_boolean_value(flag_key: "bool", default_value: false)).to be(true) + end + + it "reports offline clients without initialized data as not ready" do + empty = described_class.new(FeatBit::Options.new(offline: true)) + OpenFeature::SDK.configure do |config| + expect { config.set_provider_and_wait(empty) }.to raise_error(OpenFeature::SDK::ProviderInitializationError) + end + error_events = [] + offline_client = OpenFeature::SDK.build_client(evaluation_context: context) + offline_client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) { |details| error_events << details } + error_events.clear + + empty.client.status_provider.update(FeatBit::Status::OFFLINE, message: "No bootstrap data") + + expect(offline_client.provider_status).to eq(OpenFeature::SDK::ProviderState::ERROR) + expect(error_events.size).to eq(1) + expect(error_events.first[:error_code]).to eq("PROVIDER_NOT_READY") + expect(empty.fetch_boolean_value(flag_key: "bool", default_value: false, + evaluation_context: context).error_code).to eq("PROVIDER_NOT_READY") + ensure + empty&.shutdown + end + + it "forwards stale, recovery and configuration events and removes listeners on shutdown" do + events = [] + client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) { |details| events << details } + client.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) { |details| events << details } + provider.client.status_provider.update(FeatBit::Status::INTERRUPTED) + expect(client.provider_status).to eq(OpenFeature::SDK::ProviderState::STALE) + expect(client.fetch_boolean_value(flag_key: "bool", default_value: false)).to be(true) + provider.client.status_provider.update(FeatBit::Status::READY) + expect(client.provider_status).to eq(OpenFeature::SDK::ProviderState::READY) + provider.client.send(:broadcast_flag_change, "bool") + expect(events.last[:flags_changed]).to eq(["bool"]) + provider.shutdown + provider.client.send(:broadcast_flag_change, "bool") + expect(events.size).to eq(2) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..21f662d --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "featbit/openfeature" +require "logger" + +RSpec.configure do |config| + config.disable_monkey_patching! + config.order = :random + config.after { OpenFeature::SDK.shutdown } +end + +def flag(key, type, value, **overrides) + { + "id" => key, "key" => key, "name" => key, "variationType" => type, + "isEnabled" => true, "isArchived" => false, + "variations" => [{ "id" => "on", "value" => value }, { "id" => "off", "value" => value }], + "disabledVariationId" => "off", "targetUsers" => [], "rules" => [], + "fallthrough" => { "variations" => [{ "id" => "on", "rollout" => [0, 1] }] }, + "updatedAt" => "2026-01-01T00:00:00Z" + }.merge(overrides.transform_keys(&:to_s)) +end + +def offline_options(*flags) + FeatBit::Options.new(offline: true, logger: Logger.new(File::NULL), bootstrap: { + "messageType" => "data-sync", + "data" => { "eventType" => "full", "featureFlags" => flags, "segments" => [] } + }) +end