There's a lot of open issues
Sorbet-aware implemention of the OpenFeature specification
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies
 Project Readme

Sorbet-aware OpenFeature Ruby Implementation

NOTE This implementation has been deprecated in favor of the official Ruby SDK. We recommend switching to that. In the future, we hope to add RBS and Tapioca DSL generation support to the official library.

OpenFeature is an open standard for vendor-agnostic feature flagging. Sorbet is a type-checker for Ruby, built by Stripe. Sorbet provides powerful runtime utilities to achieve things traditionally not possible with Ruby, such as interfaces, immutable structures and enums. This makes it a very good option when defining specifications.

If an organization is not already using Sorbet, you probably don't want to introduce a dependency on sorbet-runtime, which this gem does. As such, this will always be a distinct implementation, separate from the official Ruby SDK.

Current OpenFeature specification target version: 0.5.2 Current supported Ruby versions: 3.1.X, 3.2.X Support for Evaluation Context and Hooks is not complete.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add openfeature-sdk-sorbet

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install openfeature-sdk-sorbet

Usage

require "open_feature_sorbet"

# Configure global API properties

OpenFeature.set_provider(OpenFeatureSorbet::NoOpProvider.new)
OpenFeature.set_evaluation_context(OpenFeatureSorbet::EvaluationContext.new(fields: { "globally" => "available" }))
OpenFeature.add_hooks([OpenFeatureSorbet::Hook.new]) # experimental, not fully supported

client = OpenFeature.create_client(evaluation_context: OpenFeatureSorbet::EvaluationContext.new(fields: { "client" => "available" }))

# Fetch boolean value
# Also methods available for String, Number, Integer, Float and Structure (Hash)
bool_value = client.fetch_boolean_value(flag_key: "my_toggle", default_value: false) # => (true or false)

# Sorbet sprinkles in type safety
bool_value = client.fetch_boolean_value(flag_key: "my_toggle", default_value: "bad!") # => raises TypeError from Sorbet, invalid default value

# Additional evaluation context can be provided during invocation
number_value = client.fetch_number_value(flag_key: "my_toggle", default_value: 1, context: OpenFeatureSorbet::EvaluationContext.new(fields: { "only_this_call_site" => 10 })) # => merges client and global context

# Fetch structure evaluation details
structure_evaluation_details = client.fetch_structure_details(flag_key: "my_structure", default_value: { "a" => "fallback" }) # => EvaluationDetails(value: Hash, flag_key: "my_structure", ...)

Note on Structure

The OpenFeature specification defines Structure as a potential return type. This is somewhat ambiguous in Ruby, further complicated by T::Struct that we get from Sorbet. For now, the type I've elected here is T.any(T::Array[T.untyped], T::Hash[T.untyped, T.untyped] (loosely, either an Array of untyped members or a Hash with untyped keys and untyped values) for flexibility but with a little more structure than a YML or JSON parsable string. This decision might change in the future upon further interpretation or new versions of the specification.

Evaluation Context

We support global evaluation context (set on the OpenFeature module), client evaluation context (set on client instances or during client initialization) and invocation evaluation context (passed in during flag evaluation). In compliance with the specification, the invocation context merges into the client context which merges into the global context. Fields in invocation context take precedence over fields in the client context which take precedence over fields in the global context.

Provider Abstract Class

By default, this implementation sets the provider to the OpenFeatureSorbet::NoOpProvider which always returns the default value. It's up to the individual teams to define their own providers based on their flag source (in the future, I'll release open-source providers based on various, common vendors).

This gem also provides OpenFeatureSorbet::MultipleSourceProvider to allow fetching flags from multiple sources. This is especially useful if your existing application has flags spread across bespoke and vendor solutions and you want to unify the evaluation sites. It can be instantiated and configured like so:

provider = OpenFeatureSorbet::MultipleSourceProvider.new(
  providers: [
    CustomProvider.new,
    OpenFeatureSorbet::NoOpProvider.new
  ]
)

OpenFeature.set_provider(provider)

Implementing Custom Providers

Thanks to Sorbet abstract classes, it's fairly straightforward to implement a new provider. Here is an example for a JSON-based flag format on disk:

class JsonFileFlagProvider < OpenFeatureSorbet::Provider
  extend T::Sig

  sig { void }
  def initialize
    super(OpenFeatureSorbet::ProviderStatus::NotReady)
  end

  def init(context)
    @file = File.open(context.file || "flags.json")
    @status = OpenFeatureSorbet::ProviderStatus::Ready
  end

  sig { overridable.void }
  def shutdown
    @file.close
  end

  sig { override.returns(OpenFeatureSorbet::ProviderMetadata) }
  def metadata
    OpenFeatureSorbet::ProviderMetadata.new(name: "Json File Flag Provider")
  end

  sig { override.returns(T::Array[Hook]) }
  def hooks
    []
  end

  sig do
    override
      .params(
        flag_key: String,
        default_value: T::Boolean,
        context: T.nilable(EvaluationContext)
      )
      .returns(OpenFeatureSorbet::ResolutionDetails[T::Boolean])
  end
  def resolve_boolean_value(flag_key:, default_value:, context: nil)
    file_input = JSON.parse(File.read("flags.rb"))
    value = file_input.fetch("flag_key", default_value)

    OpenFeatureSorbet::ResolutionDetails.new(
      value: value,
      # ... other optional fields
    )
  end

  # ... other resolver methods
end

By inheriting from the OpenFeatureSorbet::Provider class, Sorbet will indicate what methods it's expecting and what their type signatures should be.

A note on initialize versus init

The Ruby initialize method is the best place to do any direct construction logic for an object, such as setting configuration values. init is called by OpenFeature when setting a provider and is the best place to make any HTTP requests, establish persistent connections, or any other connection logic that could potentially fail. By the end of this method, @status must be set to either Ready or Error.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake to run Rubocop and the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/maxveldink/openfeature-sdk-sorbet. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in this project's codebase, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Sponsorships

I love creating in the open. If you find this or any other maxveld.ink content useful, please consider sponsoring me on GitHub.