opensms 0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +301 -0
- data/lib/opensms/client.rb +64 -0
- data/lib/opensms/error.rb +84 -0
- data/lib/opensms/models.rb +117 -0
- data/lib/opensms/pagination.rb +34 -0
- data/lib/opensms/resources/analytics.rb +46 -0
- data/lib/opensms/resources/base.rb +64 -0
- data/lib/opensms/resources/batches.rb +96 -0
- data/lib/opensms/resources/compliance.rb +26 -0
- data/lib/opensms/resources/contact_groups.rb +55 -0
- data/lib/opensms/resources/contacts.rb +44 -0
- data/lib/opensms/resources/countries.rb +31 -0
- data/lib/opensms/resources/inbound.rb +26 -0
- data/lib/opensms/resources/lookups.rb +25 -0
- data/lib/opensms/resources/messages.rb +53 -0
- data/lib/opensms/resources/numbers.rb +63 -0
- data/lib/opensms/resources/otp.rb +32 -0
- data/lib/opensms/resources/pricing.rb +16 -0
- data/lib/opensms/resources/sandbox.rb +16 -0
- data/lib/opensms/resources/sender_ids.rb +96 -0
- data/lib/opensms/resources/suppressions.rb +40 -0
- data/lib/opensms/resources/templates.rb +42 -0
- data/lib/opensms/resources/wallet.rb +44 -0
- data/lib/opensms/resources/webhooks.rb +77 -0
- data/lib/opensms/transport.rb +200 -0
- data/lib/opensms/version.rb +6 -0
- data/lib/opensms/webhook.rb +96 -0
- data/lib/opensms.rb +17 -0
- metadata +106 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Opensms
|
|
6
|
+
module Resources
|
|
7
|
+
# The +suppressions+ resource: numbers that must not receive messages.
|
|
8
|
+
# Accessed as +client.suppressions+.
|
|
9
|
+
class Suppressions < Base
|
|
10
|
+
# GET /v1/compliance/suppressions -> Page<Suppression>.
|
|
11
|
+
def list(params = nil, **kwargs)
|
|
12
|
+
http_page("/v1/compliance/suppressions", Models.build(merge(params, kwargs), %i[limit cursor]))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# POST /v1/compliance/suppressions -> 201 Suppression. Not auto-retried.
|
|
16
|
+
# @param params [Hash] :e164, :reason (stop_keyword|manual|complaint|invalid_number)
|
|
17
|
+
def create(params = nil, **kwargs)
|
|
18
|
+
body = Models.build(merge(params, kwargs), %i[e164 reason], required: %i[e164 reason])
|
|
19
|
+
@transport.request(:post, "/v1/compliance/suppressions", body: body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Bulk add. POST /v1/compliance/suppressions/import -> 201 { created:, received: }.
|
|
23
|
+
# Not auto-retried.
|
|
24
|
+
#
|
|
25
|
+
# @param items [Array<Hash>] each { e164:, reason: }
|
|
26
|
+
def import(items)
|
|
27
|
+
raise ArgumentError, "Opensms: `items` must be an Array." unless items.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
body = { items: items.map { |i| Models.build(i, %i[e164 reason], required: %i[e164 reason]) } }
|
|
30
|
+
@transport.request(:post, "/v1/compliance/suppressions/import", body: body)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# DELETE /v1/compliance/suppressions/{id} -> 204.
|
|
34
|
+
# @return [nil]
|
|
35
|
+
def delete(id)
|
|
36
|
+
http_delete(path("/v1/compliance/suppressions", id!(id)))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Opensms
|
|
6
|
+
module Resources
|
|
7
|
+
# The +templates+ resource: reusable message bodies with {{placeholders}}.
|
|
8
|
+
# Accessed as +client.templates+.
|
|
9
|
+
class Templates < Base
|
|
10
|
+
FIELDS = %i[name body traffic_type].freeze
|
|
11
|
+
|
|
12
|
+
# GET /v1/templates -> Page<Template>.
|
|
13
|
+
def list(params = nil, **kwargs)
|
|
14
|
+
http_page("/v1/templates", Models.build(merge(params, kwargs), %i[limit cursor]))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# POST /v1/templates -> 201 Template.
|
|
18
|
+
# @param params [Hash] :name (required), :body (required), :traffic_type
|
|
19
|
+
def create(params = nil, idempotency_key: nil, **kwargs)
|
|
20
|
+
body = Models.build(merge(params, kwargs), FIELDS, required: %i[name body])
|
|
21
|
+
@transport.request(:post, "/v1/templates", body: body, idempotency_key: idem(idempotency_key))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# GET /v1/templates/{id} -> Template.
|
|
25
|
+
def get(id)
|
|
26
|
+
http_get(path("/v1/templates", id!(id)))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# PATCH /v1/templates/{id} -> Template.
|
|
30
|
+
def update(id, params = nil, **kwargs)
|
|
31
|
+
body = Models.build(merge(params, kwargs), FIELDS)
|
|
32
|
+
@transport.request(:patch, path("/v1/templates", id!(id)), body: body)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# DELETE /v1/templates/{id} -> 204.
|
|
36
|
+
# @return [nil]
|
|
37
|
+
def delete(id)
|
|
38
|
+
http_delete(path("/v1/templates", id!(id)))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Opensms
|
|
6
|
+
module Resources
|
|
7
|
+
# The +wallet+ resource: balances, ledger and top-ups. Accessed as
|
|
8
|
+
# +client.wallet+.
|
|
9
|
+
class Wallet < Base
|
|
10
|
+
# GET /v1/wallet -> the +data+ array of balances.
|
|
11
|
+
# @return [Array<Hash>] [{ id:, currency:, balance:, reserved:, environment: }]
|
|
12
|
+
def balances
|
|
13
|
+
unwrap(http_get("/v1/wallet"))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Ledger entries, newest first. Pages with +before+ (the smallest id
|
|
17
|
+
# already seen) instead of cursors; stop when fewer than +limit+ rows
|
|
18
|
+
# come back. GET /v1/wallet/ledger.
|
|
19
|
+
#
|
|
20
|
+
# @param params [Hash] :limit (1..200), :before
|
|
21
|
+
# @return [Array<Hash>] LedgerEntry list
|
|
22
|
+
def ledger(params = nil, **kwargs)
|
|
23
|
+
unwrap(http_get("/v1/wallet/ledger", Models.build(merge(params, kwargs), %i[limit before])))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Start a payment-provider top-up (live keys only).
|
|
27
|
+
# POST /v1/wallet/topups -> 201 { id:, reference:, authorization_url:, ... }.
|
|
28
|
+
#
|
|
29
|
+
# @param params [Hash] :amount (decimal string), :currency, :channel
|
|
30
|
+
# (card|mobile_money|bank_transfer), :email
|
|
31
|
+
def create_topup(params = nil, idempotency_key: nil, **kwargs)
|
|
32
|
+
fields = %i[amount currency channel email]
|
|
33
|
+
body = Models.build(merge(params, kwargs), fields, required: fields)
|
|
34
|
+
@transport.request(:post, "/v1/wallet/topups", body: body, idempotency_key: idem(idempotency_key))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def unwrap(body)
|
|
40
|
+
body.is_a?(Hash) ? (body[:data] || []) : body
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../webhook"
|
|
5
|
+
|
|
6
|
+
module Opensms
|
|
7
|
+
module Resources
|
|
8
|
+
# The +webhooks+ resource: endpoints, deliveries, replays and signature
|
|
9
|
+
# verification. Accessed as +client.webhooks+.
|
|
10
|
+
class Webhooks < Base
|
|
11
|
+
# GET /v1/webhooks -> Page<Endpoint>.
|
|
12
|
+
def list(params = nil, **kwargs)
|
|
13
|
+
http_page("/v1/webhooks", Models.build(merge(params, kwargs), %i[limit cursor]))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# POST /v1/webhooks -> 201 Endpoint. The response carries +secret+
|
|
17
|
+
# (whsec_...) exactly once: store it.
|
|
18
|
+
#
|
|
19
|
+
# @param params [Hash] :url (https, required), :events (required), :enabled
|
|
20
|
+
def create(params = nil, idempotency_key: nil, **kwargs)
|
|
21
|
+
body = Models.build(merge(params, kwargs), %i[url events enabled], required: %i[url events])
|
|
22
|
+
@transport.request(:post, "/v1/webhooks", body: body, idempotency_key: idem(idempotency_key))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /v1/webhooks/{id} -> Endpoint (no secret).
|
|
26
|
+
def get(id)
|
|
27
|
+
http_get(path("/v1/webhooks", id!(id)))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Full replacement. PUT /v1/webhooks/{id} -> Endpoint.
|
|
31
|
+
#
|
|
32
|
+
# @param params [Hash] :url, :events, :enabled (all required)
|
|
33
|
+
def update(id, params = nil, idempotency_key: nil, **kwargs)
|
|
34
|
+
body = Models.build(merge(params, kwargs), %i[url events enabled], required: %i[url events enabled])
|
|
35
|
+
@transport.request(:put, path("/v1/webhooks", id!(id)), body: body, idempotency_key: idem(idempotency_key))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# DELETE /v1/webhooks/{id} -> 204.
|
|
39
|
+
# @return [nil]
|
|
40
|
+
def delete(id, idempotency_key: nil)
|
|
41
|
+
http_delete(path("/v1/webhooks", id!(id)), idempotency_key: idem(idempotency_key))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Queue a webhook.test delivery. POST /v1/webhooks/{id}/test -> 202 { status: }.
|
|
45
|
+
def test(id, idempotency_key: nil)
|
|
46
|
+
@transport.request(:post, path("/v1/webhooks", id!(id), :test), idempotency_key: idem(idempotency_key))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# GET /v1/webhooks/{id}/deliveries -> Page<Delivery>.
|
|
50
|
+
def list_deliveries(id, params = nil, **kwargs)
|
|
51
|
+
http_page(path("/v1/webhooks", id!(id), :deliveries), Models.build(merge(params, kwargs), %i[limit cursor]))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Replay a delivery.
|
|
55
|
+
# POST /v1/webhooks/{id}/deliveries/{delivery_id}/replay -> 202 { status: }.
|
|
56
|
+
#
|
|
57
|
+
# @param params [Hash] :generation (from the delivery), :reason (5..1000 chars)
|
|
58
|
+
def replay_delivery(id, delivery_id, params = nil, idempotency_key: nil, **kwargs)
|
|
59
|
+
body = Models.build(merge(params, kwargs), %i[generation reason], required: %i[generation reason])
|
|
60
|
+
@transport.request(:post, path("/v1/webhooks", id!(id), :deliveries, id!(delivery_id, "delivery_id"), :replay),
|
|
61
|
+
body: body, idempotency_key: idem(idempotency_key))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# See {Opensms::Webhook.verify_signature}.
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def verify_signature(payload, header, secret, tolerance_seconds: Webhook::DEFAULT_TOLERANCE, now: nil)
|
|
67
|
+
Webhook.verify_signature(payload, header, secret, tolerance_seconds: tolerance_seconds, now: now)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# See {Opensms::Webhook.construct_event}.
|
|
71
|
+
# @return [Opensms::WebhookEvent]
|
|
72
|
+
def construct_event(payload, header, secret, tolerance_seconds: Webhook::DEFAULT_TOLERANCE, now: nil)
|
|
73
|
+
Webhook.construct_event(payload, header, secret, tolerance_seconds: tolerance_seconds, now: now)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "version"
|
|
10
|
+
require_relative "error"
|
|
11
|
+
|
|
12
|
+
module Opensms
|
|
13
|
+
# Default HTTP client: a thin Net::HTTP adapter. Any object that responds to
|
|
14
|
+
# +call(method, url, headers, body, timeout)+ and returns
|
|
15
|
+
# +[status, headers, body]+ (headers with lower-cased names, body a String or
|
|
16
|
+
# nil) can replace it through +Opensms::Client.new(http_client: ...)+.
|
|
17
|
+
class NetHttpClient
|
|
18
|
+
METHODS = {
|
|
19
|
+
get: Net::HTTP::Get,
|
|
20
|
+
post: Net::HTTP::Post,
|
|
21
|
+
put: Net::HTTP::Put,
|
|
22
|
+
patch: Net::HTTP::Patch,
|
|
23
|
+
delete: Net::HTTP::Delete
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# @return [Array(Integer, Hash{String=>String}, String)]
|
|
27
|
+
def call(method, url, headers, body, timeout)
|
|
28
|
+
uri = URI.parse(url)
|
|
29
|
+
req = METHODS.fetch(method).new(uri)
|
|
30
|
+
headers.each { |k, v| req[k] = v }
|
|
31
|
+
req.body = body unless body.nil?
|
|
32
|
+
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = uri.scheme == "https"
|
|
35
|
+
http.open_timeout = timeout
|
|
36
|
+
http.read_timeout = timeout
|
|
37
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
38
|
+
res = http.request(req)
|
|
39
|
+
|
|
40
|
+
out = {}
|
|
41
|
+
res.each_header { |k, v| out[k.downcase] = v }
|
|
42
|
+
[res.code.to_i, out, res.body]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# HTTP transport: the single place that talks to the network. Owns bearer
|
|
47
|
+
# authentication, headers, JSON encode/decode, Idempotency-Key reuse,
|
|
48
|
+
# timeouts, retries with backoff, and turning non-2xx responses into
|
|
49
|
+
# {Opensms::Error}. Resources depend on this, never on Net::HTTP directly.
|
|
50
|
+
class Transport
|
|
51
|
+
DEFAULT_BASE_URL = "https://api.opensms.io"
|
|
52
|
+
USER_AGENT = "opensms-ruby/#{VERSION}"
|
|
53
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
54
|
+
MAX_RETRY_AFTER = 60
|
|
55
|
+
KEY_PATTERN = /\Ask_(test|live)_.{13,}\z/m.freeze
|
|
56
|
+
|
|
57
|
+
attr_reader :base_url, :environment
|
|
58
|
+
|
|
59
|
+
# @param api_key [String] "sk_test_..." or "sk_live_..."
|
|
60
|
+
# @param base_url [String]
|
|
61
|
+
# @param timeout [Numeric] seconds, per attempt
|
|
62
|
+
# @param max_retries [Integer] retries after the first attempt
|
|
63
|
+
# @param http_client [#call] see {NetHttpClient}
|
|
64
|
+
# @param sleeper [#call] receives the delay in seconds (tests inject a no-op)
|
|
65
|
+
# @param random [#call] returns a Float in [0, 1) for backoff jitter
|
|
66
|
+
# rubocop:disable Metrics/ParameterLists
|
|
67
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30, max_retries: 2,
|
|
68
|
+
http_client: nil, sleeper: nil, random: nil)
|
|
69
|
+
unless api_key.is_a?(String) && KEY_PATTERN.match?(api_key)
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"Opensms: `api_key` must start with sk_test_ or sk_live_ followed by more than 12 characters."
|
|
72
|
+
end
|
|
73
|
+
raise ArgumentError, "Opensms: `max_retries` must be >= 0." unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
74
|
+
|
|
75
|
+
@api_key = api_key
|
|
76
|
+
@environment = api_key.start_with?("sk_live_") ? "live" : "sandbox"
|
|
77
|
+
@base_url = (base_url || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
|
|
78
|
+
@timeout = timeout
|
|
79
|
+
@max_retries = max_retries
|
|
80
|
+
@http_client = http_client || NetHttpClient.new
|
|
81
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
82
|
+
@random = random || -> { Random.rand }
|
|
83
|
+
end
|
|
84
|
+
# rubocop:enable Metrics/ParameterLists
|
|
85
|
+
|
|
86
|
+
# Perform a request and return the decoded JSON body (symbol keys), or nil
|
|
87
|
+
# for 204 / empty bodies.
|
|
88
|
+
#
|
|
89
|
+
# @param method [Symbol] :get, :post, :put, :patch, :delete
|
|
90
|
+
# @param path [String] path beginning with "/" (segments already escaped)
|
|
91
|
+
# @param query [Hash, nil] nil values are dropped, arrays joined with ","
|
|
92
|
+
# @param body [Hash, Array, nil] JSON body
|
|
93
|
+
# @param raw_body [String, nil] raw body sent with +content_type+ instead of JSON
|
|
94
|
+
# @param content_type [String, nil]
|
|
95
|
+
# @param idempotency_key [String, nil] sent verbatim and reused on every retry
|
|
96
|
+
# @return [Hash, Array, nil]
|
|
97
|
+
# rubocop:disable Metrics/ParameterLists
|
|
98
|
+
def request(method, path, query: nil, body: nil, raw_body: nil, content_type: nil, idempotency_key: nil)
|
|
99
|
+
url = build_url(path, query)
|
|
100
|
+
headers = base_headers
|
|
101
|
+
payload = nil
|
|
102
|
+
if !raw_body.nil?
|
|
103
|
+
payload = raw_body
|
|
104
|
+
headers["Content-Type"] = content_type || "application/octet-stream"
|
|
105
|
+
elsif !body.nil?
|
|
106
|
+
payload = JSON.generate(body)
|
|
107
|
+
headers["Content-Type"] = "application/json"
|
|
108
|
+
end
|
|
109
|
+
headers["Idempotency-Key"] = idempotency_key unless idempotency_key.nil?
|
|
110
|
+
can_retry = method != :post || !idempotency_key.nil?
|
|
111
|
+
|
|
112
|
+
perform(method, url, headers, payload, can_retry)
|
|
113
|
+
end
|
|
114
|
+
# rubocop:enable Metrics/ParameterLists
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def perform(method, url, headers, payload, can_retry)
|
|
119
|
+
attempt = 0
|
|
120
|
+
loop do
|
|
121
|
+
attempt += 1
|
|
122
|
+
retries_left = can_retry && attempt <= @max_retries
|
|
123
|
+
begin
|
|
124
|
+
status, res_headers, raw = @http_client.call(method, url, headers, payload, @timeout)
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
raise Error.new(status: 0, message: "OpenSMS request failed: #{e.class}: #{e.message}") unless retries_left
|
|
127
|
+
|
|
128
|
+
@sleeper.call(backoff(attempt))
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
res_headers = normalize_headers(res_headers)
|
|
133
|
+
return decode(raw) if status.between?(200, 299)
|
|
134
|
+
|
|
135
|
+
retry_after = parse_retry_after(res_headers["retry-after"])
|
|
136
|
+
error = Error.from_response(status, res_headers, raw, retry_after: retry_after)
|
|
137
|
+
raise error unless retries_left && RETRY_STATUSES.include?(status)
|
|
138
|
+
raise error if retry_after && retry_after > MAX_RETRY_AFTER
|
|
139
|
+
|
|
140
|
+
@sleeper.call(retry_after || backoff(attempt))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def base_headers
|
|
145
|
+
{
|
|
146
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
147
|
+
"Accept" => "application/json",
|
|
148
|
+
"User-Agent" => USER_AGENT
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def build_url(path, query)
|
|
153
|
+
url = "#{@base_url}#{path}"
|
|
154
|
+
return url if query.nil?
|
|
155
|
+
|
|
156
|
+
pairs = query.each_with_object([]) do |(k, v), acc|
|
|
157
|
+
next if v.nil?
|
|
158
|
+
|
|
159
|
+
acc << [k.to_s, v.is_a?(Array) ? v.join(",") : v.to_s]
|
|
160
|
+
end
|
|
161
|
+
return url if pairs.empty?
|
|
162
|
+
|
|
163
|
+
# Commas are legal in a query (RFC 3986 sub-delims); keep them literal so
|
|
164
|
+
# list values read as countries=KE,NG on the wire.
|
|
165
|
+
"#{url}?#{URI.encode_www_form(pairs).gsub('%2C', ',')}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def normalize_headers(headers)
|
|
169
|
+
(headers || {}).each_with_object({}) do |(k, v), acc|
|
|
170
|
+
acc[k.to_s.downcase] = v.is_a?(Array) ? v.first : v
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def decode(raw)
|
|
175
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
176
|
+
|
|
177
|
+
JSON.parse(raw, symbolize_names: true)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
raise Error.new(status: 0, message: "OpenSMS returned a response that is not valid JSON", body: raw)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Full-jitter exponential backoff: random(0, min(8, 0.5 * 2^(n-1))).
|
|
183
|
+
def backoff(attempt)
|
|
184
|
+
cap = [8.0, 0.5 * (2**(attempt - 1))].min
|
|
185
|
+
@random.call * cap
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Retry-After is integer seconds or an HTTP date.
|
|
189
|
+
def parse_retry_after(value)
|
|
190
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
191
|
+
|
|
192
|
+
text = value.to_s.strip
|
|
193
|
+
return text.to_i if text.match?(/\A\d+\z/)
|
|
194
|
+
|
|
195
|
+
[Time.httpdate(text) - Time.now, 0].max.ceil
|
|
196
|
+
rescue ArgumentError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "models"
|
|
8
|
+
|
|
9
|
+
module Opensms
|
|
10
|
+
# Webhook signature verification. Works without an API key, so a receiver
|
|
11
|
+
# only needs the endpoint secret.
|
|
12
|
+
#
|
|
13
|
+
# The API sends +X-OpenSMS-Signature: t=<unix seconds>,v1=<hex>+ where
|
|
14
|
+
# v1 = hex(HMAC-SHA256(secret, "<t>.<raw body>")). The secret is the full
|
|
15
|
+
# +whsec_...+ string returned once by +webhooks.create+, used verbatim.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# event = Opensms::Webhook.construct_event(request.body.read,
|
|
19
|
+
# request.get_header("HTTP_X_OPENSMS_SIGNATURE"),
|
|
20
|
+
# ENV.fetch("OPENSMS_WEBHOOK_SECRET"))
|
|
21
|
+
module Webhook
|
|
22
|
+
HEADER = "X-OpenSMS-Signature"
|
|
23
|
+
DEFAULT_TOLERANCE = 300
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param payload [String] the exact raw request body
|
|
28
|
+
# @param header [String, nil] the X-OpenSMS-Signature header value
|
|
29
|
+
# @param secret [String] the endpoint secret (whsec_...)
|
|
30
|
+
# @param tolerance_seconds [Integer] max clock skew, default 300
|
|
31
|
+
# @param now [Integer, Time, nil] current time, injectable for tests
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def verify_signature(payload, header, secret, tolerance_seconds: DEFAULT_TOLERANCE, now: nil)
|
|
34
|
+
check(payload, header, secret, tolerance_seconds, now).nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Verify the signature, then parse the body.
|
|
38
|
+
#
|
|
39
|
+
# @return [Opensms::WebhookEvent]
|
|
40
|
+
# @raise [Opensms::Error] status 0, code "invalid_signature" or "expired_signature"
|
|
41
|
+
def construct_event(payload, header, secret, tolerance_seconds: DEFAULT_TOLERANCE, now: nil)
|
|
42
|
+
failure = check(payload, header, secret, tolerance_seconds, now)
|
|
43
|
+
unless failure.nil?
|
|
44
|
+
message = failure == "expired_signature" ? "webhook signature timestamp is outside the tolerance" : "webhook signature is invalid"
|
|
45
|
+
raise Error.new(status: 0, code: failure, message: message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
data = JSON.parse(payload.to_s, symbolize_names: true)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
raise Error.new(status: 0, code: "invalid_payload", message: "webhook payload is not valid JSON", body: payload)
|
|
52
|
+
end
|
|
53
|
+
WebhookEvent.from(data.is_a?(Hash) ? data : {})
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns nil when valid, else "invalid_signature" or "expired_signature".
|
|
57
|
+
def check(payload, header, secret, tolerance, now)
|
|
58
|
+
return "invalid_signature" if secret.nil? || secret.strip.empty? || header.nil? || tolerance.negative?
|
|
59
|
+
|
|
60
|
+
parts = parse_header(header.to_s)
|
|
61
|
+
return "invalid_signature" if parts.nil?
|
|
62
|
+
|
|
63
|
+
t = parts["t"]
|
|
64
|
+
return "invalid_signature" unless t.match?(/\A[+-]?\d+\z/)
|
|
65
|
+
|
|
66
|
+
v1 = parts["v1"]
|
|
67
|
+
return "invalid_signature" unless v1.match?(/\A[0-9a-fA-F]{64}\z/)
|
|
68
|
+
|
|
69
|
+
now_s = now.nil? ? Time.now.to_i : now.to_i
|
|
70
|
+
return "expired_signature" if (now_s - t.to_i).abs > tolerance
|
|
71
|
+
|
|
72
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret.b, "#{t}.".b + payload.to_s.b)
|
|
73
|
+
secure_compare(expected, v1.downcase) ? nil : "invalid_signature"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Mirror the server parser: exactly the keys t and v1, no duplicates, no
|
|
77
|
+
# empty keys or values.
|
|
78
|
+
def parse_header(header)
|
|
79
|
+
out = {}
|
|
80
|
+
header.split(",", -1).each do |part|
|
|
81
|
+
key, value = part.strip.split("=", 2)
|
|
82
|
+
return nil if key.nil? || key.empty? || value.nil? || value.empty?
|
|
83
|
+
return nil if out.key?(key)
|
|
84
|
+
|
|
85
|
+
out[key] = value
|
|
86
|
+
end
|
|
87
|
+
out.keys.sort == %w[t v1] ? out : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def secure_compare(a, b)
|
|
91
|
+
return false unless a.bytesize == b.bytesize
|
|
92
|
+
|
|
93
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/opensms.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "opensms/version"
|
|
4
|
+
require_relative "opensms/error"
|
|
5
|
+
require_relative "opensms/models"
|
|
6
|
+
require_relative "opensms/transport"
|
|
7
|
+
require_relative "opensms/webhook"
|
|
8
|
+
require_relative "opensms/client"
|
|
9
|
+
|
|
10
|
+
# Ruby SDK for the OpenSMS API (https://opensms.io).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# require "opensms"
|
|
14
|
+
# client = Opensms::Client.new(api_key: ENV.fetch("OPENSMS_API_KEY"))
|
|
15
|
+
# client.messages.send(to: "+254700000012", text: "Hello from Ruby")
|
|
16
|
+
module Opensms
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: opensms
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- OpenSMS
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: Send SMS and OTPs, run batches, look up numbers, and manage contacts,
|
|
42
|
+
templates, webhooks, numbers, sender IDs, suppressions and wallet through the OpenSMS
|
|
43
|
+
API. Zero runtime dependencies.
|
|
44
|
+
email:
|
|
45
|
+
- engineering@opensms.io
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/opensms.rb
|
|
53
|
+
- lib/opensms/client.rb
|
|
54
|
+
- lib/opensms/error.rb
|
|
55
|
+
- lib/opensms/models.rb
|
|
56
|
+
- lib/opensms/pagination.rb
|
|
57
|
+
- lib/opensms/resources/analytics.rb
|
|
58
|
+
- lib/opensms/resources/base.rb
|
|
59
|
+
- lib/opensms/resources/batches.rb
|
|
60
|
+
- lib/opensms/resources/compliance.rb
|
|
61
|
+
- lib/opensms/resources/contact_groups.rb
|
|
62
|
+
- lib/opensms/resources/contacts.rb
|
|
63
|
+
- lib/opensms/resources/countries.rb
|
|
64
|
+
- lib/opensms/resources/inbound.rb
|
|
65
|
+
- lib/opensms/resources/lookups.rb
|
|
66
|
+
- lib/opensms/resources/messages.rb
|
|
67
|
+
- lib/opensms/resources/numbers.rb
|
|
68
|
+
- lib/opensms/resources/otp.rb
|
|
69
|
+
- lib/opensms/resources/pricing.rb
|
|
70
|
+
- lib/opensms/resources/sandbox.rb
|
|
71
|
+
- lib/opensms/resources/sender_ids.rb
|
|
72
|
+
- lib/opensms/resources/suppressions.rb
|
|
73
|
+
- lib/opensms/resources/templates.rb
|
|
74
|
+
- lib/opensms/resources/wallet.rb
|
|
75
|
+
- lib/opensms/resources/webhooks.rb
|
|
76
|
+
- lib/opensms/transport.rb
|
|
77
|
+
- lib/opensms/version.rb
|
|
78
|
+
- lib/opensms/webhook.rb
|
|
79
|
+
homepage: https://opensms.io
|
|
80
|
+
licenses:
|
|
81
|
+
- MIT
|
|
82
|
+
metadata:
|
|
83
|
+
homepage_uri: https://opensms.io
|
|
84
|
+
source_code_uri: https://github.com/opensms-io/opensms-sdks
|
|
85
|
+
documentation_uri: https://docs.opensms.io
|
|
86
|
+
rubygems_mfa_required: 'true'
|
|
87
|
+
post_install_message:
|
|
88
|
+
rdoc_options: []
|
|
89
|
+
require_paths:
|
|
90
|
+
- lib
|
|
91
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.0'
|
|
96
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '0'
|
|
101
|
+
requirements: []
|
|
102
|
+
rubygems_version: 3.5.22
|
|
103
|
+
signing_key:
|
|
104
|
+
specification_version: 4
|
|
105
|
+
summary: Ruby SDK for the OpenSMS API.
|
|
106
|
+
test_files: []
|