stoplight 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/stoplight/admin/actions/remove.rb +23 -0
  4. data/lib/stoplight/admin/dependencies.rb +6 -1
  5. data/lib/stoplight/admin/helpers.rb +10 -5
  6. data/lib/stoplight/admin/lights_repository.rb +26 -14
  7. data/lib/stoplight/admin/views/_card.erb +13 -1
  8. data/lib/stoplight/admin.rb +9 -0
  9. data/lib/stoplight/common/deprecations.rb +11 -0
  10. data/lib/stoplight/domain/config.rb +5 -1
  11. data/lib/stoplight/domain/data_store.rb +58 -6
  12. data/lib/stoplight/domain/failure.rb +2 -0
  13. data/lib/stoplight/domain/light/configuration_builder_interface.rb +120 -16
  14. data/lib/stoplight/domain/light.rb +34 -24
  15. data/lib/stoplight/domain/metrics.rb +64 -0
  16. data/lib/stoplight/domain/recovery_lock_token.rb +15 -0
  17. data/lib/stoplight/domain/{metadata.rb → state_snapshot.rb} +29 -37
  18. data/lib/stoplight/domain/storage/metrics.rb +42 -0
  19. data/lib/stoplight/domain/storage/recovery_lock.rb +56 -0
  20. data/lib/stoplight/domain/storage/state.rb +87 -0
  21. data/lib/stoplight/domain/strategies/green_run_strategy.rb +2 -2
  22. data/lib/stoplight/domain/strategies/red_run_strategy.rb +3 -3
  23. data/lib/stoplight/domain/strategies/run_strategy.rb +2 -7
  24. data/lib/stoplight/domain/strategies/yellow_run_strategy.rb +63 -36
  25. data/lib/stoplight/domain/tracker/base.rb +0 -29
  26. data/lib/stoplight/domain/tracker/recovery_probe.rb +26 -22
  27. data/lib/stoplight/domain/tracker/request.rb +26 -21
  28. data/lib/stoplight/domain/traffic_control/base.rb +5 -5
  29. data/lib/stoplight/domain/traffic_control/consecutive_errors.rb +3 -7
  30. data/lib/stoplight/domain/traffic_control/error_rate.rb +3 -3
  31. data/lib/stoplight/domain/traffic_recovery/base.rb +5 -5
  32. data/lib/stoplight/domain/traffic_recovery/consecutive_successes.rb +4 -8
  33. data/lib/stoplight/domain/traffic_recovery.rb +0 -1
  34. data/lib/stoplight/infrastructure/data_store/fail_safe.rb +164 -0
  35. data/lib/stoplight/infrastructure/data_store/memory/metrics.rb +27 -0
  36. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_store.rb +54 -0
  37. data/lib/stoplight/infrastructure/data_store/memory/recovery_lock_token.rb +20 -0
  38. data/lib/stoplight/infrastructure/data_store/memory/state.rb +21 -0
  39. data/lib/stoplight/infrastructure/data_store/memory.rb +163 -132
  40. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/get_metrics.lua +26 -0
  41. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_failure.lua +27 -0
  42. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/record_recovery_probe_success.lua +23 -0
  43. data/lib/stoplight/infrastructure/data_store/redis/lua_scripts/release_lock.lua +6 -0
  44. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_store.rb +73 -0
  45. data/lib/stoplight/infrastructure/data_store/redis/recovery_lock_token.rb +35 -0
  46. data/lib/stoplight/infrastructure/data_store/redis/scripting.rb +71 -0
  47. data/lib/stoplight/infrastructure/data_store/redis.rb +211 -165
  48. data/lib/stoplight/infrastructure/notifier/fail_safe.rb +62 -0
  49. data/lib/stoplight/infrastructure/storage/compatibility_metrics.rb +48 -0
  50. data/lib/stoplight/infrastructure/storage/compatibility_recovery_lock.rb +36 -0
  51. data/lib/stoplight/infrastructure/storage/compatibility_recovery_metrics.rb +55 -0
  52. data/lib/stoplight/infrastructure/storage/compatibility_state.rb +55 -0
  53. data/lib/stoplight/version.rb +1 -1
  54. data/lib/stoplight/wiring/data_store/base.rb +11 -0
  55. data/lib/stoplight/wiring/data_store/memory.rb +10 -0
  56. data/lib/stoplight/wiring/data_store/redis.rb +25 -0
  57. data/lib/stoplight/wiring/default.rb +1 -1
  58. data/lib/stoplight/wiring/default_configuration.rb +1 -1
  59. data/lib/stoplight/wiring/default_factory_builder.rb +1 -1
  60. data/lib/stoplight/wiring/light_builder.rb +185 -0
  61. data/lib/stoplight/wiring/light_factory/compatibility_validator.rb +55 -0
  62. data/lib/stoplight/wiring/light_factory/config_normalizer.rb +71 -0
  63. data/lib/stoplight/wiring/light_factory/configuration_pipeline.rb +72 -0
  64. data/lib/stoplight/wiring/light_factory/traffic_control_dsl.rb +26 -0
  65. data/lib/stoplight/wiring/light_factory/traffic_recovery_dsl.rb +21 -0
  66. data/lib/stoplight/wiring/light_factory.rb +45 -132
  67. data/lib/stoplight/wiring/notifier_factory.rb +26 -0
  68. data/lib/stoplight/wiring/public_api.rb +3 -2
  69. data/lib/stoplight.rb +18 -3
  70. metadata +55 -16
  71. data/lib/stoplight/infrastructure/data_store/redis/get_metadata.lua +0 -38
  72. data/lib/stoplight/infrastructure/data_store/redis/lua.rb +0 -25
  73. data/lib/stoplight/infrastructure/dependency_injection/container.rb +0 -249
  74. data/lib/stoplight/infrastructure/dependency_injection/unresolved_dependency_error.rb +0 -13
  75. data/lib/stoplight/wiring/container.rb +0 -80
  76. data/lib/stoplight/wiring/fail_safe_data_store.rb +0 -123
  77. data/lib/stoplight/wiring/fail_safe_notifier.rb +0 -79
  78. data/lib/stoplight/wiring/system_container.rb +0 -9
  79. data/lib/stoplight/wiring/system_light_factory.rb +0 -17
  80. /data/lib/stoplight/infrastructure/data_store/redis/{record_failure.lua → lua_scripts/record_failure.lua} +0 -0
  81. /data/lib/stoplight/infrastructure/data_store/redis/{record_success.lua → lua_scripts/record_success.lua} +0 -0
  82. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_green.lua → lua_scripts/transition_to_green.lua} +0 -0
  83. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_red.lua → lua_scripts/transition_to_red.lua} +0 -0
  84. /data/lib/stoplight/infrastructure/data_store/redis/{transition_to_yellow.lua → lua_scripts/transition_to_yellow.lua} +0 -0
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # Temporary adapter that bridges Domain::Storage::Metrics to existing DataStore.
7
+ #
8
+ # This compatibility layer allows the metrics abstraction to be introduced
9
+ # without breaking existing data store implementations. It delegates all
10
+ # operations to the data store's original methods.
11
+ #
12
+ # This class will be removed in a future versions once all data stores
13
+ # have native metrics implementations.
14
+ #
15
+ # @example Creating metrics for a circuit
16
+ # metrics = CompatibilityMetrics.new(
17
+ # data_store: redis_store,
18
+ # config: config
19
+ # )
20
+ # metrics.record_success
21
+ #
22
+ # @see Stoplight::Domain::Storage::Metrics
23
+ class CompatibilityMetrics < Domain::Storage::Metrics
24
+ private attr_reader :data_store
25
+ private attr_reader :config
26
+
27
+ # @param data_store [Stoplight::Domain::DataStore]
28
+ # @param config [Stoplight::Domain::Config]
29
+ def initialize(data_store:, config:)
30
+ @data_store = data_store
31
+ @config = config
32
+ end
33
+
34
+ def metrics_snapshot = data_store.get_metrics(config)
35
+
36
+ # @return [void]
37
+ def record_success = data_store.record_success(config)
38
+
39
+ # @param error [StandardError]
40
+ # @return [void]
41
+ def record_failure(error) = data_store.record_failure(config, error)
42
+
43
+ # @return [void]
44
+ def clear = data_store.clear_metrics(config)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # Temporary adapter that bridges +Domain::Storage::RecoveryLock+ to existing DataStore.
7
+ #
8
+ # This compatibility layer allows the recovery lock abstraction to be
9
+ # introduced without breaking existing data store implementations. It
10
+ # delegates all lock operations to the data store's original methods.
11
+ #
12
+ # This adapter will be removed in a future versions once all
13
+ # data stores have native recovery lock implementations.
14
+ #
15
+ # @see Stoplight::Domain::Storage::RecoveryLock
16
+ class CompatibilityRecoveryLock < Domain::Storage::RecoveryLock
17
+ private attr_reader :data_store
18
+ private attr_reader :config
19
+
20
+ # @param data_store [Stoplight::Domain::DataStore]
21
+ # @param config [Stoplight::Domain::Config]
22
+ def initialize(data_store:, config:)
23
+ @data_store = data_store
24
+ @config = config
25
+ end
26
+
27
+ # @return [Stoplight::Domain::RecoveryLockToken, nil]
28
+ def acquire_lock = data_store.acquire_recovery_lock(config)
29
+
30
+ # @param lock [Stoplight::Domain::LockToken]
31
+ # @return [void]
32
+ def release_lock(lock) = data_store.release_recovery_lock(lock)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # When a circuit is RED (open), Stoplight periodically sends "recovery probes"
7
+ # to test whether the protected service has recovered. These test requests have
8
+ # different semantics than normal requests and their metrics are tracked separately.
9
+ #
10
+ # Like +CompatibilityMetrics+, this adapter will be replaced with purpose-built
11
+ # recovery metrics implementations (e.g., +ConsecutiveSuccessMetrics+) once the
12
+ # metrics extraction is complete.
13
+ #
14
+ # @example Recovery probe flow
15
+ # # Circuit is RED, start probing
16
+ # recovery_metrics = CompatibilityRecoveryMetrics.new(
17
+ # data_store: redis_store,
18
+ # config: circuit_config
19
+ # )
20
+ #
21
+ # recovery_metrics.record_success
22
+ # recovery_metrics.metrics_snapshot # => 1 success, 0 failures
23
+ #
24
+ # @see Stoplight::Domain::Storage::Metrics
25
+ class CompatibilityRecoveryMetrics < Domain::Storage::Metrics
26
+ private attr_reader :data_store
27
+ private attr_reader :config
28
+
29
+ # @param data_store [Stoplight::Domain::DataStore]
30
+ # @param config [Stoplight::Domain::Config]
31
+ def initialize(data_store:, config:)
32
+ @data_store = data_store
33
+ @config = config
34
+ end
35
+
36
+ def metrics_snapshot = data_store.get_recovery_metrics(config)
37
+
38
+ # Tracks successful circuit breaker execution
39
+ #
40
+ # @return [void]
41
+ def record_success = data_store.record_recovery_probe_success(config)
42
+
43
+ # Tracks failed circuit breaker execution
44
+ #
45
+ # @param error [StandardError]
46
+ # @return [void]
47
+ def record_failure(error) = data_store.record_recovery_probe_failure(config, error)
48
+
49
+ # Clears metrics
50
+ # @return [void]
51
+ def clear = data_store.clear_recovery_metrics(config)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Infrastructure
5
+ module Storage
6
+ # Temporary adapter that bridges Domain::Storage::State to existing DataStore.
7
+ #
8
+ # This compatibility layer allows the state abstraction to be introduced
9
+ # without breaking existing data store implementations. It delegates all
10
+ # state operations to the data store's original methods.
11
+ #
12
+ # This adapter will be removed in a future versions once all
13
+ # data stores have native state storage implementations.
14
+ #
15
+ # @example Creating state storage for a circuit
16
+ # state = CompatibilityState.new(
17
+ # data_store: redis_store,
18
+ # config: circuit_config
19
+ # )
20
+ # state.set_state(State::LOCKED_RED)
21
+ # snapshot = state.state_snapshot
22
+ #
23
+ class CompatibilityState < Domain::Storage::State
24
+ # @!attribute data_store
25
+ # @return [Stoplight::Domain::DataStore]
26
+ private attr_reader :data_store
27
+
28
+ # @!attribute config
29
+ # @return [Stoplight::Domain::Config]
30
+ private attr_reader :config
31
+
32
+ # @param data_store [Stoplight::Domain::DataStore]
33
+ # @param config [Stoplight::Domain::Config]
34
+ def initialize(data_store:, config:)
35
+ @data_store = data_store
36
+ @config = config
37
+ end
38
+
39
+ # @return [Stoplight::Domain::StateSnapshot]
40
+ def state_snapshot = data_store.get_state_snapshot(config)
41
+
42
+ # @param state [String]
43
+ # @return [String]
44
+ def set_state(state) = data_store.set_state(config, state)
45
+
46
+ # @param color [String]
47
+ # @return [Boolean]
48
+ def transition_to_color(color) = data_store.transition_to_color(config, color)
49
+
50
+ # @return [void]
51
+ def clear = data_store.delete_light(config)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stoplight
4
- VERSION = Gem::Version.new("5.5.0")
4
+ VERSION = Gem::Version.new("5.7.0")
5
5
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ module DataStore
6
+ # @abstract
7
+ class Base
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ module DataStore
6
+ class Memory < Base
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ module DataStore
6
+ class Redis < Base
7
+ # @!attribute redis
8
+ # @return [::Redis, ConnectionPool<::Redis>]
9
+ attr_reader :redis
10
+
11
+ # @!attribute warn_on_clock_skew
12
+ # @return [Boolean]
13
+ attr_reader :warn_on_clock_skew
14
+
15
+ # @param redis [::Redis, ConnectionPool<::Redis>]
16
+ # @param warn_on_clock_skew [Boolean] (true) Whether to warn about clock skew between Redis and
17
+ # the application server
18
+ def initialize(redis, warn_on_clock_skew: true)
19
+ @warn_on_clock_skew = warn_on_clock_skew
20
+ @redis = redis
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -5,7 +5,7 @@ module Stoplight
5
5
  module Default
6
6
  COOL_OFF_TIME = 60.0
7
7
 
8
- DATA_STORE = Infrastructure::DataStore::Memory.new
8
+ DATA_STORE = Stoplight::DataStore::Memory.new
9
9
 
10
10
  ERROR_NOTIFIER = ->(error) { warn error }
11
11
 
@@ -37,7 +37,7 @@ module Stoplight
37
37
  attr_accessor :notifiers
38
38
 
39
39
  # @!attribute [rw] data_store
40
- # @return [Stoplight::Domain::DataStore] The default data store instance.
40
+ # @return [Stoplight::Wiring::DataStore::Base] The default data store instance.
41
41
  attr_accessor :data_store
42
42
 
43
43
  # @!attribute [w] traffic_control
@@ -18,7 +18,7 @@ module Stoplight
18
18
  # @return [Stoplight::Wiring::LightFactory]
19
19
  # @api private the method is used internally by Stoplight
20
20
  def build
21
- LightFactory.new(Wiring::Container).with(**configuration.to_h)
21
+ LightFactory.new(configuration.to_h)
22
22
  end
23
23
  end
24
24
  end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+
5
+ module Stoplight
6
+ module Wiring
7
+ # Constructs a fully-wired Light instance from validated configuration.
8
+ #
9
+ # LightBuilder is the final assembly step in the Light creation pipeline.
10
+ # It receives validated config and dependencies from ConfigurationPipeline
11
+ # and wires together all infrastructure components (data stores, trackers,
12
+ # strategies) needed for a functioning circuit breaker.
13
+ #
14
+ # LightBuilder maintains a global registry (MEMORY_REGISTRY) that ensures
15
+ # the same Memory data store config object always produces the same
16
+ # data store instance:
17
+ #
18
+ # data_store = Stoplight::DataStore::Memory.new
19
+ # light1 = Stoplight("foo", data_store: data_store)
20
+ # light2 = Stoplight("bar", data_store: data_store)
21
+ # # light1 and light2 share the same underlying memory store
22
+ #
23
+ # light3 = Stoplight("baz", data_store: Stoplight::DataStore::Memory.new)
24
+ # # light3 has its own independent store
25
+ #
26
+ # This singleton behavior is keyed by config object identity (object_id),
27
+ # not by value equality.
28
+ #
29
+ # @api private
30
+ class LightBuilder
31
+ FAILOVER_DATA_STORE_CONFIG = Stoplight::DataStore::Memory.new
32
+ private_constant :FAILOVER_DATA_STORE_CONFIG
33
+
34
+ MEMORY_REGISTRY = Concurrent::Map.new
35
+ private_constant :MEMORY_REGISTRY
36
+
37
+ # @!attribute data_store_config
38
+ # @return [Stoplight::DataStore::Bose]
39
+ private attr_reader :data_store_config
40
+
41
+ # @!attribute error_notifier
42
+ # @return [Proc]
43
+ private attr_reader :error_notifier
44
+
45
+ # @!attribute traffic_recovery
46
+ # @return [Stoplight::Domain::TrafficRecovery::Base]
47
+ private attr_reader :traffic_recovery
48
+
49
+ # @!attribute traffic_control
50
+ # @return [Stoplight::Domain::TrafficControl::Base]
51
+ private attr_reader :traffic_control
52
+
53
+ # @!attribute config
54
+ # @return [Stoplight::Domain::Config]
55
+ private attr_reader :config
56
+
57
+ # @!attribute factory
58
+ # @return [Stoplight::Domain::LightFactory]
59
+ private attr_reader :factory
60
+
61
+ def initialize(settings)
62
+ @notifiers = settings[:notifiers]
63
+ @data_store_config = settings[:data_store]
64
+ @error_notifier = settings[:error_notifier]
65
+ @traffic_recovery = settings[:traffic_recovery]
66
+ @traffic_control = settings[:traffic_control]
67
+ @config = settings[:config]
68
+ @factory = settings[:factory]
69
+ end
70
+
71
+ def build
72
+ Stoplight::Domain::Light.new(
73
+ config,
74
+ state_store:,
75
+ green_run_strategy:,
76
+ yellow_run_strategy:,
77
+ red_run_strategy:,
78
+ factory:
79
+ )
80
+ end
81
+
82
+ private def state_store = Stoplight::Infrastructure::Storage::CompatibilityState.new(config:, data_store:)
83
+
84
+ # @return [<Stoplight::Notifier::Base>]
85
+ private def notifiers
86
+ Array(@notifiers).map do |notifier|
87
+ Infrastructure::Notifier::FailSafe.new(notifier:, error_notifier:)
88
+ end
89
+ end
90
+
91
+ private def redis_recovery_lock_store
92
+ Infrastructure::DataStore::Redis::RecoveryLockStore.new(
93
+ redis: data_store_config.redis,
94
+ lock_timeout: config.cool_off_time_in_milliseconds,
95
+ scripting:
96
+ )
97
+ end
98
+
99
+ private def scripting = Infrastructure::DataStore::Redis::Scripting.new(redis: data_store_config.redis)
100
+
101
+ private def memory_recovery_lock_store
102
+ Infrastructure::DataStore::Memory::RecoveryLockStore.new
103
+ end
104
+
105
+ private def failover_data_store
106
+ create_data_store(FAILOVER_DATA_STORE_CONFIG)
107
+ end
108
+
109
+ private def data_store
110
+ create_data_store(data_store_config)
111
+ end
112
+
113
+ private def metrics_store
114
+ Stoplight::Infrastructure::Storage::CompatibilityMetrics.new(config:, data_store:)
115
+ end
116
+
117
+ private def recovery_lock_store
118
+ Stoplight::Infrastructure::Storage::CompatibilityRecoveryLock.new(config:, data_store:)
119
+ end
120
+
121
+ private def request_tracker
122
+ Domain::Tracker::Request.new(traffic_control:, notifiers:, config:, metrics_store:, state_store:)
123
+ end
124
+
125
+ private def recovery_probe_tracker
126
+ Domain::Tracker::RecoveryProbe.new(
127
+ traffic_recovery:,
128
+ notifiers:,
129
+ config:,
130
+ metrics_store: recovery_metrics_store,
131
+ state_store:
132
+ )
133
+ end
134
+
135
+ private def recovery_metrics_store
136
+ Stoplight::Infrastructure::Storage::CompatibilityRecoveryMetrics.new(config:, data_store:)
137
+ end
138
+
139
+ private def green_run_strategy
140
+ Domain::Strategies::GreenRunStrategy.new(config:, request_tracker:)
141
+ end
142
+
143
+ private def yellow_run_strategy
144
+ Domain::Strategies::YellowRunStrategy.new(
145
+ config:,
146
+ notifiers:,
147
+ request_tracker: recovery_probe_tracker,
148
+ red_run_strategy:,
149
+ state_store:,
150
+ metrics_store:,
151
+ recovery_lock_store:
152
+ )
153
+ end
154
+
155
+ private def red_run_strategy
156
+ Domain::Strategies::RedRunStrategy.new(config:)
157
+ end
158
+
159
+ private def create_data_store(data_store_config)
160
+ case data_store_config
161
+ in Stoplight::DataStore::Memory
162
+ memory_registry.compute_if_absent(data_store_config.object_id) do
163
+ Infrastructure::DataStore::Memory.new(
164
+ recovery_lock_store: memory_recovery_lock_store
165
+ )
166
+ end
167
+ in Stoplight::DataStore::Redis
168
+ Infrastructure::DataStore::FailSafe.new(
169
+ data_store: Stoplight::Infrastructure::DataStore::Redis.new(
170
+ redis: data_store_config.redis,
171
+ warn_on_clock_skew: data_store_config.warn_on_clock_skew,
172
+ recovery_lock_store: redis_recovery_lock_store,
173
+ scripting:
174
+ ),
175
+ error_notifier:,
176
+ failover_data_store:,
177
+ circuit_breaker: Stoplight.system_light("data_store:fail_safe:redis")
178
+ )
179
+ end
180
+ end
181
+
182
+ private def memory_registry = MEMORY_REGISTRY
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ # Validates that traffic control and recovery strategies are
7
+ # compatible with the provided configuration.
8
+ #
9
+ # Different strategies have different configuration requirements:
10
+ # - ErrorRate requires window_size and threshold ∈ [0,1]
11
+ # - ConsecutiveErrors requires threshold > 0
12
+ # - ConsecutiveSuccesses requires recovery_threshold > 0
13
+ #
14
+ # @raise [Stoplight::Error::ConfigurationError] if incompatible
15
+ class CompatibilityValidator
16
+ private attr_reader :dependencies
17
+ private attr_reader :config
18
+
19
+ class << self
20
+ def call(config, dependencies) = new(config, dependencies).call
21
+ end
22
+
23
+ def initialize(config, dependencies)
24
+ @config = config
25
+ @dependencies = dependencies
26
+ end
27
+
28
+ def call
29
+ validate_traffic_control!
30
+ validate_traffic_recovery!
31
+ end
32
+
33
+ private def validate_traffic_control!
34
+ traffic_control = dependencies.fetch(:traffic_control)
35
+ traffic_control.check_compatibility(config).then do |compatibility_result|
36
+ if compatibility_result.incompatible?
37
+ raise Domain::Error::ConfigurationError,
38
+ "#{traffic_control.class.name} incompatible with config: #{compatibility_result.error_messages}"
39
+ end
40
+ end
41
+ end
42
+
43
+ def validate_traffic_recovery!
44
+ traffic_recovery = dependencies.fetch(:traffic_recovery)
45
+ traffic_recovery.check_compatibility(config).then do |compatibility_result|
46
+ if compatibility_result.incompatible?
47
+ raise Domain::Error::ConfigurationError,
48
+ "#{traffic_recovery.class.name} incompatible with config: #{compatibility_result.error_messages}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ # Normalizes user-provided configuration values into canonical forms.
7
+ #
8
+ # Handles common user convenience patterns:
9
+ # - Single error class → Array of error classes
10
+ # - Numeric cool_off_time → Integer
11
+ #
12
+ # @example
13
+ # config = Config.empty.with(
14
+ # tracked_errors: StandardError, # Single class
15
+ # cool_off_time: 30.5 # Float
16
+ # )
17
+ #
18
+ # normalized = ConfigNormalizer.call(config)
19
+ # normalized.tracked_errors #=> [StandardError] # Array
20
+ # normalized.cool_off_time #=> 30 # Integer
21
+ #
22
+ # @api private
23
+ class ConfigNormalizer
24
+ class << self
25
+ def call(config) = new(config).call
26
+ end
27
+
28
+ # @!attribute config
29
+ # @return [Stoplight::Domain::Config]
30
+ private attr_reader :config
31
+
32
+ # @param [Stoplight::Domain::Config]
33
+ def initialize(config)
34
+ @config = config
35
+ end
36
+
37
+ # @return [Stoplight::Domain::Config]
38
+ def call
39
+ config
40
+ .then { |c| normalize_tracked_errors(c) }
41
+ .then { |c| normalize_skipped_errors(c) }
42
+ .then { |c| normalize_cool_off_time(c) }
43
+ end
44
+
45
+ private def normalize_tracked_errors(config)
46
+ if config.tracked_errors.is_a?(Array)
47
+ config
48
+ else
49
+ config.with(tracked_errors: Array(config.tracked_errors))
50
+ end
51
+ end
52
+
53
+ private def normalize_skipped_errors(config)
54
+ if config.skipped_errors.is_a?(Array)
55
+ config
56
+ else
57
+ config.with(skipped_errors: Array(config.skipped_errors))
58
+ end
59
+ end
60
+
61
+ private def normalize_cool_off_time(config)
62
+ if config.cool_off_time.is_a?(Integer)
63
+ config
64
+ else
65
+ config.with(cool_off_time: config.cool_off_time.to_i)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ module Wiring
5
+ class LightFactory
6
+ # Orchestrates DSL interpretation, normalization, and validation.
7
+ #
8
+ # ConfigurationPipeline is the entry point for transforming raw user settings
9
+ # into validated domain objects. It coordinates three steps:
10
+ #
11
+ # 1. Normalization - Convert user-friendly values to canonical forms
12
+ # 2. DSL Interpretation - Transform symbols/hashes into strategy objects
13
+ # 3. Validation - Ensure strategies are compatible with configuration
14
+ #
15
+ # @api private
16
+ class ConfigurationPipeline
17
+ BASE_DEPENDENCIES = {
18
+ data_store: Default::DATA_STORE,
19
+ traffic_recovery: Default::TRAFFIC_RECOVERY,
20
+ traffic_control: Default::TRAFFIC_CONTROL,
21
+ notifiers: Default::NOTIFIERS,
22
+ error_notifier: Default::ERROR_NOTIFIER
23
+ }.freeze
24
+ private_constant :BASE_DEPENDENCIES
25
+
26
+ private attr_reader :dependency_settings
27
+ private attr_reader :config_settings
28
+
29
+ def self.process(config_settings, dependency_settings)
30
+ new(config_settings, dependency_settings).process
31
+ end
32
+
33
+ def initialize(config_settings, dependency_settings)
34
+ @config_settings = config_settings
35
+ @dependency_settings = dependency_settings
36
+ end
37
+
38
+ def process
39
+ config = build_config
40
+ dependencies = build_dependencies
41
+
42
+ CompatibilityValidator.call(config, dependencies)
43
+
44
+ [config, dependencies]
45
+ end
46
+
47
+ def build_config
48
+ base_config
49
+ .with(**config_settings)
50
+ .then { |cfg| ConfigNormalizer.call(cfg) }
51
+ end
52
+
53
+ def build_dependencies
54
+ base_dependencies
55
+ .merge(dependency_settings)
56
+ .then { |deps| interpret_dsl(deps) }
57
+ end
58
+
59
+ def interpret_dsl(dependencies)
60
+ dependencies.merge(
61
+ traffic_control: TrafficControlDsl.call(dependencies.fetch(:traffic_control)),
62
+ traffic_recovery: TrafficRecoveryDsl.call(dependencies.fetch(:traffic_recovery))
63
+ )
64
+ end
65
+
66
+ def base_config = Light::DefaultConfig
67
+
68
+ def base_dependencies = BASE_DEPENDENCIES
69
+ end
70
+ end
71
+ end
72
+ end