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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module Unisender
7
+ DownloadReceipt = Data.define(:bytes_written, :http_status)
8
+
9
+ # A failed transfer can leave a partial result in the caller's destination.
10
+ class DownloadError < Error
11
+ attr_reader :reason, :bytes_written, :http_status
12
+
13
+ def category
14
+ :download
15
+ end
16
+
17
+ def initialize(reason:, bytes_written: 0, http_status: nil)
18
+ @reason = reason
19
+ @bytes_written = bytes_written
20
+ @http_status = http_status
21
+ super("Download failed: #{reason}")
22
+ end
23
+ end
24
+
25
+ # Downloads use a separate credential-free connection and never follow redirects.
26
+ class Downloader
27
+ def initialize(open_timeout: 5, read_timeout: 60, write_timeout: 30, resolver: nil, http_factory: nil)
28
+ unless [open_timeout, read_timeout, write_timeout].all? do |value|
29
+ value.is_a?(Numeric) && value.positive? && value.finite?
30
+ end
31
+ raise ArgumentError, "Download timeouts must be positive and finite"
32
+ end
33
+
34
+ @open_timeout = open_timeout
35
+ @read_timeout = read_timeout
36
+ @write_timeout = write_timeout
37
+ @target = DownloadTarget.new(timeout: open_timeout, resolver:)
38
+ @http_factory = http_factory || ->(uri, address) { build_http(uri, address) }
39
+ end
40
+
41
+ def download(url, io, max_bytes:, execution: nil)
42
+ transfer = DownloadTransfer.new(io, max_bytes, execution:)
43
+ execute(url, transfer, execution)
44
+ end
45
+
46
+ private
47
+
48
+ def execute(url, transfer, execution)
49
+ response = download_response(url, transfer, execution)
50
+ DownloadReceipt.new(bytes_written: transfer.bytes_written, http_status: response.code.to_i)
51
+ rescue CancelledError, DeadlineExceededError => e
52
+ raise e.with_download(bytes_written: transfer.bytes_written), cause: nil
53
+ rescue DownloadError => e
54
+ raise DownloadError.new(reason: e.reason, bytes_written: transfer.bytes_written, http_status: e.http_status),
55
+ cause: nil
56
+ rescue StandardError
57
+ raise DownloadError.new(reason: :transport, bytes_written: transfer.bytes_written), cause: nil
58
+ end
59
+
60
+ def download_response(url, transfer, execution)
61
+ execution&.check!
62
+ uri, address = @target.resolve(url)
63
+ http = configured_http(uri, address, execution)
64
+ http.start do |connection|
65
+ request = Net::HTTP::Get.new(uri, { "Accept-Encoding" => "identity" })
66
+ connection.request(request) { |reply| stream(reply, transfer) }
67
+ end
68
+ end
69
+
70
+ def build_http(uri, address)
71
+ http = Net::HTTP.new(uri.hostname.to_s, uri.port, nil)
72
+ http.ipaddr = address
73
+ http
74
+ end
75
+
76
+ def configured_http(uri, address, execution = nil)
77
+ http = @http_factory.call(uri, address)
78
+ remaining = execution&.remaining
79
+ http.use_ssl = true
80
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
81
+ http.open_timeout = remaining ? [@open_timeout, remaining].min : @open_timeout
82
+ http.read_timeout = remaining ? [@read_timeout, remaining].min : @read_timeout
83
+ http.write_timeout = remaining ? [@write_timeout, remaining].min : @write_timeout
84
+ http.max_retries = 0
85
+ http
86
+ end
87
+
88
+ def stream(response, transfer)
89
+ status = response.code.to_i
90
+ raise DownloadError.new(reason: :http_error, http_status: status) unless (200..299).cover?(status)
91
+
92
+ response.read_body { |chunk| transfer.write(chunk) }
93
+ length = response["content-length"]
94
+ return unless length&.match?(/\A\d+\z/) && transfer.bytes_written != length.to_i
95
+
96
+ raise DownloadError.new(reason: :incomplete)
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Stable categories and safe recovery hints supplement the upstream details.
5
+ class Error
6
+ HINTS = {
7
+ authentication: "Check the API key.", authorization: "Check API access and account permissions.",
8
+ invalid_argument: "Check the operation parameters.", payment: "Check the account balance.",
9
+ rate_limit: "Wait before another request; check whether repeating this operation is safe.",
10
+ not_found: "Check the requested identifier.", client_closed: "Create an open client before making a request.",
11
+ pagination_limit: "Resume from next_offset with an explicit page budget.",
12
+ task_failed: "Inspect the task response before starting another task.",
13
+ task_state: "Inspect the task response and documented states.",
14
+ unknown_task_state: "Inspect the task response and update the supported task states.",
15
+ partial_failure: "Reconcile accepted records before retrying rejected records.",
16
+ download: "Inspect the download reason and discard or retain partial output explicitly."
17
+ }.freeze
18
+ def self.safe_operation(operation)
19
+ OPERATIONS.include?(operation) ? operation.dup.freeze : nil
20
+ end
21
+
22
+ def category
23
+ error = self # : Error
24
+ case error
25
+ when APIError then api_category(error.code)
26
+ when HTTPError then http_category(error.response.http_status)
27
+ when InvalidResponseError then :invalid_response
28
+ when TimeoutError then :timeout
29
+ when TransportError then :transport
30
+ else :unknown
31
+ end
32
+ end
33
+
34
+ def transient?
35
+ error = self # : Error
36
+ case error
37
+ when APIError then RetryPolicy::TRANSIENT_CODES.include?(error.code)
38
+ when HTTPError then [429, 502, 503, 504].include?(error.response.http_status)
39
+ when TimeoutError then true
40
+ when TransportError then error.retryable?
41
+ else false
42
+ end
43
+ end
44
+
45
+ def safe_to_retry?
46
+ name = operation
47
+ name.is_a?(String) && RetryPolicy::READ_ONLY.include?(name) && transient?
48
+ end
49
+
50
+ def request_id
51
+ response = diagnostic_response
52
+ @context_request_id || response&.headers&.[]("x-request-id")
53
+ end
54
+
55
+ def recovery_hint
56
+ HINTS.fetch(category, "Inspect the failure and reconcile mutations before repeating the operation.")
57
+ end
58
+
59
+ private
60
+
61
+ def api_category(code)
62
+ case code
63
+ when "invalid_api_key" then :authentication
64
+ when "access_denied" then :authorization
65
+ when "invalid_arg" then :invalid_argument
66
+ when "not_enough_money" then :payment
67
+ when "retry_later" then :transient
68
+ when "api_call_limit_exceeded_for_api_key", "api_call_limit_exceeded_for_ip" then :rate_limit
69
+ else :unknown
70
+ end
71
+ end
72
+
73
+ def http_category(status)
74
+ case status
75
+ when 401 then :authentication
76
+ when 403 then :authorization
77
+ when 429 then :rate_limit
78
+ when 500..599 then :service_unavailable
79
+ else :http
80
+ end
81
+ end
82
+ end
83
+ end
@@ -13,11 +13,62 @@ module Unisender
13
13
  createEmailTemplate deleteTemplate getTemplate getTemplates listTemplates updateEmailTemplate
14
14
  createField deleteField deleteTag getFields getTags updateField getContactFieldValues
15
15
  createSubscriberNote updateSubscriberNote deleteSubscriberNote getSubscriberNote getSubscriberNotes
16
- track_events
16
+ track_events setHook listHooks removeHook
17
17
  ].freeze
18
18
  private_constant :OPERATIONS
19
19
 
20
- attr_reader :operation
20
+ attr_reader :operation, :next_offset, :completed_pages, :last_response
21
+
22
+ def diagnostics
23
+ Diagnostics.for_error(self)
24
+ end
25
+
26
+ def documentation_url
27
+ Diagnostics::URLS[@operation.to_s]
28
+ end
29
+
30
+ def with_http(status:, request_id:)
31
+ @context_http_status = status
32
+ @context_request_id = request_id&.dup&.freeze
33
+ self
34
+ end
35
+
36
+ def http_status
37
+ response = diagnostic_response
38
+ @context_http_status || response&.http_status
39
+ end
40
+
41
+ def diagnostic_response
42
+ error = self # : Error
43
+ case error
44
+ when APIError, HTTPError, PartialFailure, TaskError, TaskTimeoutError then error.response
45
+ end
46
+ end
47
+
48
+ def bytes_written
49
+ @download_bytes_written
50
+ end
51
+
52
+ def with_download(bytes_written:)
53
+ @download_bytes_written = bytes_written
54
+ self
55
+ end
56
+
57
+ def task_uuid
58
+ @polling_task_uuid
59
+ end
60
+
61
+ def with_task(task_uuid:, last_response:)
62
+ @polling_task_uuid = task_uuid.dup.freeze
63
+ @last_response = last_response
64
+ self
65
+ end
66
+
67
+ def with_pagination(next_offset:, completed_pages:)
68
+ @next_offset = next_offset
69
+ @completed_pages = completed_pages
70
+ self
71
+ end
21
72
 
22
73
  def with_operation(operation)
23
74
  @operation = OPERATIONS.include?(operation) ? operation.dup.freeze : nil
@@ -44,6 +95,11 @@ module Unisender
44
95
 
45
96
  attr_reader :response, :code, :details
46
97
 
98
+ def safe_code
99
+ code = @code
100
+ code if code.is_a?(String) && SAFE_CODES.include?(code)
101
+ end
102
+
47
103
  def initialize(response)
48
104
  @response = response
49
105
  @code = response.data["code"]
@@ -58,11 +114,30 @@ module Unisender
58
114
 
59
115
  def initialize(response)
60
116
  @response = response
61
- super("HTTP #{response.status}")
117
+ super("HTTP #{response.http_status}")
62
118
  end
63
119
  end
64
120
 
65
- class InvalidResponseError < Error; end
121
+ # A malformed response with optional safe structural context.
122
+ class InvalidResponseError < Error
123
+ attr_reader :path, :expected_type, :actual_type
124
+
125
+ def initialize(message = "Invalid response", path: nil, expected_type: nil, actual_type: nil)
126
+ @path = path
127
+ @expected_type = expected_type
128
+ @actual_type = actual_type
129
+ super(message)
130
+ end
131
+
132
+ def prepend_path(prefix)
133
+ @path = @path ? "#{prefix}.#{@path}".freeze : prefix.dup.freeze
134
+ self
135
+ end
136
+
137
+ def to_s
138
+ @path ? "#{super} at #{@path}" : super
139
+ end
140
+ end
66
141
 
67
142
  # Network or TLS failure with no complete HTTP response.
68
143
  class TransportError < Error
@@ -80,6 +155,10 @@ module Unisender
80
155
 
81
156
  # A failed event request can still have accepted some events (HTTP 400).
82
157
  class EventError < HTTPError
158
+ def report
159
+ EventResult.new({ "success" => false, "uncertain" => response.http_status != 401 })
160
+ end
161
+
83
162
  def code
84
163
  response.data["code"]
85
164
  end
@@ -89,26 +168,60 @@ module Unisender
89
168
  end
90
169
 
91
170
  def possibly_partially_accepted?
92
- response.status == 400
171
+ response.http_status == 400
93
172
  end
94
173
  end
95
174
 
96
175
  # Carries the completed batches and the range whose outcome needs inspection.
97
176
  class BatchError < Error
98
- attr_reader :completed_responses, :failed_range, :error
177
+ attr_reader :completed_responses, :failed_range, :error, :report
99
178
 
100
- def initialize(completed_responses:, failed_range:, error:)
179
+ def initialize(completed_responses:, failed_range:, error:, report: nil)
101
180
  @completed_responses = completed_responses.dup.freeze
102
181
  @failed_range = failed_range.freeze
103
182
  @error = error
183
+ @report = report || BatchReport.new(@completed_responses, error:, failed_range:)
104
184
  super("UniSender batch failed; inspect the completed responses and failed range")
105
185
  end
186
+
187
+ def category
188
+ error = @error
189
+ error.is_a?(Error) || error.is_a?(ValidationError) ? error.category : :unexpected
190
+ end
191
+
192
+ def request_id
193
+ error = @error
194
+ error.request_id if error.is_a?(Error)
195
+ end
106
196
  end
107
197
 
108
- class PaginationError < Error; end
198
+ # Identifies the next unread offset when the page bound is reached.
199
+ class PaginationError < Error
200
+ def category
201
+ :pagination_limit
202
+ end
203
+
204
+ def initialize(operation:, next_offset:, completed_pages:)
205
+ @operation = operation
206
+ @next_offset = next_offset
207
+ @completed_pages = completed_pages
208
+ super("Pagination page limit reached")
209
+ end
210
+ end
211
+
212
+ # A singular operation returned no matching record.
213
+ class NotFoundError < Error
214
+ def category
215
+ :not_found
216
+ end
217
+ end
109
218
 
110
219
  # The API accepted the import request but rejected some records.
111
220
  class PartialFailure < Error
221
+ def category
222
+ :partial_failure
223
+ end
224
+
112
225
  attr_reader :response
113
226
 
114
227
  def initialize(response)
@@ -119,6 +232,10 @@ module Unisender
119
232
 
120
233
  # Includes the last response for an unexpected or failed asynchronous task state.
121
234
  class TaskError < Error
235
+ def category
236
+ :task_state
237
+ end
238
+
122
239
  attr_reader :response
123
240
 
124
241
  def initialize(response)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Chooses request boundaries before sending any event in a chunk.
5
+ module EventBatches
6
+ def self.each(events, size)
7
+ Batches.validate_size(size, 25)
8
+ Enumerator.new { |output| build(events, size, output) }
9
+ end
10
+
11
+ def self.build(events, size, output)
12
+ # @type var batch: Array[params]
13
+ batch = []
14
+ events.each_with_index do |event, index|
15
+ validate(event, index)
16
+ batch = append(batch, event, size, output)
17
+ end
18
+ output << batch unless batch.empty?
19
+ end
20
+
21
+ def self.append(batch, event, size, output)
22
+ if EventPayload.serialize(batch + [event]).bytesize > EventPayload::MAX_BYTES
23
+ output << batch
24
+ batch = [] # : Array[params]
25
+ end
26
+ batch << event
27
+ return batch unless batch.size == size
28
+
29
+ output << batch
30
+ []
31
+ end
32
+
33
+ def self.validate(event, index)
34
+ EventPayload.encode([event])
35
+ rescue ArgumentError
36
+ raise ValidationError.new(field: "events[#{index}]", code: :invalid_event, operation: "track_events", index:),
37
+ cause: nil
38
+ end
39
+ end
40
+ end
@@ -3,59 +3,87 @@
3
3
  module Unisender
4
4
  # Event Tracking uses JSON with its own batch and payload limits.
5
5
  module EventPayload
6
+ MAX_BYTES = 130 * 1024
7
+
6
8
  def self.encode(events)
7
- raise ArgumentError, "Expected 1 to 25 events" unless (1..25).cover?(events.size)
9
+ unless events.is_a?(Array)
10
+ raise ValidationError.new(field: "events", code: :invalid_type, expected_type: "Array",
11
+ actual_type: Diagnostics.type_name(events))
12
+ end
13
+ raise ValidationError.new(field: "events", code: :invalid_count) unless (1..25).cover?(events.size)
8
14
 
9
- normalized = events.map { |event| validate(normalize(event, 0)) }
10
- body = JSON.generate({ "events" => normalized })
11
- raise ArgumentError, "Event payload exceeds 130 KB" if body.bytesize > 130 * 1024
15
+ body = serialize(events)
16
+ raise ValidationError.new(field: "events", code: :too_large) if body.bytesize > MAX_BYTES
12
17
 
13
18
  body
19
+ end
20
+
21
+ def self.serialize(events)
22
+ normalized = events.each_with_index.map do |event, index|
23
+ path = "events[#{index}]"
24
+ validate(normalize(with_timestamp(event), 0, path), path)
25
+ end
26
+ JSON.generate({ "events" => normalized })
14
27
  rescue EncodingError, JSON::GeneratorError
15
- raise ArgumentError, "Events must contain valid JSON values and UTF-8 text", cause: nil
28
+ raise ValidationError.new(field: "events", code: :invalid_encoding), cause: nil
16
29
  end
17
30
 
18
- def self.validate(event)
19
- raise ArgumentError, "Each event must be an object" unless event.is_a?(Hash)
20
- raise ArgumentError, "Each event may have at most 25 fields" if event.size > 25
31
+ def self.with_timestamp(event)
32
+ return event unless event.is_a?(Hash)
33
+
34
+ event.to_h do |key, value|
35
+ item = key.to_s == "created" && value.is_a?(Time) ? value.getutc.strftime("%Y-%m-%d %H:%M:%S") : value
36
+ [key, item]
37
+ end
38
+ end
39
+
40
+ def self.validate(event, path)
41
+ unless event.is_a?(Hash)
42
+ raise ValidationError.new(field: path, code: :invalid_type, expected_type: "Hash",
43
+ actual_type: Diagnostics.type_name(event))
44
+ end
45
+ raise ValidationError.new(field: path, code: :too_many_fields) if event.size > 25
21
46
 
22
- validate_name(event["name"])
23
- validate_email(event["email"])
47
+ validate_name(event["name"], "#{path}.name")
48
+ validate_email(event["email"], "#{path}.email")
24
49
  event
25
50
  end
26
51
 
27
- def self.validate_name(name)
52
+ def self.validate_name(name, path)
28
53
  return if name.is_a?(String) && (1..40).cover?(name.size)
29
54
 
30
- raise ArgumentError, "Event name must contain 1 to 40 characters"
55
+ raise ValidationError.new(field: path, code: :invalid_name, expected_type: "String",
56
+ actual_type: Diagnostics.type_name(name))
31
57
  end
32
58
 
33
- def self.validate_email(email)
59
+ def self.validate_email(email, path)
34
60
  return if email.is_a?(String) && email.ascii_only? && email.match?(/\A[^\s@]+@[^\s@]+\z/)
35
61
 
36
- raise ArgumentError, "Event email must be an ASCII email address"
62
+ raise ValidationError.new(field: path, code: :invalid_email, expected_type: "String",
63
+ actual_type: Diagnostics.type_name(email))
37
64
  end
38
65
 
39
- def self.normalize(value, depth)
40
- raise ArgumentError, "Events are nested too deeply" if depth > 32
66
+ def self.normalize(value, depth, path)
67
+ raise ValidationError.new(field: path, code: :too_deep) if depth > 32
41
68
 
42
69
  case value
43
- when Hash then normalize_hash(value, depth)
44
- when Array then value.map { |item| normalize(item, depth + 1) }
70
+ when Hash then normalize_hash(value, depth, path)
71
+ when Array then value.each_with_index.map { |item, index| normalize(item, depth + 1, "#{path}[#{index}]") }
45
72
  when String then value.encode(Encoding::UTF_8)
46
73
  when Integer, Float, true, false, nil then value
47
- else raise ArgumentError, "Unsupported JSON value"
74
+ else raise ValidationError.new(field: path, code: :invalid_type, actual_type: Diagnostics.type_name(value))
48
75
  end
49
76
  end
50
77
 
51
- def self.normalize_hash(value, depth)
78
+ def self.normalize_hash(value, depth, path)
52
79
  # @type var result: Hash[String, json]
53
80
  result = {}
54
81
  value.each do |key, item|
55
82
  name = key.to_s.encode(Encoding::UTF_8)
56
- raise ArgumentError, "Duplicate event field names" if result.key?(name)
83
+ raise ValidationError.new(field: path, code: :duplicate_key) if result.key?(name)
57
84
 
58
- result[name] = normalize(item, depth + 1)
85
+ field = Diagnostics::FIELDS.include?(name) ? name : "*"
86
+ result[name] = normalize(item, depth + 1, "#{path}.#{field}")
59
87
  end
60
88
  result
61
89
  end
@@ -5,15 +5,31 @@ module Unisender
5
5
  class Client
6
6
  def track_events(events:)
7
7
  body = EventPayload.encode(events)
8
- headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}" }
9
- http = @transport.post(URI("https://event.unisender.com/v1/events/"), headers:, body:)
8
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{@api_key}",
9
+ "User-Agent" => USER_AGENT }
10
+ exchange("track_events", URI("https://event.unisender.com/v1/events/"), headers:, body:,
11
+ input_count: events.size) do |http|
12
+ decode_event_response(http, events.size)
13
+ end
14
+ rescue Error, ValidationError => e
15
+ raise e.with_operation("track_events"), cause: nil
16
+ end
17
+
18
+ private
19
+
20
+ def decode_event_response(http, count)
21
+ response = parse_event_response(http)
22
+ observe_acceptance("track_events", count, response, nil)
23
+ response
24
+ end
25
+
26
+ def parse_event_response(http)
10
27
  response = Response.new(data: Protocol.decode(http), status: http.status, headers: http.headers)
11
28
  raise EventError, response, cause: nil unless (200..299).cover?(http.status)
12
29
  raise InvalidResponseError, "Missing event success status", cause: nil unless response.data["success"] == true
13
30
 
14
- response # : Response[event_accepted]
15
- rescue Error => e
16
- raise e.with_operation("track_events"), cause: nil
31
+ EventResponse.new(data: response.data, status: response.http_status, headers: response.headers,
32
+ result: EventResult.new(response.data))
17
33
  end
18
34
  end
19
35
  end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Thread-safe cooperative cancellation. It never interrupts in-flight work.
5
+ class CancellationToken
6
+ def initialize
7
+ @cancelled = false
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def cancel
12
+ @mutex.synchronize { @cancelled = true }
13
+ end
14
+
15
+ def cancelled?
16
+ @mutex.synchronize { @cancelled }
17
+ end
18
+ end
19
+
20
+ # Cancellation reached a cooperative boundary.
21
+ class CancelledError < Error
22
+ def category
23
+ :cancelled
24
+ end
25
+ end
26
+
27
+ # The shared monotonic budget expired at a cooperative boundary.
28
+ class DeadlineExceededError < Error
29
+ def category
30
+ :deadline_exceeded
31
+ end
32
+ end
33
+
34
+ # Separate from request-attempt measurements and free of payloads.
35
+ ExecutionEvent = Data.define(:kind, :operation, :duration, :outcome)
36
+
37
+ # One monotonic budget shared by all work in a scoped client.
38
+ class ExecutionContext
39
+ def initialize(timeout: nil, cancellation: nil, parent: nil, instrumentation: nil, clock: nil)
40
+ if timeout && !(timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?)
41
+ raise ArgumentError, "Execution timeout must be positive and finite"
42
+ end
43
+
44
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
45
+ @deadline = @clock.call + Float(timeout) if timeout
46
+ @cancellation = cancellation
47
+ @parent = parent
48
+ @instrumentation = instrumentation
49
+ @closed = false
50
+ @owned_clients = []
51
+ end
52
+
53
+ def check!
54
+ raise ClientClosedError, "Execution scope is closed" if @closed
55
+
56
+ @parent&.check!
57
+ raise CancelledError, "Execution cancelled" if @cancellation&.cancelled?
58
+
59
+ deadline = @deadline
60
+ raise DeadlineExceededError, "Execution deadline exceeded" if deadline && @clock.call >= deadline
61
+ end
62
+
63
+ def close
64
+ @closed = true
65
+ @owned_clients.each(&:close)
66
+ @owned_clients.clear
67
+ end
68
+
69
+ def own(client)
70
+ @owned_clients << client
71
+ end
72
+
73
+ def remaining
74
+ check!
75
+ deadline = @deadline
76
+ values = [deadline ? deadline - @clock.call : nil, @parent&.remaining].compact
77
+ values.min
78
+ end
79
+
80
+ def wait(delay, kind, operation, sleeper)
81
+ measure(kind, operation) do
82
+ endpoint = @clock.call + delay
83
+ loop do
84
+ check!
85
+ remaining = endpoint - @clock.call
86
+ break unless remaining.positive?
87
+
88
+ sleeper.call([remaining, 0.05].min)
89
+ end
90
+ end
91
+ end
92
+
93
+ def measure(kind, operation)
94
+ started = @clock.call
95
+ outcome = :failure
96
+ begin
97
+ result = yield
98
+ outcome = :success
99
+ result
100
+ ensure
101
+ emit(ExecutionEvent.new(kind:, operation: Error.safe_operation(operation) || "workflow",
102
+ duration: @clock.call - started, outcome:))
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def emit(event)
109
+ @instrumentation&.call(event)
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ end
114
+ end