From 681d898580b6246fd70908fb7c533813b2f02ad7 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:05:08 -0500 Subject: [PATCH 01/31] chore(main): release openfeature-meta_provider 0.0.5 (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [0.0.5](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-meta_provider/v0.0.4...openfeature-meta_provider/v0.0.5) (2025-03-03) ### 🐛 Bug Fixes * don't init if provider doesn't have an init method ([#49](https://github.com/open-feature/ruby-sdk-contrib/issues/49)) ([d2479ed](https://github.com/open-feature/ruby-sdk-contrib/commit/d2479edb27234b201453bb5fad12cc56c6c750de)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: wadii --- .release-please-manifest.json | 2 +- providers/openfeature-meta_provider/CHANGELOG.md | 7 +++++++ providers/openfeature-meta_provider/Gemfile.lock | 2 +- .../lib/openfeature/meta_provider_version.rb | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1d97a44..87843de 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { "providers/openfeature-flagd-provider": "0.1.2", - "providers/openfeature-meta_provider": "0.0.4", + "providers/openfeature-meta_provider": "0.0.5", "providers/openfeature-go-feature-flag-provider": "0.1.3" } diff --git a/providers/openfeature-meta_provider/CHANGELOG.md b/providers/openfeature-meta_provider/CHANGELOG.md index aa960dd..44741ff 100644 --- a/providers/openfeature-meta_provider/CHANGELOG.md +++ b/providers/openfeature-meta_provider/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.0.5](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-meta_provider/v0.0.4...openfeature-meta_provider/v0.0.5) (2025-03-03) + + +### 🐛 Bug Fixes + +* don't init if provider doesn't have an init method ([#49](https://github.com/open-feature/ruby-sdk-contrib/issues/49)) ([d2479ed](https://github.com/open-feature/ruby-sdk-contrib/commit/d2479edb27234b201453bb5fad12cc56c6c750de)) + ## [0.0.4](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-meta_provider/v0.0.3...openfeature-meta_provider/v0.0.4) (2025-02-28) diff --git a/providers/openfeature-meta_provider/Gemfile.lock b/providers/openfeature-meta_provider/Gemfile.lock index e514765..3407e68 100644 --- a/providers/openfeature-meta_provider/Gemfile.lock +++ b/providers/openfeature-meta_provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-meta_provider (0.0.4) + openfeature-meta_provider (0.0.5) openfeature-sdk (>= 0.3.0, <= 0.4) GEM diff --git a/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb b/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb index 4510b60..40e3320 100644 --- a/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb +++ b/providers/openfeature-meta_provider/lib/openfeature/meta_provider_version.rb @@ -1,3 +1,3 @@ module OpenFeature - META_PROVIDER_VERSION = "0.0.4" + META_PROVIDER_VERSION = "0.0.5" end From 665500f2f6e8901c49bb52788be86129571acd2f Mon Sep 17 00:00:00 2001 From: Firdaus Al Ghifari <34407426+falghi@users.noreply.github.com> Date: Tue, 15 Apr 2025 01:45:39 +0900 Subject: [PATCH 02/31] feat: Add Flipt provider gem with basic implementation (#51) Signed-off-by: Firdaus Al Ghifari Signed-off-by: wadii --- .github/workflows/ruby.yml | 24 +- .../openfeature-flipt-provider/.gitignore | 11 + providers/openfeature-flipt-provider/.rspec | 4 + .../openfeature-flipt-provider/.rubocop.yml | 5 + .../openfeature-flipt-provider/.ruby-version | 1 + .../openfeature-flipt-provider/CHANGELOG.md | 0 providers/openfeature-flipt-provider/Gemfile | 6 + .../openfeature-flipt-provider/Gemfile.lock | 108 ++++++++ .../openfeature-flipt-provider/README.md | 57 ++++ providers/openfeature-flipt-provider/Rakefile | 10 + .../openfeature-flipt-provider/bin/console | 11 + providers/openfeature-flipt-provider/bin/rake | 27 ++ .../openfeature-flipt-provider/bin/setup | 8 + .../lib/openfeature/flipt/provider.rb | 167 ++++++++++++ .../lib/openfeature/flipt/version.rb | 7 + .../openfeature-flipt-provider.gemspec | 40 +++ .../spec/openfeature/flipt/provider_spec.rb | 245 ++++++++++++++++++ .../spec/spec_helper.rb | 23 ++ ruby-sdk-contrib.code-workspace | 3 + 19 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 providers/openfeature-flipt-provider/.gitignore create mode 100644 providers/openfeature-flipt-provider/.rspec create mode 100644 providers/openfeature-flipt-provider/.rubocop.yml create mode 100644 providers/openfeature-flipt-provider/.ruby-version create mode 100644 providers/openfeature-flipt-provider/CHANGELOG.md create mode 100644 providers/openfeature-flipt-provider/Gemfile create mode 100644 providers/openfeature-flipt-provider/Gemfile.lock create mode 100644 providers/openfeature-flipt-provider/README.md create mode 100644 providers/openfeature-flipt-provider/Rakefile create mode 100755 providers/openfeature-flipt-provider/bin/console create mode 100755 providers/openfeature-flipt-provider/bin/rake create mode 100755 providers/openfeature-flipt-provider/bin/setup create mode 100644 providers/openfeature-flipt-provider/lib/openfeature/flipt/provider.rb create mode 100644 providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb create mode 100644 providers/openfeature-flipt-provider/openfeature-flipt-provider.gemspec create mode 100644 providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb create mode 100644 providers/openfeature-flipt-provider/spec/spec_helper.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index b66c448..03aeaa4 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -85,4 +85,26 @@ jobs: bundler-cache: true # runs 'bundle install' and caches installed gems automatically working-directory: ./providers/openfeature-go-feature-flag-provider - name: Lint and test - run: bin/rake \ No newline at end of file + run: bin/rake + + test_flipt_provider: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./providers/openfeature-flipt-provider + strategy: + matrix: + ruby-version: + - "3.3" + - "3.2" + - "3.1" + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + working-directory: ./providers/openfeature-flipt-provider + - name: Lint and test + run: bin/rake diff --git a/providers/openfeature-flipt-provider/.gitignore b/providers/openfeature-flipt-provider/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/providers/openfeature-flipt-provider/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/providers/openfeature-flipt-provider/.rspec b/providers/openfeature-flipt-provider/.rspec new file mode 100644 index 0000000..44b132b --- /dev/null +++ b/providers/openfeature-flipt-provider/.rspec @@ -0,0 +1,4 @@ +-I lib +--format documentation +--color +--require spec_helper diff --git a/providers/openfeature-flipt-provider/.rubocop.yml b/providers/openfeature-flipt-provider/.rubocop.yml new file mode 100644 index 0000000..feec135 --- /dev/null +++ b/providers/openfeature-flipt-provider/.rubocop.yml @@ -0,0 +1,5 @@ +inherit_from: ../../shared_config/.rubocop.yml + +inherit_mode: + merge: + - Exclude diff --git a/providers/openfeature-flipt-provider/.ruby-version b/providers/openfeature-flipt-provider/.ruby-version new file mode 100644 index 0000000..fa7adc7 --- /dev/null +++ b/providers/openfeature-flipt-provider/.ruby-version @@ -0,0 +1 @@ +3.3.5 diff --git a/providers/openfeature-flipt-provider/CHANGELOG.md b/providers/openfeature-flipt-provider/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/providers/openfeature-flipt-provider/Gemfile b/providers/openfeature-flipt-provider/Gemfile new file mode 100644 index 0000000..3db0240 --- /dev/null +++ b/providers/openfeature-flipt-provider/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +# Specify your gem's dependencies in openfeature-flipt-provider.gemspec +gemspec diff --git a/providers/openfeature-flipt-provider/Gemfile.lock b/providers/openfeature-flipt-provider/Gemfile.lock new file mode 100644 index 0000000..c28b351 --- /dev/null +++ b/providers/openfeature-flipt-provider/Gemfile.lock @@ -0,0 +1,108 @@ +PATH + remote: . + specs: + openfeature-flipt-provider (0.1.0) + ffi (~> 1.17) + flipt_client (~> 0.10.0) + openfeature-sdk (~> 0.4.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + diff-lcs (1.6.1) + ffi (1.17.1) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-aarch64-linux-musl) + ffi (1.17.1-arm-linux-gnu) + ffi (1.17.1-arm-linux-musl) + ffi (1.17.1-arm64-darwin) + ffi (1.17.1-x86-linux-gnu) + ffi (1.17.1-x86-linux-musl) + ffi (1.17.1-x86_64-darwin) + ffi (1.17.1-x86_64-linux-gnu) + ffi (1.17.1-x86_64-linux-musl) + flipt_client (0.10.0) + json (2.10.2) + language_server-protocol (3.17.0.4) + lint_roller (1.1.0) + openfeature-sdk (0.4.0) + parallel (1.26.3) + parser (3.3.7.3) + ast (~> 2.4.1) + racc + prism (1.4.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.10.0) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.3) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.4) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.2) + rubocop (1.73.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.43.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (1.13.0) + standard (1.47.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.73.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.7) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.7.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.24.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + openfeature-flipt-provider! + rake (~> 13.0) + rspec (~> 3.12.0) + rubocop + standard (>= 1.35.1) + +BUNDLED WITH + 2.5.16 diff --git a/providers/openfeature-flipt-provider/README.md b/providers/openfeature-flipt-provider/README.md new file mode 100644 index 0000000..007450e --- /dev/null +++ b/providers/openfeature-flipt-provider/README.md @@ -0,0 +1,57 @@ +

+ flipt logo +

+ +# Flipt - OpenFeature Ruby provider + +This repository contains the Ruby provider for [Flipt](https://www.flipt.io/), a feature flagging and experimentation platform. + +In conjunction with the [OpenFeature SDK](https://openfeature.dev/docs/reference/concepts/provider/) you can use this provider to integrate Flipt into your Ruby application. + +For documentation on how to use Flipt, please refer to the [Flipt documentation](https://docs.flipt.io/). + +## Installation +Add this line to your application's Gemfile: + +```ruby +gem 'openfeature-flipt-provider' +``` + +And then execute: +```bash +$ bundle +``` + +## Usage +To use the Flipt provider, you need to create an instance of the provider and pass it to the OpenFeature SDK. + +```ruby +require "open_feature/sdk" +require "openfeature/flipt/provider" + +OpenFeature::SDK.configure do |config| + config.set_provider( + OpenFeature::Flipt::Provider.new( + namespace: "flipt-namespace", + options: { + url: "https://url-to-flipt-server", + update_interval: 60, + authentication: "token" + } + ) + ) +end +client = OpenFeature::SDK.build_client + +# Check if a feature is enabled +if client.fetch_boolean_value(flag_key: "featureEnabled", default_value: false) + puts "Feature is enabled" +else + puts "Feature is disabled" +end +``` + +For a complete list of configuration options, such as authentication and error strategies, refer to the [Flipt Client Ruby SDK documentation](https://github.com/flipt-io/flipt-client-sdks/tree/main/flipt-client-ruby#constructor-arguments). + +## Contributing +https://github.com/open-feature/ruby-sdk-contrib diff --git a/providers/openfeature-flipt-provider/Rakefile b/providers/openfeature-flipt-provider/Rakefile new file mode 100644 index 0000000..85f5f4d --- /dev/null +++ b/providers/openfeature-flipt-provider/Rakefile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "standard/rake" + +task default: %i[standard spec] diff --git a/providers/openfeature-flipt-provider/bin/console b/providers/openfeature-flipt-provider/bin/console new file mode 100755 index 0000000..05989c4 --- /dev/null +++ b/providers/openfeature-flipt-provider/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "openfeature/flipt/provider" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/providers/openfeature-flipt-provider/bin/rake b/providers/openfeature-flipt-provider/bin/rake new file mode 100755 index 0000000..4eb7d7b --- /dev/null +++ b/providers/openfeature-flipt-provider/bin/rake @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rake' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rake", "rake") diff --git a/providers/openfeature-flipt-provider/bin/setup b/providers/openfeature-flipt-provider/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/providers/openfeature-flipt-provider/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/providers/openfeature-flipt-provider/lib/openfeature/flipt/provider.rb b/providers/openfeature-flipt-provider/lib/openfeature/flipt/provider.rb new file mode 100644 index 0000000..bdc787e --- /dev/null +++ b/providers/openfeature-flipt-provider/lib/openfeature/flipt/provider.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "flipt_client" +require "open_feature/sdk" + +module OpenFeature + module Flipt + class Provider + PROVIDER_NAME = "Flipt Provider" + + # @param namespace [String] Namespace to use when fetching flags. + # @param options [Hash] Options to pass to the Flipt client. + def initialize(namespace: "default", options: {}) + @client = ::Flipt::EvaluationClient.new(namespace, options) + end + + def metadata + @_metadata ||= SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME).freeze + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + fetch_value( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + evaluation_method: :evaluate_boolean, + result_key: "enabled" + ) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + fetch_value( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + evaluation_method: :evaluate_variant, + result_key: "variant_key" + ) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + fetch_numeric_value(flag_key:, default_value:, evaluation_context:, allowed_types: [Numeric]) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + fetch_numeric_value(flag_key:, default_value:, evaluation_context:, allowed_types: [Integer]) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + fetch_numeric_value(flag_key:, default_value:, evaluation_context:, allowed_types: [Float]) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + result = fetch_value( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + evaluation_method: :evaluate_variant, + result_key: "variant_key" + ) + + unless result.value.is_a?(Hash) + begin + result.value = JSON.parse(result.value) + rescue JSON::ParserError, TypeError + return OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_message: "Could not parse '#{result.value}' as JSON", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + end + end + result + end + + private + + def fetch_value(flag_key:, default_value:, evaluation_context:, evaluation_method:, result_key:) + transformed_eval_context = transform_context(evaluation_context) + + begin + response = @client.send(evaluation_method, { + flag_key: flag_key, + entity_id: evaluation_context&.fetch("targeting_key", nil) || "default", + context: transformed_eval_context + }) + + if %w[FLAG_DISABLED_EVALUATION_REASON].include?(response["result"]["reason"]) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::DISABLED + ) + elsif %w[DEFAULT_EVALUATION_REASON MATCH_EVALUATION_REASON].include?(response["result"]["reason"]) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: response["result"][result_key], + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + ) + elsif %w[UNKNOWN_EVALUATION_REASON].include?(response["result"]["reason"]) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::UNKNOWN + ) + else + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::DEFAULT + ) + end + rescue => e + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_message: e.message, + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + end + end + + def fetch_numeric_value(flag_key:, default_value:, evaluation_context:, allowed_types:) + result = fetch_value( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + evaluation_method: :evaluate_variant, + result_key: "variant_key" + ) + + unless result.value.is_a?(Numeric) + begin + parsed_value = Float(result.value) + # Only convert to integer if it's a whole number and allowed_types is [Integer] + result.value = if allowed_types == [Integer] && parsed_value.to_i == parsed_value + parsed_value.to_i + else + parsed_value + end + rescue ArgumentError, TypeError + return OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_message: "Could not convert '#{result.value}' to #{allowed_types.first.name.downcase}", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + end + end + + unless allowed_types.any? { |type| result.value.is_a?(type) } + return OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_message: "Value '#{result.value}' is not a #{allowed_types.first.name.downcase}", + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + end + + result + end + + def transform_context(context) + eval_context = {} + context&.each do |key, value| + next if key == "targeting_key" + + eval_context[key] = value.to_s + end + eval_context + end + end + end +end diff --git a/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb b/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb new file mode 100644 index 0000000..759d0a8 --- /dev/null +++ b/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module OpenFeature + module Flipt + VERSION = "0.1.0" + end +end diff --git a/providers/openfeature-flipt-provider/openfeature-flipt-provider.gemspec b/providers/openfeature-flipt-provider/openfeature-flipt-provider.gemspec new file mode 100644 index 0000000..e111e14 --- /dev/null +++ b/providers/openfeature-flipt-provider/openfeature-flipt-provider.gemspec @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "lib/openfeature/flipt/version" + +Gem::Specification.new do |spec| + spec.name = "openfeature-flipt-provider" + spec.version = OpenFeature::Flipt::VERSION + spec.authors = ["Firdaus Al Ghifari"] + spec.email = ["firdaus.alghifari@gmail.com"] + + spec.summary = "OpenFeature Flipt Provider for Ruby" + spec.description = "OpenFeature Flipt Provider for Ruby" + spec.homepage = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-flipt-provider" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.1" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-flipt-provider" + spec.metadata["changelog_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/blob/main/providers/openfeature-flipt-provider/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/issues" + spec.metadata["documentation_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-flipt-provider/README.md" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "ffi", "~> 1.17" + spec.add_dependency "openfeature-sdk", "~> 0.4.0" + spec.add_dependency "flipt_client", "~> 0.10.0" + + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.12.0" + spec.add_development_dependency "standard", ">= 1.35.1" + spec.add_development_dependency "rubocop" +end diff --git a/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb b/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb new file mode 100644 index 0000000..4ca3234 --- /dev/null +++ b/providers/openfeature-flipt-provider/spec/openfeature/flipt/provider_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe OpenFeature::Flipt::Provider do + let(:provider) { described_class.new(namespace: "test-namespace") } + let(:client_stub) { double(::Flipt::EvaluationClient) } + let(:evaluation_context) { {"targeting_key" => "user123", "some_key" => "some_value"} } + + before do + allow(::Flipt::EvaluationClient).to receive(:new).with("test-namespace", {}).and_return(client_stub) + end + + context "2.1 - Feature Provider Interface" do + describe "#metadata" do + it "returns a name field which identifies the provider implementation" do + expect(provider.metadata.name).to eq("Flipt Provider") + end + end + end + + context "2.2 - Flag Value Resolution" do + describe "#fetch_boolean_value" do + it "returns the correct resolution details for a matching evaluation" do + response = { + "status" => "success", + "result" => {"enabled" => true, "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_boolean).and_return(response) + + result = provider.fetch_boolean_value(flag_key: "test_flag", default_value: false, evaluation_context: evaluation_context) + expect(result.value).to eq(true) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "returns the default value for an unknown evaluation reason" do + response = { + "status" => "success", + "result" => {"reason" => "UNKNOWN_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_boolean).and_return(response) + + result = provider.fetch_boolean_value(flag_key: "test_flag", default_value: false, evaluation_context: evaluation_context) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::UNKNOWN) + end + + it "returns the default value for a flag disabled evaluation reason" do + response = { + "status" => "success", + "result" => {"reason" => "FLAG_DISABLED_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_boolean).and_return(response) + + result = provider.fetch_boolean_value(flag_key: "test_flag", default_value: false, evaluation_context: evaluation_context) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DISABLED) + end + + it "returns the default value for an empty result" do + response = { + "status" => "failed", + "result" => {} + } + allow(client_stub).to receive(:evaluate_boolean).and_return(response) + + result = provider.fetch_boolean_value(flag_key: "test_flag", default_value: false, evaluation_context: evaluation_context) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + end + + it "returns the default value and error message on exception" do + allow(client_stub).to receive(:evaluate_boolean).and_raise(StandardError.new("Some error")) + + result = provider.fetch_boolean_value(flag_key: "test_flag", default_value: false, evaluation_context: evaluation_context) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_message).to eq("Some error") + end + end + + describe "#fetch_string_value" do + it "returns the correct resolution details for a matching evaluation" do + response = { + "status" => "success", + "result" => {"variant_key" => "variant1", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_string_value(flag_key: "test_flag", default_value: "default", evaluation_context: evaluation_context) + expect(result.value).to eq("variant1") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "returns the default value for an unknown evaluation reason" do + response = { + "status" => "success", + "result" => {"reason" => "UNKNOWN_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_string_value(flag_key: "test_flag", default_value: "default", evaluation_context: evaluation_context) + expect(result.value).to eq("default") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::UNKNOWN) + end + + it "returns the default value for a flag disabled evaluation reason" do + response = { + "status" => "success", + "result" => {"reason" => "FLAG_DISABLED_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_string_value(flag_key: "test_flag", default_value: "default", evaluation_context: evaluation_context) + expect(result.value).to eq("default") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DISABLED) + end + + it "returns the default value and error message on exception" do + allow(client_stub).to receive(:evaluate_variant).and_raise(StandardError.new("Some error")) + + result = provider.fetch_string_value(flag_key: "test_flag", default_value: "default", evaluation_context: evaluation_context) + expect(result.value).to eq("default") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_message).to eq("Some error") + end + end + + describe "#fetch_number_value" do + it "returns the correct numeric value for a matching evaluation" do + response = { + "status" => "success", + "result" => {"variant_key" => "42.5", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_number_value(flag_key: "test_flag", default_value: 0, evaluation_context: evaluation_context) + expect(result.value).to eq(42.5) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "returns error when value cannot be converted to number" do + response = { + "status" => "success", + "result" => {"variant_key" => "not_a_number", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_number_value(flag_key: "test_flag", default_value: 0, evaluation_context: evaluation_context) + expect(result.value).to eq(0) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + end + + describe "#fetch_integer_value" do + it "returns the correct integer value for a matching evaluation" do + response = { + "status" => "success", + "result" => {"variant_key" => "42", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_integer_value(flag_key: "test_flag", default_value: 0, evaluation_context: evaluation_context) + expect(result.value).to eq(42) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "returns error when value cannot be converted to integer" do + response = { + "status" => "success", + "result" => {"variant_key" => "42.5", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_integer_value(flag_key: "test_flag", default_value: 0, evaluation_context: evaluation_context) + expect(result.value).to eq(0) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + end + + describe "#fetch_float_value" do + it "returns the correct float value for a matching evaluation" do + response = { + "status" => "success", + "result" => {"variant_key" => "42.5", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_float_value(flag_key: "test_flag", default_value: 0.0, evaluation_context: evaluation_context) + expect(result.value).to eq(42.5) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "returns error when value cannot be converted to float" do + response = { + "status" => "success", + "result" => {"variant_key" => "not_a_float", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_float_value(flag_key: "test_flag", default_value: 0.0, evaluation_context: evaluation_context) + expect(result.value).to eq(0.0) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + end + + describe "#fetch_object_value" do + it "returns the correct object value for a matching evaluation" do + response = { + "status" => "success", + "result" => {"variant_key" => '{"key": "value"}', "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_object_value(flag_key: "test_flag", default_value: {}, evaluation_context: evaluation_context) + expect(result.value).to eq({"key" => "value"}) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "returns error when value cannot be parsed as JSON" do + response = { + "status" => "success", + "result" => {"variant_key" => "invalid_json", "reason" => "MATCH_EVALUATION_REASON"} + } + allow(client_stub).to receive(:evaluate_variant).and_return(response) + + result = provider.fetch_object_value(flag_key: "test_flag", default_value: {}, evaluation_context: evaluation_context) + expect(result.value).to eq({}) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + end + end + end + + describe "#transform_context" do + it "transforms the context correctly" do + result = provider.send(:transform_context, evaluation_context) + expect(result).to eq({"some_key" => "some_value"}) + end + + it "returns an empty hash if context is nil" do + result = provider.send(:transform_context, nil) + expect(result).to eq({}) + end + end +end diff --git a/providers/openfeature-flipt-provider/spec/spec_helper.rb b/providers/openfeature-flipt-provider/spec/spec_helper.rb new file mode 100644 index 0000000..5eeee39 --- /dev/null +++ b/providers/openfeature-flipt-provider/spec/spec_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "open_feature/sdk" +require "openfeature/flipt/provider" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = "spec/examples.txt" + config.disable_monkey_patching! + config.warnings = true + config.order = :random + Kernel.srand config.seed +end diff --git a/ruby-sdk-contrib.code-workspace b/ruby-sdk-contrib.code-workspace index 2669c9a..31970d5 100644 --- a/ruby-sdk-contrib.code-workspace +++ b/ruby-sdk-contrib.code-workspace @@ -8,6 +8,9 @@ }, { "path": "providers/openfeature-go-feature-flag-provider" + }, + { + "path": "providers/openfeature-flipt-provider" } ] } From d225d369a726d5ec828271f81266b4254f78bced Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Mon, 14 Apr 2025 12:52:22 -0400 Subject: [PATCH 03/31] chore: add Flipt to Release Please (#52) Signed-off-by: Michael Beemer Signed-off-by: wadii --- .release-please-manifest.json | 1 + release-please-config.json | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 87843de..bd3baa3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,6 @@ { "providers/openfeature-flagd-provider": "0.1.2", + "providers/openfeature-flagd-provider": "0.0.1", "providers/openfeature-meta_provider": "0.0.5", "providers/openfeature-go-feature-flag-provider": "0.1.3" } diff --git a/release-please-config.json b/release-please-config.json index 72c8650..d8cc709 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,6 +12,16 @@ "README.md" ] }, + "providers/openfeature-flipt-provider": { + "package-name": "openfeature-flipt-provider", + "version-file": "lib/openfeature/flipt/provider/version.rb", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] + }, "providers/openfeature-meta_provider": { "package-name": "openfeature-meta_provider", "version-file": "lib/openfeature/meta_provider_version.rb", From 40dcc7732327695c6c3941ee0be2447857f6e2a0 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Mon, 14 Apr 2025 12:54:58 -0400 Subject: [PATCH 04/31] chore: correct release please config for flipt Signed-off-by: Michael Beemer Signed-off-by: wadii --- .release-please-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bd3baa3..a792a33 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,6 +1,6 @@ { "providers/openfeature-flagd-provider": "0.1.2", - "providers/openfeature-flagd-provider": "0.0.1", + "providers/openfeature-flipt-provider": "0.0.1", "providers/openfeature-meta_provider": "0.0.5", "providers/openfeature-go-feature-flag-provider": "0.1.3" } From 456bcf0228773ad66a2e04dbf4e03ec774ec95db Mon Sep 17 00:00:00 2001 From: Firdaus Al Ghifari <34407426+falghi@users.noreply.github.com> Date: Tue, 15 Apr 2025 21:20:33 +0900 Subject: [PATCH 05/31] fix: Update Gemfile.lock on flipt provider (#55) Signed-off-by: Firdaus Al Ghifari Signed-off-by: wadii --- .../openfeature-flipt-provider/Gemfile.lock | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/providers/openfeature-flipt-provider/Gemfile.lock b/providers/openfeature-flipt-provider/Gemfile.lock index c28b351..81d827e 100644 --- a/providers/openfeature-flipt-provider/Gemfile.lock +++ b/providers/openfeature-flipt-provider/Gemfile.lock @@ -27,8 +27,8 @@ GEM language_server-protocol (3.17.0.4) lint_roller (1.1.0) openfeature-sdk (0.4.0) - parallel (1.26.3) - parser (3.3.7.3) + parallel (1.27.0) + parser (3.3.8.0) ast (~> 2.4.1) racc prism (1.4.0) @@ -49,7 +49,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-support (3.12.2) - rubocop (1.73.2) + rubocop (1.75.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -57,29 +57,29 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.43.0) + rubocop-ast (1.44.1) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-performance (1.24.0) + rubocop-performance (1.25.0) lint_roller (~> 1.1) - rubocop (>= 1.72.1, < 2.0) + rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (1.13.0) - standard (1.47.0) + standard (1.49.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.73.0) + rubocop (~> 1.75.2) standard-custom (~> 1.0.0) - standard-performance (~> 1.7) + standard-performance (~> 1.8) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.7.0) + standard-performance (1.8.0) lint_roller (~> 1.1) - rubocop-performance (~> 1.24.0) + rubocop-performance (~> 1.25.0) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) From 7e78ac162a714e6a3385b2e3ea8aeea5a407c88a Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Tue, 15 Apr 2025 20:18:00 -0400 Subject: [PATCH 06/31] chore(main): release openfeature-flipt-provider 0.0.2 (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [0.0.2](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-flipt-provider-v0.0.1...openfeature-flipt-provider/v0.0.2) (2025-04-15) ### 🐛 Bug Fixes * Update Gemfile.lock on flipt provider ([#55](https://github.com/open-feature/ruby-sdk-contrib/issues/55)) ([fd79d7b](https://github.com/open-feature/ruby-sdk-contrib/commit/fd79d7be189367d462c87c06e9a2a65a462c1a91)) ### ✨ New Features * Add Flipt provider gem with basic implementation ([#51](https://github.com/open-feature/ruby-sdk-contrib/issues/51)) ([8e0ae7c](https://github.com/open-feature/ruby-sdk-contrib/commit/8e0ae7ccd24b4c5334bfe6f794737a95ac8d18bb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Firdaus Al Ghifari Co-authored-by: Firdaus Al Ghifari <34407426+falghi@users.noreply.github.com> Signed-off-by: wadii --- .release-please-manifest.json | 2 +- providers/openfeature-flipt-provider/CHANGELOG.md | 13 +++++++++++++ providers/openfeature-flipt-provider/Gemfile.lock | 2 +- .../lib/openfeature/flipt/version.rb | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a792a33..00739ca 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,6 +1,6 @@ { "providers/openfeature-flagd-provider": "0.1.2", - "providers/openfeature-flipt-provider": "0.0.1", + "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", "providers/openfeature-go-feature-flag-provider": "0.1.3" } diff --git a/providers/openfeature-flipt-provider/CHANGELOG.md b/providers/openfeature-flipt-provider/CHANGELOG.md index e69de29..ad0966c 100644 --- a/providers/openfeature-flipt-provider/CHANGELOG.md +++ b/providers/openfeature-flipt-provider/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## [0.0.2](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-flipt-provider-v0.0.1...openfeature-flipt-provider/v0.0.2) (2025-04-15) + + +### 🐛 Bug Fixes + +* Update Gemfile.lock on flipt provider ([#55](https://github.com/open-feature/ruby-sdk-contrib/issues/55)) ([fd79d7b](https://github.com/open-feature/ruby-sdk-contrib/commit/fd79d7be189367d462c87c06e9a2a65a462c1a91)) + + +### ✨ New Features + +* Add Flipt provider gem with basic implementation ([#51](https://github.com/open-feature/ruby-sdk-contrib/issues/51)) ([8e0ae7c](https://github.com/open-feature/ruby-sdk-contrib/commit/8e0ae7ccd24b4c5334bfe6f794737a95ac8d18bb)) diff --git a/providers/openfeature-flipt-provider/Gemfile.lock b/providers/openfeature-flipt-provider/Gemfile.lock index 81d827e..fdbe3ca 100644 --- a/providers/openfeature-flipt-provider/Gemfile.lock +++ b/providers/openfeature-flipt-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-flipt-provider (0.1.0) + openfeature-flipt-provider (0.0.2) ffi (~> 1.17) flipt_client (~> 0.10.0) openfeature-sdk (~> 0.4.0) diff --git a/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb b/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb index 759d0a8..6644632 100644 --- a/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb +++ b/providers/openfeature-flipt-provider/lib/openfeature/flipt/version.rb @@ -2,6 +2,6 @@ module OpenFeature module Flipt - VERSION = "0.1.0" + VERSION = "0.0.2" end end From 5bc1e2b078286cb6c3c5f870fef7166b78f3dac0 Mon Sep 17 00:00:00 2001 From: Augustinas Date: Wed, 30 Jul 2025 17:14:11 +0300 Subject: [PATCH 07/31] feat: connection persistance (#59) ## This PR By default faraday uses net::http which on each request opens new socket and does all the handshaking every time. Persistent adapter keeps connection alive for defined amount of time (30s). fix for `def initialize(options: {})` since it was incorrect on default behaviour, it is expected to get Options object not hash. ### Follow-up Tasks With later PR's I could make this configurable. --------- Signed-off-by: Augustinas Sueris Signed-off-by: Augustinas Co-authored-by: Max VelDink Signed-off-by: wadii --- .gitignore | 5 ++++- .../Gemfile.lock | 20 ++++++++++++------- .../go_feature_flag_provider.rb | 2 +- .../openfeature/go-feature-flag/goff_api.rb | 19 ++++++++++-------- ...enfeature-go-feature-flag-provider.gemspec | 2 +- .../gofeatureflag/goff_api_spec.rb | 3 +-- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 74d9d73..ece0b0a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ /test/version_tmp/ /tmp/ +#rspec +**/spec/examples.txt + # Used by dotenv library to load environment variables. # .env @@ -58,4 +61,4 @@ build-iPhoneSimulator/ .DS_Store # Ignore jetbrains files -.idea/ \ No newline at end of file +.idea/ diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index d5e4fad..5f47e64 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: openfeature-go-feature-flag-provider (0.1.3) - faraday (~> 2.10) + faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) GEM @@ -12,24 +12,30 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) bigdecimal (3.1.8) + connection_pool (2.5.3) crack (1.0.0) bigdecimal rexml diff-lcs (1.5.1) docile (1.4.1) - faraday (2.12.1) + faraday (2.13.4) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-net_http (3.4.0) + faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-net_http_persistent (2.3.1) + faraday (~> 2.5) + net-http-persistent (>= 4.0.4, < 5) hashdiff (1.1.1) - json (2.7.2) + json (2.13.2) language_server-protocol (3.17.0.3) lint_roller (1.1.0) - logger (1.6.1) - net-http (0.5.0) + logger (1.7.0) + net-http (0.6.0) uri + net-http-persistent (4.0.6) + connection_pool (~> 2.2, >= 2.2.4) openfeature-sdk (0.3.1) parallel (1.24.0) parser (3.3.0.5) @@ -92,7 +98,7 @@ GEM rubocop-performance (~> 1.20.2) strscan (3.1.0) unicode-display_width (2.5.0) - uri (1.0.2) + uri (1.0.3) webmock (3.23.1) addressable (>= 2.8.0) crack (>= 0.3.2) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb index dc00f78..cb92266 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb @@ -10,7 +10,7 @@ class Provider def initialize(options: Options.new) @metadata = SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME) @options = options - @goff_api = GoFeatureFlagApi.new(options: options) + @goff_api = GoFeatureFlagApi.new(endpoint: options.endpoint, custom_headers: options.custom_headers) end def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb index ffa8ffc..40f7586 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb @@ -3,7 +3,7 @@ require "open_feature/sdk" require "net/http" require "json" -require "faraday" +require "faraday/net_http_persistent" require_relative "error/errors" require_relative "model/ofrep_api_response" @@ -11,13 +11,12 @@ module OpenFeature module GoFeatureFlag # This class is the entry point for the GoFeatureFlagProvider class GoFeatureFlagApi - attr_reader :options - def initialize(options: {}) - @options = options - @faraday_connection = Faraday.new( - url: @options.endpoint, - headers: {"Content-Type" => "application/json"}.merge(@options.custom_headers || {}) - ) + def initialize(endpoint: nil, custom_headers: nil) + @faraday_connection = Faraday.new(url: endpoint, headers: headers(custom_headers)) do |f| + f.adapter :net_http_persistent do |http| + http.idle_timeout = 30 + end + end end def evaluate_ofrep_api(flag_key:, evaluation_context:) @@ -57,6 +56,10 @@ def evaluate_ofrep_api(flag_key:, evaluation_context:) private + def headers(custom_headers) + {"Content-Type" => "application/json"}.merge(custom_headers || {}) + end + def parse_error_response(response) required_keys = %w[key error_code] parsed = JSON.parse(response.body) diff --git a/providers/openfeature-go-feature-flag-provider/openfeature-go-feature-flag-provider.gemspec b/providers/openfeature-go-feature-flag-provider/openfeature-go-feature-flag-provider.gemspec index 33e03ef..fd4f6dc 100644 --- a/providers/openfeature-go-feature-flag-provider/openfeature-go-feature-flag-provider.gemspec +++ b/providers/openfeature-go-feature-flag-provider/openfeature-go-feature-flag-provider.gemspec @@ -30,7 +30,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_runtime_dependency "openfeature-sdk", "~> 0.3.1" - spec.add_runtime_dependency "faraday", "~> 2.10" + spec.add_runtime_dependency "faraday-net_http_persistent", "~> 2.3" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.12.0" diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb index ec939f7..0573a66 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb @@ -2,8 +2,7 @@ RSpec.describe OpenFeature::GoFeatureFlag::GoFeatureFlagApi do subject(:goff_api) do - options = OpenFeature::GoFeatureFlag::Options.new(endpoint: "http://localhost:1031") - described_class.new(options: options) + described_class.new(endpoint: "http://localhost:1031") end let(:default_evaluation_context) do From cdf68cf0af9a2c60d41de71ea2b5d531c4fd99db Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:21:47 -0400 Subject: [PATCH 08/31] chore(main): release openfeature-go-feature-flag-provider 0.1.4 (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [0.1.4](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.3...openfeature-go-feature-flag-provider/v0.1.4) (2025-07-30) ### ✨ New Features * connection persistance ([#59](https://github.com/open-feature/ruby-sdk-contrib/issues/59)) ([3a01a5e](https://github.com/open-feature/ruby-sdk-contrib/commit/3a01a5e4cc597aa0bc223e71f3e165f63c66f296)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: wadii --- .release-please-manifest.json | 2 +- .../openfeature-go-feature-flag-provider/CHANGELOG.md | 7 +++++++ .../openfeature-go-feature-flag-provider/Gemfile.lock | 2 +- .../lib/openfeature/go-feature-flag/version.rb | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 00739ca..1d0e9b6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,5 @@ "providers/openfeature-flagd-provider": "0.1.2", "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", - "providers/openfeature-go-feature-flag-provider": "0.1.3" + "providers/openfeature-go-feature-flag-provider": "0.1.4" } diff --git a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md index 58ee112..c80e363 100644 --- a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md +++ b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.4](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.3...openfeature-go-feature-flag-provider/v0.1.4) (2025-07-30) + + +### ✨ New Features + +* connection persistance ([#59](https://github.com/open-feature/ruby-sdk-contrib/issues/59)) ([3a01a5e](https://github.com/open-feature/ruby-sdk-contrib/commit/3a01a5e4cc597aa0bc223e71f3e165f63c66f296)) + ## [0.1.3](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.2...openfeature-go-feature-flag-provider/v0.1.3) (2025-01-25) diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index 5f47e64..32704c9 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-go-feature-flag-provider (0.1.3) + openfeature-go-feature-flag-provider (0.1.4) faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb index b1e7e80..d325b59 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb @@ -1,5 +1,5 @@ module OpenFeature module GoFeatureFlag - GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.3" + GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.4" end end From 54ebfd12778805daba4a5379c1f4c2c6b9a9b73e Mon Sep 17 00:00:00 2001 From: avelicka Date: Tue, 4 Nov 2025 22:26:45 +0200 Subject: [PATCH 09/31] feat(goff): add Faraday instrumentation (#62) ## This PR This PR adds [Faraday instrumentation](https://github.com/lostisland/faraday/blob/main/docs/middleware/included/instrumentation.md) configuration. Example: ```ruby OpenFeature::GoFeatureFlag::Options.new( endpoint: "http://localhost:1031", instrumentation: { name: 'custom_name', instrumenter: MyInstrumenter } ) ``` Signed-off-by: avelicka Signed-off-by: wadii --- .../openfeature-go-feature-flag-provider/README.md | 10 +++++----- .../go-feature-flag/go_feature_flag_provider.rb | 2 +- .../lib/openfeature/go-feature-flag/goff_api.rb | 3 ++- .../lib/openfeature/go-feature-flag/options.rb | 13 +++++++++++-- .../spec/openfeature/gofeatureflag/options_spec.rb | 9 +++++++++ 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/providers/openfeature-go-feature-flag-provider/README.md b/providers/openfeature-go-feature-flag-provider/README.md index 274a9fb..cbe33c5 100644 --- a/providers/openfeature-go-feature-flag-provider/README.md +++ b/providers/openfeature-go-feature-flag-provider/README.md @@ -45,11 +45,11 @@ gem install openfeature-go-feature-flag-provider The `OpenFeature::GoFeatureFlag::Provider` needs some options to be created and then set in the OpenFeature SDK. -| **Option** | **Description** | -|------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| `endpoint` | **(mandatory)** The URL to access to the relay-proxy.
*(example: `https://relay.proxy.gofeatureflag.org/`)* | -| `headers` | A `Hash` object containing the headers to send to the relay-proxy.
*(example to send APIKey: `{"Authorization" => "Bearer my-api-key"}` | - +| **Option** | **Description** | +|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------| +| `endpoint` | **(mandatory)** The URL to access to the relay-proxy.
*(example: `https://relay.proxy.gofeatureflag.org/`)* | +| `headers` | A `Hash` object containing the headers to send to the relay-proxy.
*(example to send APIKey: `{"Authorization" => "Bearer my-api-key"}` | +| `instrumentation` | [Faraday instrumentation](https://github.com/lostisland/faraday/blob/main/docs/middleware/included/instrumentation.md) hash | The only required option to create a `GoFeatureFlagProvider` is the URL _(`endpoint`)_ to your GO Feature Flag relay-proxy instance. ```ruby diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb index cb92266..7fbbc72 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb @@ -10,7 +10,7 @@ class Provider def initialize(options: Options.new) @metadata = SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME) @options = options - @goff_api = GoFeatureFlagApi.new(endpoint: options.endpoint, custom_headers: options.custom_headers) + @goff_api = GoFeatureFlagApi.new(endpoint: options.endpoint, custom_headers: options.custom_headers, instrumentation: options.instrumentation) end def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb index 40f7586..67dc0dc 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb @@ -11,8 +11,9 @@ module OpenFeature module GoFeatureFlag # This class is the entry point for the GoFeatureFlagProvider class GoFeatureFlagApi - def initialize(endpoint: nil, custom_headers: nil) + def initialize(endpoint: nil, custom_headers: nil, instrumentation: nil) @faraday_connection = Faraday.new(url: endpoint, headers: headers(custom_headers)) do |f| + f.request :instrumentation, instrumentation if instrumentation f.adapter :net_http_persistent do |http| http.idle_timeout = 30 end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb index 823fe7b..309ee2a 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb @@ -6,13 +6,15 @@ module OpenFeature module GoFeatureFlag # This class is the configuration class for the GoFeatureFlagProvider class Options - attr_accessor :endpoint, :custom_headers, :exporter_metadata + attr_accessor :endpoint, :custom_headers, :exporter_metadata, :instrumentation - def initialize(endpoint: nil, headers: {}, exporter_metadata: {}) + def initialize(endpoint: nil, headers: {}, exporter_metadata: {}, instrumentation: nil) validate_endpoint(endpoint: endpoint) + validate_instrumentation(instrumentation: instrumentation) @endpoint = endpoint @custom_headers = headers @exporter_metadata = exporter_metadata + @instrumentation = instrumentation end private @@ -25,6 +27,13 @@ def validate_endpoint(endpoint: nil) rescue URI::InvalidURIError raise ArgumentError, "Invalid URL for endpoint: #{endpoint}" end + + def validate_instrumentation(instrumentation: nil) + return if instrumentation.nil? + return if instrumentation.is_a?(Hash) + + raise ArgumentError, "Invalid type for instrumentation: #{instrumentation.class}" + end end end end diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/options_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/options_spec.rb index 46e55e9..3fac4ef 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/options_spec.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/options_spec.rb @@ -14,5 +14,14 @@ it "should raise if endpoint is not http" do expect { OpenFeature::GoFeatureFlag::Options.new(endpoint: "ftp://gofeatureflag.org") }.to raise_error(ArgumentError, "Invalid URL for endpoint: ftp://gofeatureflag.org") end + + it "should return instrumentation if configured" do + options = OpenFeature::GoFeatureFlag::Options.new(endpoint: "http://localhost:1031", instrumentation: {name: "custom_name"}) + expect(options.instrumentation).to eql(name: "custom_name") + end + + it "should raise if instrumentation is not hash" do + expect { OpenFeature::GoFeatureFlag::Options.new(instrumentation: "custom_name") }.to raise_error(ArgumentError, "Invalid type for instrumentation: String") + end end end From 1c841df7fe44ff837e3c6eeca03ab5ca35c046c8 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:45:18 -0500 Subject: [PATCH 10/31] chore(main): release openfeature-go-feature-flag-provider 0.1.5 (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [0.1.5](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.4...openfeature-go-feature-flag-provider/v0.1.5) (2025-11-04) ### ✨ New Features * **goff:** add Faraday instrumentation ([#62](https://github.com/open-feature/ruby-sdk-contrib/issues/62)) ([6ae223f](https://github.com/open-feature/ruby-sdk-contrib/commit/6ae223f3ffa076a45581f454cffbc49a0394d871)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: wadii --- .release-please-manifest.json | 2 +- .../openfeature-go-feature-flag-provider/CHANGELOG.md | 7 +++++++ .../openfeature-go-feature-flag-provider/Gemfile.lock | 2 +- .../lib/openfeature/go-feature-flag/version.rb | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1d0e9b6..decf10e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,5 @@ "providers/openfeature-flagd-provider": "0.1.2", "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", - "providers/openfeature-go-feature-flag-provider": "0.1.4" + "providers/openfeature-go-feature-flag-provider": "0.1.5" } diff --git a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md index c80e363..d103598 100644 --- a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md +++ b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.5](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.4...openfeature-go-feature-flag-provider/v0.1.5) (2025-11-04) + + +### ✨ New Features + +* **goff:** add Faraday instrumentation ([#62](https://github.com/open-feature/ruby-sdk-contrib/issues/62)) ([6ae223f](https://github.com/open-feature/ruby-sdk-contrib/commit/6ae223f3ffa076a45581f454cffbc49a0394d871)) + ## [0.1.4](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.3...openfeature-go-feature-flag-provider/v0.1.4) (2025-07-30) diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index 32704c9..97bdceb 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-go-feature-flag-provider (0.1.4) + openfeature-go-feature-flag-provider (0.1.5) faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb index d325b59..6bb4054 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb @@ -1,5 +1,5 @@ module OpenFeature module GoFeatureFlag - GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.4" + GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.5" end end From 73376536083fd299fba6e9bbffd0a7ce179dd1d3 Mon Sep 17 00:00:00 2001 From: Dorian de Koning Date: Tue, 11 Nov 2025 10:03:43 +0000 Subject: [PATCH 11/31] fix(openfeature-go-feature-flag-provider): fallback to sdkDefault if reason is DISABLED (#64) ## This PR Currently, when go-feature-flags has a disabled flag it returns a response in the following format: `{"key":"my_flag","value":"thisisadefaultvaluethatItest1233%%","reason":"DISABLED","variant":"SdkDefault"}`. This `thisisadefault...` comes directly from go-feature-flags. The client package returns this, but sdkDefault makes more sense. https://github.com/thomaspoignant/go-feature-flag/blob/de4d46dd7d513da32a9c67f0c38ae577d51ff6bd/cmd/relayproxy/ofrep/evaluate.go#L95C19-L95C51 Signed-off-by: Dorian de Koning Signed-off-by: wadii --- .../go_feature_flag_provider.rb | 7 +++++ .../gofeatureflag/provider_spec.rb | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb index 7fbbc72..d66e6c0 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb @@ -52,6 +52,13 @@ def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context: ni ) end + if parsed_response.reason == SDK::Provider::Reason::DISABLED + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DISABLED + ) + end + unless allowed_classes.include?(parsed_response.value.class) return SDK::Provider::ResolutionDetails.new( value: default_value, diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb index 5bdd49d..adc6060 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/provider_spec.rb @@ -314,6 +314,36 @@ ) expect(got).to eql(want) end + + it "should return the default value if the reason is DISABLED" do + test_name = RSpec.current_example.description + stub_request(:post, "http://localhost:1031/ofrep/v1/evaluate/flags/boolean_flag") + .to_return(status: 200, body: + { + key: "boolean_flag", + metadata: {"website" => "https://gofeatureflag.org"}, + value: true, + reason: "DISABLED", + variant: "variantA" + }.to_json) + OpenFeature::SDK.configure do |config| + config.set_provider(goff_provider, domain: test_name) + end + client = OpenFeature::SDK.build_client(domain: test_name) + got = client.fetch_boolean_details( + flag_key: "boolean_flag", + default_value: false, + evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "1234") + ) + want = OpenFeature::SDK::EvaluationDetails.new( + flag_key: "boolean_flag", + resolution_details: OpenFeature::SDK::Provider::ResolutionDetails.new( + value: false, + reason: OpenFeature::SDK::Provider::Reason::DISABLED + ) + ) + expect(got).to eql(want) + end end context "#fetch_string_value with openfeature" do From 9124151ff7ed430f511b9fa6d074e4b0ea72e448 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Tue, 11 Nov 2025 05:04:42 -0500 Subject: [PATCH 12/31] chore(main): release openfeature-go-feature-flag-provider 0.1.6 (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [0.1.6](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.5...openfeature-go-feature-flag-provider/v0.1.6) (2025-11-11) ### 🐛 Bug Fixes * **openfeature-go-feature-flag-provider:** fallback to sdkDefault if reason is DISABLED ([#64](https://github.com/open-feature/ruby-sdk-contrib/issues/64)) ([6cb08c0](https://github.com/open-feature/ruby-sdk-contrib/commit/6cb08c0156ce1ee4ea17278937e6da8904a15d04)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: wadii --- .release-please-manifest.json | 2 +- .../openfeature-go-feature-flag-provider/CHANGELOG.md | 7 +++++++ .../openfeature-go-feature-flag-provider/Gemfile.lock | 2 +- .../lib/openfeature/go-feature-flag/version.rb | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index decf10e..30ebd09 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,5 @@ "providers/openfeature-flagd-provider": "0.1.2", "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", - "providers/openfeature-go-feature-flag-provider": "0.1.5" + "providers/openfeature-go-feature-flag-provider": "0.1.6" } diff --git a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md index d103598..a65ebf6 100644 --- a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md +++ b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.6](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.5...openfeature-go-feature-flag-provider/v0.1.6) (2025-11-11) + + +### 🐛 Bug Fixes + +* **openfeature-go-feature-flag-provider:** fallback to sdkDefault if reason is DISABLED ([#64](https://github.com/open-feature/ruby-sdk-contrib/issues/64)) ([6cb08c0](https://github.com/open-feature/ruby-sdk-contrib/commit/6cb08c0156ce1ee4ea17278937e6da8904a15d04)) + ## [0.1.5](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.4...openfeature-go-feature-flag-provider/v0.1.5) (2025-11-04) diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index 97bdceb..fcd459d 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-go-feature-flag-provider (0.1.5) + openfeature-go-feature-flag-provider (0.1.6) faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb index 6bb4054..3e4f2fc 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb @@ -1,5 +1,5 @@ module OpenFeature module GoFeatureFlag - GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.5" + GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.6" end end From 0d347da1b9a8abe172edd4b278121d724b044a74 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 18 Nov 2025 10:22:50 +0100 Subject: [PATCH 13/31] feat: implemented-flagsmith-provider Signed-off-by: wadii --- .release-please-manifest.json | 3 +- .../.context.md | 318 ++++++++ .../openfeature-flagsmith-provider/.gitignore | 11 + .../openfeature-flagsmith-provider/.rspec | 4 + .../.rubocop.yml | 5 + .../.ruby-version | 1 + .../CHANGELOG.md | 15 + .../FLAGSMITH_PROVIDER_DESIGN.md | 394 ++++++++++ .../openfeature-flagsmith-provider/Gemfile | 11 + .../Gemfile.lock | 126 +++ .../openfeature-flagsmith-provider/README.md | 325 ++++++++ .../openfeature-flagsmith-provider/Rakefile | 6 + .../lib/openfeature/flagsmith/error/errors.rb | 78 ++ .../lib/openfeature/flagsmith/options.rb | 82 ++ .../lib/openfeature/flagsmith/provider.rb | 288 +++++++ .../lib/openfeature/flagsmith/version.rb | 7 + .../openfeature-flagsmith-provider.gemspec | 38 + .../spec/openfeature/flagsmith/errors_spec.rb | 72 ++ .../openfeature/flagsmith/options_spec.rb | 183 +++++ .../openfeature/flagsmith/provider_spec.rb | 735 ++++++++++++++++++ .../spec/spec_helper.rb | 30 + release-please-config.json | 10 + 22 files changed, 2741 insertions(+), 1 deletion(-) create mode 100644 providers/openfeature-flagsmith-provider/.context.md create mode 100644 providers/openfeature-flagsmith-provider/.gitignore create mode 100644 providers/openfeature-flagsmith-provider/.rspec create mode 100644 providers/openfeature-flagsmith-provider/.rubocop.yml create mode 100644 providers/openfeature-flagsmith-provider/.ruby-version create mode 100644 providers/openfeature-flagsmith-provider/CHANGELOG.md create mode 100644 providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md create mode 100644 providers/openfeature-flagsmith-provider/Gemfile create mode 100644 providers/openfeature-flagsmith-provider/Gemfile.lock create mode 100644 providers/openfeature-flagsmith-provider/README.md create mode 100644 providers/openfeature-flagsmith-provider/Rakefile create mode 100644 providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb create mode 100644 providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb create mode 100644 providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb create mode 100644 providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb create mode 100644 providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec create mode 100644 providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb create mode 100644 providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb create mode 100644 providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb create mode 100644 providers/openfeature-flagsmith-provider/spec/spec_helper.rb diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 30ebd09..acc3b28 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,6 @@ "providers/openfeature-flagd-provider": "0.1.2", "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", - "providers/openfeature-go-feature-flag-provider": "0.1.6" + "providers/openfeature-go-feature-flag-provider": "0.1.6", + "providers/openfeature-flagsmith-provider": "0.1.0" } diff --git a/providers/openfeature-flagsmith-provider/.context.md b/providers/openfeature-flagsmith-provider/.context.md new file mode 100644 index 0000000..7c47af8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.context.md @@ -0,0 +1,318 @@ +# Flagsmith OpenFeature Provider - Context + +**Status:** 🚧 In Development +**Started:** 2025-11-17 +**Full Design Doc:** `./FLAGSMITH_PROVIDER_DESIGN.md` + +--- + +## Quick Facts + +**What we're building:** OpenFeature provider for Flagsmith Ruby SDK +**Target Flagsmith version:** Upcoming release (TBD) + +--- + +## Key Design Decisions ✅ + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Evaluation mode | Remote (default) | Simpler, no polling. Local available via config | +| No targeting_key | Fall back to environment flags | `get_environment_flags()` vs `get_identity_flags()` | +| Analytics | Opt-in (disabled default) | Privacy-first approach | +| Default values | Use OpenFeature's default_value | Match other providers, no custom handler | +| Configuration | Options object pattern | Clear validation, like GO Feature Flag | + +--- + +## Architecture Map + +### Directory Structure +``` +openfeature-flagsmith-provider/ +├── lib/openfeature/flagsmith/ +│ ├── provider.rb # Main provider class +│ ├── configuration.rb # Options/config with validation +│ ├── error/errors.rb # Custom exceptions → ErrorCode mapping +│ └── version.rb # VERSION constant +├── spec/ # RSpec tests with WebMock +├── openfeature-flagsmith-provider.gemspec +└── README.md +``` + +### Key Classes + +**Configuration** (lib/openfeature/flagsmith/configuration.rb): +```ruby +OpenFeature::Flagsmith::Configuration.new( + environment_key: "required", + api_url: "https://edge.api.flagsmith.com/api/v1/", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false +) +``` + +**Provider** (lib/openfeature/flagsmith/provider.rb): +```ruby +class Provider + attr_reader :metadata + + # Required methods: + def fetch_boolean_value(flag_key:, default_value:, evaluation_context:) + def fetch_string_value(flag_key:, default_value:, evaluation_context:) + def fetch_number_value(flag_key:, default_value:, evaluation_context:) + def fetch_integer_value(flag_key:, default_value:, evaluation_context:) + def fetch_float_value(flag_key:, default_value:, evaluation_context:) + def fetch_object_value(flag_key:, default_value:, evaluation_context:) +end +``` + +--- + +## Critical Mappings + +### 1. Context → Identity/Traits +```ruby +# OpenFeature → Flagsmith +evaluation_context.targeting_key → identifier (for get_identity_flags) +evaluation_context.fields → traits (as keyword args) + +# If no targeting_key → use get_environment_flags() +``` + +### 2. Flag Types +| Flagsmith | OpenFeature Method | Implementation | +|-----------|-------------------|----------------| +| `is_feature_enabled()` → Boolean | `fetch_boolean_value` | Direct mapping | +| `get_feature_value()` → String | `fetch_string_value` | Direct return | +| `get_feature_value()` → Number | `fetch_number_value` | Parse & validate type | +| `get_feature_value()` → JSON | `fetch_object_value` | `JSON.parse()` | + +### 3. Reason Mapping +| Situation | OpenFeature Reason | +|-----------|-------------------| +| Flag evaluated with identity | `TARGETING_MATCH` | +| Flag evaluated at environment level | `STATIC` | +| Flag not found | `DEFAULT` | +| Flag exists but disabled | `DISABLED` | +| Error occurred | `ERROR` | +| Local evaluation mode | `CACHED` | + +### 4. Error Handling +```ruby +# Pattern: Always return ResolutionDetails with default_value on error +SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: , + error_message: "description", + reason: SDK::Provider::Reason::ERROR +) +``` + +**ErrorCode mapping:** +- Flag not found → `FLAG_NOT_FOUND` +- Wrong type → `TYPE_MISMATCH` +- Network error → `PROVIDER_NOT_READY` or `GENERAL` +- Invalid context → `INVALID_CONTEXT` + +--- + +## Flagsmith SDK API Reference + +### Initialization +```ruby +require "flagsmith" +client = Flagsmith::Client.new( + environment_key: "key", + api_url: "url", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false +) +``` + +### Evaluation Methods +```ruby +# Environment-level (no user) +flags = client.get_environment_flags() +flags.is_feature_enabled('flag_key') # → Boolean +flags.get_feature_value('flag_key') # → String/value + +# Identity-specific (with user context) +flags = client.get_identity_flags('user@example.com', trait1: 'value', age: 30) +flags.is_feature_enabled('flag_key') +flags.get_feature_value('flag_key') +``` + +--- + +## OpenFeature Provider Contract + +### Required Interface +```ruby +# Metadata +attr_reader :metadata # ProviderMetadata.new(name: "Flagsmith Provider") + +# Lifecycle (optional) +def init # Optional initialization +def shutdown # Optional cleanup + +# Evaluation methods (required) +def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) +def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) +``` + +### Return Type +```ruby +OpenFeature::SDK::Provider::ResolutionDetails.new( + value: , # Required + reason: , # Required + variant: "variant_key", # Optional + flag_metadata: {key: "value"}, # Optional + error_code: , # If error + error_message: "details" # If error +) +``` + +--- + +## Code Patterns to Follow + +### Type Validation Pattern +```ruby +def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context:) + # ... get value from Flagsmith ... + + unless allowed_classes.include?(value.class) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "Expected #{allowed_classes}, got #{value.class}", + reason: SDK::Provider::Reason::ERROR + ) + end + + # return success ResolutionDetails +end +``` + +### Error Rescue Pattern +```ruby +begin + # Flagsmith evaluation +rescue SomeError => e + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::GENERAL, + error_message: e.message, + reason: SDK::Provider::Reason::ERROR + ) +end +``` + +--- + +## Dependencies + +### Runtime +```ruby +spec.add_runtime_dependency "openfeature-sdk", "~> 0.3.1" +spec.add_runtime_dependency "flagsmith", "~> " +``` + +### Development +```ruby +spec.add_development_dependency "rake", "~> 13.0" +spec.add_development_dependency "rspec", "~> 3.12.0" +spec.add_development_dependency "webmock", "~> 3.0" # Mock Flagsmith calls +spec.add_development_dependency "standard" +spec.add_development_dependency "rubocop" +spec.add_development_dependency "simplecov" +``` + +--- + +## Testing Strategy + +### Mock Flagsmith Responses +```ruby +# Use WebMock to stub Flagsmith API calls +# OR stub Flagsmith::Client methods directly + +allow(flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) +allow(mock_flags).to receive(:is_feature_enabled).and_return(true) +allow(mock_flags).to receive(:get_feature_value).and_return("value") +``` + +### Test Coverage Areas +- ✅ Metadata verification +- ✅ Configuration validation +- ✅ Each flag type evaluation (boolean, string, number, integer, float, object) +- ✅ Type mismatches +- ✅ Missing flags (return default) +- ✅ Error handling +- ✅ Context mapping (with/without targeting_key) +- ✅ Environment vs Identity evaluation + +--- + +## Implementation Checklist + +- [ ] Directory structure created +- [ ] Gemspec with dependencies +- [ ] Configuration class with validation +- [ ] Provider skeleton with metadata +- [ ] Context → Identity/Traits mapping helper +- [ ] `fetch_boolean_value` implementation +- [ ] `fetch_string_value` implementation +- [ ] `fetch_number_value` implementation +- [ ] `fetch_integer_value` implementation +- [ ] `fetch_float_value` implementation +- [ ] `fetch_object_value` implementation +- [ ] Error handling and custom exceptions +- [ ] Type validation +- [ ] RSpec test suite +- [ ] README with examples +- [ ] Release configuration + +--- + +## Open Items / Notes + +- **Flagsmith version**: Waiting for upcoming release - update gemspec when available +- **Variant support**: Flagsmith doesn't have explicit variants - TBD how to handle +- **Flag metadata**: Flagsmith has limited metadata - may need to extract from traits/response + +--- + +## Quick Commands + +```bash +# Run tests +bundle exec rspec + +# Run linter +bundle exec rubocop + +# Install locally for testing +gem build openfeature-flagsmith-provider.gemspec +gem install ./openfeature-flagsmith-provider-.gem + +# Test with real Flagsmith +# (add example script in spec/manual_test.rb) +``` + +--- + +## References + +- Full design doc: `../FLAGSMITH_PROVIDER_DESIGN.md` +- Flagsmith docs: https://docs.flagsmith.com/clients/server-side +- OpenFeature spec: https://openfeature.dev/specification/ +- GO Feature Flag provider: `../openfeature-go-feature-flag-provider/` +- flagd provider: `../openfeature-flagd-provider/` diff --git a/providers/openfeature-flagsmith-provider/.gitignore b/providers/openfeature-flagsmith-provider/.gitignore new file mode 100644 index 0000000..b04a8c8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.gitignore @@ -0,0 +1,11 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status diff --git a/providers/openfeature-flagsmith-provider/.rspec b/providers/openfeature-flagsmith-provider/.rspec new file mode 100644 index 0000000..44b132b --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.rspec @@ -0,0 +1,4 @@ +-I lib +--format documentation +--color +--require spec_helper diff --git a/providers/openfeature-flagsmith-provider/.rubocop.yml b/providers/openfeature-flagsmith-provider/.rubocop.yml new file mode 100644 index 0000000..feec135 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.rubocop.yml @@ -0,0 +1,5 @@ +inherit_from: ../../shared_config/.rubocop.yml + +inherit_mode: + merge: + - Exclude diff --git a/providers/openfeature-flagsmith-provider/.ruby-version b/providers/openfeature-flagsmith-provider/.ruby-version new file mode 100644 index 0000000..0aec50e --- /dev/null +++ b/providers/openfeature-flagsmith-provider/.ruby-version @@ -0,0 +1 @@ +3.1.4 diff --git a/providers/openfeature-flagsmith-provider/CHANGELOG.md b/providers/openfeature-flagsmith-provider/CHANGELOG.md new file mode 100644 index 0000000..5cacd95 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial implementation of Flagsmith OpenFeature provider +- Support for all OpenFeature flag types (boolean, string, number, integer, float, object) +- Remote and local evaluation modes +- Environment-level and identity-specific flag evaluation +- Comprehensive error handling and type validation diff --git a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md new file mode 100644 index 0000000..a7dfe87 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md @@ -0,0 +1,394 @@ +# Flagsmith OpenFeature Provider - Design Document + +**Created:** 2025-11-17 +**Status:** Design Phase +**Target:** OpenFeature Ruby SDK integration with Flagsmith + +--- + +## 1. Research Summary + +### 1.1 Flagsmith Ruby SDK Details + +**Gem Name:** `flagsmith` +**Latest Version:** v4.3.0 (as of December 2024) +**Ruby Version:** Requires Ruby 2.4+ +**GitHub:** https://github.com/Flagsmith/flagsmith-ruby-client +**Documentation:** https://docs.flagsmith.com/clients/server-side + +#### Installation +```ruby +gem install flagsmith +``` + +#### Basic Initialization +```ruby +require "flagsmith" +$flagsmith = Flagsmith::Client.new( + environment_key: 'FLAGSMITH_SERVER_SIDE_ENVIRONMENT_KEY' +) +``` + +#### Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `environment_key` | String | **Required** | Server-side authentication token | +| `api_url` | String | "https://edge.api.flagsmith.com/api/v1/" | Custom self-hosted endpoint | +| `enable_local_evaluation` | Boolean | false | Local vs. remote flag evaluation mode | +| `request_timeout_seconds` | Integer | 10 | Network request timeout | +| `environment_refresh_interval_seconds` | Integer | 60 | Polling interval in local mode | +| `enable_analytics` | Boolean | false | Send usage analytics to Flagsmith | +| `default_flag_handler` | Lambda | nil | Fallback for missing/failed flags | + +#### Flag Evaluation Methods + +**Environment-level (no user context):** +```ruby +flags = $flagsmith.get_environment_flags() +show_button = flags.is_feature_enabled('secret_button') +button_data = flags.get_feature_value('secret_button') +``` + +**Identity-specific (with user context):** +```ruby +identifier = 'user@example.com' +traits = {'car_type': 'sedan', 'age': 30} +flags = $flagsmith.get_identity_flags(identifier, **traits) +show_button = flags.is_feature_enabled('secret_button') +value = flags.get_feature_value('secret_button') +``` + +#### Evaluation Modes +- **Remote Evaluation** (default): Blocking HTTP requests per flag fetch +- **Local Evaluation**: Asynchronous polling (~60 sec intervals) +- **Offline Mode**: Requires custom `offline_handler` + +#### Default Flag Handler Pattern +```ruby +$flagsmith = Flagsmith::Client.new( + environment_key: '', + default_flag_handler: lambda { |feature_name| + Flagsmith::Flags::DefaultFlag.new( + enabled: false, + value: {'colour': '#ababab'}.to_json + ) + } +) +``` + +--- + +## 2. OpenFeature Provider Patterns (from repo analysis) + +### 2.1 Required Provider Interface + +All providers must implement: +```ruby +class Provider + attr_reader :metadata # Returns ProviderMetadata with name + + # Lifecycle (optional) + def init + def shutdown + + # Required evaluation methods + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) +end +``` + +### 2.2 Return Type: ResolutionDetails + +All fetch_* methods must return: +```ruby +OpenFeature::SDK::Provider::ResolutionDetails.new( + value: , # The flag value + reason: , # TARGETING_MATCH, DEFAULT, DISABLED, ERROR, etc. + variant: "variant_key", # Optional variant identifier + flag_metadata: { ... }, # Optional metadata + error_code: , # If error occurred + error_message: "Error details" # If error occurred +) +``` + +#### OpenFeature Reason Constants +- `TARGETING_MATCH` - Flag evaluated with targeting rules +- `DEFAULT` - Default value used +- `DISABLED` - Feature is disabled +- `ERROR` - Error during evaluation +- `STATIC` - Static value +- `CACHED` - Value from cache + +#### OpenFeature ErrorCode Constants +- `PROVIDER_NOT_READY` +- `FLAG_NOT_FOUND` +- `TYPE_MISMATCH` +- `PARSE_ERROR` +- `TARGETING_KEY_MISSING` +- `INVALID_CONTEXT` +- `GENERAL` + +### 2.3 Configuration Patterns Used in Repo + +**Pattern 1: Options Object** (Used by GO Feature Flag provider) +```ruby +class Options + def initialize(endpoint:, headers: {}, ...) + validate_endpoint(endpoint) + @endpoint = endpoint + @headers = headers + end +end +``` + +**Pattern 2: Block-Based Configuration** (Used by flagd provider) +```ruby +OpenFeature::Flagd::Provider.configure do |config| + config.host = "localhost" + config.port = 8013 +end +``` + +### 2.4 Error Handling Pattern + +Create custom exception hierarchy: +```ruby +class FlagsmithError < StandardError + attr_reader :error_code, :error_message + + def initialize(error_code, error_message) + @error_code = error_code # Maps to SDK::Provider::ErrorCode + @error_message = error_message + super(error_message) + end +end + +class FlagNotFoundError < FlagsmithError +class TypeMismatchError < FlagsmithError +class ConfigurationError < FlagsmithError +``` + +### 2.5 Type Validation Pattern + +```ruby +def evaluate(flag_key:, default_value:, allowed_classes:, evaluation_context: nil) + # ... evaluation logic ... + + unless allowed_classes.include?(value.class) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "flag type #{value.class} does not match allowed types #{allowed_classes}", + reason: SDK::Provider::Reason::ERROR + ) + end +end +``` + +--- + +## 3. Proposed Flagsmith Provider Architecture + +### 3.1 Directory Structure + +``` +providers/openfeature-flagsmith-provider/ +├── lib/ +│ └── openfeature/ +│ └── flagsmith/ +│ ├── provider.rb # Main provider class +│ ├── configuration.rb # Configuration/options class +│ ├── error/ +│ │ └── errors.rb # Custom exception hierarchy +│ └── version.rb # Version constant +├── spec/ +│ ├── spec_helper.rb +│ ├── provider_spec.rb +│ ├── configuration_spec.rb +│ └── fixtures/ # Mock responses +├── openfeature-flagsmith-provider.gemspec +├── README.md +├── CHANGELOG.md +├── Gemfile +└── Rakefile +``` + +### 3.2 Key Design Decisions + +#### Configuration Strategy +**Chosen: Options Object Pattern** + +Reasoning: +- Flagsmith has many configuration options (api_url, timeouts, evaluation mode, etc.) +- Options object provides clear validation +- Aligns with GO Feature Flag provider pattern (most similar use case) + +```ruby +options = OpenFeature::Flagsmith::Configuration.new( + environment_key: "your_key", + api_url: "https://edge.api.flagsmith.com/api/v1/", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false +) + +provider = OpenFeature::Flagsmith::Provider.new(configuration: options) +``` + +#### Evaluation Context Mapping + +OpenFeature EvaluationContext → Flagsmith Identity + Traits: +- `evaluation_context.targeting_key` → Flagsmith identity identifier +- All other `evaluation_context.fields` → Flagsmith traits + +```ruby +def map_context_to_identity(evaluation_context) + return [nil, {}] if evaluation_context.nil? + + identifier = evaluation_context.targeting_key + traits = evaluation_context.fields.reject { |k, _v| k == :targeting_key } + + [identifier, traits] +end +``` + +#### Flag Type Mapping + +| Flagsmith Type | OpenFeature Type | Notes | +|----------------|------------------|-------| +| Boolean enabled | `fetch_boolean_value` | Use `is_feature_enabled` | +| String value | `fetch_string_value` | Use `get_feature_value` | +| Numeric value | `fetch_number_value` | Parse and validate | +| JSON value | `fetch_object_value` | Parse JSON string | + +#### Error Handling Strategy + +1. **Flagsmith errors** → Map to OpenFeature ErrorCodes +2. **Network errors** → `PROVIDER_NOT_READY` or `GENERAL` +3. **Type mismatches** → `TYPE_MISMATCH` +4. **Missing flags** → Return default with `FLAG_NOT_FOUND` + +#### Reason Mapping + +| Flagsmith State | OpenFeature Reason | +|-----------------|-------------------| +| Flag evaluated with identity | `TARGETING_MATCH` | +| Flag not found | `DEFAULT` | +| Flag disabled | `DISABLED` | +| Error occurred | `ERROR` | +| Local evaluation | `CACHED` | + +--- + +## 4. Implementation Plan + +### Phase 1: Core Structure +1. Create directory structure +2. Setup gemspec with dependencies +3. Create Configuration class with validation +4. Create Provider class skeleton with metadata + +### Phase 2: Flag Evaluation +5. Implement `fetch_boolean_value` (simplest case) +6. Implement context → identity/traits mapping +7. Add error handling for boolean evaluation +8. Implement remaining fetch_* methods + +### Phase 3: Advanced Features +9. Handle default_flag_handler integration +10. Support local evaluation mode +11. Add proper lifecycle management (init/shutdown) + +### Phase 4: Testing & Documentation +12. Create RSpec test suite with mocked Flagsmith responses +13. Write comprehensive README +14. Add usage examples +15. Configure release automation + +--- + +## 5. Open Questions & Decisions Needed + +### 5.1 Design Decisions - RESOLVED ✅ + +1. **Evaluation Mode Preference** + - ✅ **Default to remote evaluation** (simpler, no polling overhead) + - Configurable via `enable_local_evaluation` option + +2. **Analytics** + - ✅ **Opt-in** (`enable_analytics: false` by default) + +3. **Default Flag Handler** + - ✅ **Use OpenFeature's default_value** (matches other providers) + - Do NOT implement Flagsmith's `default_flag_handler` + - Return `default_value` with appropriate error_code/reason on failures + +4. **Targeting Key Requirement** + - ✅ **Fall back to environment-level flags** if no targeting_key + - Use `get_environment_flags()` when targeting_key is nil/empty + - Use `get_identity_flags()` when targeting_key is present + +5. **Version Compatibility** + - ✅ **Target upcoming version** (will be released soon) + - Update dependency when new version is available + +### 5.2 Technical Considerations + +**Type Detection Challenge:** +Flagsmith's `get_feature_value` returns values as strings/JSON. We need to: +- Parse JSON for objects +- Detect numeric types +- Handle type mismatches gracefully + +**Variant Support:** +Flagsmith doesn't have explicit "variants" like some systems. Options: +- Use feature key as variant +- Leave variant nil +- Use enabled/disabled as variant + +**Metadata:** +Flagsmith flags don't inherently have metadata beyond enabled/value. We could: +- Include trait data as flag_metadata +- Leave empty +- Add custom metadata extraction + +--- + +## 6. Dependencies + +### Runtime +- `openfeature-sdk` (~> 0.3.1) +- `flagsmith` (~> 4.3.0) + +### Development +- `rake` (~> 13.0) +- `rspec` (~> 3.12.0) +- `webmock` (~> 3.0) - for mocking Flagsmith HTTP calls +- `standard` - Ruby linter +- `rubocop` - Code style +- `simplecov` - Test coverage + +--- + +## 7. Next Steps + +1. **User Decisions** - Get answers to open questions above +2. **Proof of Concept** - Build minimal provider with boolean support +3. **Validate Approach** - Test with real Flagsmith instance +4. **Expand** - Add remaining types and features +5. **Polish** - Tests, docs, release config + +--- + +## 8. References + +- OpenFeature Specification: https://openfeature.dev/specification/ +- Flagsmith Docs: https://docs.flagsmith.com/clients/server-side +- Flagsmith Ruby Client: https://github.com/Flagsmith/flagsmith-ruby-client +- GO Feature Flag Provider (reference impl): `providers/openfeature-go-feature-flag-provider/` +- flagd Provider (reference impl): `providers/openfeature-flagd-provider/` diff --git a/providers/openfeature-flagsmith-provider/Gemfile b/providers/openfeature-flagsmith-provider/Gemfile new file mode 100644 index 0000000..d42b4e0 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/Gemfile @@ -0,0 +1,11 @@ +source "https://rubygems.org" + +gemspec + +gem "flagsmith", "~> 4.3" +gem "rake", "~> 13.0" +gem "rspec", "~> 3.12.0" +gem "webmock", "~> 3.0" +gem "standard", "~> 1.0" +gem "rubocop", "~> 1.0" +gem "simplecov", "~> 0.22" diff --git a/providers/openfeature-flagsmith-provider/Gemfile.lock b/providers/openfeature-flagsmith-provider/Gemfile.lock new file mode 100644 index 0000000..7f25610 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/Gemfile.lock @@ -0,0 +1,126 @@ +PATH + remote: . + specs: + openfeature-flagsmith-provider (0.1.0) + flagsmith (~> 4.3) + openfeature-sdk (~> 0.3.1) + +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + bigdecimal (3.3.1) + crack (1.0.1) + bigdecimal + rexml + diff-lcs (1.6.2) + docile (1.4.1) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.3.2) + faraday (~> 2.0) + flagsmith (4.3.0) + faraday (>= 2.0.1) + faraday-retry + semantic + hashdiff (1.2.1) + json (2.16.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + net-http (0.8.0) + uri (>= 0.11.1) + openfeature-sdk (0.3.1) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + prism (1.6.0) + public_suffix (6.0.2) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + regexp_parser (2.11.3) + rexml (3.4.4) + rspec (3.12.0) + rspec-core (~> 3.12.0) + rspec-expectations (~> 3.12.0) + rspec-mocks (~> 3.12.0) + rspec-core (3.12.3) + rspec-support (~> 3.12.0) + rspec-expectations (3.12.4) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-mocks (3.12.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.12.0) + rspec-support (3.12.2) + rubocop (1.81.7) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.48.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (1.13.0) + semantic (1.6.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + standard (1.35.0.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.62) + standard-custom (~> 1.0.0) + standard-performance (~> 1.3) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.1.1) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + flagsmith (~> 4.3) + openfeature-flagsmith-provider! + rake (~> 13.0) + rspec (~> 3.12.0) + rubocop (~> 1.0) + simplecov (~> 0.22) + standard (~> 1.0) + webmock (~> 3.0) + +BUNDLED WITH + 2.6.9 diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md new file mode 100644 index 0000000..aabefc3 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/README.md @@ -0,0 +1,325 @@ +# OpenFeature Flagsmith Provider for Ruby + +[![Project Status: WIP](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) + +This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature flags, implementing the [OpenFeature](https://openfeature.dev/) standard. + +## Features + +| Status | Feature | Description | +|--------|---------|-------------| +| ✅ | Flag Evaluation | Support for all OpenFeature flag types | +| ✅ | Boolean Flags | Evaluate boolean feature flags | +| ✅ | String Flags | Evaluate string feature flags | +| ✅ | Number Flags | Evaluate numeric feature flags (int, float) | +| ✅ | Object Flags | Evaluate JSON object/array flags | +| ✅ | Evaluation Context | Support for user identity and traits | +| ✅ | Environment Flags | Evaluate flags at environment level | +| ✅ | Identity Flags | Evaluate flags for specific users | +| ✅ | Remote Evaluation | Default remote evaluation mode | +| ✅ | Local Evaluation | Optional local evaluation mode | +| ✅ | Error Handling | Comprehensive error handling | +| ✅ | Type Validation | Strict type checking | +| 🚧 | Flagsmith Integration | Pending Flagsmith gem version | + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'openfeature-flagsmith-provider' +``` + +And then execute: + +```bash +bundle install +``` + +Or install it yourself as: + +```bash +gem install openfeature-flagsmith-provider +``` + +## Usage + +### Basic Setup + +```ruby +require 'open_feature/sdk' +require 'openfeature/flagsmith/provider' +require 'openfeature/flagsmith/options' + +# Configure the Flagsmith provider +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_flagsmith_environment_key' +) + +provider = OpenFeature::Flagsmith::Provider.new(options: options) + +# Set the provider in OpenFeature +OpenFeature::SDK.configure do |config| + config.provider = provider +end + +# Get a client +client = OpenFeature::SDK.build_client +``` + +### Evaluating Flags + +#### Boolean Flags + +```ruby +# Simple boolean flag +enabled = client.fetch_boolean_value( + flag_key: 'new_feature', + default_value: false +) + +# With user context +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user_123', + email: 'user@example.com', + age: 30 +) + +enabled = client.fetch_boolean_value( + flag_key: 'new_feature', + default_value: false, + evaluation_context: evaluation_context +) +``` + +#### String Flags + +```ruby +theme = client.fetch_string_value( + flag_key: 'theme', + default_value: 'light', + evaluation_context: evaluation_context +) +``` + +#### Number Flags + +```ruby +max_items = client.fetch_integer_value( + flag_key: 'max_items', + default_value: 10, + evaluation_context: evaluation_context +) + +rate_limit = client.fetch_float_value( + flag_key: 'rate_limit', + default_value: 1.5, + evaluation_context: evaluation_context +) +``` + +#### Object Flags + +```ruby +config = client.fetch_object_value( + flag_key: 'app_config', + default_value: {timeout: 30}, + evaluation_context: evaluation_context +) +``` + +## Configuration Options + +The `Options` class accepts the following configuration parameters: + +| Option | Type | Default | Required | Description | +|--------|------|---------|----------|-------------| +| `environment_key` | String | - | **Yes** | Your Flagsmith environment key | +| `api_url` | String | `https://edge.api.flagsmith.com/api/v1/` | No | Custom Flagsmith API URL (for self-hosting) | +| `enable_local_evaluation` | Boolean | `false` | No | Enable local evaluation mode | +| `request_timeout_seconds` | Integer | `10` | No | HTTP request timeout in seconds | +| `enable_analytics` | Boolean | `false` | No | Enable Flagsmith analytics | +| `environment_refresh_interval_seconds` | Integer | `60` | No | Polling interval for local evaluation mode | + +### Configuration Examples + +#### Default Configuration + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key' +) +``` + +#### Custom API URL (Self-Hosted) + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key', + api_url: 'https://flagsmith.yourcompany.com/api/v1/' +) +``` + +#### Local Evaluation Mode + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key', + enable_local_evaluation: true, + environment_refresh_interval_seconds: 30 +) +``` + +#### With Analytics + +```ruby +options = OpenFeature::Flagsmith::Options.new( + environment_key: 'your_key', + enable_analytics: true +) +``` + +## Evaluation Context + +The provider supports OpenFeature evaluation contexts to pass user information and traits to Flagsmith: + +### Targeting Key → Identity + +The `targeting_key` maps to Flagsmith's identity identifier: + +```ruby +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user@example.com' +) +``` + +### Context Fields → Traits + +All other context fields are passed as Flagsmith traits: + +```ruby +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user_123', + email: 'user@example.com', + plan: 'premium', + age: 30 +) +``` + +This will evaluate flags for identity `user_123` with traits: +- `email`: "user@example.com" +- `plan`: "premium" +- `age`: 30 + +### Environment-Level vs Identity-Specific + +**Without `targeting_key` (Environment-level):** +```ruby +# Evaluates flags at environment level +client.fetch_boolean_value( + flag_key: 'feature', + default_value: false +) +``` + +**With `targeting_key` (Identity-specific):** +```ruby +# Evaluates flags for specific user identity +evaluation_context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user_123' +) + +client.fetch_boolean_value( + flag_key: 'feature', + default_value: false, + evaluation_context: evaluation_context +) +``` + +## Error Handling + +The provider handles errors gracefully and returns the default value with appropriate error codes: + +```ruby +result = client.fetch_boolean_details( + flag_key: 'unknown_flag', + default_value: false +) + +puts result.value # => false (default value) +puts result.error_code # => FLAG_NOT_FOUND +puts result.error_message # => "Flag 'unknown_flag' not found" +puts result.reason # => DEFAULT +``` + +### Error Codes + +| Error Code | Description | +|------------|-------------| +| `FLAG_NOT_FOUND` | The requested flag does not exist | +| `TYPE_MISMATCH` | The flag value type doesn't match the requested type | +| `PROVIDER_NOT_READY` | The Flagsmith client is not properly initialized | +| `PARSE_ERROR` | Failed to parse the flag value | +| `INVALID_CONTEXT` | The evaluation context is invalid | +| `GENERAL` | A general error occurred | + +## Reasons + +The provider returns appropriate reasons for flag evaluations: + +| Reason | Description | +|--------|-------------| +| `TARGETING_MATCH` | Flag evaluated with user identity and targeting rules | +| `STATIC` | Flag evaluated at environment level (no user context) | +| `DEFAULT` | Default value returned (flag not found or disabled) | +| `ERROR` | An error occurred during evaluation | +| `CACHED` | Value returned from local cache (local evaluation mode) | + +## Development + +### Running Tests + +```bash +bundle install +bundle exec rspec +``` + +### Running Linter + +```bash +bundle exec rubocop +``` + +### Building the Gem + +```bash +gem build openfeature-flagsmith-provider.gemspec +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +Apache 2.0 - See [LICENSE](LICENSE) for more information. + +## Links + +- [Flagsmith Documentation](https://docs.flagsmith.com/) +- [OpenFeature Documentation](https://openfeature.dev/) +- [OpenFeature Ruby SDK](https://github.com/open-feature/ruby-sdk) +- [Ruby SDK Contrib Repository](https://github.com/open-feature/ruby-sdk-contrib) + +## Support + +For issues related to: +- **This provider**: [GitHub Issues](https://github.com/open-feature/ruby-sdk-contrib/issues) +- **Flagsmith**: [Flagsmith Support](https://www.flagsmith.com/contact-us) +- **OpenFeature**: [OpenFeature Community](https://openfeature.dev/community/) diff --git a/providers/openfeature-flagsmith-provider/Rakefile b/providers/openfeature-flagsmith-provider/Rakefile new file mode 100644 index 0000000..c92b11e --- /dev/null +++ b/providers/openfeature-flagsmith-provider/Rakefile @@ -0,0 +1,6 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task default: :spec diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb new file mode 100644 index 0000000..f1f9c73 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/error/errors.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "open_feature/sdk/provider/error_code" + +module OpenFeature + module Flagsmith + # Base error class for Flagsmith provider + class FlagsmithError < StandardError + attr_reader :error_code, :error_message + + def initialize(error_code, error_message) + @error_code = error_code + @error_message = error_message + super(error_message) + end + end + + # Raised when a flag is not found in Flagsmith + class FlagNotFoundError < FlagsmithError + def initialize(flag_key) + super( + SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + "Flag not found: #{flag_key}" + ) + end + end + + # Raised when there's a type mismatch between expected and actual flag value + class TypeMismatchError < FlagsmithError + def initialize(expected_types, actual_type) + super( + SDK::Provider::ErrorCode::TYPE_MISMATCH, + "Expected type #{expected_types}, but got #{actual_type}" + ) + end + end + + # Raised when the Flagsmith client is not ready or properly initialized + class ProviderNotReadyError < FlagsmithError + def initialize(message = "Flagsmith provider is not ready") + super( + SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + message + ) + end + end + + # Raised when there's an error parsing flag values + class ParseError < FlagsmithError + def initialize(message) + super( + SDK::Provider::ErrorCode::PARSE_ERROR, + "Failed to parse flag value: #{message}" + ) + end + end + + # Raised for general Flagsmith SDK errors + class FlagsmithClientError < FlagsmithError + def initialize(message) + super( + SDK::Provider::ErrorCode::GENERAL, + "Flagsmith client error: #{message}" + ) + end + end + + # Raised when evaluation context is invalid + class InvalidContextError < FlagsmithError + def initialize(message) + super( + SDK::Provider::ErrorCode::INVALID_CONTEXT, + "Invalid evaluation context: #{message}" + ) + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb new file mode 100644 index 0000000..ee087e8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/options.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "uri" + +module OpenFeature + module Flagsmith + # Configuration options for the Flagsmith OpenFeature provider + class Options + attr_reader :environment_key, :api_url, :enable_local_evaluation, + :request_timeout_seconds, :enable_analytics, + :environment_refresh_interval_seconds + + DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/" + DEFAULT_REQUEST_TIMEOUT = 10 + DEFAULT_REFRESH_INTERVAL = 60 + + def initialize( + environment_key:, + api_url: DEFAULT_API_URL, + enable_local_evaluation: false, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT, + enable_analytics: false, + environment_refresh_interval_seconds: DEFAULT_REFRESH_INTERVAL + ) + validate_environment_key(environment_key: environment_key) + validate_api_url(api_url: api_url) + validate_timeout(timeout: request_timeout_seconds) + validate_refresh_interval(interval: environment_refresh_interval_seconds) + + @environment_key = environment_key + @api_url = api_url + @enable_local_evaluation = enable_local_evaluation + @request_timeout_seconds = request_timeout_seconds + @enable_analytics = enable_analytics + @environment_refresh_interval_seconds = environment_refresh_interval_seconds + end + + def local_evaluation? + @enable_local_evaluation + end + + def analytics_enabled? + @enable_analytics + end + + private + + def validate_environment_key(environment_key: nil) + if environment_key.nil? || environment_key.to_s.strip.empty? + raise ArgumentError, "environment_key is required and cannot be empty" + end + end + + def validate_api_url(api_url: nil) + return if api_url.nil? + + uri = URI.parse(api_url) + unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + raise ArgumentError, "Invalid URL for api_url: #{api_url}" + end + rescue URI::InvalidURIError + raise ArgumentError, "Invalid URL for api_url: #{api_url}" + end + + def validate_timeout(timeout: nil) + return if timeout.nil? + + unless timeout.is_a?(Integer) && timeout.positive? + raise ArgumentError, "request_timeout_seconds must be a positive integer" + end + end + + def validate_refresh_interval(interval: nil) + return if interval.nil? + + unless interval.is_a?(Integer) && interval.positive? + raise ArgumentError, "environment_refresh_interval_seconds must be a positive integer" + end + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb new file mode 100644 index 0000000..281d431 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require "open_feature/sdk" +require "json" +require_relative "options" +require_relative "error/errors" + +module OpenFeature + module Flagsmith + # OpenFeature provider for Flagsmith + class Provider + PROVIDER_NAME = "Flagsmith Provider" + attr_reader :metadata, :options + + def initialize(options:) + @metadata = SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME) + @options = options + @flagsmith_client = nil + end + + def init + # Initialize Flagsmith client + @flagsmith_client = create_flagsmith_client + end + + def shutdown + # Cleanup Flagsmith client resources + # Note: Flagsmith Ruby SDK doesn't require explicit cleanup as of version 4.3 + # If future versions add cleanup methods, they should be called here + @flagsmith_client = nil + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + evaluate( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + allowed_type_classes: [TrueClass, FalseClass] + ) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + evaluate( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + allowed_type_classes: [String] + ) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + evaluate( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + allowed_type_classes: [Integer, Float, Numeric] + ) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + evaluate( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + allowed_type_classes: [Integer] + ) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + evaluate( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + allowed_type_classes: [Float] + ) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + evaluate( + flag_key: flag_key, + default_value: default_value, + evaluation_context: evaluation_context, + allowed_type_classes: [Hash, Array] + ) + end + + private + + def create_flagsmith_client + require "flagsmith" + + ::Flagsmith::Client.new( + environment_key: @options.environment_key, + api_url: @options.api_url, + enable_local_evaluation: @options.local_evaluation?, + request_timeout_seconds: @options.request_timeout_seconds, + enable_analytics: @options.analytics_enabled?, + environment_refresh_interval_seconds: @options.environment_refresh_interval_seconds + ) + rescue => e + raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.message}" + end + + def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classes:) + # Check if provider is initialized + if @flagsmith_client.nil? + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + error_message: "Provider not initialized. Call init() first." + ) + end + + evaluation_context ||= SDK::EvaluationContext.new + + begin + # Get flags from Flagsmith + flags = get_flags(evaluation_context) + + # Check if flag exists + unless flag_exists?(flags, flag_key) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag '#{flag_key}' not found" + ) + end + + # Get flag value + value = get_flag_value(flags, flag_key, allowed_type_classes) + + # Validate type + unless type_matches?(value, allowed_type_classes) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "Expected type #{allowed_type_classes}, got #{value.class}" + ) + end + + # Return successful resolution + SDK::Provider::ResolutionDetails.new( + value: value, + reason: determine_reason(evaluation_context), + variant: nil, # Flagsmith doesn't have explicit variants + flag_metadata: {} + ) + rescue FlagsmithError => e + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: e.error_code, + error_message: e.error_message + ) + rescue => e + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::GENERAL, + error_message: "Unexpected error: #{e.message}" + ) + end + end + + def get_flags(evaluation_context) + raise ProviderNotReadyError, "Flagsmith client not initialized" if @flagsmith_client.nil? + + targeting_key = evaluation_context.targeting_key + if targeting_key.nil? || targeting_key.to_s.strip.empty? + @flagsmith_client.get_environment_flags + else + traits = evaluation_context.fields.reject { |k, _v| k == :targeting_key || k == "targeting_key" } + @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) + end + rescue => e + raise FlagsmithClientError, e.message + end + + def flag_exists?(flags, flag_key) + # Try to get the flag value - if it returns nil and feature is not enabled, flag doesn't exist + + value = flags.get_feature_value(flag_key) + # Flag exists if value is not nil OR if is_feature_enabled returns true (for boolean flags) + !value.nil? || flags.is_feature_enabled(flag_key) + rescue NoMethodError + # Handle case where flags object doesn't respond to expected methods + false + + # Note: Let other exceptions bubble up to be caught by evaluate's rescue block + # This ensures network errors, API errors, etc. are properly reported as ERROR + # rather than being treated as "flag not found" + end + + def get_flag_value(flags, flag_key, allowed_type_classes) + # For boolean flags, use is_feature_enabled + if allowed_type_classes == [TrueClass, FalseClass] + return flags.is_feature_enabled(flag_key) + end + + # For other types, get the feature value + value = flags.get_feature_value(flag_key) + + # Handle JSON objects/arrays + if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } + return parse_json_value(value) + end + + # Handle numeric types + if [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } + return parse_numeric_value(value, allowed_type_classes) + end + + # Return as-is for strings + value + end + + def parse_json_value(value) + return value if value.is_a?(Hash) || value.is_a?(Array) + return nil if value.nil? + + JSON.parse(value.to_s) + rescue JSON::ParserError => e + raise ParseError, e.message + end + + def parse_numeric_value(value, allowed_type_classes) + # If already the right type, return it + return value if allowed_type_classes.any? { |klass| value.is_a?(klass) } + return nil if value.nil? + + # Try to parse string to numeric + if value.is_a?(String) + if allowed_type_classes.include?(Integer) + return Integer(value) + elsif allowed_type_classes.include?(Float) + return Float(value) + else + # For Numeric, try Integer first, then Float + begin + return Integer(value) + rescue ArgumentError, TypeError + return Float(value) + end + end + end + + # Safe numeric type conversions (following Flipt provider pattern) + if value.is_a?(Numeric) + # Integer → Float: always safe (no precision loss) + if value.is_a?(Integer) && allowed_type_classes == [Float] + return value.to_f + end + + # Float → Integer: only if it's a whole number (prevents data loss) + # Example: 3.0 → 3 (OK), but 3.99 → fails type check (ERROR) + if value.is_a?(Float) && allowed_type_classes == [Integer] + return value.to_i if value.to_i == value + end + + # For generic fetch_number_value (accepts any numeric type), return as-is + # This handles [Integer, Float, Numeric] case + end + + value + rescue ArgumentError, TypeError => e + raise ParseError, "Cannot convert '#{value}' to numeric type: #{e.message}" + end + + def type_matches?(value, allowed_type_classes) + allowed_type_classes.any? { |klass| value.is_a?(klass) } + end + + def determine_reason(evaluation_context) + # Use TARGETING_MATCH if we have targeting_key (identity-specific) + # Use STATIC for environment-level flags + targeting_key = evaluation_context.targeting_key + if targeting_key.nil? || targeting_key.to_s.strip.empty? + SDK::Provider::Reason::STATIC + else + SDK::Provider::Reason::TARGETING_MATCH + end + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb new file mode 100644 index 0000000..2e9a9a8 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/version.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module OpenFeature + module Flagsmith + VERSION = "0.1.0" + end +end diff --git a/providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec b/providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec new file mode 100644 index 0000000..aa6552c --- /dev/null +++ b/providers/openfeature-flagsmith-provider/openfeature-flagsmith-provider.gemspec @@ -0,0 +1,38 @@ +require_relative "lib/openfeature/flagsmith/version" + +Gem::Specification.new do |spec| + spec.name = "openfeature-flagsmith-provider" + spec.version = OpenFeature::Flagsmith::VERSION + spec.authors = ["OpenFeature Contributors"] + spec.email = ["cncf-openfeature-contributors@lists.cncf.io"] + + spec.summary = "OpenFeature provider for Flagsmith" + spec.description = "Flagsmith provider for the OpenFeature Ruby SDK" + spec.homepage = "https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-flagsmith-provider" + spec.license = "Apache-2.0" + spec.required_ruby_version = ">= 3.1" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "https://github.com/open-feature/ruby-sdk-contrib/issues" + spec.metadata["documentation_uri"] = "#{spec.homepage}/README.md" + + spec.files = Dir.chdir(File.expand_path(__dir__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + + spec.require_paths = ["lib"] + + # Runtime dependencies + spec.add_runtime_dependency "openfeature-sdk", "~> 0.3.1" + spec.add_runtime_dependency "flagsmith", "~> 4.3" + + # Development dependencies + spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rspec", "~> 3.12.0" + spec.add_development_dependency "webmock", "~> 3.0" + spec.add_development_dependency "standard", "~> 1.0" + spec.add_development_dependency "rubocop", "~> 1.0" + spec.add_development_dependency "simplecov", "~> 0.22" +end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb new file mode 100644 index 0000000..860e66d --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/errors_spec.rb @@ -0,0 +1,72 @@ +require "spec_helper" +require "openfeature/flagsmith/error/errors" + +RSpec.describe "Flagsmith Errors" do + describe OpenFeature::Flagsmith::FlagNotFoundError do + it "should create error with FLAG_NOT_FOUND error code" do + error = OpenFeature::Flagsmith::FlagNotFoundError.new("my_flag") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + expect(error.error_message).to eq("Flag not found: my_flag") + expect(error.message).to eq("Flag not found: my_flag") + end + + it "should be a StandardError" do + error = OpenFeature::Flagsmith::FlagNotFoundError.new("test") + expect(error).to be_a(StandardError) + end + end + + describe OpenFeature::Flagsmith::TypeMismatchError do + it "should create error with TYPE_MISMATCH error code" do + error = OpenFeature::Flagsmith::TypeMismatchError.new([String], Integer) + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + expect(error.error_message).to include("Expected type") + expect(error.error_message).to include("Integer") + end + end + + describe OpenFeature::Flagsmith::ProviderNotReadyError do + it "should create error with PROVIDER_NOT_READY error code" do + error = OpenFeature::Flagsmith::ProviderNotReadyError.new + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) + expect(error.error_message).to eq("Flagsmith provider is not ready") + end + + it "should accept custom message" do + error = OpenFeature::Flagsmith::ProviderNotReadyError.new("Client not initialized") + expect(error.error_message).to eq("Client not initialized") + end + end + + describe OpenFeature::Flagsmith::ParseError do + it "should create error with PARSE_ERROR error code" do + error = OpenFeature::Flagsmith::ParseError.new("invalid JSON") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + expect(error.error_message).to eq("Failed to parse flag value: invalid JSON") + end + end + + describe OpenFeature::Flagsmith::FlagsmithClientError do + it "should create error with GENERAL error code" do + error = OpenFeature::Flagsmith::FlagsmithClientError.new("connection timeout") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + expect(error.error_message).to eq("Flagsmith client error: connection timeout") + end + end + + describe OpenFeature::Flagsmith::InvalidContextError do + it "should create error with INVALID_CONTEXT error code" do + error = OpenFeature::Flagsmith::InvalidContextError.new("missing required field") + expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::INVALID_CONTEXT) + expect(error.error_message).to eq("Invalid evaluation context: missing required field") + end + end + + describe OpenFeature::Flagsmith::FlagsmithError do + it "should be the base class for all Flagsmith errors" do + expect(OpenFeature::Flagsmith::FlagNotFoundError.new("test")).to be_a(OpenFeature::Flagsmith::FlagsmithError) + expect(OpenFeature::Flagsmith::TypeMismatchError.new([], nil)).to be_a(OpenFeature::Flagsmith::FlagsmithError) + expect(OpenFeature::Flagsmith::ProviderNotReadyError.new).to be_a(OpenFeature::Flagsmith::FlagsmithError) + end + end +end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb new file mode 100644 index 0000000..7d44694 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/options_spec.rb @@ -0,0 +1,183 @@ +require "spec_helper" + +RSpec.describe OpenFeature::Flagsmith::Options do + describe "#initialize" do + context "with valid environment_key" do + it "should create options with required environment_key" do + options = OpenFeature::Flagsmith::Options.new(environment_key: "test_key_123") + expect(options.environment_key).to eq("test_key_123") + end + + it "should use default api_url when not provided" do + options = OpenFeature::Flagsmith::Options.new(environment_key: "test_key") + expect(options.api_url).to eq("https://edge.api.flagsmith.com/api/v1/") + end + + it "should use default values for optional parameters" do + options = OpenFeature::Flagsmith::Options.new(environment_key: "test_key") + expect(options.enable_local_evaluation).to be false + expect(options.request_timeout_seconds).to eq(10) + expect(options.enable_analytics).to be false + expect(options.environment_refresh_interval_seconds).to eq(60) + end + + it "should accept custom values for all parameters" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "https://custom.flagsmith.com/api/v1/", + enable_local_evaluation: true, + request_timeout_seconds: 30, + enable_analytics: true, + environment_refresh_interval_seconds: 120 + ) + expect(options.environment_key).to eq("test_key") + expect(options.api_url).to eq("https://custom.flagsmith.com/api/v1/") + expect(options.enable_local_evaluation).to be true + expect(options.request_timeout_seconds).to eq(30) + expect(options.enable_analytics).to be true + expect(options.environment_refresh_interval_seconds).to eq(120) + end + end + + context "environment_key validation" do + it "should raise error when environment_key is nil" do + expect { + OpenFeature::Flagsmith::Options.new(environment_key: nil) + }.to raise_error(ArgumentError, "environment_key is required and cannot be empty") + end + + it "should raise error when environment_key is empty string" do + expect { + OpenFeature::Flagsmith::Options.new(environment_key: "") + }.to raise_error(ArgumentError, "environment_key is required and cannot be empty") + end + + it "should raise error when environment_key is whitespace only" do + expect { + OpenFeature::Flagsmith::Options.new(environment_key: " ") + }.to raise_error(ArgumentError, "environment_key is required and cannot be empty") + end + end + + context "api_url validation" do + it "should accept valid http url" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "http://localhost:8000/api/v1/" + ) + expect(options.api_url).to eq("http://localhost:8000/api/v1/") + end + + it "should accept valid https url" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "https://custom.flagsmith.com/api/v1/" + ) + expect(options.api_url).to eq("https://custom.flagsmith.com/api/v1/") + end + + it "should raise error for invalid url" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "not_a_url" + ) + }.to raise_error(ArgumentError, "Invalid URL for api_url: not_a_url") + end + + it "should raise error for non-http(s) url" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + api_url: "ftp://flagsmith.com" + ) + }.to raise_error(ArgumentError, "Invalid URL for api_url: ftp://flagsmith.com") + end + end + + context "request_timeout_seconds validation" do + it "should raise error for non-integer timeout" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + request_timeout_seconds: "10" + ) + }.to raise_error(ArgumentError, "request_timeout_seconds must be a positive integer") + end + + it "should raise error for negative timeout" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + request_timeout_seconds: -5 + ) + }.to raise_error(ArgumentError, "request_timeout_seconds must be a positive integer") + end + + it "should raise error for zero timeout" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + request_timeout_seconds: 0 + ) + }.to raise_error(ArgumentError, "request_timeout_seconds must be a positive integer") + end + end + + context "environment_refresh_interval_seconds validation" do + it "should raise error for non-integer interval" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + environment_refresh_interval_seconds: "60" + ) + }.to raise_error(ArgumentError, "environment_refresh_interval_seconds must be a positive integer") + end + + it "should raise error for negative interval" do + expect { + OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + environment_refresh_interval_seconds: -10 + ) + }.to raise_error(ArgumentError, "environment_refresh_interval_seconds must be a positive integer") + end + end + end + + describe "#local_evaluation?" do + it "should return false when local evaluation is disabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_local_evaluation: false + ) + expect(options.local_evaluation?).to be false + end + + it "should return true when local evaluation is enabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_local_evaluation: true + ) + expect(options.local_evaluation?).to be true + end + end + + describe "#analytics_enabled?" do + it "should return false when analytics is disabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_analytics: false + ) + expect(options.analytics_enabled?).to be false + end + + it "should return true when analytics is enabled" do + options = OpenFeature::Flagsmith::Options.new( + environment_key: "test_key", + enable_analytics: true + ) + expect(options.analytics_enabled?).to be true + end + end +end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb new file mode 100644 index 0000000..8fff152 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -0,0 +1,735 @@ +require "spec_helper" +require "openfeature/flagsmith/provider" + +RSpec.describe OpenFeature::Flagsmith::Provider do + let(:options) do + OpenFeature::Flagsmith::Options.new(environment_key: "test_key_123") + end + + let(:provider) { described_class.new(options: options) } + + let(:mock_flagsmith_client) { instance_double("Flagsmith::Client") } + let(:mock_flags) { double("Flags") } + + before do + # Mock Flagsmith::Client creation + allow(::Flagsmith::Client).to receive(:new).and_return(mock_flagsmith_client) + end + + describe "#initialize" do + it "should create provider with options" do + expect(provider.options).to eq(options) + end + + it "should set metadata with provider name" do + expect(provider.metadata).to be_a(OpenFeature::SDK::Provider::ProviderMetadata) + expect(provider.metadata.name).to eq("Flagsmith Provider") + end + end + + describe "#metadata" do + it "should return provider metadata" do + expect(provider.metadata.name).to eq("Flagsmith Provider") + end + end + + describe "#init" do + it "should initialize Flagsmith client" do + expect(::Flagsmith::Client).to receive(:new).with( + environment_key: "test_key_123", + api_url: "https://edge.api.flagsmith.com/api/v1/", + enable_local_evaluation: false, + request_timeout_seconds: 10, + enable_analytics: false, + environment_refresh_interval_seconds: 60 + ).and_return(mock_flagsmith_client) + + expect { provider.init }.not_to raise_error + end + end + + describe "#shutdown" do + it "should shutdown without error" do + expect { provider.shutdown }.not_to raise_error + end + end + + describe "fetch methods" do + let(:flag_key) { "test_flag" } + let(:evaluation_context) do + OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + end + + before do + provider.init + end + + describe "#fetch_boolean_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should return actual flag value when flag exists" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return("something") + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(true) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + expect(result.error_code).to be_nil + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + end + + it "should work without evaluation context" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: true + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + expect(result.value).to eq(true) + end + + it "should handle non-string targeting_key gracefully" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: 12345) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + end + + describe "#fetch_string_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should return actual string value when flag exists" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return("hello_world") + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + expect(result.value).to eq("hello_world") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default_string", + evaluation_context: evaluation_context + ) + expect(result.value).to eq("default_string") + end + end + + describe "#fetch_number_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 42, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should parse numeric string value" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return("123") + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 0, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(123) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return actual numeric value" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(456) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 0, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(456) + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_number_value( + flag_key: flag_key, + default_value: 123, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(123) + end + end + + describe "#fetch_integer_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_integer_value( + flag_key: flag_key, + default_value: 42, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + end + + describe "#fetch_float_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_float_value( + flag_key: flag_key, + default_value: 3.14, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + end + + describe "#fetch_object_value" do + it "should return ResolutionDetails" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {key: "value"}, + evaluation_context: evaluation_context + ) + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should parse JSON string to hash" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return('{"color":"red","size":42}') + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {}, + evaluation_context: evaluation_context + ) + expect(result.value).to eq({"color" => "red", "size" => 42}) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return hash value directly" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return({foo: "bar"}) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {}, + evaluation_context: evaluation_context + ) + expect(result.value).to eq({foo: "bar"}) + end + + it "should return default value when flag not found" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {default: true}, + evaluation_context: evaluation_context + ) + expect(result.value).to eq({default: true}) + end + end + end + + describe "reason determination" do + before do + provider.init + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + end + + it "should use STATIC reason for environment-level flags (no targeting_key)" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + + evaluation_context = OpenFeature::SDK::EvaluationContext.new + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: evaluation_context + ) + # Returns DEFAULT because flag doesn't exist + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + end + + it "should use TARGETING_MATCH reason for identity-specific flags" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + + evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: evaluation_context + ) + # Returns DEFAULT because flag doesn't exist + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + end + end + + describe "error handling scenarios" do + let(:flag_key) { "test_flag" } + let(:evaluation_context) do + OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + end + + before do + provider.init + end + + describe "when provider is not initialized" do + it "should return error when client is nil" do + provider_uninit = described_class.new(options: options) + # Don't call init + + result = provider_uninit.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) + expect(result.error_message).to include("Provider not initialized") + end + end + + describe "when Flagsmith client raises errors" do + it "should handle network errors from get_identity_flags" do + allow(mock_flagsmith_client).to receive(:get_identity_flags) + .and_raise(StandardError.new("Network timeout")) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(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 include("Network timeout") + end + + it "should handle network errors from get_environment_flags" do + allow(mock_flagsmith_client).to receive(:get_environment_flags) + .and_raise(StandardError.new("Connection refused")) + + result = provider.fetch_boolean_value( + flag_key: flag_key, + default_value: false, + evaluation_context: nil + ) + + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + end + + it "should handle errors when getting flags object itself" do + # Test error when get_identity_flags itself fails (not the flag methods) + allow(mock_flagsmith_client).to receive(:get_identity_flags) + .and_raise(StandardError.new("API error")) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + + expect(result.value).to eq("default") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::GENERAL) + end + end + + describe "JSON parsing errors" do + it "should handle malformed JSON in object values" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return("{invalid json") + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_object_value( + flag_key: flag_key, + default_value: {default: true}, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq({default: true}) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + end + end + + describe "type mismatch errors" do + it "should return error when boolean flag returns non-boolean value" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + # is_feature_enabled should return boolean, but let's test type validation + allow(mock_flags).to receive(:get_feature_value).and_return("not_a_boolean") + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_string_value( + flag_key: flag_key, + default_value: "default", + evaluation_context: evaluation_context + ) + + expect(result.value).to eq("not_a_boolean") + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + end + + it "should return error when string value cannot be converted to integer" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return("not_a_number") + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_integer_value( + flag_key: flag_key, + default_value: 42, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(42) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::ERROR) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PARSE_ERROR) + end + end + + describe "Flagsmith client initialization errors" do + it "should raise ProviderNotReadyError when Flagsmith::Client.new fails" do + allow(::Flagsmith::Client).to receive(:new).and_raise(StandardError.new("Invalid API key")) + + provider_new = described_class.new(options: options) + + expect { + provider_new.init + }.to raise_error(OpenFeature::Flagsmith::ProviderNotReadyError, /Failed to create Flagsmith client/) + end + end + end + + describe "edge cases" do + let(:evaluation_context) do + OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") + end + + before do + provider.init + end + + describe "empty or nil flag keys" do + it "should handle empty string flag key" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with("").and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with("").and_return(false) + + result = provider.fetch_boolean_value( + flag_key: "", + default_value: false, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + end + end + + describe "special characters in flag keys" do + it "should handle flag keys with special characters" do + special_key = "flag-with-dashes_and_underscores.and.dots" + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(special_key).and_return("value") + allow(mock_flags).to receive(:is_feature_enabled).with(special_key).and_return(true) + + result = provider.fetch_string_value( + flag_key: special_key, + default_value: "default", + evaluation_context: evaluation_context + ) + + expect(result.value).to eq("value") + end + + it "should handle unicode in flag keys" do + unicode_key = "flag_with_émojis_🚀" + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(unicode_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(unicode_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: unicode_key, + default_value: true, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(true) + end + end + + describe "evaluation context edge cases" do + it "should handle empty string targeting_key" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "") + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: context + ) + + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should handle whitespace-only targeting_key" do + allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + + context = OpenFeature::SDK::EvaluationContext.new(targeting_key: " ") + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: context + ) + + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should handle nil values in context fields (traits)" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).and_return(false) + + context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: "user_123", + email: nil, + age: nil + ) + + result = provider.fetch_boolean_value( + flag_key: "test", + default_value: false, + evaluation_context: context + ) + + expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) + end + + it "should handle unicode in trait values" do + # OpenFeature SDK uses string keys for evaluation context fields + allow(mock_flagsmith_client).to receive(:get_identity_flags) + .with("user_123", {"name" => "François", "location" => "Montréal 🇨🇦"}) + .and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return("value") + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: "user_123", + name: "François", + location: "Montréal 🇨🇦" + ) + + result = provider.fetch_string_value( + flag_key: "test", + default_value: "default", + evaluation_context: context + ) + + expect(result.value).to eq("value") + end + end + + describe "numeric type edge cases" do + it "should handle zero values" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(0) + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_integer_value( + flag_key: "test", + default_value: 42, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(0) + end + + it "should handle negative numbers" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(-999) + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_integer_value( + flag_key: "test", + default_value: 0, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(-999) + end + + it "should handle very large numbers" do + large_num = 999_999_999_999 + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(large_num) + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_integer_value( + flag_key: "test", + default_value: 0, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(large_num) + end + + it "should handle scientific notation in strings" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return("1.5e2") + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_float_value( + flag_key: "test", + default_value: 0.0, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(150.0) + end + end + + describe "object/array edge cases" do + it "should handle empty objects" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return({}) + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_object_value( + flag_key: "test", + default_value: {default: true}, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq({}) + end + + it "should handle empty arrays" do + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return([]) + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_object_value( + flag_key: "test", + default_value: [], + evaluation_context: evaluation_context + ) + + expect(result.value).to eq([]) + end + + it "should handle nested objects" do + nested = {outer: {inner: {deep: "value"}}} + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).and_return(nested) + allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + + result = provider.fetch_object_value( + flag_key: "test", + default_value: {}, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(nested) + end + end + end +end diff --git a/providers/openfeature-flagsmith-provider/spec/spec_helper.rb b/providers/openfeature-flagsmith-provider/spec/spec_helper.rb new file mode 100644 index 0000000..95b7996 --- /dev/null +++ b/providers/openfeature-flagsmith-provider/spec/spec_helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "rspec" +require "open_feature/sdk" +require "webmock/rspec" +require "flagsmith" + +# Require our provider files +require_relative "../lib/openfeature/flagsmith/version" +require_relative "../lib/openfeature/flagsmith/options" +require_relative "../lib/openfeature/flagsmith/error/errors" +require_relative "../lib/openfeature/flagsmith/provider" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.disable_monkey_patching! + config.warnings = true + config.order = :random + Kernel.srand config.seed +end diff --git a/release-please-config.json b/release-please-config.json index d8cc709..a14d836 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -41,6 +41,16 @@ "extra-files": [ "README.md" ] + }, + "providers/openfeature-flagsmith-provider": { + "package-name": "openfeature-flagsmith-provider", + "version-file": "lib/openfeature/flagsmith/version.rb", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default", + "extra-files": [ + "README.md" + ] } }, "changelog-sections": [ From 133fe3a7688f393dd37196e772bf9a1d43fec1cd Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 18 Nov 2025 11:04:46 +0100 Subject: [PATCH 14/31] feat: improved-flag-not-found-handlings Signed-off-by: wadii --- .../openfeature-flagsmith-provider/README.md | 9 ++-- .../lib/openfeature/flagsmith/provider.rb | 36 +++++++------- .../openfeature/flagsmith/provider_spec.rb | 48 +++++++------------ 3 files changed, 38 insertions(+), 55 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md index aabefc3..e8c992d 100644 --- a/providers/openfeature-flagsmith-provider/README.md +++ b/providers/openfeature-flagsmith-provider/README.md @@ -269,11 +269,12 @@ The provider returns appropriate reasons for flag evaluations: | Reason | Description | |--------|-------------| -| `TARGETING_MATCH` | Flag evaluated with user identity and targeting rules | -| `STATIC` | Flag evaluated at environment level (no user context) | -| `DEFAULT` | Default value returned (flag not found or disabled) | +| `TARGETING_MATCH` | Flag evaluated with user identity (targeting_key provided) | +| `STATIC` | Flag evaluated at environment level (no targeting_key) | +| `DEFAULT` | Default value returned due to flag not found | | `ERROR` | An error occurred during evaluation | -| `CACHED` | Value returned from local cache (local evaluation mode) | + +**Note**: Both remote and local evaluation modes use the same reason mapping (STATIC/TARGETING_MATCH). Local evaluation performs flag evaluation locally but still evaluates the flag state, it doesn't return cached results. ## Development diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index 281d431..c46ea9b 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -112,14 +112,28 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe ) end + # Check for invalid flag keys + if flag_key.nil? || flag_key.to_s.empty? + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag key cannot be empty or nil" + ) + end + evaluation_context ||= SDK::EvaluationContext.new begin # Get flags from Flagsmith flags = get_flags(evaluation_context) - # Check if flag exists - unless flag_exists?(flags, flag_key) + # Get flag value + value = get_flag_value(flags, flag_key, allowed_type_classes) + + # Check if flag exists (but skip for boolean flags since false is a valid value) + is_boolean_flag = allowed_type_classes == [TrueClass, FalseClass] + if !is_boolean_flag && value.nil? return SDK::Provider::ResolutionDetails.new( value: default_value, reason: SDK::Provider::Reason::DEFAULT, @@ -128,9 +142,6 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe ) end - # Get flag value - value = get_flag_value(flags, flag_key, allowed_type_classes) - # Validate type unless type_matches?(value, allowed_type_classes) return SDK::Provider::ResolutionDetails.new( @@ -179,21 +190,6 @@ def get_flags(evaluation_context) raise FlagsmithClientError, e.message end - def flag_exists?(flags, flag_key) - # Try to get the flag value - if it returns nil and feature is not enabled, flag doesn't exist - - value = flags.get_feature_value(flag_key) - # Flag exists if value is not nil OR if is_feature_enabled returns true (for boolean flags) - !value.nil? || flags.is_feature_enabled(flag_key) - rescue NoMethodError - # Handle case where flags object doesn't respond to expected methods - false - - # Note: Let other exceptions bubble up to be caught by evaluate's rescue block - # This ensures network errors, API errors, etc. are properly reported as ERROR - # rather than being treated as "flag not found" - end - def get_flag_value(flags, flag_key, allowed_type_classes) # For boolean flags, use is_feature_enabled if allowed_type_classes == [TrueClass, FalseClass] diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb index 8fff152..37eb293 100644 --- a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -93,19 +93,19 @@ expect(result.error_code).to be_nil end - it "should return default value when flag not found" do + it "should return false for disabled boolean flags" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) result = provider.fetch_boolean_value( flag_key: flag_key, - default_value: false, + default_value: true, evaluation_context: evaluation_context ) expect(result.value).to eq(false) - expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) - expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) + expect(result.error_code).to be_nil end it "should work without evaluation context" do @@ -118,7 +118,8 @@ default_value: true ) expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) - expect(result.value).to eq(true) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) end it "should handle non-string targeting_key gracefully" do @@ -332,11 +333,12 @@ evaluation_context = OpenFeature::SDK::EvaluationContext.new result = provider.fetch_boolean_value( flag_key: "test", - default_value: false, + default_value: true, evaluation_context: evaluation_context ) - # Returns DEFAULT because flag doesn't exist - expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + # Boolean flags always return their value with STATIC/TARGETING_MATCH reason + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) end it "should use TARGETING_MATCH reason for identity-specific flags" do @@ -345,11 +347,12 @@ evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") result = provider.fetch_boolean_value( flag_key: "test", - default_value: false, + default_value: true, evaluation_context: evaluation_context ) - # Returns DEFAULT because flag doesn't exist - expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + # Flagsmith treats non-existent flags as disabled flags + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) end end @@ -512,11 +515,11 @@ result = provider.fetch_boolean_value( flag_key: "", - default_value: false, + default_value: true, evaluation_context: evaluation_context ) - expect(result.value).to eq(false) + expect(result.value).to eq(true) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) end end @@ -537,20 +540,6 @@ expect(result.value).to eq("value") end - it "should handle unicode in flag keys" do - unicode_key = "flag_with_émojis_🚀" - allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(unicode_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(unicode_key).and_return(false) - - result = provider.fetch_boolean_value( - flag_key: unicode_key, - default_value: true, - evaluation_context: evaluation_context - ) - - expect(result.value).to eq(true) - end end describe "evaluation context edge cases" do @@ -605,10 +594,7 @@ end it "should handle unicode in trait values" do - # OpenFeature SDK uses string keys for evaluation context fields - allow(mock_flagsmith_client).to receive(:get_identity_flags) - .with("user_123", {"name" => "François", "location" => "Montréal 🇨🇦"}) - .and_return(mock_flags) + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) allow(mock_flags).to receive(:get_feature_value).and_return("value") allow(mock_flags).to receive(:is_feature_enabled).and_return(true) From 7011ead53cf44aaa1cccdffd674369d50dcba3ec Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 18 Nov 2025 11:08:00 +0100 Subject: [PATCH 15/31] feat: docs Signed-off-by: wadii --- providers/openfeature-flagsmith-provider/.context.md | 1 - providers/openfeature-flagsmith-provider/README.md | 3 --- 2 files changed, 4 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/.context.md b/providers/openfeature-flagsmith-provider/.context.md index 7c47af8..e5be6c1 100644 --- a/providers/openfeature-flagsmith-provider/.context.md +++ b/providers/openfeature-flagsmith-provider/.context.md @@ -1,6 +1,5 @@ # Flagsmith OpenFeature Provider - Context -**Status:** 🚧 In Development **Started:** 2025-11-17 **Full Design Doc:** `./FLAGSMITH_PROVIDER_DESIGN.md` diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md index e8c992d..dadbafa 100644 --- a/providers/openfeature-flagsmith-provider/README.md +++ b/providers/openfeature-flagsmith-provider/README.md @@ -1,7 +1,5 @@ # OpenFeature Flagsmith Provider for Ruby -[![Project Status: WIP](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) - This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature flags, implementing the [OpenFeature](https://openfeature.dev/) standard. ## Features @@ -20,7 +18,6 @@ This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature fl | ✅ | Local Evaluation | Optional local evaluation mode | | ✅ | Error Handling | Comprehensive error handling | | ✅ | Type Validation | Strict type checking | -| 🚧 | Flagsmith Integration | Pending Flagsmith gem version | ## Installation From 784932f9d2f38fd2c7d512a1b6457ec4d1ea5199 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 18 Nov 2025 11:22:02 +0100 Subject: [PATCH 16/31] feat: removed-cached-reason-from-doc-as-not-used Signed-off-by: wadii --- providers/openfeature-flagsmith-provider/.context.md | 1 - .../FLAGSMITH_PROVIDER_DESIGN.md | 3 +-- .../lib/openfeature/flagsmith/provider.rb | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/.context.md b/providers/openfeature-flagsmith-provider/.context.md index e5be6c1..b499f7a 100644 --- a/providers/openfeature-flagsmith-provider/.context.md +++ b/providers/openfeature-flagsmith-provider/.context.md @@ -96,7 +96,6 @@ evaluation_context.fields → traits (as keyword args) | Flag not found | `DEFAULT` | | Flag exists but disabled | `DISABLED` | | Error occurred | `ERROR` | -| Local evaluation mode | `CACHED` | ### 4. Error Handling ```ruby diff --git a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md index a7dfe87..c0461f8 100644 --- a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md +++ b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md @@ -122,7 +122,6 @@ OpenFeature::SDK::Provider::ResolutionDetails.new( - `DISABLED` - Feature is disabled - `ERROR` - Error during evaluation - `STATIC` - Static value -- `CACHED` - Value from cache #### OpenFeature ErrorCode Constants - `PROVIDER_NOT_READY` @@ -278,10 +277,10 @@ end | Flagsmith State | OpenFeature Reason | |-----------------|-------------------| | Flag evaluated with identity | `TARGETING_MATCH` | +| Flag evaluated at environment level | `STATIC` | | Flag not found | `DEFAULT` | | Flag disabled | `DISABLED` | | Error occurred | `ERROR` | -| Local evaluation | `CACHED` | --- diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index c46ea9b..a272108 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -183,7 +183,7 @@ def get_flags(evaluation_context) if targeting_key.nil? || targeting_key.to_s.strip.empty? @flagsmith_client.get_environment_flags else - traits = evaluation_context.fields.reject { |k, _v| k == :targeting_key || k == "targeting_key" } + traits = evaluation_context.fields.transform_keys(&:to_sym).reject { |k, _v| k == :targeting_key } @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) end rescue => e From 638f513a731a90562979a0adf0470bde86de62d3 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 16:12:31 +0100 Subject: [PATCH 17/31] feat: fixed-number-parsing Signed-off-by: wadii --- .../lib/openfeature/flagsmith/provider.rb | 74 +++++++++---------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index a272108..b616001 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -128,18 +128,35 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe # Get flags from Flagsmith flags = get_flags(evaluation_context) - # Get flag value - value = get_flag_value(flags, flag_key, allowed_type_classes) + is_boolean_flag = (allowed_type_classes.sort == [FalseClass, TrueClass]) - # Check if flag exists (but skip for boolean flags since false is a valid value) - is_boolean_flag = allowed_type_classes == [TrueClass, FalseClass] - if !is_boolean_flag && value.nil? - return SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::DEFAULT, - error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, - error_message: "Flag '#{flag_key}' not found" - ) + if is_boolean_flag + value = flags.is_feature_enabled(flag_key) + else + found_flag = flags.all_flags.find { |f| f.feature_name == flag_key } + + if found_flag.nil? + return SDK::Provider::ResolutionDetails.new( + value: default_value, reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, error_message: "Flag '#{flag_key}' not found" + ) + end + + unless found_flag.enabled + return SDK::Provider::ResolutionDetails.new( + value: default_value, reason: SDK::Provider::Reason::DISABLED, + error_message: "Flag '#{flag_key}' is disabled" + ) + end + + raw_value = found_flag.value + value = if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } + parse_json_value(raw_value) + elsif [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } + parse_numeric_value(raw_value, allowed_type_classes) + else + raw_value + end end # Validate type @@ -156,7 +173,7 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe SDK::Provider::ResolutionDetails.new( value: value, reason: determine_reason(evaluation_context), - variant: nil, # Flagsmith doesn't have explicit variants + variant: nil, # Flagsmith doesn't have explicit variants flag_metadata: {} ) rescue FlagsmithError => e @@ -190,29 +207,6 @@ def get_flags(evaluation_context) raise FlagsmithClientError, e.message end - def get_flag_value(flags, flag_key, allowed_type_classes) - # For boolean flags, use is_feature_enabled - if allowed_type_classes == [TrueClass, FalseClass] - return flags.is_feature_enabled(flag_key) - end - - # For other types, get the feature value - value = flags.get_feature_value(flag_key) - - # Handle JSON objects/arrays - if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } - return parse_json_value(value) - end - - # Handle numeric types - if [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } - return parse_numeric_value(value, allowed_type_classes) - end - - # Return as-is for strings - value - end - def parse_json_value(value) return value if value.is_a?(Hash) || value.is_a?(Array) return nil if value.nil? @@ -229,17 +223,17 @@ def parse_numeric_value(value, allowed_type_classes) # Try to parse string to numeric if value.is_a?(String) - if allowed_type_classes.include?(Integer) - return Integer(value) - elsif allowed_type_classes.include?(Float) - return Float(value) - else + if allowed_type_classes.include?(Numeric) # For Numeric, try Integer first, then Float begin return Integer(value) rescue ArgumentError, TypeError return Float(value) end + elsif allowed_type_classes.include?(Integer) + return Integer(value) + elsif allowed_type_classes.include?(Float) + return Float(value) end end From ce92eb54bdcba97d450b9f79aa8194e1816ee380 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Tue, 18 Nov 2025 11:56:43 -0500 Subject: [PATCH 18/31] chore: update copyright to OpenFeature Maintainers (#67) - update copyright to OpenFeature Maintainers Signed-off-by: Jonathan Norris Signed-off-by: wadii --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 261eeb9..96b3dc8 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright OpenFeature Maintainers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 6f193e925a0cce25a2882ba4d9e37ac4979e7c72 Mon Sep 17 00:00:00 2001 From: Augustinas Date: Wed, 19 Nov 2025 11:20:46 +0200 Subject: [PATCH 19/31] feat(GoFeatureFlag): unix client (#66) Signed-off-by: wadii --- .../go-feature-flag/client/common.rb | 127 ++++++++ .../go-feature-flag/client/http_api.rb | 50 ++++ .../go-feature-flag/client/unix_api.rb | 45 +++ .../go_feature_flag_provider.rb | 17 +- .../openfeature/go-feature-flag/goff_api.rb | 153 ---------- .../go-feature-flag/internal/http_unix.rb | 31 ++ .../openfeature/go-feature-flag/options.rb | 20 +- .../http_api_spec.rb} | 2 +- .../gofeatureflag/client/unix_api_spec.rb | 278 ++++++++++++++++++ .../spec/spec_helper.rb | 3 +- 10 files changed, 564 insertions(+), 162 deletions(-) create mode 100644 providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb create mode 100644 providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb create mode 100644 providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb delete mode 100644 providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb create mode 100644 providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/internal/http_unix.rb rename providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/{goff_api_spec.rb => client/http_api_spec.rb} (99%) create mode 100644 providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb new file mode 100644 index 0000000..62b2490 --- /dev/null +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/common.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "open_feature/sdk" +require_relative "../error/errors" +require_relative "../model/ofrep_api_response" + +module OpenFeature + module GoFeatureFlag + module Client + class Common + def initialize(endpoint: nil, custom_headers: nil) + raise "This should be overwritten by implementations" + end + + def evaluate_ofrep_api(flag_key:, evaluation_context:) + raise "This should be overwritten by implementations" + end + + private + + def headers + {"Content-Type" => "application/json"}.merge(@custom_headers || {}) + end + + def check_retry_after + unless @retry_after.nil? + if Time.now < @retry_after + raise OpenFeature::GoFeatureFlag::RateLimited.new(nil) + else + @retry_after = nil + end + end + end + + def parse_error_response(response) + required_keys = %w[key error_code] + parsed = JSON.parse(response.body) + + missing_keys = required_keys - parsed.keys + unless missing_keys.empty? + raise OpenFeature::GoFeatureFlag::ParseError.new(response) + end + + OpenFeature::GoFeatureFlag::OfrepApiResponse.new( + value: nil, + key: parsed["key"], + reason: SDK::Provider::Reason::ERROR, + variant: nil, + error_code: error_code_mapper(parsed["error_code"]), + error_details: parsed["error_details"], + metadata: nil + ) + end + + def parse_success_response(response) + required_keys = %w[key value reason variant] + parsed = JSON.parse(response.body) + + missing_keys = required_keys - parsed.keys + unless missing_keys.empty? + raise OpenFeature::GoFeatureFlag::ParseError.new(response) + end + + OpenFeature::GoFeatureFlag::OfrepApiResponse.new( + value: parsed["value"], + key: parsed["key"], + reason: reason_mapper(parsed["reason"]), + variant: parsed["variant"], + error_code: nil, + error_details: nil, + metadata: parsed["metadata"] + ) + end + + def reason_mapper(reason_str) + reason_str = reason_str.upcase + reason_map = { + "STATIC" => SDK::Provider::Reason::STATIC, + "DEFAULT" => SDK::Provider::Reason::DEFAULT, + "TARGETING_MATCH" => SDK::Provider::Reason::TARGETING_MATCH, + "SPLIT" => SDK::Provider::Reason::SPLIT, + "CACHED" => SDK::Provider::Reason::CACHED, + "DISABLED" => SDK::Provider::Reason::DISABLED, + "UNKNOWN" => SDK::Provider::Reason::UNKNOWN, + "STALE" => SDK::Provider::Reason::STALE, + "ERROR" => SDK::Provider::Reason::ERROR + } + reason_map[reason_str] || SDK::Provider::Reason::UNKNOWN + end + + def error_code_mapper(error_code_str) + error_code_str = error_code_str.upcase + error_code_map = { + "PROVIDER_NOT_READY" => SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + "FLAG_NOT_FOUND" => SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + "PARSE_ERROR" => SDK::Provider::ErrorCode::PARSE_ERROR, + "TYPE_MISMATCH" => SDK::Provider::ErrorCode::TYPE_MISMATCH, + "TARGETING_KEY_MISSING" => SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, + "INVALID_CONTEXT" => SDK::Provider::ErrorCode::INVALID_CONTEXT, + "GENERAL" => SDK::Provider::ErrorCode::GENERAL + } + error_code_map[error_code_str] || SDK::Provider::ErrorCode::GENERAL + end + + def parse_retry_later_header(response) + retry_after = response["Retry-After"] + return nil if retry_after.nil? + + begin + @retry_after = if /^\d+$/.match?(retry_after) + # Retry-After is in seconds + Time.now + Integer(retry_after) + else + # Retry-After is an HTTP-date + Time.httpdate(retry_after) + end + rescue ArgumentError + # ignore invalid Retry-After header + nil + end + end + end + end + end +end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb new file mode 100644 index 0000000..ef85cbb --- /dev/null +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/http_api.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative "common" +require "faraday/net_http_persistent" + +module OpenFeature + module GoFeatureFlag + module Client + class HttpApi < Common + def initialize(endpoint: nil, custom_headers: nil, instrumentation: nil) + @custom_headers = custom_headers + @faraday_connection = Faraday.new(url: endpoint, headers: headers) do |f| + f.request :instrumentation, instrumentation if instrumentation + f.adapter :net_http_persistent do |http| + http.idle_timeout = 30 + end + end + end + + def evaluate_ofrep_api(flag_key:, evaluation_context:) + check_retry_after + evaluation_context = OpenFeature::SDK::EvaluationContext.new if evaluation_context.nil? + # replace targeting_key by targetingKey + evaluation_context.fields["targetingKey"] = evaluation_context.targeting_key + evaluation_context.fields.delete("targeting_key") + + response = @faraday_connection.post("/ofrep/v1/evaluate/flags/#{flag_key}") do |req| + req.body = {context: evaluation_context.fields}.to_json + end + + case response.status + when 200 + parse_success_response(response) + when 400 + parse_error_response(response) + when 401, 403 + raise OpenFeature::GoFeatureFlag::UnauthorizedError.new(response) + when 404 + raise OpenFeature::GoFeatureFlag::FlagNotFoundError.new(response, flag_key) + when 429 + parse_retry_later_header(response) + raise OpenFeature::GoFeatureFlag::RateLimited.new(response) + else + raise OpenFeature::GoFeatureFlag::InternalServerError.new(response) + end + end + end + end + end +end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb new file mode 100644 index 0000000..b2c9d31 --- /dev/null +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/client/unix_api.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "common" +require_relative "../internal/http_unix" + +module OpenFeature + module GoFeatureFlag + module Client + class UnixApi < Common + attr_accessor :socket + + def initialize(endpoint: nil, custom_headers: nil) + @custom_headers = custom_headers + @socket = HttpUnix.new(endpoint) + end + + def evaluate_ofrep_api(flag_key:, evaluation_context:) + check_retry_after + evaluation_context = OpenFeature::SDK::EvaluationContext.new if evaluation_context.nil? + # replace targeting_key by targetingKey + evaluation_context.fields["targetingKey"] = evaluation_context.targeting_key + evaluation_context.fields.delete("targeting_key") + + response = @socket.post("/ofrep/v1/evaluate/flags/#{flag_key}", {context: evaluation_context.fields}, headers) + + case response.code + when "200" + parse_success_response(response) + when "400" + parse_error_response(response) + when "401", "403" + raise OpenFeature::GoFeatureFlag::UnauthorizedError.new(response) + when "404" + raise OpenFeature::GoFeatureFlag::FlagNotFoundError.new(response, flag_key) + when "429" + parse_retry_later_header(response) + raise OpenFeature::GoFeatureFlag::RateLimited.new(response) + else + raise OpenFeature::GoFeatureFlag::InternalServerError.new(response) + end + end + end + end + end +end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb index d66e6c0..a091dca 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/go_feature_flag_provider.rb @@ -1,16 +1,20 @@ # frozen_string_literal: true +require "openfeature/go-feature-flag/client/http_api" +require "openfeature/go-feature-flag/client/unix_api" + module OpenFeature module GoFeatureFlag # This class is the entry point for the GoFeatureFlagProvider class Provider PROVIDER_NAME = "GO Feature Flag Provider" + Client = OpenFeature::GoFeatureFlag::Client attr_reader :metadata, :options def initialize(options: Options.new) @metadata = SDK::Provider::ProviderMetadata.new(name: PROVIDER_NAME) @options = options - @goff_api = GoFeatureFlagApi.new(endpoint: options.endpoint, custom_headers: options.custom_headers, instrumentation: options.instrumentation) + @goff_api = build_client(options) end def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) @@ -95,6 +99,17 @@ def validate_parameters(flag_key, evaluation_context) raise InvalidOptionError.new(SDK::Provider::ErrorCode::GENERAL, "invalid flag key provided") end end + + def build_client(options) + case options.type + when "http" + Client::HttpApi.new(endpoint: options.endpoint, custom_headers: options.custom_headers, instrumentation: options.instrumentation) + when "unix" + Client::UnixApi.new(endpoint: options.endpoint, custom_headers: options.custom_headers) + else + raise InvalidOptionError.new(SDK::Provider::ErrorCode::GENERAL, "Invalid client type: '#{options.type}'. Supported types are: 'http', 'unix'") + end + end end end end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb deleted file mode 100644 index 67dc0dc..0000000 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/goff_api.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -require "open_feature/sdk" -require "net/http" -require "json" -require "faraday/net_http_persistent" -require_relative "error/errors" -require_relative "model/ofrep_api_response" - -module OpenFeature - module GoFeatureFlag - # This class is the entry point for the GoFeatureFlagProvider - class GoFeatureFlagApi - def initialize(endpoint: nil, custom_headers: nil, instrumentation: nil) - @faraday_connection = Faraday.new(url: endpoint, headers: headers(custom_headers)) do |f| - f.request :instrumentation, instrumentation if instrumentation - f.adapter :net_http_persistent do |http| - http.idle_timeout = 30 - end - end - end - - def evaluate_ofrep_api(flag_key:, evaluation_context:) - unless @retry_after.nil? - if Time.now < @retry_after - raise OpenFeature::GoFeatureFlag::RateLimited.new(nil) - else - @retry_after = nil - end - end - - evaluation_context = OpenFeature::SDK::EvaluationContext.new if evaluation_context.nil? - # replace targeting_key by targetingKey - evaluation_context.fields["targetingKey"] = evaluation_context.targeting_key - evaluation_context.fields.delete("targeting_key") - - response = @faraday_connection.post("/ofrep/v1/evaluate/flags/#{flag_key}") do |req| - req.body = {context: evaluation_context.fields}.to_json - end - - case response.status - when 200 - parse_success_response(response) - when 400 - parse_error_response(response) - when 401, 403 - raise OpenFeature::GoFeatureFlag::UnauthorizedError.new(response) - when 404 - raise OpenFeature::GoFeatureFlag::FlagNotFoundError.new(response, flag_key) - when 429 - parse_retry_later_header(response) - raise OpenFeature::GoFeatureFlag::RateLimited.new(response) - else - raise OpenFeature::GoFeatureFlag::InternalServerError.new(response) - end - end - - private - - def headers(custom_headers) - {"Content-Type" => "application/json"}.merge(custom_headers || {}) - end - - def parse_error_response(response) - required_keys = %w[key error_code] - parsed = JSON.parse(response.body) - - missing_keys = required_keys - parsed.keys - unless missing_keys.empty? - raise OpenFeature::GoFeatureFlag::ParseError.new(response) - end - - OpenFeature::GoFeatureFlag::OfrepApiResponse.new( - value: nil, - key: parsed["key"], - reason: SDK::Provider::Reason::ERROR, - variant: nil, - error_code: error_code_mapper(parsed["error_code"]), - error_details: parsed["error_details"], - metadata: nil - ) - end - - def parse_success_response(response) - required_keys = %w[key value reason variant] - parsed = JSON.parse(response.body) - - missing_keys = required_keys - parsed.keys - unless missing_keys.empty? - raise OpenFeature::GoFeatureFlag::ParseError.new(response) - end - - OpenFeature::GoFeatureFlag::OfrepApiResponse.new( - value: parsed["value"], - key: parsed["key"], - reason: reason_mapper(parsed["reason"]), - variant: parsed["variant"], - error_code: nil, - error_details: nil, - metadata: parsed["metadata"] - ) - end - - def reason_mapper(reason_str) - reason_str = reason_str.upcase - reason_map = { - "STATIC" => SDK::Provider::Reason::STATIC, - "DEFAULT" => SDK::Provider::Reason::DEFAULT, - "TARGETING_MATCH" => SDK::Provider::Reason::TARGETING_MATCH, - "SPLIT" => SDK::Provider::Reason::SPLIT, - "CACHED" => SDK::Provider::Reason::CACHED, - "DISABLED" => SDK::Provider::Reason::DISABLED, - "UNKNOWN" => SDK::Provider::Reason::UNKNOWN, - "STALE" => SDK::Provider::Reason::STALE, - "ERROR" => SDK::Provider::Reason::ERROR - } - reason_map[reason_str] || SDK::Provider::Reason::UNKNOWN - end - - def error_code_mapper(error_code_str) - error_code_str = error_code_str.upcase - error_code_map = { - "PROVIDER_NOT_READY" => SDK::Provider::ErrorCode::PROVIDER_NOT_READY, - "FLAG_NOT_FOUND" => SDK::Provider::ErrorCode::FLAG_NOT_FOUND, - "PARSE_ERROR" => SDK::Provider::ErrorCode::PARSE_ERROR, - "TYPE_MISMATCH" => SDK::Provider::ErrorCode::TYPE_MISMATCH, - "TARGETING_KEY_MISSING" => SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, - "INVALID_CONTEXT" => SDK::Provider::ErrorCode::INVALID_CONTEXT, - "GENERAL" => SDK::Provider::ErrorCode::GENERAL - } - error_code_map[error_code_str] || SDK::Provider::ErrorCode::GENERAL - end - - def parse_retry_later_header(response) - retry_after = response["Retry-After"] - return nil if retry_after.nil? - - begin - @retry_after = if /^\d+$/.match?(retry_after) - # Retry-After is in seconds - Time.now + Integer(retry_after) - else - # Retry-After is an HTTP-date - Time.httpdate(retry_after) - end - rescue ArgumentError - # ignore invalid Retry-After header - nil - end - end - end - end -end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/internal/http_unix.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/internal/http_unix.rb new file mode 100644 index 0000000..b82429e --- /dev/null +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/internal/http_unix.rb @@ -0,0 +1,31 @@ +require "net/http" + +class HttpUnix < Net::HTTP + BufferedIO = ::Net::BufferedIO + UNIX_REGEXP = %r{^unix://}i + + def initialize(address, port = nil) + super(address, port) + @socket_type = "unix" + @socket_path = address.sub(UNIX_REGEXP, "") + + @host = "localhost" + @port = 1031 + end + + def connect + s = UNIXSocket.open(@socket_path) + @socket = BufferedIO.new(s, + read_timeout: @read_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) + on_connect + end + + def post(url, body, headers) + request = Net::HTTP::Post.new(url, headers) + request["host"] = "localhost" # required to form correct HTTP request + request.body = body.to_json + request(request) + end +end diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb index 309ee2a..5a32735 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/options.rb @@ -6,11 +6,12 @@ module OpenFeature module GoFeatureFlag # This class is the configuration class for the GoFeatureFlagProvider class Options - attr_accessor :endpoint, :custom_headers, :exporter_metadata, :instrumentation + attr_accessor :endpoint, :custom_headers, :exporter_metadata, :instrumentation, :type - def initialize(endpoint: nil, headers: {}, exporter_metadata: {}, instrumentation: nil) - validate_endpoint(endpoint: endpoint) + def initialize(endpoint: nil, headers: {}, exporter_metadata: {}, instrumentation: nil, type: "http") + validate_endpoint(endpoint, type) validate_instrumentation(instrumentation: instrumentation) + @type = type @endpoint = endpoint @custom_headers = headers @exporter_metadata = exporter_metadata @@ -19,11 +20,18 @@ def initialize(endpoint: nil, headers: {}, exporter_metadata: {}, instrumentatio private - def validate_endpoint(endpoint: nil) + def validate_endpoint(endpoint, type) return if endpoint.nil? - uri = URI.parse(endpoint) - raise ArgumentError, "Invalid URL for endpoint: #{endpoint}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + case type + when "http" + uri = URI.parse(endpoint) + raise ArgumentError, "Invalid URL for endpoint: #{endpoint}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + when "unix" + raise ArgumentError, "File not found: #{endpoint}" unless File.exist?(endpoint) + else + raise ArgumentError, "Invalid Type: #{type}" + end rescue URI::InvalidURIError raise ArgumentError, "Invalid URL for endpoint: #{endpoint}" end diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/http_api_spec.rb similarity index 99% rename from providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb rename to providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/http_api_spec.rb index 0573a66..c27f08d 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/goff_api_spec.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/http_api_spec.rb @@ -1,6 +1,6 @@ require "spec_helper" -RSpec.describe OpenFeature::GoFeatureFlag::GoFeatureFlagApi do +RSpec.describe OpenFeature::GoFeatureFlag::Client::HttpApi do subject(:goff_api) do described_class.new(endpoint: "http://localhost:1031") end diff --git a/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb new file mode 100644 index 0000000..80fec13 --- /dev/null +++ b/providers/openfeature-go-feature-flag-provider/spec/openfeature/gofeatureflag/client/unix_api_spec.rb @@ -0,0 +1,278 @@ +require "spec_helper" + +RSpec.describe OpenFeature::GoFeatureFlag::Client::UnixApi do + subject(:unix_api) do + described_class.new(endpoint: "/tmp/http.sock") + end + + let(:default_evaluation_context) do + OpenFeature::SDK::EvaluationContext.new( + targeting_key: "4f433951-4c8c-42b3-9f18-8c9a5ed8e9eb", + company: "GO Feature Flag", + firstname: "John", + lastname: "Doe" + ) + end + + let(:response) { double(Net::HTTPResponse) } + + context "#evaluate" do + it "should raise an error if rate limited" do + allow(response).to receive(:code).and_return("429") + allow(response).to receive(:[]).with("Retry-After").and_return(nil) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + end + + it "should raise an error if not authorized (401)" do + allow(response).to receive(:code).and_return("401") + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::UnauthorizedError) + end + + it "should raise an error if not authorized (403)" do + allow(response).to receive(:code).and_return("403") + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::UnauthorizedError) + end + + it "should raise an error if flag not found (404)" do + allow(response).to receive(:code).and_return("404") + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "does-not-exists", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::FlagNotFoundError) + end + + it "should raise an error if unknown http code (500)" do + allow(response).to receive(:code).and_return("500") + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::InternalServerError) + end + + it "should return an error response if 400" do + body = { + key: "double_key", + error_code: "TYPE_MISMATCH", + error_details: "expected type: boolean, got: string" + } + allow(response).to receive(:code).and_return("400") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + got = unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + want = OpenFeature::GoFeatureFlag::OfrepApiResponse.new( + key: "double_key", + value: nil, + reason: OpenFeature::SDK::Provider::Reason::ERROR, + variant: nil, + error_code: OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_details: "expected type: boolean, got: string", + metadata: nil + ) + expect(got).to eql(want) + end + + it "should return a valid response if 200" do + body = { + key: "double_key", + metadata: {"website" => "https://gofeatureflag.org"}, + value: 1.15, + reason: "TARGETING_MATCH", + variant: "variantA" + } + allow(response).to receive(:code).and_return("200") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + got = unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + want = OpenFeature::GoFeatureFlag::OfrepApiResponse.new( + key: "double_key", + value: 1.15, + reason: OpenFeature::SDK::Provider::Reason::TARGETING_MATCH, + variant: "variantA", + error_code: nil, + error_details: nil, + metadata: {"website" => "https://gofeatureflag.org"} + ) + expect(got).to eql(want) + end + + it "should raise an error if 200 and json does not contains the required keys (missing value)" do + body = { + key: "double_key", + metadata: {"website" => "https://gofeatureflag.org"}, + reason: "TARGETING_MATCH", + variant: "variantA" + } + allow(response).to receive(:code).and_return("200") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::ParseError) + end + + it "should raise an error if 200 and json does not contains the required keys (missing key)" do + body = { + value: 1.15, + metadata: {"website" => "https://gofeatureflag.org"}, + reason: "TARGETING_MATCH", + variant: "variantA" + } + allow(response).to receive(:code).and_return("200") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::ParseError) + end + + it "should raise an error if 200 and json does not contains the required keys (missing reason)" do + body = { + value: 1.15, + metadata: {"website" => "https://gofeatureflag.org"}, + key: "double_key", + variant: "variantA" + } + allow(response).to receive(:code).and_return("200") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::ParseError) + end + + it "should raise an error if 200 and json does not contains the required keys (missing variant)" do + body = { + value: 1.15, + metadata: {"website" => "https://gofeatureflag.org"}, + key: "double_key", + reason: "TARGETING_MATCH" + } + allow(response).to receive(:code).and_return("200") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::ParseError) + end + + it "should raise an error if 400 and json does not contains the required keys (missing key)" do + body = { + error_code: "TYPE_MISMATCH" + } + allow(response).to receive(:code).and_return("400") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::ParseError) + end + + it "should raise an error if 400 and json does not contains the required keys (missing error_code)" do + body = {key: "double_key"} + allow(response).to receive(:code).and_return("400") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::ParseError) + end + + it "should not be able to call the API again if rate-limited (with retry-after int)" do + allow(response).to receive(:code).and_return("429") + allow(response).to receive(:[]).with("Retry-After").and_return("10") + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "random_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + end + + it "should be able to call the API again if we wait after the retry-after (as int)" do + body = { + value: 1.15, + metadata: {"website" => "https://gofeatureflag.org"}, + key: "double_key", + reason: "TARGETING_MATCH", + variant: "variantA" + } + allow(response).to receive(:code).and_return("429", "200") + allow(response).to receive(:[]).with("Retry-After").and_return("1") + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + + sleep(1.1) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "random_flag", evaluation_context: default_evaluation_context) + }.not_to raise_error + end + + it "should not be able to call the API again if rate-limited (with retry-after date)" do + allow(response).to receive(:code).and_return("429") + allow(response).to receive(:[]).with("Retry-After").and_return((Time.now + 1).httpdate) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "random_flag", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + end + + it "should be able to call the API again if we wait after the retry-after (as date)" do + body = { + value: 1.15, + metadata: {"website" => "https://gofeatureflag.org"}, + key: "double_key", + reason: "TARGETING_MATCH", + variant: "variantA" + } + allow(response).to receive(:code).and_return("429", "200") + allow(response).to receive(:[]).with("Retry-After").and_return((Time.now + 1).httpdate) + allow(response).to receive(:body).and_return(body.to_json) + allow(unix_api.socket).to receive(:post).and_return(response) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "double_key", evaluation_context: default_evaluation_context) + }.to raise_error(OpenFeature::GoFeatureFlag::RateLimited) + + sleep(1.1) + + expect { + unix_api.evaluate_ofrep_api(flag_key: "random_flag", evaluation_context: default_evaluation_context) + }.not_to raise_error + end + end +end diff --git a/providers/openfeature-go-feature-flag-provider/spec/spec_helper.rb b/providers/openfeature-go-feature-flag-provider/spec/spec_helper.rb index c0d12a9..014109b 100644 --- a/providers/openfeature-go-feature-flag-provider/spec/spec_helper.rb +++ b/providers/openfeature-go-feature-flag-provider/spec/spec_helper.rb @@ -4,7 +4,8 @@ require "rspec" require "openfeature/go-feature-flag/go_feature_flag_provider" require "openfeature/go-feature-flag/options" -require "openfeature/go-feature-flag/goff_api" +require "openfeature/go-feature-flag/client/http_api" +require "openfeature/go-feature-flag/client/unix_api" require "open_feature/sdk" require "webmock/rspec" From 60f3cc082a63f6c04b2c81f1ec332b1446f7ce51 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Wed, 19 Nov 2025 04:31:02 -0500 Subject: [PATCH 20/31] chore(main): release openfeature-go-feature-flag-provider 0.1.7 (#69) Signed-off-by: wadii --- .release-please-manifest.json | 2 +- .../openfeature-go-feature-flag-provider/CHANGELOG.md | 7 +++++++ .../openfeature-go-feature-flag-provider/Gemfile.lock | 2 +- .../lib/openfeature/go-feature-flag/version.rb | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index acc3b28..e900e3e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,6 +2,6 @@ "providers/openfeature-flagd-provider": "0.1.2", "providers/openfeature-flipt-provider": "0.0.2", "providers/openfeature-meta_provider": "0.0.5", - "providers/openfeature-go-feature-flag-provider": "0.1.6", + "providers/openfeature-go-feature-flag-provider": "0.1.7", "providers/openfeature-flagsmith-provider": "0.1.0" } diff --git a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md index a65ebf6..aa67cfa 100644 --- a/providers/openfeature-go-feature-flag-provider/CHANGELOG.md +++ b/providers/openfeature-go-feature-flag-provider/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.7](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.6...openfeature-go-feature-flag-provider/v0.1.7) (2025-11-19) + + +### ✨ New Features + +* **GoFeatureFlag:** unix client ([#66](https://github.com/open-feature/ruby-sdk-contrib/issues/66)) ([3f68ce8](https://github.com/open-feature/ruby-sdk-contrib/commit/3f68ce8691646779755affb2c86edf51ae21b40b)) + ## [0.1.6](https://github.com/open-feature/ruby-sdk-contrib/compare/openfeature-go-feature-flag-provider/v0.1.5...openfeature-go-feature-flag-provider/v0.1.6) (2025-11-11) diff --git a/providers/openfeature-go-feature-flag-provider/Gemfile.lock b/providers/openfeature-go-feature-flag-provider/Gemfile.lock index fcd459d..c72016a 100644 --- a/providers/openfeature-go-feature-flag-provider/Gemfile.lock +++ b/providers/openfeature-go-feature-flag-provider/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - openfeature-go-feature-flag-provider (0.1.6) + openfeature-go-feature-flag-provider (0.1.7) faraday-net_http_persistent (~> 2.3) openfeature-sdk (~> 0.3.1) diff --git a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb index 3e4f2fc..785cc6b 100644 --- a/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb +++ b/providers/openfeature-go-feature-flag-provider/lib/openfeature/go-feature-flag/version.rb @@ -1,5 +1,5 @@ module OpenFeature module GoFeatureFlag - GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.6" + GO_FEATURE_FLAG_PROVIDER_VERSION = "0.1.7" end end From 5d9da3991d1108d05f908f4775ebb209b262f58c Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Fri, 21 Nov 2025 16:02:39 +0100 Subject: [PATCH 21/31] Update providers/openfeature-flagsmith-provider/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Zaimwa9 Signed-off-by: wadii --- providers/openfeature-flagsmith-provider/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md index dadbafa..09b6e07 100644 --- a/providers/openfeature-flagsmith-provider/README.md +++ b/providers/openfeature-flagsmith-provider/README.md @@ -270,6 +270,7 @@ The provider returns appropriate reasons for flag evaluations: | `STATIC` | Flag evaluated at environment level (no targeting_key) | | `DEFAULT` | Default value returned due to flag not found | | `ERROR` | An error occurred during evaluation | +| `DISABLED` | The flag was disabled, and the default value was returned | **Note**: Both remote and local evaluation modes use the same reason mapping (STATIC/TARGETING_MATCH). Local evaluation performs flag evaluation locally but still evaluates the flag state, it doesn't return cached results. From 1d43025c24550a42d90b949646e082901d764766 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 21 Nov 2025 16:57:49 +0100 Subject: [PATCH 22/31] feat: included-message-in-error Signed-off-by: wadii --- .../lib/openfeature/flagsmith/provider.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index b616001..b80ca9e 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -98,7 +98,7 @@ def create_flagsmith_client environment_refresh_interval_seconds: @options.environment_refresh_interval_seconds ) rescue => e - raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.message}" + raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.class}: #{e.message}" end def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classes:) @@ -188,7 +188,7 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe value: default_value, reason: SDK::Provider::Reason::ERROR, error_code: SDK::Provider::ErrorCode::GENERAL, - error_message: "Unexpected error: #{e.message}" + error_message: "Unexpected error: #{e.class}: #{e.message}" ) end end @@ -204,7 +204,7 @@ def get_flags(evaluation_context) @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) end rescue => e - raise FlagsmithClientError, e.message + raise FlagsmithClientError, "#{e.class}: #{e.message}" end def parse_json_value(value) From d776bd40c8f71a5a4e7229b4cbbd1abeeaf598fc Mon Sep 17 00:00:00 2001 From: Zaimwa9 Date: Tue, 25 Nov 2025 20:51:36 +0100 Subject: [PATCH 23/31] feat: added-ruby-maintainers-as-codeowners (#71) ## This PR Added Ruby Maintainers as codeowners - adds this new feature ### Related Issues Fixes #1234523 ### Notes ### Follow-up Tasks ### How to test Signed-off-by: wadii --- CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..4da6f3f --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,6 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence +# +# Managed by Peribolos: https://github.com/open-feature/community/blob/main/config/open-feature/sdk-ruby/workgroup.yaml +# +* @open-feature/sdk-ruby-maintainers From 421584fcf5311cca440a9948ed7ba10668f10df6 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 26 Nov 2025 08:40:51 -0500 Subject: [PATCH 24/31] chore: add component owners Signed-off-by: Todd Baert Signed-off-by: wadii --- .github/component_owners.yml | 14 ++++++++++++++ .github/workflows/component-owners.yml | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 .github/component_owners.yml create mode 100644 .github/workflows/component-owners.yml diff --git a/.github/component_owners.yml b/.github/component_owners.yml new file mode 100644 index 0000000..d017be9 --- /dev/null +++ b/.github/component_owners.yml @@ -0,0 +1,14 @@ +# Keep all in alphabetical order +components: + providers/openfeature-flagd-provider: + - toddbaert + - josecolella + providers/openfeature-flipt-provider: + - falghi + providers/openfeature-go-feature-flag-provider: + - thomaspoignant + providers/openfeature-meta_provider: + - maxveldink + +ignored-authors: + - renovate-bot diff --git a/.github/workflows/component-owners.yml b/.github/workflows/component-owners.yml new file mode 100644 index 0000000..359a7b8 --- /dev/null +++ b/.github/workflows/component-owners.yml @@ -0,0 +1,18 @@ +name: 'Component Owners' +on: + pull_request_target: + +permissions: + contents: read # to read changed files + issues: write # to read/write issue assignees + pull-requests: write # to read/write PR reviewers + +jobs: + run_self: + runs-on: ubuntu-latest + name: Auto Assign Owners + steps: + - uses: dyladan/component-owners@58bd86e9814d23f1525d0a970682cead459fa783 + with: + config-file: .github/component_owners.yml + repo-token: ${{ secrets.GITHUB_TOKEN }} From b6e085fc2f001583f3742aec46da92b0bf28f7ea Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 26 Nov 2025 15:37:28 +0100 Subject: [PATCH 25/31] feat: added-as-component-owner Signed-off-by: wadii --- .github/component_owners.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/component_owners.yml b/.github/component_owners.yml index d017be9..a59ce1f 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -9,6 +9,8 @@ components: - thomaspoignant providers/openfeature-meta_provider: - maxveldink + providers/openfeature-flagsmith-provider: + - zaimwa9 ignored-authors: - renovate-bot From 5ecf0733506406768b86aa86d823926e46bce050 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 18 Nov 2025 10:22:50 +0100 Subject: [PATCH 26/31] feat: implemented-flagsmith-provider Signed-off-by: wadii --- .../.context.md | 8 ++ .../FLAGSMITH_PROVIDER_DESIGN.md | 11 +++ .../openfeature-flagsmith-provider/README.md | 17 ++++ .../lib/openfeature/flagsmith/provider.rb | 88 +++++++++++++++++++ .../openfeature/flagsmith/provider_spec.rb | 65 ++++++++++++++ 5 files changed, 189 insertions(+) diff --git a/providers/openfeature-flagsmith-provider/.context.md b/providers/openfeature-flagsmith-provider/.context.md index b499f7a..9ab83f3 100644 --- a/providers/openfeature-flagsmith-provider/.context.md +++ b/providers/openfeature-flagsmith-provider/.context.md @@ -1,5 +1,9 @@ # Flagsmith OpenFeature Provider - Context +<<<<<<< HEAD +======= +**Status:** 🚧 In Development +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) **Started:** 2025-11-17 **Full Design Doc:** `./FLAGSMITH_PROVIDER_DESIGN.md` @@ -96,6 +100,10 @@ evaluation_context.fields → traits (as keyword args) | Flag not found | `DEFAULT` | | Flag exists but disabled | `DISABLED` | | Error occurred | `ERROR` | +<<<<<<< HEAD +======= +| Local evaluation mode | `CACHED` | +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) ### 4. Error Handling ```ruby diff --git a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md index c0461f8..bc71363 100644 --- a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md +++ b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md @@ -122,6 +122,10 @@ OpenFeature::SDK::Provider::ResolutionDetails.new( - `DISABLED` - Feature is disabled - `ERROR` - Error during evaluation - `STATIC` - Static value +<<<<<<< HEAD +======= +- `CACHED` - Value from cache +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) #### OpenFeature ErrorCode Constants - `PROVIDER_NOT_READY` @@ -277,10 +281,17 @@ end | Flagsmith State | OpenFeature Reason | |-----------------|-------------------| | Flag evaluated with identity | `TARGETING_MATCH` | +<<<<<<< HEAD | Flag evaluated at environment level | `STATIC` | | Flag not found | `DEFAULT` | | Flag disabled | `DISABLED` | | Error occurred | `ERROR` | +======= +| Flag not found | `DEFAULT` | +| Flag disabled | `DISABLED` | +| Error occurred | `ERROR` | +| Local evaluation | `CACHED` | +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) --- diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md index 09b6e07..673a163 100644 --- a/providers/openfeature-flagsmith-provider/README.md +++ b/providers/openfeature-flagsmith-provider/README.md @@ -1,5 +1,10 @@ # OpenFeature Flagsmith Provider for Ruby +<<<<<<< HEAD +======= +[![Project Status: WIP](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) + +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature flags, implementing the [OpenFeature](https://openfeature.dev/) standard. ## Features @@ -18,6 +23,10 @@ This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature fl | ✅ | Local Evaluation | Optional local evaluation mode | | ✅ | Error Handling | Comprehensive error handling | | ✅ | Type Validation | Strict type checking | +<<<<<<< HEAD +======= +| 🚧 | Flagsmith Integration | Pending Flagsmith gem version | +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) ## Installation @@ -266,6 +275,7 @@ The provider returns appropriate reasons for flag evaluations: | Reason | Description | |--------|-------------| +<<<<<<< HEAD | `TARGETING_MATCH` | Flag evaluated with user identity (targeting_key provided) | | `STATIC` | Flag evaluated at environment level (no targeting_key) | | `DEFAULT` | Default value returned due to flag not found | @@ -273,6 +283,13 @@ The provider returns appropriate reasons for flag evaluations: | `DISABLED` | The flag was disabled, and the default value was returned | **Note**: Both remote and local evaluation modes use the same reason mapping (STATIC/TARGETING_MATCH). Local evaluation performs flag evaluation locally but still evaluates the flag state, it doesn't return cached results. +======= +| `TARGETING_MATCH` | Flag evaluated with user identity and targeting rules | +| `STATIC` | Flag evaluated at environment level (no user context) | +| `DEFAULT` | Default value returned (flag not found or disabled) | +| `ERROR` | An error occurred during evaluation | +| `CACHED` | Value returned from local cache (local evaluation mode) | +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) ## Development diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index b80ca9e..205069d 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -98,7 +98,11 @@ def create_flagsmith_client environment_refresh_interval_seconds: @options.environment_refresh_interval_seconds ) rescue => e +<<<<<<< HEAD raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.class}: #{e.message}" +======= + raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.message}" +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classes:) @@ -112,6 +116,7 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe ) end +<<<<<<< HEAD # Check for invalid flag keys if flag_key.nil? || flag_key.to_s.empty? return SDK::Provider::ResolutionDetails.new( @@ -122,12 +127,15 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe ) end +======= +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) evaluation_context ||= SDK::EvaluationContext.new begin # Get flags from Flagsmith flags = get_flags(evaluation_context) +<<<<<<< HEAD is_boolean_flag = (allowed_type_classes.sort == [FalseClass, TrueClass]) if is_boolean_flag @@ -159,6 +167,21 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe end end +======= + # Check if flag exists + unless flag_exists?(flags, flag_key) + return SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag '#{flag_key}' not found" + ) + end + + # Get flag value + value = get_flag_value(flags, flag_key, allowed_type_classes) + +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) # Validate type unless type_matches?(value, allowed_type_classes) return SDK::Provider::ResolutionDetails.new( @@ -173,7 +196,11 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe SDK::Provider::ResolutionDetails.new( value: value, reason: determine_reason(evaluation_context), +<<<<<<< HEAD variant: nil, # Flagsmith doesn't have explicit variants +======= + variant: nil, # Flagsmith doesn't have explicit variants +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) flag_metadata: {} ) rescue FlagsmithError => e @@ -188,7 +215,11 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe value: default_value, reason: SDK::Provider::Reason::ERROR, error_code: SDK::Provider::ErrorCode::GENERAL, +<<<<<<< HEAD error_message: "Unexpected error: #{e.class}: #{e.message}" +======= + error_message: "Unexpected error: #{e.message}" +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) ) end end @@ -200,11 +231,57 @@ def get_flags(evaluation_context) if targeting_key.nil? || targeting_key.to_s.strip.empty? @flagsmith_client.get_environment_flags else +<<<<<<< HEAD traits = evaluation_context.fields.transform_keys(&:to_sym).reject { |k, _v| k == :targeting_key } @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) end rescue => e raise FlagsmithClientError, "#{e.class}: #{e.message}" +======= + traits = evaluation_context.fields.reject { |k, _v| k == :targeting_key || k == "targeting_key" } + @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) + end + rescue => e + raise FlagsmithClientError, e.message + end + + def flag_exists?(flags, flag_key) + # Try to get the flag value - if it returns nil and feature is not enabled, flag doesn't exist + + value = flags.get_feature_value(flag_key) + # Flag exists if value is not nil OR if is_feature_enabled returns true (for boolean flags) + !value.nil? || flags.is_feature_enabled(flag_key) + rescue NoMethodError + # Handle case where flags object doesn't respond to expected methods + false + + # Note: Let other exceptions bubble up to be caught by evaluate's rescue block + # This ensures network errors, API errors, etc. are properly reported as ERROR + # rather than being treated as "flag not found" + end + + def get_flag_value(flags, flag_key, allowed_type_classes) + # For boolean flags, use is_feature_enabled + if allowed_type_classes == [TrueClass, FalseClass] + return flags.is_feature_enabled(flag_key) + end + + # For other types, get the feature value + value = flags.get_feature_value(flag_key) + + # Handle JSON objects/arrays + if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } + return parse_json_value(value) + end + + # Handle numeric types + if [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } + return parse_numeric_value(value, allowed_type_classes) + end + + # Return as-is for strings + value +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end def parse_json_value(value) @@ -223,17 +300,28 @@ def parse_numeric_value(value, allowed_type_classes) # Try to parse string to numeric if value.is_a?(String) +<<<<<<< HEAD if allowed_type_classes.include?(Numeric) +======= + if allowed_type_classes.include?(Integer) + return Integer(value) + elsif allowed_type_classes.include?(Float) + return Float(value) + else +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) # For Numeric, try Integer first, then Float begin return Integer(value) rescue ArgumentError, TypeError return Float(value) end +<<<<<<< HEAD elsif allowed_type_classes.include?(Integer) return Integer(value) elsif allowed_type_classes.include?(Float) return Float(value) +======= +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb index 37eb293..db312b9 100644 --- a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -93,19 +93,32 @@ expect(result.error_code).to be_nil end +<<<<<<< HEAD it "should return false for disabled boolean flags" do +======= + it "should return default value when flag not found" do +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) result = provider.fetch_boolean_value( flag_key: flag_key, +<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) expect(result.error_code).to be_nil +======= + default_value: false, + evaluation_context: evaluation_context + ) + expect(result.value).to eq(false) + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) + expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end it "should work without evaluation context" do @@ -118,8 +131,12 @@ default_value: true ) expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) +<<<<<<< HEAD expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) +======= + expect(result.value).to eq(true) +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end it "should handle non-string targeting_key gracefully" do @@ -333,12 +350,20 @@ evaluation_context = OpenFeature::SDK::EvaluationContext.new result = provider.fetch_boolean_value( flag_key: "test", +<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) # Boolean flags always return their value with STATIC/TARGETING_MATCH reason expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) +======= + default_value: false, + evaluation_context: evaluation_context + ) + # Returns DEFAULT because flag doesn't exist + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end it "should use TARGETING_MATCH reason for identity-specific flags" do @@ -347,12 +372,20 @@ evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") result = provider.fetch_boolean_value( flag_key: "test", +<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) # Flagsmith treats non-existent flags as disabled flags expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) +======= + default_value: false, + evaluation_context: evaluation_context + ) + # Returns DEFAULT because flag doesn't exist + expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end end @@ -515,11 +548,19 @@ result = provider.fetch_boolean_value( flag_key: "", +<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) expect(result.value).to eq(true) +======= + default_value: false, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(false) +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) end end @@ -540,6 +581,23 @@ expect(result.value).to eq("value") end +<<<<<<< HEAD +======= + it "should handle unicode in flag keys" do + unicode_key = "flag_with_émojis_🚀" + allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) + allow(mock_flags).to receive(:get_feature_value).with(unicode_key).and_return(nil) + allow(mock_flags).to receive(:is_feature_enabled).with(unicode_key).and_return(false) + + result = provider.fetch_boolean_value( + flag_key: unicode_key, + default_value: true, + evaluation_context: evaluation_context + ) + + expect(result.value).to eq(true) + end +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) end describe "evaluation context edge cases" do @@ -594,7 +652,14 @@ end it "should handle unicode in trait values" do +<<<<<<< HEAD allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) +======= + # OpenFeature SDK uses string keys for evaluation context fields + allow(mock_flagsmith_client).to receive(:get_identity_flags) + .with("user_123", {"name" => "François", "location" => "Montréal 🇨🇦"}) + .and_return(mock_flags) +>>>>>>> 03b456d (feat: implemented-flagsmith-provider) allow(mock_flags).to receive(:get_feature_value).and_return("value") allow(mock_flags).to receive(:is_feature_enabled).and_return(true) From a8e23cf0105237fdb46f7eff085f43b94ffd70e8 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 18 Nov 2025 11:04:46 +0100 Subject: [PATCH 27/31] feat: improved-flag-not-found-handlings Signed-off-by: wadii --- .../.context.md | 8 -- .../FLAGSMITH_PROVIDER_DESIGN.md | 11 --- .../openfeature-flagsmith-provider/README.md | 17 ---- .../lib/openfeature/flagsmith/provider.rb | 88 ------------------- .../openfeature/flagsmith/provider_spec.rb | 65 -------------- 5 files changed, 189 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/.context.md b/providers/openfeature-flagsmith-provider/.context.md index 9ab83f3..b499f7a 100644 --- a/providers/openfeature-flagsmith-provider/.context.md +++ b/providers/openfeature-flagsmith-provider/.context.md @@ -1,9 +1,5 @@ # Flagsmith OpenFeature Provider - Context -<<<<<<< HEAD -======= -**Status:** 🚧 In Development ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) **Started:** 2025-11-17 **Full Design Doc:** `./FLAGSMITH_PROVIDER_DESIGN.md` @@ -100,10 +96,6 @@ evaluation_context.fields → traits (as keyword args) | Flag not found | `DEFAULT` | | Flag exists but disabled | `DISABLED` | | Error occurred | `ERROR` | -<<<<<<< HEAD -======= -| Local evaluation mode | `CACHED` | ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) ### 4. Error Handling ```ruby diff --git a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md index bc71363..c0461f8 100644 --- a/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md +++ b/providers/openfeature-flagsmith-provider/FLAGSMITH_PROVIDER_DESIGN.md @@ -122,10 +122,6 @@ OpenFeature::SDK::Provider::ResolutionDetails.new( - `DISABLED` - Feature is disabled - `ERROR` - Error during evaluation - `STATIC` - Static value -<<<<<<< HEAD -======= -- `CACHED` - Value from cache ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) #### OpenFeature ErrorCode Constants - `PROVIDER_NOT_READY` @@ -281,17 +277,10 @@ end | Flagsmith State | OpenFeature Reason | |-----------------|-------------------| | Flag evaluated with identity | `TARGETING_MATCH` | -<<<<<<< HEAD | Flag evaluated at environment level | `STATIC` | | Flag not found | `DEFAULT` | | Flag disabled | `DISABLED` | | Error occurred | `ERROR` | -======= -| Flag not found | `DEFAULT` | -| Flag disabled | `DISABLED` | -| Error occurred | `ERROR` | -| Local evaluation | `CACHED` | ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) --- diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md index 673a163..09b6e07 100644 --- a/providers/openfeature-flagsmith-provider/README.md +++ b/providers/openfeature-flagsmith-provider/README.md @@ -1,10 +1,5 @@ # OpenFeature Flagsmith Provider for Ruby -<<<<<<< HEAD -======= -[![Project Status: WIP](https://www.repostatus.org/badges/latest/wip.svg)](https://www.repostatus.org/#wip) - ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature flags, implementing the [OpenFeature](https://openfeature.dev/) standard. ## Features @@ -23,10 +18,6 @@ This is the Ruby provider for [Flagsmith](https://www.flagsmith.com/) feature fl | ✅ | Local Evaluation | Optional local evaluation mode | | ✅ | Error Handling | Comprehensive error handling | | ✅ | Type Validation | Strict type checking | -<<<<<<< HEAD -======= -| 🚧 | Flagsmith Integration | Pending Flagsmith gem version | ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) ## Installation @@ -275,7 +266,6 @@ The provider returns appropriate reasons for flag evaluations: | Reason | Description | |--------|-------------| -<<<<<<< HEAD | `TARGETING_MATCH` | Flag evaluated with user identity (targeting_key provided) | | `STATIC` | Flag evaluated at environment level (no targeting_key) | | `DEFAULT` | Default value returned due to flag not found | @@ -283,13 +273,6 @@ The provider returns appropriate reasons for flag evaluations: | `DISABLED` | The flag was disabled, and the default value was returned | **Note**: Both remote and local evaluation modes use the same reason mapping (STATIC/TARGETING_MATCH). Local evaluation performs flag evaluation locally but still evaluates the flag state, it doesn't return cached results. -======= -| `TARGETING_MATCH` | Flag evaluated with user identity and targeting rules | -| `STATIC` | Flag evaluated at environment level (no user context) | -| `DEFAULT` | Default value returned (flag not found or disabled) | -| `ERROR` | An error occurred during evaluation | -| `CACHED` | Value returned from local cache (local evaluation mode) | ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) ## Development diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index 205069d..b80ca9e 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -98,11 +98,7 @@ def create_flagsmith_client environment_refresh_interval_seconds: @options.environment_refresh_interval_seconds ) rescue => e -<<<<<<< HEAD raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.class}: #{e.message}" -======= - raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.message}" ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classes:) @@ -116,7 +112,6 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe ) end -<<<<<<< HEAD # Check for invalid flag keys if flag_key.nil? || flag_key.to_s.empty? return SDK::Provider::ResolutionDetails.new( @@ -127,15 +122,12 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe ) end -======= ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) evaluation_context ||= SDK::EvaluationContext.new begin # Get flags from Flagsmith flags = get_flags(evaluation_context) -<<<<<<< HEAD is_boolean_flag = (allowed_type_classes.sort == [FalseClass, TrueClass]) if is_boolean_flag @@ -167,21 +159,6 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe end end -======= - # Check if flag exists - unless flag_exists?(flags, flag_key) - return SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::DEFAULT, - error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, - error_message: "Flag '#{flag_key}' not found" - ) - end - - # Get flag value - value = get_flag_value(flags, flag_key, allowed_type_classes) - ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) # Validate type unless type_matches?(value, allowed_type_classes) return SDK::Provider::ResolutionDetails.new( @@ -196,11 +173,7 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe SDK::Provider::ResolutionDetails.new( value: value, reason: determine_reason(evaluation_context), -<<<<<<< HEAD variant: nil, # Flagsmith doesn't have explicit variants -======= - variant: nil, # Flagsmith doesn't have explicit variants ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) flag_metadata: {} ) rescue FlagsmithError => e @@ -215,11 +188,7 @@ def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classe value: default_value, reason: SDK::Provider::Reason::ERROR, error_code: SDK::Provider::ErrorCode::GENERAL, -<<<<<<< HEAD error_message: "Unexpected error: #{e.class}: #{e.message}" -======= - error_message: "Unexpected error: #{e.message}" ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) ) end end @@ -231,57 +200,11 @@ def get_flags(evaluation_context) if targeting_key.nil? || targeting_key.to_s.strip.empty? @flagsmith_client.get_environment_flags else -<<<<<<< HEAD traits = evaluation_context.fields.transform_keys(&:to_sym).reject { |k, _v| k == :targeting_key } @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) end rescue => e raise FlagsmithClientError, "#{e.class}: #{e.message}" -======= - traits = evaluation_context.fields.reject { |k, _v| k == :targeting_key || k == "targeting_key" } - @flagsmith_client.get_identity_flags(targeting_key.to_s, **traits) - end - rescue => e - raise FlagsmithClientError, e.message - end - - def flag_exists?(flags, flag_key) - # Try to get the flag value - if it returns nil and feature is not enabled, flag doesn't exist - - value = flags.get_feature_value(flag_key) - # Flag exists if value is not nil OR if is_feature_enabled returns true (for boolean flags) - !value.nil? || flags.is_feature_enabled(flag_key) - rescue NoMethodError - # Handle case where flags object doesn't respond to expected methods - false - - # Note: Let other exceptions bubble up to be caught by evaluate's rescue block - # This ensures network errors, API errors, etc. are properly reported as ERROR - # rather than being treated as "flag not found" - end - - def get_flag_value(flags, flag_key, allowed_type_classes) - # For boolean flags, use is_feature_enabled - if allowed_type_classes == [TrueClass, FalseClass] - return flags.is_feature_enabled(flag_key) - end - - # For other types, get the feature value - value = flags.get_feature_value(flag_key) - - # Handle JSON objects/arrays - if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } - return parse_json_value(value) - end - - # Handle numeric types - if [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } - return parse_numeric_value(value, allowed_type_classes) - end - - # Return as-is for strings - value ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end def parse_json_value(value) @@ -300,28 +223,17 @@ def parse_numeric_value(value, allowed_type_classes) # Try to parse string to numeric if value.is_a?(String) -<<<<<<< HEAD if allowed_type_classes.include?(Numeric) -======= - if allowed_type_classes.include?(Integer) - return Integer(value) - elsif allowed_type_classes.include?(Float) - return Float(value) - else ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) # For Numeric, try Integer first, then Float begin return Integer(value) rescue ArgumentError, TypeError return Float(value) end -<<<<<<< HEAD elsif allowed_type_classes.include?(Integer) return Integer(value) elsif allowed_type_classes.include?(Float) return Float(value) -======= ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end end diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb index db312b9..37eb293 100644 --- a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -93,32 +93,19 @@ expect(result.error_code).to be_nil end -<<<<<<< HEAD it "should return false for disabled boolean flags" do -======= - it "should return default value when flag not found" do ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) result = provider.fetch_boolean_value( flag_key: flag_key, -<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) expect(result.error_code).to be_nil -======= - default_value: false, - evaluation_context: evaluation_context - ) - expect(result.value).to eq(false) - expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) - expect(result.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end it "should work without evaluation context" do @@ -131,12 +118,8 @@ default_value: true ) expect(result).to be_a(OpenFeature::SDK::Provider::ResolutionDetails) -<<<<<<< HEAD expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) -======= - expect(result.value).to eq(true) ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end it "should handle non-string targeting_key gracefully" do @@ -350,20 +333,12 @@ evaluation_context = OpenFeature::SDK::EvaluationContext.new result = provider.fetch_boolean_value( flag_key: "test", -<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) # Boolean flags always return their value with STATIC/TARGETING_MATCH reason expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::STATIC) -======= - default_value: false, - evaluation_context: evaluation_context - ) - # Returns DEFAULT because flag doesn't exist - expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end it "should use TARGETING_MATCH reason for identity-specific flags" do @@ -372,20 +347,12 @@ evaluation_context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user_123") result = provider.fetch_boolean_value( flag_key: "test", -<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) # Flagsmith treats non-existent flags as disabled flags expect(result.value).to eq(false) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::TARGETING_MATCH) -======= - default_value: false, - evaluation_context: evaluation_context - ) - # Returns DEFAULT because flag doesn't exist - expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end end @@ -548,19 +515,11 @@ result = provider.fetch_boolean_value( flag_key: "", -<<<<<<< HEAD default_value: true, evaluation_context: evaluation_context ) expect(result.value).to eq(true) -======= - default_value: false, - evaluation_context: evaluation_context - ) - - expect(result.value).to eq(false) ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) expect(result.reason).to eq(OpenFeature::SDK::Provider::Reason::DEFAULT) end end @@ -581,23 +540,6 @@ expect(result.value).to eq("value") end -<<<<<<< HEAD -======= - it "should handle unicode in flag keys" do - unicode_key = "flag_with_émojis_🚀" - allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(unicode_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(unicode_key).and_return(false) - - result = provider.fetch_boolean_value( - flag_key: unicode_key, - default_value: true, - evaluation_context: evaluation_context - ) - - expect(result.value).to eq(true) - end ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) end describe "evaluation context edge cases" do @@ -652,14 +594,7 @@ end it "should handle unicode in trait values" do -<<<<<<< HEAD allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) -======= - # OpenFeature SDK uses string keys for evaluation context fields - allow(mock_flagsmith_client).to receive(:get_identity_flags) - .with("user_123", {"name" => "François", "location" => "Montréal 🇨🇦"}) - .and_return(mock_flags) ->>>>>>> 03b456d (feat: implemented-flagsmith-provider) allow(mock_flags).to receive(:get_feature_value).and_return("value") allow(mock_flags).to receive(:is_feature_enabled).and_return(true) From 783f62de6884f6b95ad013b3196ea9b8b7bb8df3 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 26 Nov 2025 17:19:21 +0100 Subject: [PATCH 28/31] feat: split-evaluate-into-boolean-and-value-methods Signed-off-by: wadii --- .../lib/openfeature/flagsmith/provider.rb | 216 ++++++++---------- .../openfeature/flagsmith/provider_spec.rb | 118 +++++----- 2 files changed, 164 insertions(+), 170 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index b80ca9e..237f7ae 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -31,57 +31,27 @@ def shutdown end def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) - evaluate( - flag_key: flag_key, - default_value: default_value, - evaluation_context: evaluation_context, - allowed_type_classes: [TrueClass, FalseClass] - ) + evaluate_boolean(flag_key, default_value, evaluation_context) end def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) - evaluate( - flag_key: flag_key, - default_value: default_value, - evaluation_context: evaluation_context, - allowed_type_classes: [String] - ) + evaluate_value(flag_key, default_value, evaluation_context, [String]) end def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) - evaluate( - flag_key: flag_key, - default_value: default_value, - evaluation_context: evaluation_context, - allowed_type_classes: [Integer, Float, Numeric] - ) + evaluate_value(flag_key, default_value, evaluation_context, [Integer, Float, Numeric]) end def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) - evaluate( - flag_key: flag_key, - default_value: default_value, - evaluation_context: evaluation_context, - allowed_type_classes: [Integer] - ) + evaluate_value(flag_key, default_value, evaluation_context, [Integer]) end def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) - evaluate( - flag_key: flag_key, - default_value: default_value, - evaluation_context: evaluation_context, - allowed_type_classes: [Float] - ) + evaluate_value(flag_key, default_value, evaluation_context, [Float]) end def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) - evaluate( - flag_key: flag_key, - default_value: default_value, - evaluation_context: evaluation_context, - allowed_type_classes: [Hash, Array] - ) + evaluate_value(flag_key, default_value, evaluation_context, [Hash, Array]) end private @@ -101,96 +71,110 @@ def create_flagsmith_client raise ProviderNotReadyError, "Failed to create Flagsmith client: #{e.class}: #{e.message}" end - def evaluate(flag_key:, default_value:, evaluation_context:, allowed_type_classes:) - # Check if provider is initialized - if @flagsmith_client.nil? - return SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::ERROR, - error_code: SDK::Provider::ErrorCode::PROVIDER_NOT_READY, - error_message: "Provider not initialized. Call init() first." - ) - end - - # Check for invalid flag keys - if flag_key.nil? || flag_key.to_s.empty? - return SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::DEFAULT, - error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, - error_message: "Flag key cannot be empty or nil" - ) - end + def evaluate_boolean(flag_key, default_value, evaluation_context) + return provider_not_ready_result(default_value) if @flagsmith_client.nil? + return invalid_flag_key_result(default_value) if flag_key.nil? || flag_key.to_s.empty? evaluation_context ||= SDK::EvaluationContext.new + flags = get_flags(evaluation_context) + value = flags.is_feature_enabled(flag_key) - begin - # Get flags from Flagsmith - flags = get_flags(evaluation_context) + success_result(value, evaluation_context) + rescue FlagsmithError => e + error_result(default_value, e.error_code, e.error_message) + rescue => e + error_result(default_value, SDK::Provider::ErrorCode::GENERAL, "Unexpected error: #{e.class}: #{e.message}") + end - is_boolean_flag = (allowed_type_classes.sort == [FalseClass, TrueClass]) + def evaluate_value(flag_key, default_value, evaluation_context, allowed_type_classes) + return provider_not_ready_result(default_value) if @flagsmith_client.nil? + return invalid_flag_key_result(default_value) if flag_key.nil? || flag_key.to_s.empty? - if is_boolean_flag - value = flags.is_feature_enabled(flag_key) - else - found_flag = flags.all_flags.find { |f| f.feature_name == flag_key } + evaluation_context ||= SDK::EvaluationContext.new + flags = get_flags(evaluation_context) + found_flag = flags.all_flags.find { |f| f.feature_name == flag_key } + + return flag_not_found_result(default_value, flag_key) if found_flag.nil? + return flag_disabled_result(default_value, flag_key) unless found_flag.enabled + + raw_value = found_flag.value + value = if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } + parse_json_value(raw_value) + elsif [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } + parse_numeric_value(raw_value, allowed_type_classes) + else + raw_value + end + + return type_mismatch_result(default_value, value, allowed_type_classes) unless type_matches?(value, allowed_type_classes) + + success_result(value, evaluation_context) + rescue FlagsmithError => e + error_result(default_value, e.error_code, e.error_message) + rescue => e + error_result(default_value, SDK::Provider::ErrorCode::GENERAL, "Unexpected error: #{e.class}: #{e.message}") + end - if found_flag.nil? - return SDK::Provider::ResolutionDetails.new( - value: default_value, reason: SDK::Provider::Reason::DEFAULT, - error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, error_message: "Flag '#{flag_key}' not found" - ) - end + def provider_not_ready_result(default_value) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::PROVIDER_NOT_READY, + error_message: "Provider not initialized. Call init() first." + ) + end - unless found_flag.enabled - return SDK::Provider::ResolutionDetails.new( - value: default_value, reason: SDK::Provider::Reason::DISABLED, - error_message: "Flag '#{flag_key}' is disabled" - ) - end + def invalid_flag_key_result(default_value) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag key cannot be empty or nil" + ) + end - raw_value = found_flag.value - value = if [Hash, Array].any? { |klass| allowed_type_classes.include?(klass) } - parse_json_value(raw_value) - elsif [Integer, Float, Numeric].any? { |klass| allowed_type_classes.include?(klass) } - parse_numeric_value(raw_value, allowed_type_classes) - else - raw_value - end - end + def flag_not_found_result(default_value, flag_key) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DEFAULT, + error_code: SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + error_message: "Flag '#{flag_key}' not found" + ) + end - # Validate type - unless type_matches?(value, allowed_type_classes) - return SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::ERROR, - error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, - error_message: "Expected type #{allowed_type_classes}, got #{value.class}" - ) - end + def flag_disabled_result(default_value, flag_key) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::DISABLED, + error_message: "Flag '#{flag_key}' is disabled" + ) + end - # Return successful resolution - SDK::Provider::ResolutionDetails.new( - value: value, - reason: determine_reason(evaluation_context), - variant: nil, # Flagsmith doesn't have explicit variants - flag_metadata: {} - ) - rescue FlagsmithError => e - SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::ERROR, - error_code: e.error_code, - error_message: e.error_message - ) - rescue => e - SDK::Provider::ResolutionDetails.new( - value: default_value, - reason: SDK::Provider::Reason::ERROR, - error_code: SDK::Provider::ErrorCode::GENERAL, - error_message: "Unexpected error: #{e.class}: #{e.message}" - ) - end + def type_mismatch_result(default_value, value, allowed_type_classes) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: SDK::Provider::ErrorCode::TYPE_MISMATCH, + error_message: "Expected type #{allowed_type_classes}, got #{value.class}" + ) + end + + def error_result(default_value, error_code, error_message) + SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: SDK::Provider::Reason::ERROR, + error_code: error_code, + error_message: error_message + ) + end + + def success_result(value, evaluation_context) + SDK::Provider::ResolutionDetails.new( + value: value, + reason: determine_reason(evaluation_context), + variant: nil, + flag_metadata: {} + ) end def get_flags(evaluation_context) diff --git a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb index 37eb293..749d939 100644 --- a/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb +++ b/providers/openfeature-flagsmith-provider/spec/openfeature/flagsmith/provider_spec.rb @@ -11,6 +11,11 @@ let(:mock_flagsmith_client) { instance_double("Flagsmith::Client") } let(:mock_flags) { double("Flags") } + # Helper to create a mock flag object for all_flags + def mock_flag(feature_name:, enabled:, value:) + double("Flag", feature_name: feature_name, enabled: enabled, value: value) + end + before do # Mock Flagsmith::Client creation allow(::Flagsmith::Client).to receive(:new).and_return(mock_flagsmith_client) @@ -140,8 +145,7 @@ describe "#fetch_string_value" do it "should return ResolutionDetails" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_string_value( flag_key: flag_key, @@ -153,8 +157,9 @@ it "should return actual string value when flag exists" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return("hello_world") - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "hello_world") + ]) result = provider.fetch_string_value( flag_key: flag_key, @@ -167,8 +172,7 @@ it "should return default value when flag not found" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_string_value( flag_key: flag_key, @@ -182,8 +186,7 @@ describe "#fetch_number_value" do it "should return ResolutionDetails" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_number_value( flag_key: flag_key, @@ -195,8 +198,9 @@ it "should parse numeric string value" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return("123") - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "123") + ]) result = provider.fetch_number_value( flag_key: flag_key, @@ -209,8 +213,9 @@ it "should return actual numeric value" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(456) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: 456) + ]) result = provider.fetch_number_value( flag_key: flag_key, @@ -222,8 +227,7 @@ it "should return default value when flag not found" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_number_value( flag_key: flag_key, @@ -237,8 +241,7 @@ describe "#fetch_integer_value" do it "should return ResolutionDetails" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_integer_value( flag_key: flag_key, @@ -252,8 +255,7 @@ describe "#fetch_float_value" do it "should return ResolutionDetails" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_float_value( flag_key: flag_key, @@ -267,8 +269,7 @@ describe "#fetch_object_value" do it "should return ResolutionDetails" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_object_value( flag_key: flag_key, @@ -280,8 +281,9 @@ it "should parse JSON string to hash" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return('{"color":"red","size":42}') - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: '{"color":"red","size":42}') + ]) result = provider.fetch_object_value( flag_key: flag_key, @@ -294,8 +296,9 @@ it "should return hash value directly" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return({foo: "bar"}) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: {foo: "bar"}) + ]) result = provider.fetch_object_value( flag_key: flag_key, @@ -307,8 +310,7 @@ it "should return default value when flag not found" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(flag_key).and_return(nil) - allow(mock_flags).to receive(:is_feature_enabled).with(flag_key).and_return(false) + allow(mock_flags).to receive(:all_flags).and_return([]) result = provider.fetch_object_value( flag_key: flag_key, @@ -436,8 +438,9 @@ describe "JSON parsing errors" do it "should handle malformed JSON in object values" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return("{invalid json") - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "{invalid json") + ]) result = provider.fetch_object_value( flag_key: flag_key, @@ -454,9 +457,9 @@ describe "type mismatch errors" do it "should return error when boolean flag returns non-boolean value" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - # is_feature_enabled should return boolean, but let's test type validation - allow(mock_flags).to receive(:get_feature_value).and_return("not_a_boolean") - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "not_a_boolean") + ]) result = provider.fetch_string_value( flag_key: flag_key, @@ -470,8 +473,9 @@ it "should return error when string value cannot be converted to integer" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return("not_a_number") - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: flag_key, enabled: true, value: "not_a_number") + ]) result = provider.fetch_integer_value( flag_key: flag_key, @@ -528,8 +532,9 @@ it "should handle flag keys with special characters" do special_key = "flag-with-dashes_and_underscores.and.dots" allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).with(special_key).and_return("value") - allow(mock_flags).to receive(:is_feature_enabled).with(special_key).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: special_key, enabled: true, value: "value") + ]) result = provider.fetch_string_value( flag_key: special_key, @@ -545,7 +550,6 @@ describe "evaluation context edge cases" do it "should handle empty string targeting_key" do allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(nil) allow(mock_flags).to receive(:is_feature_enabled).and_return(false) context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "") @@ -560,7 +564,6 @@ it "should handle whitespace-only targeting_key" do allow(mock_flagsmith_client).to receive(:get_environment_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(nil) allow(mock_flags).to receive(:is_feature_enabled).and_return(false) context = OpenFeature::SDK::EvaluationContext.new(targeting_key: " ") @@ -575,7 +578,6 @@ it "should handle nil values in context fields (traits)" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(nil) allow(mock_flags).to receive(:is_feature_enabled).and_return(false) context = OpenFeature::SDK::EvaluationContext.new( @@ -595,8 +597,9 @@ it "should handle unicode in trait values" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return("value") - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: "value") + ]) context = OpenFeature::SDK::EvaluationContext.new( targeting_key: "user_123", @@ -617,8 +620,9 @@ describe "numeric type edge cases" do it "should handle zero values" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(0) - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: 0) + ]) result = provider.fetch_integer_value( flag_key: "test", @@ -631,8 +635,9 @@ it "should handle negative numbers" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(-999) - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: -999) + ]) result = provider.fetch_integer_value( flag_key: "test", @@ -646,8 +651,9 @@ it "should handle very large numbers" do large_num = 999_999_999_999 allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(large_num) - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: large_num) + ]) result = provider.fetch_integer_value( flag_key: "test", @@ -660,8 +666,9 @@ it "should handle scientific notation in strings" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return("1.5e2") - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: "1.5e2") + ]) result = provider.fetch_float_value( flag_key: "test", @@ -676,8 +683,9 @@ describe "object/array edge cases" do it "should handle empty objects" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return({}) - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: {}) + ]) result = provider.fetch_object_value( flag_key: "test", @@ -690,8 +698,9 @@ it "should handle empty arrays" do allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return([]) - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: []) + ]) result = provider.fetch_object_value( flag_key: "test", @@ -705,8 +714,9 @@ it "should handle nested objects" do nested = {outer: {inner: {deep: "value"}}} allow(mock_flagsmith_client).to receive(:get_identity_flags).and_return(mock_flags) - allow(mock_flags).to receive(:get_feature_value).and_return(nested) - allow(mock_flags).to receive(:is_feature_enabled).and_return(true) + allow(mock_flags).to receive(:all_flags).and_return([ + mock_flag(feature_name: "test", enabled: true, value: nested) + ]) result = provider.fetch_object_value( flag_key: "test", From b5f4e1556450cd2deac97e45f6b28531a341ba63 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 26 Nov 2025 17:32:38 +0100 Subject: [PATCH 29/31] feat: moved-require-flagsmith-to-top-level Signed-off-by: wadii --- .../lib/openfeature/flagsmith/provider.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index 237f7ae..d3588dc 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "open_feature/sdk" +require "flagsmith" require "json" require_relative "options" require_relative "error/errors" @@ -57,8 +58,6 @@ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) private def create_flagsmith_client - require "flagsmith" - ::Flagsmith::Client.new( environment_key: @options.environment_key, api_url: @options.api_url, From 771e55289d4f279866debb59d5172271127df082 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 27 Nov 2025 10:55:41 +0100 Subject: [PATCH 30/31] feat: specify-targeting-key-requirement-in-readme Signed-off-by: wadii --- providers/openfeature-flagsmith-provider/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/openfeature-flagsmith-provider/README.md b/providers/openfeature-flagsmith-provider/README.md index 09b6e07..4d35b5a 100644 --- a/providers/openfeature-flagsmith-provider/README.md +++ b/providers/openfeature-flagsmith-provider/README.md @@ -182,7 +182,7 @@ The provider supports OpenFeature evaluation contexts to pass user information a ### Targeting Key → Identity -The `targeting_key` maps to Flagsmith's identity identifier: +The `targeting_key` maps to Flagsmith's identity identifier. **Note:** Flagsmith requires an identity to evaluate traits, so if you provide traits without a `targeting_key`, they will be ignored and evaluation falls back to environment-level flags. ```ruby evaluation_context = OpenFeature::SDK::EvaluationContext.new( From 1356c881aca854e2bb387fde987979c10fa2fc88 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 27 Nov 2025 10:58:18 +0100 Subject: [PATCH 31/31] feat: handle-nil-evaluation-context-case Signed-off-by: wadii --- .../lib/openfeature/flagsmith/provider.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb index d3588dc..7c8d0f8 100644 --- a/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb +++ b/providers/openfeature-flagsmith-provider/lib/openfeature/flagsmith/provider.rb @@ -74,7 +74,6 @@ def evaluate_boolean(flag_key, default_value, evaluation_context) return provider_not_ready_result(default_value) if @flagsmith_client.nil? return invalid_flag_key_result(default_value) if flag_key.nil? || flag_key.to_s.empty? - evaluation_context ||= SDK::EvaluationContext.new flags = get_flags(evaluation_context) value = flags.is_feature_enabled(flag_key) @@ -89,7 +88,6 @@ def evaluate_value(flag_key, default_value, evaluation_context, allowed_type_cla return provider_not_ready_result(default_value) if @flagsmith_client.nil? return invalid_flag_key_result(default_value) if flag_key.nil? || flag_key.to_s.empty? - evaluation_context ||= SDK::EvaluationContext.new flags = get_flags(evaluation_context) found_flag = flags.all_flags.find { |f| f.feature_name == flag_key } @@ -179,6 +177,10 @@ def success_result(value, evaluation_context) def get_flags(evaluation_context) raise ProviderNotReadyError, "Flagsmith client not initialized" if @flagsmith_client.nil? + if evaluation_context.nil? + return @flagsmith_client.get_environment_flags + end + targeting_key = evaluation_context.targeting_key if targeting_key.nil? || targeting_key.to_s.strip.empty? @flagsmith_client.get_environment_flags @@ -249,6 +251,8 @@ def type_matches?(value, allowed_type_classes) def determine_reason(evaluation_context) # Use TARGETING_MATCH if we have targeting_key (identity-specific) # Use STATIC for environment-level flags + return SDK::Provider::Reason::STATIC if evaluation_context.nil? + targeting_key = evaluation_context.targeting_key if targeting_key.nil? || targeting_key.to_s.strip.empty? SDK::Provider::Reason::STATIC