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 ef4da6e..617d108 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,15 @@ PATH remote: . specs: - featurevisor (1.0.0) - benchmark + featurevisor (1.1.0) + benchmark (>= 0, < 1) GEM remote: https://rubygems.org/ 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/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 0faed19..af50c17 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) @@ -778,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 ``` @@ -811,30 +812,100 @@ $ bundle exec featurevisor assess-distribution \ --n=1000 ``` +## OpenFeature + +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-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" + +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 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 +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 ### 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 ``` -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). +Build and verify both gems with: + +```bash +$ make build +``` + +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 7a42ea8..a70e316 100644 --- a/featurevisor.gemspec +++ b/featurevisor.gemspec @@ -10,12 +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" 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..711ad38 --- /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.4.22 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 new file mode 100644 index 0000000..22bccbe --- /dev/null +++ b/lib/featurevisor/openfeature_provider.rb @@ -0,0 +1,205 @@ +# 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, **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.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 + 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 + + 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) + 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 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 + 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/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 new file mode 100644 index 0000000..bbfe275 --- /dev/null +++ b/spec/openfeature_provider_spec.rb @@ -0,0 +1,215 @@ +require "featurevisor/openfeature_provider" + +RSpec.describe Featurevisor::OpenFeatureProvider do + let(:datafile) do + { + 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}', 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: "{}" }, + 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" }] + } + } + } + end + + def provider(**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 + 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: "{", 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") + instance.featurevisor.set_datafile(datafile, true) + 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, + 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