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.
@@ -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