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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a17384937f73b877c763bb9b088637508a42b0a31dd2bb3fa729cbafa264877a
|
|
4
|
+
data.tar.gz: 7b69b1f9c8775fc52f5487decd20e56794f57b397828835a1a7d724277e7491b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f5aec7b0fa7ea901aaa99c775ba7ccb00e323a27300b2be33e6e591104ac36a20bc8540f8d74d3a983696815de678a2df7d42d9b24647576ec4d6178ae74183f
|
|
7
|
+
data.tar.gz: a9afec572d9b9e706d40653ec5045c76b79f4a2a818f745cf68cffb1f69bfb9aa546d7fc84bdd5b1cea78ba2aceffbb108ce169523c0d4d6bb866606d4c08401
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opensms
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# opensms (Ruby)
|
|
2
|
+
|
|
3
|
+
Official Ruby client for [opensms](https://opensms.io): prepaid SMS for Africa.
|
|
4
|
+
|
|
5
|
+
Ruby 3.0+, zero runtime dependencies (standard library only).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile, once the gem is published
|
|
11
|
+
gem "opensms"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Not on RubyGems yet: until then, install from this checkout with
|
|
15
|
+
`gem "opensms", path: "path/to/sdks/packages/ruby"` in your Gemfile, or
|
|
16
|
+
`gem build opensms.gemspec && gem install ./opensms-0.1.0.gem`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require "opensms"
|
|
22
|
+
|
|
23
|
+
client = Opensms::Client.new(api_key: ENV.fetch("OPENSMS_API_KEY"))
|
|
24
|
+
|
|
25
|
+
msg = client.messages.send(to: "+254712345678", text: "Your order has shipped")
|
|
26
|
+
puts msg[:id], msg[:status]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The key selects the environment: `sk_test_...` keys run in the sandbox
|
|
30
|
+
(`client.environment == "sandbox"`), `sk_live_...` keys send real traffic
|
|
31
|
+
(`"live"`). A malformed key raises `ArgumentError` before any network call.
|
|
32
|
+
|
|
33
|
+
Methods take keyword arguments (or a Hash with String or Symbol keys) using
|
|
34
|
+
the API's snake_case field names. Responses are plain Ruby `Hash`/`Array`
|
|
35
|
+
values with symbol keys, exactly as the API returns them: money stays a
|
|
36
|
+
decimal string, timestamps stay RFC 3339 strings, and unknown fields are
|
|
37
|
+
kept. Unknown request parameters raise `ArgumentError` locally, because the
|
|
38
|
+
API rejects unknown fields. Time-like inputs (`scheduled_at`, `date_from`,
|
|
39
|
+
`date_to`, analytics `from`/`to`) accept a `Time`, `Date` or string.
|
|
40
|
+
|
|
41
|
+
## More
|
|
42
|
+
|
|
43
|
+
One example per resource. See [`../../spec/SURFACE.md`](https://github.com/opensms-io/opensms-sdks/blob/main/spec/SURFACE.md)
|
|
44
|
+
for the full surface.
|
|
45
|
+
|
|
46
|
+
### messages
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
msg = client.messages.send(to: "+254712345678", text: "Hi Ada", sender_id: "ACME")
|
|
50
|
+
client.messages.list(limit: 20, status: "delivered", country: "KE")
|
|
51
|
+
client.messages.get(msg[:id])
|
|
52
|
+
client.messages.attempts(msg[:id]) # delivery attempts (Array)
|
|
53
|
+
client.messages.cancel(msg[:id]) # only queued or scheduled messages; never auto-retried
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### batches
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
batch = client.batches.create(items: [
|
|
60
|
+
{ to: "+254712345678", text: "Hello 1" },
|
|
61
|
+
{ to: "+254712345679", text: "Hello 2" }
|
|
62
|
+
], dedupe: true)
|
|
63
|
+
client.batches.validation(batch[:id]) # per-row report
|
|
64
|
+
client.batches.start(batch[:id]) # batches are created "ready" and send nothing until started
|
|
65
|
+
client.batches.list_items(batch[:id], status: "delivered")
|
|
66
|
+
client.batches.stop(batch[:id])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### otp
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
otp = client.otp.send(to: "+254712345678", length: 6, ttl_seconds: 300)
|
|
73
|
+
res = client.otp.verify(otp_id: otp[:otp_id], code: "123456")
|
|
74
|
+
res[:valid] # true / false
|
|
75
|
+
res[:attempts_left] # a wrong code burns an attempt, so verify is never auto-retried
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### lookups
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
lookup = client.lookups.create(to: "+254712345678")
|
|
82
|
+
client.lookups.get(lookup[:id])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### contacts
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
contact = client.contacts.create(e164: "+254712345678", name: "Ada", attributes: { tier: "gold" })
|
|
89
|
+
client.contacts.update(contact[:id], name: "Ada L") # partial update
|
|
90
|
+
client.contacts.list(limit: 50)
|
|
91
|
+
client.contacts.delete(contact[:id])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### contact_groups
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
group = client.contact_groups.create(name: "VIP", contact_ids: [contact[:id]])
|
|
98
|
+
client.contact_groups.send(group[:id], text: "Hi all") # returns a running Batch
|
|
99
|
+
client.contact_groups.send(group[:id], template_id: "tpl-id", variables: { name: "Ada" })
|
|
100
|
+
client.contact_groups.delete(group[:id])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### templates
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
tpl = client.templates.create(name: "welcome", body: "Hi {{name}}", traffic_type: "transactional")
|
|
107
|
+
client.templates.update(tpl[:id], body: "Hello {{name}}")
|
|
108
|
+
client.templates.list
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### webhooks
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
hook = client.webhooks.create(url: "https://you.example/opensms", events: ["message.delivered", "message.failed"])
|
|
115
|
+
secret = hook[:secret] # whsec_..., returned only once: store it
|
|
116
|
+
|
|
117
|
+
client.webhooks.update(hook[:id], url: "https://you.example/v2", events: ["message.delivered"], enabled: true)
|
|
118
|
+
client.webhooks.test(hook[:id]) # queues a webhook.test delivery
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`update` is a full replacement: `url`, `events` and `enabled` are all required.
|
|
122
|
+
See [Webhooks](#webhooks) below for signature verification.
|
|
123
|
+
|
|
124
|
+
### inbound
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
client.inbound.list
|
|
128
|
+
client.inbound.reply("inbound-id", text: "Thanks!") # live keys only
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### numbers
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
client.numbers.available(country: "KE", kind: "long_code")
|
|
135
|
+
number = client.numbers.assign(country: "KE", kind: "long_code") # charges the wallet; live keys only
|
|
136
|
+
client.numbers.create_rule(number[:id], match: "keyword", pattern: "STOP", action: "webhook",
|
|
137
|
+
target: "https://you.example/inbound")
|
|
138
|
+
client.numbers.release(number[:id])
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### sender_ids
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
quote = client.sender_ids.quote(countries: ["KE", "NG"])
|
|
145
|
+
sender = client.sender_ids.create(value: "ACME", kind: "alphanumeric", countries: ["KE"],
|
|
146
|
+
use_case: "transactional", documents: ["doc-id-1"],
|
|
147
|
+
quote_id: quote[:quote_id]) # may charge fees: never auto-retried
|
|
148
|
+
client.sender_ids.update(sender[:id], use_case: "otp", countries: ["KE"], documents: ["doc-id-1"])
|
|
149
|
+
|
|
150
|
+
draft = client.sender_ids.create_draft(source: "application", value: "ACME", kind: "alphanumeric", countries: ["KE"])
|
|
151
|
+
client.sender_ids.update_draft(draft[:id], version: draft[:version], sample_message: "Your code is 1234")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Document upload and download are console-only and not part of the SDK.
|
|
155
|
+
|
|
156
|
+
### suppressions
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
sup = client.suppressions.create(e164: "+254712345678", reason: "manual") # never auto-retried
|
|
160
|
+
client.suppressions.import([{ e164: "+254712345679", reason: "complaint" }])
|
|
161
|
+
client.suppressions.delete(sup[:id])
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### compliance
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
client.compliance.list_countries
|
|
168
|
+
client.compliance.get_country("KE")
|
|
169
|
+
client.compliance.list_content_rules
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### wallet
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
client.wallet.balances # [{ currency: "KES", balance: "100.000000", ... }]
|
|
176
|
+
entries = client.wallet.ledger(limit: 100)
|
|
177
|
+
older = client.wallet.ledger(limit: 100, before: entries.map { |e| e[:id] }.min) # pages with `before`
|
|
178
|
+
client.wallet.create_topup(amount: "1000", currency: "KES", channel: "mobile_money", email: "billing@you.example")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### pricing
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
client.pricing.get(product: "sms", country: "KE")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### analytics
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
client.analytics.overview(range: "7d")
|
|
191
|
+
client.analytics.by_country(from: Date.today - 30, to: Date.today)
|
|
192
|
+
client.analytics.timeseries(range: "2d", bucket: "hour")
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### sandbox
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
client.sandbox.list_messages(limit: 10) # rendered text of sandbox sends, including OTP codes
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### countries
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
client.countries.list
|
|
205
|
+
client.countries.carriers("KE")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Pagination
|
|
209
|
+
|
|
210
|
+
Cursor lists return an `Opensms::Page` with `items` and `next_cursor` (nil on
|
|
211
|
+
the last page). `client.paginate` walks every page lazily:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
client.paginate(:messages, :list, limit: 50).each { |m| puts m[:id] }
|
|
215
|
+
client.paginate(client.contacts.method(:list), limit: 200).to_a
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Errors and retries
|
|
219
|
+
|
|
220
|
+
Every non-2xx response raises `Opensms::Error`, mapped from the API's
|
|
221
|
+
RFC 9457 problem body:
|
|
222
|
+
|
|
223
|
+
| Accessor | Meaning |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `status` | HTTP status (`0` for a network failure or timeout) |
|
|
226
|
+
| `type` | problem `type` (`"about:blank"` or a problems URI) |
|
|
227
|
+
| `title` | HTTP reason title, e.g. `"Bad Request"` |
|
|
228
|
+
| `detail` | human-readable detail (also `e.message`) |
|
|
229
|
+
| `code` | machine code, absent on most errors |
|
|
230
|
+
| `errors` | field-error map, when present |
|
|
231
|
+
| `request_id` | set on message and OTP admission rejections |
|
|
232
|
+
| `retry_after` | seconds, when the API asked to wait |
|
|
233
|
+
| `trace_id` | present when the API attaches one |
|
|
234
|
+
| `body` | raw decoded body (or raw text when not JSON) |
|
|
235
|
+
|
|
236
|
+
**Branch on `status`, not `code`.** Most opensms errors carry no `code`; use
|
|
237
|
+
`detail` for display. Insufficient API key scope is `401` on `messages` and
|
|
238
|
+
`otp`, but `403` everywhere else.
|
|
239
|
+
|
|
240
|
+
Retries: `429`, `500`, `502`, `503`, `504`, network errors and timeouts are
|
|
241
|
+
retried up to `max_retries` times (default 2), only when safe. `GET`, `PUT`,
|
|
242
|
+
`PATCH` and `DELETE` are always retryable. A `POST` is retried only when it
|
|
243
|
+
carries an `Idempotency-Key`; the SDK generates a UUIDv4 per call and reuses
|
|
244
|
+
it unchanged across retries of that call, so a retried send is never
|
|
245
|
+
duplicated. `Retry-After` is honoured (seconds or HTTP date); if it asks for
|
|
246
|
+
more than 60 seconds the SDK does not wait, it raises `Opensms::Error` with
|
|
247
|
+
`retry_after` set. Otherwise the delay is exponential backoff with full
|
|
248
|
+
jitter, capped at 8 seconds. Never retried: other `4xx` responses, and
|
|
249
|
+
`messages.cancel`, `otp.verify`, `sender_ids.create`,
|
|
250
|
+
`sender_ids.create_draft`, `suppressions.create` and `suppressions.import`.
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
begin
|
|
254
|
+
client.messages.send(to: "+254712345678", text: "hi")
|
|
255
|
+
rescue Opensms::Error => e
|
|
256
|
+
warn "#{e.status} #{e.code}: #{e.detail}"
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Webhooks
|
|
261
|
+
|
|
262
|
+
Every delivery carries `X-OpenSMS-Signature: t=<unix>,v1=<hex>`, an
|
|
263
|
+
HMAC-SHA256 of `"<t>.<raw body>"` keyed with the endpoint's `whsec_...`
|
|
264
|
+
secret used verbatim. Verify the raw body before parsing; no API key is
|
|
265
|
+
needed:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
payload = request.body.read
|
|
269
|
+
header = request.get_header("HTTP_X_OPENSMS_SIGNATURE")
|
|
270
|
+
|
|
271
|
+
begin
|
|
272
|
+
event = Opensms::Webhook.construct_event(payload, header, ENV.fetch("OPENSMS_WEBHOOK_SECRET"))
|
|
273
|
+
case event.type
|
|
274
|
+
when "message.delivered" then mark_delivered(event.data[:id])
|
|
275
|
+
end
|
|
276
|
+
rescue Opensms::Error => e
|
|
277
|
+
# e.code is "invalid_signature" or "expired_signature"
|
|
278
|
+
head :bad_request
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
Opensms::Webhook.verify_signature(payload, header, secret) # => true / false
|
|
282
|
+
Opensms::Webhook.verify_signature(payload, header, secret, tolerance_seconds: 60) # default 300
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The same helpers are available as `client.webhooks.verify_signature` and
|
|
286
|
+
`client.webhooks.construct_event`.
|
|
287
|
+
|
|
288
|
+
## Testing
|
|
289
|
+
|
|
290
|
+
```sh
|
|
291
|
+
rake test # offline unit tests, no network
|
|
292
|
+
rake integration # live scenario against a sandbox; skipped unless OPENSMS_BASE_URL and OPENSMS_API_KEY are set
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
See the monorepo [root README](https://github.com/opensms-io/opensms-sdks) and
|
|
296
|
+
[`../../spec/SURFACE.md`](https://github.com/opensms-io/opensms-sdks/blob/main/spec/SURFACE.md) for the API surface this
|
|
297
|
+
SDK implements.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport"
|
|
4
|
+
require_relative "models"
|
|
5
|
+
require_relative "pagination"
|
|
6
|
+
Dir[File.join(__dir__, "resources", "*.rb")].sort.each { |f| require f }
|
|
7
|
+
|
|
8
|
+
module Opensms
|
|
9
|
+
# OpenSMS API client. Composes the HTTP transport with the resource groups.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# client = Opensms::Client.new(api_key: ENV.fetch("OPENSMS_API_KEY"))
|
|
13
|
+
# msg = client.messages.send(to: "+254700000012", text: "Hello")
|
|
14
|
+
# puts msg[:id]
|
|
15
|
+
class Client
|
|
16
|
+
include Pagination
|
|
17
|
+
|
|
18
|
+
RESOURCES = {
|
|
19
|
+
messages: Resources::Messages,
|
|
20
|
+
batches: Resources::Batches,
|
|
21
|
+
otp: Resources::Otp,
|
|
22
|
+
lookups: Resources::Lookups,
|
|
23
|
+
contacts: Resources::Contacts,
|
|
24
|
+
contact_groups: Resources::ContactGroups,
|
|
25
|
+
templates: Resources::Templates,
|
|
26
|
+
webhooks: Resources::Webhooks,
|
|
27
|
+
inbound: Resources::Inbound,
|
|
28
|
+
numbers: Resources::Numbers,
|
|
29
|
+
sender_ids: Resources::SenderIds,
|
|
30
|
+
suppressions: Resources::Suppressions,
|
|
31
|
+
compliance: Resources::Compliance,
|
|
32
|
+
wallet: Resources::Wallet,
|
|
33
|
+
pricing: Resources::Pricing,
|
|
34
|
+
analytics: Resources::Analytics,
|
|
35
|
+
sandbox: Resources::Sandbox,
|
|
36
|
+
countries: Resources::Countries
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
RESOURCES.each_key { |name| attr_reader name }
|
|
40
|
+
|
|
41
|
+
# @return [String] "sandbox" for sk_test_ keys, "live" for sk_live_ keys
|
|
42
|
+
attr_reader :environment
|
|
43
|
+
# @return [String] the base URL with trailing slashes removed
|
|
44
|
+
attr_reader :base_url
|
|
45
|
+
|
|
46
|
+
# @param api_key [String] required; "sk_test_..." or "sk_live_..."
|
|
47
|
+
# @param base_url [String] default "https://api.opensms.io"
|
|
48
|
+
# @param timeout [Numeric] per-attempt timeout in seconds, default 30
|
|
49
|
+
# @param max_retries [Integer] retries after the first attempt, default 2
|
|
50
|
+
# @param http_client [#call, nil] replaces the Net::HTTP adapter (see {Opensms::NetHttpClient})
|
|
51
|
+
# @param sleeper [#call, nil] called with the retry delay in seconds
|
|
52
|
+
# @raise [ArgumentError] for a malformed key (no network call is made)
|
|
53
|
+
# rubocop:disable Metrics/ParameterLists
|
|
54
|
+
def initialize(api_key:, base_url: Transport::DEFAULT_BASE_URL, timeout: 30, max_retries: 2,
|
|
55
|
+
http_client: nil, sleeper: nil, random: nil)
|
|
56
|
+
transport = Transport.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries,
|
|
57
|
+
http_client: http_client, sleeper: sleeper, random: random)
|
|
58
|
+
@environment = transport.environment
|
|
59
|
+
@base_url = transport.base_url
|
|
60
|
+
RESOURCES.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(transport)) }
|
|
61
|
+
end
|
|
62
|
+
# rubocop:enable Metrics/ParameterLists
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Opensms
|
|
6
|
+
# Raised for every non-2xx API response, for transport failures that survive
|
|
7
|
+
# all retries (status 0), and for webhook signature failures (status 0, code
|
|
8
|
+
# "invalid_signature" or "expired_signature").
|
|
9
|
+
#
|
|
10
|
+
# Fields map the RFC 9457 problem+json body the API returns. Most OpenSMS
|
|
11
|
+
# errors carry no +code+, so branch on {#status} and show {#detail}.
|
|
12
|
+
# Insufficient scope is 401 on messages and otp, but 403 everywhere else.
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
# @return [Integer] HTTP status (0 when there was no response)
|
|
15
|
+
attr_reader :status
|
|
16
|
+
# @return [String, nil] problem +type+ ("about:blank" or a problems URI)
|
|
17
|
+
attr_reader :type
|
|
18
|
+
# @return [String, nil] problem +title+ ("Bad Request", ...)
|
|
19
|
+
attr_reader :title
|
|
20
|
+
# @return [String, nil] problem +detail+, human readable
|
|
21
|
+
attr_reader :detail
|
|
22
|
+
# @return [String, nil] optional machine code ("invalid_message_id", ...)
|
|
23
|
+
attr_reader :code
|
|
24
|
+
# @return [String, nil] problem +trace_id+
|
|
25
|
+
attr_reader :trace_id
|
|
26
|
+
# @return [Hash{Symbol=>Array<String>}, nil] field validation errors
|
|
27
|
+
attr_reader :errors
|
|
28
|
+
# @return [String, nil] the X-Request-ID response header
|
|
29
|
+
attr_reader :request_id
|
|
30
|
+
# @return [Numeric, nil] Retry-After in seconds
|
|
31
|
+
attr_reader :retry_after
|
|
32
|
+
# @return [Object, nil] raw decoded body (symbol keys), or the raw text if not JSON
|
|
33
|
+
attr_reader :body
|
|
34
|
+
|
|
35
|
+
# rubocop:disable Metrics/ParameterLists
|
|
36
|
+
def initialize(status:, message: nil, type: nil, title: nil, detail: nil, code: nil, trace_id: nil,
|
|
37
|
+
errors: nil, request_id: nil, retry_after: nil, body: nil)
|
|
38
|
+
@status = status
|
|
39
|
+
@type = type
|
|
40
|
+
@title = title
|
|
41
|
+
@detail = detail
|
|
42
|
+
@code = code
|
|
43
|
+
@trace_id = trace_id
|
|
44
|
+
@errors = errors
|
|
45
|
+
@request_id = request_id
|
|
46
|
+
@retry_after = retry_after
|
|
47
|
+
@body = body
|
|
48
|
+
super(message || detail || title || "OpenSMS request failed with status #{status}")
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists
|
|
51
|
+
|
|
52
|
+
# Build an error from an HTTP response.
|
|
53
|
+
#
|
|
54
|
+
# @param status [Integer]
|
|
55
|
+
# @param headers [Hash{String=>String}] lower-cased header names
|
|
56
|
+
# @param raw [String, nil] response body text
|
|
57
|
+
# @param retry_after [Numeric, nil]
|
|
58
|
+
# @return [Opensms::Error]
|
|
59
|
+
def self.from_response(status, headers, raw, retry_after: nil)
|
|
60
|
+
parsed = nil
|
|
61
|
+
if raw && !raw.strip.empty?
|
|
62
|
+
begin
|
|
63
|
+
parsed = JSON.parse(raw, symbolize_names: true)
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
parsed = nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
problem = parsed.is_a?(Hash) ? parsed : {}
|
|
69
|
+
str = ->(v) { v.is_a?(String) ? v : nil }
|
|
70
|
+
new(
|
|
71
|
+
status: status,
|
|
72
|
+
type: str.call(problem[:type]),
|
|
73
|
+
title: str.call(problem[:title]),
|
|
74
|
+
detail: str.call(problem[:detail]),
|
|
75
|
+
code: str.call(problem[:code]),
|
|
76
|
+
trace_id: str.call(problem[:trace_id]),
|
|
77
|
+
errors: problem[:errors].is_a?(Hash) ? problem[:errors] : nil,
|
|
78
|
+
request_id: headers["x-request-id"],
|
|
79
|
+
retry_after: retry_after,
|
|
80
|
+
body: parsed.nil? ? raw : parsed
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Opensms
|
|
7
|
+
# One page of a cursor-paginated list: +items+ plus +next_cursor+ (nil on
|
|
8
|
+
# the last page). Items are Hashes with the API's snake_case field names as
|
|
9
|
+
# symbol keys, for example +page.items.first[:traffic_type]+.
|
|
10
|
+
#
|
|
11
|
+
# Page is Enumerable over the items of this page only; use
|
|
12
|
+
# {Opensms::Client#paginate} to walk every page.
|
|
13
|
+
class Page
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
# @return [Array<Hash>]
|
|
17
|
+
attr_reader :items
|
|
18
|
+
# @return [String, nil]
|
|
19
|
+
attr_reader :next_cursor
|
|
20
|
+
|
|
21
|
+
# @param items [Array<Hash>]
|
|
22
|
+
# @param next_cursor [String, nil]
|
|
23
|
+
def initialize(items, next_cursor)
|
|
24
|
+
@items = items || []
|
|
25
|
+
@next_cursor = next_cursor
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Build a Page from a decoded +{items, next_cursor}+ body.
|
|
29
|
+
#
|
|
30
|
+
# @param body [Hash, nil]
|
|
31
|
+
# @return [Opensms::Page]
|
|
32
|
+
def self.from(body)
|
|
33
|
+
body ||= {}
|
|
34
|
+
new(body[:items], body[:next_cursor])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def each(&block)
|
|
38
|
+
@items.each(&block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash] the page as a Hash (+{items:, next_cursor:}+)
|
|
42
|
+
def to_h
|
|
43
|
+
{ items: @items, next_cursor: @next_cursor }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A verified webhook delivery envelope, returned by
|
|
48
|
+
# {Opensms::Webhook.construct_event}. Wire fields: id, type, workspace_id,
|
|
49
|
+
# environment, created_at, data.
|
|
50
|
+
WebhookEvent = Struct.new(:id, :type, :workspace_id, :environment, :created_at, :data, keyword_init: true) do
|
|
51
|
+
# @param hash [Hash] decoded envelope with symbol keys
|
|
52
|
+
# @return [Opensms::WebhookEvent]
|
|
53
|
+
def self.from(hash)
|
|
54
|
+
new(
|
|
55
|
+
id: hash[:id],
|
|
56
|
+
type: hash[:type],
|
|
57
|
+
workspace_id: hash[:workspace_id],
|
|
58
|
+
environment: hash[:environment],
|
|
59
|
+
created_at: hash[:created_at],
|
|
60
|
+
data: hash[:data]
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Request-side model mapping. Response models are plain Hashes (symbol keys,
|
|
66
|
+
# wire names, money kept as decimal strings, timestamps kept as RFC 3339
|
|
67
|
+
# strings, unknown fields preserved). Wire names are already snake_case, so
|
|
68
|
+
# Ruby names are the wire names; this module is the one place that decides
|
|
69
|
+
# which fields a request may carry and how values are serialized.
|
|
70
|
+
module Models
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# Select the allowed keys from +params+ (String or Symbol keys), raise on
|
|
74
|
+
# unknown or missing required keys, drop nils and serialize time values.
|
|
75
|
+
#
|
|
76
|
+
# @param params [Hash]
|
|
77
|
+
# @param allowed [Array<Symbol>]
|
|
78
|
+
# @param required [Array<Symbol>]
|
|
79
|
+
# @param times [Array<Symbol>] keys whose values are serialized as RFC 3339
|
|
80
|
+
# @return [Hash]
|
|
81
|
+
def build(params, allowed, required: [], times: [])
|
|
82
|
+
params = symbolize(params || {})
|
|
83
|
+
unknown = params.keys - allowed
|
|
84
|
+
raise ArgumentError, "Opensms: unknown parameter(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
85
|
+
|
|
86
|
+
missing = required.select { |k| params[k].nil? }
|
|
87
|
+
raise ArgumentError, "Opensms: missing required parameter(s): #{missing.join(', ')}" unless missing.empty?
|
|
88
|
+
|
|
89
|
+
params.each_with_object({}) do |(k, v), out|
|
|
90
|
+
next if v.nil?
|
|
91
|
+
|
|
92
|
+
out[k] = times.include?(k) ? time(v) : v
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Serialize a Time/DateTime as RFC 3339 UTC, a Date as YYYY-MM-DD, and
|
|
97
|
+
# pass strings through.
|
|
98
|
+
#
|
|
99
|
+
# @param value [Time, DateTime, Date, String]
|
|
100
|
+
# @return [String]
|
|
101
|
+
def time(value)
|
|
102
|
+
case value
|
|
103
|
+
when DateTime then value.new_offset(0).iso8601
|
|
104
|
+
when Time then value.getutc.iso8601
|
|
105
|
+
when Date then value.iso8601
|
|
106
|
+
else value.to_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Shallow-symbolize the keys of a Hash.
|
|
111
|
+
def symbolize(hash)
|
|
112
|
+
raise ArgumentError, "Opensms: parameters must be a Hash" unless hash.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opensms
|
|
4
|
+
# Auto-pagination over cursor lists. Mixed into {Opensms::Client}.
|
|
5
|
+
module Pagination
|
|
6
|
+
# Lazily walk every page of a cursor list, feeding +next_cursor+ back as
|
|
7
|
+
# +cursor+ until it is nil.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# client.paginate(:messages, :list, limit: 50).each { |m| puts m[:id] }
|
|
11
|
+
# client.paginate(:batches, :list_items, batch_id, limit: 100).first(10)
|
|
12
|
+
# client.paginate(client.contacts.method(:list), limit: 200).to_a
|
|
13
|
+
#
|
|
14
|
+
# @overload paginate(resource, method, *args, **params)
|
|
15
|
+
# @param resource [Symbol] a client resource name (:messages, ...)
|
|
16
|
+
# @param method [Symbol] a list method returning {Opensms::Page}
|
|
17
|
+
# @overload paginate(callable, *args, **params)
|
|
18
|
+
# @param callable [#call] returns {Opensms::Page}
|
|
19
|
+
# @return [Enumerator<Hash>]
|
|
20
|
+
def paginate(target, *args, **params)
|
|
21
|
+
fn = target.respond_to?(:call) ? target : public_send(target).method(args.shift)
|
|
22
|
+
Enumerator.new do |yielder|
|
|
23
|
+
query = params.dup
|
|
24
|
+
loop do
|
|
25
|
+
page = fn.call(*args, **query)
|
|
26
|
+
page.items.each { |item| yielder << item }
|
|
27
|
+
break if page.next_cursor.nil? || page.next_cursor.to_s.empty?
|
|
28
|
+
|
|
29
|
+
query = query.merge(cursor: page.next_cursor)
|
|
30
|
+
end
|
|
31
|
+
end.lazy
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Opensms
|
|
6
|
+
module Resources
|
|
7
|
+
# The +analytics+ resource: delivery and spend metrics. Every method takes
|
|
8
|
+
# the same optional query: :currency, :range ("30d"), or :from / :to
|
|
9
|
+
# (Time, Date or string; not combined with :range), and :bucket
|
|
10
|
+
# (day|hour). Accessed as +client.analytics+.
|
|
11
|
+
class Analytics < Base
|
|
12
|
+
QUERY = %i[currency range from to bucket].freeze
|
|
13
|
+
|
|
14
|
+
# GET /v1/analytics/overview -> Metrics + { from:, to:, currency:, environment: }.
|
|
15
|
+
def overview(params = nil, **kwargs)
|
|
16
|
+
fetch("overview", params, kwargs)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# GET /v1/analytics/by-country -> Array<Metrics + { key:, name: }>.
|
|
20
|
+
def by_country(params = nil, **kwargs)
|
|
21
|
+
fetch("by-country", params, kwargs)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# GET /v1/analytics/by-carrier -> Array<Metrics + { key:, name: }>.
|
|
25
|
+
def by_carrier(params = nil, **kwargs)
|
|
26
|
+
fetch("by-carrier", params, kwargs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# GET /v1/analytics/by-sender-id -> Array<Metrics + { key:, name: }>.
|
|
30
|
+
def by_sender_id(params = nil, **kwargs)
|
|
31
|
+
fetch("by-sender-id", params, kwargs)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# GET /v1/analytics/timeseries -> Array<Metrics + { bucket: }>.
|
|
35
|
+
def timeseries(params = nil, **kwargs)
|
|
36
|
+
fetch("timeseries", params, kwargs)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def fetch(name, params, kwargs)
|
|
42
|
+
http_get("/v1/analytics/#{name}", Models.build(merge(params, kwargs), QUERY, times: %i[from to]))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|