clicksend 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ module Testing
5
+ # Builds ClickSend-shaped JSON responses for FakeAPI.
6
+ # @api private
7
+ module Payloads
8
+ BASE_URL = "https://rest.clicksend.com"
9
+ USER_ID = 1
10
+ SUBACCOUNT_ID = 1
11
+ ERRORS = {
12
+ 400 => ["BAD_REQUEST", "Bad request."],
13
+ 401 => ["UNAUTHORIZED", "Authorization failed."],
14
+ 403 => ["FORBIDDEN", "Forbidden."],
15
+ 404 => ["NOT_FOUND", "Resource not found."],
16
+ 405 => ["METHOD_NOT_ALLOWED", "Method not allowed."],
17
+ 429 => ["HTTP_TOO_MANY_REQUESTS", "Too many attempts."],
18
+ 500 => ["INTERNAL_SERVER_ERROR", "Internal server error."]
19
+ }.freeze
20
+ LIMITS = Page::LIMITS
21
+
22
+ module_function
23
+
24
+ # @return [Clicksend::Transport::Response]
25
+ def respond(status, body, headers = {})
26
+ Transport::Response.new(status: status, headers: {"content-type" => "application/json"}.merge(headers).freeze, body: JSON.generate(body))
27
+ end
28
+
29
+ def ok(message, data)
30
+ respond(200, {"http_code" => 200, "response_code" => "SUCCESS", "response_msg" => message, "data" => data})
31
+ end
32
+
33
+ def error(status, response_code = nil, response_msg = nil, headers = {})
34
+ code, msg = ERRORS.fetch(status, ["ERROR", "Simulated HTTP #{status}."])
35
+ respond(status, {"http_code" => status, "response_code" => response_code || code, "response_msg" => response_msg || msg, "data" => nil}, headers)
36
+ end
37
+
38
+ # ClickSend's pagination envelope. Out-of-range +limit+ and +page+ values
39
+ # are clamped (ClickSend's handling of them is undocumented). An empty
40
+ # list has +last_page+ 0, as observed live.
41
+ def page(items, path, query, message)
42
+ limit = (Integer(query["limit"], 10, exception: false) || LIMITS.min).clamp(LIMITS.min, LIMITS.max)
43
+ number = [Integer(query["page"], 10, exception: false) || 1, 1].max
44
+ last = (items.size + limit - 1) / limit
45
+ slice = items[(number - 1) * limit, limit] || []
46
+ first = (number - 1) * limit + 1 unless slice.empty?
47
+ ok(message, {
48
+ "total" => items.size, "per_page" => limit, "current_page" => number, "last_page" => last,
49
+ "next_page_url" => ("#{BASE_URL}#{path}?page=#{number + 1}" if number < last),
50
+ "prev_page_url" => ("#{BASE_URL}#{path}?page=#{number - 1}" if number > 1),
51
+ "from" => first, "to" => (first + slice.size - 1 if first), "data" => slice
52
+ })
53
+ end
54
+
55
+ # An estimate (one part per 160 characters), not ClickSend's rule, which
56
+ # depends on the encoding and on concatenation headers.
57
+ def parts(body)
58
+ [(body.length + 159) / 160, 1].max
59
+ end
60
+
61
+ def accepted(message, id, now, price)
62
+ parts = parts(message["body"])
63
+ {
64
+ "direction" => "out", "date" => now.to_i, "to" => message["to"], "body" => message["body"],
65
+ "from" => message["from"], "schedule" => Integer(message["schedule"], exception: false) || now.to_i,
66
+ "message_id" => id, "message_parts" => parts, "message_price" => format("%.4f", Rational(price) * parts),
67
+ "from_email" => message["from_email"], "list_id" => message["list_id"],
68
+ "custom_string" => message["custom_string"] || "", "contact_id" => nil, "user_id" => USER_ID,
69
+ "subaccount_id" => SUBACCOUNT_ID, "is_shared_system_number" => false, "country" => message["country"],
70
+ "carrier" => "", "status" => "SUCCESS"
71
+ }.freeze
72
+ end
73
+
74
+ # The minimal shape ClickSend returns for a message it did not accept.
75
+ def rejected(message, id, status)
76
+ {
77
+ "to" => message["to"], "body" => message["body"], "from" => message["from"], "schedule" => "",
78
+ "message_id" => id, "custom_string" => message["custom_string"] || "", "is_shared_system_number" => false,
79
+ "status" => status
80
+ }
81
+ end
82
+
83
+ def currency(name)
84
+ {"currency_name_short" => name}
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ module Testing
5
+ # A message the FakeAPI accepted (per-message status "SUCCESS"), as
6
+ # submitted. +to+ is nil for a message sent to a contact list
7
+ # (+list_id+); +scheduled_at+ is the (UTC) time it was scheduled for, or
8
+ # nil; +sent_at+ is the (UTC) time the fake accepted it.
9
+ SentMessage = Data.define(:message_id, :to, :from, :body, :custom_string, :list_id, :scheduled_at, :country, :sent_at)
10
+
11
+ # Raised when test code given to the FakeAPI (a #stub block, or the
12
+ # +clock:+) raises a StandardError or ScriptError, i.e. has a bug. It is
13
+ # deliberately not a StandardError, so the client does not report it as a
14
+ # ClickSend failure (connection error, retry, "ambiguous" send): a typo in
15
+ # a stub must fail the test, not satisfy it. Other exceptions (Ctrl-C,
16
+ # timeouts, assertion failures) are never converted.
17
+ class StubError < Exception # rubocop:disable Lint/InheritException
18
+ end
19
+
20
+ # A request the FakeAPI received, recorded whatever its outcome.
21
+ #
22
+ # +http_method+ is a lower-case Symbol; +query+ has String keys and values, as
23
+ # ClickSend would receive them; +body+ is the parsed JSON (deep-frozen), or
24
+ # nil. Headers are never recorded: they carry the credentials.
25
+ Request = Data.define(:http_method, :path, :query, :body)
26
+ end
27
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "uri"
6
+ require_relative "../clicksend"
7
+ require_relative "testing/records"
8
+ require_relative "testing/payloads"
9
+ require_relative "testing/failure"
10
+ require_relative "testing/fake_api"
11
+
12
+ module Clicksend
13
+ # Test support: an in-memory ClickSend. Not loaded by +require "clicksend"+.
14
+ #
15
+ # require "clicksend/testing"
16
+ #
17
+ # Clicksend::Testing::FakeAPI is a *transport*, so the code under test uses a
18
+ # real Clicksend::Client: argument validation, error mapping, retry and
19
+ # ambiguity rules and models are the production code paths. Only the HTTP
20
+ # exchange is replaced. It does no I/O and sends nothing.
21
+ #
22
+ # An RSpec example. The application code sends one-time codes. When a send
23
+ # is ambiguous it never sends again; it reports the outcome as unknown (and
24
+ # might hand it to a reconciliation job):
25
+ #
26
+ # class OtpSender
27
+ # def initialize(sms:) = @sms = sms
28
+ #
29
+ # def call(phone:, code:, ref:)
30
+ # @sms.deliver(to: phone, body: "Your code is #{code}", custom_string: ref)
31
+ # :sent
32
+ # rescue Clicksend::AmbiguousRequestError
33
+ # :unknown
34
+ # end
35
+ # end
36
+ #
37
+ # require "clicksend/testing"
38
+ #
39
+ # RSpec.describe OtpSender do
40
+ # let(:fake) { Clicksend::Testing::FakeAPI.new }
41
+ # let(:sender) { OtpSender.new(sms: fake.client.sms) }
42
+ #
43
+ # it "sends the code" do
44
+ # expect(sender.call(phone: "+61411111111", code: "481516", ref: "otp:42")).to eq(:sent)
45
+ # expect(fake.sent_messages.map { |m| [m.to, m.custom_string] }).to eq([["+61411111111", "otp:42"]])
46
+ # end
47
+ #
48
+ # it "surfaces a rejected number" do
49
+ # fake.reject(to: "+61400000000", status: "INVALID_RECIPIENT")
50
+ # expect { sender.call(phone: "+61400000000", code: "1", ref: "otp:43") }.to raise_error(Clicksend::MessageRejected)
51
+ # end
52
+ #
53
+ # # Both ambiguous cases must lead to the same, safe behaviour.
54
+ # [true, false].each do |processed|
55
+ # it "never sends twice when the outcome is unknown (processed: #{processed})" do
56
+ # fake.fail_next(:timeout, processed: processed, path: "/v3/sms/send")
57
+ #
58
+ # expect(sender.call(phone: "+61411111111", code: "481516", ref: "otp:42")).to eq(:unknown)
59
+ # expect(fake.requests.count { |r| r.path == "/v3/sms/send" }).to eq(1)
60
+ # expect(fake.sent_messages.size).to eq(processed ? 1 : 0)
61
+ # end
62
+ # end
63
+ #
64
+ # it "reads delivery receipts" do
65
+ # message = fake.client.sms.deliver(to: "+61411111111", body: "Hi")
66
+ # fake.add_receipt(for: fake.sent_messages.last, status_code: 301, error_text: "Expired")
67
+ #
68
+ # expect(fake.client.sms.receipt(message.message_id)).to be_failed
69
+ # end
70
+ # end
71
+ #
72
+ # History is not served (see FakeAPI). To test a reconciliation step, stub
73
+ # it with the rows the scenario needs, for example none:
74
+ #
75
+ # fake.stub(:get, "/v3/sms/history") do |_request|
76
+ # {"data" => {"total" => 0, "per_page" => 15, "current_page" => 1, "last_page" => 0, "data" => []}}
77
+ # end
78
+ #
79
+ # It also works as a development "dry run" transport, which is why the
80
+ # client has no +dry_run:+ flag; nothing leaves the process:
81
+ #
82
+ # client = Clicksend::Client.new(username: "dev", api_key: "dev", transport: Clicksend::Testing::FakeAPI.new)
83
+ #
84
+ # A FakeAPI keeps every request in memory until #reset!, so a long-running
85
+ # process should reset it from time to time.
86
+ module Testing
87
+ end
88
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clicksend
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ # Parses delivery receipts and inbound SMS that ClickSend pushes to your URL.
5
+ # Pure functions over params your web framework has already decoded; no I/O.
6
+ #
7
+ # *Experimental*: ClickSend's current docs define no push payload, and no
8
+ # real push has been captured for this gem yet. This API may change in a
9
+ # minor release.
10
+ #
11
+ # ClickSend pushes through automation rules with the +URL+ action. Inbound
12
+ # rules POST a form (the default), GET with a query string, or POST JSON, by
13
+ # the rule's +webhook_type+; ClickSend does not document the JSON field
14
+ # names, and this assumes they are the same. Receipt pushes are form-encoded
15
+ # according to the archived v3 docs. The field names come from the poll
16
+ # schemas (+sms_receipt+, +inbound_sms+), which match the archived push
17
+ # docs. According to those, pushes also carry +user_id+ and, on receipts,
18
+ # +status+ ("Delivered"/"Undelivered"); those stay in +raw+.
19
+ #
20
+ # ClickSend documents no signing or authentication for pushes: no HMAC,
21
+ # signature or secret, and no current list of source IP addresses (archived
22
+ # help pages once listed some; they can't be checked today, so don't
23
+ # allowlist by IP). Treat anyone who knows the URL as able to forge one, so:
24
+ # - put an unguessable secret in the URL path and compare it in constant time
25
+ # (it will appear in access logs: restrict who reads them);
26
+ # - use HTTPS;
27
+ # - treat the event as a hint; a receipt can probably be confirmed with
28
+ # <tt>client.sms.receipt(event.message_id)</tt> (not yet verified for
29
+ # accounts with only URL rules);
30
+ # - process idempotently: several matching rules may each push, and (per the
31
+ # archived docs) a non-200 is retried every 10 minutes, 10 times. Key inbound
32
+ # messages on +message_id+, and receipts on +message_id+ and +status_code+:
33
+ # non-final codes (200, 300) mean one message can get several receipts;
34
+ # - answer 200 quickly and do the work in a job.
35
+ #
36
+ # Prefer one URL per rule type with #parse_receipt / #parse_inbound; #parse
37
+ # guesses the type from the fields present.
38
+ #
39
+ # Pass the body params, not params merged with route params, so your secret
40
+ # does not end up in +raw+. +raw+ is the payload with String keys, minus
41
+ # Rails' "controller", "action" and "format", frozen.
42
+ #
43
+ # Rails (route: <tt>post "clicksend/:secret/receipts", to: "clicksend#receipt"</tt>):
44
+ #
45
+ # def receipt
46
+ # secret = Rails.application.credentials.clicksend_webhook_secret
47
+ # return head(:not_found) unless ActiveSupport::SecurityUtils.secure_compare(params[:secret].to_s, secret)
48
+ #
49
+ # receipt = Clicksend::Webhook.parse_receipt(request.request_parameters)
50
+ # ConfirmReceiptJob.perform_later(receipt.message_id, receipt.status_code) # idempotent on both
51
+ # head :ok
52
+ # rescue Clicksend::Webhook::InvalidPayload
53
+ # head :bad_request
54
+ # end
55
+ #
56
+ # Rails' +params+ (ActionController::Parameters) is also accepted: the gem
57
+ # calls +to_unsafe_h+, which is safe here because only known fields are read
58
+ # into frozen models (nothing is mass-assigned).
59
+ #
60
+ # Rack / Sinatra (use +request.GET+ for a +get+ inbound rule, or
61
+ # <tt>JSON.parse(request.body.read)</tt> for +json+):
62
+ #
63
+ # post "/clicksend/:secret/inbound" do
64
+ # halt 404 unless Rack::Utils.secure_compare(params["secret"].to_s, ENV.fetch("CLICKSEND_WEBHOOK_SECRET"))
65
+ # message = Clicksend::Webhook.parse_inbound(request.POST)
66
+ # InboundJob.perform_async(message.message_id, message.raw)
67
+ # 200
68
+ # rescue Clicksend::Webhook::InvalidPayload
69
+ # 400
70
+ # end
71
+ #
72
+ # Because the endpoint is unauthenticated, payloads with more than
73
+ # MAX_FIELDS keys, a key or String value over MAX_BYTES bytes, a value that
74
+ # is not a scalar (Hash, Array, uploaded file, ...) or text that is not
75
+ # valid UTF-8 are rejected.
76
+ module Webhook
77
+ # Raised for anything that is not a usable push. The message names fields,
78
+ # never their values (bodies and phone numbers are personal data).
79
+ class InvalidPayload < Error; end
80
+
81
+ MAX_FIELDS = 64
82
+ MAX_BYTES = 10_000
83
+ RAILS_ROUTING_KEYS = %w[controller action format].freeze
84
+ SCALARS = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
85
+ # Fields the models read; they must be scalars. Other fields that are not
86
+ # (e.g. the copy Rails' ParamsWrapper nests into a JSON request, or a
87
+ # field ClickSend may add) are left out of +raw+ instead of failing.
88
+ READ_FIELDS = %w[
89
+ message_id status_code status_text error_code error_text custom_string message_type subaccount_id
90
+ timestamp_send timestamp from to body original_body original_message_id
91
+ ].freeze
92
+ private_constant :RAILS_ROUTING_KEYS, :SCALARS, :READ_FIELDS
93
+
94
+ module_function
95
+
96
+ # @return [Clicksend::SMS::Receipt, Clicksend::SMS::InboundMessage]
97
+ # @raise [InvalidPayload] also when the type cannot be told from the fields
98
+ def parse(params)
99
+ payload = normalize(params)
100
+ if payload.key?("from") && payload.key?("body") && !payload.key?("status_code")
101
+ inbound(payload)
102
+ elsif payload.key?("status_code") && !payload.key?("body")
103
+ receipt(payload)
104
+ else
105
+ raise InvalidPayload, "cannot tell whether this is a delivery receipt or an inbound message; use parse_receipt or parse_inbound"
106
+ end
107
+ end
108
+
109
+ # Requires +message_id+ and an integer +status_code+.
110
+ # @return [Clicksend::SMS::Receipt]
111
+ def parse_receipt(params)
112
+ receipt(normalize(params))
113
+ end
114
+
115
+ # Requires +message_id+, a non-empty +from+ and a String +body+ (may be empty).
116
+ # @return [Clicksend::SMS::InboundMessage]
117
+ def parse_inbound(params)
118
+ inbound(normalize(params))
119
+ end
120
+
121
+ def receipt(payload)
122
+ message_id!(payload)
123
+ raise InvalidPayload, "delivery receipt: status_code is missing or not an integer" unless Model.integer(payload["status_code"])
124
+
125
+ SMS::Receipt.from_api(payload)
126
+ end
127
+
128
+ def inbound(payload)
129
+ message_id!(payload)
130
+ from = payload["from"]
131
+ raise InvalidPayload, "inbound message: from is missing or not a non-empty String" unless from.is_a?(String) && !from.empty?
132
+ raise InvalidPayload, "inbound message: body is missing or not a String" unless payload["body"].is_a?(String)
133
+
134
+ SMS::InboundMessage.from_api(payload)
135
+ end
136
+
137
+ # message_id is interpolated into API paths when the event is confirmed.
138
+ def message_id!(payload)
139
+ return if payload["message_id"].is_a?(String) && payload["message_id"].match?(Resources::SMS::MESSAGE_ID)
140
+
141
+ raise InvalidPayload, "message_id is missing or not a ClickSend message ID"
142
+ end
143
+
144
+ def normalize(params)
145
+ params = params.to_unsafe_h if !params.is_a?(Hash) && params.respond_to?(:to_unsafe_h)
146
+ raise InvalidPayload, "expected a Hash of params, got #{params.class}" unless params.is_a?(Hash)
147
+ raise InvalidPayload, "payload has more than #{MAX_FIELDS} fields" if params.size > MAX_FIELDS
148
+
149
+ params.each_with_object({}) do |(key, value), payload|
150
+ raise InvalidPayload, "payload keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
151
+
152
+ key = utf8!(key.to_s)
153
+ next if RAILS_ROUTING_KEYS.include?(key)
154
+ raise InvalidPayload, "payload has the same key as both a String and a Symbol" if payload.key?(key)
155
+ unless SCALARS.any? { |type| value.is_a?(type) }
156
+ next unless READ_FIELDS.include?(key)
157
+
158
+ raise InvalidPayload, "#{key} must be a String, number, boolean or null"
159
+ end
160
+ if key.bytesize > MAX_BYTES || (value.is_a?(String) && value.bytesize > MAX_BYTES)
161
+ raise InvalidPayload, "payload has a key or value longer than #{MAX_BYTES} bytes"
162
+ end
163
+
164
+ payload[key] = value.is_a?(String) ? utf8!(value) : value
165
+ end.freeze
166
+ end
167
+
168
+ # Rack may hand over form values as binary Strings; ClickSend sends UTF-8.
169
+ # Anything that is not valid UTF-8 is rejected rather than passed on to
170
+ # break JSON encoding or logging later.
171
+ def utf8!(value)
172
+ text = (value.encoding == Encoding::BINARY) ? value.dup.force_encoding(Encoding::UTF_8) : value.encode(Encoding::UTF_8)
173
+ raise InvalidPayload, "payload has a value that is not valid UTF-8" unless text.valid_encoding?
174
+
175
+ text.frozen? ? text : text.dup.freeze
176
+ rescue EncodingError
177
+ raise InvalidPayload, "payload has a value that is not valid UTF-8"
178
+ end
179
+
180
+ private_class_method :receipt, :inbound, :message_id!, :normalize, :utf8!
181
+ end
182
+ end
data/lib/clicksend.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "clicksend/version"
4
4
  require_relative "clicksend/errors"
5
+ require_relative "clicksend/rate_limit"
6
+ require_relative "clicksend/instrumentation"
5
7
  require_relative "clicksend/transport"
6
8
  require_relative "clicksend/response"
7
9
  require_relative "clicksend/retry_policy"
@@ -14,6 +16,8 @@ require_relative "clicksend/sms/message"
14
16
  require_relative "clicksend/sms/batch"
15
17
  require_relative "clicksend/sms/receipt"
16
18
  require_relative "clicksend/sms/inbound_message"
19
+ require_relative "clicksend/sms/history_record"
20
+ require_relative "clicksend/webhook"
17
21
  require_relative "clicksend/resources/sms"
18
22
  require_relative "clicksend/client"
19
23
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clicksend
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amit Solanki
@@ -50,18 +50,27 @@ files:
50
50
  - lib/clicksend/client.rb
51
51
  - lib/clicksend/connection.rb
52
52
  - lib/clicksend/errors.rb
53
+ - lib/clicksend/instrumentation.rb
53
54
  - lib/clicksend/model.rb
54
55
  - lib/clicksend/page.rb
56
+ - lib/clicksend/rate_limit.rb
55
57
  - lib/clicksend/resources/account.rb
56
58
  - lib/clicksend/resources/sms.rb
57
59
  - lib/clicksend/response.rb
58
60
  - lib/clicksend/retry_policy.rb
59
61
  - lib/clicksend/sms/batch.rb
62
+ - lib/clicksend/sms/history_record.rb
60
63
  - lib/clicksend/sms/inbound_message.rb
61
64
  - lib/clicksend/sms/message.rb
62
65
  - lib/clicksend/sms/receipt.rb
66
+ - lib/clicksend/testing.rb
67
+ - lib/clicksend/testing/failure.rb
68
+ - lib/clicksend/testing/fake_api.rb
69
+ - lib/clicksend/testing/payloads.rb
70
+ - lib/clicksend/testing/records.rb
63
71
  - lib/clicksend/transport.rb
64
72
  - lib/clicksend/version.rb
73
+ - lib/clicksend/webhook.rb
65
74
  homepage: https://github.com/prayantr/clicksend
66
75
  licenses:
67
76
  - MIT