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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opensms
4
+ # The gem version. Bumped on release; tags use the `ruby-v*` prefix.
5
+ VERSION = "0.1.0"
6
+ 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: []