sender-core 0.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +120 -0
- data/Rakefile +42 -0
- data/lib/sender/core/capabilities.rb +22 -0
- data/lib/sender/core/circuit_breaker.rb +119 -0
- data/lib/sender/core/delivery.rb +97 -0
- data/lib/sender/core/delivery_event.rb +55 -0
- data/lib/sender/core/election.rb +63 -0
- data/lib/sender/core/errors.rb +71 -0
- data/lib/sender/core/health.rb +84 -0
- data/lib/sender/core/http.rb +162 -0
- data/lib/sender/core/message.rb +53 -0
- data/lib/sender/core/observability.rb +49 -0
- data/lib/sender/core/provider.rb +37 -0
- data/lib/sender/core/provider_configuration.rb +68 -0
- data/lib/sender/core/provider_registry.rb +122 -0
- data/lib/sender/core/router.rb +149 -0
- data/lib/sender/core/state_store.rb +49 -0
- data/lib/sender/core/version.rb +8 -0
- data/lib/sender/core.rb +29 -0
- data/sig/sender/core.rbs +78 -0
- metadata +72 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Thread-safe in-memory health history for one provider.
|
|
6
|
+
class Health
|
|
7
|
+
# @param observation_window [Numeric] period for retaining observations
|
|
8
|
+
# @param clock [#call] monotonic clock returning seconds
|
|
9
|
+
def initialize(observation_window: CircuitBreaker::DEFAULT_OBSERVATION_WINDOW,
|
|
10
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
11
|
+
unless observation_window.is_a?(Numeric) && observation_window.positive?
|
|
12
|
+
raise ArgumentError, "observation_window must be a positive number"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
@observation_window = observation_window
|
|
16
|
+
@clock = clock
|
|
17
|
+
@successes = []
|
|
18
|
+
@failures = []
|
|
19
|
+
@consecutive_failures = 0
|
|
20
|
+
@circuit_state = :closed
|
|
21
|
+
@lock = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Record a successful provider attempt.
|
|
25
|
+
# @param latency [Numeric] elapsed request time in seconds
|
|
26
|
+
# @param circuit_state [Symbol] circuit state after the attempt
|
|
27
|
+
def record_success(latency:, circuit_state: :closed)
|
|
28
|
+
@lock.synchronize do
|
|
29
|
+
prune
|
|
30
|
+
@successes << [@clock.call, validate_latency(latency)]
|
|
31
|
+
@consecutive_failures = 0
|
|
32
|
+
@circuit_state = circuit_state.to_sym
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Record a failed provider attempt.
|
|
37
|
+
# @param latency [Numeric] elapsed request time in seconds
|
|
38
|
+
# @param error [Errors::Base] normalized error
|
|
39
|
+
# @param circuit_state [Symbol] circuit state after the attempt
|
|
40
|
+
def record_failure(latency:, error:, circuit_state: :closed)
|
|
41
|
+
@lock.synchronize do
|
|
42
|
+
prune
|
|
43
|
+
@failures << [@clock.call, validate_latency(latency), error.category]
|
|
44
|
+
@consecutive_failures += 1
|
|
45
|
+
@circuit_state = circuit_state.to_sym
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Hash] immutable health snapshot
|
|
50
|
+
def snapshot
|
|
51
|
+
@lock.synchronize do
|
|
52
|
+
prune
|
|
53
|
+
latencies = (@successes + @failures).map { |event| event[1] }
|
|
54
|
+
snapshot_values(latencies).freeze
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def prune
|
|
61
|
+
cutoff = @clock.call - @observation_window
|
|
62
|
+
@successes.reject! { |event| event[0] < cutoff }
|
|
63
|
+
@failures.reject! { |event| event[0] < cutoff }
|
|
64
|
+
@consecutive_failures = 0 if @failures.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def snapshot_values(latencies)
|
|
68
|
+
{
|
|
69
|
+
recent_successes: @successes.length,
|
|
70
|
+
recent_failures: @failures.length,
|
|
71
|
+
consecutive_failures: @consecutive_failures,
|
|
72
|
+
average_latency: latencies.empty? ? nil : latencies.sum / latencies.length.to_f,
|
|
73
|
+
circuit_state: @circuit_state
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_latency(value)
|
|
78
|
+
return value if value.is_a?(Numeric) && value >= 0
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "latency must be a non-negative number"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Sender
|
|
9
|
+
module Core
|
|
10
|
+
# Internal HTTP transport used by provider adapters.
|
|
11
|
+
module HTTP
|
|
12
|
+
# Normalized HTTP response returned by the transport.
|
|
13
|
+
class Response
|
|
14
|
+
attr_reader :status, :headers, :body
|
|
15
|
+
|
|
16
|
+
# @param status [Integer] HTTP status code
|
|
17
|
+
# @param headers [Hash] response headers
|
|
18
|
+
# @param body [String] response body
|
|
19
|
+
def initialize(status:, headers: {}, body: "")
|
|
20
|
+
raise ArgumentError, "status must be an Integer" unless status.is_a?(Integer)
|
|
21
|
+
raise ArgumentError, "headers must be a Hash" unless headers.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
@status = status
|
|
24
|
+
@headers = headers.transform_keys(&:to_s).freeze
|
|
25
|
+
@body = body.to_s.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] whether the response has a 2xx status
|
|
30
|
+
def successful?
|
|
31
|
+
status.between?(200, 299)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Standard-library Net::HTTP implementation of the transport boundary.
|
|
36
|
+
class NetHttpTransport
|
|
37
|
+
# @param http_factory [#call] factory returning a configured HTTP client
|
|
38
|
+
def initialize(http_factory: ->(uri) { Net::HTTP.new(uri.host, uri.port) })
|
|
39
|
+
@http_factory = http_factory
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Response] normalized HTTP response
|
|
43
|
+
def call(method:, url:, headers:, body:, open_timeout:, read_timeout:, write_timeout:)
|
|
44
|
+
uri = URI.parse(url)
|
|
45
|
+
http = @http_factory.call(uri)
|
|
46
|
+
http.use_ssl = true if uri.scheme == "https"
|
|
47
|
+
http.open_timeout = open_timeout
|
|
48
|
+
http.read_timeout = read_timeout
|
|
49
|
+
http.write_timeout = write_timeout if http.respond_to?(:write_timeout=)
|
|
50
|
+
|
|
51
|
+
request = Net::HTTPGenericRequest.new(
|
|
52
|
+
method.to_s.upcase,
|
|
53
|
+
!body.nil?,
|
|
54
|
+
true,
|
|
55
|
+
uri.request_uri,
|
|
56
|
+
headers
|
|
57
|
+
)
|
|
58
|
+
request.body = body if body
|
|
59
|
+
response = http.start { http.request(request) }
|
|
60
|
+
Response.new(status: response.code.to_i, headers: response.each_header.to_h, body: response.body)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Executes HTTP requests and maps transport details into domain values.
|
|
65
|
+
class Client
|
|
66
|
+
# Default connection timeout in seconds.
|
|
67
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
68
|
+
# Default response read timeout in seconds.
|
|
69
|
+
DEFAULT_READ_TIMEOUT = 10
|
|
70
|
+
# Default request write timeout in seconds.
|
|
71
|
+
DEFAULT_WRITE_TIMEOUT = 10
|
|
72
|
+
|
|
73
|
+
# @param transport [#call, nil] injectable transport implementation
|
|
74
|
+
# @param open_timeout [Numeric] connection timeout in seconds
|
|
75
|
+
# @param read_timeout [Numeric] response read timeout in seconds
|
|
76
|
+
# @param write_timeout [Numeric] request write timeout in seconds
|
|
77
|
+
# @param total_timeout [Numeric, nil] complete request timeout in seconds
|
|
78
|
+
def initialize(transport: nil, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT,
|
|
79
|
+
write_timeout: DEFAULT_WRITE_TIMEOUT, total_timeout: nil)
|
|
80
|
+
@transport = transport || NetHttpTransport.new
|
|
81
|
+
@open_timeout = open_timeout
|
|
82
|
+
@read_timeout = read_timeout
|
|
83
|
+
@write_timeout = write_timeout
|
|
84
|
+
@total_timeout = total_timeout
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param method [Symbol, String] HTTP method
|
|
88
|
+
# @param url [String] absolute request URL
|
|
89
|
+
# @param headers [Hash] request headers
|
|
90
|
+
# @param body [String, Hash, nil] request body
|
|
91
|
+
# @return [Response] normalized response
|
|
92
|
+
def request(method, url, headers: {}, body: nil)
|
|
93
|
+
request_headers = normalize_headers(headers)
|
|
94
|
+
serialized_body, request_headers = serialize_body(body, request_headers)
|
|
95
|
+
result = with_total_timeout do
|
|
96
|
+
@transport.call(
|
|
97
|
+
method: method,
|
|
98
|
+
url: url,
|
|
99
|
+
headers: request_headers,
|
|
100
|
+
body: serialized_body,
|
|
101
|
+
open_timeout: @open_timeout,
|
|
102
|
+
read_timeout: @read_timeout,
|
|
103
|
+
write_timeout: @write_timeout
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
normalize_response(result)
|
|
107
|
+
rescue ::Timeout::Error
|
|
108
|
+
raise Errors::Timeout, "HTTP request timed out"
|
|
109
|
+
rescue SocketError, SystemCallError, EOFError
|
|
110
|
+
raise Errors::Network, "HTTP request failed"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param headers [Hash] request headers
|
|
114
|
+
# @return [Hash] headers safe for diagnostics
|
|
115
|
+
def redacted_headers(headers)
|
|
116
|
+
redacted = normalize_headers(headers)
|
|
117
|
+
redacted.each_key do |key|
|
|
118
|
+
redacted[key] = "[REDACTED]" if sensitive_header?(key)
|
|
119
|
+
end
|
|
120
|
+
redacted.freeze
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def normalize_headers(headers)
|
|
126
|
+
raise ArgumentError, "headers must be a Hash" unless headers.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
headers.to_h { |key, value| [key.to_s, value.to_s] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def serialize_body(body, headers)
|
|
132
|
+
return [nil, headers] if body.nil?
|
|
133
|
+
return [body, headers] if body.is_a?(String)
|
|
134
|
+
return [JSON.generate(body), headers.merge("Content-Type" => "application/json")] if body.is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
raise ArgumentError, "body must be a String, Hash, or nil"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def with_total_timeout(&)
|
|
140
|
+
return yield unless @total_timeout
|
|
141
|
+
|
|
142
|
+
::Timeout.timeout(@total_timeout, &)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def sensitive_header?(key)
|
|
146
|
+
key = key.downcase
|
|
147
|
+
key.include?("authorization") || key.include?("api-key") || key.include?("token")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def normalize_response(response)
|
|
151
|
+
return response if response.is_a?(Response)
|
|
152
|
+
|
|
153
|
+
unless response.respond_to?(:status) && response.respond_to?(:headers) && response.respond_to?(:body)
|
|
154
|
+
raise ArgumentError, "transport must return an HTTP::Response"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Response.new(status: response.status, headers: response.headers, body: response.body)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable provider-neutral outbound message.
|
|
6
|
+
class Message
|
|
7
|
+
# @return [String] normalized recipient address
|
|
8
|
+
attr_reader :to
|
|
9
|
+
# @return [String] message body
|
|
10
|
+
attr_reader :body
|
|
11
|
+
# @return [Hash] application metadata
|
|
12
|
+
attr_reader :metadata
|
|
13
|
+
# @return [Array<Symbol>] required provider capabilities
|
|
14
|
+
attr_reader :requirements
|
|
15
|
+
|
|
16
|
+
# @param to [String] recipient address
|
|
17
|
+
# @param body [String] message body
|
|
18
|
+
# @param metadata [Hash] optional application metadata
|
|
19
|
+
# @param requirements [Array<Symbol>] required provider capabilities
|
|
20
|
+
def initialize(to:, body:, metadata: {}, requirements: [])
|
|
21
|
+
@to = normalize_recipient(to)
|
|
22
|
+
@body = validate_body(body)
|
|
23
|
+
@metadata = normalize_metadata(metadata)
|
|
24
|
+
@requirements = Capabilities.normalize(requirements)
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def normalize_recipient(value)
|
|
31
|
+
raise ArgumentError, "to must be a String" unless value.is_a?(String)
|
|
32
|
+
|
|
33
|
+
recipient = value.strip
|
|
34
|
+
raise ArgumentError, "to must not be empty" if recipient.empty?
|
|
35
|
+
|
|
36
|
+
recipient
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_body(value)
|
|
40
|
+
raise ArgumentError, "body must be a String" unless value.is_a?(String)
|
|
41
|
+
raise ArgumentError, "body must not be empty" if value.strip.empty?
|
|
42
|
+
|
|
43
|
+
value.dup.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def normalize_metadata(value)
|
|
47
|
+
raise ArgumentError, "metadata must be a Hash" unless value.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
value.dup.freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Namespace for dependency-free runtime event sinks.
|
|
6
|
+
module Observability
|
|
7
|
+
# Thread-safe in-memory event sink with optional subscribers.
|
|
8
|
+
class Memory
|
|
9
|
+
def initialize
|
|
10
|
+
@events = []
|
|
11
|
+
@subscribers = []
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Record an event and notify subscribers.
|
|
16
|
+
# @param event [Hash] structured sender event
|
|
17
|
+
# @return [Hash] frozen event
|
|
18
|
+
def emit(event)
|
|
19
|
+
normalized = event.dup.freeze
|
|
20
|
+
@lock.synchronize do
|
|
21
|
+
@events << normalized
|
|
22
|
+
@subscribers.each { |subscriber| subscriber.call(normalized) }
|
|
23
|
+
end
|
|
24
|
+
normalized
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Subscribe a callable to future events.
|
|
28
|
+
# @param subscriber [#call] event subscriber
|
|
29
|
+
def subscribe(subscriber = nil, &block)
|
|
30
|
+
callback = subscriber || block
|
|
31
|
+
raise ArgumentError, "subscriber must respond to call" unless callback.respond_to?(:call)
|
|
32
|
+
|
|
33
|
+
@lock.synchronize { @subscribers << callback }
|
|
34
|
+
callback
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Hash>] immutable event history
|
|
38
|
+
def events
|
|
39
|
+
@lock.synchronize { @events.dup.freeze }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Clear recorded events.
|
|
43
|
+
def clear
|
|
44
|
+
@lock.synchronize { @events.clear }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Base contract implemented by provider adapters.
|
|
6
|
+
class Provider
|
|
7
|
+
# @return [ProviderConfiguration] provider configuration
|
|
8
|
+
attr_reader :configuration
|
|
9
|
+
|
|
10
|
+
# @param configuration [ProviderConfiguration] provider configuration
|
|
11
|
+
def initialize(configuration:)
|
|
12
|
+
unless configuration.is_a?(ProviderConfiguration)
|
|
13
|
+
raise ArgumentError, "configuration must be a ProviderConfiguration"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@configuration = configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Symbol] provider name
|
|
20
|
+
def name
|
|
21
|
+
configuration.name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<Symbol>] provider capabilities
|
|
25
|
+
def capabilities
|
|
26
|
+
configuration.capabilities
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deliver a normalized message through the provider.
|
|
30
|
+
#
|
|
31
|
+
# @raise [NotImplementedError] when called on the base contract
|
|
32
|
+
def deliver(_message)
|
|
33
|
+
raise NotImplementedError, "provider adapters must implement #deliver"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Immutable configuration shared by provider adapters.
|
|
6
|
+
class ProviderConfiguration
|
|
7
|
+
attr_reader :name, :settings, :enabled, :priority, :capabilities
|
|
8
|
+
|
|
9
|
+
# @param name [Symbol, String] provider name
|
|
10
|
+
# @param settings [Hash] provider-specific settings
|
|
11
|
+
# @param enabled [Boolean] whether the provider may be used
|
|
12
|
+
# @param priority [Integer] static provider preference
|
|
13
|
+
# @param capabilities [Array<Symbol>] advertised capabilities
|
|
14
|
+
def initialize(name:, settings: {}, enabled: true, priority: 0, capabilities: [])
|
|
15
|
+
@name = normalize_name(name)
|
|
16
|
+
@settings = normalize_settings(settings)
|
|
17
|
+
@enabled = validate_enabled(enabled)
|
|
18
|
+
@priority = validate_priority(priority)
|
|
19
|
+
@capabilities = normalize_capabilities(capabilities)
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param key [Symbol, String] setting name
|
|
24
|
+
# @return [Object, nil] configured setting
|
|
25
|
+
def [](key)
|
|
26
|
+
settings[key] || settings[key.to_sym]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Boolean] whether this configuration has been enabled
|
|
30
|
+
def enabled?
|
|
31
|
+
enabled
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize_name(value)
|
|
37
|
+
name = value.to_sym
|
|
38
|
+
raise ArgumentError, "name must not be empty" if name.empty?
|
|
39
|
+
|
|
40
|
+
name
|
|
41
|
+
rescue NoMethodError
|
|
42
|
+
raise ArgumentError, "name must be symbolizable"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_settings(value)
|
|
46
|
+
raise ArgumentError, "settings must be a Hash" unless value.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
value.dup.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_enabled(value)
|
|
52
|
+
return value if [true, false].include?(value)
|
|
53
|
+
|
|
54
|
+
raise ArgumentError, "enabled must be true or false"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_priority(value)
|
|
58
|
+
return value if value.is_a?(Integer)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "priority must be an Integer"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_capabilities(value)
|
|
64
|
+
Capabilities.normalize(value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sender
|
|
4
|
+
module Core
|
|
5
|
+
# Catalogs, configures, and lazily loads provider adapters.
|
|
6
|
+
class ProviderRegistry
|
|
7
|
+
# Internal metadata for a registered provider.
|
|
8
|
+
Entry = Struct.new(:name, :loader, :capabilities, keyword_init: true) do
|
|
9
|
+
def load
|
|
10
|
+
adapter = loader.call
|
|
11
|
+
raise Errors::ConfigurationError, "provider #{name} loader did not return a class" unless adapter.is_a?(Class)
|
|
12
|
+
|
|
13
|
+
adapter
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Empty by default; channel gems register their provider adapters.
|
|
18
|
+
DEFAULT_PROVIDERS = {}.freeze
|
|
19
|
+
|
|
20
|
+
# @param catalog [Hash] provider names mapped to adapter paths
|
|
21
|
+
# @param default_capabilities [Array<Symbol>] catalog entry capabilities
|
|
22
|
+
def initialize(catalog: DEFAULT_PROVIDERS, default_capabilities: [])
|
|
23
|
+
@entries = {}
|
|
24
|
+
@configurations = {}
|
|
25
|
+
catalog.each do |name, path|
|
|
26
|
+
register(name, loader: lazy_loader(path), capabilities: default_capabilities)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<Symbol>] providers known to the registry
|
|
31
|
+
def supported
|
|
32
|
+
@entries.keys.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<Symbol>] configured providers
|
|
36
|
+
def configured
|
|
37
|
+
@configurations.keys.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register a provider without loading its adapter.
|
|
41
|
+
# @param name [Symbol, String] provider name
|
|
42
|
+
# @param loader [#call] callable returning the adapter class
|
|
43
|
+
# @param capabilities [Array<Symbol>] advertised capabilities
|
|
44
|
+
def register(name, loader:, capabilities: [:sms])
|
|
45
|
+
provider_name = normalize_name(name)
|
|
46
|
+
raise ArgumentError, "loader must respond to call" unless loader.respond_to?(:call)
|
|
47
|
+
raise ArgumentError, "provider already registered: #{provider_name}" if @entries.key?(provider_name)
|
|
48
|
+
|
|
49
|
+
@entries[provider_name] = Entry.new(
|
|
50
|
+
name: provider_name,
|
|
51
|
+
loader: loader,
|
|
52
|
+
capabilities: normalize_capabilities(capabilities)
|
|
53
|
+
).freeze
|
|
54
|
+
provider_name
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Configure a registered provider without loading its adapter.
|
|
58
|
+
# @return [ProviderConfiguration] the stored configuration
|
|
59
|
+
def configure(name, settings: {}, enabled: true, priority: 0, capabilities: nil)
|
|
60
|
+
entry = fetch_entry(name)
|
|
61
|
+
configuration = ProviderConfiguration.new(
|
|
62
|
+
name: entry.name,
|
|
63
|
+
settings: settings,
|
|
64
|
+
enabled: enabled,
|
|
65
|
+
priority: priority,
|
|
66
|
+
capabilities: capabilities || entry.capabilities
|
|
67
|
+
)
|
|
68
|
+
@configurations[entry.name] = configuration
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Instantiate a configured provider, loading its adapter on demand.
|
|
72
|
+
# @return [Provider] configured provider adapter
|
|
73
|
+
def provider(name)
|
|
74
|
+
entry = fetch_entry(name)
|
|
75
|
+
configuration = @configurations.fetch(entry.name) do
|
|
76
|
+
raise Errors::ConfigurationError, "provider is not configured: #{entry.name}"
|
|
77
|
+
end
|
|
78
|
+
entry.load.new(configuration: configuration)
|
|
79
|
+
rescue LoadError => e
|
|
80
|
+
raise Errors::ConfigurationError, "could not load provider #{entry.name}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Array<Provider>] instantiated configured providers
|
|
84
|
+
def providers
|
|
85
|
+
configured.map { |name| provider(name) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def fetch_entry(name)
|
|
91
|
+
@entries.fetch(normalize_name(name)) do
|
|
92
|
+
raise Errors::ConfigurationError, "unknown provider: #{name.inspect}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def lazy_loader(path)
|
|
97
|
+
lambda do
|
|
98
|
+
require path
|
|
99
|
+
constant_name = path.split("/").last.split("_").map(&:capitalize).join
|
|
100
|
+
Sender::Core.const_get(:Providers).const_get(constant_name)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_name(value)
|
|
105
|
+
name = value.to_sym
|
|
106
|
+
raise ArgumentError, "provider name must not be empty" if name.empty?
|
|
107
|
+
|
|
108
|
+
name
|
|
109
|
+
rescue NoMethodError
|
|
110
|
+
raise ArgumentError, "provider name must be symbolizable"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def normalize_capabilities(value)
|
|
114
|
+
raise ArgumentError, "capabilities must be an Array" unless value.is_a?(Array)
|
|
115
|
+
|
|
116
|
+
value.map(&:to_sym).uniq.freeze
|
|
117
|
+
rescue NoMethodError
|
|
118
|
+
raise ArgumentError, "capabilities must contain symbolizable values"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|