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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/CODE_OF_CONDUCT.md +90 -0
  4. data/CONTRIBUTING.md +23 -2
  5. data/README.md +53 -90
  6. data/SECURITY.md +13 -0
  7. data/docs/api.md +395 -210
  8. data/docs/execution.md +89 -0
  9. data/docs/import-recovery.md +41 -0
  10. data/docs/live-contracts.md +34 -0
  11. data/docs/migration-0.2.md +129 -0
  12. data/docs/rails.md +248 -0
  13. data/docs/releases.md +22 -0
  14. data/docs/testing.md +83 -0
  15. data/docs/usage.md +144 -0
  16. data/docs/webhooks.md +41 -0
  17. data/examples/campaign.rb +5 -6
  18. data/examples/campaign_lifecycle.rb +28 -0
  19. data/examples/check_connection.rb +1 -1
  20. data/examples/events.rb +1 -1
  21. data/examples/export.rb +20 -6
  22. data/examples/import.rb +3 -2
  23. data/examples/import_recovery.rb +70 -0
  24. data/examples/quickstart.rb +2 -1
  25. data/examples/subscribe.rb +2 -2
  26. data/lib/unisender/acceptance.rb +104 -0
  27. data/lib/unisender/attachment.rb +47 -0
  28. data/lib/unisender/batch_report.rb +115 -0
  29. data/lib/unisender/batch_summary.rb +87 -0
  30. data/lib/unisender/batches.rb +41 -12
  31. data/lib/unisender/campaign_workflow.rb +72 -0
  32. data/lib/unisender/client.rb +65 -33
  33. data/lib/unisender/client_configuration.rb +50 -0
  34. data/lib/unisender/client_execution.rb +43 -0
  35. data/lib/unisender/client_state.rb +31 -0
  36. data/lib/unisender/client_validation.rb +97 -0
  37. data/lib/unisender/configuration.rb +151 -0
  38. data/lib/unisender/contact_records.rb +251 -0
  39. data/lib/unisender/diagnostic_urls.rb +68 -0
  40. data/lib/unisender/diagnostics.rb +83 -0
  41. data/lib/unisender/download_target.rb +61 -0
  42. data/lib/unisender/download_transfer.rb +43 -0
  43. data/lib/unisender/downloader.rb +99 -0
  44. data/lib/unisender/error_diagnostics.rb +83 -0
  45. data/lib/unisender/errors.rb +125 -8
  46. data/lib/unisender/event_batches.rb +40 -0
  47. data/lib/unisender/event_payload.rb +50 -22
  48. data/lib/unisender/events.rb +21 -5
  49. data/lib/unisender/execution_context.rb +114 -0
  50. data/lib/unisender/form.rb +20 -12
  51. data/lib/unisender/helpers.rb +70 -10
  52. data/lib/unisender/hooks.rb +79 -0
  53. data/lib/unisender/import_cells.rb +62 -0
  54. data/lib/unisender/input_types.rb +30 -0
  55. data/lib/unisender/inputs.rb +262 -0
  56. data/lib/unisender/iteration.rb +15 -0
  57. data/lib/unisender/iterators.rb +60 -0
  58. data/lib/unisender/mail_inputs.rb +37 -0
  59. data/lib/unisender/named_inputs.rb +52 -0
  60. data/lib/unisender/observation.rb +30 -0
  61. data/lib/unisender/operation_contracts.rb +106 -0
  62. data/lib/unisender/operations/contacts_1.rb +4 -3
  63. data/lib/unisender/operations/contacts_2.rb +23 -4
  64. data/lib/unisender/operations/messages_1.rb +7 -4
  65. data/lib/unisender/operations/messages_2.rb +8 -3
  66. data/lib/unisender/operations/messages_3.rb +1 -1
  67. data/lib/unisender/operations/notes_1.rb +1 -1
  68. data/lib/unisender/operations/statistics_1.rb +3 -3
  69. data/lib/unisender/operations/templates_1.rb +2 -2
  70. data/lib/unisender/outcomes.rb +320 -0
  71. data/lib/unisender/page_response.rb +48 -0
  72. data/lib/unisender/paginated_operations.rb +43 -2
  73. data/lib/unisender/pagination.rb +53 -20
  74. data/lib/unisender/persistent_transport.rb +104 -0
  75. data/lib/unisender/rate_limiter.rb +72 -0
  76. data/lib/unisender/record.rb +210 -0
  77. data/lib/unisender/record_rows.rb +69 -0
  78. data/lib/unisender/records.rb +614 -0
  79. data/lib/unisender/request_execution.rb +108 -0
  80. data/lib/unisender/response.rb +13 -10
  81. data/lib/unisender/result_registry.rb +72 -0
  82. data/lib/unisender/results.rb +105 -0
  83. data/lib/unisender/retry_policy.rb +10 -16
  84. data/lib/unisender/serialization.rb +54 -0
  85. data/lib/unisender/statistic_records.rb +129 -0
  86. data/lib/unisender/task_response.rb +85 -0
  87. data/lib/unisender/task_waiter.rb +25 -11
  88. data/lib/unisender/testing/assertions.rb +48 -0
  89. data/lib/unisender/testing/fixtures.rb +24 -0
  90. data/lib/unisender/testing.rb +91 -0
  91. data/lib/unisender/transport.rb +17 -9
  92. data/lib/unisender/validation.rb +11 -9
  93. data/lib/unisender/version.rb +1 -1
  94. data/lib/unisender/webhooks/tokens.rb +38 -0
  95. data/lib/unisender/webhooks.rb +230 -0
  96. data/lib/unisender.rb +42 -0
  97. data/sig/acceptance.rbs +30 -0
  98. data/sig/attachment.rbs +15 -0
  99. data/sig/batch_summary.rbs +38 -0
  100. data/sig/campaign_workflow.rbs +20 -0
  101. data/sig/configuration.rbs +62 -0
  102. data/sig/connection_controls.rbs +55 -0
  103. data/sig/diagnostics.rbs +37 -0
  104. data/sig/domain_records.rbs +96 -0
  105. data/sig/downloads.rbs +53 -0
  106. data/sig/execution.rbs +52 -0
  107. data/sig/execution_context.rbs +46 -0
  108. data/sig/hooks.rbs +18 -0
  109. data/sig/inputs.rbs +84 -0
  110. data/sig/iterators.rbs +11 -0
  111. data/sig/mail_inputs.rbs +8 -0
  112. data/sig/manifest.yaml +11 -0
  113. data/sig/operation_contracts.rbs +28 -0
  114. data/sig/operations.rbs +73 -69
  115. data/sig/outcomes.rbs +90 -0
  116. data/sig/record.rbs +77 -0
  117. data/sig/records.rbs +182 -0
  118. data/sig/serialization.rbs +13 -0
  119. data/sig/tasks.rbs +26 -0
  120. data/sig/testing.rbs +54 -0
  121. data/sig/unisender.rbs +128 -39
  122. data/sig/webhooks.rbs +55 -0
  123. 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
@@ -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
- def self.each(items, size, maximum, &operation)
7
- raise ArgumentError, "Invalid batch_size" unless size.is_a?(Integer) && size.positive? && size <= maximum
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
- # @type var completed: Array[Response]
11
- completed = []
12
- items.each_slice(size).with_index do |batch, index|
13
- response = execute(batch, index * size, completed, &operation)
14
- completed << response
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.execute(batch, offset, completed)
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
- result = response.result
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 BatchError.new(completed_responses: completed, failed_range: offset...(offset + batch.size), error: e),
28
- cause: nil
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
@@ -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
- def initialize(api_key:, transport: Transport.new, language: :en, retry_policy: RetryPolicy.new)
9
- unless api_key.is_a?(String) && !api_key.strip.empty? && !api_key.match?(/[\r\n]/)
10
- raise ArgumentError, "api_key must be a nonempty string without line breaks"
11
- end
12
- raise ArgumentError, "language must be en or ru" unless %w[en ru].include?(language.to_s)
13
-
14
- @api_key = api_key.dup.freeze
15
- @language = language.to_s.dup.freeze
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
- def validate_request(method, params)
41
- raise ArgumentError, "Invalid API method name" unless method.match?(%r{\A(?:async/)?[A-Za-z][A-Za-z0-9]*\z})
42
- return unless params.keys.any? { |key| RESERVED_PARAMETERS.include?(key.to_s) }
43
-
44
- raise ArgumentError, "api_key and format are managed by the client"
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
- # The wire response stays JSON; each named operation declares the documented result type.
48
- # @type method invoke: (String, params, ?required: Array[String], ?comma: Array[String]) -> Response[json]
49
- def invoke(method, params, required: [], comma: [])
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