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
data/lib/unisender/response.rb
CHANGED
|
@@ -21,27 +21,30 @@ module Unisender
|
|
|
21
21
|
empty = []
|
|
22
22
|
EMPTY_WARNINGS = empty.freeze
|
|
23
23
|
|
|
24
|
-
attr_reader :data, :
|
|
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
|
-
@
|
|
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
|
|
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?
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
60
|
+
raise TaskTimeoutError, response unless remaining.positive?
|
|
47
61
|
|
|
48
|
-
@
|
|
49
|
-
raise
|
|
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
|