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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/CONTRIBUTING.md +10 -1
- data/README.md +55 -112
- data/docs/api.md +395 -210
- data/docs/execution.md +89 -0
- data/docs/import-recovery.md +41 -0
- data/docs/live-contracts.md +34 -0
- data/docs/migration-0.2.md +129 -0
- data/docs/rails.md +248 -0
- data/docs/releases.md +4 -4
- data/docs/testing.md +83 -0
- data/docs/usage.md +144 -0
- data/docs/webhooks.md +41 -0
- data/examples/campaign.rb +5 -6
- data/examples/campaign_lifecycle.rb +28 -0
- data/examples/check_connection.rb +1 -1
- data/examples/events.rb +1 -1
- data/examples/export.rb +20 -6
- data/examples/import.rb +3 -2
- data/examples/import_recovery.rb +70 -0
- data/examples/quickstart.rb +1 -1
- data/examples/subscribe.rb +2 -2
- data/lib/unisender/acceptance.rb +104 -0
- data/lib/unisender/attachment.rb +47 -0
- data/lib/unisender/batch_report.rb +115 -0
- data/lib/unisender/batch_summary.rb +87 -0
- data/lib/unisender/batches.rb +41 -12
- data/lib/unisender/campaign_workflow.rb +72 -0
- data/lib/unisender/client.rb +65 -33
- data/lib/unisender/client_configuration.rb +50 -0
- data/lib/unisender/client_execution.rb +43 -0
- data/lib/unisender/client_state.rb +31 -0
- data/lib/unisender/client_validation.rb +97 -0
- data/lib/unisender/configuration.rb +151 -0
- data/lib/unisender/contact_records.rb +251 -0
- data/lib/unisender/diagnostic_urls.rb +68 -0
- data/lib/unisender/diagnostics.rb +83 -0
- data/lib/unisender/download_target.rb +61 -0
- data/lib/unisender/download_transfer.rb +43 -0
- data/lib/unisender/downloader.rb +99 -0
- data/lib/unisender/error_diagnostics.rb +83 -0
- data/lib/unisender/errors.rb +125 -8
- data/lib/unisender/event_batches.rb +40 -0
- data/lib/unisender/event_payload.rb +50 -22
- data/lib/unisender/events.rb +21 -5
- data/lib/unisender/execution_context.rb +114 -0
- data/lib/unisender/form.rb +20 -12
- data/lib/unisender/helpers.rb +70 -10
- data/lib/unisender/hooks.rb +79 -0
- data/lib/unisender/import_cells.rb +62 -0
- data/lib/unisender/input_types.rb +30 -0
- data/lib/unisender/inputs.rb +262 -0
- data/lib/unisender/iteration.rb +15 -0
- data/lib/unisender/iterators.rb +60 -0
- data/lib/unisender/mail_inputs.rb +37 -0
- data/lib/unisender/named_inputs.rb +52 -0
- data/lib/unisender/observation.rb +30 -0
- data/lib/unisender/operation_contracts.rb +106 -0
- data/lib/unisender/operations/contacts_1.rb +4 -3
- data/lib/unisender/operations/contacts_2.rb +23 -4
- data/lib/unisender/operations/messages_1.rb +7 -4
- data/lib/unisender/operations/messages_2.rb +8 -3
- data/lib/unisender/operations/messages_3.rb +1 -1
- data/lib/unisender/operations/notes_1.rb +1 -1
- data/lib/unisender/operations/statistics_1.rb +3 -3
- data/lib/unisender/operations/templates_1.rb +2 -2
- data/lib/unisender/outcomes.rb +320 -0
- data/lib/unisender/page_response.rb +48 -0
- data/lib/unisender/paginated_operations.rb +43 -2
- data/lib/unisender/pagination.rb +53 -20
- data/lib/unisender/persistent_transport.rb +104 -0
- data/lib/unisender/rate_limiter.rb +72 -0
- data/lib/unisender/record.rb +210 -0
- data/lib/unisender/record_rows.rb +69 -0
- data/lib/unisender/records.rb +614 -0
- data/lib/unisender/request_execution.rb +108 -0
- data/lib/unisender/response.rb +13 -10
- data/lib/unisender/result_registry.rb +72 -0
- data/lib/unisender/results.rb +105 -0
- data/lib/unisender/retry_policy.rb +10 -16
- data/lib/unisender/serialization.rb +54 -0
- data/lib/unisender/statistic_records.rb +129 -0
- data/lib/unisender/task_response.rb +85 -0
- data/lib/unisender/task_waiter.rb +25 -11
- data/lib/unisender/testing/assertions.rb +48 -0
- data/lib/unisender/testing/fixtures.rb +24 -0
- data/lib/unisender/testing.rb +91 -0
- data/lib/unisender/transport.rb +17 -9
- data/lib/unisender/validation.rb +11 -9
- data/lib/unisender/version.rb +1 -1
- data/lib/unisender/webhooks/tokens.rb +38 -0
- data/lib/unisender/webhooks.rb +230 -0
- data/lib/unisender.rb +42 -0
- data/sig/acceptance.rbs +30 -0
- data/sig/attachment.rbs +15 -0
- data/sig/batch_summary.rbs +38 -0
- data/sig/campaign_workflow.rbs +20 -0
- data/sig/configuration.rbs +62 -0
- data/sig/connection_controls.rbs +55 -0
- data/sig/diagnostics.rbs +37 -0
- data/sig/domain_records.rbs +96 -0
- data/sig/downloads.rbs +53 -0
- data/sig/execution.rbs +52 -0
- data/sig/execution_context.rbs +46 -0
- data/sig/hooks.rbs +18 -0
- data/sig/inputs.rbs +84 -0
- data/sig/iterators.rbs +11 -0
- data/sig/mail_inputs.rbs +8 -0
- data/sig/manifest.yaml +11 -0
- data/sig/operation_contracts.rbs +28 -0
- data/sig/operations.rbs +73 -69
- data/sig/outcomes.rbs +90 -0
- data/sig/record.rbs +77 -0
- data/sig/records.rbs +182 -0
- data/sig/serialization.rbs +13 -0
- data/sig/tasks.rbs +26 -0
- data/sig/testing.rbs +54 -0
- data/sig/unisender.rbs +128 -39
- data/sig/webhooks.rbs +55 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
data/lib/unisender/pagination.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|