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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../unisender"
|
|
4
|
+
require_relative "testing/assertions"
|
|
5
|
+
require_relative "testing/fixtures"
|
|
6
|
+
|
|
7
|
+
module Unisender
|
|
8
|
+
# Optional deterministic transport support for application tests.
|
|
9
|
+
module Testing
|
|
10
|
+
Request = Data.define(:uri, :headers, :body)
|
|
11
|
+
Reply = Data.define(:operation, :response, :expected_form, :expected_json)
|
|
12
|
+
|
|
13
|
+
# Explicit body access is private data; inspection and form access redact credentials.
|
|
14
|
+
class Request
|
|
15
|
+
def operation
|
|
16
|
+
uri.host == "event.unisender.com" ? "track_events" : uri.path.to_s.split("/api/", 2).last.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def form
|
|
20
|
+
URI.decode_www_form(body).to_h.except("api_key")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def json
|
|
24
|
+
JSON.parse(body) # : json
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inspect
|
|
28
|
+
"#<Unisender::Testing::Request>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
alias to_s inspect
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Queued responses never open a socket.
|
|
35
|
+
class Transport
|
|
36
|
+
def initialize
|
|
37
|
+
@replies = []
|
|
38
|
+
@requests = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# rubocop:disable-next Metrics/ParameterLists -- Expectations belong to their queued response.
|
|
42
|
+
def enqueue(data:, http_status: 200, headers: {}, operation: nil, expected_form: nil, expected_json: nil)
|
|
43
|
+
reply = HTTPResponse.new(status: http_status, headers:, body: JSON.generate(data))
|
|
44
|
+
enqueue_response(reply, operation:, expected_form:, expected_json:)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enqueue_response(response, operation: nil, expected_form: nil, expected_json: nil)
|
|
48
|
+
Assertions.validate(expected_form, expected_json)
|
|
49
|
+
@replies << Reply.new(operation:, response:, expected_form:, expected_json:)
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def requests
|
|
54
|
+
@requests.dup.freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def verify!
|
|
58
|
+
raise "#{@replies.size} queued response(s) were not consumed" unless @replies.empty?
|
|
59
|
+
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def post(uri, headers:, body:)
|
|
64
|
+
request = Request.new(uri: uri.dup.freeze, headers: headers.dup.freeze, body: body.dup.freeze)
|
|
65
|
+
@requests << request
|
|
66
|
+
response = next_response(request)
|
|
67
|
+
raise response if response.is_a?(StandardError)
|
|
68
|
+
|
|
69
|
+
response
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<Unisender::Testing::Transport>"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def next_response(request)
|
|
79
|
+
reply = @replies.shift or raise "Unexpected request: no queued response"
|
|
80
|
+
if reply.operation && reply.operation != request.operation
|
|
81
|
+
expected = Error.safe_operation(reply.operation) || "request"
|
|
82
|
+
actual = Error.safe_operation(request.operation) || "request"
|
|
83
|
+
raise "Unexpected request: expected #{expected}, received #{actual}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Assertions.check(request, reply.expected_form, reply.expected_json)
|
|
87
|
+
reply.response
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/unisender/transport.rb
CHANGED
|
@@ -16,11 +16,10 @@ module Unisender
|
|
|
16
16
|
@read_timeout = read_timeout
|
|
17
17
|
@write_timeout = write_timeout
|
|
18
18
|
@http_factory = http_factory || ->(uri) { Net::HTTP.new(uri.hostname.to_s, uri.port) }
|
|
19
|
-
freeze
|
|
20
19
|
end
|
|
21
20
|
|
|
22
|
-
def post(uri, headers:, body:)
|
|
23
|
-
exchange(uri, headers:, body:)
|
|
21
|
+
def post(uri, headers:, body:, execution: nil)
|
|
22
|
+
exchange(uri, headers:, body:, execution:)
|
|
24
23
|
rescue Timeout::Error
|
|
25
24
|
raise TimeoutError, "UniSender request timed out", cause: nil
|
|
26
25
|
rescue OpenSSL::SSL::SSLError
|
|
@@ -31,23 +30,32 @@ module Unisender
|
|
|
31
30
|
|
|
32
31
|
private
|
|
33
32
|
|
|
34
|
-
def exchange(uri, headers:, body:)
|
|
33
|
+
def exchange(uri, headers:, body:, execution:)
|
|
35
34
|
request = Net::HTTP::Post.new(uri, headers)
|
|
36
35
|
request.body = body
|
|
37
|
-
response =
|
|
36
|
+
response = perform(uri, request, execution:)
|
|
38
37
|
headers = response.to_hash.transform_values { |items| items.join(", ") }
|
|
39
38
|
HTTPResponse.new(status: response.code.to_i, headers:, body: response.body.to_s)
|
|
40
39
|
end
|
|
41
40
|
|
|
42
|
-
def
|
|
41
|
+
def perform(uri, request, execution:)
|
|
42
|
+
configured_http(uri, execution:).request(request)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def configured_http(uri, execution: nil)
|
|
43
46
|
http = @http_factory.call(uri)
|
|
44
47
|
http.use_ssl = true
|
|
45
48
|
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
46
|
-
http
|
|
47
|
-
http.read_timeout = @read_timeout
|
|
48
|
-
http.write_timeout = @write_timeout
|
|
49
|
+
configure_timeouts(http, execution)
|
|
49
50
|
http.max_retries = 0
|
|
50
51
|
http
|
|
51
52
|
end
|
|
53
|
+
|
|
54
|
+
def configure_timeouts(http, execution)
|
|
55
|
+
remaining = execution&.remaining
|
|
56
|
+
http.open_timeout = remaining ? [@open_timeout, remaining].min : @open_timeout
|
|
57
|
+
http.read_timeout = remaining ? [@read_timeout, remaining].min : @read_timeout
|
|
58
|
+
http.write_timeout = remaining ? [@write_timeout, remaining].min : @write_timeout
|
|
59
|
+
end
|
|
52
60
|
end
|
|
53
61
|
end
|
data/lib/unisender/validation.rb
CHANGED
|
@@ -19,20 +19,19 @@ module Unisender
|
|
|
19
19
|
def self.campaign!(params)
|
|
20
20
|
return unless params[:contacts] && params[:contacts_url]
|
|
21
21
|
|
|
22
|
-
raise
|
|
23
|
-
"contacts and contacts_url are mutually exclusive"
|
|
22
|
+
raise ValidationError.new(field: "contacts", code: :conflicting_input)
|
|
24
23
|
end
|
|
25
24
|
|
|
26
25
|
def self.template!(params)
|
|
27
26
|
return unless params[:template_id].nil? && params[:system_template_id].nil?
|
|
28
27
|
|
|
29
|
-
raise
|
|
28
|
+
raise ValidationError.new(field: "template_id", code: :required_alternative)
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
def self.import!(params)
|
|
33
32
|
fields = params[:field_names]
|
|
34
33
|
unless fields.is_a?(Array) && fields.all?(String) && fields.include?("email") && fields.uniq == fields
|
|
35
|
-
raise
|
|
34
|
+
raise ValidationError.new(field: "field_names", code: :invalid_columns)
|
|
36
35
|
end
|
|
37
36
|
|
|
38
37
|
rows!(params[:data], fields.size)
|
|
@@ -42,14 +41,14 @@ module Unisender
|
|
|
42
41
|
unless rows.is_a?(Array) && (1..500).cover?(rows.size) && rows.all? do |row|
|
|
43
42
|
row.is_a?(Array) && row.size == width
|
|
44
43
|
end
|
|
45
|
-
raise
|
|
44
|
+
raise ValidationError.new(field: "data", code: :invalid_rows)
|
|
46
45
|
end
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
def self.required!(params, names)
|
|
50
49
|
names.each do |name|
|
|
51
50
|
value = params[name.to_sym]
|
|
52
|
-
raise
|
|
51
|
+
raise ValidationError.new(field: name, code: :required) if missing?(value)
|
|
53
52
|
end
|
|
54
53
|
end
|
|
55
54
|
|
|
@@ -61,9 +60,12 @@ module Unisender
|
|
|
61
60
|
end
|
|
62
61
|
end
|
|
63
62
|
|
|
64
|
-
def self.comma_list(value)
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
def self.comma_list(value, field: "parameters")
|
|
64
|
+
value.each_with_index do |item, index|
|
|
65
|
+
next if item.is_a?(String) || item.is_a?(Integer)
|
|
66
|
+
|
|
67
|
+
raise ValidationError.new(field: "#{field}[#{index}]", code: :invalid_list,
|
|
68
|
+
expected_type: "String or Integer", actual_type: Diagnostics.type_name(item))
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
value.join(",")
|
data/lib/unisender/version.rb
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unisender
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Scan strings and structural tokens once, preserving original byte offsets.
|
|
6
|
+
class Tokens
|
|
7
|
+
def initialize(text)
|
|
8
|
+
@text = text
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def each
|
|
12
|
+
index = 0
|
|
13
|
+
while index < @text.bytesize
|
|
14
|
+
token, finish = read(index)
|
|
15
|
+
yield token, index, finish if token.start_with?('"') || "{}[],".include?(token)
|
|
16
|
+
index = finish
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def read(index)
|
|
23
|
+
finish = @text.getbyte(index) == 34 ? string_end(index + 1) : index + 1
|
|
24
|
+
[@text.byteslice(index...finish).to_s, finish]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def string_end(index)
|
|
28
|
+
while index < @text.bytesize
|
|
29
|
+
byte = @text.getbyte(index)
|
|
30
|
+
return index + 1 if byte == 34
|
|
31
|
+
|
|
32
|
+
index += byte == 92 ? 2 : 1
|
|
33
|
+
end
|
|
34
|
+
raise WebhookError, :malformed_payload
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../unisender"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require_relative "webhooks/tokens"
|
|
8
|
+
|
|
9
|
+
module Unisender
|
|
10
|
+
# Rejected inbound data never appears in exception messages.
|
|
11
|
+
class WebhookError < Error
|
|
12
|
+
attr_reader :reason
|
|
13
|
+
|
|
14
|
+
def initialize(reason)
|
|
15
|
+
@reason = reason
|
|
16
|
+
super("Invalid webhook: #{reason}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def category
|
|
20
|
+
:invalid_webhook
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A notification event keeps its schema-specific fields in event_data.
|
|
25
|
+
class WebhookEvent < Record
|
|
26
|
+
def event_name
|
|
27
|
+
text!("event_name")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def event_time
|
|
31
|
+
text!("event_time")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def event_data
|
|
35
|
+
value = self["event_data"]
|
|
36
|
+
raise WebhookError, :invalid_event unless value.is_a?(Record)
|
|
37
|
+
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
protected
|
|
42
|
+
|
|
43
|
+
def normalize
|
|
44
|
+
event_name
|
|
45
|
+
event_time
|
|
46
|
+
event_data
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Events grouped by the account login from the notification.
|
|
51
|
+
class WebhookUser < Record
|
|
52
|
+
def login
|
|
53
|
+
text!("login")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def events
|
|
57
|
+
raise WebhookError, :conflicting_events if key?("events") && key?("Events")
|
|
58
|
+
|
|
59
|
+
values = self[key?("events") ? "events" : "Events"]
|
|
60
|
+
raise WebhookError, :unsupported_shape unless values.is_a?(Array)
|
|
61
|
+
|
|
62
|
+
Outcomes.records(values, "events").map { |event| WebhookEvent.new(event.to_h) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
protected
|
|
66
|
+
|
|
67
|
+
def normalize
|
|
68
|
+
login
|
|
69
|
+
raise WebhookError, :unsupported_shape unless key?("events") || key?("Events")
|
|
70
|
+
|
|
71
|
+
events
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Authenticated notification payload with typed user and event readers.
|
|
76
|
+
class WebhookNotification < Record
|
|
77
|
+
def users
|
|
78
|
+
Outcomes.records(self["events_by_user"], "events_by_user").map { |user| WebhookUser.new(user.to_h) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def events
|
|
82
|
+
users.flat_map(&:events)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
protected
|
|
86
|
+
|
|
87
|
+
def normalize
|
|
88
|
+
raise WebhookError, :unsupported_shape unless self["events_by_user"].is_a?(Array)
|
|
89
|
+
|
|
90
|
+
users
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The documented signature covers original decompressed JSON bytes.
|
|
95
|
+
module Webhooks
|
|
96
|
+
# The token walk rejects duplicate keys even on older JSON parsers.
|
|
97
|
+
class ObjectKeys
|
|
98
|
+
attr_reader :key
|
|
99
|
+
|
|
100
|
+
def initialize
|
|
101
|
+
@seen = {}
|
|
102
|
+
@key = nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def register(token)
|
|
106
|
+
return if @key
|
|
107
|
+
|
|
108
|
+
key = JSON.parse(token) # : String
|
|
109
|
+
raise WebhookError, :duplicate_key if @seen.key?(key)
|
|
110
|
+
|
|
111
|
+
@seen[key] = true
|
|
112
|
+
@key = key
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def next_key
|
|
116
|
+
@key = nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# rubocop:disable-next Metrics/ParameterLists -- Independent receiver limits are explicit.
|
|
121
|
+
def self.verify(body:, api_key:, content_encoding: nil, max_bytes: 1_048_576,
|
|
122
|
+
max_decompressed_bytes: 4_194_304, max_events: 10_000)
|
|
123
|
+
validate_limits(max_bytes, max_decompressed_bytes, max_events)
|
|
124
|
+
raise WebhookError, :too_large if body.bytesize > max_bytes
|
|
125
|
+
|
|
126
|
+
bytes = decompress(body, content_encoding, max_decompressed_bytes)
|
|
127
|
+
data = decode(bytes)
|
|
128
|
+
authenticate(bytes, data, api_key)
|
|
129
|
+
notification = WebhookNotification.new(data)
|
|
130
|
+
raise WebhookError, :too_many_events if notification.events.size > max_events
|
|
131
|
+
|
|
132
|
+
notification
|
|
133
|
+
rescue JSON::ParserError, EncodingError, InvalidResponseError, TypeError, ArgumentError
|
|
134
|
+
raise WebhookError, :malformed_payload, cause: nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.decode(bytes)
|
|
138
|
+
data = JSON.parse(bytes, max_nesting: 64) # : json
|
|
139
|
+
raise WebhookError, :unsupported_shape unless data.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
data
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.validate_limits(*limits)
|
|
145
|
+
return if limits.all? { |value| value.is_a?(Integer) && value.positive? }
|
|
146
|
+
|
|
147
|
+
raise WebhookError, :invalid_limits
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.decompress(body, encoding, limit)
|
|
151
|
+
case encoding
|
|
152
|
+
when nil, "", "identity" then bounded(body, limit)
|
|
153
|
+
when "gzip" then inflate(body, limit)
|
|
154
|
+
else raise WebhookError, :unsupported_encoding
|
|
155
|
+
end
|
|
156
|
+
rescue Zlib::Error, EOFError
|
|
157
|
+
raise WebhookError, :invalid_gzip, cause: nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.inflate(body, limit)
|
|
161
|
+
gzip = Zlib::GzipReader.new(StringIO.new(body))
|
|
162
|
+
begin
|
|
163
|
+
result = bounded(gzip.read(limit + 1).to_s, limit)
|
|
164
|
+
raise WebhookError, :trailing_data unless gzip.unused.to_s.empty?
|
|
165
|
+
|
|
166
|
+
result
|
|
167
|
+
ensure
|
|
168
|
+
gzip.close
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.bounded(body, limit)
|
|
173
|
+
raise WebhookError, :too_large if body.bytesize > limit
|
|
174
|
+
|
|
175
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
176
|
+
raise WebhookError, :invalid_encoding unless text.valid_encoding?
|
|
177
|
+
|
|
178
|
+
text
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.authenticate(bytes, data, api_key)
|
|
182
|
+
auth = auth_value(data)
|
|
183
|
+
start, finish = auth_span(bytes)
|
|
184
|
+
signed = bytes.byteslice(0...start).to_s + JSON.generate(api_key) + bytes.byteslice(finish..).to_s
|
|
185
|
+
# UniSender requires MD5; the accepted protocol exception is documented in docs/webhooks.md.
|
|
186
|
+
expected = Digest::MD5.hexdigest(signed)
|
|
187
|
+
raise WebhookError, :invalid_auth unless secure_equal?(auth, expected)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.auth_value(data)
|
|
191
|
+
auth = data["auth"]
|
|
192
|
+
raise WebhookError, :invalid_auth unless auth.is_a?(String) && auth.match?(/\A[0-9a-f]{32}\z/)
|
|
193
|
+
|
|
194
|
+
auth
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.secure_equal?(actual, expected)
|
|
198
|
+
actual.bytes.zip(expected.bytes).reduce(0) { |memo, (a, b)| memo | (a ^ b.to_i) }.zero?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/CyclomaticComplexity -- Preserve JSON spans.
|
|
202
|
+
def self.auth_span(bytes)
|
|
203
|
+
# @type var stack: Array[ObjectKeys?]
|
|
204
|
+
stack = []
|
|
205
|
+
# @type var span: [Integer, Integer]?
|
|
206
|
+
span = nil
|
|
207
|
+
Tokens.new(bytes).each do |token, start, finish|
|
|
208
|
+
case token
|
|
209
|
+
when "{" then stack << ObjectKeys.new
|
|
210
|
+
when "[" then stack << nil
|
|
211
|
+
when "}", "]" then stack.pop
|
|
212
|
+
when "," then stack.last&.next_key
|
|
213
|
+
else
|
|
214
|
+
span = register_string(stack, token, start, finish) || span
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
span || raise(WebhookError, :invalid_auth)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def self.register_string(stack, token, start, finish)
|
|
221
|
+
frame = stack.last
|
|
222
|
+
return unless frame
|
|
223
|
+
|
|
224
|
+
# @type var span: [Integer, Integer]?
|
|
225
|
+
span = [start, finish] if stack.size == 1 && frame.key == "auth"
|
|
226
|
+
frame.register(token)
|
|
227
|
+
span
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/unisender.rb
CHANGED
|
@@ -4,19 +4,60 @@ require_relative "unisender/version"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "unisender/response"
|
|
6
6
|
require_relative "unisender/errors"
|
|
7
|
+
require_relative "unisender/record"
|
|
8
|
+
require_relative "unisender/serialization"
|
|
9
|
+
require_relative "unisender/records"
|
|
10
|
+
require_relative "unisender/hooks"
|
|
11
|
+
require_relative "unisender/outcomes"
|
|
12
|
+
require_relative "unisender/contact_records"
|
|
13
|
+
require_relative "unisender/statistic_records"
|
|
14
|
+
require_relative "unisender/task_response"
|
|
15
|
+
require_relative "unisender/result_registry"
|
|
16
|
+
require_relative "unisender/results"
|
|
7
17
|
require_relative "unisender/protocol"
|
|
8
18
|
require_relative "unisender/form"
|
|
9
19
|
require_relative "unisender/transport"
|
|
20
|
+
require_relative "unisender/persistent_transport"
|
|
21
|
+
require_relative "unisender/rate_limiter"
|
|
22
|
+
require_relative "unisender/download_target"
|
|
23
|
+
require_relative "unisender/download_transfer"
|
|
24
|
+
require_relative "unisender/downloader"
|
|
10
25
|
require_relative "unisender/validation"
|
|
26
|
+
require_relative "unisender/attachment"
|
|
27
|
+
require_relative "unisender/inputs"
|
|
28
|
+
require_relative "unisender/named_inputs"
|
|
29
|
+
require_relative "unisender/mail_inputs"
|
|
30
|
+
require_relative "unisender/input_types"
|
|
31
|
+
require_relative "unisender/operation_contracts"
|
|
32
|
+
require_relative "unisender/diagnostic_urls"
|
|
33
|
+
require_relative "unisender/diagnostics"
|
|
34
|
+
require_relative "unisender/import_cells"
|
|
35
|
+
require_relative "unisender/record_rows"
|
|
11
36
|
require_relative "unisender/event_payload"
|
|
12
37
|
require_relative "unisender/retry_policy"
|
|
38
|
+
require_relative "unisender/error_diagnostics"
|
|
39
|
+
require_relative "unisender/observation"
|
|
40
|
+
require_relative "unisender/acceptance"
|
|
41
|
+
require_relative "unisender/client_state"
|
|
42
|
+
require_relative "unisender/configuration"
|
|
13
43
|
require_relative "unisender/client"
|
|
44
|
+
require_relative "unisender/execution_context"
|
|
45
|
+
require_relative "unisender/client_execution"
|
|
46
|
+
require_relative "unisender/request_execution"
|
|
47
|
+
require_relative "unisender/client_configuration"
|
|
48
|
+
require_relative "unisender/client_validation"
|
|
14
49
|
require_relative "unisender/events"
|
|
15
50
|
require_relative "unisender/paginated_operations"
|
|
16
51
|
require_relative "unisender/pagination"
|
|
52
|
+
require_relative "unisender/iteration"
|
|
53
|
+
require_relative "unisender/page_response"
|
|
17
54
|
require_relative "unisender/batches"
|
|
55
|
+
require_relative "unisender/event_batches"
|
|
56
|
+
require_relative "unisender/batch_report"
|
|
57
|
+
require_relative "unisender/batch_summary"
|
|
18
58
|
require_relative "unisender/task_waiter"
|
|
19
59
|
require_relative "unisender/helpers"
|
|
60
|
+
require_relative "unisender/iterators"
|
|
20
61
|
require_relative "unisender/operations/contacts_1"
|
|
21
62
|
require_relative "unisender/operations/contacts_2"
|
|
22
63
|
require_relative "unisender/operations/inputs_1"
|
|
@@ -27,3 +68,4 @@ require_relative "unisender/operations/notes_1"
|
|
|
27
68
|
require_relative "unisender/operations/statistics_1"
|
|
28
69
|
require_relative "unisender/operations/statistics_2"
|
|
29
70
|
require_relative "unisender/operations/templates_1"
|
|
71
|
+
require_relative "unisender/campaign_workflow"
|
data/sig/acceptance.rbs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Unisender
|
|
2
|
+
class AcceptanceEvent < Data
|
|
3
|
+
attr_reader operation: String
|
|
4
|
+
attr_reader outcome: Symbol
|
|
5
|
+
attr_reader input_count: Integer
|
|
6
|
+
attr_reader accepted_count: Integer?
|
|
7
|
+
attr_reader rejected_count: Integer?
|
|
8
|
+
attr_reader unknown_count: Integer?
|
|
9
|
+
attr_reader row_warning_count: Integer?
|
|
10
|
+
attr_reader request_id: String?
|
|
11
|
+
def self.new: (operation: String, outcome: Symbol, input_count: Integer, accepted_count: Integer?, rejected_count: Integer?, unknown_count: Integer?, row_warning_count: Integer?, request_id: String?) -> AcceptanceEvent
|
|
12
|
+
end
|
|
13
|
+
module Acceptance
|
|
14
|
+
def self.input_count: (String, params) -> Integer?
|
|
15
|
+
def self.event: (String, Integer, Response?, StandardError?) -> AcceptanceEvent
|
|
16
|
+
def self.warning_count: (Response?) -> Integer?
|
|
17
|
+
def self.counts: (Integer, Response?, StandardError?) -> [Integer?, Integer?, Integer?]
|
|
18
|
+
def self.bounded_counts: (Integer, [Integer?, Integer?, Integer?]) -> [Integer?, Integer?, Integer?]
|
|
19
|
+
def self.send_counts: (Integer, SendResponse) -> [Integer, Integer, Integer]
|
|
20
|
+
def self.import_counts: (Integer, ImportResponse) -> [Integer?, Integer, Integer?]
|
|
21
|
+
def self.state: (Response?, StandardError?, Integer?) -> Symbol
|
|
22
|
+
def self.failure_state: (StandardError) -> Symbol
|
|
23
|
+
def self.result_state: (OutcomeResponse) -> Symbol
|
|
24
|
+
def self.emit: (^(AcceptanceEvent) -> void, AcceptanceEvent) -> void
|
|
25
|
+
end
|
|
26
|
+
class Client
|
|
27
|
+
private
|
|
28
|
+
def observe_acceptance: (String, Integer?, Response?, StandardError?) -> void
|
|
29
|
+
end
|
|
30
|
+
end
|
data/sig/attachment.rbs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Unisender
|
|
2
|
+
interface _Readable
|
|
3
|
+
def read: (?Integer) -> String?
|
|
4
|
+
end
|
|
5
|
+
class Attachment
|
|
6
|
+
attr_reader filename: String
|
|
7
|
+
attr_reader bytes: String
|
|
8
|
+
def initialize: (filename: String, bytes: String) -> void
|
|
9
|
+
def self.from_path: (String, ?filename: String, ?max_bytes: Integer?) -> Attachment
|
|
10
|
+
def self.from_io: (_Readable, filename: String, ?max_bytes: Integer?) -> Attachment
|
|
11
|
+
def inspect: () -> String
|
|
12
|
+
private
|
|
13
|
+
def self.read_size: (Integer?) -> Integer?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Unisender
|
|
2
|
+
class BatchProgress < Data
|
|
3
|
+
attr_reader completed_batches: Integer
|
|
4
|
+
attr_reader completed_items: Integer
|
|
5
|
+
attr_reader next_index: Integer
|
|
6
|
+
attr_reader invalid: Integer
|
|
7
|
+
attr_reader warning_count: Integer
|
|
8
|
+
attr_reader response: Response?
|
|
9
|
+
def self.new: (completed_batches: Integer, completed_items: Integer, next_index: Integer, invalid: Integer, warning_count: Integer, response: Response?) -> BatchProgress
|
|
10
|
+
end
|
|
11
|
+
class BatchSummary
|
|
12
|
+
@failure: BatchReport
|
|
13
|
+
attr_reader completed_batches: Integer
|
|
14
|
+
attr_reader completed_items: Integer
|
|
15
|
+
attr_reader invalid: Integer
|
|
16
|
+
attr_reader warning_count: Integer
|
|
17
|
+
def initialize: (BatchProgress, ?error: StandardError?, ?failed_range: Range[Integer]?) -> void
|
|
18
|
+
def complete?: () -> bool
|
|
19
|
+
def partial?: () -> bool
|
|
20
|
+
def uncertain?: () -> bool
|
|
21
|
+
def failures: () -> Array[Failure]
|
|
22
|
+
def row_warnings: () -> Array[Failure]
|
|
23
|
+
end
|
|
24
|
+
class BatchAccumulator
|
|
25
|
+
@retention: Symbol?
|
|
26
|
+
@offsets: Array[Integer]
|
|
27
|
+
@completed_batches: Integer
|
|
28
|
+
@invalid: Integer
|
|
29
|
+
@warning_count: Integer
|
|
30
|
+
attr_reader responses: Array[Response]
|
|
31
|
+
attr_reader completed_items: Integer
|
|
32
|
+
def initialize: (Symbol?) -> void
|
|
33
|
+
def add: (Response, Integer) -> void
|
|
34
|
+
def progress: (?response: Response?) -> BatchProgress
|
|
35
|
+
def report: (?error: StandardError?, ?failed_range: Range[Integer]?) -> (BatchReport | BatchSummary)
|
|
36
|
+
def failure: (StandardError, Range[Integer]) -> BatchError
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Unisender
|
|
2
|
+
class CampaignReceipt < Data
|
|
3
|
+
attr_reader message: MessageCreatedResponse
|
|
4
|
+
attr_reader campaign: CampaignCreatedResponse
|
|
5
|
+
def self.new: (message: MessageCreatedResponse, campaign: CampaignCreatedResponse) -> CampaignReceipt
|
|
6
|
+
end
|
|
7
|
+
class CampaignWorkflowError < Error
|
|
8
|
+
attr_reader message_response: MessageCreatedResponse
|
|
9
|
+
attr_reader error: StandardError
|
|
10
|
+
def initialize: (MessageCreatedResponse, StandardError) -> void
|
|
11
|
+
end
|
|
12
|
+
class Client
|
|
13
|
+
def send_campaign: (message: Hash[Symbol, parameter], ?campaign: Hash[Symbol, parameter]) -> CampaignReceipt
|
|
14
|
+
private
|
|
15
|
+
def campaign_parameters: (Hash[Symbol, parameter], Hash[Symbol, parameter]) -> [params, params]
|
|
16
|
+
def workflow_keys!: (params, Symbol) -> void
|
|
17
|
+
def workflow_options!: (params, params) -> void
|
|
18
|
+
def finish_campaign: (MessageCreatedResponse, params) -> CampaignReceipt
|
|
19
|
+
end
|
|
20
|
+
end
|