diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index d3531eb..158095b 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -10,12 +10,14 @@ permissions: contents: read jobs: - test: + unit-tests: + name: Unit Tests (Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - ruby-version: [ '3.2.0', '3.3.0', '3.4.1' ] + ruby-version: [ '3.4', '4.0' ] env: DEVCYCLE_SERVER_SDK_KEY: dvc_server_token_hash steps: @@ -27,3 +29,15 @@ jobs: bundler-cache: true # runs 'bundle install' and caches installed gems automatically - name: Run tests run: bundle exec rake + + unit-tests-required: + name: Unit Tests + if: always() + needs: unit-tests + runs-on: ubuntu-latest + steps: + - name: Verify all Ruby versions passed + if: needs.unit-tests.result != 'success' + run: exit 1 + - name: Report success + run: echo "All Ruby version tests passed" diff --git a/Gemfile b/Gemfile index d666cbc..e3c75d3 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,7 @@ gem 'wasmtime' gem 'concurrent-ruby' gem 'google-protobuf' gem 'ld-eventsource' -gem "openfeature-sdk", "~> 0.4.0" +gem "openfeature-sdk", "~> 0.6.5" group :development, :test do gem 'sorbet' diff --git a/devcycle-ruby-server-sdk.gemspec b/devcycle-ruby-server-sdk.gemspec index f6fe177..fb5de19 100644 --- a/devcycle-ruby-server-sdk.gemspec +++ b/devcycle-ruby-server-sdk.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |s| s.summary = "DevCycle Bucketing API Ruby Gem" s.description = "DevCycle Ruby Server SDK, for interacting with feature flags created with the DevCycle platform." s.license = "MIT" - s.required_ruby_version = ">= 3.2" + s.required_ruby_version = ">= 3.4" s.add_runtime_dependency 'typhoeus', '~> 1.0', '>= 1.0.1' s.add_runtime_dependency 'wasmtime', '39.0.1' @@ -30,7 +30,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'oj', '~> 3.0' s.add_runtime_dependency 'google-protobuf', '>= 3.22', '< 5.0' s.add_runtime_dependency 'ld-eventsource', '~> 2.2.3' - s.add_runtime_dependency 'openfeature-sdk', '~> 0.4.1' + s.add_runtime_dependency 'openfeature-sdk', '~> 0.6.5' s.add_development_dependency 'rspec', '~> 3.6', '>= 3.6.0' diff --git a/lib/devcycle-ruby-server-sdk/api/dev_cycle_provider.rb b/lib/devcycle-ruby-server-sdk/api/dev_cycle_provider.rb index 7a921a4..2b59789 100644 --- a/lib/devcycle-ruby-server-sdk/api/dev_cycle_provider.rb +++ b/lib/devcycle-ruby-server-sdk/api/dev_cycle_provider.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'open_feature/sdk' + module DevCycle class Provider attr_reader :client @@ -20,36 +22,27 @@ def shutdown def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) # Retrieve a boolean value from provider source - @client.variable(Provider.user_from_openfeature_context(evaluation_context), flag_key, default_value) + resolve(flag_key, default_value, evaluation_context) end def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) - @client.variable(Provider.user_from_openfeature_context(evaluation_context), flag_key, default_value) + resolve(flag_key, default_value, evaluation_context) end def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) - @client.variable(Provider.user_from_openfeature_context(evaluation_context), flag_key, default_value) + resolve(flag_key, default_value, evaluation_context) end def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) - variable = @client.variable(Provider.user_from_openfeature_context(evaluation_context), flag_key, default_value) - - Variable.new( - key: variable.key, - type: variable.type, - value: variable.value.to_i, - defaultValue: variable.defaultValue, - isDefaulted: variable.isDefaulted, - eval: variable.eval - ) + resolve(flag_key, default_value, evaluation_context) { |value| value.to_i } end def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) - @client.variable(Provider.user_from_openfeature_context(evaluation_context), flag_key, default_value) + resolve(flag_key, default_value, evaluation_context) end def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) - @client.variable(Provider.user_from_openfeature_context(evaluation_context), flag_key, default_value) + resolve(flag_key, default_value, evaluation_context) end def self.user_from_openfeature_context(context) @@ -133,5 +126,52 @@ def self.user_from_openfeature_context(context) args.merge!(privateCustomData: privateCustomData) User.new(**args) end + + # Maps a DevCycle evaluation reason onto an OpenFeature reason. + def self.openfeature_reason(variable) + reason = eval_field(variable, :reason) + return reason if reason.is_a?(String) && !reason.empty? + + if variable.isDefaulted + OpenFeature::SDK::Provider::Reason::DEFAULT + else + OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + end + end + + # Surfaces DevCycle's evaluation details as OpenFeature flag metadata. + def self.flag_metadata(variable) + metadata = {} + details = eval_field(variable, :details) + target_id = eval_field(variable, :target_id) + metadata[:details] = details unless details.nil? + metadata[:target_id] = target_id unless target_id.nil? + metadata + end + + def self.eval_field(variable, key) + eval_details = variable.eval + return nil unless eval_details.is_a?(Hash) + + eval_details[key] || eval_details[key.to_s] + end + + private + + # Evaluates a variable and wraps the result in the ResolutionDetails + # structure that the OpenFeature SDK expects back from a provider. + def resolve(flag_key, default_value, evaluation_context) + user = Provider.user_from_openfeature_context(evaluation_context) + variable = @client.variable(user, flag_key, default_value) + + value = variable.value + value = yield(value) if block_given? + + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: value, + reason: Provider.openfeature_reason(variable), + flag_metadata: Provider.flag_metadata(variable) + ) + end end end