From 6ed0f1e045a361c82e50132a9414416879b6f5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=ABma=20Bolshakov?= Date: Mon, 5 Jan 2026 22:20:33 +0100 Subject: [PATCH] Enable strict type checking across Wiring layer Remove Steep ignores for core Wiring components, bringing them under strict type checking. Previously only Domain and Infrastructure layers were fully typed. Replaced `**settings` hash splats with explicit keyword parameters throughout the public API. Hash splats defeat static analysis since Steep can't infer which keys are valid. Required to type optional parameters that accept `nil` as a valid value. Without this, we can't distinguish "user passed nil" from "user passed nothing": ```ruby def light(name, threshold: T.undefined, window_size: T.undefined) ``` Internal representation for Settings that preserves the configured/unconfigured distinction through the configuration pipeline. Enables typed traversal from user input to resolved Config. Replaces ad-hoc `Hash[Symbol, untyped]` with a typed container that Steep can reason about. - `ConfigNormalizer` - logic moved into `ConfigurationPipeline` - Hash-based settings passing throughout factory classes --- Steepfile | 13 -- lib/stoplight.rb | 153 ++++++++++++---- lib/stoplight/admin/helpers.rb | 8 +- lib/stoplight/common.rb | 14 ++ lib/stoplight/common/none.rb | 29 +++ lib/stoplight/common/some.rb | 36 ++++ lib/stoplight/domain/config.rb | 9 +- lib/stoplight/domain/light_factory.rb | 45 ++++- lib/stoplight/domain/system.rb | 28 +++ lib/stoplight/types.rb | 16 ++ lib/stoplight/wiring/default.rb | 2 +- lib/stoplight/wiring/default_configuration.rb | 165 ++++++++++++----- .../wiring/default_factory_builder.rb | 18 +- lib/stoplight/wiring/light/default_config.rb | 7 +- lib/stoplight/wiring/light_builder.rb | 60 +++--- lib/stoplight/wiring/light_factory.rb | 80 ++++---- .../light_factory/compatibility_validator.rb | 12 +- .../wiring/light_factory/config_normalizer.rb | 72 -------- .../light_factory/configuration_pipeline.rb | 84 ++++++--- .../light_factory/traffic_control_dsl.rb | 4 +- .../light_factory/traffic_recovery_dsl.rb | 4 +- lib/stoplight/wiring/settings.rb | 173 ++++++++++++++++++ lib/stoplight/wiring/system.rb | 74 ++++---- lib/stoplight/wiring/system/light_builder.rb | 13 +- lib/stoplight/wiring/system/light_factory.rb | 77 ++++---- rbs_collection.yaml | 3 +- sig/stoplight.rbs | 89 ++++++++- sig/stoplight/common/option.rbs | 29 +++ sig/stoplight/domain.rbs | 9 +- sig/stoplight/domain/config.rbs | 60 ++++-- sig/stoplight/domain/data_store.rbs | 6 +- sig/stoplight/domain/failure.rbs | 2 +- sig/stoplight/domain/light_factory.rbs | 47 ++--- sig/stoplight/domain/system.rbs | 21 +++ .../infrastructure/fail_safe/data_store.rbs | 10 +- .../infrastructure/memory/data_store.rbs | 2 +- .../infrastructure/redis/data_store.rbs | 2 +- .../storage/compatibility_metrics.rbs | 4 +- .../storage/compatibility_recovery_lock.rbs | 4 +- .../compatibility_recovery_metrics.rbs | 4 +- .../storage/compatibility_state.rbs | 4 +- sig/stoplight/system/light_builder.rbs | 7 +- sig/stoplight/system/light_factory.rbs | 6 +- sig/stoplight/types.rbs | 9 + sig/stoplight/wiring.rbs | 2 + sig/stoplight/wiring/data_store/redis.rbs | 2 +- sig/stoplight/wiring/default.rbs | 18 +- .../wiring/default_configuration.rbs | 58 +++--- .../wiring/default_factory_builder.rbs | 10 + sig/stoplight/wiring/light_builder.rbs | 19 +- sig/stoplight/wiring/light_factory.rbs | 22 +-- .../light_factory/compatibility_validator.rbs | 7 +- .../light_factory/config_normalizer.rbs | 23 --- .../light_factory/configuration_pipeline.rbs | 22 ++- sig/stoplight/wiring/public_api.rbs | 22 +++ sig/stoplight/wiring/settings.rbs | 54 ++++++ sig/stoplight/wiring/system.rbs | 17 +- .../stoplight/wiring/system_spec.rb | 57 ++++-- spec/integration/wiring/light_factory_spec.rb | 61 +----- spec/unit/stoplight/domain/config_spec.rb | 5 + .../compatibility_validator_spec.rb | 15 +- .../light_factory/config_normalizer_spec.rb | 57 ------ 62 files changed, 1299 insertions(+), 686 deletions(-) create mode 100644 lib/stoplight/common.rb create mode 100644 lib/stoplight/common/none.rb create mode 100644 lib/stoplight/common/some.rb create mode 100644 lib/stoplight/domain/system.rb delete mode 100644 lib/stoplight/wiring/light_factory/config_normalizer.rb create mode 100644 lib/stoplight/wiring/settings.rb create mode 100644 sig/stoplight/common/option.rbs create mode 100644 sig/stoplight/domain/system.rbs create mode 100644 sig/stoplight/wiring/default_factory_builder.rbs delete mode 100644 sig/stoplight/wiring/light_factory/config_normalizer.rbs create mode 100644 sig/stoplight/wiring/public_api.rbs create mode 100644 sig/stoplight/wiring/settings.rbs delete mode 100644 spec/unit/stoplight/wiring/light_factory/config_normalizer_spec.rb diff --git a/Steepfile b/Steepfile index 3bbabc70..96655cea 100644 --- a/Steepfile +++ b/Steepfile @@ -8,19 +8,6 @@ target :lib do ignore "lib/stoplight/rspec" ignore "lib/stoplight/rspec.rb" - ignore "lib/stoplight/wiring/data_store" - ignore "lib/stoplight/wiring/default.rb" - ignore "lib/stoplight/wiring/default_factory_builder.rb" - ignore "lib/stoplight/wiring/light" - ignore "lib/stoplight/wiring/light_builder.rb" - ignore "lib/stoplight/wiring/light_factory" - ignore "lib/stoplight/wiring/light_factory.rb" - ignore "lib/stoplight/wiring/public_api.rb" - ignore "lib/stoplight/wiring/system/light_factory.rb" - ignore "lib/stoplight/wiring/system.rb" - - ignore "lib/stoplight.rb" - # library "pathname" # Standard libraries # library "strong_json" # Gems diff --git a/lib/stoplight.rb b/lib/stoplight.rb index d38fc4aa..6ddcec3b 100644 --- a/lib/stoplight.rb +++ b/lib/stoplight.rb @@ -3,6 +3,7 @@ require "concurrent/map" require "zeitwerk" +# steep:ignore:start loader = Zeitwerk::Loader.for_gem loader.inflector.inflect("io" => "IO") loader.do_not_eager_load( @@ -13,6 +14,7 @@ loader.ignore("#{__dir__}/generators") loader.ignore("#{__dir__}/stoplight/rspec.rb", "#{__dir__}/stoplight/rspec") loader.setup +# steep:ignore:end module Stoplight # rubocop:disable Style/Documentation include Wiring::PublicApi @@ -66,24 +68,69 @@ def configure(trust_me_im_an_engineer: false) end end - # Creates a Light for internal use. - # - # @param name [String] - # @param settings [Hash] - # @return [Stoplight::Light] # @api private - def system_light(name, **settings) - Wiring::LightFactory.new.with(name: "__stoplight__#{name}", **settings).build + def system_light( + name, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) + Wiring::LightFactory.new(settings: Wiring::Settings.empty).build_with( + name: "__stoplight__#{name}", + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ) end # Create a Light with the user default configuration. # - # @param name [String] - # @param settings [Hash] # @return [Stoplight::Light] # @api private - def light(name, **settings) - __stoplight__default_light_factory.build_with(name:, **settings) + def light( + name, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) + __stoplight__default_light_factory.build_with( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ) end # Creates a new named system with the given configuration. @@ -110,30 +157,48 @@ def light(name, **settings) # Analytics = Stoplight.__stoplight__system(:analytics, data_store: analytics_redis) # # @api private - def __stoplight__system(name, **settings) + def __stoplight__system( + name, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) ensure_configured - @systems.compute(name.to_s) do |existing_system| + settings = default_configuration.to_settings.extend_with( + cool_off_time: cool_off_time, + threshold: threshold, + recovery_threshold: recovery_threshold, + window_size: window_size, + tracked_errors: tracked_errors, + skipped_errors: skipped_errors, + traffic_control: traffic_control, + traffic_recovery: traffic_recovery, + data_store: data_store, + error_notifier: error_notifier, + notifiers: notifiers + ) + + systems.compute(name.to_s) do |existing_system| if existing_system raise ArgumentError, "system `#{name}` is already in use" else - Stoplight::Wiring::System.new(name.to_s, **{ - cool_off_time: @default_configuration.cool_off_time, - threshold: @default_configuration.threshold, - recovery_threshold: @default_configuration.recovery_threshold, - window_size: @default_configuration.window_size, - tracked_errors: @default_configuration.tracked_errors, - skipped_errors: @default_configuration.skipped_errors, - data_store: @default_configuration.data_store, - error_notifier: @default_configuration.error_notifier, - notifiers: @default_configuration.notifiers, - traffic_control: @default_configuration.traffic_control, - traffic_recovery: @default_configuration.traffic_recovery - }.compact.merge(settings)) + Stoplight::Wiring::System.new(name.to_s, settings:) end end end + private attr_reader :systems + private def default_configuration = T.must(@default_configuration) + # Resets Stoplight to an unconfigured state. # # Clears all registered systems, default configuration, and the default light factory. @@ -170,12 +235,12 @@ def __stoplight__reset! # @api private def __stoplight__default_light_factory ensure_configured - @default_light_factory + T.must(@default_light_factory) end def __stoplight__default_configuration ensure_configured - @default_configuration + T.must(@default_configuration) end # @api private @@ -243,8 +308,21 @@ def __stoplight__default_configuration # # When 66.6% error rate reached withing a sliding 5 minute window, the circuit breaker will trip. # light = Stoplight("Payment API", traffic_control: :error_rate, threshold: 0.666, window_size: 300) # -def Stoplight(name, **settings) # rubocop:disable Naming/MethodName - Stoplight::Common::Deprecations.deprecate(<<~MSG) if settings.include?(:error_notifier) +def Stoplight( + name, + cool_off_time: Stoplight::T.undefined, + threshold: Stoplight::T.undefined, + recovery_threshold: Stoplight::T.undefined, + window_size: Stoplight::T.undefined, + tracked_errors: Stoplight::T.undefined, + skipped_errors: Stoplight::T.undefined, + data_store: Stoplight::T.undefined, + error_notifier: Stoplight::T.undefined, + notifiers: Stoplight::T.undefined, + traffic_control: Stoplight::T.undefined, + traffic_recovery: Stoplight::T.undefined +) # rubocop:disable Naming/MethodName + Stoplight::Common::Deprecations.deprecate(<<~MSG) if error_notifier != Stoplight::T.undefined Passing "error_notifier" to Stoplight('#{name}') is deprecated and will be removed in v6.0.0. IMPORTANT: The `error_notifier` is NOT called for exceptions in your protected code. @@ -258,5 +336,18 @@ def Stoplight(name, **settings) # rubocop:disable Naming/MethodName See: https://github.com/bolshakov/stoplight#error-notifiers MSG - Stoplight.light(name, **settings) + Stoplight.light( + name, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ) end diff --git a/lib/stoplight/admin/helpers.rb b/lib/stoplight/admin/helpers.rb index f9fa7112..187f7589 100644 --- a/lib/stoplight/admin/helpers.rb +++ b/lib/stoplight/admin/helpers.rb @@ -20,10 +20,10 @@ def dependencies "Please configure a different data store in your Stoplight configuration." else Stoplight::Wiring::LightBuilder.new( - { - data_store: settings.data_store, - config: Wiring::Light::DefaultConfig - } + config: Wiring::Light::DefaultConfig.with( + data_store: settings.data_store + ), + factory: nil ).__send__(:data_store) end end diff --git a/lib/stoplight/common.rb b/lib/stoplight/common.rb new file mode 100644 index 00000000..4fbe0b43 --- /dev/null +++ b/lib/stoplight/common.rb @@ -0,0 +1,14 @@ +module Stoplight + # Shared utilities and data structures. + module Common + # Wraps a value in a Some, indicating presence. + def self.some(value) + Some.new(value) # steep:ignore + end + + # Returns a None instance, indicating absence.W + def self.none + None.new + end + end +end diff --git a/lib/stoplight/common/none.rb b/lib/stoplight/common/none.rb new file mode 100644 index 00000000..7625f6ce --- /dev/null +++ b/lib/stoplight/common/none.rb @@ -0,0 +1,29 @@ +module Stoplight + module Common + # Represents the absence of a value in an Option type. + # + # When a setting is None, it means the user hasn't configured it, + # allowing downstream code to apply library defaults. + # + # @see Some + class None + def get_or_else + yield + end + + def map + self + end + + def ==(other) + other.is_a?(None) + end + + def to_s + "None" + end + + def empty? = true + end + end +end diff --git a/lib/stoplight/common/some.rb b/lib/stoplight/common/some.rb new file mode 100644 index 00000000..f05ce3b2 --- /dev/null +++ b/lib/stoplight/common/some.rb @@ -0,0 +1,36 @@ +module Stoplight + module Common + # Represents the presence of a value in an Option type. + # + # Used to distinguish between "explicitly configured" (Some) and + # "not configured" (None) in the Settings DSL, enabling three-state + # logic: configured, not-configured, and explicitly-nil. + # + # @see None + class Some + protected attr_reader :value + + def initialize(value) + @value = value + end + + def get_or_else + @value + end + + def map + Some.new(yield value) + end + + def ==(other) + other.is_a?(Some) && value == other.value # steep:ignore + end + + def to_s + "Some(#{@value})" + end + + def empty? = false + end + end +end diff --git a/lib/stoplight/domain/config.rb b/lib/stoplight/domain/config.rb index 5bb22505..6bdd1856 100644 --- a/lib/stoplight/domain/config.rb +++ b/lib/stoplight/domain/config.rb @@ -12,7 +12,12 @@ module Domain :recovery_threshold, :window_size, :tracked_errors, - :skipped_errors + :skipped_errors, + :traffic_control, + :traffic_recovery, + :error_notifier, + :notifiers, + :data_store ) class Config # # @!attribute [r] name @@ -45,7 +50,7 @@ def track_error?(error) end def cool_off_time_in_milliseconds - cool_off_time * 1_000 + (cool_off_time * 1_000).to_i end end end diff --git a/lib/stoplight/domain/light_factory.rb b/lib/stoplight/domain/light_factory.rb index c17a5651..44f2b80a 100644 --- a/lib/stoplight/domain/light_factory.rb +++ b/lib/stoplight/domain/light_factory.rb @@ -33,7 +33,20 @@ class LightFactory # @raise [NotImplementedError] Must be implemented by subclass # @abstract # :nocov: - def with(**settings) + def with( + name: T.undefined, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) raise NotImplementedError end @@ -68,8 +81,34 @@ def build # # # You can do: # light = factory.build_with(threshold: 10) - def build_with(**settings) - with(**settings).build + def build_with( + name: T.undefined, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) + with( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ).build end end # steep:ignore:end diff --git a/lib/stoplight/domain/system.rb b/lib/stoplight/domain/system.rb new file mode 100644 index 00000000..40518cee --- /dev/null +++ b/lib/stoplight/domain/system.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Stoplight + module Domain + # Abstract base class defining the System contract. + # + # A System is a composition root that groups related circuit breakers + # with shared configuration. Concrete implementations live in the + # Wiring layer. + # + # @abstract + class System + def name = raise NotImplementedError + + def light( + name, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + skipped_errors: T.undefined, + tracked_errors: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) = raise NotImplementedError + end + end +end diff --git a/lib/stoplight/types.rb b/lib/stoplight/types.rb index 4cfae7c8..962a1720 100644 --- a/lib/stoplight/types.rb +++ b/lib/stoplight/types.rb @@ -1,7 +1,23 @@ # frozen_string_literal: true +require "singleton" + module Stoplight module Types + # Singleton representing an undefined/not-provided argument. + # + # Distinct from nil, which may be a valid configured value. + # Used with keyword arguments to detect when a parameter + # wasn't passed vs. explicitly set to nil. + class Undefined + include Singleton + + def inspect = "UNDEFINED" + alias_method :to_s, :inspect + end + + def self.undefined = Undefined.instance + # Asserts a value is non-nil, returning it with a narrowed type. # # Use this to satisfy Steep's flow typing when you know a nilable value diff --git a/lib/stoplight/wiring/default.rb b/lib/stoplight/wiring/default.rb index f12913fe..aaad251e 100644 --- a/lib/stoplight/wiring/default.rb +++ b/lib/stoplight/wiring/default.rb @@ -19,7 +19,7 @@ module Default WINDOW_SIZE = nil TRACKED_ERRORS = [StandardError].freeze - SKIPPED_ERRORS = [].freeze + SKIPPED_ERRORS = [].freeze # steep:ignore TRAFFIC_CONTROL = Domain::TrafficControl::ConsecutiveErrors.new TRAFFIC_RECOVERY = Domain::TrafficRecovery::ConsecutiveSuccesses.new diff --git a/lib/stoplight/wiring/default_configuration.rb b/lib/stoplight/wiring/default_configuration.rb index 4b1ee3dd..042d0d69 100644 --- a/lib/stoplight/wiring/default_configuration.rb +++ b/lib/stoplight/wiring/default_configuration.rb @@ -2,67 +2,134 @@ module Stoplight module Wiring - # User-facing configuration interface + # User-facing configuration interface for setting global Stoplight defaults. + # + # This class serves as the configuration DSL yielded to users when calling + # +Stoplight.configure+. It provides a clean interface for setting default + # values while internally tracking whether each setting was explicitly + # configured or should fall back to library defaults. + # + # @example Configuring Stoplight defaults + # Stoplight.configure do |config| + # config.data_store = Redis.new + # config.threshold = 5 + # # window_size not set - will use library default + # end + # + # == Option-Based Configuration Tracking + # + # Internally, each setting is wrapped in an Option type (+Some+ or +None+). + # This design allows the class to distinguish between three states: + # + # 1. Not configured: Stored as +None+, getter returns library default + # 2. Explicitly configured: Stored as +Some(value)+, getter returns that value + # 3. Explicitly set to nil: Stored as +Some(nil)+, getter returns +nil+ + # + # This distinction is critical when building {Settings} and {Dependencies} + # objects, which need to know whether a value was user-specified (and should + # be enforced) or inherited (and can be overridden per-circuit). + # + # == Dual Interface + # + # The class exposes two interfaces for each setting: + # + # - *Setters* (+attr_writer+): Accept raw values, wrap them in +Some+ + # - *Getters* (custom methods): Unwrap the Option, returning the value + # or falling back to library defaults from {Default} + # + # The {#settings} and {#dependencies} methods preserve the raw Option + # values, allowing downstream code to detect explicit configuration. + # + # @see Settings Value object for circuit behavior configuration + # @see Dependencies Value object for infrastructure dependencies + # @see Default Library default constants + # class DefaultConfiguration - # @!attribute [w] cool_off_time - # @return [Integer, nil] The default cool-off time in seconds. - # @dynamic cool_off_time - attr_accessor :cool_off_time + def initialize + @cool_off_time = Common.none + @threshold = Common.none + @recovery_threshold = Common.none + @window_size = Common.none + @tracked_errors = Common.none + @skipped_errors = Common.none + @traffic_control = Common.none + @traffic_recovery = Common.none + @error_notifier = Common.none + @data_store = Common.none + @notifiers = Common.none + end + + def cool_off_time = @cool_off_time.get_or_else { Default::COOL_OFF_TIME } + def threshold = @threshold.get_or_else { Default::THRESHOLD } + def recovery_threshold = @recovery_threshold.get_or_else { Default::RECOVERY_THRESHOLD } + def window_size = @window_size.get_or_else { Default::WINDOW_SIZE } + def tracked_errors = @tracked_errors.get_or_else { Default::TRACKED_ERRORS } + def skipped_errors = @skipped_errors.get_or_else { Default::SKIPPED_ERRORS } + def traffic_control = @traffic_control.get_or_else { Default::TRAFFIC_CONTROL } + def traffic_recovery = @traffic_recovery.get_or_else { Default::TRAFFIC_RECOVERY } + def error_notifier = @error_notifier.get_or_else { Default::ERROR_NOTIFIER } + def data_store = @data_store.get_or_else { Default::DATA_STORE } + def notifiers = @notifiers.get_or_else { Default::NOTIFIERS } + + def cool_off_time=(value) + @cool_off_time = Common.some(value) + end - # @!attribute [w] threshold - # @return [Integer, Float, nil] The default failure threshold to trip the circuit breaker. - # @dynamic threshold - attr_accessor :threshold + def threshold=(value) + @threshold = Common.some(value) + end - # @!attribute [w] recovery_threshold - # @return [Integer, nil] The default recovery threshold for the circuit breaker. - # @dynamic recovery_threshold - attr_accessor :recovery_threshold + def recovery_threshold=(value) + @recovery_threshold = Common.some(value) + end - # @!attribute [w] window_size - # @return [Integer, nil] The default size of the rolling window for failure tracking. - # @dynamic window_size - attr_accessor :window_size + def window_size=(value) + @window_size = Common.some(value) + end - # @!attribute [w] tracked_errors - # @return [Array, nil] The default list of errors to track. - # @dynamic tracked_errors - attr_accessor :tracked_errors + def tracked_errors=(value) + @tracked_errors = Common.some(value) + end - # @!attribute [w] skipped_errors - # @return [Array, nil] The default list of errors to skip. - # @dynamic skipped_errors - attr_accessor :skipped_errors + def skipped_errors=(value) + @skipped_errors = Common.some(value) + end - # @!attribute [w] error_notifier - # @return [Proc, nil] The default error notifier (callable object). - # @dynamic error_notifier - attr_accessor :error_notifier + def traffic_control=(value) + @traffic_control = Common.some(value) + end - # @!attribute [rw] notifiers - # @return [Array] The default list of notifiers. - # @dynamic notifiers - attr_accessor :notifiers + def traffic_recovery=(value) + @traffic_recovery = Common.some(value) + end - # @!attribute [rw] data_store - # @return [Stoplight::Wiring::DataStore::Base] The default data store instance. - # @dynamic data_store - attr_accessor :data_store + def error_notifier=(value) + @error_notifier = Common.some(value) + end - # @!attribute [w] traffic_control - # @return [Stoplight::Domain::TrafficControl::Base] The traffic control strategy. - # @dynamic traffic_control - attr_accessor :traffic_control + def data_store=(value) + @data_store = Common.some(value) + end - # @!attribute [w] traffic_recovery - # @return [Stoplight::Domain::TrafficRecovery::Base] The traffic recovery strategy. - # @dynamic traffic_recovery - attr_accessor :traffic_recovery + def notifiers=(value) + @notifiers = Common.some(value) + end - def initialize - # This allows users appending notifiers to the default list, - # while still allowing them to override the default list. - @notifiers = Default::NOTIFIERS + def to_settings + Settings.new( + name: Common.none, + cool_off_time: @cool_off_time, + threshold: @threshold, + recovery_threshold: @recovery_threshold, + window_size: @window_size, + tracked_errors: @tracked_errors, + skipped_errors: @skipped_errors, + traffic_control: @traffic_control, + traffic_recovery: @traffic_recovery, + error_notifier: @error_notifier, + notifiers: @notifiers, + data_store: @data_store + ) end end end diff --git a/lib/stoplight/wiring/default_factory_builder.rb b/lib/stoplight/wiring/default_factory_builder.rb index 993b3d46..671a9b19 100644 --- a/lib/stoplight/wiring/default_factory_builder.rb +++ b/lib/stoplight/wiring/default_factory_builder.rb @@ -8,8 +8,6 @@ module Wiring class DefaultFactoryBuilder # @!attribute [r] configuration # @return [Stoplight::Wiring::DefaultConfiguration] - # - # @dynamic configuration attr_reader :configuration def initialize @@ -19,21 +17,7 @@ def initialize # @return [Stoplight::Wiring::LightFactory] # @api private the method is used internally by Stoplight def build - LightFactory.new( - { - cool_off_time: configuration.cool_off_time, - threshold: configuration.threshold, - recovery_threshold: configuration.recovery_threshold, - window_size: configuration.window_size, - tracked_errors: configuration.tracked_errors, - skipped_errors: configuration.skipped_errors, - data_store: configuration.data_store, - error_notifier: configuration.error_notifier, - notifiers: configuration.notifiers, - traffic_control: configuration.traffic_control, - traffic_recovery: configuration.traffic_recovery - }.compact - ) + LightFactory.new(settings: configuration.to_settings) end end end diff --git a/lib/stoplight/wiring/light/default_config.rb b/lib/stoplight/wiring/light/default_config.rb index 426654a2..9a35ddc1 100644 --- a/lib/stoplight/wiring/light/default_config.rb +++ b/lib/stoplight/wiring/light/default_config.rb @@ -12,7 +12,12 @@ module Light recovery_threshold: Default::RECOVERY_THRESHOLD, window_size: Default::WINDOW_SIZE, tracked_errors: Default::TRACKED_ERRORS, - skipped_errors: Default::SKIPPED_ERRORS + skipped_errors: Default::SKIPPED_ERRORS, + traffic_control: Default::TRAFFIC_CONTROL, + traffic_recovery: Default::TRAFFIC_RECOVERY, + error_notifier: Default::ERROR_NOTIFIER, + notifiers: Default::NOTIFIERS, + data_store: Default::DATA_STORE ) end end diff --git a/lib/stoplight/wiring/light_builder.rb b/lib/stoplight/wiring/light_builder.rb index 139ce95d..5951b4c8 100644 --- a/lib/stoplight/wiring/light_builder.rb +++ b/lib/stoplight/wiring/light_builder.rb @@ -34,50 +34,23 @@ class LightBuilder MEMORY_REGISTRY = Concurrent::Map.new private_constant :MEMORY_REGISTRY - # @!attribute data_store_config - # @return [Stoplight::DataStore::Bose] - # @dynamic data_store_config private attr_reader :data_store_config - - # @!attribute error_notifier - # @return [Proc] - # @dynamic error_notifier private attr_reader :error_notifier - - # @!attribute traffic_recovery - # @return [Stoplight::Domain::TrafficRecovery::Base] - # @dynamic traffic_recovery - private attr_reader :traffic_recovery - - # @!attribute traffic_control - # @return [Stoplight::Domain::TrafficControl::Base] - # @dynamic traffic_control - private attr_reader :traffic_control - - # @!attribute config - # @return [Stoplight::Domain::Config] - # @dynamic config private attr_reader :config - - # @!attribute factory - # @return [Stoplight::Domain::LightFactory] - # @dynamic factory private attr_reader :factory - - # @!attribute clock - # @return [Stoplight::Domain::Clock] - # @dynamic clock private attr_reader :clock + private attr_reader :traffic_control + private attr_reader :traffic_recovery - def initialize(settings) - @notifiers = settings[:notifiers] - @data_store_config = settings[:data_store] - @error_notifier = settings[:error_notifier] - @traffic_recovery = settings[:traffic_recovery] - @traffic_control = settings[:traffic_control] - @config = settings[:config] - @factory = settings[:factory] + def initialize(config:, factory:) @clock = Infrastructure::SystemClock.new + @config = config + @data_store_config = config.data_store + @error_notifier = config.error_notifier + @factory = factory + @notifiers = config.notifiers + @traffic_recovery = config.traffic_recovery + @traffic_control = config.traffic_control end def build @@ -102,13 +75,13 @@ def build private def redis_recovery_lock_store Infrastructure::Redis::DataStore::RecoveryLockStore.new( - redis: data_store_config.redis, + redis:, lock_timeout: config.cool_off_time_in_milliseconds, scripting: ) end - private def scripting = Infrastructure::Redis::DataStore::Scripting.new(redis: data_store_config.redis) + private def scripting = Infrastructure::Redis::DataStore::Scripting.new(redis:) private def memory_recovery_lock_store Infrastructure::Memory::DataStore::RecoveryLockStore.new @@ -195,6 +168,15 @@ def build end end + private def redis + case data_store_config + when DataStore::Redis + data_store_config.redis + else + raise TypeError, "Expected Stoplight::DataStore::Redis, got #{data_store_config.class}" + end + end + private def memory_registry = MEMORY_REGISTRY end end diff --git a/lib/stoplight/wiring/light_factory.rb b/lib/stoplight/wiring/light_factory.rb index 98ae1fff..5d17ea35 100644 --- a/lib/stoplight/wiring/light_factory.rb +++ b/lib/stoplight/wiring/light_factory.rb @@ -13,40 +13,48 @@ module Wiring # @see Stoplight::Domain::LightFactory # @see Stoplight() # @api private - + # class LightFactory < Domain::LightFactory - DEPENDENCY_KEYS = %i[data_store traffic_recovery traffic_control notifiers error_notifier].freeze - private_constant :DEPENDENCY_KEYS - - CONFIG_KEYS = Domain::Config.members.freeze - private_constant :CONFIG_KEYS - # @!attribute [r] settings # @return [Hash] - # @dynamic settings protected attr_reader :settings - def initialize(settings = {}) + def initialize(settings:) @settings = settings - - validate_settings! - end - - private def validate_settings! - recognized = CONFIG_KEYS + DEPENDENCY_KEYS - unknown = settings.keys - recognized - - return if unknown.empty? - - raise ArgumentError, "Unknown settings: #{unknown.join(", ")}", caller(2) end - # @param settings [Hash] Settings to override in the new factory - # @see Stoplight() # @return [Stoplight::Wiring::LightFactory] # @see Stoplight() - def with(**settings) - self.class.new(self.settings.merge(settings)) + def with( + name: T.undefined, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) + self.class.new( + settings: settings.extend_with( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ) + ) end # Builds a fully-configured Light instance. @@ -66,26 +74,12 @@ def with(**settings) # light.run { api_call } def build - config_settings = settings.slice(*CONFIG_KEYS) - dependency_settings = settings.slice(*DEPENDENCY_KEYS) - - config, dependencies = ConfigurationPipeline.process( - config_settings, - dependency_settings - ) - light_builder(config, dependencies).build + config = ConfigurationPipeline.process(settings:) + light_builder(config:).build end - # @return [Stoplight::Error::ConfigurationError] def validate_configuration! - config_settings = settings.slice(*CONFIG_KEYS) - dependency_settings = settings.slice(*DEPENDENCY_KEYS) - - ConfigurationPipeline.process( - config_settings, - dependency_settings - ) - nil + ConfigurationPipeline.process(settings: settings.extend_with(name: "validate")) end def ==(other) @@ -98,8 +92,8 @@ def hash [self.class, settings].hash end - private def light_builder(config, dependencies) - LightBuilder.new({factory: light_factory, config:, **dependencies}) + private def light_builder(config:) + LightBuilder.new(config:, factory: light_factory) end private def light_factory = self diff --git a/lib/stoplight/wiring/light_factory/compatibility_validator.rb b/lib/stoplight/wiring/light_factory/compatibility_validator.rb index 74fadd31..44d1c2ea 100644 --- a/lib/stoplight/wiring/light_factory/compatibility_validator.rb +++ b/lib/stoplight/wiring/light_factory/compatibility_validator.rb @@ -13,18 +13,14 @@ class LightFactory # # @raise [Stoplight::Error::ConfigurationError] if incompatible class CompatibilityValidator - # @dynamic dependencies - private attr_reader :dependencies - # @dynamic config private attr_reader :config class << self - def call(config, dependencies) = new(config, dependencies).call + def call(config:) = new(config:).call end - def initialize(config, dependencies) + def initialize(config:) @config = config - @dependencies = dependencies end def call @@ -33,7 +29,7 @@ def call end private def validate_traffic_control! - traffic_control = dependencies.fetch(:traffic_control) + traffic_control = config.traffic_control traffic_control.check_compatibility(config).then do |compatibility_result| if compatibility_result.incompatible? raise Domain::Error::ConfigurationError, @@ -44,7 +40,7 @@ def call end def validate_traffic_recovery! - traffic_recovery = dependencies.fetch(:traffic_recovery) + traffic_recovery = config.traffic_recovery traffic_recovery.check_compatibility(config).then do |compatibility_result| if compatibility_result.incompatible? raise Domain::Error::ConfigurationError, diff --git a/lib/stoplight/wiring/light_factory/config_normalizer.rb b/lib/stoplight/wiring/light_factory/config_normalizer.rb deleted file mode 100644 index 524840f0..00000000 --- a/lib/stoplight/wiring/light_factory/config_normalizer.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module Stoplight - module Wiring - class LightFactory - # Normalizes user-provided configuration values into canonical forms. - # - # Handles common user convenience patterns: - # - Single error class → Array of error classes - # - Numeric cool_off_time → Integer - # - # @example - # config = Config.empty.with( - # tracked_errors: StandardError, # Single class - # cool_off_time: 30.5 # Float - # ) - # - # normalized = ConfigNormalizer.call(config) - # normalized.tracked_errors #=> [StandardError] # Array - # normalized.cool_off_time #=> 30 # Integer - # - # @api private - class ConfigNormalizer - class << self - def call(config) = new(config).call - end - - # @!attribute config - # @return [Stoplight::Domain::Config] - # @dynamic config - private attr_reader :config - - # @param [Stoplight::Domain::Config] - def initialize(config) - @config = config - end - - # @return [Stoplight::Domain::Config] - def call - config - .then { |c| normalize_tracked_errors(c) } - .then { |c| normalize_skipped_errors(c) } - .then { |c| normalize_cool_off_time(c) } - end - - private def normalize_tracked_errors(config) - if config.tracked_errors.is_a?(Array) - config - else - config.with(tracked_errors: Array(config.tracked_errors)) - end - end - - private def normalize_skipped_errors(config) - if config.skipped_errors.is_a?(Array) - config - else - config.with(skipped_errors: Array(config.skipped_errors)) - end - end - - private def normalize_cool_off_time(config) - if config.cool_off_time.is_a?(Integer) - config - else - config.with(cool_off_time: config.cool_off_time.to_i) - end - end - end - end - end -end diff --git a/lib/stoplight/wiring/light_factory/configuration_pipeline.rb b/lib/stoplight/wiring/light_factory/configuration_pipeline.rb index 23b34dc2..7e1445fc 100644 --- a/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +++ b/lib/stoplight/wiring/light_factory/configuration_pipeline.rb @@ -14,48 +14,80 @@ class LightFactory # # @api private class ConfigurationPipeline - # @dynamic dependency_settings - private attr_reader :dependency_settings - # @dynamic config_settings - private attr_reader :config_settings + private attr_reader :settings - def self.process(config_settings, dependency_settings) - new(config_settings, dependency_settings).process + def self.process(settings:) + new(settings:).process end - def initialize(config_settings, dependency_settings) - @config_settings = config_settings - @dependency_settings = dependency_settings + def initialize(settings:) + @settings = settings end def process config = build_config - dependencies = build_dependencies - CompatibilityValidator.call(config, dependencies) + CompatibilityValidator.call(config:) - [config, dependencies] + config end def build_config - base_config - .with(**config_settings) - .then { |cfg| ConfigNormalizer.call(cfg) } + Domain::Config.new( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + skipped_errors:, + tracked_errors:, + traffic_control:, + traffic_recovery:, + error_notifier:, + notifiers:, + data_store: + ) end - def build_dependencies - traffic_recovery = dependency_settings.fetch(:traffic_recovery, Default::TRAFFIC_RECOVERY) - traffic_control = dependency_settings.fetch(:traffic_control, Default::TRAFFIC_CONTROL) - { - error_notifier: dependency_settings.fetch(:error_notifier, Default::ERROR_NOTIFIER), - notifiers: dependency_settings.fetch(:notifiers, Default::NOTIFIERS), - data_store: dependency_settings.fetch(:data_store, Default::DATA_STORE), - traffic_control: TrafficControlDsl.call(traffic_control), - traffic_recovery: TrafficRecoveryDsl.call(traffic_recovery) - } + private def name = settings.name.get_or_else { raise ArgumentError, "name is required" } + + private def cool_off_time = settings.cool_off_time.get_or_else { Default::COOL_OFF_TIME } + + private def threshold = settings.threshold.get_or_else { Default::THRESHOLD } + + private def recovery_threshold = settings.recovery_threshold.get_or_else { Default::RECOVERY_THRESHOLD } + + private def window_size = settings.window_size.get_or_else { Default::WINDOW_SIZE } + + private def skipped_errors + settings.skipped_errors + .map { Array(_1) } + .get_or_else { Default::SKIPPED_ERRORS } + end + + private def tracked_errors + settings.tracked_errors + .map { Array(_1) } + .get_or_else { Default::TRACKED_ERRORS } end - def base_config = Light::DefaultConfig + private def traffic_control + settings.traffic_control + .map { TrafficControlDsl.call(_1) } + .get_or_else { Default::TRAFFIC_CONTROL } + end + + private def traffic_recovery + settings.traffic_recovery + .map { TrafficRecoveryDsl.call(_1) } + .get_or_else { Default::TRAFFIC_RECOVERY } + end + + private def error_notifier = settings.error_notifier.get_or_else { Default::ERROR_NOTIFIER } + + private def notifiers = settings.notifiers.get_or_else { Default::NOTIFIERS } + + private def data_store = settings.data_store.get_or_else { Default::DATA_STORE } end end end diff --git a/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb b/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb index 7cdbce8c..f494aab0 100644 --- a/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +++ b/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb @@ -3,7 +3,7 @@ module Stoplight module Wiring class LightFactory - TrafficControlDsl = proc do |value| + TrafficControlDsl = ->(value) { case value in Domain::TrafficControl::Base value @@ -20,7 +20,7 @@ class LightFactory * :error_rate ERROR end - end + } end end end diff --git a/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb b/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb index 2293fde8..6a9ae82a 100644 --- a/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +++ b/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb @@ -3,7 +3,7 @@ module Stoplight module Wiring class LightFactory - TrafficRecoveryDsl = proc do |value| + TrafficRecoveryDsl = ->(value) { case value in Domain::TrafficRecovery::Base value @@ -15,7 +15,7 @@ class LightFactory * :consecutive_successes ERROR end - end + } end end end diff --git a/lib/stoplight/wiring/settings.rb b/lib/stoplight/wiring/settings.rb new file mode 100644 index 00000000..94f93699 --- /dev/null +++ b/lib/stoplight/wiring/settings.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Stoplight + module Wiring + # Intermediate representation of user-provided configuration. + # + # Unlike {Domain::Config} which holds resolved, validated values, + # Settings wraps each field in an Option type to track whether + # the user explicitly provided it. + # + # == Lifecycle + # + # User DSL-→ Settings (Option-wrapped)-→ Config (resolved values) + # + # == Example + # + # # User provides only threshold + # settings = Settings.empty.extend_with(name: "api", threshold: 5) + # settings.threshold #=> Some(5) + # settings.window_size #=> None + # + # # ConfigurationPipeline resolves None to defaults + # config.threshold #=> 5 + # config.window_size #=> nil (library default) + # + # @see Domain::Config Resolved configuration value object + # + class Settings + attr_reader :name + attr_reader :cool_off_time + attr_reader :threshold + attr_reader :recovery_threshold + attr_reader :window_size + attr_reader :skipped_errors + attr_reader :tracked_errors + attr_reader :traffic_control + attr_reader :traffic_recovery + attr_reader :error_notifier + attr_reader :notifiers + attr_reader :data_store + + class << self + def empty = EMPTY + end + + def initialize( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + skipped_errors:, + tracked_errors:, + traffic_control:, + traffic_recovery:, + error_notifier:, + notifiers:, + data_store: + ) + @name = name + @cool_off_time = cool_off_time + @threshold = threshold + @recovery_threshold = recovery_threshold + @window_size = window_size + @skipped_errors = skipped_errors + @tracked_errors = tracked_errors + @traffic_control = traffic_control + @traffic_recovery = traffic_recovery + @error_notifier = error_notifier + @notifiers = notifiers + @data_store = data_store + end + + EMPTY = Settings.new( + name: Common.none, + cool_off_time: Common.none, + threshold: Common.none, + recovery_threshold: Common.none, + window_size: Common.none, + skipped_errors: Common.none, + tracked_errors: Common.none, + traffic_control: Common.none, + traffic_recovery: Common.none, + error_notifier: Common.none, + notifiers: Common.none, + data_store: Common.none + ) + + def extend_with( + name: T.undefined, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + skipped_errors: T.undefined, + tracked_errors: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + data_store: T.undefined + ) + Settings.new( + name: name.is_a?(Types::Undefined) ? @name : Common.some(name), + cool_off_time: cool_off_time.is_a?(Types::Undefined) ? @cool_off_time : Common.some(cool_off_time), + threshold: threshold.is_a?(Types::Undefined) ? @threshold : Common.some(threshold), + recovery_threshold: recovery_threshold.is_a?(Types::Undefined) ? @recovery_threshold : Common.some(recovery_threshold), + window_size: window_size.is_a?(Types::Undefined) ? @window_size : Common.some(window_size), + skipped_errors: skipped_errors.is_a?(Types::Undefined) ? @skipped_errors : Common.some(skipped_errors), + tracked_errors: tracked_errors.is_a?(Types::Undefined) ? @tracked_errors : Common.some(tracked_errors), + traffic_control: traffic_control.is_a?(Types::Undefined) ? @traffic_control : Common.some(traffic_control), + traffic_recovery: traffic_recovery.is_a?(Types::Undefined) ? @traffic_recovery : Common.some(traffic_recovery), + error_notifier: error_notifier.is_a?(Types::Undefined) ? @error_notifier : Common.some(error_notifier), + notifiers: notifiers.is_a?(Types::Undefined) ? @notifiers : Common.some(notifiers), + data_store: data_store.is_a?(Types::Undefined) ? @data_store : Common.some(data_store) + ) + end + + def empty? + @name == Common.none && + @cool_off_time == Common.none && + @threshold == Common.none && + @recovery_threshold == Common.none && + @window_size == Common.none && + @skipped_errors == Common.none && + @tracked_errors == Common.none && + @traffic_control == Common.none && + @traffic_recovery == Common.none && + @error_notifier == Common.none && + @notifiers == Common.none && + @data_store == Common.none + end + + def ==(other) + other.is_a?(Settings) && + name == other.name && + cool_off_time == other.cool_off_time && + threshold == other.threshold && + recovery_threshold == other.recovery_threshold && + window_size == other.window_size && + skipped_errors == other.skipped_errors && + tracked_errors == other.tracked_errors && + traffic_control == other.traffic_control && + traffic_recovery == other.traffic_recovery && + error_notifier == other.error_notifier && + notifiers == other.notifiers && + data_store == other.data_store + end + + def to_s + effective_settings = to_h.reject { |_, v| v.empty? } + "#<#{self.class.name} #{effective_settings.map { |(k, v)| "#{k}=#{v}" }.join(", ")}>" + end + + def to_h + { + name: @name, + cool_off_time: @cool_off_time, + threshold: @threshold, + recovery_threshold: @recovery_threshold, + window_size: @window_size, + skipped_errors: @skipped_errors, + tracked_errors: @tracked_errors, + traffic_control: @traffic_control, + traffic_recovery: @traffic_recovery, + error_notifier: @error_notifier, + notifiers: @notifiers, + data_store: @data_store + } + end + end + end +end diff --git a/lib/stoplight/wiring/system.rb b/lib/stoplight/wiring/system.rb index 60e29d3b..887fffdc 100644 --- a/lib/stoplight/wiring/system.rb +++ b/lib/stoplight/wiring/system.rb @@ -44,31 +44,22 @@ module Wiring # the same name returns the cached instance. # # @api private - class System - # @dynamic name + class System < Domain::System attr_reader :name - # @dynamic light_factory - private attr_reader :light_factory - # @dynamic lights private attr_reader :lights + private attr_reader :settings # @api private - def initialize(name, **defaults) - @name = name.to_s - @light_factory = LightFactory.new( - { - system: self, - **defaults, - data_store: defaults.fetch(:data_store, Default::DATA_STORE), - traffic_recovery: defaults.fetch(:traffic_recovery, Default::TRAFFIC_RECOVERY), - traffic_control: defaults.fetch(:traffic_control, Default::TRAFFIC_CONTROL), - notifiers: defaults.fetch(:notifiers, Default::NOTIFIERS), - error_notifier: defaults.fetch(:error_notifier, Default::ERROR_NOTIFIER) - } - ) + def initialize(name, settings:) + @name = name + @settings = settings @lights = Concurrent::Map.new - light_factory.validate_configuration! + validate_configuration! + end + + private def validate_configuration! + LightFactory.new(system: self, settings:).validate_configuration! end # Creates or retrieves a light. @@ -105,33 +96,48 @@ def initialize(name, **defaults) # # @note Thread-safe: multiple threads can safely call this method concurrently # - def light(name, **settings) - light, _ = lights.compute(name) do |(existing_light, existing_normalized_settings)| - normalized_settings = normalize_settings(settings) - - if existing_light - if normalized_settings.empty? || normalized_settings == existing_normalized_settings - [existing_light, existing_normalized_settings] + def light( + name, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) + light_settings = settings.extend_with( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + traffic_control:, + traffic_recovery: + ) + light, _ = lights.compute(name) do |existing| + if existing + existing_light, existing_settings = existing + if light_settings.empty? || light_settings == existing_settings + [existing_light, existing_settings] else raise Stoplight::Error::ConfigurationError, <<~MSG Light name `#{name}` reused with different settings: - existing settings: #{existing_normalized_settings} - new settings: #{normalized_settings} + existing settings: #{existing_settings} + new settings: #{light_settings} You cannot use the same light name with different settings. MSG end else - [light_factory.build_with(name:, **settings), normalized_settings] + [LightFactory.new(system: self, settings: light_settings).build, light_settings] end end light end - - # @param settings [Hash] - private def normalize_settings(settings) - settings.sort.to_h #: Domain::config_overrides - end end end end diff --git a/lib/stoplight/wiring/system/light_builder.rb b/lib/stoplight/wiring/system/light_builder.rb index 926cdb7d..1a0c2f71 100644 --- a/lib/stoplight/wiring/system/light_builder.rb +++ b/lib/stoplight/wiring/system/light_builder.rb @@ -6,10 +6,10 @@ class System class LightBuilder < Wiring::LightBuilder private attr_reader :system - def initialize(system, settings) + def initialize(system:, config:, factory:) @system = system - super(settings) + super(config:, factory:) end def key_space = @key_space ||= Infrastructure::Redis::Storage::KeySpace.build( @@ -21,15 +21,6 @@ def key_space = @key_space ||= Infrastructure::Redis::Storage::KeySpace.build( private def recovery_lock_store = storage_set.recovery_lock_store private def recovery_metrics_store = storage_set.recovery_metrics_store private def metrics_store = storage_set.metrics_store - - private def redis - case data_store_config - when DataStore::Redis - data_store_config.redis - else - raise TypeError, "should be redis" - end - end private def storage_scripting = Infrastructure::Redis::Storage::Scripting.new(redis:) private def failover_system = @failover_system ||= Stoplight.__stoplight__system("failover-#{system.name}") diff --git a/lib/stoplight/wiring/system/light_factory.rb b/lib/stoplight/wiring/system/light_factory.rb index 5cd790a2..8f471c5f 100644 --- a/lib/stoplight/wiring/system/light_factory.rb +++ b/lib/stoplight/wiring/system/light_factory.rb @@ -4,52 +4,63 @@ module Stoplight module Wiring class System class LightFactory < Wiring::LightFactory - ALLOWED_LIGHT_SETTINGS = [ - :cool_off_time, - :name, - :recovery_threshold, - :skipped_errors, - :threshold, - :tracked_errors, - :traffic_control, - :traffic_recovery, - :window_size - ].freeze - private_constant :ALLOWED_LIGHT_SETTINGS - # @dynamic system attr_reader :system - def initialize(settings) - @system = settings.delete(:system) + def initialize(system:, settings:) + @system = system + @settings = settings - super + super(settings:) end - # @param settings [Hash] Settings to override in the new factory - # @see Stoplight() - # @return [Stoplight::Wiring::LightFactory] - # @see Stoplight() - def with(**settings) - self.class.new(self.settings.merge(system:, **settings)) - end - - def build_with(**settings) - unknown_settings = settings.keys - ALLOWED_LIGHT_SETTINGS - raise ArgumentError, "Unknown settings: #{unknown_settings}", caller(7) unless unknown_settings.empty? - - super + def with( + name: T.undefined, + cool_off_time: T.undefined, + threshold: T.undefined, + recovery_threshold: T.undefined, + window_size: T.undefined, + tracked_errors: T.undefined, + skipped_errors: T.undefined, + data_store: T.undefined, + error_notifier: T.undefined, + notifiers: T.undefined, + traffic_control: T.undefined, + traffic_recovery: T.undefined + ) + self.class.new( + system:, + settings: settings.extend_with( + name:, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ) + ) end class InternalLightFactory < Wiring::LightFactory - def with(**settings) = raise NotImplementedError, "You're not allowed to extend system lights" + def initialize + end + + def with(**settings) # steep:ignore + raise NotImplementedError, "You're not allowed to extend system lights" + end end - private def light_builder(config, dependencies) - System::LightBuilder.new(system, {factory: light_factory, config:, **dependencies}) + private def light_builder(config:) + System::LightBuilder.new(system:, factory: light_factory, config:) end - private def light_factory = InternalLightFactory.new(settings) + private def light_factory = InternalLightFactory.new end end end diff --git a/rbs_collection.yaml b/rbs_collection.yaml index 410b59f7..e7a14a48 100644 --- a/rbs_collection.yaml +++ b/rbs_collection.yaml @@ -14,8 +14,9 @@ sources: path: .gem_rbs_collection gems: - - name: redis - name: connection_pool + - name: redis + - name: zeitwerk # # If you want to avoid installing rbs files for gems, you can specify them here. # - name: GEM_NAME # ignore: true diff --git a/sig/stoplight.rbs b/sig/stoplight.rbs index 7fa4f8d6..5584a327 100644 --- a/sig/stoplight.rbs +++ b/sig/stoplight.rbs @@ -1,5 +1,11 @@ module Stoplight + include Wiring::PublicApi + T: singleton(Types) + CONFIG_MUTEX: Mutex + + type undefined = Types::Undefined + type timestamp = Float | Integer type redis = ::Redis | ConnectionPool[::Redis] @@ -8,6 +14,13 @@ module Stoplight type error_notifier = Infrastructure::error_notifier type traffic_control = Symbol | Hash[Symbol, Hash[Symbol, Integer]] type traffic_recovery = Symbol + type cool_off_time = (Float | Integer) + type threshold = (Float | Integer) + type recovery_threshold = Integer + type window_size = Integer? + type errors = Array[singleton(StandardError)] + type data_store = Domain::DataStoreConfig + type notifiers = Array[Domain::StateTransitionNotifier] module Color = Domain::Color module State = Domain::State @@ -25,10 +38,82 @@ module Stoplight class Logger = Infrastructure::Notifier::Logger end + self.@configured: bool? + self.@default_configuration: Wiring::DefaultConfiguration? + self.@systems: Concurrent::Map[String, Domain::System] + self.@default_light_factory: Wiring::LightFactory? + + def self.configure: (?trust_me_im_an_engineer: bool) ?{ (Wiring::DefaultConfiguration) -> void } -> void + def self.warn_if_reconfiguring: (bool) { () -> void } -> void + def self.configured?: -> bool + def self.__stoplight__reset!: -> void + + def self.light: ( + String name, + ?cool_off_time: cool_off_time | undefined, + ?threshold: threshold | undefined, + ?recovery_threshold: recovery_threshold | undefined, + ?window_size: window_size | undefined, + ?tracked_errors: errors | undefined, + ?skipped_errors: errors | undefined, + ?data_store: data_store | undefined, + ?error_notifier: error_notifier | undefined, + ?notifiers: notifiers | undefined, + ?traffic_control: traffic_control | undefined, + ?traffic_recovery: traffic_recovery | undefined, + ) -> Domain::Light + def self.system_light: ( String name, - ?notifiers: Array[Domain::StateTransitionNotifier] + ?cool_off_time: cool_off_time | undefined, + ?threshold: threshold | undefined, + ?recovery_threshold: recovery_threshold | undefined, + ?window_size: window_size | undefined, + ?tracked_errors: errors | undefined, + ?skipped_errors: errors | undefined, + ?data_store: data_store | undefined, + ?error_notifier: error_notifier | undefined, + ?notifiers: notifiers | undefined, + ?traffic_control: traffic_control | undefined, + ?traffic_recovery: traffic_recovery | undefined, ) -> Domain::Light - def self.__stoplight__system: (String name) -> Wiring::System + def self.systems: -> Concurrent::Map[String, Domain::System] + def self.default_configuration: -> Wiring::DefaultConfiguration + def self.ensure_configured: -> void + def self.__stoplight__system: ( + _ToS name, + ?cool_off_time: cool_off_time | undefined, + ?threshold: threshold | undefined, + ?recovery_threshold: recovery_threshold | undefined, + ?window_size: window_size | undefined, + ?tracked_errors: errors | undefined, + ?skipped_errors: errors | undefined, + ?data_store: data_store | undefined, + ?error_notifier: error_notifier | undefined, + ?notifiers: notifiers | undefined, + ?traffic_control: traffic_control | undefined, + ?traffic_recovery: traffic_recovery | undefined, + ) -> Domain::System + def self.__stoplight__default_configuration: -> Wiring::DefaultConfiguration + def self.__stoplight__default_light_factory: -> Wiring::LightFactory +end + +module Kernel + # For a method usable both as Kernel.Stoplight() and just Stoplight() + def self?.Stoplight: ( + String name, + ?cool_off_time: Stoplight::cool_off_time | Stoplight::undefined, + ?threshold: Stoplight::threshold | Stoplight::undefined, + ?recovery_threshold: Stoplight::recovery_threshold | Stoplight::undefined, + ?window_size: Stoplight::window_size | Stoplight::undefined, + ?tracked_errors: Stoplight::errors | Stoplight::undefined, + ?skipped_errors: Stoplight::errors | Stoplight::undefined, + ?data_store: Stoplight::data_store | Stoplight::undefined, + ?error_notifier: Stoplight::error_notifier | Stoplight::undefined, + ?notifiers: Stoplight::notifiers | Stoplight::undefined, + ?traffic_control: Stoplight::traffic_control | Stoplight::undefined, + ?traffic_recovery: Stoplight::traffic_recovery | Stoplight::undefined, + ) -> Stoplight::Domain::Light end + diff --git a/sig/stoplight/common/option.rbs b/sig/stoplight/common/option.rbs new file mode 100644 index 00000000..41dfa13a --- /dev/null +++ b/sig/stoplight/common/option.rbs @@ -0,0 +1,29 @@ +module Stoplight + module Common + type option[T] = Common::Some[T] | Common::None + + class Some[T] + attr_reader value: T + + def initialize: (T value) -> void + + def get_or_else: [U] { () -> untyped } -> T + def map: [U] { (T) -> U } -> Some[U] + def ==: (untyped) -> bool + def empty?: -> false + end + + class None + def get_or_else: [U] { () -> U } -> U + def map: [U] { (untyped) -> U } -> None + def ==: (untyped) -> bool + def empty?: -> true + end + + def self.some: [T] (T value) -> Some[T] + def self.none: -> None + # def self.option: [T] (T? value) -> option[T] + # def self.provided: (undefined) -> None + # | [T] (T value) -> Some[T] + end +end diff --git a/sig/stoplight/domain.rbs b/sig/stoplight/domain.rbs index 59a02b92..7a3c9050 100644 --- a/sig/stoplight/domain.rbs +++ b/sig/stoplight/domain.rbs @@ -1,8 +1,5 @@ module Stoplight module Domain - type threshold = (Float | Integer) - type traffic_control = Symbol | Hash[Symbol, Hash[Symbol, Integer]] - type traffic_recovery = Symbol type config_params = { name: String, cool_off_time: Integer, @@ -10,7 +7,9 @@ module Stoplight recovery_threshold: Integer, window_size: Integer?, tracked_errors: Array[StandardError], - skipped_errors: Array[StandardError] + skipped_errors: Array[StandardError], + traffic_control: TrafficControl::Base, + traffic_recovery: TrafficRecovery::Base } type config_overrides = { ?name: String, @@ -19,7 +18,7 @@ module Stoplight ?recovery_threshold: Integer, ?window_size: Integer?, ?tracked_errors: Array[StandardError], - ?skipped_errors: Array[StandardError] + ?skipped_errors: Array[StandardError], } end end diff --git a/sig/stoplight/domain/config.rbs b/sig/stoplight/domain/config.rbs index 256e064d..bcdc2202 100644 --- a/sig/stoplight/domain/config.rbs +++ b/sig/stoplight/domain/config.rbs @@ -2,14 +2,47 @@ module Stoplight module Domain class Config < Data attr_reader threshold: threshold - attr_reader window_size: Integer? - attr_reader tracked_errors: Array[StandardError] - attr_reader skipped_errors: Array[StandardError] + attr_reader window_size: window_size + attr_reader tracked_errors: errors + attr_reader skipped_errors: errors attr_reader name: String - attr_reader cool_off_time: Integer - attr_reader recovery_threshold: Integer + attr_reader cool_off_time: cool_off_time + attr_reader recovery_threshold: recovery_threshold + attr_reader traffic_control: TrafficControl::Base + attr_reader traffic_recovery: TrafficRecovery::Base + attr_reader error_notifier: error_notifier + attr_reader notifiers: notifiers + attr_reader data_store: data_store - def initialize: (**config_params) -> void + def self.new: ( + name: String, + cool_off_time: cool_off_time, + threshold: threshold, + recovery_threshold: recovery_threshold, + window_size: window_size, + tracked_errors: errors, + skipped_errors: errors, + traffic_control: TrafficControl::Base, + traffic_recovery: TrafficRecovery::Base, + error_notifier: error_notifier, + notifiers: notifiers, + data_store: data_store + ) -> instance + + def initialize: ( + name: String, + cool_off_time: cool_off_time, + threshold: threshold, + recovery_threshold: recovery_threshold, + window_size: window_size, + tracked_errors: errors, + skipped_errors: errors, + traffic_control: TrafficControl::Base, + traffic_recovery: TrafficRecovery::Base, + error_notifier: error_notifier, + notifiers: notifiers, + data_store: data_store + ) -> void def track_error?: ((Class) error) -> bool @@ -17,12 +50,17 @@ module Stoplight def with: ( ?name: String, - ?cool_off_time: Integer, + ?cool_off_time: cool_off_time, ?threshold: threshold, - ?recovery_threshold: Integer, - ?window_size: Integer?, - ?tracked_errors: Array[StandardError], - ?skipped_errors: Array[StandardError] + ?recovery_threshold: recovery_threshold, + ?window_size: window_size, + ?tracked_errors: errors, + ?skipped_errors: errors, + ?traffic_control: TrafficControl::Base, + ?traffic_recovery: TrafficRecovery::Base, + ?error_notifier: error_notifier, + ?notifiers: notifiers, + ?data_store: data_store ) -> Config end end diff --git a/sig/stoplight/domain/data_store.rbs b/sig/stoplight/domain/data_store.rbs index 8ca030f5..3f384f33 100644 --- a/sig/stoplight/domain/data_store.rbs +++ b/sig/stoplight/domain/data_store.rbs @@ -2,7 +2,7 @@ module Stoplight module Domain - class DataStore[T < RecoveryLockToken] + class DataStore METRICS_RETENTION_TIME: Integer def names: -> Array[String] @@ -27,9 +27,9 @@ module Stoplight def set_state: (Config config, state state) -> state - def acquire_recovery_lock: (Config config) -> T? + def acquire_recovery_lock: (Config config) -> RecoveryLockToken? - def release_recovery_lock: (T lock) -> void + def release_recovery_lock: (RecoveryLockToken lock) -> void def transition_to_color: (Config config, color color) -> bool diff --git a/sig/stoplight/domain/failure.rbs b/sig/stoplight/domain/failure.rbs index e005f6c6..f0eb050b 100644 --- a/sig/stoplight/domain/failure.rbs +++ b/sig/stoplight/domain/failure.rbs @@ -6,7 +6,7 @@ module Stoplight attr_reader time: Time alias occurred_at time - def self.from_error: (StandardError error, time: Time) -> instance + def self.from_error: (StandardError error, time: Time) -> Failure def initialize: (String error_class, String error_message, Time time) -> void diff --git a/sig/stoplight/domain/light_factory.rbs b/sig/stoplight/domain/light_factory.rbs index c9b880a7..b08c24a9 100644 --- a/sig/stoplight/domain/light_factory.rbs +++ b/sig/stoplight/domain/light_factory.rbs @@ -2,32 +2,35 @@ module Stoplight module Domain class LightFactory def with: ( - ?cool_off_time: Integer, - ?threshold: (Integer | Float), - ?recovery_threshold: Integer, - ?window_size: Integer, - ?tracked_errors: Array[StandardError], - ?skipped_errors: Array[StandardError], - ?error_notifier: Infrastructure::error_notifier, - ?notifiers: Array[Notifier::Base], - ?data_store: Domain::DataStoreConfig, - ?traffic_control: traffic_control, - ?traffic_recovery: traffic_recovery, + ?name: String | undefined, + ?cool_off_time: cool_off_time | undefined, + ?threshold: threshold | undefined, + ?recovery_threshold: recovery_threshold | undefined, + ?window_size: window_size | undefined, + ?tracked_errors: errors | undefined, + ?skipped_errors: errors | undefined, + ?error_notifier: error_notifier | undefined, + ?notifiers: notifiers | undefined, + ?data_store: data_store | undefined, + ?traffic_control: traffic_control | undefined, + ?traffic_recovery: traffic_recovery | undefined, ) -> LightFactory + def build: -> Light def build_with: ( - ?cool_off_time: Integer, - ?threshold: (Integer | Float), - ?recovery_threshold: Integer, - ?window_size: Integer, - ?tracked_errors: Array[StandardError], - ?skipped_errors: Array[StandardError], - ?error_notifier: Infrastructure::error_notifier, - ?notifiers: Array[Notifier::Base], - ?data_store: Domain::DataStoreConfig, - ?traffic_control: traffic_control, - ?traffic_recovery: traffic_recovery, + name: String | undefined, + cool_off_time: cool_off_time | undefined, + threshold: threshold | undefined, + recovery_threshold: recovery_threshold | undefined, + window_size: window_size | undefined, + tracked_errors: errors | undefined, + skipped_errors: errors | undefined, + error_notifier: error_notifier | undefined, + notifiers: notifiers | undefined, + data_store: data_store | undefined, + traffic_control: traffic_control | undefined, + traffic_recovery: traffic_recovery | undefined, ) -> Light end end diff --git a/sig/stoplight/domain/system.rbs b/sig/stoplight/domain/system.rbs new file mode 100644 index 00000000..6c3aacfd --- /dev/null +++ b/sig/stoplight/domain/system.rbs @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Stoplight + module Domain + class System + attr_reader name: String + + def light: ( + String name, + ?cool_off_time: cool_off_time | undefined, + ?threshold: threshold | undefined, + ?recovery_threshold: recovery_threshold | undefined, + ?window_size: window_size | undefined, + ?skipped_errors: errors | undefined, + ?tracked_errors: errors | undefined, + ?traffic_control: traffic_control | undefined, + ?traffic_recovery: traffic_recovery | undefined + ) -> Light + end + end +end diff --git a/sig/stoplight/infrastructure/fail_safe/data_store.rbs b/sig/stoplight/infrastructure/fail_safe/data_store.rbs index 056e0c7e..40267475 100644 --- a/sig/stoplight/infrastructure/fail_safe/data_store.rbs +++ b/sig/stoplight/infrastructure/fail_safe/data_store.rbs @@ -1,16 +1,16 @@ module Stoplight module Infrastructure module FailSafe - class DataStore < Domain::DataStore[Domain::RecoveryLockToken] - attr_reader data_store: Infrastructure::Redis::DataStore - attr_reader failover_data_store: Infrastructure::Memory::DataStore + class DataStore < Domain::DataStore + attr_reader data_store: Domain::DataStore + attr_reader failover_data_store: Domain::DataStore attr_reader error_notifier: error_notifier private attr_reader circuit_breaker: Domain::Light def initialize: ( - data_store: Infrastructure::Redis::DataStore, + data_store: Domain::DataStore, error_notifier: error_notifier, - failover_data_store: Infrastructure::Memory::DataStore, + failover_data_store: Domain::DataStore, circuit_breaker: Domain::Light ) -> void diff --git a/sig/stoplight/infrastructure/memory/data_store.rbs b/sig/stoplight/infrastructure/memory/data_store.rbs index 946fa2bd..254ef868 100644 --- a/sig/stoplight/infrastructure/memory/data_store.rbs +++ b/sig/stoplight/infrastructure/memory/data_store.rbs @@ -1,7 +1,7 @@ module Stoplight module Infrastructure module Memory - class DataStore < Domain::DataStore[DataStore::RecoveryLockToken] + class DataStore < Domain::DataStore include MonitorMixin KEY_SEPARATOR: String diff --git a/sig/stoplight/infrastructure/redis/data_store.rbs b/sig/stoplight/infrastructure/redis/data_store.rbs index d8f4e912..e5277b57 100644 --- a/sig/stoplight/infrastructure/redis/data_store.rbs +++ b/sig/stoplight/infrastructure/redis/data_store.rbs @@ -1,7 +1,7 @@ module Stoplight module Infrastructure module Redis - class DataStore < Domain::DataStore[DataStore::RecoveryLockToken] + class DataStore < Domain::DataStore BUCKET_SIZE: Integer KEY_SEPARATOR: String KEY_PREFIX: String diff --git a/sig/stoplight/infrastructure/storage/compatibility_metrics.rbs b/sig/stoplight/infrastructure/storage/compatibility_metrics.rbs index 9af90912..be93acca 100644 --- a/sig/stoplight/infrastructure/storage/compatibility_metrics.rbs +++ b/sig/stoplight/infrastructure/storage/compatibility_metrics.rbs @@ -2,11 +2,11 @@ module Stoplight module Infrastructure module Storage class CompatibilityMetrics < Domain::Storage::Metrics - private attr_reader data_store: Domain::DataStore[Domain::RecoveryLockToken] + private attr_reader data_store: Domain::DataStore private attr_reader config: Domain::Config def initialize: ( - data_store: Domain::DataStore[Domain::RecoveryLockToken], + data_store: Domain::DataStore, config: Domain::Config, ) -> void end diff --git a/sig/stoplight/infrastructure/storage/compatibility_recovery_lock.rbs b/sig/stoplight/infrastructure/storage/compatibility_recovery_lock.rbs index eadc0eda..7e64a64e 100644 --- a/sig/stoplight/infrastructure/storage/compatibility_recovery_lock.rbs +++ b/sig/stoplight/infrastructure/storage/compatibility_recovery_lock.rbs @@ -2,10 +2,10 @@ module Stoplight module Infrastructure module Storage class CompatibilityRecoveryLock < Domain::Storage::RecoveryLock - private attr_reader data_store: Domain::DataStore[Domain::RecoveryLockToken] + private attr_reader data_store: Domain::DataStore private attr_reader config: Domain::Config - def initialize: (data_store: Domain::DataStore[Domain::RecoveryLockToken], config: Domain::Config) -> void + def initialize: (data_store: Domain::DataStore, config: Domain::Config) -> void end end end diff --git a/sig/stoplight/infrastructure/storage/compatibility_recovery_metrics.rbs b/sig/stoplight/infrastructure/storage/compatibility_recovery_metrics.rbs index 4ea654a8..c6d966a1 100644 --- a/sig/stoplight/infrastructure/storage/compatibility_recovery_metrics.rbs +++ b/sig/stoplight/infrastructure/storage/compatibility_recovery_metrics.rbs @@ -2,10 +2,10 @@ module Stoplight module Infrastructure module Storage class CompatibilityRecoveryMetrics < Domain::Storage::Metrics - private attr_reader data_store: Domain::DataStore[Domain::RecoveryLockToken] + private attr_reader data_store: Domain::DataStore private attr_reader config: Domain::Config - def initialize: (data_store: Domain::DataStore[Domain::RecoveryLockToken], config: Domain::Config) -> void + def initialize: (data_store: Domain::DataStore, config: Domain::Config) -> void end end end diff --git a/sig/stoplight/infrastructure/storage/compatibility_state.rbs b/sig/stoplight/infrastructure/storage/compatibility_state.rbs index be510b13..8ff19900 100644 --- a/sig/stoplight/infrastructure/storage/compatibility_state.rbs +++ b/sig/stoplight/infrastructure/storage/compatibility_state.rbs @@ -2,10 +2,10 @@ module Stoplight module Infrastructure module Storage class CompatibilityState < Domain::Storage::State - private attr_reader data_store: Domain::DataStore[Domain::RecoveryLockToken] + private attr_reader data_store: Domain::DataStore private attr_reader config: Domain::Config - def initialize: (data_store: Domain::DataStore[Domain::RecoveryLockToken], config: Domain::Config) -> void + def initialize: (data_store: Domain::DataStore, config: Domain::Config) -> void end end end diff --git a/sig/stoplight/system/light_builder.rbs b/sig/stoplight/system/light_builder.rbs index 8597cefc..09b39335 100644 --- a/sig/stoplight/system/light_builder.rbs +++ b/sig/stoplight/system/light_builder.rbs @@ -3,17 +3,18 @@ module Stoplight class System class LightBuilder < Wiring::LightBuilder attr_reader system: System - @failover_system: System + + @failover_system: Domain::System @storage_set: StorageSet @key_space: Infrastructure::Redis::Storage::KeySpace - def initialize: (System, settings) -> void + def initialize: (config: Domain::Config, factory: Domain::LightFactory, system: System) -> void def key_space: -> Infrastructure::Redis::Storage::KeySpace private def redis: -> redis private def storage_scripting: -> Infrastructure::Redis::Storage::Scripting - private def failover_system: -> System + private def failover_system: -> Domain::System private def storage_set: -> StorageSet private def build_backend: -> DataStoreBackend end diff --git a/sig/stoplight/system/light_factory.rbs b/sig/stoplight/system/light_factory.rbs index cd604e43..7686a9bb 100644 --- a/sig/stoplight/system/light_factory.rbs +++ b/sig/stoplight/system/light_factory.rbs @@ -4,11 +4,13 @@ module Stoplight module Wiring class System class LightFactory < Wiring::LightFactory - ALLOWED_LIGHT_SETTINGS: Array[Symbol] + class InternalLightFactory < Domain::LightFactory + end attr_reader system: System + attr_reader settings: Settings - def initialize: ((dependency_overrides & Domain::config_overrides & {system: System})) -> void + def initialize: (system: System, settings: Settings) -> void end end end diff --git a/sig/stoplight/types.rbs b/sig/stoplight/types.rbs index 4623202e..ab5bfd99 100644 --- a/sig/stoplight/types.rbs +++ b/sig/stoplight/types.rbs @@ -1,5 +1,14 @@ module Stoplight module Types + class Undefined + include Singleton + + def self.instance: -> instance + def inspect: -> String + def to_s: -> String + end + + def self.undefined: -> undefined def self.must: [T < Object] (T? value) -> T end end diff --git a/sig/stoplight/wiring.rbs b/sig/stoplight/wiring.rbs index 126c49d0..ea359b8c 100644 --- a/sig/stoplight/wiring.rbs +++ b/sig/stoplight/wiring.rbs @@ -1,5 +1,7 @@ module Stoplight module Wiring + type option[T] = Common::option[T] + type dependency_overrides = { ?error_notifier: Infrastructure::error_notifier, ?notifiers: Array[Notifier::Base], diff --git a/sig/stoplight/wiring/data_store/redis.rbs b/sig/stoplight/wiring/data_store/redis.rbs index b6da8b1b..92f206dd 100644 --- a/sig/stoplight/wiring/data_store/redis.rbs +++ b/sig/stoplight/wiring/data_store/redis.rbs @@ -5,7 +5,7 @@ module Stoplight attr_reader redis: redis attr_reader warn_on_clock_skew: bool - def initialize: (redis redis, warn_on_clock_skew: bool) -> void + def initialize: (redis, ?warn_on_clock_skew: bool) -> void end end end diff --git a/sig/stoplight/wiring/default.rbs b/sig/stoplight/wiring/default.rbs index a25c28ba..c485bb77 100644 --- a/sig/stoplight/wiring/default.rbs +++ b/sig/stoplight/wiring/default.rbs @@ -1,23 +1,23 @@ module Stoplight module Wiring module Default - COOL_OFF_TIME: Float + COOL_OFF_TIME: cool_off_time - DATA_STORE: Domain::DataStoreConfig + DATA_STORE: data_store - ERROR_NOTIFIER: Infrastructure::error_notifier + ERROR_NOTIFIER: error_notifier FORMATTER: Infrastructure::notification_formatter - NOTIFIERS: Array[Domain::StateTransitionNotifier] + NOTIFIERS: notifiers - THRESHOLD: Integer - RECOVERY_THRESHOLD: Integer + THRESHOLD: threshold + RECOVERY_THRESHOLD: recovery_threshold - WINDOW_SIZE: Integer? + WINDOW_SIZE: window_size - TRACKED_ERRORS: Array[StandardError] - SKIPPED_ERRORS: Array[StandardError] + TRACKED_ERRORS: errors + SKIPPED_ERRORS: errors TRAFFIC_CONTROL: Domain::TrafficControl::Base TRAFFIC_RECOVERY: Domain::TrafficRecovery::Base diff --git a/sig/stoplight/wiring/default_configuration.rbs b/sig/stoplight/wiring/default_configuration.rbs index 91e15e17..c21e4511 100644 --- a/sig/stoplight/wiring/default_configuration.rbs +++ b/sig/stoplight/wiring/default_configuration.rbs @@ -1,27 +1,43 @@ module Stoplight module Wiring class DefaultConfiguration - attr_accessor cool_off_time: Integer? - - attr_accessor threshold: Integer? - - attr_accessor recovery_threshold: Integer? - - attr_accessor window_size: Integer? - - attr_accessor tracked_errors: Array[StandardError] - - attr_accessor skipped_errors: Array[StandardError] - - attr_accessor error_notifier: Infrastructure::error_notifier - - attr_accessor notifiers: Array[Notifier::Base] - - attr_accessor data_store: Domain::DataStoreConfig - - attr_accessor traffic_control: traffic_control - - attr_accessor traffic_recovery: traffic_recovery + @cool_off_time: option[cool_off_time] + @threshold: option[threshold] + @recovery_threshold: option[recovery_threshold] + @window_size: option[window_size] + @tracked_errors: option[errors] + @skipped_errors: option[errors] + @traffic_control: option[traffic_control] + @traffic_recovery: option[traffic_recovery] + @error_notifier: option[error_notifier] + @data_store: option[data_store] + @notifiers: option[notifiers] + + def to_settings: -> Settings + + def cool_off_time: -> cool_off_time + def threshold: -> threshold + def recovery_threshold: -> recovery_threshold + def window_size: -> window_size + def tracked_errors: -> errors + def skipped_errors: -> errors + def traffic_control: -> traffic_control + def traffic_recovery: -> traffic_recovery + def error_notifier: -> error_notifier + def data_store: -> data_store + def notifiers: -> notifiers + + def cool_off_time=: (cool_off_time) -> void + def threshold=: (threshold) -> void + def recovery_threshold=: (recovery_threshold) -> void + def window_size=: (window_size) -> void + def tracked_errors=: (errors) -> void + def skipped_errors=: (errors) -> void + def traffic_control=: (traffic_control) -> void + def traffic_recovery=: (traffic_recovery) -> void + def error_notifier=: (error_notifier) -> void + def data_store=: (data_store) -> void + def notifiers=: (notifiers) -> void end end end diff --git a/sig/stoplight/wiring/default_factory_builder.rbs b/sig/stoplight/wiring/default_factory_builder.rbs new file mode 100644 index 00000000..98053bb3 --- /dev/null +++ b/sig/stoplight/wiring/default_factory_builder.rbs @@ -0,0 +1,10 @@ +module Stoplight + module Wiring + class DefaultFactoryBuilder + attr_reader configuration: DefaultConfiguration + + def initialize: -> void + def build: -> LightFactory + end + end +end diff --git a/sig/stoplight/wiring/light_builder.rbs b/sig/stoplight/wiring/light_builder.rbs index 159fecd0..1c46ced6 100644 --- a/sig/stoplight/wiring/light_builder.rbs +++ b/sig/stoplight/wiring/light_builder.rbs @@ -4,15 +4,17 @@ module Stoplight FAILOVER_DATA_STORE_CONFIG: Stoplight::DataStore::Memory MEMORY_REGISTRY: Concurrent::Map[Integer, Infrastructure::Memory::DataStore] - attr_reader data_store_config: Domain::DataStoreConfig + attr_reader data_store_config: data_store attr_reader error_notifier: error_notifier - attr_reader traffic_recovery: traffic_recovery - attr_reader traffic_control: traffic_control attr_reader config: Domain::Config - attr_reader factory: untyped + attr_reader factory: Domain::LightFactory attr_reader clock: Domain::Clock - def initialize: (Hash[Symbol, untyped]) -> void + attr_reader traffic_recovery: Domain::TrafficRecovery::Base + attr_reader traffic_control: Domain::TrafficControl::Base + @notifiers: notifiers + + def initialize: (config: Domain::Config, factory: Domain::LightFactory) -> void def build: -> Domain::Light @@ -22,8 +24,8 @@ module Stoplight def scripting: -> Infrastructure::Redis::DataStore::Scripting def memory_recovery_lock_store: -> Infrastructure::Memory::DataStore::RecoveryLockStore - def failover_data_store: -> Domain::DataStore[Domain::RecoveryLockToken] - def data_store: -> Domain::DataStore[Domain::RecoveryLockToken] + def failover_data_store: -> Domain::DataStore + def data_store: -> Domain::DataStore def metrics_store: -> Domain::Storage::Metrics def recovery_lock_store: -> Domain::Storage::RecoveryLock @@ -34,8 +36,9 @@ module Stoplight def green_run_strategy: -> Domain::Strategies::GreenRunStrategy def yellow_run_strategy: -> Domain::Strategies::YellowRunStrategy def red_run_strategy: -> Domain::Strategies::RedRunStrategy - def create_data_store: (Domain::DataStoreConfig data_store_config) -> Domain::DataStore[Domain::RecoveryLockToken] + def create_data_store: (Domain::DataStoreConfig data_store_config) -> Domain::DataStore def memory_registry: -> Concurrent::Map[Integer, Infrastructure::Memory::DataStore] + def redis: -> redis end end end diff --git a/sig/stoplight/wiring/light_factory.rbs b/sig/stoplight/wiring/light_factory.rbs index 093c348f..9041a015 100644 --- a/sig/stoplight/wiring/light_factory.rbs +++ b/sig/stoplight/wiring/light_factory.rbs @@ -1,12 +1,9 @@ module Stoplight module Wiring class LightFactory < Domain::LightFactory - DEPENDENCY_KEYS: Array[Symbol] - CONFIG_KEYS: Array[Symbol] + attr_reader settings: Settings - attr_reader settings: settings_overrides - - def initialize: (settings_overrides) -> void + def initialize: (settings: Settings) -> void def validate_configuration!: -> void @@ -14,19 +11,8 @@ module Stoplight def eql: (untyped other) -> bool def hash: -> Integer - private def validate_settings!: -> void - private def light_builder: ( - Domain::Config config, - { - ?error_notifier: Infrastructure::error_notifier, - ?notifiers: Array[Notifier::Base], - ?data_store: Domain::DataStoreConfig, - ?traffic_control: traffic_control, - ?traffic_recovery: traffic_recovery, - } dependencies - ) -> LightBuilder - - private def light_factory: -> LightFactory + def light_builder: (config: Domain::Config) -> LightBuilder + def light_factory: -> Domain::LightFactory end end end diff --git a/sig/stoplight/wiring/light_factory/compatibility_validator.rbs b/sig/stoplight/wiring/light_factory/compatibility_validator.rbs index f32a8d7d..056cacc0 100644 --- a/sig/stoplight/wiring/light_factory/compatibility_validator.rbs +++ b/sig/stoplight/wiring/light_factory/compatibility_validator.rbs @@ -4,12 +4,11 @@ module Stoplight module Wiring class LightFactory class CompatibilityValidator - private attr_reader dependencies: dependencies - private attr_reader config: Domain::Config + attr_reader config: Domain::Config - def self.call: (Domain::Config, dependencies) -> void + def self.call: (config: Domain::Config) -> void - def initialize: (Domain::Config, dependencies) -> void + def initialize: (config: Domain::Config) -> void def call: -> void diff --git a/sig/stoplight/wiring/light_factory/config_normalizer.rbs b/sig/stoplight/wiring/light_factory/config_normalizer.rbs deleted file mode 100644 index 5892d7fa..00000000 --- a/sig/stoplight/wiring/light_factory/config_normalizer.rbs +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Stoplight - module Wiring - class LightFactory - class ConfigNormalizer - def self.call: (Domain::Config config) -> Domain::Config - - private attr_reader config: Domain::Config - - def initialize: (Domain::Config config) -> void - - def call: -> Domain::Config - - private def normalize_tracked_errors: (Domain::Config config) -> Domain::Config - - private def normalize_skipped_errors: (Domain::Config config) -> Domain::Config - - private def normalize_cool_off_time: (Domain::Config config) -> Domain::Config - end - end - end -end diff --git a/sig/stoplight/wiring/light_factory/configuration_pipeline.rbs b/sig/stoplight/wiring/light_factory/configuration_pipeline.rbs index 100d0133..9164540d 100644 --- a/sig/stoplight/wiring/light_factory/configuration_pipeline.rbs +++ b/sig/stoplight/wiring/light_factory/configuration_pipeline.rbs @@ -2,20 +2,32 @@ module Stoplight module Wiring class LightFactory class ConfigurationPipeline - attr_reader dependency_settings: dependency_overrides - attr_reader config_settings: Domain::config_overrides + attr_reader settings: Settings - def self.process: (Domain::config_overrides, dependency_overrides) -> [Domain::Config, dependencies] + def self.process: (settings: Settings) -> Domain::Config - def initialize: (Domain::config_overrides, dependency_overrides) -> void + def initialize: (settings: Settings) -> void - def process: -> [Domain::Config, dependencies] + def process: -> Domain::Config def build_config: -> Domain::Config def build_dependencies: -> dependencies def base_config: -> Domain::Config + + private def name: -> String + private def cool_off_time: -> cool_off_time + private def threshold: -> threshold + private def recovery_threshold: -> recovery_threshold + private def window_size: -> window_size + private def skipped_errors: -> errors + private def tracked_errors: -> errors + private def traffic_control: -> Domain::TrafficControl::Base + private def traffic_recovery: -> Domain::TrafficRecovery::Base + private def error_notifier: -> error_notifier + private def notifiers: -> notifiers + private def data_store: -> data_store end end end diff --git a/sig/stoplight/wiring/public_api.rbs b/sig/stoplight/wiring/public_api.rbs new file mode 100644 index 00000000..53de73f1 --- /dev/null +++ b/sig/stoplight/wiring/public_api.rbs @@ -0,0 +1,22 @@ +module Stoplight + module Wiring + module PublicApi + Color: singleton(Domain::Color) + Error: singleton(Domain::Error) + State: singleton(Domain::State) + + module DataStore + Base: singleton(Domain::DataStoreConfig) + Redis: singleton(Wiring::DataStore::Redis) + Memory: singleton(Wiring::DataStore::Memory) + end + + module Notifier + Base: singleton(Domain::StateTransitionNotifier) + Generic: singleton(Infrastructure::Notifier::Generic) + IO: singleton(Infrastructure::Notifier::IO) + Logger: singleton(Infrastructure::Notifier::Logger) + end + end + end +end diff --git a/sig/stoplight/wiring/settings.rbs b/sig/stoplight/wiring/settings.rbs new file mode 100644 index 00000000..5cbea2dd --- /dev/null +++ b/sig/stoplight/wiring/settings.rbs @@ -0,0 +1,54 @@ +module Stoplight + module Wiring + class Settings + attr_reader name: option[String] + attr_reader cool_off_time: option[cool_off_time] + attr_reader threshold: option[threshold] + attr_reader recovery_threshold: option[recovery_threshold] + attr_reader window_size: option[window_size] + attr_reader skipped_errors: option[errors] + attr_reader tracked_errors: option[errors] + attr_reader traffic_control: option[traffic_control] + attr_reader traffic_recovery: option[traffic_recovery] + attr_reader error_notifier: option[error_notifier] + attr_reader notifiers: option[notifiers] + attr_reader data_store: option[data_store] + + EMPTY: Settings + def self.empty: -> Settings + + def initialize: ( + name: option[String], + cool_off_time: option[cool_off_time], + threshold: option[threshold], + recovery_threshold: option[recovery_threshold], + window_size: option[window_size], + skipped_errors: option[errors], + tracked_errors: option[errors], + traffic_control: option[traffic_control], + traffic_recovery: option[traffic_recovery], + error_notifier: option[error_notifier], + notifiers: option[notifiers], + data_store: option[data_store] + ) -> void + + def extend_with: ( + ?name: String | undefined, + ?cool_off_time: cool_off_time | undefined, + ?threshold: threshold | undefined, + ?recovery_threshold: recovery_threshold | undefined, + ?window_size: window_size | undefined, + ?skipped_errors: errors | undefined, + ?tracked_errors: errors | undefined, + ?traffic_control: traffic_control | undefined, + ?traffic_recovery: traffic_recovery | undefined, + ?error_notifier: error_notifier | undefined, + ?notifiers: notifiers | undefined, + ?data_store: data_store | undefined + ) -> Settings + + def empty?: -> bool + def to_h: -> Hash[Symbol, option[untyped]] + end + end +end diff --git a/sig/stoplight/wiring/system.rbs b/sig/stoplight/wiring/system.rbs index f478fa01..08e66c8a 100644 --- a/sig/stoplight/wiring/system.rbs +++ b/sig/stoplight/wiring/system.rbs @@ -1,18 +1,13 @@ module Stoplight module Wiring - class System - attr_reader name: _ToS - private attr_reader light_factory: LightFactory - private attr_reader lights: Concurrent::Map[ - _ToS, - [Domain::Light, Domain::config_overrides] - ] + class System < Domain::System + attr_reader light_factory: LightFactory + attr_reader lights: Concurrent::Map[String, [Domain::Light, Settings]] + attr_reader settings: Settings - def initialize: (_ToS, **settings_overrides) -> void + def initialize: (String name, settings: Settings) -> void - def light: (_ToS name, **Domain::config_overrides) -> Domain::Light - - private def normalize_settings: (Domain::config_overrides) -> Domain::config_overrides + def validate_configuration!: -> void end end end diff --git a/spec/integration/stoplight/wiring/system_spec.rb b/spec/integration/stoplight/wiring/system_spec.rb index cccb829b..cf81f17e 100644 --- a/spec/integration/stoplight/wiring/system_spec.rb +++ b/spec/integration/stoplight/wiring/system_spec.rb @@ -1,17 +1,38 @@ # frozen_string_literal: true RSpec.describe Stoplight::Wiring::System do - subject(:system) { described_class.new("system name", **system_defaults) } - - let(:system_defaults) { {} } + subject(:system) { described_class.new("system name", settings:) } + let(:settings) do + Stoplight::Wiring::Settings.new( + name: Stoplight::Common.none, + cool_off_time:, + threshold:, + recovery_threshold:, + window_size:, + tracked_errors:, + skipped_errors:, + data_store:, + error_notifier:, + notifiers:, + traffic_control:, + traffic_recovery: + ) + end + let(:cool_off_time) { Stoplight::Common.none } + let(:threshold) { Stoplight::Common.none } + let(:recovery_threshold) { Stoplight::Common.none } + let(:window_size) { Stoplight::Common.none } + let(:tracked_errors) { Stoplight::Common.none } + let(:skipped_errors) { Stoplight::Common.none } + let(:data_store) { Stoplight::Common.none } + let(:error_notifier) { Stoplight::Common.none } + let(:notifiers) { Stoplight::Common.none } + let(:traffic_control) { Stoplight::Common.none } + let(:traffic_recovery) { Stoplight::Common.none } describe "#new" do - let(:system_defaults) do - { - traffic_control: :error_rate, - threshold: 1 - } - end + let(:traffic_control) { Stoplight::Common.some(:error_rate) } + let(:threshold) { Stoplight::Common.some(1) } it "validates system configuration" do expect { system }.to raise_error(Stoplight::Error::ConfigurationError) @@ -51,8 +72,8 @@ subject(:light) { system.light(name) } it "raises configuration error" do - existing_light = system.light(name, window_size: 422) - expect(light).to be(existing_light) + system.light(name, window_size: 422) + expect { light }.to raise_error(Stoplight::Error::ConfigurationError) end end @@ -70,7 +91,7 @@ subject(:light) { system.light(name, window_size: 422) } it "allows same name light in different systems" do - another_system = described_class.new("another system") + another_system = described_class.new("another system", settings: Stoplight::Wiring::Settings.empty) existing_light = another_system.light(name, window_size: 224) expect(light).not_to be(existing_light) @@ -140,22 +161,22 @@ context "with skipped_errors" do subject(:config) { light.config } - let(:light) { system.light(name, skipped_errors:) } - let(:skipped_errors) { [StandardError, KeyError] } + let(:light) { system.light(name, skipped_errors: light_skipped_errors) } + let(:light_skipped_errors) { [StandardError, KeyError] } it "sets skipped_errors" do - is_expected.to have_attributes(skipped_errors:) + is_expected.to have_attributes(skipped_errors: light_skipped_errors) end end context "with tracked_errors" do subject(:config) { light.config } - let(:light) { system.light(name, tracked_errors:) } - let(:tracked_errors) { [StandardError, KeyError] } + let(:light) { system.light(name, tracked_errors: light_tracked_errors) } + let(:light_tracked_errors) { [StandardError, KeyError] } it "sets tracked_errors" do - is_expected.to have_attributes(tracked_errors:) + is_expected.to have_attributes(tracked_errors: light_tracked_errors) end end diff --git a/spec/integration/wiring/light_factory_spec.rb b/spec/integration/wiring/light_factory_spec.rb index 4719a63d..1111faa6 100644 --- a/spec/integration/wiring/light_factory_spec.rb +++ b/spec/integration/wiring/light_factory_spec.rb @@ -1,42 +1,15 @@ # frozen_string_literal: true RSpec.describe Stoplight::Wiring::LightFactory do - let(:base_config) do - Stoplight::Domain::Config.new( - name: "test-light", - threshold: 5, - window_size: 300, - cool_off_time: 60, - tracked_errors: [StandardError], - skipped_errors: [], - recovery_threshold: 2 - ) - end - - let(:base_data_store) { instance_double(Stoplight::Domain::DataStore) } - let(:base_notifiers) { [] } - let(:base_error_notifier) { ->(error) {} } - let(:base_traffic_control) { Stoplight::Domain::TrafficControl::ConsecutiveErrors.new } - let(:base_traffic_recovery) { Stoplight::Domain::TrafficRecovery::ConsecutiveSuccesses.new } - - let(:base_container) do - Stoplight::Wiring::Container.with( - config: base_config, - data_store: base_data_store, - notifiers: base_notifiers, - error_notifier: base_error_notifier, - traffic_control: base_traffic_control, - traffic_recovery: base_traffic_recovery - ) - end - let(:factory) { described_class.new({}) } + let(:settings) { Stoplight::Wiring::Settings.empty.extend_with(name: SecureRandom.uuid) } + let(:factory) { described_class.new(settings:) } + let(:light) { factory.build } describe "transformations" do describe "tracked_errors" do subject(:tracked_errors) { light.config.tracked_errors } - let(:new_factory) { factory.with(tracked_errors: Timeout::Error) } - let(:light) { new_factory.build } + let(:settings) { super().extend_with(tracked_errors: [Timeout::Error]) } it "normalizes tracked_errors to array" do expect(tracked_errors).to eq([Timeout::Error]) @@ -46,8 +19,7 @@ describe "skipped_errors" do subject(:skipped_errors) { light.config.skipped_errors } - let(:new_factory) { factory.with(skipped_errors: Timeout::Error) } - let(:light) { new_factory.build } + let(:settings) { super().extend_with(skipped_errors: [Timeout::Error]) } it "normalizes skipped_errors to array" do expect(skipped_errors).to eq([Timeout::Error]) @@ -57,8 +29,7 @@ describe "cool_off_time" do subject(:cool_off_time) { light.config.cool_off_time } - let(:new_factory) { factory.with(cool_off_time: 120.0) } - let(:light) { new_factory.build } + let(:settings) { super().extend_with(cool_off_time: 120) } it "converts cool_off_time to integer" do expect(cool_off_time).to eq(120) @@ -73,8 +44,7 @@ .__send__(:traffic_recovery) end - let(:new_factory) { factory.with(traffic_recovery: traffic_recovery_in, recovery_threshold: 2) } - let(:light) { new_factory.build } + let(:settings) { super().extend_with(traffic_recovery: traffic_recovery_in, recovery_threshold: 2) } context "when TrafficRecovery::ConsecutiveSuccesses" do let(:traffic_recovery_in) { Stoplight::Domain::TrafficRecovery::ConsecutiveSuccesses.new } @@ -114,8 +84,7 @@ .__send__(:traffic_control) end - let(:new_factory) { factory.with(traffic_control: traffic_control_in, **config) } - let(:light) { new_factory.build } + let(:settings) { super().extend_with(traffic_control: traffic_control_in, **config) } context "when TrafficControl::ConsecutiveErrors" do let(:traffic_control_in) { Stoplight::Domain::TrafficControl::ConsecutiveErrors.new } @@ -165,10 +134,8 @@ end describe "validation" do - subject(:light) { new_factory.build } - context "when traffic control is not compatible with the config" do - let(:new_factory) { factory.with(traffic_control:, threshold: 4, window_size: 60) } + let(:settings) { super().extend_with(traffic_control:, threshold: 4, window_size: 60) } let(:traffic_control) { Stoplight::Domain::TrafficControl::ErrorRate.new } it "raises a configuration errors" do @@ -181,7 +148,7 @@ end context "when traffic recovery is not compatible with the config" do - let(:new_factory) { factory.with(traffic_recovery:, recovery_threshold: -1) } + let(:settings) { super().extend_with(traffic_recovery:, recovery_threshold: -1) } let(:traffic_recovery) { Stoplight::Domain::TrafficRecovery::ConsecutiveSuccesses.new } it "raises a configuration errors" do @@ -192,13 +159,5 @@ ) end end - - context "when unexpected setting provider" do - it "raises an ArgumentError" do - expect { - factory.with(unexpected: 42, another_unexpected: 43) - }.to raise_error(ArgumentError, "Unknown settings: unexpected, another_unexpected") - end - end end end diff --git a/spec/unit/stoplight/domain/config_spec.rb b/spec/unit/stoplight/domain/config_spec.rb index 249291ea..60643411 100644 --- a/spec/unit/stoplight/domain/config_spec.rb +++ b/spec/unit/stoplight/domain/config_spec.rb @@ -10,6 +10,11 @@ def config_factory(**settings) window_size: nil, tracked_errors: [StandardError], skipped_errors: [], + traffic_control: instance_double(Stoplight::Domain::TrafficControl::Base), + traffic_recovery: instance_double(Stoplight::Domain::TrafficRecovery::Base), + error_notifier: ->(e) {}, + notifiers: [], + data_store: instance_double(Stoplight::Domain::DataStoreConfig), **settings ) end diff --git a/spec/unit/stoplight/wiring/light_factory/compatibility_validator_spec.rb b/spec/unit/stoplight/wiring/light_factory/compatibility_validator_spec.rb index 62c91942..d58fd4c5 100644 --- a/spec/unit/stoplight/wiring/light_factory/compatibility_validator_spec.rb +++ b/spec/unit/stoplight/wiring/light_factory/compatibility_validator_spec.rb @@ -1,11 +1,18 @@ # frozen_string_literal: true RSpec.describe Stoplight::Wiring::LightFactory::CompatibilityValidator do - subject(:validate) { described_class.call(config, dependencies) } + subject(:validate) { described_class.call(config:) } - let(:dependencies) { {traffic_control:, traffic_recovery:} } - - let(:config) { instance_double(Stoplight::Domain::Config, threshold:, window_size:, recovery_threshold:) } + let(:config) do + instance_double( + Stoplight::Domain::Config, + threshold:, + window_size:, + recovery_threshold:, + traffic_control:, + traffic_recovery: + ) + end let(:threshold) { 3 } let(:window_size) { nil } let(:recovery_threshold) { 1 } diff --git a/spec/unit/stoplight/wiring/light_factory/config_normalizer_spec.rb b/spec/unit/stoplight/wiring/light_factory/config_normalizer_spec.rb deleted file mode 100644 index dca79a8f..00000000 --- a/spec/unit/stoplight/wiring/light_factory/config_normalizer_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Stoplight::Wiring::LightFactory::ConfigNormalizer do - describe "tracked_errors" do - subject(:tracked_errors_out) { described_class.call(config).tracked_errors } - - let(:config) { Stoplight::Wiring::Light::DefaultConfig.with(tracked_errors:) } - - context "when array" do - let(:tracked_errors) { [KeyError, NotImplementedError] } - - it { is_expected.to eq(tracked_errors) } - end - - context "when single value" do - let(:tracked_errors) { KeyError } - - it { is_expected.to eq([tracked_errors]) } - end - end - - describe "skipped_errors" do - subject(:skipped_errors_out) { described_class.call(config).skipped_errors } - - let(:config) { Stoplight::Wiring::Light::DefaultConfig.with(skipped_errors:) } - - context "when array" do - let(:skipped_errors) { [KeyError, NotImplementedError] } - - it { is_expected.to eq(skipped_errors) } - end - - context "when single value" do - let(:skipped_errors) { KeyError } - - it { is_expected.to eq([skipped_errors]) } - end - end - - describe "cool_off_time" do - subject(:cool_off_time_out) { described_class.call(config).cool_off_time } - - let(:config) { Stoplight::Wiring::Light::DefaultConfig.with(cool_off_time:) } - - context "when Integer" do - let(:cool_off_time) { 42 } - - it { is_expected.to eq(cool_off_time) } - end - - context "when not integer" do - let(:cool_off_time) { 42.2 } - - it { is_expected.to eq(42) } - end - end -end