diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4633a7ac --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +root = true + +[*] +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +tab_width = 8 +trim_trailing_whitespace = true + +[*.bat] +end_of_line = crlf + +[*.gemspec] +indent_size = 2 + +[*.rb] +indent_size = 2 + +[*.yml] +indent_size = 2 + +[{*[Mm]akefile*,*.mak,*.mk,depend}] +indent_style = tab + +[enc/*] +indent_size = 2 + +[reg*.[ch]] +indent_size = 2 diff --git a/.rubocop.yml b/.rubocop.yml index 030f2943..1c951a20 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,4 +11,11 @@ Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes Layout/LineLength: - Max: 120 + Enabled: false + Max: 270 + +Style/AccessorGrouping: + Enabled: false + +Metrics/BlockLength: + Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index 4d8d55e4..a778957c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: openfeature-sdk (0.0.1) + sorbet-runtime (~> 0.5.10539) GEM remote: https://rubygems.org/ @@ -42,6 +43,12 @@ GEM rubocop-ast (1.23.0) parser (>= 3.1.1.0) ruby-progressbar (1.11.0) + sorbet (0.5.10539) + sorbet-static (= 0.5.10539) + sorbet-runtime (0.5.10539) + sorbet-static (0.5.10539-universal-darwin-21) + sorbet-static (0.5.10539-universal-darwin-22) + sorbet-static (0.5.10539-x86_64-linux) unicode-display_width (2.3.0) PLATFORMS @@ -59,6 +66,7 @@ DEPENDENCIES rake (~> 13.0) rspec (~> 3.12.0) rubocop (~> 1.37.1) + sorbet (~> 0.5.10539) BUNDLED WITH 2.3.25 diff --git a/lib/openfeature/sdk.rb b/lib/openfeature/sdk.rb index 46085e2f..a132bdf8 100644 --- a/lib/openfeature/sdk.rb +++ b/lib/openfeature/sdk.rb @@ -1,10 +1,65 @@ # frozen_string_literal: true +require "sorbet-runtime" +require "forwardable" + require_relative "sdk/version" +require_relative "sdk/configuration" +require_relative "sdk/client" +require_relative "sdk/metadata" +require_relative "sdk/provider/no_op_provider" module OpenFeature + # API Initialization and Configuration + # + # Represents the entry point to the SDK, including configuration of Provider,Hook, + # and building the Client + # + # To use the SDK, you can optionally configure a Provider, with Hook + # + # OpenFeature::SDK.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.build_client(name: 'my-open-feature-client') module SDK - class Error < StandardError; end - # Your code goes here... + class << self + extend T::Sig + extend Forwardable + + def_delegator :@configuration, :provider + def_delegator :@configuration, :hooks + def_delegator :@configuration, :context + + sig { returns(Configuration) } + def configuration + @configuration ||= T.let(Configuration.new, Configuration) + end + + # rubocop:disable Lint/UnusedMethodArgument + sig { params(block: T.proc.params(arg0: Configuration).void).void } + def configure(&block) + return unless block_given? + + yield(configuration) + end + # rubocop:enable Lint/UnusedMethodArgument + + sig do + params( + name: T.nilable(String), + version: T.nilable(String), + context: T.nilable(EvaluationContext) + ).returns(SDK::Client) + end + def build_client(name: nil, version: nil, context: nil) + client_options = Metadata.new(name: name, version: version) + provider = Provider::NoOpProvider.new if provider.nil? + SDK::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..a34b03bb --- /dev/null +++ b/lib/openfeature/sdk/client.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" +require "forwardable" +require "json" + +require_relative "./provider/provider" +require_relative "./evaluation_context" +require_relative "./metadata" +require_relative "./hook/hook" +require_relative "./hook/hook_context" +require_relative "./evaluation_options" +require_relative "./resolver/boolean_resolver" +require_relative "./resolver/number_resolver" +require_relative "./resolver/object_resolver" +require_relative "./resolver/string_resolver" + +module OpenFeature + module SDK + # TODO: Write + # + class Client + extend T::Sig + extend Forwardable + + class OpenFeatureOptions < T::Struct + const :name, T.nilable(String) + const :version, T.nilable(String) + end + + sig { returns(Metadata) } + attr_reader :metadata + + sig { returns(T::Array[Hook]) } + attr_accessor :hooks + + def_delegator :@boolean_resolver, :fetch_value, :fetch_boolean_value + def_delegator :@boolean_resolver, :fetch_detailed_value, :fetch_boolean_details + + def_delegator :@number_resolver, :fetch_value, :fetch_number_value + def_delegator :@number_resolver, :fetch_detailed_value, :fetch_number_details + + def_delegator :@string_resolver, :fetch_value, :fetch_string_value + def_delegator :@string_resolver, :fetch_detailed_value, :fetch_string_details + + def_delegator :@object_resolver, :fetch_value, :fetch_object_value + def_delegator :@object_resolver, :fetch_detailed_value, :fetch_object_details + + sig do + params( + provider: Provider, + client_options: Metadata, + context: T.nilable(EvaluationContext) + ).void + end + def initialize(provider, client_options, context) + @provider = provider + @metadata = client_options.dup.freeze + @context = context.dup.freeze + @hooks = [] + + @boolean_resolver = Resolver::BooleanResolver.new(provider) + @number_resolver = Resolver::NumberResolver.new(provider) + @string_resolver = Resolver::StringResolver.new(provider) + @object_resolver = Resolver::ObjectResolver.new(provider) + end + end + end +end diff --git a/lib/openfeature/sdk/configuration.rb b/lib/openfeature/sdk/configuration.rb new file mode 100644 index 00000000..7cfb0119 --- /dev/null +++ b/lib/openfeature/sdk/configuration.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true +# typed: true + +require "forwardable" + +require_relative "./provider/provider" +require_relative "./provider/no_op_provider" + +module OpenFeature + module SDK + # TODO: Write documentation + # + class Configuration + extend T::Sig + extend Forwardable + + sig { returns(T.nilable(EvaluationContext)) } + attr_accessor :context + + sig { returns(SDK::Provider) } + attr_accessor :provider + + sig { returns(T::Array[Hook]) } + attr_accessor :hooks + + def_delegator :@provider, :metadata + + def initialize + @hooks = [] + end + end + end +end diff --git a/lib/openfeature/sdk/evaluation_context.rb b/lib/openfeature/sdk/evaluation_context.rb new file mode 100644 index 00000000..0462a3ac --- /dev/null +++ b/lib/openfeature/sdk/evaluation_context.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +# typed: true + +# frozen_literal: true + +require "sorbet-runtime" +require "date" + +class EvaluationContext < T::Struct + CustomFieldValues = T.type_alias { T.any(T::Boolean, String, Integer, Float, T.untyped, DateTime) } + CustomField = T.type_alias { T::Hash[String, CustomFieldValues] } + + const :targeting_key, T.nilable(String) + const :custom_fields, T.nilable(CustomField) +end diff --git a/lib/openfeature/sdk/evaluation_details.rb b/lib/openfeature/sdk/evaluation_details.rb new file mode 100644 index 00000000..3358d741 --- /dev/null +++ b/lib/openfeature/sdk/evaluation_details.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +# typed: strict + +require "sorbet-runtime" +require_relative("./resolution_details") + +class EvaluationDetails < ResolutionDetails + const :flag_key, String +end diff --git a/lib/openfeature/sdk/evaluation_options.rb b/lib/openfeature/sdk/evaluation_options.rb new file mode 100644 index 00000000..e26fc59f --- /dev/null +++ b/lib/openfeature/sdk/evaluation_options.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +# typed: strict + +require "sorbet-runtime" +require_relative("./hook/hook") + +class EvaluationOptions < T::Struct + const :hooks, T::Array[Hook], default: [] + const :hook_hints, T.nilable(T::Hash[String, T.untyped]) +end diff --git a/lib/openfeature/sdk/feature_flag_error_code.rb b/lib/openfeature/sdk/feature_flag_error_code.rb new file mode 100644 index 00000000..79ab8c1a --- /dev/null +++ b/lib/openfeature/sdk/feature_flag_error_code.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" + +class FeatureFlagErrorCode < T::Enum + enums do + PROVIDER_NOT_READY = new("PROVIDER_NOT_READY") + FLAG_NOT_FOUND = new("FLAG_NOT_FOUND") + PARSE_ERROR = new("PARSE_ERROR") + TYPE_MISMATCH = new("TYPE_MISMATCH") + TARGETING_KEY_MISSING = new("TARGETING_KEY_MISSING") + INVALID_CONTEXT = new("INVALID_CONTEXT") + GENERAL = new("GENERAL") + end +end diff --git a/lib/openfeature/sdk/feature_flag_evaluation_details.rb b/lib/openfeature/sdk/feature_flag_evaluation_details.rb new file mode 100644 index 00000000..e8002f18 --- /dev/null +++ b/lib/openfeature/sdk/feature_flag_evaluation_details.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +# typed: strict + +require "sorbet-runtime" +require_relative("./feature_flag_error_code") + +class FeatureFlagEvaluationDetails < T::Struct + const :reason, T.nilable(String) + const :variant, T.nilable(String) + const :error_code, T.nilable(FeatureFlagErrorCode) + const :error_message, T.nilable(String) +end diff --git a/lib/openfeature/sdk/flag_evaluation_options.rb b/lib/openfeature/sdk/flag_evaluation_options.rb new file mode 100644 index 00000000..ddb72f6e --- /dev/null +++ b/lib/openfeature/sdk/flag_evaluation_options.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" +require_relative("./hook") + +class FlagEvaluationOptions < T::Struct + const :hooks, T.nilable(T::Array[Hook]) + const :hook_hints, T.nilable(T::Hash[String, T.untyped]) +end diff --git a/lib/openfeature/sdk/hook/hook.rb b/lib/openfeature/sdk/hook/hook.rb new file mode 100644 index 00000000..48fd790a --- /dev/null +++ b/lib/openfeature/sdk/hook/hook.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" + +require_relative("./hook_context") + +module Hook + extend T::Sig + extend T::Helpers + interface! + + sig do + abstract.params( + hook_context: HookContext, + hook_hints: T.nilable(T::Hash[Symbol, T.untyped]) + ).returns(EvaluationContext) + end + def before(hook_context:, hook_hints: nil); end + + sig do + abstract.params( + hook_context: HookContext, + hook_hints: T.nilable(T::Hash[Symbol, T.untyped]) + ).returns(EvaluationContext) + end + def after(hook_context:, hook_hints: nil); end + + sig do + abstract.params( + hook_context: HookContext, + hook_hints: T.nilable(T::Hash[Symbol, T.untyped]) + ).returns(EvaluationContext) + end + def error(hook_context:, hook_hints: nil); end + + sig do + abstract.params( + hook_context: HookContext, + hook_hints: T.nilable(T::Hash[Symbol, T.untyped]) + ).returns(EvaluationContext) + end + def finally(hook_context:, hook_hints: nil); end +end diff --git a/lib/openfeature/sdk/hook/hook_context.rb b/lib/openfeature/sdk/hook/hook_context.rb new file mode 100644 index 00000000..858231b1 --- /dev/null +++ b/lib/openfeature/sdk/hook/hook_context.rb @@ -0,0 +1,21 @@ +# typed: true +# frozen_string_literal: true + +require_relative("../metadata") +require_relative("../evaluation_context") + +module OpenFeature + module SDK + module Hook + class HookContext < T::Struct + const :flag_key, String + const :default_value, T.any(T::Boolean, String, Integer, Integer, Float) + const :flag_value_type, T.any(String, Integer, Float, TrueClass, FalseClass) + const :context, T.nilable(EvaluationContext) + const :client_metadata, SDK::Metadata + const :provider_metadata, SDK::Metadata + const :logger, T.nilable(T.untyped) + end + end + end +end diff --git a/lib/openfeature/sdk/metadata.rb b/lib/openfeature/sdk/metadata.rb new file mode 100644 index 00000000..01a5b81b --- /dev/null +++ b/lib/openfeature/sdk/metadata.rb @@ -0,0 +1,43 @@ +# typed: true +# frozen_string_literal: true + +require "sorbet-runtime" + +module OpenFeature + module SDK + # Metadata structure that defines general metadata relating to a Provider or Client + # + # Within the Metadata structure you have access to the following attribute reader: + # + # * name - Allows you to specify name of the Metadata structure + # + # * version - Allows you to specify version of the Metadata structure + # + # Usage: + # + # metadata = Metadata.new(name: 'name-for-metadata') + # metadata.name # 'name-for-metadata' + # metadata_two = Metadata.new(name: 'name-for-metadata') + # metadata_two == metadata # true - equality based on values + class Metadata + extend T::Sig + + sig { returns(String) } + attr_reader :name + + sig { returns(T.nilable(String)) } + attr_reader :version + + sig { params(name: String, version: T.nilable(String)).void } + def initialize(name:, version: nil) + @name = T.let(name.dup, String) + @version = T.let(version.dup, T.nilable(String)) + end + + sig { params(other: Metadata).returns(T::Boolean) } + def ==(other) + @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..7f4808b2 --- /dev/null +++ b/lib/openfeature/sdk/provider/no_op_provider.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +# typed: true + +# frozen_literal: true + +require "sorbet-runtime" +require "json" + +require_relative("./provider") +require_relative("../metadata") +require_relative("../evaluation_context") +require_relative("../resolution_details") + +# rubocop:disable Lint/UnusedMethodArgument +module OpenFeature + module SDK + module Provider + # TODO: Write documentation + # + class NoOpProvider + extend T::Sig + include Provider + + Number = T.type_alias { T.any(Integer, Float) } + + REASON_NO_OP = "No-op" + NAME = "No-op Provider" + + def initialize + @metadata = SDK::Metadata.new(name: NAME).freeze + end + + sig do + override.params( + flag_key: String, + default_value: T::Boolean, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + sig do + override.params( + flag_key: String, + default_value: String, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + sig do + override.params( + flag_key: String, + default_value: Number, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + sig do + override.params( + flag_key: String, + default_value: T.untyped, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + no_op(default_value) + end + + private + + sig { params(default_value: T.untyped, variant: T.nilable(String)).returns(ResolutionDetails) } + def no_op(default_value) + ResolutionDetails.new(value: default_value, reason: REASON_NO_OP) + end + end + end + end +end +# rubocop:enable Lint/UnusedMethodArgument diff --git a/lib/openfeature/sdk/provider/provider.rb b/lib/openfeature/sdk/provider/provider.rb new file mode 100644 index 00000000..22fb50d8 --- /dev/null +++ b/lib/openfeature/sdk/provider/provider.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +# typed: true + +# frozen_literal: true + +require "sorbet-runtime" +require_relative("../feature_flag_evaluation_details") +require_relative("../evaluation_context") +require_relative("../hook/hook") + +module OpenFeature + module SDK + # TODO: Write documentation + # + module Provider + extend T::Sig + extend T::Helpers + interface! + + Number = T.type_alias { T.any(Integer, Float) } + + sig { returns(Metadata) } + attr_reader :metadata + + sig { returns(T.nilable(T::Array[Hook])) } + attr_accessor :hooks + + sig do + abstract.params( + flag_key: String, + default_value: T::Boolean, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil); end + + sig do + abstract.params( + flag_key: String, + default_value: T::Boolean, + evaluation_details: FeatureFlagEvaluationDetails + ).returns(ResolutionDetails) + end + def fetch_boolean_details(flag_key:, default_value:, evaluation_details:); end + + sig do + abstract.params( + flag_key: String, + default_value: String, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil); end + + sig do + abstract.params( + flag_key: String, + default_value: String, + evaluation_details: FeatureFlagEvaluationDetails + ).returns(ResolutionDetails) + end + def fetch_string_details(flag_key:, default_value:, evaluation_details:); end + + sig do + abstract.params( + flag_key: String, + default_value: Number, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil); end + + sig do + abstract.params( + flag_key: String, + default_value: Number, + evaluation_details: FeatureFlagEvaluationDetails + ).returns(ResolutionDetails) + end + def fetch_number_details(flag_key:, default_value:, evaluation_details:); end + + sig do + abstract.params( + flag_key: String, + default_value: T.untyped, + evaluation_context: T.nilable(EvaluationContext) + ).returns(ResolutionDetails) + end + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil); end + + sig do + abstract.params( + flag_key: String, + default_value: T.untyped, + evaluation_details: FeatureFlagEvaluationDetails + ).returns(Object) + end + def fetch_object_details(flag_key:, default_value:, evaluation_details:); end + end + end +end diff --git a/lib/openfeature/sdk/resolution_details.rb b/lib/openfeature/sdk/resolution_details.rb new file mode 100644 index 00000000..49b1f1a2 --- /dev/null +++ b/lib/openfeature/sdk/resolution_details.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# typed: strict + +require "sorbet-runtime" +require_relative("./feature_flag_error_code") +require_relative("./resolution_reason") + +class ResolutionDetails < T::Struct + const :value, T.any(String, T::Boolean, Integer, Float, T::Hash[String, T.untyped], T::Array[T.untyped]) + const :reason, T.nilable(T.any(ResolutionReason, String)) + const :variant, T.nilable(String) + const :error_code, T.nilable(FeatureFlagErrorCode) + const :error_message, T.nilable(String) +end diff --git a/lib/openfeature/sdk/resolution_reason.rb b/lib/openfeature/sdk/resolution_reason.rb new file mode 100644 index 00000000..52a41a8a --- /dev/null +++ b/lib/openfeature/sdk/resolution_reason.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# typed: strict + +require "sorbet-runtime" +require_relative("./resolution_details") + +class ResolutionReason < T::Enum + enums do + DEFAULT = new("DEFAULT") + TARGETING_MATCH = new("TARGETING_MATCH") + SPLIT = new("SPLIT") + DISABLED = new("DISABLED") + UNKNOWN = new("UNKNOWN") + ERROR = new("ERROR") + end +end diff --git a/lib/openfeature/sdk/resolver/boolean_resolver.rb b/lib/openfeature/sdk/resolver/boolean_resolver.rb new file mode 100644 index 00000000..5b64da5c --- /dev/null +++ b/lib/openfeature/sdk/resolver/boolean_resolver.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" + +require_relative "../provider/provider" +require_relative "../evaluation_context" +require_relative "../metadata" +require_relative "../hook/hook" +require_relative "../hook/hook_context" +require_relative "../evaluation_options" + +module OpenFeature + module Resolver + # TODO: Write documentation + # + class BooleanResolver + extend T::Sig + + sig do + params( + provider: SDK::Provider + ).void + end + def initialize(provider) + @provider = provider + end + + sig do + params( + flag_key: String, + default_value: T::Boolean, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(T::Boolean) + end + def fetch_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + resolution_details = @provider.fetch_boolean_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, evaluation_options: evaluation_options) + correct_type?(resolution_details.value) ? resolution_details.value : default_value + end + + sig do + params( + flag_key: String, + default_value: T::Boolean, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(ResolutionDetails) + end + def fetch_detailed_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + @provider.fetch_boolean_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, evaluation_options: evaluation_options) + end + + private + + sig { params(value: T.untyped).returns(T::Boolean) } + def correct_type?(value) + [TrueClass, FalseClass].include?(value.class) + end + end + end +end diff --git a/lib/openfeature/sdk/resolver/number_resolver.rb b/lib/openfeature/sdk/resolver/number_resolver.rb new file mode 100644 index 00000000..c75adfbe --- /dev/null +++ b/lib/openfeature/sdk/resolver/number_resolver.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" +require_relative "../provider/provider" +require_relative("../evaluation_context") +require_relative("../metadata") +require_relative("../hook/hook") +require_relative("../hook/hook_context") +require_relative("../evaluation_options") + +module OpenFeature + module Resolver + # TODO: Write documentation + # + class NumberResolver + extend T::Sig + + Number = T.type_alias { T.any(Integer, Float) } + + sig do + params( + provider: SDK::Provider + ).void + end + def initialize(provider) + @provider = provider + end + + sig do + params( + flag_key: String, + default_value: Number, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(Number) + end + def fetch_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + resolution_details = @provider.fetch_number_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, evaluation_options: evaluation_options) + correct_type?(resolution_details.value) ? resolution_details.value : default_value + end + + sig do + params( + flag_key: String, + default_value: Number, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(ResolutionDetails) + end + def fetch_detailed_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + @provider.fetch_number_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, evaluation_options: evaluation_options) + end + + private + + def correct_type?(value) + [Float, Integer].include?(value.class) + end + end + end +end diff --git a/lib/openfeature/sdk/resolver/object_resolver.rb b/lib/openfeature/sdk/resolver/object_resolver.rb new file mode 100644 index 00000000..286da26d --- /dev/null +++ b/lib/openfeature/sdk/resolver/object_resolver.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" +require_relative "../provider/provider" +require_relative("../evaluation_context") +require_relative("../metadata") +require_relative("../hook/hook") +require_relative("../hook/hook_context") +require_relative("../evaluation_options") + +module OpenFeature + module Resolver + # TODO: Write documentation + # + class ObjectResolver + extend T::Sig + + sig do + params( + provider: SDK::Provider + ).void + end + def initialize(provider) + @provider = provider + end + + sig do + params( + flag_key: String, + default_value: T.untyped, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(String) + end + def fetch_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + resolution_details = @provider.fetch_object_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, evaluation_options: evaluation_options) + correct_type?(resolution_details.value) ? resolution_details.value : default_value + end + + sig do + params( + flag_key: String, + default_value: T.untyped, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(ResolutionDetails) + end + def fetch_detailed_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + @provider.fetch_object_value(flag_key: flag_key, default_value: default_value, evaluation_context: evaluation_context, evaluation_options: evaluation_options) + end + + private + + def correct_type?(value) + result = JSON.parse(value) + result.is_a?(Hash) || result.is_a?(Array) + rescue JSON::ParserError + false + end + end + end +end diff --git a/lib/openfeature/sdk/resolver/string_resolver.rb b/lib/openfeature/sdk/resolver/string_resolver.rb new file mode 100644 index 00000000..a71a89b2 --- /dev/null +++ b/lib/openfeature/sdk/resolver/string_resolver.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +# typed: true + +require "sorbet-runtime" +require_relative "../provider/provider" +require_relative("../evaluation_context") +require_relative("../metadata") +require_relative("../hook/hook") +require_relative("../hook/hook_context") +require_relative("../evaluation_options") + +module OpenFeature + module Resolver + # TODO: Write documentation + # + class StringResolver + extend T::Sig + + sig do + params( + provider: SDK::Provider + ).void + end + def initialize(provider) + @provider = provider + end + + sig do + params( + flag_key: String, + default_value: String, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(String) + end + def fetch_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + resolution_details = @provider.fetch_string_value(flag_key: flag_key, default_value: default_value, + evaluation_context: evaluation_context, + evaluation_options: evaluation_options) + is_correct_type?(resolution_details.value) ? resolution_details.value : default_value + end + + sig do + params( + flag_key: String, + default_value: String, + evaluation_context: T.nilable(EvaluationContext), + evaluation_options: T.nilable(EvaluationOptions) + ).returns(ResolutionDetails) + end + def fetch_detailed_value(flag_key:, default_value:, evaluation_context: nil, evaluation_options: nil) + @provider.fetch_string_value(flag_key: flag_key, default_value: default_value, + evaluation_context: evaluation_context, + evaluation_options: evaluation_options) + end + + private + + def correct_type?(value) + value.is_a?(String) + end + end + end +end diff --git a/openfeature-sdk.gemspec b/openfeature-sdk.gemspec index 98942e83..873b9986 100644 --- a/openfeature-sdk.gemspec +++ b/openfeature-sdk.gemspec @@ -31,8 +31,11 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency "sorbet-runtime", "~> 0.5.10539" + spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rspec", "~> 3.12.0" spec.add_development_dependency "rubocop", "~> 1.37.1" + spec.add_development_dependency "sorbet", "~> 0.5.10539" spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/spec/openfeature/client_spec.rb b/spec/openfeature/client_spec.rb new file mode 100644 index 00000000..a553f23e --- /dev/null +++ b/spec/openfeature/client_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "./src/open_feature" +require "./src/no_op_provider" +require "./src/provider" +require "./src/hook/hook" +require "./src/metadata" +require "./src/client" + +require "spec_helper" + +describe OpenFeature::Client do + before do + subject + end + + context "Requirement 1.2.1" do + subject do + OpenFeature.configure do |config| + config.provider = NoOpProvider.new + config.hooks << api_hook1 + config.hooks << api_hook2 + end + + client = OpenFeature.build_client(name: "my-openfeature-client") + client.hooks << client_hook1 + client + end + + let(:api_hook1) do + Class.new do + include Hook + end + end + let(:api_hook2) do + Class.new do + include Hook + end + end + let(:client_hook1) do + Class.new do + include Hook + end + end + + it "MUST provide a method to add hooks which accepts one or more API-conformant hooks, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed." do + expect(subject).to respond_to(:hooks) + expect(subject.hooks).to have_attributes(size: 1).and eq([client_hook1]) + end + end + + context "Requirement 1.2.2" do + subject do + OpenFeature.configure do |config| + config.provider = NoOpProvider.new + end + + OpenFeature.build_client(name: "my-openfeature-client") + end + + it "MUST define a metadata member or accessor, containing an immutable name field or accessor of type string, which corresponds to the name value supplied during client creation." do + expect(subject).to respond_to(:metadata) + expect(subject.metadata).to respond_to(:name) + expect(subject.metadata.name).to eq("my-openfeature-client") + end + end + + context "Flag evaluation" do + context "Requirement 1.3.1" do + context "Provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns the flag value." do + subject(:client) do + OpenFeature.build_client(name: "client") + end + let(:flag_key) { "my-awesome-feature-flag-key" } + + context "boolean value" do + it do + expect(client).to respond_to(:fetch_boolean_value).with(4).arguments + end + + it do + expect(client.fetch_boolean_value(flag_key: flag_key, default_value: false)).is_a?(FalseClass) + end + + it do + expect(client.fetch_boolean_value(flag_key: flag_key, default_value: true)).is_a?(TrueClass) + end + end + + context "string value" do + it do + expect(client).to respond_to(:fetch_string_value).with(4).arguments + end + + it do + expect(client.fetch_string_value(flag_key: flag_key, default_value: "default_value")).is_a?(String) + end + end + + context "number value" do + it do + expect(client).to respond_to(:fetch_number_value).with(4).arguments + end + + context "Condition 1.3.2 - The implementation language differentiates between floating-point numbers and integers." do + it do + expect(client.fetch_number_value(flag_key: flag_key, default_value: 4)).is_a?(Integer) + end + + it do + expect(client.fetch_number_value(flag_key: flag_key, default_value: 95.5)).is_a?(Float) + end + end + end + + context "object value" do + it do + expect(client).to respond_to(:fetch_object_value).with(4).arguments + end + + it do + expect(client.fetch_object_value(flag_key: flag_key, + default_value: JSON.dump({ data: "some-data" }))).is_a?(String) + end + end + end + end + + context "Requirement 1.4.1" do + context "MUST provide methods for detailed flag value evaluation with parameters flag key (string, required), default value (boolean | number | string | structure, required), evaluation context (optional), and evaluation options (optional), which returns an evaluation details structure." do + subject(:client) do + OpenFeature.build_client(name: "client") + end + let(:flag_key) { "my-awesome-feature-flag-key" } + + context "boolean value" do + it do + expect(client).to respond_to(:fetch_boolean_details).with(4).arguments + end + + it do + expect(client.fetch_boolean_details(flag_key: flag_key, default_value: false)).is_a?(ResolutionDetails) + end + + context "Requirement 1.4.2" do + it "The evaluation details structure's value field MUST contain the evaluated flag value" do + expect(client.fetch_boolean_details(flag_key: flag_key, default_value: true).value).is_a?(TrueClass) + expect(client.fetch_boolean_details(flag_key: flag_key, default_value: false).value).is_a?(FalseClass) + end + end + + context "Requirement 1.4.4" do + it "The evaluation details structure's flag key field MUST contain the flag key argument passed to the detailed flag evaluation method." do + expect(client).to respond_to(:fetch_boolean_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + end + end + + context "number value" do + it do + expect(client).to respond_to(:fetch_number_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + + it do + expect(client.fetch_number_details(flag_key: flag_key, default_value: 1.2)).is_a?(ResolutionDetails) + expect(client.fetch_number_details(flag_key: flag_key, default_value: 1)).is_a?(ResolutionDetails) + end + + context "Requirement 1.4.2" do + it "The evaluation details structure's value field MUST contain the evaluated flag value" do + expect(client.fetch_number_details(flag_key: flag_key, default_value: 1.0).value).is_a?(Float) + expect(client.fetch_number_details(flag_key: flag_key, default_value: 1).value).is_a?(Integer) + end + end + + context "Requirement 1.4.4" do + it "The evaluation details structure's flag key field MUST contain the flag key argument passed to the detailed flag evaluation method." do + expect(client).to respond_to(:fetch_number_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + end + end + + context "string value" do + it do + expect(client).to respond_to(:fetch_string_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + + it do + expect(client.fetch_string_details(flag_key: flag_key, default_value: "some-string")).is_a?(ResolutionDetails) + end + + context "Requirement 1.4.2" do + it "The evaluation details structure's value field MUST contain the evaluated flag value" do + expect(client.fetch_string_details(flag_key: flag_key, default_value: "some-string").value).is_a?(String) + end + end + + context "Requirement 1.4.4" do + it "The evaluation details structure's flag key field MUST contain the flag key argument passed to the detailed flag evaluation method." do + expect(client).to respond_to(:fetch_string_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + end + end + + context "object value" do + it do + expect(client).to respond_to(:fetch_object_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + + it do + expect(client.fetch_object_details(flag_key: flag_key, + default_value: JSON.dump({ name: "some-name" }))).is_a?(ResolutionDetails) + end + + context "Requirement 1.4.2" do + it "The evaluation details structure's value field MUST contain the evaluated flag value" do + expect(client.fetch_object_details(flag_key: flag_key, + default_value: JSON.dump({ name: "some-name" })).value).is_a?(String) + end + end + + context "Requirement 1.4.4" do + it "The evaluation details structure's flag key field MUST contain the flag key argument passed to the detailed flag evaluation method." do + expect(client).to respond_to(:fetch_object_details).with(4).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context, :evaluation_options) + end + end + end + end + end + end +end diff --git a/spec/openfeature/provider/no_op_provider_spec.rb b/spec/openfeature/provider/no_op_provider_spec.rb new file mode 100644 index 00000000..ed2ff637 --- /dev/null +++ b/spec/openfeature/provider/no_op_provider_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative "../../../lib/openfeature/sdk/provider/no_op_provider" + + +describe OpenFeature::SDK::Provider::NoOpProvider do + subject(:provider) { described_class.new } + let(:flag_key) { 'some-feature-flag-key' } + + context 'Requirement 2.1.1' do + it 'MUST define a metadata member or accessor, containing a name field or accessor of type string, which identifies the provider implementation.' do + expect(provider).to respond_to(:metadata) + expect(provider.metadata).to respond_to(:name) + expect(provider.metadata.name).to eq(described_class::NAME) + end + end + + context 'Requirement 2.2.1' do + context 'MUST define methods to resolve flag values, with parameters flag key (string, required), default value (boolean | number | string | structure, required) and evaluation context (optional), which returns a flag resolution structure.' do + context 'boolean value' do + it do + expect(provider).to respond_to(:fetch_boolean_value).with(3).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context) + end + + it do + expect(provider.fetch_boolean_value(flag_key:, default_value: false)).is_a?(ResolutionDetails) + end + end + + context 'number value' do + it do + expect(provider).to respond_to(:fetch_number_value).with(3).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context) + end + + it do + expect(provider.fetch_number_value(flag_key:, default_value: 1.0)).is_a?(ResolutionDetails) + expect(provider.fetch_number_value(flag_key:, default_value: 1)).is_a?(ResolutionDetails) + end + end + + context 'string value' do + it do + expect(provider).to respond_to(:fetch_string_value).with(3).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context) + end + + it do + expect(provider.fetch_string_value(flag_key:, default_value: 'some-string-value')).is_a?(ResolutionDetails) + end + end + + context 'boolean value' do + it do + expect(provider).to respond_to(:fetch_object_value).with(3).arguments.and_keywords(:flag_key, + :default_value, :evaluation_context) + end + + it do + expect(provider.fetch_object_value(flag_key:, + default_value: JSON.dump({ example: 'some-cool-object-value' }))).is_a?(ResolutionDetails) + end + end + end + end + + context 'Requirement 2.2.3' do + context "SHOULD populate the flag resolution structure's variant field with a string identifier corresponding to the returned flag value" do + context 'boolean value' do + it do + expect(provider.fetch_boolean_value(flag_key:, default_value: false).value).is_a?(FalseClass) + expect(provider.fetch_boolean_value(flag_key:, default_value: false).value).is_a?(TrueClass) + end + end + + context 'number value' do + it do + expect(provider.fetch_number_value(flag_key:, default_value: 1.0).value).is_a?(Float) + expect(provider.fetch_number_value(flag_key:, default_value: 1).value).is_a?(Integer) + end + end + + context 'string value' do + it do + expect(provider.fetch_string_value(flag_key:, default_value: 'some-string-value').value).is_a?(String) + end + end + + context 'boolean value' do + it do + expect(provider.fetch_object_value(flag_key:, + default_value: JSON.dump({ example: 'some-cool-object-value' }))).is_a?(String) + end + end + end + end + + context 'Requirement 2.2.4' do + context "MUST populate the flag resolution structure's value field with the resolved flag value." do + context 'boolean value' do + it do + expect(provider.fetch_boolean_value(flag_key:, default_value: false).value).is_a?(FalseClass) + expect(provider.fetch_boolean_value(flag_key:, default_value: false).value).is_a?(TrueClass) + end + end + + context 'number value' do + it do + expect(provider.fetch_number_value(flag_key:, default_value: 1.0).value).is_a?(Float) + expect(provider.fetch_number_value(flag_key:, default_value: 1).value).is_a?(Integer) + end + end + + context 'string value' do + it do + expect(provider.fetch_string_value(flag_key:, default_value: 'some-string-value').value).is_a?(String) + end + end + + context 'boolean value' do + it do + expect(provider.fetch_object_value(flag_key:, + default_value: JSON.dump({ example: 'some-cool-object-value' }))).is_a?(String) + end + end + end + end +end diff --git a/spec/openfeature/sdk_spec.rb b/spec/openfeature/sdk_spec.rb index 96829020..5d3b4b43 100644 --- a/spec/openfeature/sdk_spec.rb +++ b/spec/openfeature/sdk_spec.rb @@ -1,7 +1,85 @@ # frozen_string_literal: true +require_relative "../spec_helper" + +require_relative "../../lib/openfeature/sdk/provider/no_op_provider" +require_relative "../../lib/openfeature/sdk/configuration" +require_relative "../../lib/openfeature/sdk" +require_relative "../../lib/openfeature/sdk/metadata" + RSpec.describe OpenFeature::SDK do - it "has a version number" do - expect(OpenFeature::SDK::VERSION).not_to be nil + before do + subject + end + + context "Requirement 1.1.2" do + subject do + OpenFeature::SDK.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + end + end + + it "must provide a function to set the global provider singleton, which accepts an API-conformant provider implementation" do + expect(OpenFeature::SDK).to respond_to(:provider) + expect(OpenFeature::SDK.provider).not_to be_nil + expect(OpenFeature::SDK.provider).is_a?(OpenFeature::SDK::Provider) + end + end + + context "Requirement 1.1.3" do + subject do + OpenFeature::SDK.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + config.hooks << hook_1 + config.hooks << hook_2 + end + end + + let(:hook1) do + Class.new do + include Hook + end + end + let(:hook2) do + Class.new do + include Hook + end + end + + it "must provide a function that adds hooks which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed." do + expect(OpenFeature::SDK).to respond_to(:hooks) + expect(OpenFeature::SDK.hooks).to have_attributes(size: 2).and eq([hook1, hook2]) + end + end + + context "Requirement 1.1.4" do + subject do + OpenFeature::SDK.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(OpenFeature::SDK.provider.metadata).not_to be_nil + expect(OpenFeature::SDK.provider).to respond_to(:metadata) + expect(OpenFeature::SDK.provider.metadata).is_a?(OpenFeature::SDK::Metadata) + + expect(OpenFeature::SDK.provider.metadata).to eq(OpenFeature::SDK::Metadata.new(name: OpenFeature::SDK::Provider::NoOpProvider::NAME)) + end + end + + context "Requirement 1.1.5" do + subject do + OpenFeature::SDK.configure do |config| + config.provider = OpenFeature::SDK::Provider::NoOpProvider.new + end + + OpenFeature::SDK.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(OpenFeature::SDK).to respond_to(:build_client).with(1).arguments + expect(subject).is_a?(OpenFeature::SDK::Client) + end end end