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 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