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,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Process-local sliding windows; share an instance across clients using one budget.
|
|
5
|
+
class RateLimiter
|
|
6
|
+
# https://www.unisender.com/ru/support/api/common/api-limits/
|
|
7
|
+
# @type var defaults: Hash[String, [Integer, duration]]
|
|
8
|
+
defaults = {
|
|
9
|
+
"subscribe" => [300, 60], "async/exportContacts" => [20, 60], "getContactCount" => [60, 60],
|
|
10
|
+
"createCampaign" => [100, 60], "checkEmail" => [300, 60], "sendEmail" => [60, 60], "sendSms" => [300, 60]
|
|
11
|
+
}
|
|
12
|
+
DEFAULT_METHODS = defaults.freeze
|
|
13
|
+
|
|
14
|
+
def self.unisender(clock: nil, sleeper: nil)
|
|
15
|
+
new(global: [1200, 60], methods: DEFAULT_METHODS, clock:, sleeper:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(global:, methods: {}, clock: nil, sleeper: nil)
|
|
19
|
+
@limits = { "*" => validate(global) }.merge(methods.transform_values { |limit| validate(limit) }).freeze
|
|
20
|
+
@history = @limits.to_h { |name, _| [name, []] }
|
|
21
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
22
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def acquire(operation, execution: nil)
|
|
27
|
+
loop do
|
|
28
|
+
execution&.check!
|
|
29
|
+
delay = @mutex.synchronize { reserve(operation) }
|
|
30
|
+
return if delay.zero?
|
|
31
|
+
|
|
32
|
+
if execution
|
|
33
|
+
execution.wait(delay, :rate_limit_wait, operation, @sleeper)
|
|
34
|
+
else
|
|
35
|
+
@sleeper.call(delay)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inspect
|
|
41
|
+
"#<Unisender::RateLimiter>"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def validate(limit)
|
|
47
|
+
count, seconds = limit
|
|
48
|
+
unless count.is_a?(Integer) && count.positive? && seconds.is_a?(Numeric) && seconds.positive? && seconds.finite?
|
|
49
|
+
raise ArgumentError, "Rate limits require a positive count and finite interval"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @type var result: [Integer, Float]
|
|
53
|
+
result = [count, seconds.to_f]
|
|
54
|
+
result.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reserve(operation)
|
|
58
|
+
now = @clock.call
|
|
59
|
+
keys = @limits.key?(operation) ? ["*", operation] : ["*"]
|
|
60
|
+
delay = keys.map { |key| delay_for(key, now) }.max.to_f
|
|
61
|
+
keys.each { |key| @history.fetch(key) << now } if delay.zero?
|
|
62
|
+
delay
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delay_for(key, now)
|
|
66
|
+
count, seconds = @limits.fetch(key)
|
|
67
|
+
times = @history.fetch(key)
|
|
68
|
+
times.reject! { |time| time <= now - seconds }
|
|
69
|
+
times.size >= count ? times.fetch(0) + seconds - now : 0.0
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Immutable result fields, including fields added by the service.
|
|
5
|
+
class Record
|
|
6
|
+
def initialize(data)
|
|
7
|
+
@values = data.transform_values { |value| self.class.decode(value) }
|
|
8
|
+
normalize
|
|
9
|
+
@values.freeze
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.decode(value)
|
|
14
|
+
case value
|
|
15
|
+
when Hash then Record.new(value)
|
|
16
|
+
when Array then value.map { |item| decode(item) }.freeze
|
|
17
|
+
else value.dup.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def [](key) = @values[key.to_s]
|
|
22
|
+
|
|
23
|
+
def fetch(key, *defaults, &block)
|
|
24
|
+
raise ArgumentError, "Expected at most one default" if defaults.size > 1
|
|
25
|
+
return @values.fetch(key.to_s) { yield(key) } if block
|
|
26
|
+
return @values.fetch(key.to_s) if defaults.empty?
|
|
27
|
+
|
|
28
|
+
@values.fetch(key.to_s, defaults.fetch(0))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def key?(key) = @values.key?(key.to_s)
|
|
32
|
+
|
|
33
|
+
def keys = @values.keys
|
|
34
|
+
|
|
35
|
+
def values = @values.values
|
|
36
|
+
|
|
37
|
+
def dig(key, *path)
|
|
38
|
+
@values.dig(key.to_s, *path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
@values.transform_values { |value| Record.copy(value) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
alias to_hash to_h
|
|
46
|
+
|
|
47
|
+
def as_json(options = nil)
|
|
48
|
+
Serialization.payload(self, options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_json(state = nil)
|
|
52
|
+
Serialization.json(self, state)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def ==(other)
|
|
56
|
+
case other
|
|
57
|
+
when Record then self.class == other.class && to_h == other.to_h
|
|
58
|
+
else false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def eql?(other)
|
|
63
|
+
case other
|
|
64
|
+
when Record then self.class == other.class && to_h.eql?(other.to_h)
|
|
65
|
+
else false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def hash = [self.class, to_h].hash
|
|
70
|
+
|
|
71
|
+
def inspect = "#<#{self.class}>"
|
|
72
|
+
|
|
73
|
+
def self.copy(value)
|
|
74
|
+
case value
|
|
75
|
+
when Record then value.to_h
|
|
76
|
+
when Array then value.map { |item| copy(item) }
|
|
77
|
+
when Hash then value.transform_values { |item| copy(item) }
|
|
78
|
+
else value.dup
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
protected
|
|
83
|
+
|
|
84
|
+
def child(model, data, path)
|
|
85
|
+
model.new(data)
|
|
86
|
+
rescue InvalidResponseError => e
|
|
87
|
+
raise e.prepend_path(path), cause: nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize; end
|
|
91
|
+
|
|
92
|
+
def text(name)
|
|
93
|
+
value = self[name]
|
|
94
|
+
return value if value.nil? || value.is_a?(String)
|
|
95
|
+
|
|
96
|
+
invalid!(name, expected_type: "String or nil")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def text!(name)
|
|
100
|
+
text(name) || invalid!(name, expected_type: "String")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def identifier!(name)
|
|
104
|
+
value = self[name]
|
|
105
|
+
return value if value.is_a?(String) || value.is_a?(Integer)
|
|
106
|
+
|
|
107
|
+
invalid!(name, expected_type: "String or Integer")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def integer!(name)
|
|
111
|
+
value = self[name]
|
|
112
|
+
return value if value.is_a?(Integer)
|
|
113
|
+
return Integer(value, 10) if value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
114
|
+
|
|
115
|
+
invalid!(name, expected_type: "Integer")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def integer(name)
|
|
119
|
+
self[name].nil? ? nil : integer!(name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def number(name)
|
|
123
|
+
value = self[name]
|
|
124
|
+
return value if value.nil? || value.is_a?(Integer) || value.is_a?(Float)
|
|
125
|
+
|
|
126
|
+
invalid!(name, expected_type: "Number or nil")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def invalid!(name, expected_type: "documented value")
|
|
130
|
+
raise InvalidResponseError.new("Invalid result field", path: name, expected_type:,
|
|
131
|
+
actual_type: Diagnostics.type_name(self[name])), cause: nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A fetched collection; Enumerable never makes further requests.
|
|
136
|
+
class CollectionResponse < Response
|
|
137
|
+
include Enumerable
|
|
138
|
+
|
|
139
|
+
def each(&block)
|
|
140
|
+
return result.each unless block
|
|
141
|
+
|
|
142
|
+
result.each(&block)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Track omission separately because Array rejects an explicitly nil length.
|
|
146
|
+
def [](index, length = begin
|
|
147
|
+
omitted = true
|
|
148
|
+
nil
|
|
149
|
+
end)
|
|
150
|
+
return index.is_a?(Range) ? result[index] : result.at(index) if omitted
|
|
151
|
+
|
|
152
|
+
raise TypeError, "Invalid slice arguments" if index.is_a?(Range) || length.nil?
|
|
153
|
+
|
|
154
|
+
result[index, length]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def at(index)
|
|
158
|
+
result.at(index)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fetch(index, *defaults, &block)
|
|
162
|
+
raise ArgumentError, "Expected at most one default" if defaults.size > 1
|
|
163
|
+
return result.fetch(index, &block) if block
|
|
164
|
+
return result.fetch(index) if defaults.empty?
|
|
165
|
+
|
|
166
|
+
result.fetch(index, defaults.fetch(0))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def size
|
|
170
|
+
result.size
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
alias length size
|
|
174
|
+
|
|
175
|
+
def empty?
|
|
176
|
+
result.empty?
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A typed object with explicit field readers supplied by its subclass.
|
|
181
|
+
class ObjectResponse < Response
|
|
182
|
+
def key?(key)
|
|
183
|
+
result.key?(key)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def keys
|
|
187
|
+
result.keys
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def values
|
|
191
|
+
result.values
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def [](key)
|
|
195
|
+
result[key]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def fetch(key, *defaults, &)
|
|
199
|
+
result.fetch(key, *defaults, &)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def to_h
|
|
203
|
+
result.to_h
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def dig(key, *path)
|
|
207
|
+
result.dig(key, *path)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Keeps one column order across a streaming import.
|
|
5
|
+
class RecordRows
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
attr_reader :field_names
|
|
9
|
+
|
|
10
|
+
def self.bounded(records)
|
|
11
|
+
values = records.take(501)
|
|
12
|
+
if values.size > 500
|
|
13
|
+
raise ValidationError.new(field: "records", code: :too_many_records, operation: "importContacts")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
new(values)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(records)
|
|
20
|
+
@field_names = []
|
|
21
|
+
@records = records
|
|
22
|
+
@rows = case records
|
|
23
|
+
when Array then records.each_with_index.map { |record, index| row(record, index) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each(&)
|
|
28
|
+
rows = @rows
|
|
29
|
+
if rows
|
|
30
|
+
rows.each(&)
|
|
31
|
+
else
|
|
32
|
+
@records.each_with_index { |record, index| yield row(record, index) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def row(record, index)
|
|
39
|
+
object = record_object(record, index)
|
|
40
|
+
names = object.keys.map(&:to_s)
|
|
41
|
+
validate_columns(names, index)
|
|
42
|
+
normalized = object.transform_keys(&:to_s)
|
|
43
|
+
ImportCells.validate_times(normalized, index)
|
|
44
|
+
@field_names.map { |name| ImportCells.convert(name, normalized.fetch(name), index) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_object(record, index)
|
|
48
|
+
unless record.is_a?(Hash)
|
|
49
|
+
raise ValidationError.new(field: "records[#{index}]", code: :invalid_record, operation: "importContacts",
|
|
50
|
+
index:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
record
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def validate_columns(names, index)
|
|
57
|
+
if names.uniq.size != names.size || !names.include?("email")
|
|
58
|
+
raise ValidationError.new(field: "records[#{index}]", code: :invalid_columns, operation: "importContacts",
|
|
59
|
+
index:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@field_names = names.freeze if @field_names.empty?
|
|
63
|
+
return if names.sort == @field_names.sort
|
|
64
|
+
|
|
65
|
+
raise ValidationError.new(field: "records[#{index}]", code: :inconsistent_columns, operation: "importContacts",
|
|
66
|
+
index:)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|