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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +418 -81
- data/docs/clicksend-api-notes.md +52 -16
- data/lib/clicksend/client.rb +58 -17
- data/lib/clicksend/connection.rb +203 -32
- data/lib/clicksend/errors.rb +99 -3
- data/lib/clicksend/instrumentation.rb +42 -0
- data/lib/clicksend/model.rb +9 -0
- data/lib/clicksend/page.rb +6 -3
- data/lib/clicksend/rate_limit.rb +40 -0
- data/lib/clicksend/resources/account.rb +1 -1
- data/lib/clicksend/resources/sms.rb +98 -20
- data/lib/clicksend/response.rb +35 -0
- data/lib/clicksend/retry_policy.rb +44 -27
- data/lib/clicksend/sms/history_record.rb +85 -0
- data/lib/clicksend/sms/message.rb +1 -1
- data/lib/clicksend/testing/failure.rb +81 -0
- data/lib/clicksend/testing/fake_api.rb +422 -0
- data/lib/clicksend/testing/payloads.rb +88 -0
- data/lib/clicksend/testing/records.rb +27 -0
- data/lib/clicksend/testing.rb +88 -0
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +182 -0
- data/lib/clicksend.rb +4 -0
- metadata +10 -1
|
@@ -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
|
data/lib/clicksend/version.rb
CHANGED
|
@@ -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.
|
|
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
|