unisender-ruby 0.1.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 +7 -0
- data/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/docs/api.md +1063 -0
- data/examples/campaign.rb +13 -0
- data/examples/check_connection.rb +16 -0
- data/examples/events.rb +20 -0
- data/examples/export.rb +11 -0
- data/examples/import.rb +18 -0
- data/examples/offline.rb +26 -0
- data/examples/quickstart.rb +6 -0
- data/examples/subscribe.rb +12 -0
- data/lib/unisender/batches.rb +31 -0
- data/lib/unisender/client.rb +59 -0
- data/lib/unisender/errors.rb +129 -0
- data/lib/unisender/event_payload.rb +63 -0
- data/lib/unisender/events.rb +19 -0
- data/lib/unisender/form.rb +67 -0
- data/lib/unisender/helpers.rb +28 -0
- data/lib/unisender/operations/contacts_1.rb +51 -0
- data/lib/unisender/operations/contacts_2.rb +44 -0
- data/lib/unisender/operations/inputs_1.rb +48 -0
- data/lib/unisender/operations/messages_1.rb +58 -0
- data/lib/unisender/operations/messages_2.rb +54 -0
- data/lib/unisender/operations/messages_3.rb +12 -0
- data/lib/unisender/operations/notes_1.rb +38 -0
- data/lib/unisender/operations/statistics_1.rb +48 -0
- data/lib/unisender/operations/statistics_2.rb +18 -0
- data/lib/unisender/operations/templates_1.rb +45 -0
- data/lib/unisender/paginated_operations.rb +42 -0
- data/lib/unisender/pagination.rb +68 -0
- data/lib/unisender/protocol.rb +46 -0
- data/lib/unisender/response.rb +64 -0
- data/lib/unisender/retry_policy.rb +82 -0
- data/lib/unisender/task_waiter.rb +52 -0
- data/lib/unisender/transport.rb +53 -0
- data/lib/unisender/validation.rb +72 -0
- data/lib/unisender/version.rb +5 -0
- data/lib/unisender.rb +29 -0
- data/sig/operations.rbs +78 -0
- data/sig/unisender.rbs +218 -0
- metadata +87 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Inputs operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/inputs/createfield/
|
|
7
|
+
def create_field(name:, type:, public_name: nil)
|
|
8
|
+
params = { name:, type:, public_name: }
|
|
9
|
+
invoke("createField", params, required: %w[name type])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/inputs/deletefield/
|
|
13
|
+
def delete_field(id:)
|
|
14
|
+
params = { id: }
|
|
15
|
+
invoke("deleteField", params, required: %w[id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# https://www.unisender.com/ru/support/api/inputs/deletetag/
|
|
19
|
+
def delete_tag(id:)
|
|
20
|
+
params = { id: }
|
|
21
|
+
invoke("deleteTag", params, required: %w[id])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# https://www.unisender.com/ru/support/api/inputs/getfields/
|
|
25
|
+
# rubocop:disable-next Naming/AccessorMethodName -- Mirrors the documented operation.
|
|
26
|
+
def get_fields
|
|
27
|
+
invoke("getFields", {})
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# https://www.unisender.com/ru/support/api/inputs/gettags/
|
|
31
|
+
# rubocop:disable-next Naming/AccessorMethodName -- Mirrors the documented operation.
|
|
32
|
+
def get_tags
|
|
33
|
+
invoke("getTags", {})
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# https://www.unisender.com/ru/support/api/inputs/updatefield/
|
|
37
|
+
def update_field(id:, name:, public_name: nil)
|
|
38
|
+
params = { id:, name:, public_name: }
|
|
39
|
+
invoke("updateField", params, required: %w[id name])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# https://www.unisender.com/ru/support/api/inputs/getcontactfieldvalues/
|
|
43
|
+
def get_contact_field_values(email:, field_ids:)
|
|
44
|
+
params = { email:, field_ids: }
|
|
45
|
+
invoke("getContactFieldValues", params, required: %w[email field_ids], comma: %w[field_ids])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Messages operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/messages/cancel-campaign/
|
|
7
|
+
def cancel_campaign(campaign_id:)
|
|
8
|
+
params = { campaign_id: }
|
|
9
|
+
invoke("cancelCampaign", params, required: %w[campaign_id])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/messages/check-email/
|
|
13
|
+
def check_email(email_id:)
|
|
14
|
+
params = { email_id: }
|
|
15
|
+
invoke("checkEmail", params, required: %w[email_id], comma: %w[email_id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# https://www.unisender.com/ru/support/api/messages/check-sms/
|
|
19
|
+
def check_sms(sms_id:)
|
|
20
|
+
params = { sms_id: }
|
|
21
|
+
invoke("checkSms", params, required: %w[sms_id])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# https://www.unisender.com/ru/support/api/messages/createcampaign/
|
|
25
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
26
|
+
def create_campaign(message_id:, start_time: nil, timezone: nil, track_read: nil, track_links: nil,
|
|
27
|
+
contacts: nil, contacts_url: nil, track_ga: nil, payment_limit: nil, payment_currency: nil,
|
|
28
|
+
ga_medium: nil, ga_source: nil, ga_campaign: nil, ga_content: nil, ga_term: nil)
|
|
29
|
+
params = { message_id:, start_time:, timezone:, track_read:, track_links:, contacts:, contacts_url:,
|
|
30
|
+
track_ga:, payment_limit:, payment_currency:, ga_medium:, ga_source:, ga_campaign:,
|
|
31
|
+
ga_content:, ga_term: }
|
|
32
|
+
invoke("createCampaign", params, required: %w[message_id], comma: %w[contacts])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# https://www.unisender.com/ru/support/api/messages/createemailmessage/
|
|
36
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
37
|
+
def create_email_message(sender_name:, sender_email:, list_id:, subject: nil, body: nil, text_body: nil,
|
|
38
|
+
generate_text: nil, tag: nil, attachments: nil, lang: nil, template_id: nil,
|
|
39
|
+
system_template_id: nil,
|
|
40
|
+
wrap_type: nil, categories: nil)
|
|
41
|
+
params = { sender_name:, sender_email:, list_id:, subject:, body:, text_body:, generate_text:, tag:,
|
|
42
|
+
attachments:, lang:, template_id:, system_template_id:, wrap_type:, categories: }
|
|
43
|
+
invoke("createEmailMessage", params, required: %w[sender_name sender_email list_id], comma: %w[categories])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# https://www.unisender.com/ru/support/api/messages/createsmsmessage/
|
|
47
|
+
def create_sms_message(sender:, body:, list_id:, tag: nil)
|
|
48
|
+
params = { sender:, body:, list_id:, tag: }
|
|
49
|
+
invoke("createSmsMessage", params, required: %w[sender body list_id])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# https://www.unisender.com/ru/support/api/messages/deletemessage/
|
|
53
|
+
def delete_message(message_id:)
|
|
54
|
+
params = { message_id: }
|
|
55
|
+
invoke("deleteMessage", params, required: %w[message_id])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Messages operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/messages/get-actual-message-version/
|
|
7
|
+
def get_actual_message_version(message_id:)
|
|
8
|
+
params = { message_id: }
|
|
9
|
+
invoke("getActualMessageVersion", params, required: %w[message_id])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/messages/getwebversion/
|
|
13
|
+
def get_web_version(campaign_id:)
|
|
14
|
+
params = { campaign_id: }
|
|
15
|
+
invoke("getWebVersion", params, required: %w[campaign_id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# https://www.unisender.com/ru/support/api/messages/sendemail/
|
|
19
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
20
|
+
def send_email(email:, sender_name:, sender_email:, subject:, body:, list_id:, attachments: nil, lang: nil,
|
|
21
|
+
track_read: nil, track_links: nil, cc: nil, headers: nil, images_as: nil, ref_key: nil,
|
|
22
|
+
error_checking: nil, metadata: nil)
|
|
23
|
+
params = { email:, sender_name:, sender_email:, subject:, body:, list_id:, attachments:, lang:,
|
|
24
|
+
track_read:, track_links:, cc:, headers:, images_as:, ref_key:, error_checking:, metadata: }
|
|
25
|
+
invoke("sendEmail", params, required: %w[email sender_name sender_email subject body list_id])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# https://www.unisender.com/ru/support/api/messages/sendsms/
|
|
29
|
+
def send_sms(phone:, sender:, text:)
|
|
30
|
+
params = { phone:, sender:, text: }
|
|
31
|
+
invoke("sendSms", params, required: %w[phone sender text])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# https://www.unisender.com/ru/support/api/messages/sendtestemail/
|
|
35
|
+
def send_test_email(email:, id:)
|
|
36
|
+
params = { email:, id: }
|
|
37
|
+
invoke("sendTestEmail", params, required: %w[email id], comma: %w[email])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# https://www.unisender.com/ru/support/api/messages/updateemailmessage/
|
|
41
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
42
|
+
def update_email_message(id:, sender_name: nil, sender_email: nil, subject: nil, body: nil, list_id: nil,
|
|
43
|
+
text_body: nil, lang: nil, categories: nil)
|
|
44
|
+
params = { id:, sender_name:, sender_email:, subject:, body:, list_id:, text_body:, lang:, categories: }
|
|
45
|
+
invoke("updateEmailMessage", params, required: %w[id], comma: %w[categories])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# https://www.unisender.com/ru/support/api/messages/updateoptinemail/
|
|
49
|
+
def update_opt_in_email(sender_name:, sender_email:, subject:, body:, list_id:)
|
|
50
|
+
params = { sender_name:, sender_email:, subject:, body:, list_id: }
|
|
51
|
+
invoke("updateOptInEmail", params, required: %w[sender_name sender_email subject body list_id])
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Messages operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/messages/getsenderdomainlist/
|
|
7
|
+
def get_sender_domain_list(username:, domain: nil, limit: nil, offset: nil)
|
|
8
|
+
params = { username:, domain:, limit:, offset: }
|
|
9
|
+
PaginatedOperations.call(self, :get_sender_domain_list, params)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Notes operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/notes/createsubscibernote/
|
|
7
|
+
def create_subscriber_note(subscriber_id:, content:)
|
|
8
|
+
params = { subscriber_id:, content: }
|
|
9
|
+
invoke("createSubscriberNote", params, required: %w[subscriber_id content])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/notes/updatesubcribernote/
|
|
13
|
+
def update_subscriber_note(id:, content:)
|
|
14
|
+
params = { id:, content: }
|
|
15
|
+
invoke("updateSubscriberNote", params, required: %w[id content])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# https://www.unisender.com/ru/support/api/notes/deletesubscribernote/
|
|
19
|
+
def delete_subscriber_note(id:)
|
|
20
|
+
params = { id: }
|
|
21
|
+
invoke("deleteSubscriberNote", params, required: %w[id])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# https://www.unisender.com/ru/support/api/notes/getsubscribernote/
|
|
25
|
+
def get_subscriber_note(id:)
|
|
26
|
+
params = { id: }
|
|
27
|
+
invoke("getSubscriberNote", params, required: %w[id])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# https://www.unisender.com/ru/support/api/notes/getsubscribernotes/
|
|
31
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
32
|
+
def get_subscriber_notes(subscriber_id:, limit: nil, offset: nil, order_type: nil, order_by: nil,
|
|
33
|
+
is_pinned: nil)
|
|
34
|
+
params = { subscriber_id:, limit:, offset:, order_type:, order_by:, is_pinned: }
|
|
35
|
+
PaginatedOperations.call(self, :get_subscriber_notes, params)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Statistics operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/statistics/get-campaign-common-stats/
|
|
7
|
+
def get_campaign_common_stats(campaign_id:)
|
|
8
|
+
params = { campaign_id: }
|
|
9
|
+
invoke("getCampaignCommonStats", params, required: %w[campaign_id])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/statistics/getcampaigndeliverystats/
|
|
13
|
+
def get_campaign_delivery_stats(campaign_id:, notify_url: nil, changed_since: nil, field_ids: nil)
|
|
14
|
+
params = { campaign_id:, notify_url:, changed_since:, field_ids: }
|
|
15
|
+
invoke("async/getCampaignDeliveryStats", params, required: %w[campaign_id])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# https://www.unisender.com/ru/support/api/statistics/getcampaignstatus/
|
|
19
|
+
def get_campaign_status(campaign_id:)
|
|
20
|
+
params = { campaign_id: }
|
|
21
|
+
invoke("getCampaignStatus", params, required: %w[campaign_id])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# https://www.unisender.com/ru/support/api/statistics/getmessages/
|
|
25
|
+
def get_messages(date_from:, date_to:, limit: nil, offset: nil)
|
|
26
|
+
params = { date_from:, date_to:, limit:, offset: }
|
|
27
|
+
PaginatedOperations.call(self, :get_messages, params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# https://www.unisender.com/ru/support/api/statistics/getvisitedlinks/
|
|
31
|
+
def get_visited_links(campaign_id:, group: nil)
|
|
32
|
+
params = { campaign_id:, group: }
|
|
33
|
+
invoke("getVisitedLinks", params, required: %w[campaign_id])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# https://www.unisender.com/ru/support/api/statistics/listmessages/
|
|
37
|
+
def list_messages(date_from:, date_to:, limit: nil, offset: nil)
|
|
38
|
+
params = { date_from:, date_to:, limit:, offset: }
|
|
39
|
+
PaginatedOperations.call(self, :list_messages, params)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# https://www.unisender.com/ru/support/api/statistics/getcampaigns/
|
|
43
|
+
def get_campaigns(from:, to: nil, limit: nil, offset: nil)
|
|
44
|
+
params = { from:, to:, limit:, offset: }
|
|
45
|
+
PaginatedOperations.call(self, :get_campaigns, params)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Statistics operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/statistics/getmessage/
|
|
7
|
+
def get_message(id:)
|
|
8
|
+
params = { id: }
|
|
9
|
+
invoke("getMessage", params, required: %w[id])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# https://www.unisender.com/ru/support/api/statistics/../contacts/exportcontacts/
|
|
13
|
+
def get_task_result(task_uuid:)
|
|
14
|
+
params = { task_uuid: }
|
|
15
|
+
invoke("async/getTaskResult", params, required: %w[task_uuid])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Templates operations from the official UniSender API.
|
|
5
|
+
class Client
|
|
6
|
+
# https://www.unisender.com/ru/support/api/templates/createemailtemplate/
|
|
7
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
8
|
+
def create_email_template(title:, subject:, body:, description: nil, text_body: nil, lang: nil)
|
|
9
|
+
params = { title:, subject:, body:, description:, text_body:, lang: }
|
|
10
|
+
invoke("createEmailTemplate", params, required: %w[title subject body])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# https://www.unisender.com/ru/support/api/templates/deletetemplate/
|
|
14
|
+
def delete_template(template_id:)
|
|
15
|
+
params = { template_id: }
|
|
16
|
+
invoke("deleteTemplate", params, required: %w[template_id])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# https://www.unisender.com/ru/support/api/templates/gettemplate/
|
|
20
|
+
def get_template(template_id: nil, system_template_id: nil)
|
|
21
|
+
params = { template_id:, system_template_id: }
|
|
22
|
+
invoke("getTemplate", params)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# https://www.unisender.com/ru/support/api/templates/gettemplates/
|
|
26
|
+
def get_templates(type: nil, date_from: nil, date_to: nil, limit: nil, offset: nil)
|
|
27
|
+
params = { type:, date_from:, date_to:, limit:, offset: }
|
|
28
|
+
PaginatedOperations.call(self, :get_templates, params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# https://www.unisender.com/ru/support/api/templates/listtemplates/
|
|
32
|
+
def list_templates(type: nil, date_from: nil, date_to: nil, limit: nil, offset: nil)
|
|
33
|
+
params = { type:, date_from:, date_to:, limit:, offset: }
|
|
34
|
+
PaginatedOperations.call(self, :list_templates, params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# https://www.unisender.com/ru/support/api/templates/updateemailtemplate/
|
|
38
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
39
|
+
def update_email_template(template_id:, title: nil, description: nil, subject: nil, body: nil,
|
|
40
|
+
text_body: nil, lang: nil)
|
|
41
|
+
params = { template_id:, title:, description:, subject:, body:, text_body:, lang: }
|
|
42
|
+
invoke("updateEmailTemplate", params, required: %w[template_id])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Shared contracts for the operations that support offset pagination.
|
|
5
|
+
module PaginatedOperations
|
|
6
|
+
# @type var empty: Array[Symbol]
|
|
7
|
+
empty = []
|
|
8
|
+
empty.freeze
|
|
9
|
+
# @type var definitions: Hash[String, [String, Array[Symbol], Array[Symbol], Integer?]]
|
|
10
|
+
definitions = {
|
|
11
|
+
"get_campaigns" => ["getCampaigns", %i[from].freeze, %i[from to limit offset].freeze, 10_000],
|
|
12
|
+
"get_messages" => ["getMessages", %i[date_from date_to].freeze, %i[date_from date_to limit offset].freeze, 100],
|
|
13
|
+
"list_messages" => ["listMessages", %i[date_from date_to].freeze, %i[date_from date_to limit offset].freeze, 100],
|
|
14
|
+
"get_templates" => ["getTemplates", empty, %i[type date_from date_to limit offset].freeze, 100],
|
|
15
|
+
"list_templates" => ["listTemplates", empty, %i[type date_from date_to limit offset].freeze, 100],
|
|
16
|
+
"get_sender_domain_list" => ["getSenderDomainList", %i[username].freeze,
|
|
17
|
+
%i[username domain limit offset].freeze, 100],
|
|
18
|
+
"get_subscriber_notes" => ["getSubscriberNotes", %i[subscriber_id].freeze,
|
|
19
|
+
%i[subscriber_id limit offset order_type order_by is_pinned].freeze, nil]
|
|
20
|
+
}
|
|
21
|
+
DEFINITIONS = definitions.transform_values(&:freeze).freeze
|
|
22
|
+
private_constant :DEFINITIONS
|
|
23
|
+
|
|
24
|
+
def self.call(client, method, params)
|
|
25
|
+
wire, required, allowed, = definition(method)
|
|
26
|
+
raise ArgumentError, "Unknown operation parameter" unless params.keys.all? { |key| allowed.include?(key) }
|
|
27
|
+
|
|
28
|
+
Validation.required!(params, required.map(&:to_s))
|
|
29
|
+
Validation.operation!(wire, params)
|
|
30
|
+
client.request(wire, params:) # : Response[objects]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.page_limit(method)
|
|
34
|
+
definition(method).last
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.definition(method)
|
|
38
|
+
DEFINITIONS.fetch(method.to_s) { raise ArgumentError, "Unsupported paginated method" }
|
|
39
|
+
end
|
|
40
|
+
private_class_method :definition
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Offset pagination is supported only for operations that document it.
|
|
5
|
+
class Pagination
|
|
6
|
+
def initialize(client, method, page_size, max_pages, params)
|
|
7
|
+
@client = client
|
|
8
|
+
@method = method.to_s
|
|
9
|
+
@page_size = page_size
|
|
10
|
+
@max_pages = max_pages
|
|
11
|
+
@params = params.dup
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def each
|
|
15
|
+
Enumerator.new do |pages|
|
|
16
|
+
validate!(PaginatedOperations.page_limit(@method))
|
|
17
|
+
fetch_pages(pages, initial_offset)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def initial_offset
|
|
24
|
+
offset = @params.fetch(:offset, 0)
|
|
25
|
+
raise ArgumentError, "offset must be nonnegative" unless offset.is_a?(Integer) && offset >= 0
|
|
26
|
+
|
|
27
|
+
offset
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch_pages(pages, offset)
|
|
31
|
+
@max_pages.times do
|
|
32
|
+
response = PaginatedOperations.call(@client, @method, @params.merge(limit: @page_size, offset:))
|
|
33
|
+
count = page_length(response)
|
|
34
|
+
pages << response
|
|
35
|
+
return if count < @page_size
|
|
36
|
+
|
|
37
|
+
offset += count
|
|
38
|
+
end
|
|
39
|
+
raise PaginationError, "Pagination page limit reached"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate!(maximum)
|
|
43
|
+
validate_limit!(maximum)
|
|
44
|
+
raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages.positive?
|
|
45
|
+
raise ArgumentError, "Use page_size instead of limit" if @params.key?(:limit)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_limit!(maximum)
|
|
49
|
+
raise ArgumentError, "Invalid page_size" unless @page_size.is_a?(Integer) && @page_size.positive?
|
|
50
|
+
raise ArgumentError, "page_size exceeds the method limit" if maximum && @page_size > maximum
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def page_length(response)
|
|
54
|
+
result = response.result
|
|
55
|
+
raise InvalidResponseError, "Expected paginated result array" unless result.is_a?(Array)
|
|
56
|
+
return result.size unless @method == "get_sender_domain_list"
|
|
57
|
+
|
|
58
|
+
result.sum do |group|
|
|
59
|
+
raise InvalidResponseError, "Expected sender domain groups" unless group.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
domains = group["domains"]
|
|
62
|
+
raise InvalidResponseError, "Expected sender domain groups" unless domains.is_a?(Array)
|
|
63
|
+
|
|
64
|
+
domains.size
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Keeps HTTP failures separate from the API's success/error envelope.
|
|
5
|
+
module Protocol
|
|
6
|
+
def self.parse(http, method: nil)
|
|
7
|
+
data = decode(http)
|
|
8
|
+
response = Response.new(data:, status: http.status, headers: http.headers)
|
|
9
|
+
raise HTTPError, response, cause: nil unless (200..299).cover?(http.status)
|
|
10
|
+
raise APIError, response, cause: nil if data.key?("error")
|
|
11
|
+
raise InvalidResponseError, "Missing API result", cause: nil unless result?(data, method)
|
|
12
|
+
|
|
13
|
+
response
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# These method pages document success objects without the usual result envelope.
|
|
17
|
+
# https://www.unisender.com/ru/support/api/messages/sendtestemail/
|
|
18
|
+
# https://www.unisender.com/ru/support/api/inputs/updatefield/
|
|
19
|
+
def self.result?(data, method)
|
|
20
|
+
return true if data.key?("result")
|
|
21
|
+
|
|
22
|
+
case method
|
|
23
|
+
when "sendTestEmail" then data["message"].is_a?(String)
|
|
24
|
+
when "updateField" then data["id"].is_a?(Integer)
|
|
25
|
+
else false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.decode(http)
|
|
30
|
+
# @type var data: json
|
|
31
|
+
data = JSON.parse(http.body)
|
|
32
|
+
unless data.is_a?(Hash)
|
|
33
|
+
raise InvalidResponseError, "Expected a JSON object", cause: nil if (200..299).cover?(http.status)
|
|
34
|
+
|
|
35
|
+
return {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
data.delete("result") if data.key?("error")
|
|
39
|
+
data
|
|
40
|
+
rescue JSON::ParserError, EncodingError
|
|
41
|
+
raise InvalidResponseError, "Invalid JSON response", cause: nil if (200..299).cover?(http.status)
|
|
42
|
+
|
|
43
|
+
{}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Unisender
|
|
6
|
+
# Complete HTTP response at the transport boundary.
|
|
7
|
+
HTTPResponse = Data.define(:status, :headers, :body)
|
|
8
|
+
|
|
9
|
+
# Transport metadata with a payload-free inspection representation.
|
|
10
|
+
class HTTPResponse
|
|
11
|
+
def inspect
|
|
12
|
+
"#<Unisender::HTTPResponse status=#{status}>"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
alias to_s inspect
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A successful response. JSON values retain their original types and string keys.
|
|
19
|
+
class Response
|
|
20
|
+
# @type var empty: Array[json]
|
|
21
|
+
empty = []
|
|
22
|
+
EMPTY_WARNINGS = empty.freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :data, :status, :headers
|
|
25
|
+
|
|
26
|
+
def initialize(data:, status:, headers:)
|
|
27
|
+
@data = immutable_hash(data)
|
|
28
|
+
@status = status
|
|
29
|
+
@headers = headers.to_h { |key, value| [key.dup.freeze, value.dup.freeze] }.freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
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
|
+
def warnings
|
|
40
|
+
@data.fetch("warnings", EMPTY_WARNINGS)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect
|
|
44
|
+
"#<Unisender::Response status=#{@status}>"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def immutable_hash(value)
|
|
50
|
+
value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def immutable(value)
|
|
54
|
+
case value
|
|
55
|
+
when Hash
|
|
56
|
+
immutable_hash(value)
|
|
57
|
+
when Array
|
|
58
|
+
value.map { |item| immutable(item) }.freeze
|
|
59
|
+
else
|
|
60
|
+
value.dup.freeze
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Unisender
|
|
6
|
+
# Explicit retry policy; never retries a request that can change server state.
|
|
7
|
+
class RetryPolicy
|
|
8
|
+
READ_ONLY = %w[
|
|
9
|
+
getLists getTotalContactsCount getContactCount isContactInLists getContact checkEmail checkSms
|
|
10
|
+
getActualMessageVersion getWebVersion getSenderDomainList getCampaignCommonStats getCampaignStatus
|
|
11
|
+
getMessages getVisitedLinks listMessages getCampaigns getMessage getTemplate getTemplates listTemplates
|
|
12
|
+
getFields getTags getContactFieldValues getSubscriberNote getSubscriberNotes async/getTaskResult
|
|
13
|
+
].freeze
|
|
14
|
+
TRANSIENT_CODES = %w[retry_later api_call_limit_exceeded_for_api_key api_call_limit_exceeded_for_ip].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(max_retries: 0, max_elapsed: 60, clock: nil, sleeper: nil, random: nil)
|
|
17
|
+
validate!(max_retries, max_elapsed)
|
|
18
|
+
@max_retries = max_retries
|
|
19
|
+
@max_elapsed = max_elapsed
|
|
20
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
21
|
+
@sleeper = sleeper || ->(delay) { sleep(delay) }
|
|
22
|
+
@random = random || -> { rand }
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(operation)
|
|
27
|
+
started = @clock.call
|
|
28
|
+
attempts = 0
|
|
29
|
+
begin
|
|
30
|
+
yield
|
|
31
|
+
rescue Error => e
|
|
32
|
+
raise unless READ_ONLY.include?(operation) && attempts < @max_retries && transient?(e)
|
|
33
|
+
|
|
34
|
+
raise unless wait?(e, attempts, started + @max_elapsed)
|
|
35
|
+
|
|
36
|
+
attempts += 1
|
|
37
|
+
retry
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate!(max_retries, max_elapsed)
|
|
44
|
+
unless max_retries.is_a?(Integer) && (0..2).cover?(max_retries)
|
|
45
|
+
raise ArgumentError, "max_retries must be between 0 and 2"
|
|
46
|
+
end
|
|
47
|
+
return if max_elapsed.is_a?(Numeric) && max_elapsed.positive? && max_elapsed.finite?
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "max_elapsed must be positive and finite"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def wait?(error, attempts, deadline)
|
|
53
|
+
delay = [(2.0**attempts) + @random.call, retry_after(error)].max
|
|
54
|
+
return false if delay > 30 || @clock.call + delay >= deadline
|
|
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
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def retry_after(error)
|
|
71
|
+
return 0.0 unless error.is_a?(HTTPError)
|
|
72
|
+
|
|
73
|
+
value = error.response.headers["retry-after"]
|
|
74
|
+
return 0.0 unless value
|
|
75
|
+
return value.to_f if value.match?(/\A\d+(?:\.\d+)?\z/)
|
|
76
|
+
|
|
77
|
+
[Time.httpdate(value) - Time.now, 0.0].max
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
0.0
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|