From 15c24d1f61a7e69bc0c6f65f87e468880dc3ff18 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 14 Jul 2026 21:35:55 +0200 Subject: [PATCH 1/3] feat: OpenFeature provider for Ruby --- Gemfile.lock | 2 + README.md | 44 ++++++ featurevisor.gemspec | 1 + lib/featurevisor/openfeature_provider.rb | 187 +++++++++++++++++++++++ spec/openfeature_provider_spec.rb | 89 +++++++++++ 5 files changed, 323 insertions(+) create mode 100644 lib/featurevisor/openfeature_provider.rb create mode 100644 spec/openfeature_provider_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index ef4da6e..df5efd1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,6 +9,7 @@ GEM specs: benchmark (0.4.0) diff-lcs (1.6.2) + openfeature-sdk (0.6.5) rake (13.3.0) rspec (3.13.1) rspec-core (~> 3.13.0) @@ -30,6 +31,7 @@ PLATFORMS DEPENDENCIES featurevisor! + openfeature-sdk (~> 0.6.5) rake (~> 13.0) rspec (~> 3.12) diff --git a/README.md b/README.md index 0faed19..75237ac 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) - [CLI usage](#cli-usage) - [Test](#test) - [Test against local monorepo's example-1](#test-against-local-monorepos-example-1) @@ -811,6 +812,49 @@ $ bundle exec featurevisor assess-distribution \ --n=1000 ``` +## OpenFeature + +Install the official OpenFeature SDK next to Featurevisor: + +```ruby +gem "featurevisor" +gem "openfeature-sdk", "~> 0.6.5" +``` + +```ruby +require "featurevisor/openfeature_provider" + +provider = Featurevisor::OpenFeatureProvider.new( + datafile: datafile_content, +) + +OpenFeature::SDK.configure do |config| + config.set_provider_and_wait(provider) +end + +client = OpenFeature::SDK.build_client +enabled = client.fetch_boolean_value( + flag_key: "checkout", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123"), +) +``` + +Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver. + +OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping. + +You can also reuse an existing Featurevisor instance: + +```ruby +featurevisor = Featurevisor.create_featurevisor(datafile: datafile_content) +provider = Featurevisor::OpenFeatureProvider.new(featurevisor: featurevisor) +``` + +The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options hash. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages. + ## Development diff --git a/featurevisor.gemspec b/featurevisor.gemspec index 7a42ea8..5a2ff14 100644 --- a/featurevisor.gemspec +++ b/featurevisor.gemspec @@ -18,4 +18,5 @@ Gem::Specification.new do |spec| spec.add_dependency "benchmark", ">= 0" spec.add_development_dependency "rspec", "~> 3.12" spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "openfeature-sdk", "~> 0.6.5" end diff --git a/lib/featurevisor/openfeature_provider.rb b/lib/featurevisor/openfeature_provider.rb new file mode 100644 index 0000000..ab04195 --- /dev/null +++ b/lib/featurevisor/openfeature_provider.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "json" +require "time" +require "open_feature/sdk" +require_relative "../featurevisor" + +module Featurevisor + class OpenFeatureProvider + Provider = OpenFeature::SDK::Provider + + attr_reader :metadata, :featurevisor + + def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", on_track: nil) + @metadata = Provider::ProviderMetadata.new(name: "Featurevisor").freeze + @targeting_key_field = targeting_key_field.empty? ? "userId" : targeting_key_field + @key_separator = key_separator.empty? ? ":" : key_separator + @variation_key = variation_key.empty? ? "variation" : variation_key + @on_track = on_track + @datafile_error = nil + @owns_featurevisor = featurevisor.nil? + if featurevisor + @featurevisor = featurevisor + else + resolved_options = options.dup + original_handler = resolved_options[:onDiagnostic] || resolved_options[:on_diagnostic] + resolved_options[:onDiagnostic] = lambda do |diagnostic| + @datafile_error = diagnostic[:message] if diagnostic[:code] == "invalid_datafile" + @datafile_error = nil if diagnostic[:code] == "datafile_set" + original_handler&.call(diagnostic) + end + @featurevisor = Featurevisor.create_featurevisor(resolved_options) + end + @datafile_unsubscribe = @featurevisor.on("datafile_set", ->(*) { @datafile_error = nil }) + end + + def init(_evaluation_context = nil); end + def shutdown + @datafile_unsubscribe&.call + featurevisor.close if @owns_featurevisor + end + def hooks = [] + + def track(tracking_event_name:, evaluation_context: nil, tracking_event_details: nil) + @on_track&.call(tracking_event_name, evaluation_context, tracking_event_details) + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, evaluation_context, :boolean) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, evaluation_context, :string) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, evaluation_context, :number) + end + + alias fetch_integer_value fetch_number_value + alias fetch_float_value fetch_number_value + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, evaluation_context, :object) + end + + private + + def resolve(flag_key, default_value, evaluation_context, expected_type) + return error(default_value, Provider::ErrorCode::PARSE_ERROR, @datafile_error) if @datafile_error + + feature_key, selector = split_key(flag_key) + context = normalize(evaluation_context&.fields || {}) + targeting_key = evaluation_context&.targeting_key + context[@targeting_key_field] = targeting_key if targeting_key && !targeting_key.empty? + + if selector.nil? || selector.empty? + return type_mismatch(flag_key, default_value, expected_type) unless expected_type == :boolean + evaluation = featurevisor.evaluate_flag(feature_key, context) + value = evaluation[:enabled] + elsif selector == @variation_key + evaluation = featurevisor.evaluate_variation(feature_key, context) + value = evaluation[:variation_value] || evaluation.dig(:variation, :value) + else + evaluation = featurevisor.evaluate_variable(feature_key, selector, context) + value = evaluation[:variable_value] + if evaluation.dig(:variable_schema, :type) == "json" && value.is_a?(String) + begin + value = JSON.parse(value) + rescue JSON::ParserError + # Type validation below returns TYPE_MISMATCH when object was requested. + end + end + end + + metadata = metadata_for(evaluation) + code = error_code(evaluation[:reason]) + return error(default_value, code, error_message(evaluation), metadata) if code + value = default_value if value.nil? + value = normalize(value) if expected_type == :object + return type_mismatch(flag_key, default_value, expected_type, metadata) unless matches?(value, expected_type) + + Provider::ResolutionDetails.new( + value: value, + reason: reason(evaluation[:reason]), + variant: variant(evaluation), + flag_metadata: metadata + ) + end + + def split_key(key) + index = key.index(@key_separator) + index ? [key[0...index], key[(index + @key_separator.length)..]] : [key, nil] + end + + def metadata_for(evaluation) + metadata = { + "featureKey" => evaluation[:feature_key], + "featurevisorReason" => evaluation[:reason], + "schemaVersion" => featurevisor.get_schema_version + } + metadata["revision"] = featurevisor.get_revision if featurevisor.get_revision + { + variable_key: "variableKey", + rule_key: "ruleKey", + bucket_key: "bucketKey", + bucket_value: "bucketValue", + force_index: "forceIndex", + variable_override_index: "variableOverrideIndex" + }.each do |key, metadata_key| + metadata[metadata_key] = evaluation[key] unless evaluation[key].nil? + end + metadata + end + + def reason(value) + return Provider::Reason::ERROR if %w[feature_not_found variable_not_found no_variations error].include?(value) + return Provider::Reason::TARGETING_MATCH if %w[required forced sticky rule variable_override_variation variable_override_rule].include?(value) + return Provider::Reason::SPLIT if value == "allocated" + return Provider::Reason::DISABLED if %w[disabled variation_disabled variable_disabled].include?(value) + Provider::Reason::DEFAULT + end + + def error_code(value) + return Provider::ErrorCode::FLAG_NOT_FOUND if %w[feature_not_found variable_not_found no_variations].include?(value) + return Provider::ErrorCode::GENERAL if value == "error" + nil + end + + def error_message(evaluation) + return evaluation[:error].message if evaluation[:error].respond_to?(:message) + return %(Feature "#{evaluation[:feature_key]}" was not found) if evaluation[:reason] == "feature_not_found" + return %(Variable "#{evaluation[:variable_key]}" was not found for feature "#{evaluation[:feature_key]}") if evaluation[:reason] == "variable_not_found" + return %(Feature "#{evaluation[:feature_key]}" has no variations) if evaluation[:reason] == "no_variations" + "Featurevisor evaluation failed" + end + + def variant(evaluation) = evaluation[:variation_value] || evaluation.dig(:variation, :value) + + def matches?(value, type) + case type + when :boolean then value == true || value == false + when :string then value.is_a?(String) + when :number then value.is_a?(Numeric) && value.finite? + when :object then value.is_a?(Hash) || value.is_a?(Array) + else false + end + end + + def normalize(value) + case value + when Time, DateTime then value.iso8601 + when Hash then value.to_h { |key, item| [key.to_s, normalize(item)] } + when Array then value.map { |item| normalize(item) } + else value + end + end + + def error(value, code, message, metadata = {}) + Provider::ResolutionDetails.new(value: value, reason: Provider::Reason::ERROR, error_code: code, error_message: message, flag_metadata: metadata) + end + + def type_mismatch(key, value, expected_type, metadata = {}) + error(value, Provider::ErrorCode::TYPE_MISMATCH, %(Flag "#{key}" did not resolve to a #{expected_type} value), metadata) + end + end +end diff --git a/spec/openfeature_provider_spec.rb b/spec/openfeature_provider_spec.rb new file mode 100644 index 0000000..baad944 --- /dev/null +++ b/spec/openfeature_provider_spec.rb @@ -0,0 +1,89 @@ +require "featurevisor/openfeature_provider" + +RSpec.describe Featurevisor::OpenFeatureProvider do + let(:datafile) do + { + schemaVersion: "2", revision: "openfeature-test", segments: {}, + features: { + "checkout" => { + bucketBy: "userId", + variations: [{ value: "on", variables: { title: "Hello", count: 3, ratio: 1.5, visible: true, items: ["a"], config: { color: "blue" }, json: '{"nested":true}' } }], + variablesSchema: { + title: { type: "string", defaultValue: "Default" }, count: { type: "integer", defaultValue: 0 }, + ratio: { type: "double", defaultValue: 0 }, visible: { type: "boolean", defaultValue: false }, + items: { type: "array", defaultValue: [] }, config: { type: "object", defaultValue: {} }, json: { type: "json", defaultValue: "{}" } + }, + force: [{ conditions: { attribute: "userId", operator: "equals", value: "forced-user" }, enabled: true, variation: "on" }], + traffic: [{ key: "all", segments: "*", percentage: 100_000, variation: "on" }] + } + } + } + end + + def provider(**options) + described_class.new({ datafile: datafile, logLevel: "fatal" }, **options) + end + + it "resolves every OpenFeature type and maps targeting key" do + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "forced-user") + expect(provider.fetch_boolean_value(flag_key: "checkout", default_value: false, evaluation_context: context).value).to be(true) + expect(provider.fetch_string_value(flag_key: "checkout:variation", default_value: "fallback", evaluation_context: context).value).to eq("on") + expect(provider.fetch_string_value(flag_key: "checkout:title", default_value: "fallback", evaluation_context: context).value).to eq("Hello") + expect(provider.fetch_integer_value(flag_key: "checkout:count", default_value: 0, evaluation_context: context).value).to eq(3) + expect(provider.fetch_float_value(flag_key: "checkout:ratio", default_value: 0.0, evaluation_context: context).value).to eq(1.5) + expect(provider.fetch_boolean_value(flag_key: "checkout:visible", default_value: false, evaluation_context: context).value).to be(true) + expect(provider.fetch_object_value(flag_key: "checkout:items", default_value: [], evaluation_context: context).value).to eq(["a"]) + expect(provider.fetch_object_value(flag_key: "checkout:config", default_value: {}, evaluation_context: context).value).to eq({ "color" => "blue" }) + expect(provider.fetch_object_value(flag_key: "checkout:json", default_value: {}, evaluation_context: context).value).to eq({ "nested" => true }) + end + + it "supports errors, custom grammar, tracking, and shutdown" do + tracked = [] + instance = provider(key_separator: "/", variation_key: "$variation", on_track: ->(*args) { tracked << args }) + expect(instance.fetch_string_value(flag_key: "checkout/$variation", default_value: "fallback").value).to eq("on") + expect(instance.fetch_string_value(flag_key: "missing", default_value: "fallback").error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + missing = instance.fetch_boolean_value(flag_key: "missing", default_value: true) + expect(missing.value).to be(true) + expect(missing.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + instance.track(tracking_event_name: "purchase") + expect(tracked.first.first).to eq("purchase") + instance.shutdown + end + + it "works through the OpenFeature SDK" do + OpenFeature::SDK.configure { |configuration| configuration.set_provider_and_wait(provider) } + client = OpenFeature::SDK.build_client + value = client.fetch_boolean_value( + flag_key: "checkout", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "forced-user") + ) + expect(value).to be(true) + end + + it "returns a parse error for malformed datafiles" do + instance = described_class.new({ datafile: "{", logLevel: "fatal" }) + result = instance.fetch_boolean_value(flag_key: "checkout", default_value: false) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + expect(result.error_message).to eq("Could not parse datafile") + instance.featurevisor.set_datafile(datafile, true) + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true) + end + + it "borrows an existing Featurevisor instance" do + closed = false + featurevisor = Featurevisor.create_featurevisor( + datafile: datafile, + logLevel: "fatal", + modules: [{ name: "owner", close: -> { closed = true } }] + ) + instance = described_class.new(featurevisor: featurevisor) + + expect(instance.featurevisor).to equal(featurevisor) + instance.shutdown + expect(closed).to be(false) + + featurevisor.close + expect(closed).to be(true) + end +end From f52291b812b61edcb18696f68dcc83d6c2f3633a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Wed, 22 Jul 2026 19:44:45 +0200 Subject: [PATCH 2/3] split gems --- .github/workflows/checks.yml | 79 ++++++++----- .github/workflows/publish.yml | 49 +++++--- .nvmrc | 2 +- Gemfile | 6 +- Gemfile.lock | 4 +- Makefile | 10 +- README.md | 45 ++++++-- Rakefile | 11 +- featurevisor-openfeature.gemspec | 31 +++++ featurevisor.gemspec | 15 ++- gemfiles/base.gemfile | 3 + gemfiles/base.gemfile.lock | 37 ++++++ lib/featurevisor-openfeature.rb | 3 + lib/featurevisor/openfeature_provider.rb | 40 +++++-- lib/featurevisor/version.rb | 2 +- scripts/verify_gems.rb | 39 +++++++ spec/openfeature_provider_spec.rb | 138 ++++++++++++++++++++++- 17 files changed, 432 insertions(+), 82 deletions(-) create mode 100644 featurevisor-openfeature.gemspec create mode 100644 gemfiles/base.gemfile create mode 100644 gemfiles/base.gemfile.lock create mode 100644 lib/featurevisor-openfeature.rb create mode 100644 scripts/verify_gems.rb diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 601b16c..f620cbd 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,50 +3,75 @@ name: Checks on: push: branches: - - "**" + - main pull_request: - types: - - opened - - synchronize # when a PR is updated - - reopened # when a PR is reopened - - ready_for_review # when a PR is ready for review - - review_requested # when a PR is requested for review + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - ci: + base-sdk: + name: Base SDK on Ruby ${{ matrix.ruby-version }} runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + ruby-version: ["3.0", "3.4"] + env: + BUNDLE_GEMFILE: gemfiles/base.gemfile steps: - - name: Check out - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v6 - - name: Set up Ruby ${{ matrix.ruby-version }} + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: + ruby-version: ${{ matrix.ruby-version }} bundler-cache: true - - name: Install - run: | - make install + - name: Test base SDK without OpenFeature dependencies + run: bundle exec rspec spec/ --exclude-pattern spec/openfeature_provider_spec.rb - - name: Build - run: | - make build + full: + name: Full SDK and provider + runs-on: ubuntu-latest + timeout-minutes: 20 - - name: Test - run: | - make test + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + + - name: Test both packages + run: make test + + - name: Build and verify both gem artifacts + run: make build - - uses: actions/setup-node@v4 + - name: Set up Node.js + uses: actions/setup-node@v7 with: - node-version-file: ".nvmrc" + node-version-file: .nvmrc - - name: Setup Featurevisor example-1 project + - name: Set up Featurevisor example-1 project run: | mkdir example-1 - (cd example-1 && npx @featurevisor/cli@2.x init --example=1) - (cd example-1 && npm install) - (cd example-1 && npx featurevisor test) + cd example-1 + npx --yes @featurevisor/cli@3 init --example=1 + npm install + npx featurevisor build + npx featurevisor test --onlyFailures - name: Run Featurevisor project tests against Ruby SDK - run: ./bin/featurevisor test --projectDirectoryPath=./example-1 + run: bundle exec ruby bin/featurevisor test --projectDirectoryPath=example-1 --onlyFailures diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eb3093c..4a81d06 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,32 +5,51 @@ on: tags: - "v*.*.*" +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - name: Check out - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v6 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: + ruby-version: "3.4" bundler-cache: true - - name: Configure RubyGems credentials - run: | - mkdir -p ~/.gem - echo ":rubygems_api_key: $RUBYGEMS_API_KEY" > ~/.gem/credentials - chmod 0600 ~/.gem/credentials - env: - RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + - name: Test both packages + run: make test - - name: Build - run: | - make build - ls -l *.gem + - name: Build and verify both gem artifacts + run: make build - - name: Push to RubyGems + - name: Verify tag matches package version + id: version + shell: bash run: | - gem push *.gem + version="$(bundle exec ruby -Ilib -rfeaturevisor/version -e 'print Featurevisor::VERSION')" + if [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "Tag $GITHUB_REF_NAME does not match gem version $version" >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Publish Featurevisor SDK + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: gem push "featurevisor-${{ steps.version.outputs.version }}.gem" + + - name: Publish Featurevisor OpenFeature provider + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} + run: gem push "featurevisor-openfeature-${{ steps.version.outputs.version }}.gem" diff --git a/.nvmrc b/.nvmrc index 209e3ef..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +24 diff --git a/Gemfile b/Gemfile index d21c653..7478bdc 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,6 @@ source "https://rubygems.org" -# Specify your gem's dependencies in featurevisor.gemspec -gemspec +gemspec name: "featurevisor" + +# Needed only when developing and testing the separately packaged provider. +gem "openfeature-sdk", "~> 0.6.5" diff --git a/Gemfile.lock b/Gemfile.lock index df5efd1..617d108 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - featurevisor (1.0.0) - benchmark + featurevisor (1.1.0) + benchmark (>= 0, < 1) GEM remote: https://rubygems.org/ diff --git a/Makefile b/Makefile index 3a11b6d..639a30e 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,20 @@ -.PHONY: install build test test-example-1 setup-monorepo update-monorepo +.PHONY: install build verify-artifacts test test-base test-example-1 setup-monorepo update-monorepo install: bundle install build: - gem build featurevisor.gemspec + bundle exec rake build + +verify-artifacts: + bundle exec ruby scripts/verify_gems.rb test: bundle exec rspec spec/ +test-base: + BUNDLE_GEMFILE=gemfiles/base.gemfile bundle exec rspec spec/ --exclude-pattern spec/openfeature_provider_spec.rb + test-example-1: bundle exec rspec spec/ bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures diff --git a/README.md b/README.md index 75237ac..af50c17 100644 --- a/README.md +++ b/README.md @@ -779,7 +779,7 @@ All three commands accept repeatable `--target=` options. `test` builds ```bash $ cd /absolute/path/to/featurevisor-ruby -$ bundle exec ruby bin/featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures +$ bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures $ make test-example-1 ``` @@ -814,13 +814,18 @@ $ bundle exec featurevisor assess-distribution \ ## OpenFeature -Install the official OpenFeature SDK next to Featurevisor: +The OpenFeature provider is published as a separate gem. This keeps OpenFeature code and dependencies out of applications that only use the Featurevisor SDK. + +The provider currently requires Ruby 3.4 or newer because that is the minimum version supported by the official OpenFeature Ruby SDK. + +Install the provider: ```ruby -gem "featurevisor" -gem "openfeature-sdk", "~> 0.6.5" +gem "featurevisor-openfeature", "~> 1.1" ``` +It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version. + ```ruby require "featurevisor/openfeature_provider" @@ -844,6 +849,17 @@ Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping. +You can pass any Featurevisor initialization options directly to the provider. These options are used to create the Featurevisor instance owned by the provider: + +```ruby +provider = Featurevisor::OpenFeatureProvider.new( + datafile: datafile_content, + targeting_key_field: "accountId", + key_separator: "/", + variation_key: "$variation", +) +``` + You can also reuse an existing Featurevisor instance: ```ruby @@ -861,24 +877,35 @@ See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeat ### Setting up -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +After checking out the repository, run `make install` to install dependencies. ### Running tests ```bash $ bundle exec rspec +$ make test-base +$ make test-example-1 +``` + +Build and verify both gems with: + +```bash +$ make build ``` -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERSION.gem`. The artifact verifier confirms that OpenFeature code and dependencies are absent from the base gem and present only in the provider gem. ### Releasing -- Update version in `lib/featurevisor/version.rb` +- Update the shared version in `lib/featurevisor/version.rb` - Run `bundle install` - Push commit to `main` branch - Wait for CI to complete -- Tag the release with the version number -- This will trigger a new release to [RubyGems](https://rubygems.org/gems/featurevisor) +- Tag the release with the same version number, for example `v1.1.0` +- The workflow verifies that the tag matches the shared version +- The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature` + +The gems are separate RubyGems packages built from the same repository. Publishing the base gem first ensures the provider's exact Featurevisor dependency is available when the provider is published. If the second push fails, rerun or retry the provider publication without republishing the existing base version. ## License diff --git a/Rakefile b/Rakefile index 6966803..aff975a 100644 --- a/Rakefile +++ b/Rakefile @@ -1,17 +1,22 @@ require "rspec/core/rake_task" +require "fileutils" +require_relative "lib/featurevisor/version" RSpec::Core::RakeTask.new(:spec) task default: :spec -desc "Build the gem" +desc "Build and verify both gems" task :build do - system "gem build featurevisor.gemspec" + sh "gem build featurevisor.gemspec" + sh "gem build featurevisor-openfeature.gemspec" + sh "ruby scripts/verify_gems.rb" end desc "Install the gem locally" task :install => :build do - system "gem install featurevisor-*.gem" + sh "gem install featurevisor-#{Featurevisor::VERSION}.gem" + sh "gem install featurevisor-openfeature-#{Featurevisor::VERSION}.gem" end desc "Clean up built gems" diff --git a/featurevisor-openfeature.gemspec b/featurevisor-openfeature.gemspec new file mode 100644 index 0000000..8a1eee5 --- /dev/null +++ b/featurevisor-openfeature.gemspec @@ -0,0 +1,31 @@ +require_relative "lib/featurevisor/version" + +Gem::Specification.new do |spec| + spec.name = "featurevisor-openfeature" + spec.version = Featurevisor::VERSION + spec.authors = ["Fahad Heylaal"] + spec.summary = "OpenFeature provider for Featurevisor" + spec.description = "OpenFeature provider backed by the Featurevisor Ruby SDK" + spec.homepage = "https://featurevisor.com/docs/sdks/openfeature/" + spec.license = "MIT" + spec.required_ruby_version = ">= 3.4.0" + + spec.metadata = { + "source_code_uri" => "https://github.com/featurevisor/featurevisor-ruby", + "documentation_uri" => "https://featurevisor.com/docs/sdks/ruby/#openfeature", + "bug_tracker_uri" => "https://github.com/featurevisor/featurevisor-ruby/issues", + "allowed_push_host" => "https://rubygems.org", + "rubygems_mfa_required" => "true" + } + + spec.files = %w[ + lib/featurevisor-openfeature.rb + lib/featurevisor/openfeature_provider.rb + README.md + LICENSE + ] + spec.require_paths = ["lib"] + + spec.add_dependency "featurevisor", "= #{Featurevisor::VERSION}" + spec.add_dependency "openfeature-sdk", "~> 0.6.5" +end diff --git a/featurevisor.gemspec b/featurevisor.gemspec index 5a2ff14..a70e316 100644 --- a/featurevisor.gemspec +++ b/featurevisor.gemspec @@ -10,13 +10,22 @@ Gem::Specification.new do |spec| spec.license = "MIT" spec.required_ruby_version = ">= 3.0.0" - spec.files = Dir.glob("lib/**/*") + Dir.glob("bin/**/*") + %w[README.md LICENSE] + spec.metadata = { + "source_code_uri" => "https://github.com/featurevisor/featurevisor-ruby", + "documentation_uri" => "https://featurevisor.com/docs/sdks/ruby/", + "bug_tracker_uri" => "https://github.com/featurevisor/featurevisor-ruby/issues", + "allowed_push_host" => "https://rubygems.org", + "rubygems_mfa_required" => "true" + } + + spec.files = (Dir.glob("lib/**/*") + Dir.glob("bin/**/*") + %w[README.md LICENSE]).reject do |file| + file == "lib/featurevisor/openfeature_provider.rb" || file == "lib/featurevisor-openfeature.rb" + end spec.bindir = "bin" spec.executables = ["featurevisor"] spec.require_paths = ["lib"] - spec.add_dependency "benchmark", ">= 0" + spec.add_dependency "benchmark", ">= 0", "< 1" spec.add_development_dependency "rspec", "~> 3.12" spec.add_development_dependency "rake", "~> 13.0" - spec.add_development_dependency "openfeature-sdk", "~> 0.6.5" end diff --git a/gemfiles/base.gemfile b/gemfiles/base.gemfile new file mode 100644 index 0000000..981adab --- /dev/null +++ b/gemfiles/base.gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec path: "..", name: "featurevisor" diff --git a/gemfiles/base.gemfile.lock b/gemfiles/base.gemfile.lock new file mode 100644 index 0000000..3b68b4d --- /dev/null +++ b/gemfiles/base.gemfile.lock @@ -0,0 +1,37 @@ +PATH + remote: .. + specs: + featurevisor (1.1.0) + benchmark (>= 0, < 1) + +GEM + remote: https://rubygems.org/ + specs: + benchmark (0.5.0) + diff-lcs (1.6.2) + rake (13.4.2) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + featurevisor! + rake (~> 13.0) + rspec (~> 3.12) + +BUNDLED WITH + 2.6.9 diff --git a/lib/featurevisor-openfeature.rb b/lib/featurevisor-openfeature.rb new file mode 100644 index 0000000..72c04df --- /dev/null +++ b/lib/featurevisor-openfeature.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require "featurevisor/openfeature_provider" diff --git a/lib/featurevisor/openfeature_provider.rb b/lib/featurevisor/openfeature_provider.rb index ab04195..22bccbe 100644 --- a/lib/featurevisor/openfeature_provider.rb +++ b/lib/featurevisor/openfeature_provider.rb @@ -11,31 +11,38 @@ class OpenFeatureProvider attr_reader :metadata, :featurevisor - def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", on_track: nil) + def initialize(options = {}, featurevisor: nil, targeting_key_field: "userId", key_separator: ":", variation_key: "variation", on_track: nil, **featurevisor_options) + raise ArgumentError, "options must be a Hash" unless options.is_a?(Hash) + @metadata = Provider::ProviderMetadata.new(name: "Featurevisor").freeze @targeting_key_field = targeting_key_field.empty? ? "userId" : targeting_key_field @key_separator = key_separator.empty? ? ":" : key_separator @variation_key = variation_key.empty? ? "variation" : variation_key @on_track = on_track @datafile_error = nil + @shutdown = false @owns_featurevisor = featurevisor.nil? if featurevisor @featurevisor = featurevisor else - resolved_options = options.dup - original_handler = resolved_options[:onDiagnostic] || resolved_options[:on_diagnostic] - resolved_options[:onDiagnostic] = lambda do |diagnostic| - @datafile_error = diagnostic[:message] if diagnostic[:code] == "invalid_datafile" - @datafile_error = nil if diagnostic[:code] == "datafile_set" - original_handler&.call(diagnostic) - end + resolved_options = options.merge(featurevisor_options) + initial_datafile = resolved_options.delete(:datafile) @featurevisor = Featurevisor.create_featurevisor(resolved_options) end + @error_unsubscribe = @featurevisor.on("error", lambda do |event| + diagnostic = event[:diagnostic] + @datafile_error = diagnostic[:message] if diagnostic&.dig(:code) == "invalid_datafile" + end) @datafile_unsubscribe = @featurevisor.on("datafile_set", ->(*) { @datafile_error = nil }) + @featurevisor.set_datafile(initial_datafile, true) if @owns_featurevisor && initial_datafile end def init(_evaluation_context = nil); end def shutdown + return if @shutdown + + @shutdown = true + @error_unsubscribe&.call @datafile_unsubscribe&.call featurevisor.close if @owns_featurevisor end @@ -57,8 +64,13 @@ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) resolve(flag_key, default_value, evaluation_context, :number) end - alias fetch_integer_value fetch_number_value - alias fetch_float_value fetch_number_value + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, evaluation_context, :integer) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, evaluation_context, :float) + end def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) resolve(flag_key, default_value, evaluation_context, :object) @@ -161,12 +173,18 @@ def matches?(value, type) case type when :boolean then value == true || value == false when :string then value.is_a?(String) - when :number then value.is_a?(Numeric) && value.finite? + when :number then finite_number?(value) + when :integer then value.is_a?(Integer) + when :float then value.is_a?(Float) && value.finite? when :object then value.is_a?(Hash) || value.is_a?(Array) else false end end + def finite_number?(value) + value.is_a?(Numeric) && !value.is_a?(Complex) && (!value.respond_to?(:finite?) || value.finite?) + end + def normalize(value) case value when Time, DateTime then value.iso8601 diff --git a/lib/featurevisor/version.rb b/lib/featurevisor/version.rb index 90d5e0f..722ba7e 100644 --- a/lib/featurevisor/version.rb +++ b/lib/featurevisor/version.rb @@ -1,3 +1,3 @@ module Featurevisor - VERSION = "1.0.0" + VERSION = "1.1.0" end diff --git a/scripts/verify_gems.rb b/scripts/verify_gems.rb new file mode 100644 index 0000000..523fa5b --- /dev/null +++ b/scripts/verify_gems.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rubygems/package" +require_relative "../lib/featurevisor/version" + +version = Featurevisor::VERSION +base_path = "featurevisor-#{version}.gem" +provider_path = "featurevisor-openfeature-#{version}.gem" + +def inspect_gem(path) + raise "Missing built gem: #{path}" unless File.file?(path) + + package = Gem::Package.new(path) + [package.spec, package.contents] +end + +base, base_files = inspect_gem(base_path) +provider, provider_files = inspect_gem(provider_path) + +raise "Base gem version mismatch" unless base.version.to_s == version +raise "Provider gem version mismatch" unless provider.version.to_s == version +raise "Base gem Ruby requirement changed" unless base.required_ruby_version == Gem::Requirement.new(">= 3.0.0") +raise "Provider must require Ruby 3.4 or newer" unless provider.required_ruby_version == Gem::Requirement.new(">= 3.4.0") +raise "Base gem is missing its entry point" unless base_files.include?("lib/featurevisor.rb") +raise "Base gem is missing its CLI" unless base_files.include?("bin/featurevisor") +raise "Provider leaked into base gem" if base_files.include?("lib/featurevisor/openfeature_provider.rb") +raise "Provider entry point leaked into base gem" if base_files.include?("lib/featurevisor-openfeature.rb") +raise "Base gem depends on OpenFeature" if base.dependencies.any? { |dependency| dependency.name == "openfeature-sdk" } + +expected_provider_files = %w[lib/featurevisor-openfeature.rb lib/featurevisor/openfeature_provider.rb] +missing_provider_files = expected_provider_files - provider_files +raise "Provider gem is missing: #{missing_provider_files.join(', ')}" unless missing_provider_files.empty? + +featurevisor_dependency = provider.dependencies.find { |dependency| dependency.name == "featurevisor" } +openfeature_dependency = provider.dependencies.find { |dependency| dependency.name == "openfeature-sdk" } +raise "Provider must depend on matching Featurevisor version" unless featurevisor_dependency&.requirement == Gem::Requirement.new("= #{version}") +raise "Provider must depend on openfeature-sdk ~> 0.6.5" unless openfeature_dependency&.requirement == Gem::Requirement.new("~> 0.6.5") + +puts "Verified #{base_path} and #{provider_path}" diff --git a/spec/openfeature_provider_spec.rb b/spec/openfeature_provider_spec.rb index baad944..bbfe275 100644 --- a/spec/openfeature_provider_spec.rb +++ b/spec/openfeature_provider_spec.rb @@ -3,15 +3,16 @@ RSpec.describe Featurevisor::OpenFeatureProvider do let(:datafile) do { - schemaVersion: "2", revision: "openfeature-test", segments: {}, + schemaVersion: "2", revision: "openfeature-test", featurevisorVersion: "3.0.1", segments: {}, features: { "checkout" => { bucketBy: "userId", - variations: [{ value: "on", variables: { title: "Hello", count: 3, ratio: 1.5, visible: true, items: ["a"], config: { color: "blue" }, json: '{"nested":true}' } }], + variations: [{ value: "on", variables: { title: "Hello", count: 3, ratio: 1.5, visible: true, items: ["a"], config: { color: "blue" }, json: '{"nested":true}', invalidJson: "not-json" } }], variablesSchema: { title: { type: "string", defaultValue: "Default" }, count: { type: "integer", defaultValue: 0 }, ratio: { type: "double", defaultValue: 0 }, visible: { type: "boolean", defaultValue: false }, - items: { type: "array", defaultValue: [] }, config: { type: "object", defaultValue: {} }, json: { type: "json", defaultValue: "{}" } + items: { type: "array", defaultValue: [] }, config: { type: "object", defaultValue: {} }, json: { type: "json", defaultValue: "{}" }, + invalidJson: { type: "json", defaultValue: "{}" } }, force: [{ conditions: { attribute: "userId", operator: "equals", value: "forced-user" }, enabled: true, variation: "on" }], traffic: [{ key: "all", segments: "*", percentage: 100_000, variation: "on" }] @@ -21,7 +22,14 @@ end def provider(**options) - described_class.new({ datafile: datafile, logLevel: "fatal" }, **options) + described_class.new({ datafile: datafile, log_level: "fatal" }, **options) + end + + it "accepts Featurevisor options as documented keyword arguments" do + instance = described_class.new(datafile: datafile, log_level: "fatal") + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true) + ensure + instance&.shutdown end it "resolves every OpenFeature type and maps targeting key" do @@ -62,7 +70,7 @@ def provider(**options) end it "returns a parse error for malformed datafiles" do - instance = described_class.new({ datafile: "{", logLevel: "fatal" }) + instance = described_class.new(datafile: "{", log_level: "fatal") result = instance.fetch_boolean_value(flag_key: "checkout", default_value: false) expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) expect(result.error_message).to eq("Could not parse datafile") @@ -70,20 +78,138 @@ def provider(**options) expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true) end + it "uses the exact OpenFeature number resolver types" do + instance = provider + + expect(instance.fetch_number_value(flag_key: "checkout:count", default_value: 0).value).to eq(3) + expect(instance.fetch_integer_value(flag_key: "checkout:count", default_value: 0).value).to eq(3) + expect(instance.fetch_float_value(flag_key: "checkout:ratio", default_value: 0.0).value).to eq(1.5) + expect(instance.fetch_integer_value(flag_key: "checkout:ratio", default_value: 0).error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + expect(instance.fetch_float_value(flag_key: "checkout:count", default_value: 0.0).error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + ensure + instance&.shutdown + end + + it "rejects wrong types and malformed JSON variables" do + instance = provider + + expect(instance.fetch_string_value(flag_key: "checkout", default_value: "fallback").error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + expect(instance.fetch_boolean_value(flag_key: "checkout:title", default_value: false).error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + expect(instance.fetch_object_value(flag_key: "checkout:invalidJson", default_value: {}).error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + ensure + instance&.shutdown + end + + it "normalizes custom fields, dates, arrays, and nested context without mutation" do + captured = [] + original_time = Time.utc(2026, 1, 2, 3, 4, 5) + fields = { createdAt: original_time, nested: { dates: [original_time] } } + instance = described_class.new( + datafile: datafile, + log_level: "fatal", + targeting_key_field: "accountId", + modules: [{ name: "capture", before: ->(options) { captured << options[:context]; options } }] + ) + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "subject", **fields) + + instance.fetch_boolean_value(flag_key: "checkout", default_value: false, evaluation_context: context) + expect(captured.first).to include("accountId" => "subject", "createdAt" => "2026-01-02T03:04:05Z") + expect(captured.first.dig("nested", "dates")).to eq(["2026-01-02T03:04:05Z"]) + expect(fields[:createdAt]).to equal(original_time) + ensure + instance&.shutdown + end + + it "returns stable Featurevisor metadata and the selected variant" do + instance = provider + result = instance.fetch_string_value(flag_key: "checkout:variation", default_value: "fallback") + + expect(result.variant).to eq("on") + expect(result.flag_metadata).to include( + "featureKey" => "checkout", + "featurevisorReason" => "rule", + "revision" => "openfeature-test", + "schemaVersion" => "2" + ) + expect(result.flag_metadata).to include("bucketKey", "bucketValue") + ensure + instance&.shutdown + end + + { + "required" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + "forced" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + "sticky" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + "rule" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + "variable_override_variation" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + "variable_override_rule" => OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + "allocated" => OpenFeature::SDK::Provider::Reason::SPLIT, + "disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, + "variation_disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, + "variable_disabled" => OpenFeature::SDK::Provider::Reason::DISABLED, + "out_of_range" => OpenFeature::SDK::Provider::Reason::DEFAULT, + "no_match" => OpenFeature::SDK::Provider::Reason::DEFAULT, + "variable_default" => OpenFeature::SDK::Provider::Reason::DEFAULT + }.each do |featurevisor_reason, openfeature_reason| + it "maps #{featurevisor_reason} to the expected OpenFeature reason" do + featurevisor = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + allow(featurevisor).to receive(:evaluate_flag).and_return( + type: "flag", feature_key: "checkout", reason: featurevisor_reason, enabled: true + ) + instance = described_class.new(featurevisor: featurevisor) + + result = instance.fetch_boolean_value(flag_key: "checkout", default_value: false) + expect(result.reason).to eq(openfeature_reason) + expect(result.error_code).to be_nil + ensure + instance&.shutdown + featurevisor&.close + end + end + + it "maps general evaluation errors" do + featurevisor = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + allow(featurevisor).to receive(:evaluate_flag).and_return( + type: "flag", feature_key: "checkout", reason: "error", error: StandardError.new("Evaluation failed") + ) + instance = described_class.new(featurevisor: featurevisor) + + result = instance.fetch_boolean_value(flag_key: "checkout", default_value: false) + expect(result.value).to be(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(result.error_message).to eq("Evaluation failed") + ensure + instance&.shutdown + featurevisor&.close + end + it "borrows an existing Featurevisor instance" do closed = false featurevisor = Featurevisor.create_featurevisor( datafile: datafile, - logLevel: "fatal", + log_level: "fatal", modules: [{ name: "owner", close: -> { closed = true } }] ) instance = described_class.new(featurevisor: featurevisor) expect(instance.featurevisor).to equal(featurevisor) instance.shutdown + instance.shutdown expect(closed).to be(false) featurevisor.close expect(closed).to be(true) end + + it "prefers a borrowed instance over construction options" do + featurevisor = Featurevisor.create_featurevisor(datafile: datafile, log_level: "fatal") + instance = described_class.new(datafile: "{", featurevisor: featurevisor) + + expect(instance.featurevisor).to equal(featurevisor) + expect(instance.fetch_boolean_value(flag_key: "checkout", default_value: false).value).to be(true) + ensure + instance&.shutdown + featurevisor&.close + end end From 1e1d42b5191bf0814e7a25cf8e4e8f52c3e6ca65 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Wed, 22 Jul 2026 19:48:39 +0200 Subject: [PATCH 3/3] bundler issue --- gemfiles/base.gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gemfiles/base.gemfile.lock b/gemfiles/base.gemfile.lock index 3b68b4d..711ad38 100644 --- a/gemfiles/base.gemfile.lock +++ b/gemfiles/base.gemfile.lock @@ -34,4 +34,4 @@ DEPENDENCIES rspec (~> 3.12) BUNDLED WITH - 2.6.9 + 2.4.22