sendara 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +313 -0
- data/lib/sendara/client.rb +240 -0
- data/lib/sendara/errors.rb +23 -0
- data/lib/sendara/generators/install_generator.rb +17 -0
- data/lib/sendara/generators/templates/sendara.rb +8 -0
- data/lib/sendara/message_page.rb +34 -0
- data/lib/sendara/railtie.rb +72 -0
- data/lib/sendara/resource.rb +26 -0
- data/lib/sendara/resources/api_keys.rb +28 -0
- data/lib/sendara/resources/billing.rb +22 -0
- data/lib/sendara/resources/broadcasts.rb +72 -0
- data/lib/sendara/resources/contacts.rb +121 -0
- data/lib/sendara/resources/domains.rb +44 -0
- data/lib/sendara/resources/emails.rb +49 -0
- data/lib/sendara/resources/lists.rb +56 -0
- data/lib/sendara/resources/messages.rb +42 -0
- data/lib/sendara/resources/suppressions.rb +29 -0
- data/lib/sendara/resources/templates.rb +54 -0
- data/lib/sendara/resources/usage.rb +12 -0
- data/lib/sendara/version.rb +5 -0
- data/lib/sendara/webhooks.rb +74 -0
- data/lib/sendara.rb +27 -0
- data/sendara.gemspec +30 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d4dc1a93353c4f74c9858a5c0456819cf09d8822bfc2d85e52380310800ab361
|
|
4
|
+
data.tar.gz: 01b26945825759c360b80e0fa82be866b76daeb75632a2aae4800b79eb7024a4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d2ff26bc76b493d4f12dabfeba8aecf5bee14192bc2bae40fc31ba7e85f6c4116b89b1d5293912869f8278588c3729e7ceecbc6e431bb7b5ef4793a835059b93
|
|
7
|
+
data.tar.gz: 1792963bb32043679409cc010483e1fb7a0cb9fa9af8019656cce503370ec1b8e6b8130dc47ed7d27f99c1b3c91d1d45026d6281fa9bb630a1742f3c93759f55
|
data/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Sendara Ruby
|
|
2
|
+
|
|
3
|
+
Email-first Ruby client for the [Sendara](https://sendara.dev) API: transactional email, broadcasts, contacts, templates, domains, and signed webhooks. Pure stdlib HTTP, zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
Requires Ruby >= 3.0.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install sendara
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or with Bundler, add to your `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "sendara"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "sendara"
|
|
29
|
+
|
|
30
|
+
client = Sendara.new(ENV.fetch("SENDARA_API_KEY"))
|
|
31
|
+
|
|
32
|
+
result = client.emails.send(
|
|
33
|
+
from: "you@yourdomain.com",
|
|
34
|
+
to: "customer@example.com",
|
|
35
|
+
subject: "Welcome to Acme",
|
|
36
|
+
html: "<h1>Hello</h1><p>Thanks for signing up.</p>",
|
|
37
|
+
text: "Hello — thanks for signing up."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
puts result["id"]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`Sendara.new` accepts the API key as the first argument plus optional keywords:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
client = Sendara.new(
|
|
47
|
+
ENV.fetch("SENDARA_API_KEY"),
|
|
48
|
+
base_url: "https://api.sendara.dev",
|
|
49
|
+
timeout: 30,
|
|
50
|
+
max_retries: 2
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The client automatically attaches an `Idempotency-Key` to every write and retries idempotent requests with exponential backoff (honoring `Retry-After`).
|
|
55
|
+
|
|
56
|
+
### Sending email
|
|
57
|
+
|
|
58
|
+
`emails.send` is keyword-based. `from`, `to`, and `subject` are the essentials; provide `html`, `text`, or both:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
client.emails.send(
|
|
62
|
+
from: "you@yourdomain.com",
|
|
63
|
+
to: "customer@example.com",
|
|
64
|
+
subject: "Your receipt",
|
|
65
|
+
html: "<p>Thanks for your order.</p>",
|
|
66
|
+
text: "Thanks for your order.",
|
|
67
|
+
message_type: "transactional",
|
|
68
|
+
metadata: { "order_id" => "ord_123" }
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Send with a stored template instead of inline content:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
client.emails.send(
|
|
76
|
+
from: "you@yourdomain.com",
|
|
77
|
+
to: "customer@example.com",
|
|
78
|
+
subject: "Your receipt",
|
|
79
|
+
template_id: "tmpl_abc",
|
|
80
|
+
template_vars: { "name" => "Ada", "total" => "$42.00" }
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Pass your own `idempotency_key` to make retries safe across process restarts:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
client.emails.send(
|
|
88
|
+
from: "you@yourdomain.com",
|
|
89
|
+
to: "customer@example.com",
|
|
90
|
+
subject: "Your receipt",
|
|
91
|
+
html: "<p>Thanks!</p>",
|
|
92
|
+
idempotency_key: "receipt-ord_123"
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
For full control over the request envelope, use `send_raw`, or `send_batch` for many messages in one call:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
client.emails.send_raw({
|
|
100
|
+
"channel" => "email",
|
|
101
|
+
"destination" => { "email" => "customer@example.com" },
|
|
102
|
+
"payload" => { "subject" => "Hi", "body_html" => "<p>Hi</p>" }
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
client.emails.send_batch([request_a, request_b, request_c])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Broadcasts
|
|
109
|
+
|
|
110
|
+
Broadcasts send one email to an audience — a saved list or an inline set of recipients.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
broadcast = client.broadcasts.create(
|
|
114
|
+
from_email: "you@yourdomain.com",
|
|
115
|
+
name: "June newsletter",
|
|
116
|
+
subject: "What's new in June",
|
|
117
|
+
body_html: "<h1>June</h1><p>Updates inside.</p>",
|
|
118
|
+
body_text: "June updates inside.",
|
|
119
|
+
audience_list_id: "list_123"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
client.broadcasts.send(broadcast["id"])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Schedule for later, or send immediately on create with `send_now: true`:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
client.broadcasts.create(
|
|
129
|
+
from_email: "you@yourdomain.com",
|
|
130
|
+
name: "Launch",
|
|
131
|
+
subject: "We're live",
|
|
132
|
+
body_html: "<p>We launched.</p>",
|
|
133
|
+
recipients: ["a@example.com", "b@example.com"],
|
|
134
|
+
scheduled_at: "2026-07-01T09:00:00Z"
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
List, fetch, cancel, and delete:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
client.broadcasts.list(limit: 20, offset: 0)
|
|
142
|
+
client.broadcasts.get("bcast_123")
|
|
143
|
+
client.broadcasts.cancel("bcast_123")
|
|
144
|
+
client.broadcasts.delete("bcast_123")
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
To create-and-send an audience in a single request, use `bulk_send` (same arguments as `create`):
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
client.broadcasts.bulk_send(
|
|
151
|
+
from_email: "you@yourdomain.com",
|
|
152
|
+
subject: "Flash sale",
|
|
153
|
+
body_html: "<p>24 hours only.</p>",
|
|
154
|
+
audience_list_id: "list_123",
|
|
155
|
+
send_now: true
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Messages & pagination
|
|
160
|
+
|
|
161
|
+
Fetch a single page with `messages.page`, which returns a `Sendara::MessagePage`:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
page = client.messages.page(status: "delivered", limit: 50)
|
|
165
|
+
|
|
166
|
+
page.messages.each { |message| puts message["id"] }
|
|
167
|
+
page.has_more? # => true / false
|
|
168
|
+
page.next_cursor # => cursor string or nil
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`messages.each` iterates across **all** pages transparently, following the cursor for you:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
client.messages.each(status: "bounced") do |message|
|
|
175
|
+
puts "#{message['id']} → #{message['to']}"
|
|
176
|
+
end
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Called without a block it returns an `Enumerator`, so the full `Enumerable` API is available:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
recent = client.messages.each(channel: "email", limit: 100).first(10)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Filters: `channel`, `status`, `from`, `to`, `limit`, `cursor`. Fetch one message by id:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
client.messages.get("msg_123")
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Webhook verification
|
|
192
|
+
|
|
193
|
+
Verify the signature on incoming webhooks with `Sendara::Webhooks.verify`. Pass the **raw request body** (not parsed), the request headers, and your signing secret. On success it returns the parsed event payload; on failure it raises `Sendara::WebhookVerificationError`.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
require "sendara"
|
|
197
|
+
|
|
198
|
+
event = Sendara::Webhooks.verify(
|
|
199
|
+
raw_body,
|
|
200
|
+
request.headers,
|
|
201
|
+
ENV.fetch("SENDARA_WEBHOOK_SECRET")
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
event["type"] # => "email.delivered"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`verify` checks the `Sendara-Signature` HMAC and rejects requests whose `Sendara-Timestamp` is outside the tolerance window (default 300 seconds). Adjust it if needed:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
Sendara::Webhooks.verify(raw_body, headers, secret, tolerance: 600)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Error handling
|
|
214
|
+
|
|
215
|
+
API responses outside the 2xx range raise `Sendara::ApiError`, which exposes the HTTP `status`, the machine-readable `code`, the `request_id`, and `retry_after` (seconds, when the server sent it):
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
begin
|
|
219
|
+
client.emails.send(
|
|
220
|
+
from: "you@yourdomain.com",
|
|
221
|
+
to: "customer@example.com",
|
|
222
|
+
subject: "Hi",
|
|
223
|
+
html: "<p>Hi</p>"
|
|
224
|
+
)
|
|
225
|
+
rescue Sendara::ApiError => e
|
|
226
|
+
warn "#{e.code} (HTTP #{e.status}): #{e.message}"
|
|
227
|
+
warn "request_id=#{e.request_id}"
|
|
228
|
+
raise unless e.code == "rate_limited"
|
|
229
|
+
sleep(e.retry_after || 1)
|
|
230
|
+
retry
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The exception hierarchy, all under `Sendara::Error`:
|
|
235
|
+
|
|
236
|
+
| Class | Raised when |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| `Sendara::ApiError` | The API returned a non-2xx response. Has `status`, `code`, `request_id`, `retry_after`. |
|
|
239
|
+
| `Sendara::ConnectionError` | The request could not reach the API (DNS, TLS, socket). |
|
|
240
|
+
| `Sendara::TimeoutError` | The request exceeded the configured timeout. Subclass of `ConnectionError`. |
|
|
241
|
+
| `Sendara::WebhookVerificationError` | A webhook signature, timestamp, or body failed verification. |
|
|
242
|
+
|
|
243
|
+
Rescue `Sendara::Error` to catch everything from the gem:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
rescue Sendara::Error => e
|
|
247
|
+
# any Sendara failure
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Rails usage
|
|
252
|
+
|
|
253
|
+
Build one client and share it. An initializer works well:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# config/initializers/sendara.rb
|
|
257
|
+
require "sendara"
|
|
258
|
+
|
|
259
|
+
SENDARA = Sendara.new(Rails.application.credentials.sendara_api_key)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Send from anywhere, ideally off the request cycle in a background job:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# app/jobs/welcome_email_job.rb
|
|
266
|
+
class WelcomeEmailJob < ApplicationJob
|
|
267
|
+
queue_as :default
|
|
268
|
+
|
|
269
|
+
def perform(user)
|
|
270
|
+
SENDARA.emails.send(
|
|
271
|
+
from: "hello@yourdomain.com",
|
|
272
|
+
to: user.email,
|
|
273
|
+
subject: "Welcome to Acme",
|
|
274
|
+
html: WelcomeMailer.render(user),
|
|
275
|
+
idempotency_key: "welcome-#{user.id}"
|
|
276
|
+
)
|
|
277
|
+
rescue Sendara::ApiError => e
|
|
278
|
+
Rails.logger.error("sendara send failed: #{e.code} #{e.message} (#{e.request_id})")
|
|
279
|
+
raise
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Receiving webhooks — verify against the **raw** body, which Rails exposes via `request.raw_post`:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# config/routes.rb
|
|
288
|
+
post "/webhooks/sendara", to: "sendara_webhooks#receive"
|
|
289
|
+
|
|
290
|
+
# app/controllers/sendara_webhooks_controller.rb
|
|
291
|
+
class SendaraWebhooksController < ActionController::API
|
|
292
|
+
def receive
|
|
293
|
+
event = Sendara::Webhooks.verify(
|
|
294
|
+
request.raw_post,
|
|
295
|
+
request.headers,
|
|
296
|
+
Rails.application.credentials.sendara_webhook_secret
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
case event["type"]
|
|
300
|
+
when "email.delivered" then handle_delivered(event)
|
|
301
|
+
when "email.bounced" then handle_bounced(event)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
head :ok
|
|
305
|
+
rescue Sendara::WebhookVerificationError
|
|
306
|
+
head :bad_request
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## License
|
|
312
|
+
|
|
313
|
+
MIT
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Sendara
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.sendara.dev"
|
|
11
|
+
DEFAULT_TIMEOUT = 30
|
|
12
|
+
DEFAULT_MAX_RETRIES = 2
|
|
13
|
+
|
|
14
|
+
RETRY_BASE_DELAY = 0.5
|
|
15
|
+
RETRY_MAX_DELAY = 8.0
|
|
16
|
+
WRITE_METHODS = %w[POST PUT PATCH].freeze
|
|
17
|
+
RETRIABLE_METHODS = %w[GET HEAD PUT DELETE].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :base_url, :timeout, :max_retries
|
|
20
|
+
|
|
21
|
+
def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
22
|
+
max_retries: DEFAULT_MAX_RETRIES, transport: nil)
|
|
23
|
+
raise Error, "An API key is required" if api_key.nil? || api_key.to_s.empty?
|
|
24
|
+
|
|
25
|
+
@api_key = api_key.to_s
|
|
26
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
27
|
+
@timeout = Integer(timeout)
|
|
28
|
+
@max_retries = [0, Integer(max_retries)].max
|
|
29
|
+
@transport = transport || method(:net_http_transport)
|
|
30
|
+
@resources = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request(method, path, body: nil, query: {})
|
|
34
|
+
method = method.to_s.upcase
|
|
35
|
+
url = @base_url + path + build_query(query)
|
|
36
|
+
|
|
37
|
+
headers = {
|
|
38
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
39
|
+
"Accept" => "application/json"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
raw_body = nil
|
|
43
|
+
unless body.nil?
|
|
44
|
+
headers["Content-Type"] = "application/json"
|
|
45
|
+
raw_body = JSON.generate(body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
headers["Idempotency-Key"] = SecureRandom.uuid if WRITE_METHODS.include?(method)
|
|
49
|
+
|
|
50
|
+
idempotent = RETRIABLE_METHODS.include?(method)
|
|
51
|
+
max_attempts = idempotent ? @max_retries + 1 : 1
|
|
52
|
+
|
|
53
|
+
last_error = nil
|
|
54
|
+
attempt = 0
|
|
55
|
+
|
|
56
|
+
while attempt < max_attempts
|
|
57
|
+
begin
|
|
58
|
+
response = @transport.call(method, url, headers, raw_body, @timeout)
|
|
59
|
+
rescue ConnectionError => e
|
|
60
|
+
last_error = e
|
|
61
|
+
if attempt < max_attempts - 1
|
|
62
|
+
sleep_for(backoff_delay(attempt, nil))
|
|
63
|
+
attempt += 1
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
status = response[:status].to_i
|
|
70
|
+
response_headers = response[:headers] || {}
|
|
71
|
+
response_body = response[:body].to_s
|
|
72
|
+
|
|
73
|
+
if status >= 200 && status < 300
|
|
74
|
+
return nil if status == 204 || response_body.empty?
|
|
75
|
+
|
|
76
|
+
return parse_json(response_body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
error = error_from_response(status, response_body, response_headers)
|
|
80
|
+
if idempotent && attempt < max_attempts - 1 && retriable_status?(status)
|
|
81
|
+
last_error = error
|
|
82
|
+
sleep_for(backoff_delay(attempt, retry_after_seconds(response_headers)))
|
|
83
|
+
attempt += 1
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise error
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise last_error || ConnectionError.new("Request failed after retries")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def emails = resource(:emails, Resources::Emails)
|
|
94
|
+
def broadcasts = resource(:broadcasts, Resources::Broadcasts)
|
|
95
|
+
def messages = resource(:messages, Resources::Messages)
|
|
96
|
+
def contacts = resource(:contacts, Resources::Contacts)
|
|
97
|
+
def lists = resource(:lists, Resources::Lists)
|
|
98
|
+
def domains = resource(:domains, Resources::Domains)
|
|
99
|
+
def templates = resource(:templates, Resources::Templates)
|
|
100
|
+
def suppressions = resource(:suppressions, Resources::Suppressions)
|
|
101
|
+
def usage = resource(:usage, Resources::Usage)
|
|
102
|
+
def api_keys = resource(:api_keys, Resources::ApiKeys)
|
|
103
|
+
def billing = resource(:billing, Resources::Billing)
|
|
104
|
+
|
|
105
|
+
def self.generate_idempotency_key
|
|
106
|
+
SecureRandom.uuid
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def resource(name, klass)
|
|
112
|
+
@resources[name] ||= klass.new(self)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_json(text)
|
|
116
|
+
JSON.parse(text)
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_query(query)
|
|
122
|
+
return "" if query.nil? || query.empty?
|
|
123
|
+
|
|
124
|
+
pairs = query.each_with_object([]) do |(key, value), acc|
|
|
125
|
+
next if value.nil? || value == ""
|
|
126
|
+
|
|
127
|
+
value = value ? "true" : "false" if value == true || value == false
|
|
128
|
+
acc << [key.to_s, value.to_s]
|
|
129
|
+
end
|
|
130
|
+
return "" if pairs.empty?
|
|
131
|
+
|
|
132
|
+
"?" + URI.encode_www_form(pairs)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def retriable_status?(status)
|
|
136
|
+
status == 429 || status >= 500
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def retry_after_seconds(headers)
|
|
140
|
+
value = header_value(headers, "retry-after")
|
|
141
|
+
return nil if value.nil?
|
|
142
|
+
|
|
143
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
144
|
+
|
|
145
|
+
timestamp = (Time.httpdate(value).to_i rescue nil)
|
|
146
|
+
return nil if timestamp.nil?
|
|
147
|
+
|
|
148
|
+
[0, timestamp - Time.now.to_i].max
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def backoff_delay(attempt, retry_after)
|
|
152
|
+
return retry_after.to_f if !retry_after.nil? && retry_after >= 0
|
|
153
|
+
|
|
154
|
+
exp = [RETRY_MAX_DELAY, RETRY_BASE_DELAY * (2**attempt)].min
|
|
155
|
+
half = exp / 2.0
|
|
156
|
+
half + (rand * half)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def sleep_for(seconds)
|
|
160
|
+
sleep(seconds) if seconds.positive?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def error_from_response(status, body, headers)
|
|
164
|
+
code = "error"
|
|
165
|
+
message = body.empty? ? "HTTP #{status}" : body
|
|
166
|
+
|
|
167
|
+
decoded = body.empty? ? nil : (JSON.parse(body) rescue nil)
|
|
168
|
+
if decoded.is_a?(Hash)
|
|
169
|
+
envelope = decoded["error"]
|
|
170
|
+
if envelope.is_a?(Hash)
|
|
171
|
+
code = (envelope["code"] || code).to_s
|
|
172
|
+
message = (envelope["message"] || message).to_s
|
|
173
|
+
elsif decoded.key?("message")
|
|
174
|
+
code = (decoded["code"] || code).to_s
|
|
175
|
+
message = decoded["message"].to_s
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
request_id = header_value(headers, "x-request-id")
|
|
180
|
+
retry_after_header = header_value(headers, "retry-after")
|
|
181
|
+
retry_after = retry_after_header&.match?(/\A\d+\z/) ? retry_after_header.to_i : nil
|
|
182
|
+
|
|
183
|
+
ApiError.new(message, status: status, code: code, request_id: request_id, retry_after: retry_after)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def header_value(headers, name)
|
|
187
|
+
lower = name.downcase
|
|
188
|
+
headers.each do |key, value|
|
|
189
|
+
next unless key.to_s.downcase == lower
|
|
190
|
+
|
|
191
|
+
value = value.first if value.is_a?(Array)
|
|
192
|
+
return value.nil? ? nil : value.to_s
|
|
193
|
+
end
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def net_http_transport(method, url, headers, body, timeout)
|
|
198
|
+
uri = URI.parse(url)
|
|
199
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
200
|
+
http.use_ssl = uri.scheme == "https"
|
|
201
|
+
http.open_timeout = timeout
|
|
202
|
+
http.read_timeout = timeout
|
|
203
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
204
|
+
|
|
205
|
+
request_class = net_http_request_class(method)
|
|
206
|
+
request = request_class.new(uri.request_uri)
|
|
207
|
+
headers.each { |name, value| request[name] = value }
|
|
208
|
+
request.body = body unless body.nil?
|
|
209
|
+
|
|
210
|
+
response = http.request(request)
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
status: response.code.to_i,
|
|
214
|
+
headers: flatten_headers(response),
|
|
215
|
+
body: response.body.to_s
|
|
216
|
+
}
|
|
217
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
218
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
219
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
220
|
+
raise ConnectionError, "Network request failed: #{e.message}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def net_http_request_class(method)
|
|
224
|
+
{
|
|
225
|
+
"GET" => Net::HTTP::Get,
|
|
226
|
+
"HEAD" => Net::HTTP::Head,
|
|
227
|
+
"POST" => Net::HTTP::Post,
|
|
228
|
+
"PUT" => Net::HTTP::Put,
|
|
229
|
+
"PATCH" => Net::HTTP::Patch,
|
|
230
|
+
"DELETE" => Net::HTTP::Delete
|
|
231
|
+
}.fetch(method) { raise Error, "Unsupported HTTP method: #{method}" }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def flatten_headers(response)
|
|
235
|
+
headers = {}
|
|
236
|
+
response.each_header { |name, value| headers[name] = value }
|
|
237
|
+
headers
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendara
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ApiError < Error
|
|
7
|
+
attr_reader :status, :code, :request_id, :retry_after
|
|
8
|
+
|
|
9
|
+
def initialize(message, status:, code: "error", request_id: nil, retry_after: nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@status = status
|
|
12
|
+
@code = code
|
|
13
|
+
@request_id = request_id
|
|
14
|
+
@retry_after = retry_after
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ConnectionError < Error; end
|
|
19
|
+
|
|
20
|
+
class TimeoutError < ConnectionError; end
|
|
21
|
+
|
|
22
|
+
class WebhookVerificationError < Error; end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Sendara
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates a Sendara initializer at config/initializers/sendara.rb"
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "sendara.rb", "config/initializers/sendara.rb"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sendara
|
|
4
|
+
class MessagePage
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :messages, :next_cursor
|
|
8
|
+
|
|
9
|
+
def initialize(messages:, next_cursor: nil)
|
|
10
|
+
@messages = messages
|
|
11
|
+
@next_cursor = next_cursor
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.from_response(response)
|
|
15
|
+
response ||= {}
|
|
16
|
+
raw = response["messages"]
|
|
17
|
+
messages = raw.is_a?(Array) ? raw : []
|
|
18
|
+
cursor = response["next_cursor"]
|
|
19
|
+
cursor = nil unless cursor.is_a?(String) && !cursor.empty?
|
|
20
|
+
|
|
21
|
+
new(messages: messages, next_cursor: cursor)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def each(&block)
|
|
25
|
+
return enum_for(:each) unless block_given?
|
|
26
|
+
|
|
27
|
+
messages.each(&block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def has_more?
|
|
31
|
+
!next_cursor.nil?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|