diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7a1ade30..7c0eb658 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,54 +26,9 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - bundler-cache: true - run: bundle install - run: bundle exec rake - - rubocop: - runs-on: ${{ matrix.os }} - name: Rubocop ${{ matrix.ruby }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - ruby: - - "3.1.2" - - "2.7.6" - - "3.0.4" - env: - BUNDLE_GEMFILE: Gemfile - - steps: - - uses: actions/checkout@v3 - - name: Set up Ruby ${{ matrix.ruby }} - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - name: Run style checks run: bundle exec rubocop - - test: - runs-on: ${{ matrix.os }} - name: RSpec ${{ matrix.ruby }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - ruby: - - "3.1.2" - - "2.7.6" - - "3.0.4" - env: - BUNDLE_GEMFILE: Gemfile - - steps: - - uses: actions/checkout@v3 - - name: Set up Ruby ${{ matrix.ruby }} - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - name: Run tests run: bundle exec rspec diff --git a/.rspec b/.rspec index 34c5164d..44b132be 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,4 @@ +-I lib --format documentation --color --require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml index af98bb01..236b1161 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,6 +12,13 @@ Style/StringLiteralsInInterpolation: Layout/LineLength: Max: 120 + Exclude: + - 'spec/**/*.rb' + +Metrics/BlockLength: + Exclude: + - 'spec/**/*.rb' + - 'openfeature-sdk.gemspec' Gemspec/RequireMFA: Enabled: false diff --git a/Gemfile b/Gemfile index 3b2e7f2c..76a864be 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,5 @@ source "https://rubygems.org" # Specify your gem's dependencies in openfeature-sdk.gemspec gemspec + +gem "concurrent-ruby", require: "concurrent" diff --git a/Gemfile.lock b/Gemfile.lock index c06b2b31..3c35b44a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,13 @@ PATH remote: . specs: - openfeature-sdk (0.0.2) + openfeature-sdk (0.0.3) GEM remote: https://rubygems.org/ specs: ast (2.4.2) + concurrent-ruby (1.1.10) diff-lcs (1.5.0) json (2.6.2) parallel (1.22.1) @@ -55,6 +56,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + concurrent-ruby openfeature-sdk! rake (~> 13.0) rspec (~> 3.12.0) diff --git a/lib/openfeature/sdk.rb b/lib/openfeature/sdk.rb index 46085e2f..bfd3550c 100644 --- a/lib/openfeature/sdk.rb +++ b/lib/openfeature/sdk.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true require_relative "sdk/version" +require_relative "sdk/api" module OpenFeature + # TODO: Add documentation + # module SDK - class Error < StandardError; end - # Your code goes here... end end diff --git a/lib/openfeature/sdk/api.rb b/lib/openfeature/sdk/api.rb new file mode 100644 index 00000000..0db9f1f5 --- /dev/null +++ b/lib/openfeature/sdk/api.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "forwardable" +require "singleton" + +require_relative "configuration" +require_relative "client" +require_relative "metadata" +require_relative "provider/no_op_provider" + +module OpenFeature + module SDK + # API Initialization and Configuration + # + # Represents the entry point to the API, including configuration of Provider,Hook, + # and building the Client + # + # To use the SDK, you can optionally configure a Provider, with Hook + # + # OpenFeature::SDK::API.instance.configure do |config| + # config.provider = NoOpProvider.new + # end + # + # If no provider is specified, the NoOpProvider is set as the default Provider. + # Once the SDK has been configured, a client can be built + # + # client = OpenFeature::SDK::API.instance.build_client(name: 'my-open-feature-client') + class API + include Singleton + extend Forwardable + + def_delegator :@configuration, :provider + def_delegator :@configuration, :hooks + def_delegator :@configuration, :context + + def configuration + @configuration ||= Configuration.new + end + + def configure(&block) + return unless block_given? + + block.call(configuration) + end + + def build_client(name: nil, version: nil) + client_options = Metadata.new(name: name, version: version).freeze + provider = Provider::NoOpProvider.new if provider.nil? + Client.new(provider, client_options, context) + end + end + end +end diff --git a/lib/openfeature/sdk/client.rb b/lib/openfeature/sdk/client.rb new file mode 100644 index 00000000..7dfe001c --- /dev/null +++ b/lib/openfeature/sdk/client.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + # TODO: Write documentation + # + class Client + attr_reader :metadata + + attr_accessor :hooks + + def initialize(provider, client_options, context) + @provider = provider + @client_options = client_options + @context = context + end + end + end +end diff --git a/lib/openfeature/sdk/configuration.rb b/lib/openfeature/sdk/configuration.rb new file mode 100644 index 00000000..06ce7d94 --- /dev/null +++ b/lib/openfeature/sdk/configuration.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "concurrent" + +require_relative "api" + +module OpenFeature + module SDK + # Represents the configuration object for the global API where Provider, Hook, + # and Context are configured. + # This class is not meant to be interacted with directly but instead through the OpenFeature::SDK.configure + # method + class Configuration + extend Forwardable + + attr_accessor :context, :provider, :hooks + + def_delegator :@provider, :metadata + + def initialize + @hooks = Concurrent::Array.new([]) + end + end + end +end diff --git a/lib/openfeature/sdk/metadata.rb b/lib/openfeature/sdk/metadata.rb new file mode 100644 index 00000000..411ae1d5 --- /dev/null +++ b/lib/openfeature/sdk/metadata.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + # Metadata structure that defines general metadata relating to a Provider or Client + # + # Within the Metadata structure, the following attribute readers are available: + # + # * name - Defines the name of the structure + # + # * version - Allows you to specify version of the Metadata structure + # + # Usage: + # + # metadata = Metadata.new(name: 'name-for-metadata', version: 'v1.1.3') + # metadata.name # 'name-for-metadata' + # metadata.version # version + # metadata_two = Metadata.new(name: 'name-for-metadata') + # metadata_two == metadata # true - equality based on values + class Metadata + attr_reader :name, :version + + def initialize(name:, version: nil) + @name = name + @version = version + end + + def ==(other) + raise ArgumentError("Expected comparison to be between Metadata object") unless other.is_a?(Metadata) + + @name == other.name && @version == other.version + end + end + end +end diff --git a/lib/openfeature/sdk/provider/no_op_provider.rb b/lib/openfeature/sdk/provider/no_op_provider.rb new file mode 100644 index 00000000..781c3c18 --- /dev/null +++ b/lib/openfeature/sdk/provider/no_op_provider.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require_relative "../metadata" + +# rubocop:disable Lint/UnusedMethodArgument +module OpenFeature + module SDK + module Provider + # Defines the default provider that is set if no provider is specified. + # + # To use NoOpProvider, it can be set during the configuration of the SDK + # + # OpenFeature::SDK.configure do |config| + # config.provider = NoOpProvider.new + # end + # + # Within the NoOpProvider, the following methods exist + # + # * fetch_boolean_value - Retrieve feature flag boolean value + # + # * fetch_string_value - Retrieve feature flag string value + # + # * fetch_number_value - Retrieve feature flag number value + # + # * fetch_object_value - Retrieve feature flag object value + # + class NoOpProvider + REASON_NO_OP = "No-op" + NAME = "No-op Provider" + + attr_reader :metadata + + def initialize + @metadata = Metadata.new(name: NAME).freeze + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + private + + def no_op(default_value) + Struct.new("ResolutionDetails", :value, :reason) + end + end + end + end +end +# rubocop:enable Lint/UnusedMethodArgument diff --git a/spec/openfeature/sdk/api_spec.rb b/spec/openfeature/sdk/api_spec.rb new file mode 100644 index 00000000..5491e807 --- /dev/null +++ b/spec/openfeature/sdk/api_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +require "openfeature/sdk/configuration" +require "openfeature/sdk/api" +require "openfeature/sdk/metadata" + +# https://docs.openfeature.dev/docs/specification/sections/flag-evaluation#11-api-initialization-and-configuration + +RSpec.describe OpenFeature::SDK::API do + subject(:api) { described_class.instance } + + context "with Requirement 1.1.2" do + before do + api.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + end + end + + it do + expect(api).to respond_to(:provider) + end + + it do + expect(api.provider).not_to be_nil + end + + it do + expect(api.provider).is_a?(OpenFeature::SDK::Provider) + end + end + + context "with Requirement 1.1.3" do + before do + api.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + config.hooks << hook1 + config.hooks << hook2 + end + end + + let(:hook1) { "my_hook" } + let(:hook2) { "my_other_hook" } + + it do + expect(api).to respond_to(:hooks) + expect(api.hooks).to have_attributes(size: 2).and eq([hook1, hook2]) + end + end + + context "with Requirement 1.1.4" do + before do + api.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + end + end + + it "must provide a function for retrieving the metadata field of the configured provider" do + expect(api.provider.metadata).not_to be_nil + end + + it do + expect(api.provider).to respond_to(:metadata) + end + + it do + expect(api.provider.metadata).is_a?(OpenFeature::SDK::Metadata) + end + + it do + expect(api.provider.metadata).to eq(OpenFeature::SDK::Metadata.new(name: OpenFeature::SDK::Provider::NoOpProvider::NAME)) + end + end + + context "with Requirement 1.1.5" do + before do + api.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + end + + api.build_client(name: "requirement-1.1.5") + end + + it "provide a function for creating a client which accepts the following options: * name (optional): A logical string identifier for the client." do + expect(api).to respond_to(:build_client).with_keywords(:name, :version) + end + + it do + expect(api).is_a?(OpenFeature::SDK::Client) + end + end +end diff --git a/spec/openfeature/sdk_spec.rb b/spec/openfeature/sdk_spec.rb index 96829020..1474a9f2 100644 --- a/spec/openfeature/sdk_spec.rb +++ b/spec/openfeature/sdk_spec.rb @@ -2,6 +2,6 @@ RSpec.describe OpenFeature::SDK do it "has a version number" do - expect(OpenFeature::SDK::VERSION).not_to be nil + expect(OpenFeature::SDK::VERSION).not_to be_nil end end