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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/CONTRIBUTING.md +43 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +140 -0
  6. data/docs/api.md +1063 -0
  7. data/examples/campaign.rb +13 -0
  8. data/examples/check_connection.rb +16 -0
  9. data/examples/events.rb +20 -0
  10. data/examples/export.rb +11 -0
  11. data/examples/import.rb +18 -0
  12. data/examples/offline.rb +26 -0
  13. data/examples/quickstart.rb +6 -0
  14. data/examples/subscribe.rb +12 -0
  15. data/lib/unisender/batches.rb +31 -0
  16. data/lib/unisender/client.rb +59 -0
  17. data/lib/unisender/errors.rb +129 -0
  18. data/lib/unisender/event_payload.rb +63 -0
  19. data/lib/unisender/events.rb +19 -0
  20. data/lib/unisender/form.rb +67 -0
  21. data/lib/unisender/helpers.rb +28 -0
  22. data/lib/unisender/operations/contacts_1.rb +51 -0
  23. data/lib/unisender/operations/contacts_2.rb +44 -0
  24. data/lib/unisender/operations/inputs_1.rb +48 -0
  25. data/lib/unisender/operations/messages_1.rb +58 -0
  26. data/lib/unisender/operations/messages_2.rb +54 -0
  27. data/lib/unisender/operations/messages_3.rb +12 -0
  28. data/lib/unisender/operations/notes_1.rb +38 -0
  29. data/lib/unisender/operations/statistics_1.rb +48 -0
  30. data/lib/unisender/operations/statistics_2.rb +18 -0
  31. data/lib/unisender/operations/templates_1.rb +45 -0
  32. data/lib/unisender/paginated_operations.rb +42 -0
  33. data/lib/unisender/pagination.rb +68 -0
  34. data/lib/unisender/protocol.rb +46 -0
  35. data/lib/unisender/response.rb +64 -0
  36. data/lib/unisender/retry_policy.rb +82 -0
  37. data/lib/unisender/task_waiter.rb +52 -0
  38. data/lib/unisender/transport.rb +53 -0
  39. data/lib/unisender/validation.rb +72 -0
  40. data/lib/unisender/version.rb +5 -0
  41. data/lib/unisender.rb +29 -0
  42. data/sig/operations.rbs +78 -0
  43. data/sig/unisender.rbs +218 -0
  44. 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