voicetel 2.2.10
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 +235 -0
- data/lib/voicetel/api_error.rb +59 -0
- data/lib/voicetel/internal/transport.rb +178 -0
- data/lib/voicetel/resources/account.rb +64 -0
- data/lib/voicetel/resources/acl.rb +27 -0
- data/lib/voicetel/resources/authentication.rb +25 -0
- data/lib/voicetel/resources/base.rb +21 -0
- data/lib/voicetel/resources/e911.rb +38 -0
- data/lib/voicetel/resources/gateways.rb +35 -0
- data/lib/voicetel/resources/i_numbering.rb +53 -0
- data/lib/voicetel/resources/lookups.rb +20 -0
- data/lib/voicetel/resources/messaging.rb +54 -0
- data/lib/voicetel/resources/numbers.rb +120 -0
- data/lib/voicetel/resources/support.rb +43 -0
- data/lib/voicetel/version.rb +16 -0
- data/lib/voicetel.rb +122 -0
- data/sig/voicetel.rbs +206 -0
- data/voicetel.gemspec +35 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7c70dc67d349736fd88fd576459960103bcb4afbc7e9bb1512eb80569a7ad020
|
|
4
|
+
data.tar.gz: 6b3cb14e263751cb26e572d73cb511920af75047f84a9f4b2bb6f5bcef72550e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 011706d8c9b695a96e2ad2e8b9594f6916b268565c81ffaf2b598bd5ce3e009c4205594e525bb8fe5501b169dd1679e4934bde97a2d1fb490ec948037c2c5578
|
|
7
|
+
data.tar.gz: a1349e176f37078243b4a295ea9180c031707e529ad8f109eb3a08567526d3d0817fde73efd4fd4da65c6c4629a118b7fa38e1e80c4e9857992767233a666278
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VoiceTel Communications
|
|
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,235 @@
|
|
|
1
|
+
# π VoiceTel Ruby SDK
|
|
2
|
+
|
|
3
|
+
The official Ruby client for the [VoiceTel REST API](https://voicetel.com/docs/api/v2.2/) β provision numbers, place orders, validate e911, send messages, and manage your account, all with idiomatic Ruby, structured errors, and zero codegen footprint.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## π Table of Contents
|
|
12
|
+
|
|
13
|
+
- [Features](#-features)
|
|
14
|
+
- [Installation](#-installation)
|
|
15
|
+
- [Quickstart](#-quickstart)
|
|
16
|
+
- [Authentication](#-authentication)
|
|
17
|
+
- [Resource Reference](#-resource-reference)
|
|
18
|
+
- [Error Handling](#-error-handling)
|
|
19
|
+
- [Rate Limits](#-rate-limits)
|
|
20
|
+
- [Development](#-development)
|
|
21
|
+
- [API Documentation](#-api-documentation)
|
|
22
|
+
- [Contributors](#-contributors)
|
|
23
|
+
- [Sponsors](#-sponsors)
|
|
24
|
+
- [License](#-license)
|
|
25
|
+
|
|
26
|
+
## β¨ Features
|
|
27
|
+
|
|
28
|
+
### π§± Idiomatic Ruby End-to-End
|
|
29
|
+
- **Resource-style API** β `client.numbers.list`, `client.messaging.send_message(...)`, just like the official SDKs in every other language.
|
|
30
|
+
- **snake_case everywhere**, automatically camelCased on the wire so the spec's `fromNumber` / `toNumber` / `messagingBrandId` look like plain Ruby.
|
|
31
|
+
- **RBS signatures** ship in `sig/` β opt into type-checking with Steep or Sorbet, or ignore them and stay dynamic.
|
|
32
|
+
|
|
33
|
+
### π Production-Grade Transport
|
|
34
|
+
- **Faraday 2.x** with connection pooling, persistent HTTP, and pluggable adapters.
|
|
35
|
+
- **Automatic retry** on 429 / 5xx via `faraday-retry`, honoring `Retry-After`.
|
|
36
|
+
- **Configurable timeouts** per client.
|
|
37
|
+
- **Bearer auth** managed for you; passwordβkey exchange handled by `client.login`.
|
|
38
|
+
- **Structured ApiError** β `:rate_limit`, `:not_found`, `:conflict`, ... pattern-match on a Symbol, or call `err.rate_limit?`.
|
|
39
|
+
|
|
40
|
+
### π Complete API Coverage
|
|
41
|
+
- **Numbers** β list, get, add, remove, route, translate, CNAM, LIDB, fax, forward, SMS, messaging campaigns, port-out PIN, account moves.
|
|
42
|
+
- **Account** β profile, sub-accounts, CDRs, credits, payments, MRC, registration, password recovery.
|
|
43
|
+
- **e911** β record provisioning, address validation, lookup, removal.
|
|
44
|
+
- **Gateways** β list, create, update, delete, view bound numbers.
|
|
45
|
+
- **Messaging** β SMS & MMS sending, message history, 10DLC brand and campaign registration, per-number messaging state.
|
|
46
|
+
- **Lookups** β CNAM and LRN dips.
|
|
47
|
+
- **iNumbering** β inventory search, coverage queries, number orders, port-in submissions, port-out availability checks (v2.2.10 LRN + rate-center tier fields).
|
|
48
|
+
- **Support** β ticket create / read / update / delete, threaded messages, replies.
|
|
49
|
+
- **ACL** β IP allowlist management with structured 409 conflict bodies.
|
|
50
|
+
- **Authentication** β switch between Digest, IP-only, or hybrid modes; rotate passwords.
|
|
51
|
+
|
|
52
|
+
### π§ͺ Battle-Tested
|
|
53
|
+
- Unit tests against a mocked HTTP layer (`webmock`), every resource method covered.
|
|
54
|
+
- Read-only integration suite gated by env vars β safe for CI.
|
|
55
|
+
- β₯85% line coverage via `simplecov`.
|
|
56
|
+
|
|
57
|
+
## π Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
gem install voicetel
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or in your `Gemfile`:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
gem "voicetel", "~> 2.2.10"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Requires Ruby 3.1 or later. Tested on 3.1, 3.2, 3.3, and 3.4.
|
|
70
|
+
|
|
71
|
+
## π Quickstart
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
require "voicetel"
|
|
75
|
+
|
|
76
|
+
client = VoiceTel::Client.new
|
|
77
|
+
|
|
78
|
+
# Exchange username + password for an API key (one-time per session)
|
|
79
|
+
client.login(username: 1_000_000_001, password: "hunter2")
|
|
80
|
+
|
|
81
|
+
me = client.account.get
|
|
82
|
+
puts "Balance: $#{me['cash']} | Caller ID: #{me['callerId']}"
|
|
83
|
+
|
|
84
|
+
# List your numbers
|
|
85
|
+
client.numbers.list["numbers"].each do |n|
|
|
86
|
+
puts "#{n['number']} route=#{n['route']} cnam=#{n['cnam']} sms=#{n['smsEnabled']}"
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Or, if you already have an API key:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
client = VoiceTel::Client.new(api_key: ENV.fetch("VOICETEL_API_KEY"))
|
|
94
|
+
|
|
95
|
+
coverage = client.i_numbering.coverage(state: "NJ")
|
|
96
|
+
coverage["coverage"].each do |bucket|
|
|
97
|
+
puts "#{bucket['npa']}-#{bucket['nxx']}: #{bucket['count']} TNs available"
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## π Authentication
|
|
102
|
+
|
|
103
|
+
Every endpoint requires `Authorization: Bearer <apikey>` **except** `POST /v2.2/account/api-key`, which exchanges username + password for a fresh key. `Client#login` handles the exchange and installs the returned key on the transport.
|
|
104
|
+
|
|
105
|
+
Re-fetch the API key after any password change β the old one is invalidated.
|
|
106
|
+
|
|
107
|
+
> Don't have credentials yet? Get them at **[voicetel.com/docs/api/v2.2/credentials](https://voicetel.com/docs/api/v2.2/credentials/)**.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
client = VoiceTel::Client.new
|
|
111
|
+
key = client.login(username: 1_000_000_001, password: "hunter2")
|
|
112
|
+
# `key` is the freshly minted bearer; the client already has it installed.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## πΊοΈ Resource Reference
|
|
116
|
+
|
|
117
|
+
| Resource | Operations | Example |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `client.account` | Profile, CDR, credits, payments, MRC, signup, recovery, sub-accounts | `client.account.cdr(start: t1, end_at: t2)` |
|
|
120
|
+
| `client.acl` | IP allowlist (CIDR entries) | `client.acl.add(acl: [{ cidr: "1.2.3.0/24" }])` |
|
|
121
|
+
| `client.authentication` | SIP/HTTP auth mode + password | `client.authentication.update(auth_type: 1)` |
|
|
122
|
+
| `client.e911` | Records, address validation, provisioning | `client.e911.validate(address1: "...", city: "...", state: "NJ", zip: "07101")` |
|
|
123
|
+
| `client.gateways` | Termination routes | `client.gateways.list` |
|
|
124
|
+
| `client.i_numbering` | Inventory, orders, port-ins | `client.i_numbering.search_inventory(npa: 201)` |
|
|
125
|
+
| `client.lookups` | CNAM & LRN dips | `client.lookups.lrn("2015551234", "2012548000")` |
|
|
126
|
+
| `client.messaging` | SMS/MMS, 10DLC brands & campaigns | `client.messaging.send_message(from_number: "...", to_number: "...", text: "...")` |
|
|
127
|
+
| `client.numbers` | All operations on TNs on the account | `client.numbers.assign_campaign("2015551234", campaign_id: "CABC123")` |
|
|
128
|
+
| `client.support` | Tickets, replies, attachments | `client.support.create(subject: "...", message: "...")` |
|
|
129
|
+
|
|
130
|
+
Every method that takes a body accepts a plain Ruby Hash with `snake_case` keys; the SDK camelCases them when serializing to JSON:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
client.messaging.send_message(
|
|
134
|
+
from_number: "2012548000",
|
|
135
|
+
to_number: "2015551234",
|
|
136
|
+
text: "Your code is 482917"
|
|
137
|
+
)
|
|
138
|
+
# β POST /v2.2/messages { "fromNumber": "2012548000", "toNumber": "2015551234", "text": "Your code is 482917" }
|
|
139
|
+
|
|
140
|
+
client.numbers.assign_campaign("2015551234", campaign_id: "CABC123")
|
|
141
|
+
# β PUT /v2.2/numbers/2015551234/messaging-campaign { "campaignId": "CABC123" }
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Responses come back as Hash/Array structures with the API's native (camelCase) keys β easy to pass straight to `to_json` or pretty-print.
|
|
145
|
+
|
|
146
|
+
## π¨ Error Handling
|
|
147
|
+
|
|
148
|
+
All HTTP errors raise `VoiceTel::ApiError`. The `kind` attribute is a Symbol; convenience predicates are exposed for readability:
|
|
149
|
+
|
|
150
|
+
| Status | `kind` | Predicate |
|
|
151
|
+
|--------|-----------------------|--------------------------|
|
|
152
|
+
| 400 | `:bad_request` | `err.bad_request?` |
|
|
153
|
+
| 401 | `:authentication` | `err.authentication?` |
|
|
154
|
+
| 403 | `:permission_denied` | `err.permission_denied?` |
|
|
155
|
+
| 404 | `:not_found` | `err.not_found?` |
|
|
156
|
+
| 409 | `:conflict` | `err.conflict?` |
|
|
157
|
+
| 429 | `:rate_limit` | `err.rate_limit?` |
|
|
158
|
+
| 5xx | `:server` | `err.server?` |
|
|
159
|
+
| other | `:unknown` | `err.unknown?` |
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
begin
|
|
163
|
+
client.numbers.get("9999999999")
|
|
164
|
+
rescue VoiceTel::ApiError => e
|
|
165
|
+
case e.kind
|
|
166
|
+
when :not_found then puts "Not on your account."
|
|
167
|
+
when :rate_limit then puts "Slow down β retry suggested."
|
|
168
|
+
when :authentication then puts "Re-login: #{e.message}"
|
|
169
|
+
else raise
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`ApiError#body` preserves the parsed response payload β useful on 409 ACL conflicts, where the server returns structured `{added, removed, failed}` detail.
|
|
175
|
+
|
|
176
|
+
## β±οΈ Rate Limits
|
|
177
|
+
|
|
178
|
+
These endpoints are limited to **6 requests per hour per IP**:
|
|
179
|
+
|
|
180
|
+
- `account/info` (`client.account.get`)
|
|
181
|
+
- `account/cdr` (`client.account.cdr`)
|
|
182
|
+
- `account/recurring-charges` (`client.account.recurring_charges`)
|
|
183
|
+
- `account/payments` (`client.account.payments`)
|
|
184
|
+
- `account/registration` (`client.account.registration`)
|
|
185
|
+
- `account/api-key` (`client.login`)
|
|
186
|
+
|
|
187
|
+
The SDK automatically retries 429 responses with `Retry-After` honored, up to `max_retries` (default `2`). To raise it:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
VoiceTel::Client.new(api_key: key, max_retries: 4, timeout: 60)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## π οΈ Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git clone https://github.com/voicetel/ruby-sdk
|
|
197
|
+
cd ruby-sdk
|
|
198
|
+
bundle install
|
|
199
|
+
|
|
200
|
+
# Unit tests (fast, no network)
|
|
201
|
+
bundle exec rspec
|
|
202
|
+
|
|
203
|
+
# Lint
|
|
204
|
+
bundle exec rubocop
|
|
205
|
+
|
|
206
|
+
# Integration tests (live api.voicetel.com, read-only)
|
|
207
|
+
cp .env.example .env # fill in VOICETEL_USERNAME / VOICETEL_PASSWORD
|
|
208
|
+
INTEGRATION=1 bundle exec rspec spec/integration
|
|
209
|
+
|
|
210
|
+
# Build the gem
|
|
211
|
+
gem build voicetel.gemspec
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## π API Documentation
|
|
215
|
+
|
|
216
|
+
- **Reference docs:** [voicetel.com/docs/api/v2.2/](https://voicetel.com/docs/api/v2.2/)
|
|
217
|
+
- **Interactive playground:** [voicetel.com/docs/api/v2.2/playground/](https://voicetel.com/docs/api/v2.2/playground/) β try the API in your browser without writing any code
|
|
218
|
+
- **API credentials:** [voicetel.com/docs/api/v2.2/credentials/](https://voicetel.com/docs/api/v2.2/credentials/)
|
|
219
|
+
- **Type signatures:** see `sig/voicetel.rbs`
|
|
220
|
+
|
|
221
|
+
## π Contributors
|
|
222
|
+
|
|
223
|
+
- [Michael Mavroudis](https://github.com/mavroudis) β Lead Developer
|
|
224
|
+
|
|
225
|
+
Contributions welcome. Open an issue describing the change you want to make, or send a pull request against `main`.
|
|
226
|
+
|
|
227
|
+
## π Sponsors
|
|
228
|
+
|
|
229
|
+
| Sponsor | Contribution |
|
|
230
|
+
|---------|--------------|
|
|
231
|
+
| [VoiceTel Communications](https://voicetel.com) | Primary development and production hosting |
|
|
232
|
+
|
|
233
|
+
## π License
|
|
234
|
+
|
|
235
|
+
This project is licensed under the MIT License β see the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoiceTel
|
|
4
|
+
# ApiError is raised on every non-2xx response (and on transport failures).
|
|
5
|
+
#
|
|
6
|
+
# `kind` is a Symbol classifying the failure so callers can pattern-match
|
|
7
|
+
# without checking HTTP status codes directly. The convenience predicates
|
|
8
|
+
# (`rate_limit?`, `not_found?`, etc.) are exposed for readability.
|
|
9
|
+
class ApiError < StandardError
|
|
10
|
+
KINDS = %i[
|
|
11
|
+
bad_request
|
|
12
|
+
authentication
|
|
13
|
+
permission_denied
|
|
14
|
+
not_found
|
|
15
|
+
conflict
|
|
16
|
+
rate_limit
|
|
17
|
+
server
|
|
18
|
+
unknown
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :kind, :status_code, :code, :body
|
|
22
|
+
|
|
23
|
+
def initialize(message, kind: :unknown, status_code: nil, code: nil, body: nil)
|
|
24
|
+
super(message)
|
|
25
|
+
@kind = kind
|
|
26
|
+
@status_code = status_code
|
|
27
|
+
@code = code
|
|
28
|
+
@body = body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Convenience predicates β one per kind.
|
|
32
|
+
KINDS.each do |k|
|
|
33
|
+
define_method("#{k}?") { @kind == k }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.kind_from_status(status)
|
|
37
|
+
case status
|
|
38
|
+
when 400 then :bad_request
|
|
39
|
+
when 401 then :authentication
|
|
40
|
+
when 403 then :permission_denied
|
|
41
|
+
when 404 then :not_found
|
|
42
|
+
when 409 then :conflict
|
|
43
|
+
when 429 then :rate_limit
|
|
44
|
+
when 500..599 then :server
|
|
45
|
+
else :unknown
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.from_response(status, code, message, body)
|
|
50
|
+
new(
|
|
51
|
+
message,
|
|
52
|
+
kind: kind_from_status(status),
|
|
53
|
+
status_code: status,
|
|
54
|
+
code: code,
|
|
55
|
+
body: body
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "faraday/net_http_persistent"
|
|
6
|
+
require "json"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "zlib"
|
|
10
|
+
|
|
11
|
+
require_relative "../api_error"
|
|
12
|
+
require_relative "../version"
|
|
13
|
+
|
|
14
|
+
module VoiceTel
|
|
15
|
+
module Internal
|
|
16
|
+
# Transport is the low-level Faraday wrapper used by every resource service.
|
|
17
|
+
# It owns the connection, installs the bearer token, retries 429/5xx with
|
|
18
|
+
# Retry-After honored, and strips the `{status, data}` envelope from
|
|
19
|
+
# responses before returning the inner payload.
|
|
20
|
+
class Transport
|
|
21
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :base_url, :user_agent, :timeout, :max_retries
|
|
24
|
+
attr_accessor :api_key
|
|
25
|
+
|
|
26
|
+
def initialize(base_url:, api_key: nil, timeout: 30, max_retries: 2, user_agent: nil, adapter: nil)
|
|
27
|
+
@base_url = (base_url || VoiceTel::DEFAULT_BASE_URL).chomp("/")
|
|
28
|
+
@api_key = api_key
|
|
29
|
+
@timeout = timeout
|
|
30
|
+
@max_retries = max_retries
|
|
31
|
+
@user_agent = user_agent || VoiceTel::USER_AGENT
|
|
32
|
+
@adapter = adapter
|
|
33
|
+
@conn = build_connection
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Perform an HTTP request. Returns the parsed inner data (envelope
|
|
37
|
+
# stripped) on success, or raises ApiError on failure. Returns nil on
|
|
38
|
+
# 204 No Content.
|
|
39
|
+
#
|
|
40
|
+
# @param method [Symbol] one of :get, :post, :put, :patch, :delete
|
|
41
|
+
# @param path [String] absolute path including the /v2.2 prefix
|
|
42
|
+
# @param query [Hash, nil] query string params, snake_case keys
|
|
43
|
+
# @param body [Hash, nil] request body, snake_case keys (translated to camelCase)
|
|
44
|
+
# @param require_auth [Boolean] false skips the bearer header
|
|
45
|
+
def request(method, path, query: nil, body: nil, require_auth: true)
|
|
46
|
+
if require_auth && (@api_key.nil? || @api_key.empty?)
|
|
47
|
+
raise ApiError.new(
|
|
48
|
+
"no api key set; call client.login(...) or pass api_key: to Client.new",
|
|
49
|
+
kind: :authentication
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
headers = build_headers(require_auth)
|
|
54
|
+
headers["Idempotency-Key"] = SecureRandom.uuid if %i[post put patch].include?(method)
|
|
55
|
+
response = @conn.run_request(method, path, body ? JSON.generate(camelize_keys(body)) : nil, headers) do |req|
|
|
56
|
+
req.params.update(camelize_keys(query)) if query && !query.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
handle_response(response)
|
|
60
|
+
rescue Faraday::TimeoutError => e
|
|
61
|
+
raise ApiError.new("voicetel: request timed out: #{e.message}", kind: :unknown)
|
|
62
|
+
rescue Faraday::ConnectionFailed => e
|
|
63
|
+
raise ApiError.new("voicetel: connection failed: #{e.message}", kind: :unknown)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Recursively transform a Ruby snake_case key Hash/Array structure into
|
|
67
|
+
# a Hash with camelCase keys, suitable for JSON serialization. Values
|
|
68
|
+
# that are already strings/numbers/bools/nil/symbols pass through.
|
|
69
|
+
def camelize_keys(obj)
|
|
70
|
+
case obj
|
|
71
|
+
when Hash
|
|
72
|
+
obj.each_with_object({}) do |(k, v), h|
|
|
73
|
+
h[snake_to_camel(k)] = camelize_keys(v)
|
|
74
|
+
end
|
|
75
|
+
when Array
|
|
76
|
+
obj.map { |v| camelize_keys(v) }
|
|
77
|
+
else
|
|
78
|
+
obj
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def snake_to_camel(key)
|
|
85
|
+
s = key.to_s
|
|
86
|
+
return s unless s.include?("_")
|
|
87
|
+
|
|
88
|
+
head, *rest = s.split("_")
|
|
89
|
+
head + rest.map(&:capitalize).join
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_connection
|
|
93
|
+
Faraday.new(url: @base_url) do |f|
|
|
94
|
+
f.request :retry, retry_options
|
|
95
|
+
f.options.timeout = @timeout
|
|
96
|
+
f.options.open_timeout = @timeout
|
|
97
|
+
f.adapter(@adapter || :net_http_persistent)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def retry_options
|
|
102
|
+
{
|
|
103
|
+
max: @max_retries,
|
|
104
|
+
interval: 0.5,
|
|
105
|
+
interval_randomness: 0.0,
|
|
106
|
+
backoff_factor: 2,
|
|
107
|
+
max_interval: 8,
|
|
108
|
+
retry_statuses: RETRYABLE_STATUSES,
|
|
109
|
+
methods: %i[get post put patch delete],
|
|
110
|
+
retry_if: ->(env, _exception) { RETRYABLE_STATUSES.include?(env.status) }
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def decode_body(response)
|
|
115
|
+
raw = response.body.to_s
|
|
116
|
+
encoding = response.headers["content-encoding"] || response.headers["Content-Encoding"]
|
|
117
|
+
return raw unless encoding&.downcase&.include?("gzip")
|
|
118
|
+
|
|
119
|
+
Zlib::GzipReader.new(StringIO.new(raw)).read
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_headers(require_auth)
|
|
123
|
+
h = {
|
|
124
|
+
"User-Agent" => @user_agent,
|
|
125
|
+
"Accept" => "application/json",
|
|
126
|
+
"Accept-Encoding" => "gzip",
|
|
127
|
+
"Content-Type" => "application/json"
|
|
128
|
+
}
|
|
129
|
+
h["Authorization"] = "Bearer #{@api_key}" if require_auth && @api_key && !@api_key.empty?
|
|
130
|
+
h
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_response(response)
|
|
134
|
+
status = response.status
|
|
135
|
+
raw = decode_body(response)
|
|
136
|
+
|
|
137
|
+
if status >= 200 && status < 300
|
|
138
|
+
return nil if raw.empty? || status == 204
|
|
139
|
+
|
|
140
|
+
parsed = parse_json(raw)
|
|
141
|
+
return unwrap(parsed)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
raise build_error(status, raw)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_json(raw)
|
|
148
|
+
return nil if raw.nil? || raw.empty?
|
|
149
|
+
|
|
150
|
+
JSON.parse(raw)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
raw
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unwrap(parsed)
|
|
156
|
+
if parsed.is_a?(Hash) && parsed.key?("status") && parsed.key?("data")
|
|
157
|
+
parsed["data"]
|
|
158
|
+
else
|
|
159
|
+
parsed
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def build_error(status, raw)
|
|
164
|
+
parsed = parse_json(raw)
|
|
165
|
+
code = nil
|
|
166
|
+
message = nil
|
|
167
|
+
|
|
168
|
+
if parsed.is_a?(Hash)
|
|
169
|
+
code = parsed["code"] || parsed["error"]
|
|
170
|
+
message = parsed["message"] || parsed["error"]
|
|
171
|
+
end
|
|
172
|
+
message ||= "HTTP #{status}"
|
|
173
|
+
|
|
174
|
+
ApiError.from_response(status, code, "voicetel: HTTP #{status}: #{message}", parsed || raw)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module VoiceTel
|
|
6
|
+
module Resources
|
|
7
|
+
# AccountService β Account tag in the OpenAPI spec.
|
|
8
|
+
#
|
|
9
|
+
# Rate-limited endpoints (6 req/hour/IP, shared with Client#login):
|
|
10
|
+
# `cdr`, `recurring_charges`, `payments`, `registration`, and `info`.
|
|
11
|
+
class Account < Base
|
|
12
|
+
# GET /v2.2/account β return the authenticated account profile.
|
|
13
|
+
def get
|
|
14
|
+
@transport.request(:get, "/v2.2/account")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# PUT /v2.2/account β partial-update account settings.
|
|
18
|
+
def update(body)
|
|
19
|
+
@transport.request(:put, "/v2.2/account", body: body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# POST /v2.2/account β create a sub-account (admin-only).
|
|
23
|
+
def add(body)
|
|
24
|
+
@transport.request(:post, "/v2.2/account", body: body)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# POST /v2.2/accounts β public sign-up flow.
|
|
28
|
+
def signup(body)
|
|
29
|
+
@transport.request(:post, "/v2.2/accounts", body: body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# GET /v2.2/account/cdr β call detail records. Rate-limited.
|
|
33
|
+
def cdr(start: nil, end_at: nil)
|
|
34
|
+
q = compact_query("start" => start, "end" => end_at)
|
|
35
|
+
@transport.request(:get, "/v2.2/account/cdr", query: q)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# GET /v2.2/account/credits β credit history.
|
|
39
|
+
def credits
|
|
40
|
+
@transport.request(:get, "/v2.2/account/credits")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# GET /v2.2/account/recurring-charges β active monthly recurring charges. Rate-limited.
|
|
44
|
+
def recurring_charges
|
|
45
|
+
@transport.request(:get, "/v2.2/account/recurring-charges")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# GET /v2.2/account/payments β payment history. Rate-limited.
|
|
49
|
+
def payments
|
|
50
|
+
@transport.request(:get, "/v2.2/account/payments")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# GET /v2.2/account/registration β current SIP registration. Rate-limited.
|
|
54
|
+
def registration
|
|
55
|
+
@transport.request(:get, "/v2.2/account/registration")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /v2.2/account/recovery β start password recovery. No auth required.
|
|
59
|
+
def recover(body)
|
|
60
|
+
@transport.request(:post, "/v2.2/account/recovery", body: body, require_auth: false)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module VoiceTel
|
|
6
|
+
module Resources
|
|
7
|
+
# AclService β manages the IP allowlist (CIDR entries).
|
|
8
|
+
#
|
|
9
|
+
# The DELETE /v2.2/acl endpoint is unusual: it returns 200 with a body
|
|
10
|
+
# (not 204). 409 conflicts include partial success/failure detail in the
|
|
11
|
+
# body β surfaced through ApiError#body so callers can inspect it.
|
|
12
|
+
class Acl < Base
|
|
13
|
+
def list
|
|
14
|
+
@transport.request(:get, "/v2.2/acl")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# body example: { acl: [{ cidr: "1.2.3.0/24" }] }
|
|
18
|
+
def add(body)
|
|
19
|
+
@transport.request(:post, "/v2.2/acl", body: body)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remove(body)
|
|
23
|
+
@transport.request(:delete, "/v2.2/acl", body: body)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module VoiceTel
|
|
6
|
+
module Resources
|
|
7
|
+
# AuthenticationService β SIP/HTTP authentication settings (mode + password).
|
|
8
|
+
#
|
|
9
|
+
# auth_type values: 0 = Digest, 1 = IP Auth, 2 = Digest OR IP, 3 = Digest AND IP.
|
|
10
|
+
class Authentication < Base
|
|
11
|
+
AUTH_TYPE_DIGEST = 0
|
|
12
|
+
AUTH_TYPE_IP = 1
|
|
13
|
+
AUTH_TYPE_DIGEST_OR_IP = 2
|
|
14
|
+
AUTH_TYPE_DIGEST_AND_IP = 3
|
|
15
|
+
|
|
16
|
+
def get
|
|
17
|
+
@transport.request(:get, "/v2.2/auth")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update(body)
|
|
21
|
+
@transport.request(:put, "/v2.2/auth", body: body)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module VoiceTel
|
|
4
|
+
module Resources
|
|
5
|
+
# Shared base for every resource service. Each service receives the
|
|
6
|
+
# client's transport on construction and uses it for HTTP calls. Keeping
|
|
7
|
+
# this thin lets us keep resource files focused on their endpoints.
|
|
8
|
+
class Base
|
|
9
|
+
def initialize(transport)
|
|
10
|
+
@transport = transport
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Compact a query hash, dropping nil and empty-string values, before
|
|
14
|
+
# handing it to the transport. Some endpoints take a lot of optional
|
|
15
|
+
# filters and writing `nil` everywhere at call sites would be noisy.
|
|
16
|
+
def compact_query(hash)
|
|
17
|
+
hash.reject { |_, v| v.nil? || v == "" }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module VoiceTel
|
|
6
|
+
module Resources
|
|
7
|
+
# E911Service β provisioning, validation, lookup, removal.
|
|
8
|
+
#
|
|
9
|
+
# Note: request bodies take a 10-digit TN in `dn`; responses return the
|
|
10
|
+
# 11-digit E.164 US form (leading 1).
|
|
11
|
+
class E911 < Base
|
|
12
|
+
def list
|
|
13
|
+
@transport.request(:get, "/v2.2/e911")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(body)
|
|
17
|
+
@transport.request(:post, "/v2.2/e911", body: body)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate(body)
|
|
21
|
+
@transport.request(:post, "/v2.2/e911/validations", body: body)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(dn)
|
|
25
|
+
@transport.request(:get, "/v2.2/e911/#{dn}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def provision(dn, body)
|
|
29
|
+
@transport.request(:put, "/v2.2/e911/#{dn}", body: body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns nil on 204 No Content.
|
|
33
|
+
def remove(dn)
|
|
34
|
+
@transport.request(:delete, "/v2.2/e911/#{dn}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|