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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
BatchProgress = Data.define(:completed_batches, :completed_items, :next_index, :invalid, :warning_count, :response)
|
|
5
|
+
|
|
6
|
+
# Bounded completed-work metadata; only the failing response is retained.
|
|
7
|
+
class BatchSummary
|
|
8
|
+
attr_reader :completed_batches, :completed_items, :invalid, :warning_count
|
|
9
|
+
|
|
10
|
+
def initialize(progress, error: nil, failed_range: nil)
|
|
11
|
+
@completed_batches = progress.completed_batches
|
|
12
|
+
@completed_items = progress.completed_items
|
|
13
|
+
@invalid = progress.invalid
|
|
14
|
+
@warning_count = progress.warning_count
|
|
15
|
+
@failure = BatchReport.new([], error:, failed_range:)
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def complete?
|
|
20
|
+
@failure.complete?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def partial?
|
|
24
|
+
!complete? && (completed_batches.positive? || @failure.partial?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def uncertain?
|
|
28
|
+
@failure.uncertain?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def failures
|
|
32
|
+
@failure.failures
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def row_warnings
|
|
36
|
+
@failure.row_warnings
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Stores source offsets independently from optional upstream counters.
|
|
41
|
+
class BatchAccumulator
|
|
42
|
+
attr_reader :responses, :completed_items
|
|
43
|
+
|
|
44
|
+
def initialize(retention)
|
|
45
|
+
raise ValidationError.new(field: "retention", code: :invalid_choice) unless %i[full summary].include?(retention)
|
|
46
|
+
|
|
47
|
+
@retention = retention
|
|
48
|
+
@responses = []
|
|
49
|
+
@offsets = []
|
|
50
|
+
@completed_items = 0
|
|
51
|
+
@completed_batches = 0
|
|
52
|
+
@invalid = 0
|
|
53
|
+
@warning_count = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add(response, count)
|
|
57
|
+
if @retention == :full
|
|
58
|
+
@offsets << @completed_items
|
|
59
|
+
@responses << response
|
|
60
|
+
end
|
|
61
|
+
@completed_items += count
|
|
62
|
+
@completed_batches += 1
|
|
63
|
+
return unless response.is_a?(ImportResponse)
|
|
64
|
+
|
|
65
|
+
@invalid += response.invalid
|
|
66
|
+
@warning_count += response.row_warnings.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def progress(response: nil)
|
|
70
|
+
BatchProgress.new(completed_batches: @completed_batches, completed_items: @completed_items,
|
|
71
|
+
next_index: @completed_items, invalid: @invalid, warning_count: @warning_count, response:)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def report(error: nil, failed_range: nil)
|
|
75
|
+
if @retention == :summary
|
|
76
|
+
BatchSummary.new(progress, error:, failed_range:)
|
|
77
|
+
else
|
|
78
|
+
BatchReport.new(@responses, offsets: @offsets, error:, failed_range:)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def failure(error, range)
|
|
83
|
+
BatchError.new(completed_responses: @responses, failed_range: range, error:,
|
|
84
|
+
report: report(error:, failed_range: range))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/unisender/batches.rb
CHANGED
|
@@ -3,29 +3,58 @@
|
|
|
3
3
|
module Unisender
|
|
4
4
|
# Executes one batch at a time; a failure never replays previous batches.
|
|
5
5
|
module Batches
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# rubocop:disable-next Metrics/ParameterLists -- Shared explicit batch controls.
|
|
7
|
+
def self.each(items, size, maximum, accumulator: nil, retention: :full, on_progress: nil, execution: nil, &)
|
|
8
|
+
validate_size(size, maximum)
|
|
9
|
+
each_chunks(items.each_slice(size), accumulator:, retention:, on_progress:, execution:, &)
|
|
10
|
+
end
|
|
8
11
|
|
|
12
|
+
# rubocop:disable-next Metrics/MethodLength -- Keep accounting next to the yielding boundary.
|
|
13
|
+
def self.each_chunks(chunks, accumulator: nil, retention: :full, on_progress: nil, execution: nil, &operation)
|
|
9
14
|
Enumerator.new do |output|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
state = accumulator || BatchAccumulator.new(retention)
|
|
16
|
+
cursor = chunks.to_enum
|
|
17
|
+
offset = 0
|
|
18
|
+
while (batch = next_chunk(cursor, offset, state, execution))
|
|
19
|
+
response = execute(batch, offset, state, &operation)
|
|
20
|
+
state.add(response, batch.size)
|
|
21
|
+
offset += batch.size
|
|
22
|
+
progress(on_progress, state, offset, response)
|
|
15
23
|
output << response
|
|
16
24
|
end
|
|
17
25
|
end
|
|
18
26
|
end
|
|
19
27
|
|
|
20
|
-
def self.
|
|
28
|
+
def self.validate_size(size, maximum)
|
|
29
|
+
return if size.is_a?(Integer) && size.positive? && size <= maximum
|
|
30
|
+
|
|
31
|
+
raise ValidationError.new(field: "batch_size", code: :invalid_range)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.next_chunk(chunks, offset, accumulator, execution)
|
|
35
|
+
execution&.check!
|
|
36
|
+
chunks.next
|
|
37
|
+
rescue StopIteration
|
|
38
|
+
nil
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
position = e.is_a?(ValidationError) ? e.index : nil
|
|
41
|
+
endpoint = position ? position + 1 : offset
|
|
42
|
+
raise accumulator.failure(e, offset...endpoint), cause: nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.execute(batch, offset, accumulator)
|
|
21
46
|
response = yield batch
|
|
22
|
-
|
|
23
|
-
raise PartialFailure, response if result.is_a?(Hash) && result.fetch("invalid", 0).to_s.to_i.positive?
|
|
47
|
+
raise PartialFailure, response if response.is_a?(OutcomeResponse) && !response.complete?
|
|
24
48
|
|
|
25
49
|
response
|
|
26
50
|
rescue Error, ArgumentError => e
|
|
27
|
-
raise
|
|
28
|
-
|
|
51
|
+
raise accumulator.failure(e, offset...(offset + batch.size)), cause: nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.progress(callback, accumulator, offset, response)
|
|
55
|
+
callback&.call(accumulator.progress(response:))
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
raise accumulator.failure(e, offset...offset), cause: nil
|
|
29
58
|
end
|
|
30
59
|
end
|
|
31
60
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Both responses are retained after a completed workflow.
|
|
5
|
+
CampaignReceipt = Data.define(:message, :campaign)
|
|
6
|
+
|
|
7
|
+
# A created message remains available when campaign creation fails.
|
|
8
|
+
class CampaignWorkflowError < Error
|
|
9
|
+
attr_reader :message_response, :error
|
|
10
|
+
|
|
11
|
+
def initialize(message_response, error)
|
|
12
|
+
@message_response = message_response
|
|
13
|
+
@error = error
|
|
14
|
+
super("Campaign creation failed after message creation")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def category
|
|
18
|
+
error = @error
|
|
19
|
+
error.is_a?(Error) || error.is_a?(ValidationError) ? error.category : :unexpected
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def request_id
|
|
23
|
+
error = @error
|
|
24
|
+
error.request_id if error.is_a?(Error)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Validates both steps before creating a message.
|
|
29
|
+
class Client
|
|
30
|
+
def send_campaign(message:, campaign: {})
|
|
31
|
+
message_params, campaign_params = campaign_parameters(message, campaign)
|
|
32
|
+
created = invoke("createEmailMessage", message_params, required: EMAIL_REQUIRED) # : MessageCreatedResponse
|
|
33
|
+
finish_campaign(created, campaign_params)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def campaign_parameters(message, campaign)
|
|
39
|
+
workflow_options!(message, campaign)
|
|
40
|
+
|
|
41
|
+
defaults = { sender_name: UNSET, sender_email: UNSET, lang: UNSET }
|
|
42
|
+
message_params = prepare("createEmailMessage", defaults.merge(message), required: EMAIL_REQUIRED,
|
|
43
|
+
comma: %w[categories])
|
|
44
|
+
campaign_params = prepare("createCampaign", campaign.merge(message_id: 0), required: CAMPAIGN_REQUIRED,
|
|
45
|
+
comma: %w[contacts])
|
|
46
|
+
Form.encode(message_params)
|
|
47
|
+
Form.encode(campaign_params)
|
|
48
|
+
[message_params, campaign_params]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def workflow_options!(message, campaign)
|
|
52
|
+
workflow_keys!(message, :create_email_message)
|
|
53
|
+
workflow_keys!(campaign, :create_campaign)
|
|
54
|
+
raise ArgumentError, "The workflow supplies message_id" if campaign.key?(:message_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def workflow_keys!(params, operation)
|
|
58
|
+
allowed = method(operation).parameters.map(&:last)
|
|
59
|
+
return if params.keys.all? { |key| allowed.include?(key) }
|
|
60
|
+
|
|
61
|
+
raise ArgumentError, "Unknown workflow option"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def finish_campaign(created, params)
|
|
65
|
+
response = invoke("createCampaign", params.merge(message_id: created.message_id),
|
|
66
|
+
required: CAMPAIGN_REQUIRED) # : CampaignCreatedResponse
|
|
67
|
+
CampaignReceipt.new(message: created, campaign: response)
|
|
68
|
+
rescue Error, ArgumentError => e
|
|
69
|
+
raise CampaignWorkflowError.new(created, e), cause: nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/unisender/client.rb
CHANGED
|
@@ -3,57 +3,89 @@
|
|
|
3
3
|
module Unisender
|
|
4
4
|
# Synchronous client for the documented UniSender API.
|
|
5
5
|
class Client
|
|
6
|
+
USER_AGENT = "unisender-ruby/#{VERSION} Ruby/#{RUBY_VERSION}".freeze
|
|
6
7
|
RESERVED_PARAMETERS = %w[api_key format].freeze
|
|
8
|
+
TASK_METHODS = %w[async/exportContacts async/getCampaignDeliveryStats async/getTaskResult].freeze
|
|
9
|
+
UNSET = :__unisender_unset__
|
|
10
|
+
EMAIL_REQUIRED = %w[sender_name sender_email list_id].freeze
|
|
11
|
+
CAMPAIGN_REQUIRED = %w[message_id].freeze
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
-
@transport = transport
|
|
17
|
-
@retry_policy = retry_policy
|
|
13
|
+
# @type method initialize: (api_key: String, **config_value) -> void
|
|
14
|
+
def initialize(api_key:, **)
|
|
15
|
+
@configuration = Configuration.new(api_key:, **)
|
|
16
|
+
@api_key = @configuration.api_key
|
|
17
|
+
@language = @configuration.language
|
|
18
|
+
@transport = @configuration.transport
|
|
19
|
+
@retry_policy = @configuration.retry_policy
|
|
20
|
+
@execution = nil
|
|
18
21
|
freeze
|
|
19
22
|
end
|
|
20
23
|
|
|
21
|
-
def request(method, params: {})
|
|
22
|
-
validate_request(method, params)
|
|
23
|
-
uri = URI("https://api.unisender.com/#{@language}/api/#{method}")
|
|
24
|
-
headers = { "Content-Type" => "application/x-www-form-urlencoded; charset=UTF-8" }
|
|
25
|
-
body = Form.encode(params.merge("api_key" => @api_key, "format" => "json"))
|
|
26
|
-
@retry_policy.call(method) do
|
|
27
|
-
response = @transport.post(uri, headers:, body:)
|
|
28
|
-
Protocol.parse(response, method:)
|
|
29
|
-
end
|
|
30
|
-
rescue Error => e
|
|
31
|
-
raise e.with_operation(method), cause: nil
|
|
32
|
-
end
|
|
33
|
-
|
|
34
24
|
def inspect
|
|
35
25
|
"#<Unisender::Client>"
|
|
36
26
|
end
|
|
37
27
|
|
|
38
28
|
private
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
# @api private
|
|
31
|
+
# Shared dispatcher for named operations and pagination.
|
|
32
|
+
# @type method invoke: (String, params, ?required: Array[String], ?comma: Array[String]) -> Response
|
|
33
|
+
def invoke(method, params, required: [], comma: [])
|
|
34
|
+
params = prepare(method, params, required:, comma:)
|
|
35
|
+
perform_request(method, params, named: true)
|
|
36
|
+
rescue Error, ValidationError => e
|
|
37
|
+
raise e.with_operation(method), cause: nil
|
|
45
38
|
end
|
|
46
39
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
def prepare(method, params, required: [], comma: [])
|
|
41
|
+
params = preparation_defaults(method).merge(params)
|
|
42
|
+
params = message_defaults(params)
|
|
43
|
+
OperationContracts.validate!(method, params)
|
|
44
|
+
params = Inputs.normalize(method, params)
|
|
50
45
|
Validation.required!(params, required)
|
|
51
46
|
Validation.operation!(method, params)
|
|
47
|
+
comma_parameters(params, comma)
|
|
48
|
+
rescue ValidationError => e
|
|
49
|
+
raise e.with_operation(method), cause: nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def preparation_defaults(method)
|
|
53
|
+
case method
|
|
54
|
+
when "createEmailMessage", "sendEmail"
|
|
55
|
+
# @type var values: params
|
|
56
|
+
values = { sender_name: UNSET, sender_email: UNSET, lang: UNSET }
|
|
57
|
+
values[:error_checking] = true if method == "sendEmail"
|
|
58
|
+
values
|
|
59
|
+
when "setHook" then { event_format: :json_post_gzip, single_event: false, status: :active }
|
|
60
|
+
else {}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def comma_parameters(params, comma)
|
|
52
65
|
comma.each do |name|
|
|
53
66
|
value = params[name.to_sym]
|
|
54
|
-
params[name.to_sym] = Validation.comma_list(value) if value.is_a?(Array)
|
|
67
|
+
params[name.to_sym] = Validation.comma_list(value, field: name) if value.is_a?(Array)
|
|
68
|
+
end
|
|
69
|
+
params
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def message_defaults(params)
|
|
73
|
+
params.to_h do |key, value|
|
|
74
|
+
default = @configuration.message_defaults[key.to_s.to_sym] if value == UNSET
|
|
75
|
+
[key, value == UNSET ? default : value]
|
|
55
76
|
end
|
|
56
|
-
request(method, params:)
|
|
57
77
|
end
|
|
78
|
+
|
|
79
|
+
def bind_task(method, response, params)
|
|
80
|
+
return response unless TASK_METHODS.include?(method)
|
|
81
|
+
|
|
82
|
+
task = response # : TaskResultResponse
|
|
83
|
+
uuid = params[:task_uuid] || task.task_uuid
|
|
84
|
+
raise InvalidResponseError, "Missing task UUID" unless uuid.is_a?(String)
|
|
85
|
+
|
|
86
|
+
TaskResponse.new(task, client: self, task_uuid: uuid)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
public :invoke
|
|
58
90
|
end
|
|
59
91
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Explicit construction, immutable configuration variants, and lifetime.
|
|
5
|
+
class Client
|
|
6
|
+
class << self
|
|
7
|
+
def open(api_key:, **)
|
|
8
|
+
raise ArgumentError, "Client.open requires a block" unless block_given?
|
|
9
|
+
|
|
10
|
+
client = new(api_key:, **)
|
|
11
|
+
begin
|
|
12
|
+
yield client
|
|
13
|
+
ensure
|
|
14
|
+
client.close
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# rubocop:disable-next Metrics/ParameterLists -- Mirrors the constructor options for typed forwarding.
|
|
19
|
+
def from_env(env: ENV, language: :en, transport: nil, retry_policy: nil, open_timeout: nil,
|
|
20
|
+
read_timeout: nil, write_timeout: nil, max_retries: nil, max_elapsed: nil,
|
|
21
|
+
message_defaults: {}, instrumentation: nil, persistent: false, rate_limiter: nil,
|
|
22
|
+
event_rate_limiter: nil, acceptance_instrumentation: nil)
|
|
23
|
+
key = env["UNISENDER_API_KEY"]
|
|
24
|
+
raise ArgumentError, "UNISENDER_API_KEY is required" unless key
|
|
25
|
+
|
|
26
|
+
new(api_key: key, language:, transport:, retry_policy:, open_timeout:, read_timeout:, write_timeout:,
|
|
27
|
+
max_retries:, max_elapsed:, message_defaults:, instrumentation:, persistent:, rate_limiter:,
|
|
28
|
+
event_rate_limiter:, acceptance_instrumentation:)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @type method with: (**config_value) -> Client
|
|
33
|
+
def with(**options)
|
|
34
|
+
settings = @configuration.options_for(options)
|
|
35
|
+
key = settings.delete(:api_key)
|
|
36
|
+
case key
|
|
37
|
+
when String
|
|
38
|
+
client = Client.new(api_key: key, **settings)
|
|
39
|
+
context = @execution
|
|
40
|
+
context&.own(client)
|
|
41
|
+
context ? client.execution_client(context) : client
|
|
42
|
+
else raise ArgumentError, "api_key must be a string"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@configuration.close unless @execution
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# Scoped execution shares the original client's transport and lifetime.
|
|
5
|
+
class Client
|
|
6
|
+
def with_execution(timeout: nil, cancellation: nil, instrumentation: nil)
|
|
7
|
+
context = ExecutionContext.new(timeout:, cancellation:, instrumentation:, parent: @execution)
|
|
8
|
+
scoped = execution_client(context)
|
|
9
|
+
begin
|
|
10
|
+
context.measure(:workflow, "workflow") { yield scoped }
|
|
11
|
+
ensure
|
|
12
|
+
context.close
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Shared by helpers so waits observe the same budget as requests.
|
|
17
|
+
def execution_wait(delay, kind, operation, sleeper)
|
|
18
|
+
context = @execution
|
|
19
|
+
if context
|
|
20
|
+
context.wait(delay, kind, operation, sleeper)
|
|
21
|
+
else
|
|
22
|
+
sleeper.call(delay)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_execution!
|
|
27
|
+
@configuration.check_open!
|
|
28
|
+
@execution&.check!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def execution_context
|
|
32
|
+
@execution
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
def execution_client(context)
|
|
38
|
+
client = dup
|
|
39
|
+
client.instance_variable_set(:@execution, context)
|
|
40
|
+
client.freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# The client has completed its resource lifetime.
|
|
5
|
+
class ClientClosedError < Error
|
|
6
|
+
def category
|
|
7
|
+
:client_closed
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Mutable resource lifetime is separate from immutable client options.
|
|
12
|
+
class ClientState
|
|
13
|
+
def initialize
|
|
14
|
+
@closed = false
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check_open!
|
|
19
|
+
@mutex.synchronize { raise ClientClosedError, "Client is closed" if @closed }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def close
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
return if @closed
|
|
25
|
+
|
|
26
|
+
@closed = true
|
|
27
|
+
yield
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
# The same named preparation is available without crossing the transport seam.
|
|
5
|
+
class Client
|
|
6
|
+
def validate!(operation, records: nil, **params)
|
|
7
|
+
if records
|
|
8
|
+
raise ArgumentError, "Records require import_contacts" unless operation == :import_contacts
|
|
9
|
+
|
|
10
|
+
params = validation_import(params, records)
|
|
11
|
+
end
|
|
12
|
+
validation_dispatch(operation, params)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def validation_dispatch(operation, params)
|
|
18
|
+
case operation
|
|
19
|
+
when :send_campaign then validate_campaign(params)
|
|
20
|
+
when :track_events then validate_events(params)
|
|
21
|
+
else validate_named(operation, params)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_named(operation, params)
|
|
26
|
+
wire = OperationContracts.wire_name(operation)
|
|
27
|
+
OperationContracts.validate_keys!(wire, params)
|
|
28
|
+
params = validation_page(operation, params) if PaginatedOperations.supports?(operation)
|
|
29
|
+
Form.encode(prepare(wire, validation_parameters(wire, params), comma: OperationContracts.comma_fields(wire)))
|
|
30
|
+
true
|
|
31
|
+
rescue ValidationError => e
|
|
32
|
+
raise e.with_operation(wire || operation.to_s), cause: nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validation_page(operation, params)
|
|
36
|
+
PaginatedOperations.prepare(operation, params)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# rubocop:disable-next Naming/PredicateMethod -- Validation returns true or raises.
|
|
40
|
+
def validate_campaign(params)
|
|
41
|
+
raise ValidationError.new(field: "parameters", code: :unknown_keyword) unless (params.keys - %i[message
|
|
42
|
+
campaign]).empty?
|
|
43
|
+
|
|
44
|
+
# @type var empty: params
|
|
45
|
+
empty = {}
|
|
46
|
+
message = validation_keywords(params[:message], "message")
|
|
47
|
+
campaign = validation_keywords(params.fetch(:campaign, empty), "campaign")
|
|
48
|
+
campaign_parameters(message, campaign)
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validation_keywords(value, field)
|
|
53
|
+
raise ValidationError.new(field:, code: :invalid_type) unless value.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
value.to_h do |key, item|
|
|
56
|
+
raise ValidationError.new(field:, code: :unknown_keyword) unless key.is_a?(Symbol)
|
|
57
|
+
|
|
58
|
+
[key, item]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_events(params)
|
|
63
|
+
raise ValidationError.new(field: "events", code: :invalid_type) unless params.keys == [:events]
|
|
64
|
+
|
|
65
|
+
events = params.fetch(:events)
|
|
66
|
+
EventPayload.encode(events)
|
|
67
|
+
true
|
|
68
|
+
rescue ValidationError => e
|
|
69
|
+
raise e.with_operation("track_events"), cause: nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validation_import(params, records)
|
|
73
|
+
raise ValidationError.new(field: "records", code: :conflicting_input) if params[:field_names] || params[:data]
|
|
74
|
+
|
|
75
|
+
rows = RecordRows.bounded(records)
|
|
76
|
+
data = rows.to_a
|
|
77
|
+
params.merge(field_names: rows.field_names, data:)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validation_parameters(wire, params)
|
|
81
|
+
case wire
|
|
82
|
+
when "subscribe"
|
|
83
|
+
params.except(:email).merge(fields: Inputs.contact_fields(params[:fields], params[:email]))
|
|
84
|
+
when "exclude", "unsubscribe" then ContactInputs.contact_selector(params, wire)
|
|
85
|
+
when "getContactCount" then validation_count(params)
|
|
86
|
+
when "setHook"
|
|
87
|
+
params.merge(events: hook_events(params[:events], params[:max_parallel]))
|
|
88
|
+
else params
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validation_count(params)
|
|
93
|
+
filters = ContactInputs.count_filters(params[:params], params[:tag_id], params[:contact_type], params[:search])
|
|
94
|
+
params.except(:tag_id, :contact_type, :search).merge(params: filters)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|