unisender-ruby 0.1.0 → 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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/CODE_OF_CONDUCT.md +90 -0
  4. data/CONTRIBUTING.md +23 -2
  5. data/README.md +53 -90
  6. data/SECURITY.md +13 -0
  7. data/docs/api.md +395 -210
  8. data/docs/execution.md +89 -0
  9. data/docs/import-recovery.md +41 -0
  10. data/docs/live-contracts.md +34 -0
  11. data/docs/migration-0.2.md +129 -0
  12. data/docs/rails.md +248 -0
  13. data/docs/releases.md +22 -0
  14. data/docs/testing.md +83 -0
  15. data/docs/usage.md +144 -0
  16. data/docs/webhooks.md +41 -0
  17. data/examples/campaign.rb +5 -6
  18. data/examples/campaign_lifecycle.rb +28 -0
  19. data/examples/check_connection.rb +1 -1
  20. data/examples/events.rb +1 -1
  21. data/examples/export.rb +20 -6
  22. data/examples/import.rb +3 -2
  23. data/examples/import_recovery.rb +70 -0
  24. data/examples/quickstart.rb +2 -1
  25. data/examples/subscribe.rb +2 -2
  26. data/lib/unisender/acceptance.rb +104 -0
  27. data/lib/unisender/attachment.rb +47 -0
  28. data/lib/unisender/batch_report.rb +115 -0
  29. data/lib/unisender/batch_summary.rb +87 -0
  30. data/lib/unisender/batches.rb +41 -12
  31. data/lib/unisender/campaign_workflow.rb +72 -0
  32. data/lib/unisender/client.rb +65 -33
  33. data/lib/unisender/client_configuration.rb +50 -0
  34. data/lib/unisender/client_execution.rb +43 -0
  35. data/lib/unisender/client_state.rb +31 -0
  36. data/lib/unisender/client_validation.rb +97 -0
  37. data/lib/unisender/configuration.rb +151 -0
  38. data/lib/unisender/contact_records.rb +251 -0
  39. data/lib/unisender/diagnostic_urls.rb +68 -0
  40. data/lib/unisender/diagnostics.rb +83 -0
  41. data/lib/unisender/download_target.rb +61 -0
  42. data/lib/unisender/download_transfer.rb +43 -0
  43. data/lib/unisender/downloader.rb +99 -0
  44. data/lib/unisender/error_diagnostics.rb +83 -0
  45. data/lib/unisender/errors.rb +125 -8
  46. data/lib/unisender/event_batches.rb +40 -0
  47. data/lib/unisender/event_payload.rb +50 -22
  48. data/lib/unisender/events.rb +21 -5
  49. data/lib/unisender/execution_context.rb +114 -0
  50. data/lib/unisender/form.rb +20 -12
  51. data/lib/unisender/helpers.rb +70 -10
  52. data/lib/unisender/hooks.rb +79 -0
  53. data/lib/unisender/import_cells.rb +62 -0
  54. data/lib/unisender/input_types.rb +30 -0
  55. data/lib/unisender/inputs.rb +262 -0
  56. data/lib/unisender/iteration.rb +15 -0
  57. data/lib/unisender/iterators.rb +60 -0
  58. data/lib/unisender/mail_inputs.rb +37 -0
  59. data/lib/unisender/named_inputs.rb +52 -0
  60. data/lib/unisender/observation.rb +30 -0
  61. data/lib/unisender/operation_contracts.rb +106 -0
  62. data/lib/unisender/operations/contacts_1.rb +4 -3
  63. data/lib/unisender/operations/contacts_2.rb +23 -4
  64. data/lib/unisender/operations/messages_1.rb +7 -4
  65. data/lib/unisender/operations/messages_2.rb +8 -3
  66. data/lib/unisender/operations/messages_3.rb +1 -1
  67. data/lib/unisender/operations/notes_1.rb +1 -1
  68. data/lib/unisender/operations/statistics_1.rb +3 -3
  69. data/lib/unisender/operations/templates_1.rb +2 -2
  70. data/lib/unisender/outcomes.rb +320 -0
  71. data/lib/unisender/page_response.rb +48 -0
  72. data/lib/unisender/paginated_operations.rb +43 -2
  73. data/lib/unisender/pagination.rb +53 -20
  74. data/lib/unisender/persistent_transport.rb +104 -0
  75. data/lib/unisender/rate_limiter.rb +72 -0
  76. data/lib/unisender/record.rb +210 -0
  77. data/lib/unisender/record_rows.rb +69 -0
  78. data/lib/unisender/records.rb +614 -0
  79. data/lib/unisender/request_execution.rb +108 -0
  80. data/lib/unisender/response.rb +13 -10
  81. data/lib/unisender/result_registry.rb +72 -0
  82. data/lib/unisender/results.rb +105 -0
  83. data/lib/unisender/retry_policy.rb +10 -16
  84. data/lib/unisender/serialization.rb +54 -0
  85. data/lib/unisender/statistic_records.rb +129 -0
  86. data/lib/unisender/task_response.rb +85 -0
  87. data/lib/unisender/task_waiter.rb +25 -11
  88. data/lib/unisender/testing/assertions.rb +48 -0
  89. data/lib/unisender/testing/fixtures.rb +24 -0
  90. data/lib/unisender/testing.rb +91 -0
  91. data/lib/unisender/transport.rb +17 -9
  92. data/lib/unisender/validation.rb +11 -9
  93. data/lib/unisender/version.rb +1 -1
  94. data/lib/unisender/webhooks/tokens.rb +38 -0
  95. data/lib/unisender/webhooks.rb +230 -0
  96. data/lib/unisender.rb +42 -0
  97. data/sig/acceptance.rbs +30 -0
  98. data/sig/attachment.rbs +15 -0
  99. data/sig/batch_summary.rbs +38 -0
  100. data/sig/campaign_workflow.rbs +20 -0
  101. data/sig/configuration.rbs +62 -0
  102. data/sig/connection_controls.rbs +55 -0
  103. data/sig/diagnostics.rbs +37 -0
  104. data/sig/domain_records.rbs +96 -0
  105. data/sig/downloads.rbs +53 -0
  106. data/sig/execution.rbs +52 -0
  107. data/sig/execution_context.rbs +46 -0
  108. data/sig/hooks.rbs +18 -0
  109. data/sig/inputs.rbs +84 -0
  110. data/sig/iterators.rbs +11 -0
  111. data/sig/mail_inputs.rbs +8 -0
  112. data/sig/manifest.yaml +11 -0
  113. data/sig/operation_contracts.rbs +28 -0
  114. data/sig/operations.rbs +73 -69
  115. data/sig/outcomes.rbs +90 -0
  116. data/sig/record.rbs +77 -0
  117. data/sig/records.rbs +182 -0
  118. data/sig/serialization.rbs +13 -0
  119. data/sig/tasks.rbs +26 -0
  120. data/sig/testing.rbs +54 -0
  121. data/sig/unisender.rbs +128 -39
  122. data/sig/webhooks.rbs +55 -0
  123. metadata +88 -4
@@ -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