unisender-ruby 0.1.1 → 0.2.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/CONTRIBUTING.md +10 -1
  4. data/README.md +55 -112
  5. data/docs/api.md +395 -210
  6. data/docs/execution.md +89 -0
  7. data/docs/import-recovery.md +41 -0
  8. data/docs/live-contracts.md +34 -0
  9. data/docs/migration-0.2.md +129 -0
  10. data/docs/rails.md +248 -0
  11. data/docs/releases.md +4 -4
  12. data/docs/testing.md +83 -0
  13. data/docs/usage.md +144 -0
  14. data/docs/webhooks.md +41 -0
  15. data/examples/campaign.rb +5 -6
  16. data/examples/campaign_lifecycle.rb +28 -0
  17. data/examples/check_connection.rb +1 -1
  18. data/examples/events.rb +1 -1
  19. data/examples/export.rb +20 -6
  20. data/examples/import.rb +3 -2
  21. data/examples/import_recovery.rb +70 -0
  22. data/examples/quickstart.rb +1 -1
  23. data/examples/subscribe.rb +2 -2
  24. data/lib/unisender/acceptance.rb +104 -0
  25. data/lib/unisender/attachment.rb +47 -0
  26. data/lib/unisender/batch_report.rb +115 -0
  27. data/lib/unisender/batch_summary.rb +87 -0
  28. data/lib/unisender/batches.rb +41 -12
  29. data/lib/unisender/campaign_workflow.rb +72 -0
  30. data/lib/unisender/client.rb +65 -33
  31. data/lib/unisender/client_configuration.rb +50 -0
  32. data/lib/unisender/client_execution.rb +43 -0
  33. data/lib/unisender/client_state.rb +31 -0
  34. data/lib/unisender/client_validation.rb +97 -0
  35. data/lib/unisender/configuration.rb +151 -0
  36. data/lib/unisender/contact_records.rb +251 -0
  37. data/lib/unisender/diagnostic_urls.rb +68 -0
  38. data/lib/unisender/diagnostics.rb +83 -0
  39. data/lib/unisender/download_target.rb +61 -0
  40. data/lib/unisender/download_transfer.rb +43 -0
  41. data/lib/unisender/downloader.rb +99 -0
  42. data/lib/unisender/error_diagnostics.rb +83 -0
  43. data/lib/unisender/errors.rb +125 -8
  44. data/lib/unisender/event_batches.rb +40 -0
  45. data/lib/unisender/event_payload.rb +50 -22
  46. data/lib/unisender/events.rb +21 -5
  47. data/lib/unisender/execution_context.rb +114 -0
  48. data/lib/unisender/form.rb +20 -12
  49. data/lib/unisender/helpers.rb +70 -10
  50. data/lib/unisender/hooks.rb +79 -0
  51. data/lib/unisender/import_cells.rb +62 -0
  52. data/lib/unisender/input_types.rb +30 -0
  53. data/lib/unisender/inputs.rb +262 -0
  54. data/lib/unisender/iteration.rb +15 -0
  55. data/lib/unisender/iterators.rb +60 -0
  56. data/lib/unisender/mail_inputs.rb +37 -0
  57. data/lib/unisender/named_inputs.rb +52 -0
  58. data/lib/unisender/observation.rb +30 -0
  59. data/lib/unisender/operation_contracts.rb +106 -0
  60. data/lib/unisender/operations/contacts_1.rb +4 -3
  61. data/lib/unisender/operations/contacts_2.rb +23 -4
  62. data/lib/unisender/operations/messages_1.rb +7 -4
  63. data/lib/unisender/operations/messages_2.rb +8 -3
  64. data/lib/unisender/operations/messages_3.rb +1 -1
  65. data/lib/unisender/operations/notes_1.rb +1 -1
  66. data/lib/unisender/operations/statistics_1.rb +3 -3
  67. data/lib/unisender/operations/templates_1.rb +2 -2
  68. data/lib/unisender/outcomes.rb +320 -0
  69. data/lib/unisender/page_response.rb +48 -0
  70. data/lib/unisender/paginated_operations.rb +43 -2
  71. data/lib/unisender/pagination.rb +53 -20
  72. data/lib/unisender/persistent_transport.rb +104 -0
  73. data/lib/unisender/rate_limiter.rb +72 -0
  74. data/lib/unisender/record.rb +210 -0
  75. data/lib/unisender/record_rows.rb +69 -0
  76. data/lib/unisender/records.rb +614 -0
  77. data/lib/unisender/request_execution.rb +108 -0
  78. data/lib/unisender/response.rb +13 -10
  79. data/lib/unisender/result_registry.rb +72 -0
  80. data/lib/unisender/results.rb +105 -0
  81. data/lib/unisender/retry_policy.rb +10 -16
  82. data/lib/unisender/serialization.rb +54 -0
  83. data/lib/unisender/statistic_records.rb +129 -0
  84. data/lib/unisender/task_response.rb +85 -0
  85. data/lib/unisender/task_waiter.rb +25 -11
  86. data/lib/unisender/testing/assertions.rb +48 -0
  87. data/lib/unisender/testing/fixtures.rb +24 -0
  88. data/lib/unisender/testing.rb +91 -0
  89. data/lib/unisender/transport.rb +17 -9
  90. data/lib/unisender/validation.rb +11 -9
  91. data/lib/unisender/version.rb +1 -1
  92. data/lib/unisender/webhooks/tokens.rb +38 -0
  93. data/lib/unisender/webhooks.rb +230 -0
  94. data/lib/unisender.rb +42 -0
  95. data/sig/acceptance.rbs +30 -0
  96. data/sig/attachment.rbs +15 -0
  97. data/sig/batch_summary.rbs +38 -0
  98. data/sig/campaign_workflow.rbs +20 -0
  99. data/sig/configuration.rbs +62 -0
  100. data/sig/connection_controls.rbs +55 -0
  101. data/sig/diagnostics.rbs +37 -0
  102. data/sig/domain_records.rbs +96 -0
  103. data/sig/downloads.rbs +53 -0
  104. data/sig/execution.rbs +52 -0
  105. data/sig/execution_context.rbs +46 -0
  106. data/sig/hooks.rbs +18 -0
  107. data/sig/inputs.rbs +84 -0
  108. data/sig/iterators.rbs +11 -0
  109. data/sig/mail_inputs.rbs +8 -0
  110. data/sig/manifest.yaml +11 -0
  111. data/sig/operation_contracts.rbs +28 -0
  112. data/sig/operations.rbs +73 -69
  113. data/sig/outcomes.rbs +90 -0
  114. data/sig/record.rbs +77 -0
  115. data/sig/records.rbs +182 -0
  116. data/sig/serialization.rbs +13 -0
  117. data/sig/tasks.rbs +26 -0
  118. data/sig/testing.rbs +54 -0
  119. data/sig/unisender.rbs +128 -39
  120. data/sig/webhooks.rbs +55 -0
  121. metadata +84 -3
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Shared decoding for error records inside successful envelopes.
5
+ module Outcomes
6
+ def self.records(value, path)
7
+ return [] if value.nil?
8
+ unless value.is_a?(Array)
9
+ raise InvalidResponseError.new("Expected result records", path:, expected_type: "Array",
10
+ actual_type: Diagnostics.type_name(value))
11
+ end
12
+
13
+ value.each_with_index.map { |item, index| record(item, "#{path}[#{index}]") }
14
+ end
15
+
16
+ def self.record(item, path)
17
+ return item if item.is_a?(Record)
18
+
19
+ raise InvalidResponseError.new("Expected result record", path:, expected_type: "Hash",
20
+ actual_type: Diagnostics.type_name(item))
21
+ end
22
+
23
+ def self.failures(value, path, overrides = {})
24
+ records(value, path).each_with_index.map { |item, index| failure(item, "#{path}[#{index}]", overrides) }
25
+ end
26
+
27
+ def self.failure(item, path, overrides)
28
+ Failure.new(item.to_h.merge(overrides))
29
+ rescue InvalidResponseError => e
30
+ raise e.prepend_path(path), cause: nil
31
+ end
32
+ end
33
+
34
+ # A record position and its upstream diagnostic, kept out of inspection output.
35
+ class Failure < Record
36
+ def index
37
+ self["index"].nil? ? nil : integer!("index")
38
+ end
39
+
40
+ def code
41
+ text("code")
42
+ end
43
+
44
+ def message
45
+ text("message")
46
+ end
47
+
48
+ def warning?
49
+ value = code
50
+ value.is_a?(String) && value.start_with?("w_")
51
+ end
52
+
53
+ def with_offset(offset)
54
+ position = index
55
+ return self unless position
56
+
57
+ Failure.new(to_h.merge("index" => position + offset))
58
+ end
59
+
60
+ protected
61
+
62
+ def normalize
63
+ @values["index"] = index if @values.key?("index")
64
+ code
65
+ message
66
+ end
67
+ end
68
+
69
+ # Import acceptance is distinct from recipient delivery.
70
+ class ImportResult < Record
71
+ OPTIONAL_COUNTERS = %w[inserted updated deleted new_emails].freeze
72
+
73
+ def inserted
74
+ integer("inserted")
75
+ end
76
+
77
+ def updated
78
+ integer("updated")
79
+ end
80
+
81
+ def deleted
82
+ integer("deleted")
83
+ end
84
+
85
+ def new_emails
86
+ integer("new_emails")
87
+ end
88
+
89
+ def invalid
90
+ integer!("invalid")
91
+ end
92
+
93
+ def total
94
+ self["total"].nil? ? nil : integer!("total")
95
+ end
96
+
97
+ def failures
98
+ Outcomes.failures(self["log"], "log").reject(&:warning?)
99
+ end
100
+
101
+ def row_warnings
102
+ Outcomes.failures(self["log"], "log").select(&:warning?)
103
+ end
104
+
105
+ def complete?
106
+ invalid.zero?
107
+ end
108
+
109
+ def uncertain?
110
+ invalid.positive? && total.nil?
111
+ end
112
+
113
+ def partial?
114
+ count = total
115
+ count.is_a?(Integer) && invalid.positive? && invalid < count
116
+ end
117
+
118
+ protected
119
+
120
+ def normalize
121
+ @values["invalid"] = invalid
122
+ @values["total"] = total if @values.key?("total")
123
+ OPTIONAL_COUNTERS.each { |name| @values[name] = integer(name) if @values.key?(name) }
124
+ failures
125
+ end
126
+ end
127
+
128
+ # Per-recipient acceptance from sendEmail with error_checking enabled.
129
+ class RecipientResult < Record
130
+ def id
131
+ self["id"].nil? ? nil : identifier!("id")
132
+ end
133
+
134
+ def index
135
+ self["index"].nil? ? nil : integer!("index")
136
+ end
137
+
138
+ def email
139
+ text("email")
140
+ end
141
+
142
+ def failures
143
+ Outcomes.failures(self["errors"], "errors", { "index" => index })
144
+ end
145
+
146
+ def accepted?
147
+ !id.nil? && failures.empty?
148
+ end
149
+
150
+ protected
151
+
152
+ def normalize
153
+ id
154
+ index
155
+ email
156
+ failures
157
+ end
158
+ end
159
+
160
+ # Handles both documented sendEmail result formats.
161
+ class SendResult < Record
162
+ def accepted_recipients
163
+ recipients.select(&:accepted?).freeze
164
+ end
165
+
166
+ def rejected_recipients
167
+ recipients.reject { |recipient| recipient.failures.empty? }.freeze
168
+ end
169
+
170
+ def unknown_recipients
171
+ recipients.reject { |recipient| recipient.accepted? || !recipient.failures.empty? }.freeze
172
+ end
173
+
174
+ def accepted_ids
175
+ legacy = email_id
176
+ (legacy ? [legacy] : accepted_recipients.filter_map(&:id)).freeze
177
+ end
178
+
179
+ def recipients
180
+ Outcomes.records(self["recipients"], "recipients").each_with_index.map do |item, index|
181
+ child(RecipientResult, item.to_h, "recipients[#{index}]")
182
+ end
183
+ end
184
+
185
+ def email_id
186
+ self["email_id"].nil? ? nil : identifier!("email_id")
187
+ end
188
+
189
+ def failures
190
+ recipients.flat_map(&:failures)
191
+ end
192
+
193
+ def complete?
194
+ return true if email_id
195
+
196
+ !recipients.empty? && recipients.all?(&:accepted?)
197
+ end
198
+
199
+ def uncertain?
200
+ return false if email_id
201
+
202
+ recipients.empty? || recipients.any? { |item| !item.accepted? && item.failures.empty? }
203
+ end
204
+
205
+ def partial?
206
+ !complete? && recipients.any?(&:accepted?)
207
+ end
208
+
209
+ protected
210
+
211
+ def normalize
212
+ email_id
213
+ recipients
214
+ end
215
+ end
216
+
217
+ # Event failures can have an unknown number of accepted events.
218
+ class EventResult < Record
219
+ def complete?
220
+ self["success"] == true
221
+ end
222
+
223
+ def partial?
224
+ false
225
+ end
226
+
227
+ def uncertain?
228
+ self["uncertain"] == true
229
+ end
230
+
231
+ def failures
232
+ []
233
+ end
234
+ end
235
+
236
+ # Uniform acceptance inspection for supported report responses.
237
+ class OutcomeResponse < ObjectResponse
238
+ def complete?
239
+ result.complete?
240
+ end
241
+
242
+ def partial?
243
+ result.partial?
244
+ end
245
+
246
+ def uncertain?
247
+ result.uncertain?
248
+ end
249
+
250
+ def failures
251
+ result.failures
252
+ end
253
+
254
+ def raise_for_partial_failure!
255
+ raise PartialFailure, self unless complete?
256
+
257
+ self
258
+ end
259
+ end
260
+
261
+ # Import counters and acceptance state.
262
+ class ImportResponse < OutcomeResponse
263
+ def row_warnings
264
+ result.row_warnings
265
+ end
266
+
267
+ def inserted
268
+ result.inserted
269
+ end
270
+
271
+ def updated
272
+ result.updated
273
+ end
274
+
275
+ def deleted
276
+ result.deleted
277
+ end
278
+
279
+ def new_emails
280
+ result.new_emails
281
+ end
282
+
283
+ def total
284
+ result.total
285
+ end
286
+
287
+ def invalid
288
+ result.invalid
289
+ end
290
+ end
291
+
292
+ # Per-recipient send acceptance.
293
+ class SendResponse < OutcomeResponse
294
+ def accepted_recipients
295
+ result.accepted_recipients
296
+ end
297
+
298
+ def rejected_recipients
299
+ result.rejected_recipients
300
+ end
301
+
302
+ def unknown_recipients
303
+ result.unknown_recipients
304
+ end
305
+
306
+ def accepted_ids
307
+ result.accepted_ids
308
+ end
309
+
310
+ def recipients
311
+ result.recipients
312
+ end
313
+
314
+ def email_id
315
+ result.email_id
316
+ end
317
+ end
318
+
319
+ class EventResponse < OutcomeResponse; end
320
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Pagination is explicit; ordinary Enumerable methods only visit this page.
5
+ class PageResponse < CollectionResponse
6
+ def initialize(response, client:, method:, params:)
7
+ @client = client
8
+ @method = method
9
+ @params = Pagination.snapshot(params)
10
+ super(data: response.data, status: response.http_status, headers: response.headers, result: response.result)
11
+ end
12
+
13
+ # rubocop:disable Layout/LineLength
14
+ # @type method each_page: (?max_pages: Integer) ?{ (PageResponse[Record]) -> void } -> Enumerator[PageResponse[Record], void]
15
+ # rubocop:enable Layout/LineLength
16
+ def each_page(max_pages: 1000, &)
17
+ size = @params.fetch(:limit, 100) # : Integer
18
+ pager = Pagination.new(@client, @method, size, max_pages, @params.except(:limit), first_page: self)
19
+ Iteration.call(pager.each, &) # : Enumerator[PageResponse[Record], void]
20
+ end
21
+
22
+ # @type method auto_each: (?max_pages: Integer) ?{ (Record) -> void } -> Enumerator[Record, void]
23
+ def auto_each(max_pages: 1000, &)
24
+ Iteration.call(Iteration.items(each_page(max_pages:)), &)
25
+ end
26
+
27
+ def offset
28
+ @params.fetch(:offset, 0) # : Integer
29
+ end
30
+
31
+ def limit
32
+ @params.fetch(:limit, 100) # : Integer
33
+ end
34
+
35
+ def next_offset
36
+ count = if @method == "get_sender_domain_list"
37
+ result.sum do |item|
38
+ raise InvalidResponseError, "Expected a sender-domain group" unless item.is_a?(SenderDomainGroup)
39
+
40
+ item.domains.size
41
+ end
42
+ else
43
+ size
44
+ end
45
+ offset + count
46
+ end
47
+ end
48
+ end
@@ -21,19 +21,60 @@ module Unisender
21
21
  DEFINITIONS = definitions.transform_values(&:freeze).freeze
22
22
  private_constant :DEFINITIONS
23
23
 
24
+ def self.supports?(method)
25
+ DEFINITIONS.key?(method.to_s)
26
+ end
27
+
24
28
  def self.call(client, method, params)
29
+ params = prepare(method, params)
30
+ response = client.invoke(wire_name(method), params) # : CollectionResponse[Record]
31
+ PageResponse.new(response, client:, method: method.to_s, params:)
32
+ rescue ValidationError => e
33
+ raise e.with_operation(wire_name(method)), cause: nil
34
+ end
35
+
36
+ def self.prepare(method, params)
37
+ params = params.compact
38
+ params = params.merge(limit: params.fetch(:limit, 100))
39
+ wire = validate(method, params)
40
+ params = Inputs.normalize(wire, params)
41
+ validate_controls(method, params)
42
+ params
43
+ end
44
+
45
+ def self.validate(method, params)
25
46
  wire, required, allowed, = definition(method)
26
- raise ArgumentError, "Unknown operation parameter" unless params.keys.all? { |key| allowed.include?(key) }
47
+ unless params.keys.all? do |key|
48
+ allowed.include?(key)
49
+ end
50
+ raise ValidationError.new(field: "parameters", code: :unknown_keyword,
51
+ operation: wire)
52
+ end
27
53
 
28
54
  Validation.required!(params, required.map(&:to_s))
29
55
  Validation.operation!(wire, params)
30
- client.request(wire, params:) # : Response[objects]
56
+ wire
31
57
  end
32
58
 
33
59
  def self.page_limit(method)
34
60
  definition(method).last
35
61
  end
36
62
 
63
+ def self.wire_name(method)
64
+ definition(method).first
65
+ end
66
+
67
+ def self.validate_controls(method, params)
68
+ limit = params.fetch(:limit, 100)
69
+ maximum = page_limit(method)
70
+ unless limit.is_a?(Integer) && limit.positive? && (!maximum || limit <= maximum)
71
+ raise ValidationError.new(field: "limit", code: :out_of_range)
72
+ end
73
+
74
+ offset = params.fetch(:offset, 0)
75
+ raise ValidationError.new(field: "offset", code: :out_of_range) unless offset.is_a?(Integer) && offset >= 0
76
+ end
77
+
37
78
  def self.definition(method)
38
79
  DEFINITIONS.fetch(method.to_s) { raise ArgumentError, "Unsupported paginated method" }
39
80
  end
@@ -3,65 +3,98 @@
3
3
  module Unisender
4
4
  # Offset pagination is supported only for operations that document it.
5
5
  class Pagination
6
- def initialize(client, method, page_size, max_pages, params)
6
+ # rubocop:disable-next Metrics/ParameterLists -- Adds a fetched seed page to the pagination controls.
7
+ def initialize(client, method, page_size, max_pages, params, first_page: nil)
7
8
  @client = client
8
9
  @method = method.to_s
9
10
  @page_size = page_size
10
11
  @max_pages = max_pages
11
- @params = params.dup
12
+ @params = self.class.snapshot(params)
13
+ @first_page = first_page
14
+ initialize_params
15
+ rescue ValidationError => e
16
+ raise e.with_operation(PaginatedOperations.wire_name(@method)), cause: nil
17
+ end
18
+
19
+ def initialize_params
20
+ validate!(PaginatedOperations.page_limit(@method))
21
+ wire = PaginatedOperations.validate(@method, @params)
22
+ @params = self.class.snapshot(Inputs.normalize(wire, @params))
23
+ initial_offset
12
24
  end
13
25
 
14
26
  def each
15
27
  Enumerator.new do |pages|
16
- validate!(PaginatedOperations.page_limit(@method))
17
28
  fetch_pages(pages, initial_offset)
18
29
  end
19
30
  end
20
31
 
32
+ def self.snapshot(params)
33
+ params.transform_values { |value| copy(value) }.freeze
34
+ end
35
+
36
+ def self.copy(value)
37
+ case value
38
+ when Hash then value.transform_values { |item| copy(item) }.freeze
39
+ when Array then value.map { |item| copy(item) }.freeze
40
+ else value.dup.freeze
41
+ end
42
+ end
43
+
21
44
  private
22
45
 
23
46
  def initial_offset
24
47
  offset = @params.fetch(:offset, 0)
25
- raise ArgumentError, "offset must be nonnegative" unless offset.is_a?(Integer) && offset >= 0
48
+ raise ValidationError.new(field: "offset", code: :out_of_range) unless offset.is_a?(Integer) && offset >= 0
26
49
 
27
50
  offset
28
51
  end
29
52
 
30
53
  def fetch_pages(pages, offset)
31
- @max_pages.times do
32
- response = PaginatedOperations.call(@client, @method, @params.merge(limit: @page_size, offset:))
54
+ @max_pages.times do |index|
55
+ response = fetch_page(index, offset)
33
56
  count = page_length(response)
34
57
  pages << response
35
- return if count < @page_size
58
+ return nil if count < @page_size
36
59
 
37
60
  offset += count
38
61
  end
39
- raise PaginationError, "Pagination page limit reached"
62
+ raise PaginationError.new(operation: @method, next_offset: offset, completed_pages: @max_pages)
63
+ end
64
+
65
+ def fetch_page(index, offset)
66
+ first = @first_page
67
+ return first if index.zero? && first
68
+
69
+ PaginatedOperations.call(@client, @method, @params.merge(limit: @page_size, offset:))
70
+ rescue Error => e
71
+ raise e.with_pagination(next_offset: offset, completed_pages: index), cause: nil
40
72
  end
41
73
 
42
74
  def validate!(maximum)
43
75
  validate_limit!(maximum)
44
- raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages.positive?
45
- raise ArgumentError, "Use page_size instead of limit" if @params.key?(:limit)
76
+ unless @max_pages.is_a?(Integer) && @max_pages.positive?
77
+ raise ValidationError.new(field: "max_pages",
78
+ code: :out_of_range)
79
+ end
80
+ raise ValidationError.new(field: "limit", code: :use_page_size) if @params.key?(:limit)
46
81
  end
47
82
 
48
83
  def validate_limit!(maximum)
49
- raise ArgumentError, "Invalid page_size" unless @page_size.is_a?(Integer) && @page_size.positive?
50
- raise ArgumentError, "page_size exceeds the method limit" if maximum && @page_size > maximum
84
+ unless @page_size.is_a?(Integer) && @page_size.positive?
85
+ raise ValidationError.new(field: "page_size",
86
+ code: :out_of_range)
87
+ end
88
+ raise ValidationError.new(field: "page_size", code: :out_of_range) if maximum && @page_size > maximum
51
89
  end
52
90
 
53
91
  def page_length(response)
54
- result = response.result
55
- raise InvalidResponseError, "Expected paginated result array" unless result.is_a?(Array)
92
+ result = response.result # : Array[Record]
56
93
  return result.size unless @method == "get_sender_domain_list"
57
94
 
58
95
  result.sum do |group|
59
- raise InvalidResponseError, "Expected sender domain groups" unless group.is_a?(Hash)
60
-
61
- domains = group["domains"]
62
- raise InvalidResponseError, "Expected sender domain groups" unless domains.is_a?(Array)
63
-
64
- domains.size
96
+ record = group # : SenderDomainGroup
97
+ record.domains.size
65
98
  end
66
99
  end
67
100
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Serializes requests per client and reuses verified HTTPS connections.
5
+ class PersistentTransport < Transport
6
+ Connection = Data.define(:http, :used_at)
7
+ HOSTS = %w[api.unisender.com event.unisender.com].freeze
8
+
9
+ # rubocop:disable-next Metrics/ParameterLists -- Preserves transport injection and explicit timeouts.
10
+ def initialize(idle_timeout: 30, clock: nil, pid: nil, open_timeout: 5, read_timeout: 60,
11
+ write_timeout: 30, http_factory: nil)
12
+ unless idle_timeout.is_a?(Numeric) && idle_timeout.positive? && idle_timeout.finite?
13
+ raise ArgumentError, "idle_timeout must be positive and finite"
14
+ end
15
+
16
+ super(open_timeout:, read_timeout:, write_timeout:, http_factory:)
17
+ @idle_timeout = idle_timeout
18
+ initialize_pool(clock, pid)
19
+ end
20
+
21
+ def initialize_pool(clock, pid)
22
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
23
+ @pid_source = pid || -> { Process.pid }
24
+ @pid = @pid_source.call
25
+ @connections = {}
26
+ @mutex = Mutex.new
27
+ @closed = false
28
+ end
29
+
30
+ def close
31
+ reset_after_fork
32
+ @mutex.synchronize do
33
+ @closed = true
34
+ # rubocop:disable-next Style/HashEachMethods -- Snapshot keys before removing pooled entries.
35
+ @connections.keys.each { |key| discard(key) }
36
+ end
37
+ nil
38
+ end
39
+
40
+ def inspect
41
+ "#<Unisender::PersistentTransport>"
42
+ end
43
+
44
+ private
45
+
46
+ def perform(uri, request, execution:)
47
+ reset_after_fork
48
+ key = origin(uri)
49
+ @mutex.synchronize do
50
+ raise TransportError, "Transport is closed" if @closed
51
+
52
+ execution&.check!
53
+ send_request(key, uri, request, execution)
54
+ end
55
+ end
56
+
57
+ def send_request(key, uri, request, execution)
58
+ http = connection(key, uri, execution)
59
+ configure_timeouts(http, execution)
60
+ response = http.request(request)
61
+ @connections[key] = Connection.new(http:, used_at: @clock.call)
62
+ response
63
+ rescue StandardError
64
+ discard(key)
65
+ raise
66
+ end
67
+
68
+ def origin(uri)
69
+ unless uri.scheme == "https" && uri.port == 443 && HOSTS.include?(uri.hostname)
70
+ raise ArgumentError, "Persistent transport requires a UniSender API origin"
71
+ end
72
+
73
+ uri.hostname.to_s
74
+ end
75
+
76
+ def connection(key, uri, execution)
77
+ current = @connections[key]
78
+ return current.http if current && @clock.call - current.used_at < @idle_timeout
79
+
80
+ discard(key)
81
+ http = configured_http(uri, execution:)
82
+ http.start
83
+ @connections[key] = Connection.new(http:, used_at: @clock.call)
84
+ http
85
+ end
86
+
87
+ def discard(key)
88
+ http = @connections.delete(key)&.http
89
+ http.finish if http&.started?
90
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
91
+ nil
92
+ end
93
+
94
+ def reset_after_fork
95
+ current = @pid_source.call
96
+ return if @pid == current
97
+
98
+ # rubocop:disable-next Style/HashEachMethods -- Snapshot keys before removing pooled entries.
99
+ @connections.keys.each { |key| discard(key) }
100
+ @pid = current
101
+ @mutex = Mutex.new
102
+ end
103
+ end
104
+ end