sendly 1.0.5
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/.ruby-version +1 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +95 -0
- data/README.md +227 -0
- data/examples/list_messages.rb +33 -0
- data/examples/send_sms.rb +31 -0
- data/lib/sendly/client.rb +173 -0
- data/lib/sendly/errors.rb +121 -0
- data/lib/sendly/messages.rb +283 -0
- data/lib/sendly/types.rb +162 -0
- data/lib/sendly/version.rb +5 -0
- data/lib/sendly/webhooks.rb +159 -0
- data/lib/sendly.rb +60 -0
- metadata +159 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8ede8c7152df0e2261bf901af9012076aef1e8606d4c4ceb096620c6ade299aa
|
|
4
|
+
data.tar.gz: b898bece3e69f9f2181e33d0e5d9ba487430c0577a422147621a0be2658ea086
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 053a7b98c18a0f0ba8422f6e0282ef31b6f456927501f9047917b597006a92dc5b0573592d4f56935f12c402ed5c64aed7d1b3e58787e2a805d804e32b9d130f
|
|
7
|
+
data.tar.gz: 14cf12613830faacffb654404318e5f61c67767f719cdcc2421a6742c0e30bbdd49c53c7e0fd1773ab7dc335d112617a8d801dc665676bdabb72dbee7c69abeb
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.4.5
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
sendly (1.0.5)
|
|
5
|
+
faraday (~> 2.0)
|
|
6
|
+
faraday-retry (~> 2.0)
|
|
7
|
+
|
|
8
|
+
GEM
|
|
9
|
+
remote: https://rubygems.org/
|
|
10
|
+
specs:
|
|
11
|
+
addressable (2.8.8)
|
|
12
|
+
public_suffix (>= 2.0.2, < 8.0)
|
|
13
|
+
ast (2.4.3)
|
|
14
|
+
bigdecimal (3.3.1)
|
|
15
|
+
crack (1.0.1)
|
|
16
|
+
bigdecimal
|
|
17
|
+
rexml
|
|
18
|
+
diff-lcs (1.6.2)
|
|
19
|
+
faraday (2.14.0)
|
|
20
|
+
faraday-net_http (>= 2.0, < 3.5)
|
|
21
|
+
json
|
|
22
|
+
logger
|
|
23
|
+
faraday-net_http (3.4.2)
|
|
24
|
+
net-http (~> 0.5)
|
|
25
|
+
faraday-retry (2.3.2)
|
|
26
|
+
faraday (~> 2.0)
|
|
27
|
+
hashdiff (1.2.1)
|
|
28
|
+
json (2.18.0)
|
|
29
|
+
language_server-protocol (3.17.0.5)
|
|
30
|
+
lint_roller (1.1.0)
|
|
31
|
+
logger (1.7.0)
|
|
32
|
+
net-http (0.8.0)
|
|
33
|
+
uri (>= 0.11.1)
|
|
34
|
+
parallel (1.27.0)
|
|
35
|
+
parser (3.3.10.0)
|
|
36
|
+
ast (~> 2.4.1)
|
|
37
|
+
racc
|
|
38
|
+
prism (1.6.0)
|
|
39
|
+
public_suffix (7.0.0)
|
|
40
|
+
racc (1.8.1)
|
|
41
|
+
rainbow (3.1.1)
|
|
42
|
+
rake (13.3.1)
|
|
43
|
+
regexp_parser (2.11.3)
|
|
44
|
+
rexml (3.4.4)
|
|
45
|
+
rspec (3.13.2)
|
|
46
|
+
rspec-core (~> 3.13.0)
|
|
47
|
+
rspec-expectations (~> 3.13.0)
|
|
48
|
+
rspec-mocks (~> 3.13.0)
|
|
49
|
+
rspec-core (3.13.6)
|
|
50
|
+
rspec-support (~> 3.13.0)
|
|
51
|
+
rspec-expectations (3.13.5)
|
|
52
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
53
|
+
rspec-support (~> 3.13.0)
|
|
54
|
+
rspec-mocks (3.13.7)
|
|
55
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
56
|
+
rspec-support (~> 3.13.0)
|
|
57
|
+
rspec-support (3.13.6)
|
|
58
|
+
rubocop (1.81.7)
|
|
59
|
+
json (~> 2.3)
|
|
60
|
+
language_server-protocol (~> 3.17.0.2)
|
|
61
|
+
lint_roller (~> 1.1.0)
|
|
62
|
+
parallel (~> 1.10)
|
|
63
|
+
parser (>= 3.3.0.2)
|
|
64
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
65
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
66
|
+
rubocop-ast (>= 1.47.1, < 2.0)
|
|
67
|
+
ruby-progressbar (~> 1.7)
|
|
68
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
69
|
+
rubocop-ast (1.48.0)
|
|
70
|
+
parser (>= 3.3.7.2)
|
|
71
|
+
prism (~> 1.4)
|
|
72
|
+
ruby-progressbar (1.13.0)
|
|
73
|
+
unicode-display_width (3.2.0)
|
|
74
|
+
unicode-emoji (~> 4.1)
|
|
75
|
+
unicode-emoji (4.1.0)
|
|
76
|
+
uri (1.1.1)
|
|
77
|
+
webmock (3.26.1)
|
|
78
|
+
addressable (>= 2.8.0)
|
|
79
|
+
crack (>= 0.3.2)
|
|
80
|
+
hashdiff (>= 0.4.0, < 2.0.0)
|
|
81
|
+
|
|
82
|
+
PLATFORMS
|
|
83
|
+
arm64-darwin-24
|
|
84
|
+
ruby
|
|
85
|
+
|
|
86
|
+
DEPENDENCIES
|
|
87
|
+
bundler (~> 2.0)
|
|
88
|
+
rake (~> 13.0)
|
|
89
|
+
rspec (~> 3.0)
|
|
90
|
+
rubocop (~> 1.0)
|
|
91
|
+
sendly!
|
|
92
|
+
webmock (~> 3.0)
|
|
93
|
+
|
|
94
|
+
BUNDLED WITH
|
|
95
|
+
2.6.9
|
data/README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Sendly Ruby SDK
|
|
2
|
+
|
|
3
|
+
Official Ruby SDK for the Sendly SMS API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# gem
|
|
9
|
+
gem install sendly
|
|
10
|
+
|
|
11
|
+
# Bundler (add to Gemfile)
|
|
12
|
+
gem 'sendly'
|
|
13
|
+
|
|
14
|
+
# then run
|
|
15
|
+
bundle install
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require 'sendly'
|
|
22
|
+
|
|
23
|
+
# Create a client
|
|
24
|
+
client = Sendly::Client.new("sk_live_v1_your_api_key")
|
|
25
|
+
|
|
26
|
+
# Send an SMS
|
|
27
|
+
message = client.messages.send(
|
|
28
|
+
to: "+15551234567",
|
|
29
|
+
text: "Hello from Sendly!"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
puts message.id # => "msg_abc123"
|
|
33
|
+
puts message.status # => "queued"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Prerequisites for Live Messaging
|
|
37
|
+
|
|
38
|
+
Before sending live SMS messages, you need:
|
|
39
|
+
|
|
40
|
+
1. **Business Verification** - Complete verification in the [Sendly dashboard](https://sendly.live/dashboard)
|
|
41
|
+
- **International**: Instant approval (just provide Sender ID)
|
|
42
|
+
- **US/Canada**: Requires carrier approval (3-7 business days)
|
|
43
|
+
|
|
44
|
+
2. **Credits** - Add credits to your account
|
|
45
|
+
- Test keys (`sk_test_*`) work without credits (sandbox mode)
|
|
46
|
+
- Live keys (`sk_live_*`) require credits for each message
|
|
47
|
+
|
|
48
|
+
3. **Live API Key** - Generate after verification + credits
|
|
49
|
+
- Dashboard → API Keys → Create Live Key
|
|
50
|
+
|
|
51
|
+
### Test vs Live Keys
|
|
52
|
+
|
|
53
|
+
| Key Type | Prefix | Credits Required | Verification Required | Use Case |
|
|
54
|
+
|----------|--------|------------------|----------------------|----------|
|
|
55
|
+
| Test | `sk_test_v1_*` | No | No | Development, testing |
|
|
56
|
+
| Live | `sk_live_v1_*` | Yes | Yes | Production messaging |
|
|
57
|
+
|
|
58
|
+
> **Note**: You can start development immediately with a test key. Messages to sandbox test numbers are free and don't require verification.
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
### Global Configuration
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
Sendly.configure do |config|
|
|
66
|
+
config.api_key = "sk_live_v1_xxx"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Use the default client
|
|
70
|
+
Sendly.send_message(to: "+15551234567", text: "Hello!")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Client Options
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
client = Sendly::Client.new(
|
|
77
|
+
"sk_live_v1_xxx",
|
|
78
|
+
base_url: "https://api.sendly.live/v1",
|
|
79
|
+
timeout: 60,
|
|
80
|
+
max_retries: 5
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Messages
|
|
85
|
+
|
|
86
|
+
### Send an SMS
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
message = client.messages.send(
|
|
90
|
+
to: "+15551234567",
|
|
91
|
+
text: "Hello from Sendly!"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
puts message.id
|
|
95
|
+
puts message.status
|
|
96
|
+
puts message.credits_used
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### List Messages
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Basic listing
|
|
103
|
+
messages = client.messages.list(limit: 50)
|
|
104
|
+
messages.each { |m| puts m.to }
|
|
105
|
+
|
|
106
|
+
# With filters
|
|
107
|
+
messages = client.messages.list(
|
|
108
|
+
status: "delivered",
|
|
109
|
+
to: "+15551234567",
|
|
110
|
+
limit: 20,
|
|
111
|
+
offset: 0
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Pagination info
|
|
115
|
+
puts messages.total
|
|
116
|
+
puts messages.has_more
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Get a Message
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
message = client.messages.get("msg_abc123")
|
|
123
|
+
|
|
124
|
+
puts message.to
|
|
125
|
+
puts message.text
|
|
126
|
+
puts message.status
|
|
127
|
+
puts message.delivered_at
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Iterate All Messages
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
# Auto-pagination
|
|
134
|
+
client.messages.each do |message|
|
|
135
|
+
puts "#{message.id}: #{message.to}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# With filters
|
|
139
|
+
client.messages.each(status: "delivered") do |message|
|
|
140
|
+
puts "Delivered: #{message.id}"
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Error Handling
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
begin
|
|
148
|
+
message = client.messages.send(
|
|
149
|
+
to: "+15551234567",
|
|
150
|
+
text: "Hello!"
|
|
151
|
+
)
|
|
152
|
+
rescue Sendly::AuthenticationError => e
|
|
153
|
+
puts "Invalid API key"
|
|
154
|
+
rescue Sendly::RateLimitError => e
|
|
155
|
+
puts "Rate limited, retry after #{e.retry_after} seconds"
|
|
156
|
+
rescue Sendly::InsufficientCreditsError => e
|
|
157
|
+
puts "Add more credits to your account"
|
|
158
|
+
rescue Sendly::ValidationError => e
|
|
159
|
+
puts "Invalid request: #{e.message}"
|
|
160
|
+
rescue Sendly::NotFoundError => e
|
|
161
|
+
puts "Resource not found"
|
|
162
|
+
rescue Sendly::NetworkError => e
|
|
163
|
+
puts "Network error: #{e.message}"
|
|
164
|
+
rescue Sendly::Error => e
|
|
165
|
+
puts "Error: #{e.message} (#{e.code})"
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Message Object
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
message.id # Unique identifier
|
|
173
|
+
message.to # Recipient phone number
|
|
174
|
+
message.text # Message content
|
|
175
|
+
message.status # queued, sending, sent, delivered, failed
|
|
176
|
+
message.credits_used # Credits consumed
|
|
177
|
+
message.created_at # Creation time
|
|
178
|
+
message.updated_at # Last update time
|
|
179
|
+
message.delivered_at # Delivery time (if delivered)
|
|
180
|
+
message.error_code # Error code (if failed)
|
|
181
|
+
message.error_message # Error message (if failed)
|
|
182
|
+
|
|
183
|
+
# Helper methods
|
|
184
|
+
message.delivered? # => true/false
|
|
185
|
+
message.failed? # => true/false
|
|
186
|
+
message.pending? # => true/false
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Message Status
|
|
190
|
+
|
|
191
|
+
| Status | Description |
|
|
192
|
+
|--------|-------------|
|
|
193
|
+
| `queued` | Message is queued for delivery |
|
|
194
|
+
| `sending` | Message is being sent |
|
|
195
|
+
| `sent` | Message was sent to carrier |
|
|
196
|
+
| `delivered` | Message was delivered |
|
|
197
|
+
| `failed` | Message delivery failed |
|
|
198
|
+
|
|
199
|
+
## Pricing Tiers
|
|
200
|
+
|
|
201
|
+
| Tier | Countries | Credits per SMS |
|
|
202
|
+
|------|-----------|-----------------|
|
|
203
|
+
| Domestic | US, CA | 1 |
|
|
204
|
+
| Tier 1 | GB, PL, IN, etc. | 8 |
|
|
205
|
+
| Tier 2 | FR, JP, AU, etc. | 12 |
|
|
206
|
+
| Tier 3 | DE, IT, MX, etc. | 16 |
|
|
207
|
+
|
|
208
|
+
## Sandbox Testing
|
|
209
|
+
|
|
210
|
+
Use test API keys (`sk_test_v1_xxx`) with these test numbers:
|
|
211
|
+
|
|
212
|
+
| Number | Behavior |
|
|
213
|
+
|--------|----------|
|
|
214
|
+
| +15550001234 | Success |
|
|
215
|
+
| +15550001001 | Invalid number |
|
|
216
|
+
| +15550001002 | Carrier rejected |
|
|
217
|
+
| +15550001003 | No credits |
|
|
218
|
+
| +15550001004 | Rate limited |
|
|
219
|
+
|
|
220
|
+
## Requirements
|
|
221
|
+
|
|
222
|
+
- Ruby 3.0+
|
|
223
|
+
- Faraday 2.0+
|
|
224
|
+
|
|
225
|
+
## License
|
|
226
|
+
|
|
227
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sendly"
|
|
5
|
+
|
|
6
|
+
client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
|
|
7
|
+
|
|
8
|
+
# List recent messages
|
|
9
|
+
puts "=== Recent Messages ==="
|
|
10
|
+
messages = client.messages.list(limit: 10)
|
|
11
|
+
puts "Total: #{messages.total}"
|
|
12
|
+
puts "Has more: #{messages.has_more}"
|
|
13
|
+
puts
|
|
14
|
+
|
|
15
|
+
messages.each do |msg|
|
|
16
|
+
puts "#{msg.id}: #{msg.to} - #{msg.status}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# List with filters
|
|
20
|
+
puts "\n=== Delivered Messages ==="
|
|
21
|
+
delivered = client.messages.list(status: "delivered", limit: 5)
|
|
22
|
+
delivered.each do |msg|
|
|
23
|
+
puts "#{msg.id}: Delivered at #{msg.delivered_at}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Iterate all with auto-pagination
|
|
27
|
+
puts "\n=== All Messages (paginated) ==="
|
|
28
|
+
count = 0
|
|
29
|
+
client.messages.each(batch_size: 50) do |msg|
|
|
30
|
+
puts "#{msg.id}: #{msg.to}"
|
|
31
|
+
count += 1
|
|
32
|
+
break if count >= 20 # Limit for demo
|
|
33
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sendly"
|
|
5
|
+
|
|
6
|
+
# Configure with your API key
|
|
7
|
+
client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
|
|
8
|
+
|
|
9
|
+
# Send an SMS
|
|
10
|
+
begin
|
|
11
|
+
message = client.messages.send(
|
|
12
|
+
to: "+15551234567",
|
|
13
|
+
text: "Hello from Sendly Ruby SDK!"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
puts "Message sent successfully!"
|
|
17
|
+
puts " ID: #{message.id}"
|
|
18
|
+
puts " To: #{message.to}"
|
|
19
|
+
puts " Status: #{message.status}"
|
|
20
|
+
puts " Credits used: #{message.credits_used}"
|
|
21
|
+
rescue Sendly::AuthenticationError => e
|
|
22
|
+
puts "Authentication failed: #{e.message}"
|
|
23
|
+
rescue Sendly::InsufficientCreditsError => e
|
|
24
|
+
puts "Insufficient credits: #{e.message}"
|
|
25
|
+
rescue Sendly::ValidationError => e
|
|
26
|
+
puts "Validation error: #{e.message}"
|
|
27
|
+
rescue Sendly::RateLimitError => e
|
|
28
|
+
puts "Rate limited. Retry after: #{e.retry_after} seconds"
|
|
29
|
+
rescue Sendly::Error => e
|
|
30
|
+
puts "Error: #{e.message}"
|
|
31
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Sendly
|
|
8
|
+
# Main Sendly API client
|
|
9
|
+
class Client
|
|
10
|
+
# @return [String] API key
|
|
11
|
+
attr_reader :api_key
|
|
12
|
+
|
|
13
|
+
# @return [String] Base URL
|
|
14
|
+
attr_reader :base_url
|
|
15
|
+
|
|
16
|
+
# @return [Integer] Request timeout in seconds
|
|
17
|
+
attr_reader :timeout
|
|
18
|
+
|
|
19
|
+
# @return [Integer] Maximum retry attempts
|
|
20
|
+
attr_reader :max_retries
|
|
21
|
+
|
|
22
|
+
# Create a new Sendly client
|
|
23
|
+
#
|
|
24
|
+
# @param api_key [String] Your Sendly API key
|
|
25
|
+
# @param base_url [String] API base URL (optional)
|
|
26
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
27
|
+
# @param max_retries [Integer] Maximum retry attempts (default: 3)
|
|
28
|
+
#
|
|
29
|
+
# @example
|
|
30
|
+
# client = Sendly::Client.new("sk_live_v1_xxx")
|
|
31
|
+
# client = Sendly::Client.new("sk_live_v1_xxx", timeout: 60, max_retries: 5)
|
|
32
|
+
def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 3)
|
|
33
|
+
@api_key = api_key
|
|
34
|
+
@base_url = (base_url || Sendly.base_url).chomp("/")
|
|
35
|
+
@timeout = timeout
|
|
36
|
+
@max_retries = max_retries
|
|
37
|
+
|
|
38
|
+
validate_api_key!
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Access the Messages resource
|
|
42
|
+
#
|
|
43
|
+
# @return [Sendly::Messages]
|
|
44
|
+
def messages
|
|
45
|
+
@messages ||= Messages.new(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Make a GET request
|
|
49
|
+
#
|
|
50
|
+
# @param path [String] API path
|
|
51
|
+
# @param params [Hash] Query parameters
|
|
52
|
+
# @return [Hash] Response body
|
|
53
|
+
def get(path, params = {})
|
|
54
|
+
request(:get, path, params: params)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Make a POST request
|
|
58
|
+
#
|
|
59
|
+
# @param path [String] API path
|
|
60
|
+
# @param body [Hash] Request body
|
|
61
|
+
# @return [Hash] Response body
|
|
62
|
+
def post(path, body = {})
|
|
63
|
+
request(:post, path, body: body)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Make a DELETE request
|
|
67
|
+
#
|
|
68
|
+
# @param path [String] API path
|
|
69
|
+
# @return [Hash] Response body
|
|
70
|
+
def delete(path)
|
|
71
|
+
request(:delete, path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def validate_api_key!
|
|
77
|
+
raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
|
|
78
|
+
|
|
79
|
+
unless api_key.match?(/^sk_(test|live)_v1_[a-zA-Z0-9_-]+$/)
|
|
80
|
+
raise AuthenticationError, "Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def request(method, path, params: {}, body: nil)
|
|
85
|
+
uri = build_uri(path, params)
|
|
86
|
+
http = build_http(uri)
|
|
87
|
+
req = build_request(method, uri, body)
|
|
88
|
+
|
|
89
|
+
attempt = 0
|
|
90
|
+
begin
|
|
91
|
+
response = http.request(req)
|
|
92
|
+
handle_response(response)
|
|
93
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
94
|
+
raise TimeoutError, "Request timed out after #{timeout} seconds"
|
|
95
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
|
|
96
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
97
|
+
rescue RateLimitError => e
|
|
98
|
+
attempt += 1
|
|
99
|
+
if attempt <= max_retries && e.retry_after
|
|
100
|
+
sleep(e.retry_after)
|
|
101
|
+
retry
|
|
102
|
+
end
|
|
103
|
+
raise
|
|
104
|
+
rescue ServerError => e
|
|
105
|
+
attempt += 1
|
|
106
|
+
if attempt <= max_retries
|
|
107
|
+
sleep(2 ** attempt) # Exponential backoff
|
|
108
|
+
retry
|
|
109
|
+
end
|
|
110
|
+
raise
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_uri(path, params)
|
|
115
|
+
url = "#{base_url}#{path}"
|
|
116
|
+
uri = URI.parse(url)
|
|
117
|
+
|
|
118
|
+
if params.any?
|
|
119
|
+
query = params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join("&")
|
|
120
|
+
uri.query = query
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
uri
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_http(uri)
|
|
127
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
128
|
+
http.use_ssl = uri.scheme == "https"
|
|
129
|
+
http.open_timeout = 10
|
|
130
|
+
http.read_timeout = timeout
|
|
131
|
+
http
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_request(method, uri, body)
|
|
135
|
+
req = case method
|
|
136
|
+
when :get
|
|
137
|
+
Net::HTTP::Get.new(uri)
|
|
138
|
+
when :post
|
|
139
|
+
Net::HTTP::Post.new(uri)
|
|
140
|
+
when :delete
|
|
141
|
+
Net::HTTP::Delete.new(uri)
|
|
142
|
+
else
|
|
143
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
req["Authorization"] = "Bearer #{api_key}"
|
|
147
|
+
req["Content-Type"] = "application/json"
|
|
148
|
+
req["Accept"] = "application/json"
|
|
149
|
+
req["User-Agent"] = "sendly-ruby/#{VERSION}"
|
|
150
|
+
|
|
151
|
+
req.body = body.to_json if body
|
|
152
|
+
|
|
153
|
+
req
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_response(response)
|
|
157
|
+
body = parse_body(response.body)
|
|
158
|
+
status = response.code.to_i
|
|
159
|
+
|
|
160
|
+
return body if status >= 200 && status < 300
|
|
161
|
+
|
|
162
|
+
raise ErrorFactory.from_response(status, body)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def parse_body(body)
|
|
166
|
+
return {} if body.nil? || body.empty?
|
|
167
|
+
|
|
168
|
+
JSON.parse(body)
|
|
169
|
+
rescue JSON::ParserError
|
|
170
|
+
{ "message" => body }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendly
|
|
4
|
+
# Base error class for all Sendly errors
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# @return [String, nil] Error code from the API
|
|
7
|
+
attr_reader :code
|
|
8
|
+
|
|
9
|
+
# @return [Hash, nil] Additional error details
|
|
10
|
+
attr_reader :details
|
|
11
|
+
|
|
12
|
+
# @return [Integer, nil] HTTP status code
|
|
13
|
+
attr_reader :status_code
|
|
14
|
+
|
|
15
|
+
def initialize(message = nil, code: nil, details: nil, status_code: nil)
|
|
16
|
+
@code = code
|
|
17
|
+
@details = details
|
|
18
|
+
@status_code = status_code
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when the API key is invalid or missing
|
|
24
|
+
class AuthenticationError < Error
|
|
25
|
+
def initialize(message = "Invalid or missing API key")
|
|
26
|
+
super(message, code: "AUTHENTICATION_ERROR", status_code: 401)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raised when the rate limit is exceeded
|
|
31
|
+
class RateLimitError < Error
|
|
32
|
+
# @return [Integer, nil] Seconds to wait before retrying
|
|
33
|
+
attr_reader :retry_after
|
|
34
|
+
|
|
35
|
+
def initialize(message = "Rate limit exceeded", retry_after: nil)
|
|
36
|
+
@retry_after = retry_after
|
|
37
|
+
super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Raised when the account has insufficient credits
|
|
42
|
+
class InsufficientCreditsError < Error
|
|
43
|
+
def initialize(message = "Insufficient credits")
|
|
44
|
+
super(message, code: "INSUFFICIENT_CREDITS", status_code: 402)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Raised when the request contains invalid parameters
|
|
49
|
+
class ValidationError < Error
|
|
50
|
+
# @return [Hash, nil] Field-specific validation errors
|
|
51
|
+
attr_reader :field_errors
|
|
52
|
+
|
|
53
|
+
def initialize(message = "Validation failed", field_errors: nil, details: nil)
|
|
54
|
+
@field_errors = field_errors
|
|
55
|
+
super(message, code: "VALIDATION_ERROR", details: details, status_code: 400)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Raised when the requested resource is not found
|
|
60
|
+
class NotFoundError < Error
|
|
61
|
+
def initialize(message = "Resource not found")
|
|
62
|
+
super(message, code: "NOT_FOUND", status_code: 404)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Raised when a network error occurs
|
|
67
|
+
class NetworkError < Error
|
|
68
|
+
def initialize(message = "Network error occurred")
|
|
69
|
+
super(message, code: "NETWORK_ERROR")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Raised when a timeout occurs
|
|
74
|
+
class TimeoutError < NetworkError
|
|
75
|
+
def initialize(message = "Request timed out")
|
|
76
|
+
super(message)
|
|
77
|
+
@code = "TIMEOUT_ERROR"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Raised for unexpected API errors
|
|
82
|
+
class APIError < Error
|
|
83
|
+
def initialize(message = "An unexpected error occurred", status_code: nil, code: nil, details: nil)
|
|
84
|
+
super(message, code: code || "API_ERROR", status_code: status_code, details: details)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Raised for server errors (5xx)
|
|
89
|
+
class ServerError < Error
|
|
90
|
+
def initialize(message = "Server error occurred", status_code: 500)
|
|
91
|
+
super(message, code: "SERVER_ERROR", status_code: status_code)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Convert API response to appropriate error
|
|
96
|
+
class ErrorFactory
|
|
97
|
+
def self.from_response(status, body)
|
|
98
|
+
message = body["message"] || body["error"] || "Unknown error"
|
|
99
|
+
code = body["code"]
|
|
100
|
+
details = body["details"]
|
|
101
|
+
|
|
102
|
+
case status
|
|
103
|
+
when 400, 422
|
|
104
|
+
ValidationError.new(message, details: details)
|
|
105
|
+
when 401
|
|
106
|
+
AuthenticationError.new(message)
|
|
107
|
+
when 402
|
|
108
|
+
InsufficientCreditsError.new(message)
|
|
109
|
+
when 404
|
|
110
|
+
NotFoundError.new(message)
|
|
111
|
+
when 429
|
|
112
|
+
retry_after = body["retry_after"] || body["retryAfter"]
|
|
113
|
+
RateLimitError.new(message, retry_after: retry_after)
|
|
114
|
+
when 500..599
|
|
115
|
+
ServerError.new(message, status_code: status)
|
|
116
|
+
else
|
|
117
|
+
APIError.new(message, status_code: status, code: code, details: details)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|