unisender-ruby 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/CODE_OF_CONDUCT.md +90 -0
- data/CONTRIBUTING.md +23 -2
- data/README.md +53 -90
- data/SECURITY.md +13 -0
- 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 +22 -0
- 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 +2 -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 +88 -4
data/lib/unisender/form.rb
CHANGED
|
@@ -6,11 +6,11 @@ module Unisender
|
|
|
6
6
|
def self.encode(params)
|
|
7
7
|
URI.encode_www_form(pairs(params, nil, 0))
|
|
8
8
|
rescue EncodingError
|
|
9
|
-
raise
|
|
9
|
+
raise ValidationError.new(field: "parameters", code: :invalid_encoding), cause: nil
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def self.pairs(value, prefix, depth)
|
|
13
|
-
raise
|
|
13
|
+
raise ValidationError.new(field: prefix.to_s, code: :too_deep) if depth > 32
|
|
14
14
|
|
|
15
15
|
case value
|
|
16
16
|
when Hash
|
|
@@ -24,43 +24,51 @@ module Unisender
|
|
|
24
24
|
|
|
25
25
|
def self.hash_pairs(value, prefix, depth)
|
|
26
26
|
keys = value.keys.map(&:to_s)
|
|
27
|
-
raise
|
|
27
|
+
raise ValidationError.new(field: "parameters", code: :duplicate_key) unless keys.uniq.size == keys.size
|
|
28
28
|
|
|
29
29
|
value.flat_map do |key, item|
|
|
30
|
-
name = key
|
|
31
|
-
raise ArgumentError, "Parameter names cannot contain brackets" if name.match?(/[\[\]]/)
|
|
30
|
+
name = key_name(key)
|
|
32
31
|
next [] if item.nil?
|
|
33
32
|
|
|
34
33
|
pairs(item, prefix ? "#{prefix}[#{name}]" : name, depth + 1)
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
37
|
+
def self.key_name(key)
|
|
38
|
+
name = key.to_s.encode(Encoding::UTF_8)
|
|
39
|
+
raise ValidationError.new(field: "parameters", code: :invalid_key) if name.match?(/[\[\]]/)
|
|
40
|
+
|
|
41
|
+
name
|
|
42
|
+
end
|
|
43
|
+
|
|
38
44
|
def self.scalar(value, name)
|
|
39
45
|
case value
|
|
40
46
|
when String
|
|
41
|
-
name.start_with?("attachments[") ? value.b : text(value)
|
|
47
|
+
name.start_with?("attachments[") ? value.b : text(value, name)
|
|
42
48
|
when true then "1"
|
|
43
49
|
when false then "0"
|
|
44
50
|
when nil then ""
|
|
45
|
-
else numeric(value)
|
|
51
|
+
else numeric(value, name)
|
|
46
52
|
end
|
|
47
53
|
end
|
|
48
54
|
|
|
49
|
-
def self.text(value)
|
|
50
|
-
raise
|
|
55
|
+
def self.text(value, field = "parameters")
|
|
56
|
+
raise ValidationError.new(field:, code: :invalid_encoding) unless value.valid_encoding?
|
|
51
57
|
|
|
52
58
|
value.encode(Encoding::UTF_8)
|
|
59
|
+
rescue EncodingError
|
|
60
|
+
raise ValidationError.new(field:, code: :invalid_encoding), cause: nil
|
|
53
61
|
end
|
|
54
62
|
|
|
55
|
-
def self.numeric(value)
|
|
63
|
+
def self.numeric(value, field = "parameters")
|
|
56
64
|
case value
|
|
57
65
|
when Integer then value.to_s
|
|
58
66
|
when Float
|
|
59
|
-
raise
|
|
67
|
+
raise ValidationError.new(field:, code: :invalid_number) unless value.finite?
|
|
60
68
|
|
|
61
69
|
value.to_s
|
|
62
70
|
else
|
|
63
|
-
raise
|
|
71
|
+
raise ValidationError.new(field:, code: :invalid_type, actual_type: Diagnostics.type_name(value))
|
|
64
72
|
end
|
|
65
73
|
end
|
|
66
74
|
end
|
data/lib/unisender/helpers.rb
CHANGED
|
@@ -3,26 +3,86 @@
|
|
|
3
3
|
module Unisender
|
|
4
4
|
# Explicit helpers for multiple requests.
|
|
5
5
|
class Client
|
|
6
|
-
def each_page(method, page_size: 100, max_pages: 1000, **params)
|
|
7
|
-
Pagination.new(self, method, page_size, max_pages, params).each
|
|
6
|
+
def each_page(method, page_size: 100, max_pages: 1000, **params, &)
|
|
7
|
+
Iteration.call(Pagination.new(self, method, page_size, max_pages, params).each, &)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
# rubocop:disable-next Metrics/ParameterLists -- Includes the documented import options.
|
|
11
|
-
def import_contacts_in_batches(field_names
|
|
12
|
-
overwrite_lists: nil, telegram_bot_channel_id: nil
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
def import_contacts_in_batches(field_names: nil, data: nil, records: nil, batch_size: 500, overwrite_tags: nil,
|
|
12
|
+
overwrite_lists: nil, telegram_bot_channel_id: nil, retention: :full,
|
|
13
|
+
on_progress: nil)
|
|
14
|
+
accumulator = BatchAccumulator.new(retention)
|
|
15
|
+
import_batches(field_names:, data:, records:, batch_size:, overwrite_tags:, overwrite_lists:,
|
|
16
|
+
telegram_bot_channel_id:, accumulator:, on_progress:).each { |_response| nil }
|
|
17
|
+
accumulator.report
|
|
18
|
+
rescue ValidationError => e
|
|
19
|
+
raise e.with_operation("importContacts"), cause: nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# rubocop:disable-next Metrics/ParameterLists -- Includes the documented import options.
|
|
23
|
+
def each_import_batch(field_names: nil, data: nil, records: nil, batch_size: 500, overwrite_tags: nil,
|
|
24
|
+
overwrite_lists: nil, telegram_bot_channel_id: nil, retention: :full, on_progress: nil, &)
|
|
25
|
+
batches = import_batches(field_names:, data:, records:, batch_size:, overwrite_tags:, overwrite_lists:,
|
|
26
|
+
telegram_bot_channel_id:, retention:, on_progress:)
|
|
27
|
+
Iteration.call(batches, &)
|
|
28
|
+
rescue ValidationError => e
|
|
29
|
+
raise e.with_operation("importContacts"), cause: nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# rubocop:disable-next Metrics/ParameterLists -- Shares the batch options without changing public signatures.
|
|
33
|
+
def import_batches(field_names:, data:, records:, batch_size:, overwrite_tags:, overwrite_lists:,
|
|
34
|
+
telegram_bot_channel_id:, accumulator: nil, retention: :full, on_progress: nil)
|
|
35
|
+
source = import_source(field_names, data, records)
|
|
36
|
+
Batches.each(source, batch_size, 500, accumulator:, retention:, on_progress:, execution: @execution) do |batch|
|
|
37
|
+
names = case source
|
|
38
|
+
when RecordRows then source.field_names
|
|
39
|
+
else field_names
|
|
40
|
+
end
|
|
41
|
+
import_contacts(field_names: names, data: batch, overwrite_tags:, overwrite_lists:, telegram_bot_channel_id:)
|
|
15
42
|
end
|
|
16
43
|
end
|
|
44
|
+
private :import_batches
|
|
45
|
+
|
|
46
|
+
def track_events_in_batches(events:, batch_size: 25, retention: :full, on_progress: nil)
|
|
47
|
+
accumulator = BatchAccumulator.new(retention)
|
|
48
|
+
event_batches(events, batch_size, accumulator:, on_progress:).each { |_response| nil }
|
|
49
|
+
accumulator.report
|
|
50
|
+
rescue ValidationError => e
|
|
51
|
+
raise e.with_operation("track_events"), cause: nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def each_event_batch(events:, batch_size: 25, retention: :full, on_progress: nil, &)
|
|
55
|
+
Iteration.call(event_batches(events, batch_size, retention:, on_progress:), &)
|
|
56
|
+
rescue ValidationError => e
|
|
57
|
+
raise e.with_operation("track_events"), cause: nil
|
|
58
|
+
end
|
|
17
59
|
|
|
18
|
-
def
|
|
19
|
-
|
|
60
|
+
def event_batches(events, batch_size, accumulator: nil, retention: :full, on_progress: nil)
|
|
61
|
+
chunks = EventBatches.each(events, batch_size)
|
|
62
|
+
Batches.each_chunks(chunks, accumulator:, retention:, on_progress:, execution: @execution) do |batch|
|
|
63
|
+
track_events(events: batch)
|
|
64
|
+
end
|
|
20
65
|
end
|
|
66
|
+
private :event_batches
|
|
21
67
|
|
|
22
|
-
def wait_for_task(task_uuid:, interval: 1, timeout: 60, clock: nil, sleeper: nil)
|
|
68
|
+
def wait_for_task(task_uuid:, interval: 1, timeout: 60, clock: nil, sleeper: nil, &progress)
|
|
23
69
|
clock ||= -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
24
70
|
sleeper ||= ->(delay) { sleep(delay) }
|
|
25
|
-
TaskWaiter.new(self, clock:, sleeper:).call(task_uuid, interval, timeout)
|
|
71
|
+
TaskWaiter.new(self, clock:, sleeper:, progress:).call(task_uuid, interval, timeout)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def import_source(field_names, data, records)
|
|
77
|
+
if records
|
|
78
|
+
raise ValidationError.new(field: "records", code: :conflicting_input) if field_names || data
|
|
79
|
+
|
|
80
|
+
return RecordRows.new(records)
|
|
81
|
+
end
|
|
82
|
+
raise ValidationError.new(field: "data", code: :required) unless data
|
|
83
|
+
|
|
84
|
+
Validation.required!({ field_names: }, %w[field_names])
|
|
85
|
+
data
|
|
26
86
|
end
|
|
27
87
|
end
|
|
28
88
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# The documented hook record, with unknown fields retained through Record.
|
|
5
|
+
class Hook < IdentifierRecord
|
|
6
|
+
def url
|
|
7
|
+
text!("url")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def event_format
|
|
11
|
+
text!("event_format")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def max_parallel
|
|
15
|
+
integer!("max_parallel")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def single_event
|
|
19
|
+
integer!("single_event")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def status
|
|
23
|
+
text!("status")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def events
|
|
27
|
+
value = self["events"]
|
|
28
|
+
raise InvalidResponseError, "Expected hook event filters" unless value.is_a?(Record)
|
|
29
|
+
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def normalize
|
|
36
|
+
super
|
|
37
|
+
url
|
|
38
|
+
event_format
|
|
39
|
+
max_parallel
|
|
40
|
+
single_event
|
|
41
|
+
status
|
|
42
|
+
events
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Hook mutation result shapes are not specified by the upstream reference.
|
|
47
|
+
class Client
|
|
48
|
+
# https://www.unisender.com/ru/support/api/common/sistema-uvedomlenij-o-sobytiyah-webhooks/
|
|
49
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the documented operation.
|
|
50
|
+
def set_hook(hook_url:, events:, event_format: :json_post_gzip, max_parallel: nil, single_event: false,
|
|
51
|
+
status: :active)
|
|
52
|
+
events = hook_events(events, max_parallel)
|
|
53
|
+
params = { hook_url:, events:, event_format:, max_parallel:, single_event:, status: }
|
|
54
|
+
request("setHook", params: prepare("setHook", params, required: %w[hook_url events]))
|
|
55
|
+
rescue ValidationError => e
|
|
56
|
+
raise e.with_operation("setHook"), cause: nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hook_events(events, max_parallel)
|
|
60
|
+
if !max_parallel.nil? && !(max_parallel.is_a?(Integer) && max_parallel.positive?)
|
|
61
|
+
raise ValidationError.new(field: "max_parallel", code: :invalid_range, operation: "setHook")
|
|
62
|
+
end
|
|
63
|
+
raise ValidationError.new(field: "events", code: :invalid_type, operation: "setHook") unless events.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
events.to_h do |name, filters|
|
|
66
|
+
[name, filters.is_a?(Array) ? Validation.comma_list(filters) : filters]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
private :hook_events
|
|
70
|
+
|
|
71
|
+
def list_hooks
|
|
72
|
+
invoke("listHooks", {})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def remove_hook(hook_url:)
|
|
76
|
+
request("removeHook", params: prepare("removeHook", { hook_url: }, required: %w[hook_url]))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Converts only documented system columns on the records input path.
|
|
5
|
+
module ImportCells
|
|
6
|
+
LISTS = %w[tags email_list_ids email_unsubscribed_list_ids email_excluded_list_ids
|
|
7
|
+
phone_list_ids phone_unsubscribed_list_ids phone_excluded_list_ids].freeze
|
|
8
|
+
TIMES = %w[email_subscribe_times phone_subscribe_times].freeze
|
|
9
|
+
|
|
10
|
+
def self.convert(name, value, index)
|
|
11
|
+
case value
|
|
12
|
+
when Array then array(name, value, index)
|
|
13
|
+
when Time then TIMES.include?(name) ? times(value, index) : invalid!(index)
|
|
14
|
+
when Hash then invalid!(index)
|
|
15
|
+
else value
|
|
16
|
+
end
|
|
17
|
+
rescue ValidationError => e
|
|
18
|
+
raise ValidationError.new(field: "records[#{index}].#{name}", code: e.code, operation: "importContacts", index:),
|
|
19
|
+
cause: nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.array(name, value, index)
|
|
23
|
+
return list(value, index) if LISTS.include?(name)
|
|
24
|
+
return times(value, index) if TIMES.include?(name)
|
|
25
|
+
|
|
26
|
+
invalid!(index)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.list(values, index)
|
|
30
|
+
values.map do |value|
|
|
31
|
+
invalid!(index) unless (value.is_a?(String) || value.is_a?(Integer)) && !value.to_s.include?(",")
|
|
32
|
+
value.to_s
|
|
33
|
+
end.join(",")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.times(value, index)
|
|
37
|
+
values = value.is_a?(Array) ? value : [value]
|
|
38
|
+
values.map do |item|
|
|
39
|
+
case item
|
|
40
|
+
when Time then item.getutc.strftime("%Y-%m-%d %H:%M:%S")
|
|
41
|
+
when String then item
|
|
42
|
+
else invalid!(index)
|
|
43
|
+
end
|
|
44
|
+
end.join(",")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.validate_times(record, index)
|
|
48
|
+
TIMES.each do |name|
|
|
49
|
+
times = record[name]
|
|
50
|
+
lists = record[name.sub("subscribe_times", "list_ids")]
|
|
51
|
+
next unless times.is_a?(Array) && lists.is_a?(Array) && times.size != lists.size
|
|
52
|
+
|
|
53
|
+
raise ValidationError.new(field: "records[#{index}].#{name}", code: :invalid_cell,
|
|
54
|
+
operation: "importContacts", index:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.invalid!(index)
|
|
59
|
+
raise ValidationError.new(field: "records[#{index}]", code: :invalid_cell, operation: "importContacts", index:)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Named input types are structural; account rules remain server decisions.
|
|
5
|
+
module InputTypes
|
|
6
|
+
# @type var kinds: Hash[String, Module]
|
|
7
|
+
kinds = { "string" => String, "integer" => Integer, "float" => Float, "symbol" => Symbol,
|
|
8
|
+
"hash" => Hash, "time" => Time, "attachment" => Attachment, "enumerable" => Enumerable }
|
|
9
|
+
KINDS = kinds.freeze
|
|
10
|
+
|
|
11
|
+
def self.validate!(value, types, field)
|
|
12
|
+
arrays = types.grep(/\Aarray:/)
|
|
13
|
+
if value.is_a?(Array) && !arrays.empty?
|
|
14
|
+
elements = arrays.map { |type| type.delete_prefix("array:") }
|
|
15
|
+
value.each_with_index { |item, index| validate!(item, elements, "#{field}[#{index}]") }
|
|
16
|
+
elsif types.none? { |type| accepts?(value, type) }
|
|
17
|
+
raise ValidationError.new(field:, code: :invalid_type, expected_type: types.join(" | "),
|
|
18
|
+
actual_type: Diagnostics.type_name(value))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.accepts?(value, type)
|
|
23
|
+
return true if type == "parameter"
|
|
24
|
+
return [true, false].include?(value) if type == "boolean"
|
|
25
|
+
|
|
26
|
+
kind = KINDS[type]
|
|
27
|
+
kind ? value.is_a?(kind) : false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Local structural failures omit parameter values from diagnostics.
|
|
5
|
+
class ValidationError < ArgumentError
|
|
6
|
+
attr_reader :field, :code, :operation, :index, :expected_type, :actual_type
|
|
7
|
+
|
|
8
|
+
# rubocop:disable-next Metrics/ParameterLists -- Structured diagnostic fields remain explicit.
|
|
9
|
+
def initialize(field:, code:, operation: nil, index: nil, expected_type: nil, actual_type: nil)
|
|
10
|
+
@field = Diagnostics.input_path(field)
|
|
11
|
+
@code = code
|
|
12
|
+
@operation = operation
|
|
13
|
+
@index = index
|
|
14
|
+
@expected_type = expected_type
|
|
15
|
+
@actual_type = actual_type
|
|
16
|
+
super("Invalid parameter #{@field}: #{code}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
alias path field
|
|
20
|
+
|
|
21
|
+
def diagnostics
|
|
22
|
+
Diagnostics.for_error(self)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def documentation_url
|
|
26
|
+
Diagnostics::URLS[@operation.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_operation(operation)
|
|
30
|
+
@operation = Error.safe_operation(operation)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def category
|
|
35
|
+
:invalid_argument
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def recovery_hint
|
|
39
|
+
"Check the operation parameters."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def transient?
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def safe_to_retry?
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Converts convenience values only on the named-operation path.
|
|
52
|
+
module Inputs
|
|
53
|
+
MODES = {
|
|
54
|
+
double_optin: { request_confirmation: 0, already_confirmed: 3, confirm_if_needed: 4 }.freeze,
|
|
55
|
+
overwrite: { preserve: 0, replace: 1, update: 2 }.freeze
|
|
56
|
+
}.freeze
|
|
57
|
+
MINUTES = %w[getMessages listMessages getTemplates listTemplates createCampaign].freeze
|
|
58
|
+
SECONDS = %w[getCampaigns async/getCampaignDeliveryStats].freeze
|
|
59
|
+
DATES = %i[date_from date_to from to start_time changed_since].freeze
|
|
60
|
+
|
|
61
|
+
def self.normalize(method, params)
|
|
62
|
+
result = operation_values(method, NamedInputs.normalize(method, attachment_parameters(params)))
|
|
63
|
+
format = timestamp_format(method)
|
|
64
|
+
return result unless format
|
|
65
|
+
|
|
66
|
+
DATES.each { |key| result[key] = timestamp(result[key], format, key) if result.key?(key) }
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.operation_values(method, result)
|
|
71
|
+
case method
|
|
72
|
+
when "sendSms" then result[:phone] = SmsInputs.phone(result[:phone])
|
|
73
|
+
when "sendEmail" then result = MailInputs.normalize(result)
|
|
74
|
+
when "createCampaign" then schedule_timezone!(result)
|
|
75
|
+
when "subscribe" then MODES.each { |key, modes| result[key] = mode(result[key], modes, key) }
|
|
76
|
+
end
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.schedule_timezone!(params)
|
|
81
|
+
return unless params[:start_time].is_a?(Time)
|
|
82
|
+
|
|
83
|
+
unless params[:timezone].nil? || params[:timezone] == "UTC"
|
|
84
|
+
raise ValidationError.new(field: "timezone", code: :conflicting_input)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
params[:timezone] = "UTC"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.attachment_parameters(params)
|
|
91
|
+
result = params.dup
|
|
92
|
+
result[:attachments] = attachments(result[:attachments]) if result.key?(:attachments)
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.attachments(value)
|
|
97
|
+
return value if value.nil? || value.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
output = {} # : Hash[String, parameter]
|
|
100
|
+
attachment_items(value).each do |item|
|
|
101
|
+
raise ValidationError.new(field: "attachments", code: :duplicate_filename) if output.key?(item.filename)
|
|
102
|
+
|
|
103
|
+
output[item.filename] = item.bytes
|
|
104
|
+
end
|
|
105
|
+
output
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.attachment_items(value)
|
|
109
|
+
items = value.is_a?(Attachment) ? [value] : value
|
|
110
|
+
raise ValidationError.new(field: "attachments", code: :invalid_type) unless items.is_a?(Array)
|
|
111
|
+
|
|
112
|
+
items.map do |item|
|
|
113
|
+
raise ValidationError.new(field: "attachments", code: :invalid_type) unless item.is_a?(Attachment)
|
|
114
|
+
|
|
115
|
+
item
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.timestamp_format(method)
|
|
120
|
+
return "%Y-%m-%d %H:%M" if MINUTES.include?(method)
|
|
121
|
+
return "%Y-%m-%d %H:%M:%S" if SECONDS.include?(method)
|
|
122
|
+
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.mode(value, modes, field)
|
|
127
|
+
return nil if value.nil?
|
|
128
|
+
return modes.fetch(value) if value.is_a?(Symbol) && modes.key?(value)
|
|
129
|
+
return value if value.is_a?(Integer) && modes.value?(value)
|
|
130
|
+
|
|
131
|
+
raise ValidationError.new(field: field.to_s, code: :invalid_mode, operation: "subscribe")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.timestamp(value, format, field)
|
|
135
|
+
return value if value.nil? || value.is_a?(String)
|
|
136
|
+
return value.getutc.strftime(format) if value.is_a?(Time)
|
|
137
|
+
|
|
138
|
+
raise ValidationError.new(field: field.to_s, code: :invalid_timestamp)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.contact_fields(fields, email)
|
|
142
|
+
validate_email_alias(email)
|
|
143
|
+
unless fields.nil? || fields.is_a?(Hash)
|
|
144
|
+
raise ValidationError.new(field: "fields", code: :invalid_type, operation: "subscribe")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @type var result: params
|
|
148
|
+
result = fields ? fields.dup : {}
|
|
149
|
+
names = result.keys.map(&:to_s)
|
|
150
|
+
raise ValidationError.new(field: "fields", code: :duplicate_key, operation: "subscribe") if names.uniq != names
|
|
151
|
+
|
|
152
|
+
merge_email(result, email) if email
|
|
153
|
+
validate_email(result)
|
|
154
|
+
result
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.validate_email_alias(email)
|
|
158
|
+
return if email.nil? || email.is_a?(String)
|
|
159
|
+
|
|
160
|
+
raise ValidationError.new(field: "email", code: :invalid_type, operation: "subscribe",
|
|
161
|
+
expected_type: "String", actual_type: Diagnostics.type_name(email))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.merge_email(result, email)
|
|
165
|
+
existing = result[:email] || result["email"]
|
|
166
|
+
if existing && existing != email
|
|
167
|
+
raise ValidationError.new(field: "fields.email", code: :conflicting_value, operation: "subscribe")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
result.delete(:email)
|
|
171
|
+
result["email"] = email
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.validate_email(result)
|
|
175
|
+
address = result[:email] || result["email"]
|
|
176
|
+
return if address.is_a?(String) && !address.empty?
|
|
177
|
+
|
|
178
|
+
raise ValidationError.new(field: "fields.email", code: :required, operation: "subscribe")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# SMS numbers remain strings and share one bounded recipient list.
|
|
183
|
+
module SmsInputs
|
|
184
|
+
def self.phone(value)
|
|
185
|
+
text = case value
|
|
186
|
+
when Array then Validation.comma_list(value, field: "phone")
|
|
187
|
+
when String then value
|
|
188
|
+
else raise ValidationError.new(field: "phone", code: :invalid_type)
|
|
189
|
+
end
|
|
190
|
+
phones = text.split(",", -1)
|
|
191
|
+
unless (1..150).cover?(phones.size) && phones.none? { |phone| phone.strip.empty? }
|
|
192
|
+
raise ValidationError.new(field: "phone", code: :invalid_count)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
text
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Contact selectors and filters keep their wire forms behind named inputs.
|
|
200
|
+
module ContactInputs
|
|
201
|
+
def self.contact_selector(params, operation)
|
|
202
|
+
selected = params.slice(:email, :phone).compact
|
|
203
|
+
return params.slice(:contact_type, :contact, :list_ids) if selected.empty?
|
|
204
|
+
|
|
205
|
+
validate_selector(params, selected, operation)
|
|
206
|
+
kind = selected.key?(:email) ? :email : :phone
|
|
207
|
+
{ contact_type: kind.to_s, contact: selected.fetch(kind), list_ids: params[:list_ids] }
|
|
208
|
+
rescue ValidationError => e
|
|
209
|
+
raise e.with_operation(operation), cause: nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.validate_selector(params, selected, operation)
|
|
213
|
+
if selected.size != 1 || params[:contact_type] || params[:contact]
|
|
214
|
+
raise ValidationError.new(field: "contact", code: :conflicting_input, operation:)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
value = selected.values.fetch(0)
|
|
218
|
+
unless value.is_a?(String) && !value.empty?
|
|
219
|
+
raise ValidationError.new(field: "contact", code: :invalid_contact, operation:)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
Validation.required!(params, %w[list_ids])
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.count_filters(params, tag_id, contact_type, search)
|
|
226
|
+
params = filter_container(params)
|
|
227
|
+
validate_filters(tag_id, search)
|
|
228
|
+
if params && [tag_id, contact_type, search].any?
|
|
229
|
+
raise ValidationError.new(field: "params", code: :conflicting_input, operation: "getContactCount")
|
|
230
|
+
end
|
|
231
|
+
return params if params
|
|
232
|
+
|
|
233
|
+
type = count_contact_type(contact_type)
|
|
234
|
+
raise ValidationError.new(field: "contact_type", code: :required, operation: "getContactCount") if search && !type
|
|
235
|
+
|
|
236
|
+
{ tagId: tag_id, type:, search: }.compact
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.filter_container(value)
|
|
240
|
+
return value if value.nil? || value.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
raise ValidationError.new(field: "params", code: :invalid_type, operation: "getContactCount")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.validate_filters(tag_id, search)
|
|
246
|
+
if !tag_id.nil? && !tag_id.is_a?(String) && !tag_id.is_a?(Integer)
|
|
247
|
+
raise ValidationError.new(field: "tag_id", code: :invalid_type, operation: "getContactCount")
|
|
248
|
+
end
|
|
249
|
+
return if search.nil? || search.is_a?(String)
|
|
250
|
+
|
|
251
|
+
raise ValidationError.new(field: "search", code: :invalid_type, operation: "getContactCount")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def self.count_contact_type(value)
|
|
255
|
+
return nil if value.nil?
|
|
256
|
+
return "address" if %w[email address].include?(value.to_s)
|
|
257
|
+
return "phone" if value.to_s == "phone"
|
|
258
|
+
|
|
259
|
+
raise ValidationError.new(field: "contact_type", code: :invalid_choice, operation: "getContactCount")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Applies an optional block without changing the iterator's return type.
|
|
5
|
+
module Iteration
|
|
6
|
+
def self.items(pages)
|
|
7
|
+
Enumerator.new { |output| pages.each { |page| page.each { |item| output << item } } }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.call(items, &block)
|
|
11
|
+
items.each(&block) if block
|
|
12
|
+
items
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Lazy typed iteration over every page of a supported operation.
|
|
5
|
+
class Client
|
|
6
|
+
def each_campaign(from:, to: nil, page_size: 100, max_pages: 1000, offset: 0, &)
|
|
7
|
+
pages = each_page(
|
|
8
|
+
:get_campaigns, from:, to:, page_size:, max_pages:, offset:
|
|
9
|
+
) # : Enumerator[PageResponse[Campaign], void]
|
|
10
|
+
Iteration.call(Iteration.items(pages), &)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each_message(date_from:, date_to:, page_size: 100, max_pages: 1000, offset: 0, &)
|
|
14
|
+
pages = each_page(
|
|
15
|
+
:get_messages, date_from:, date_to:, page_size:, max_pages:, offset:
|
|
16
|
+
) # : Enumerator[PageResponse[Message], void]
|
|
17
|
+
Iteration.call(Iteration.items(pages), &)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each_message_summary(date_from:, date_to:, page_size: 100, max_pages: 1000, offset: 0, &)
|
|
21
|
+
pages = each_page(
|
|
22
|
+
:list_messages, date_from:, date_to:, page_size:, max_pages:, offset:
|
|
23
|
+
) # : Enumerator[PageResponse[Message], void]
|
|
24
|
+
Iteration.call(Iteration.items(pages), &)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# rubocop:disable-next Metrics/ParameterLists -- Includes the operation keywords and pagination controls.
|
|
28
|
+
def each_template(type: nil, date_from: nil, date_to: nil, page_size: 100, max_pages: 1000, offset: 0, &)
|
|
29
|
+
pages = each_page(
|
|
30
|
+
:get_templates, type:, date_from:, date_to:, page_size:, max_pages:, offset:
|
|
31
|
+
) # : Enumerator[PageResponse[Template], void]
|
|
32
|
+
Iteration.call(Iteration.items(pages), &)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# rubocop:disable-next Metrics/ParameterLists -- Includes the operation keywords and pagination controls.
|
|
36
|
+
def each_template_summary(type: nil, date_from: nil, date_to: nil, page_size: 100, max_pages: 1000, offset: 0,
|
|
37
|
+
&)
|
|
38
|
+
pages = each_page(
|
|
39
|
+
:list_templates, type:, date_from:, date_to:, page_size:, max_pages:, offset:
|
|
40
|
+
) # : Enumerator[PageResponse[Template], void]
|
|
41
|
+
Iteration.call(Iteration.items(pages), &)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def each_sender_domain_group(username:, domain: nil, page_size: 100, max_pages: 1000, offset: 0, &)
|
|
45
|
+
pages = each_page(
|
|
46
|
+
:get_sender_domain_list, username:, domain:, page_size:, max_pages:, offset:
|
|
47
|
+
) # : Enumerator[PageResponse[SenderDomainGroup], void]
|
|
48
|
+
Iteration.call(Iteration.items(pages), &)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# rubocop:disable-next Metrics/ParameterLists -- Includes the operation keywords and pagination controls.
|
|
52
|
+
def each_subscriber_note(subscriber_id:, order_type: nil, order_by: nil,
|
|
53
|
+
is_pinned: nil, page_size: 100, max_pages: 1000, offset: 0, &)
|
|
54
|
+
pages = each_page(
|
|
55
|
+
:get_subscriber_notes, subscriber_id:, order_type:, order_by:, is_pinned:, page_size:, max_pages:, offset:
|
|
56
|
+
) # : Enumerator[PageResponse[Note], void]
|
|
57
|
+
Iteration.call(Iteration.items(pages), &)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|