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
@@ -21,27 +21,30 @@ module Unisender
21
21
  empty = []
22
22
  EMPTY_WARNINGS = empty.freeze
23
23
 
24
- attr_reader :data, :status, :headers
24
+ attr_reader :data, :http_status, :headers, :result
25
25
 
26
- def initialize(data:, status:, headers:)
26
+ def initialize(data:, status:, headers:, result: nil)
27
27
  @data = immutable_hash(data)
28
- @status = status
28
+ @http_status = status
29
+ @result = result.nil? ? @data.fetch("result", @data) : result
29
30
  @headers = headers.to_h { |key, value| [key.dup.freeze, value.dup.freeze] }.freeze
30
31
  freeze
31
32
  end
32
33
 
33
- # The parser retains JSON; named operation signatures describe its upstream shape.
34
- # @type method result: () -> json
35
- def result
36
- @data.fetch("result", @data)
37
- end
38
-
39
34
  def warnings
40
35
  @data.fetch("warnings", EMPTY_WARNINGS)
41
36
  end
42
37
 
38
+ def as_json(options = nil)
39
+ Serialization.payload(@result, options)
40
+ end
41
+
42
+ def to_json(state = nil)
43
+ Serialization.json(@result, state)
44
+ end
45
+
43
46
  def inspect
44
- "#<Unisender::Response status=#{@status}>"
47
+ "#<Unisender::Response http_status=#{@http_status}>"
45
48
  end
46
49
 
47
50
  private
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Result classes for named operations.
5
+ module Results
6
+ # @type var models: Hash[String, [singleton(Record), singleton(ObjectResponse)]]
7
+ models = {
8
+ "createList" => [IdentifierRecord, IdentifierRecordResponse],
9
+ "deleteList" => [Record, ObjectResponse],
10
+ "exclude" => [Record, ObjectResponse],
11
+ "async/exportContacts" => [TaskResult, TaskResultResponse],
12
+ "getContactCount" => [ContactCount, ContactCountResponse],
13
+ "getLists" => [List, ListResponse],
14
+ "getTotalContactsCount" => [TotalContacts, TotalContactsResponse],
15
+ "importContacts" => [ImportResult, ImportResponse],
16
+ "subscribe" => [Subscription, SubscriptionResponse],
17
+ "updateList" => [Record, ObjectResponse],
18
+ "getContact" => [Contact, ContactResponse],
19
+ "unsubscribe" => [Record, ObjectResponse],
20
+ "cancelCampaign" => [Record, ObjectResponse],
21
+ "checkEmail" => [EmailDelivery, ObjectResponse],
22
+ "checkSms" => [Status, StatusResponse],
23
+ "createCampaign" => [CampaignCreated, CampaignCreatedResponse],
24
+ "createEmailMessage" => [MessageCreated, MessageCreatedResponse],
25
+ "createSmsMessage" => [MessageCreated, MessageCreatedResponse],
26
+ "deleteMessage" => [Record, ObjectResponse],
27
+ "getActualMessageVersion" => [MessageVersion, MessageVersionResponse],
28
+ "getWebVersion" => [WebVersion, WebVersionResponse],
29
+ "sendEmail" => [SendResult, SendResponse],
30
+ "sendSms" => [SmsReceipt, ObjectResponse],
31
+ "sendTestEmail" => [MessageNotice, MessageNoticeResponse],
32
+ "updateEmailMessage" => [MessageCreated, MessageCreatedResponse],
33
+ "updateOptInEmail" => [Record, ObjectResponse],
34
+ "getSenderDomainList" => [SenderDomainGroup, ObjectResponse],
35
+ "getCampaignCommonStats" => [CampaignStats, CampaignStatsResponse],
36
+ "async/getCampaignDeliveryStats" => [TaskResult, TaskResultResponse],
37
+ "getCampaignStatus" => [Status, StatusResponse],
38
+ "getMessages" => [Message, MessageResponse],
39
+ "getVisitedLinks" => [Record, ObjectResponse],
40
+ "listMessages" => [Message, MessageResponse],
41
+ "getCampaigns" => [Campaign, CampaignResponse],
42
+ "getMessage" => [Message, MessageResponse],
43
+ "async/getTaskResult" => [TaskResult, TaskResultResponse],
44
+ "createEmailTemplate" => [IdentifierRecord, IdentifierRecordResponse],
45
+ "deleteTemplate" => [Record, ObjectResponse],
46
+ "getTemplate" => [Template, TemplateResponse],
47
+ "getTemplates" => [Template, TemplateResponse],
48
+ "listTemplates" => [Template, TemplateResponse],
49
+ "updateEmailTemplate" => [IdentifierRecord, IdentifierRecordResponse],
50
+ "createField" => [IdentifierRecord, IdentifierRecordResponse],
51
+ "deleteField" => [Record, ObjectResponse],
52
+ "deleteTag" => [Record, ObjectResponse],
53
+ "getFields" => [Field, FieldResponse],
54
+ "getTags" => [Tag, TagResponse],
55
+ "updateField" => [OptionalIdentifier, OptionalIdentifierResponse],
56
+ "getContactFieldValues" => [ContactFields, ContactFieldsResponse],
57
+ "createSubscriberNote" => [IdentifierRecord, IdentifierRecordResponse],
58
+ "updateSubscriberNote" => [IdentifierRecord, IdentifierRecordResponse],
59
+ "deleteSubscriberNote" => [Record, ObjectResponse],
60
+ "getSubscriberNote" => [Note, NoteResponse],
61
+ "getSubscriberNotes" => [Note, NoteResponse],
62
+ "listHooks" => [Hook, ObjectResponse]
63
+ }
64
+ MODELS = models.freeze
65
+ COLLECTION_OPERATIONS = %w[getLists checkEmail sendSms getSenderDomainList getMessages listMessages
66
+ getCampaigns getVisitedLinks getTemplates listTemplates getFields getTags
67
+ getSubscriberNotes listHooks].freeze
68
+ EMPTY_OPERATIONS = %w[deleteList exclude updateList unsubscribe cancelCampaign deleteMessage
69
+ sendTestEmail updateField updateOptInEmail deleteTemplate deleteField
70
+ deleteTag deleteSubscriberNote].freeze
71
+ end
72
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Operation-specific decoding leaves the low-level request path unchanged.
5
+ module Results
6
+ def self.build(method, response)
7
+ return boolean(response) if method == "isContactInLists"
8
+
9
+ value = normalize(method, response.result)
10
+ validate_container(method, value)
11
+ model, wrapper = MODELS.fetch(method) { [Record, ObjectResponse] }
12
+ wrap(value, model, wrapper, response)
13
+ end
14
+
15
+ def self.boolean(response)
16
+ shape_error("Boolean", response.result, "result") unless [true, false].include?(response.result)
17
+
18
+ response
19
+ end
20
+
21
+ def self.validate_container(method, value)
22
+ collection = COLLECTION_OPERATIONS.include?(method)
23
+ return if collection == value.is_a?(Array)
24
+
25
+ shape_error(collection ? "Array" : "Hash", value, "result")
26
+ end
27
+
28
+ def self.normalize(method, value)
29
+ return normalize_send(method, value) if %w[sendSms sendEmail].include?(method)
30
+ return single_message(value) if method == "getMessage"
31
+ return rows(value) if method == "getVisitedLinks"
32
+ return {} if EMPTY_OPERATIONS.include?(method) && value == []
33
+
34
+ value
35
+ end
36
+
37
+ def self.normalize_send(method, value)
38
+ return value.is_a?(Hash) ? [value] : value if method == "sendSms"
39
+
40
+ value.is_a?(Array) ? { "recipients" => value } : value
41
+ end
42
+
43
+ def self.single_message(value)
44
+ raise NotFoundError, "Message not found" if value.is_a?(Array) && value.empty?
45
+
46
+ shape_error("Array with one message", value, "result") unless value.is_a?(Array) && value.size == 1
47
+
48
+ value.first
49
+ end
50
+
51
+ def self.wrap(value, model, wrapper, response)
52
+ if value.is_a?(Array)
53
+ records = value.each_with_index.map { |item, index| object(model, item, "result[#{index}]") }.freeze
54
+ CollectionResponse.new(data: response.data, status: response.http_status, headers: response.headers,
55
+ result: records)
56
+ else
57
+ wrapper.new(data: response.data, status: response.http_status, headers: response.headers,
58
+ result: object(model, value))
59
+ end
60
+ end
61
+
62
+ def self.object(model, value, path = "result")
63
+ unless value.is_a?(Hash)
64
+ raise InvalidResponseError.new("Expected result object", expected_type: "Hash",
65
+ actual_type: Diagnostics.type_name(value))
66
+ end
67
+
68
+ model.new(value)
69
+ rescue InvalidResponseError => e
70
+ raise e.prepend_path(path), cause: nil
71
+ end
72
+
73
+ def self.rows(value)
74
+ shape_error("Hash", value, "result") unless value.is_a?(Hash)
75
+ names, data = value.values_at("fields", "data")
76
+ shape_error("Array", names, "result.fields") unless names.is_a?(Array)
77
+ shape_error("Array", data, "result.data") unless data.is_a?(Array)
78
+
79
+ fields = field_names(names)
80
+ data.each_with_index.map { |row, index| row_object(fields, row, index) }
81
+ end
82
+
83
+ def self.row_object(fields, row, index)
84
+ unless row.is_a?(Array) && row.size == fields.size
85
+ shape_error("Array with #{fields.size} cells", row, "result.data[#{index}]")
86
+ end
87
+
88
+ fields.zip(row).to_h
89
+ end
90
+
91
+ def self.field_names(value)
92
+ value.each_with_index do |name, index|
93
+ shape_error("String", name, "result.fields[#{index}]") unless name.is_a?(String)
94
+ end
95
+ shape_error("Unique field names", value, "result.fields") unless value.uniq == value
96
+
97
+ value # : Array[String]
98
+ end
99
+
100
+ def self.shape_error(expected, value, path)
101
+ raise InvalidResponseError.new("Unexpected result shape", path:, expected_type: expected,
102
+ actual_type: Diagnostics.type_name(value))
103
+ end
104
+ end
105
+ end
@@ -9,7 +9,7 @@ module Unisender
9
9
  getLists getTotalContactsCount getContactCount isContactInLists getContact checkEmail checkSms
10
10
  getActualMessageVersion getWebVersion getSenderDomainList getCampaignCommonStats getCampaignStatus
11
11
  getMessages getVisitedLinks listMessages getCampaigns getMessage getTemplate getTemplates listTemplates
12
- getFields getTags getContactFieldValues getSubscriberNote getSubscriberNotes async/getTaskResult
12
+ getFields getTags getContactFieldValues getSubscriberNote getSubscriberNotes async/getTaskResult listHooks
13
13
  ].freeze
14
14
  TRANSIENT_CODES = %w[retry_later api_call_limit_exceeded_for_api_key api_call_limit_exceeded_for_ip].freeze
15
15
 
@@ -23,15 +23,15 @@ module Unisender
23
23
  freeze
24
24
  end
25
25
 
26
- def call(operation)
26
+ def call(operation, execution: nil)
27
27
  started = @clock.call
28
28
  attempts = 0
29
29
  begin
30
30
  yield
31
31
  rescue Error => e
32
- raise unless READ_ONLY.include?(operation) && attempts < @max_retries && transient?(e)
32
+ raise unless READ_ONLY.include?(operation) && attempts < @max_retries && e.transient?
33
33
 
34
- raise unless wait?(e, attempts, started + @max_elapsed)
34
+ raise unless wait?(e, attempts, started + @max_elapsed, execution, operation)
35
35
 
36
36
  attempts += 1
37
37
  retry
@@ -49,22 +49,16 @@ module Unisender
49
49
  raise ArgumentError, "max_elapsed must be positive and finite"
50
50
  end
51
51
 
52
- def wait?(error, attempts, deadline)
52
+ def wait?(error, attempts, deadline, execution, operation)
53
53
  delay = [(2.0**attempts) + @random.call, retry_after(error)].max
54
54
  return false if delay > 30 || @clock.call + delay >= deadline
55
55
 
56
- @sleeper.call(delay)
57
- @clock.call < deadline
58
- end
59
-
60
- def transient?(error)
61
- case error
62
- when APIError then TRANSIENT_CODES.include?(error.code)
63
- when HTTPError then [429, 502, 503, 504].include?(error.response.status)
64
- when TimeoutError then true
65
- when TransportError then error.retryable?
66
- else false
56
+ if execution
57
+ execution.wait(delay, :retry_wait, operation, @sleeper)
58
+ else
59
+ @sleeper.call(delay)
67
60
  end
61
+ @clock.call < deadline
68
62
  end
69
63
 
70
64
  def retry_after(error)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Filtering selects root fields only; nested values remain detached copies.
5
+ module Serialization
6
+ OPTIONS = %w[only except].freeze
7
+ def self.payload(value, options = nil)
8
+ data = Record.copy(value)
9
+ fields = options_for(options)
10
+ fields.empty? ? data : filter(data, fields)
11
+ end
12
+
13
+ def self.json(value, options = nil)
14
+ return payload(value).to_json(options) if options.is_a?(JSON::State)
15
+
16
+ payload(value, options).to_json
17
+ end
18
+
19
+ def self.options_for(options)
20
+ return {} if options.nil?
21
+ raise ArgumentError, "Serialization options must be a hash" unless options.is_a?(Hash)
22
+
23
+ result = {} # : Hash[Symbol, Array[String]]
24
+ options.each do |key, value|
25
+ unless OPTIONS.include?(key.to_s) && !result.key?(key.to_s.to_sym)
26
+ raise ArgumentError, "Unknown or duplicate serialization option"
27
+ end
28
+
29
+ result[key.to_s.to_sym] = field_names(value)
30
+ end
31
+ result
32
+ end
33
+
34
+ def self.field_names(value)
35
+ names = value.is_a?(Array) ? value : [value]
36
+ unless names.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
37
+ raise ArgumentError, "Serialization field names must be strings or symbols"
38
+ end
39
+
40
+ names.map(&:to_s)
41
+ end
42
+
43
+ def self.filter(data, fields)
44
+ case data
45
+ when Array then data.map { |item| filter(item, fields) }
46
+ when Hash
47
+ only = fields[:only]
48
+ result = only ? data.slice(*only) : data
49
+ result.except(*fields.fetch(:except, []))
50
+ else raise ArgumentError, "Serialization filters require object results"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # Documented campaign stats values.
5
+ class CampaignStats < Record
6
+ COUNTERS = %w[total sent delivered read_unique read_all clicked_unique clicked_all unsubscribed spam].freeze
7
+ def total
8
+ integer("total")
9
+ end
10
+
11
+ def sent
12
+ integer("sent")
13
+ end
14
+
15
+ def delivered
16
+ integer("delivered")
17
+ end
18
+
19
+ def read_unique
20
+ integer("read_unique")
21
+ end
22
+
23
+ def read_all
24
+ integer("read_all")
25
+ end
26
+
27
+ def clicked_unique
28
+ integer("clicked_unique")
29
+ end
30
+
31
+ def clicked_all
32
+ integer("clicked_all")
33
+ end
34
+
35
+ def unsubscribed
36
+ integer("unsubscribed")
37
+ end
38
+
39
+ def spam
40
+ integer("spam")
41
+ end
42
+
43
+ protected
44
+
45
+ def normalize
46
+ COUNTERS.each { |name| @values[name] = integer(name) if @values.key?(name) }
47
+ end
48
+ end
49
+
50
+ # Transport metadata and CampaignStats readers.
51
+ class CampaignStatsResponse < ObjectResponse
52
+ def total
53
+ result.total
54
+ end
55
+
56
+ def sent
57
+ result.sent
58
+ end
59
+
60
+ def delivered
61
+ result.delivered
62
+ end
63
+
64
+ def read_unique
65
+ result.read_unique
66
+ end
67
+
68
+ def read_all
69
+ result.read_all
70
+ end
71
+
72
+ def clicked_unique
73
+ result.clicked_unique
74
+ end
75
+
76
+ def clicked_all
77
+ result.clicked_all
78
+ end
79
+
80
+ def unsubscribed
81
+ result.unsubscribed
82
+ end
83
+
84
+ def spam
85
+ result.spam
86
+ end
87
+ end
88
+
89
+ # Documented sms receipt values.
90
+ class SmsReceipt < Record
91
+ def sms_id
92
+ identifier!("sms_id")
93
+ end
94
+
95
+ def price
96
+ number("price")
97
+ end
98
+
99
+ def currency
100
+ text("currency")
101
+ end
102
+
103
+ protected
104
+
105
+ def normalize
106
+ @values["sms_id"] = sms_id
107
+ @values["price"] = price if @values.key?("price")
108
+ @values["currency"] = currency if @values.key?("currency")
109
+ end
110
+ end
111
+
112
+ # Documented email delivery values.
113
+ class EmailDelivery < Record
114
+ def id
115
+ self["id"].nil? ? nil : identifier!("id")
116
+ end
117
+
118
+ def status
119
+ text("status")
120
+ end
121
+
122
+ protected
123
+
124
+ def normalize
125
+ @values["id"] = id if @values.key?("id")
126
+ @values["status"] = status if @values.key?("status")
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ # A task handle polls only when the caller requests it.
5
+ class TaskResponse < TaskResultResponse
6
+ attr_reader :task_uuid
7
+
8
+ def initialize(response, client:, task_uuid:)
9
+ @client = client
10
+ @task_uuid = task_uuid.dup.freeze
11
+ super(data: response.data, status: response.http_status, headers: response.headers, result: response.result)
12
+ end
13
+
14
+ def wait(interval: 1, timeout: 60, clock: nil, sleeper: nil, &)
15
+ if completed? || failed?
16
+ yield self if block_given?
17
+ raise TaskFailedError.new(self).with_task(task_uuid:, last_response: self) if failed?
18
+
19
+ return self
20
+ end
21
+
22
+ @client.wait_for_task(task_uuid:, interval:, timeout:, clock:, sleeper:, &)
23
+ end
24
+
25
+ def completed?
26
+ status == "completed"
27
+ end
28
+
29
+ def failed?
30
+ status == "failed"
31
+ end
32
+
33
+ def pending?
34
+ TaskWaiter::PENDING.include?(status)
35
+ end
36
+
37
+ def download_to(io, max_bytes:, downloader: Downloader.new)
38
+ @client.check_execution!
39
+ raise TaskError, self unless status == "completed"
40
+
41
+ url = file_to_download
42
+ raise InvalidResponseError, "Missing task download URL" unless url
43
+
44
+ download_file(url, io, max_bytes, downloader)
45
+ end
46
+
47
+ private
48
+
49
+ def download_file(url, io, max_bytes, downloader)
50
+ case downloader
51
+ when Downloader
52
+ # rubocop:disable-next Style/ClassEqualityComparison -- Preserve custom subclass types in Steep's fallback.
53
+ if downloader.class == Downloader
54
+ return downloader.download(url, io, max_bytes:, execution: @client.execution_context)
55
+ end
56
+ end
57
+ downloader.download(url, io, max_bytes:)
58
+ end
59
+ end
60
+
61
+ # A task reached the failed terminal state.
62
+ class TaskFailedError < TaskError
63
+ def category
64
+ :task_failed
65
+ end
66
+ end
67
+
68
+ # A task returned a state not documented by the protocol.
69
+ class UnknownTaskStateError < TaskError
70
+ def category
71
+ :unknown_task_state
72
+ end
73
+ end
74
+
75
+ # A polling deadline retains the last known task state.
76
+ class TaskTimeoutError < TimeoutError
77
+ attr_reader :response, :task_uuid
78
+
79
+ def initialize(response)
80
+ @response = response
81
+ @task_uuid = response.task_uuid
82
+ super("Timed out waiting for the UniSender task")
83
+ end
84
+ end
85
+ end
@@ -5,24 +5,38 @@ module Unisender
5
5
  class TaskWaiter
6
6
  PENDING = %w[new processing].freeze
7
7
 
8
- def initialize(client, clock:, sleeper:)
8
+ def initialize(client, clock:, sleeper:, progress: nil)
9
9
  @client = client
10
10
  @clock = clock
11
11
  @sleeper = sleeper
12
+ @progress = progress
12
13
  end
13
14
 
14
15
  def call(task_uuid, interval, timeout)
15
- validate!(interval, timeout)
16
- deadline = @clock.call + timeout
16
+ poll(task_uuid, interval, deadline_for(interval, timeout))
17
+ end
18
+
19
+ private
20
+
21
+ def poll(task_uuid, interval, deadline)
22
+ # @type var last_response: TaskResponse?
23
+ last_response = nil
17
24
  loop do
18
25
  response = @client.get_task_result(task_uuid:)
26
+ last_response = response
27
+ @progress&.call(response)
19
28
  return response if complete?(response)
20
29
 
21
- wait(interval, deadline)
30
+ wait(interval, deadline, response)
22
31
  end
32
+ rescue Error => e
33
+ raise e.with_task(task_uuid:, last_response:), cause: nil
23
34
  end
24
35
 
25
- private
36
+ def deadline_for(interval, timeout)
37
+ validate!(interval, timeout)
38
+ @clock.call + timeout
39
+ end
26
40
 
27
41
  def validate!(interval, timeout)
28
42
  [interval, timeout].each do |value|
@@ -34,19 +48,19 @@ module Unisender
34
48
 
35
49
  def complete?(response)
36
50
  result = response.result
37
- raise TaskError, response unless result.is_a?(Hash)
38
51
  return true if result["status"] == "completed"
39
- raise TaskError, response unless PENDING.include?(result["status"])
52
+ raise TaskFailedError, response if result["status"] == "failed"
53
+ raise UnknownTaskStateError, response unless PENDING.include?(result["status"])
40
54
 
41
55
  false
42
56
  end
43
57
 
44
- def wait(interval, deadline)
58
+ def wait(interval, deadline, response)
45
59
  remaining = deadline - @clock.call
46
- raise TimeoutError, "Timed out waiting for the UniSender task" unless remaining.positive?
60
+ raise TaskTimeoutError, response unless remaining.positive?
47
61
 
48
- @sleeper.call([interval, remaining].min)
49
- raise TimeoutError, "Timed out waiting for the UniSender task" if @clock.call >= deadline
62
+ @client.execution_wait([interval, remaining].min, :poll_wait, "async/getTaskResult", @sleeper)
63
+ raise TaskTimeoutError, response if @clock.call >= deadline
50
64
  end
51
65
  end
52
66
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ module Testing
5
+ # Compare expected subsets without including payload values in failure messages.
6
+ module Assertions
7
+ def self.check(request, form, json)
8
+ subset!(request.form, form, "") unless form.nil?
9
+ subset!(request.json, json, "") unless json.nil?
10
+ rescue JSON::ParserError
11
+ raise "Expected JSON request body", cause: nil
12
+ end
13
+
14
+ def self.validate(form, json)
15
+ raise ArgumentError, "Choose expected_form or expected_json" unless form.nil? || json.nil?
16
+ return unless form&.key?("api_key")
17
+
18
+ raise ArgumentError, "Credentials cannot be request expectations"
19
+ end
20
+
21
+ def self.subset!(actual, expected, path)
22
+ case expected
23
+ when Hash then hash!(actual, expected, path)
24
+ when Array then array!(actual, expected, path)
25
+ else mismatch!(path) unless actual == expected
26
+ end
27
+ end
28
+
29
+ def self.hash!(actual, expected, path)
30
+ mismatch!(path) unless actual.is_a?(Hash)
31
+ expected.each do |key, value|
32
+ child = path.empty? ? key : "#{path}.#{key}"
33
+ mismatch!(child) unless actual.key?(key)
34
+ subset!(actual[key], value, child)
35
+ end
36
+ end
37
+
38
+ def self.array!(actual, expected, path)
39
+ mismatch!(path) unless actual.is_a?(Array) && actual.size >= expected.size
40
+ expected.each_with_index { |value, index| subset!(actual[index], value, "#{path}[#{index}]") }
41
+ end
42
+
43
+ def self.mismatch!(path)
44
+ raise "Unexpected request value at #{Diagnostics.input_path(path)}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unisender
4
+ module Testing
5
+ # Wire-shaped values for common application tests, without a response envelope.
6
+ module Fixtures
7
+ def self.list(id:, title:)
8
+ { "id" => id, "title" => title }
9
+ end
10
+
11
+ def self.import_result(total:, invalid: 0, log: [])
12
+ { "total" => total, "invalid" => invalid, "log" => log }
13
+ end
14
+
15
+ def self.task(status:, task_uuid:, file_to_download: nil)
16
+ { "status" => status, "task_uuid" => task_uuid, "file_to_download" => file_to_download }.compact
17
+ end
18
+
19
+ def self.recipient(index:, email:, id: nil, errors: [])
20
+ { "index" => index, "email" => email, "id" => id, "errors" => errors }.compact
21
+ end
22
+ end
23
+ end
24
+ end