simple_connect-client 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/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/lib/simple_connect/api/events.rb +81 -0
- data/lib/simple_connect/api/integrations.rb +30 -0
- data/lib/simple_connect/api/response_attachment.rb +37 -0
- data/lib/simple_connect/client.rb +60 -0
- data/lib/simple_connect/errors.rb +24 -0
- data/lib/simple_connect/headers.rb +39 -0
- data/lib/simple_connect/request.rb +96 -0
- data/lib/simple_connect/responses/deliver_response.rb +39 -0
- data/lib/simple_connect/responses/error_response.rb +27 -0
- data/lib/simple_connect/responses/event_response.rb +62 -0
- data/lib/simple_connect/responses/message_response.rb +65 -0
- data/lib/simple_connect/responses/verify_response.rb +77 -0
- data/lib/simple_connect/result.rb +39 -0
- data/lib/simple_connect/retryable.rb +45 -0
- data/lib/simple_connect/version.rb +5 -0
- data/lib/simple_connect.rb +28 -0
- metadata +127 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e1ab061c2118ee363fa9fdc9c1f69518a247cf55297d5b8dbf0de893c3507823
|
|
4
|
+
data.tar.gz: ce307f5e3403aead072705a621115308a573da3839bf5121b2e80b14a40503bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cdc8f0ea63a0d3a133a871e0a0cd9ab21649d70ece906e1b54a14f7f36254186e80a5015e4890e12d9b4fdb673e89cb6a29aa941708e2c73bdef60761a7ed996
|
|
7
|
+
data.tar.gz: f800d93c034416cd8b143283f8fe9a354bc768e932f7ad399476e0ee134599adedaf59c7cec3ccd04ff330253bab4efae2ade766a0d879f6bb45cd301dddf35d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-04-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial public release.
|
|
15
|
+
- `SimpleConnect::Client` — entry point with two resource objects:
|
|
16
|
+
- `events.deliver(event_key, fields, event_id:, language:, occurred_at:)` — POST a signed domain event.
|
|
17
|
+
- `events.detail(event_id)` — GET a previously-ingested event.
|
|
18
|
+
- `integrations.verify` — GET the status endpoint (derived by swapping `/events` → `/status`).
|
|
19
|
+
- HMAC-SHA256 signing via three headers: `X-SimpleConnect-Key-Id`, `X-SimpleConnect-Timestamp`, `X-SimpleConnect-Signature`.
|
|
20
|
+
- `User-Agent` header (`simple_connect-client/<version> ruby/<version>`) with optional `user_agent:` override.
|
|
21
|
+
- Typed response classes under `SimpleConnect::Responses::`:
|
|
22
|
+
- `DeliverResponse`, `EventResponse`, `VerifyResponse`, `MessageResponse` for 2xx success shapes.
|
|
23
|
+
- `ErrorResponse` for 4xx / 5xx bodies (universal across endpoints).
|
|
24
|
+
- Polymorphic `Result#data` — endpoint-specific success class on 2xx, `ErrorResponse` on 4xx/5xx with JSON body, `nil` on non-JSON or network errors.
|
|
25
|
+
- `Result` as an immutable `Data.define` value type (not a mutable Struct) — consumers cannot mutate post-construction.
|
|
26
|
+
- Library-level retry via `SimpleConnect::Retryable` — default 3 attempts with linear backoff on 5xx and network errors. Configurable via `max_attempts:` (simple) or `retryable:` (power-user policy injection). Mutually exclusive.
|
|
27
|
+
- Optional client-side event-key whitelist via `event_keys:` — opt-in validation before the HTTP call. Without it, the server is the source of truth.
|
|
28
|
+
- Error hierarchy under `SimpleConnect::Error`: `ConfigurationError`, `UnknownEventError`, `MalformedResponseError`.
|
|
29
|
+
|
|
30
|
+
### Compatibility
|
|
31
|
+
|
|
32
|
+
- Ruby 3.2+.
|
|
33
|
+
- No runtime gem dependencies (stdlib only).
|
|
34
|
+
|
|
35
|
+
[unreleased]: https://github.com/GemsEssence/SimpleWaConnect/compare/simple_connect-client-v0.1.0...HEAD
|
|
36
|
+
[0.1.0]: https://github.com/GemsEssence/SimpleWaConnect/releases/tag/simple_connect-client-v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ramkrishan Patidar
|
|
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,337 @@
|
|
|
1
|
+
# simple_connect-client
|
|
2
|
+
|
|
3
|
+
Dependency-free Ruby client for the **SimpleWaConnect** integration endpoints.
|
|
4
|
+
Ships domain events (POST), fetches event details (GET), and verifies
|
|
5
|
+
integration health — all signed with HMAC-SHA256, with built-in retries on
|
|
6
|
+
5xx / network errors.
|
|
7
|
+
|
|
8
|
+
Stdlib-only at runtime: no Rails, no Faraday, no gems.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# Gemfile
|
|
14
|
+
gem "simple_connect-client", require: "simple_connect"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then `bundle install`.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
Create one client per app (typically in an initializer):
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# config/initializers/simple_connect.rb
|
|
25
|
+
SIMPLECONNECT = SimpleConnect::Client.new(
|
|
26
|
+
endpoint_url: ENV.fetch("SIMPLECONNECT_ENDPOINT_URL"), # e.g. https://app.simplewaconnect.com/api/v1/integrations/purepani/events
|
|
27
|
+
key_id: ENV.fetch("SIMPLECONNECT_KEY_ID"), # the "wa_sec_…" prefix shown on the integration Security card
|
|
28
|
+
secret: ENV.fetch("SIMPLECONNECT_SECRET"), # the raw signing secret shown once at connect / rotation
|
|
29
|
+
logger: Rails.logger # optional
|
|
30
|
+
)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
One `Client` instance is safe to share across threads.
|
|
34
|
+
|
|
35
|
+
### Optional: client-side event-key whitelist
|
|
36
|
+
|
|
37
|
+
The gem ships no hardcoded event list — each provider owns its own taxonomy.
|
|
38
|
+
By default, `events.deliver` accepts any non-empty `event_key` and the server
|
|
39
|
+
validates it (returning 422 on unknown keys).
|
|
40
|
+
|
|
41
|
+
If you'd rather catch typos before any HTTP call, pass `event_keys:`:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
SIMPLECONNECT = SimpleConnect::Client.new(
|
|
45
|
+
endpoint_url: "...",
|
|
46
|
+
key_id: "...",
|
|
47
|
+
secret: "...",
|
|
48
|
+
event_keys: %w[
|
|
49
|
+
customer_payment_received
|
|
50
|
+
customer_invoice_ready
|
|
51
|
+
customer_invoice_payment_reminder
|
|
52
|
+
customer_today_order_delivered
|
|
53
|
+
customer_app_invitation
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
SIMPLECONNECT.events.deliver("customer_paymnet_received", ...) # typo
|
|
58
|
+
# => ArgumentError: Unknown event_key 'customer_paymnet_received'.
|
|
59
|
+
# Must be one of: customer_payment_received, customer_invoice_ready, ...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Leave `event_keys:` unset when your provider's event taxonomy changes often,
|
|
63
|
+
or when you'd rather rely on a single source of truth (the server).
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
The client groups calls into two resource objects — `events` and `integrations`.
|
|
68
|
+
|
|
69
|
+
### Deliver a domain event
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
SIMPLECONNECT.events.deliver(
|
|
73
|
+
"customer_payment_received",
|
|
74
|
+
customer_name: "Ramesh Kumar",
|
|
75
|
+
customer_mobile_no: "+919812345678",
|
|
76
|
+
agency_name: "Acme Dairy",
|
|
77
|
+
payment_date: "2026-04-16",
|
|
78
|
+
payment_mode: "UPI",
|
|
79
|
+
payment_amount: "450.00",
|
|
80
|
+
customer_total_due_amount: "0.00",
|
|
81
|
+
event_id: "pp_payment_#{payment.id}" # idempotency key
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Pass an explicit `event_id:` (unique per domain event) for safe retries —
|
|
86
|
+
duplicate event_ids are treated as no-ops by the server.
|
|
87
|
+
|
|
88
|
+
### Fetch a previously-ingested event
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
result = SIMPLECONNECT.events.detail("pp_payment_42")
|
|
92
|
+
# result.response_body is JSON (see server docs for shape)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Verify integration health
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
result = SIMPLECONNECT.integrations.verify
|
|
99
|
+
# result.response_body contains the per-event-flow snapshot
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Return value — `SimpleConnect::Result`
|
|
103
|
+
|
|
104
|
+
Every resource call returns a `Result` struct with:
|
|
105
|
+
|
|
106
|
+
| Field | Type | Notes |
|
|
107
|
+
| -------------- | -------- | ---------------------------------------------------------------------------------------- |
|
|
108
|
+
| `success?` | Boolean | `true` on 2xx |
|
|
109
|
+
| `status_code` | Integer | HTTP status, or 0 on network error |
|
|
110
|
+
| `response_body`| String? | Raw body string (nil on network error) |
|
|
111
|
+
| `error` | String? | Short error description on failure |
|
|
112
|
+
| `attempts` | Integer | HTTP attempts made (1..MAX_ATTEMPTS) |
|
|
113
|
+
| `data` | Response?| Typed response object (see below) when the body was parseable; `nil` otherwise |
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
result = SIMPLECONNECT.events.deliver("customer_payment_received", fields)
|
|
117
|
+
if result.success?
|
|
118
|
+
Rails.logger.info("delivered in #{result.attempts} attempt(s); id=#{result.data.event_id}")
|
|
119
|
+
else
|
|
120
|
+
Rails.logger.error("deliver failed: #{result.error}")
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Working with responses
|
|
125
|
+
|
|
126
|
+
`result.data` is polymorphic by outcome — typed to whatever the server sent:
|
|
127
|
+
|
|
128
|
+
| Outcome | `result.data` |
|
|
129
|
+
| ------------------------------------ | ----------------------------------------------------------------- |
|
|
130
|
+
| 2xx + valid JSON | endpoint-specific success class (see below) |
|
|
131
|
+
| 4xx / 5xx + valid JSON | `SimpleConnect::Responses::ErrorResponse` |
|
|
132
|
+
| 4xx / 5xx + non-JSON body | `nil` (fall back to `result.error` + `result.response_body`) |
|
|
133
|
+
| Network error (timeout, DNS, refused)| `nil` |
|
|
134
|
+
| 2xx + unparseable JSON (server bug) | raises `SimpleConnect::MalformedResponseError` |
|
|
135
|
+
|
|
136
|
+
Always check `result.success?` first, then read `result.data`.
|
|
137
|
+
|
|
138
|
+
### `events.deliver` → `DeliverResponse`
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
result = SIMPLECONNECT.events.deliver(
|
|
142
|
+
"customer_payment_received", fields, event_id: "pp_pay_42"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if result.success?
|
|
146
|
+
response = result.data
|
|
147
|
+
response.event_id # => "pp_pay_42"
|
|
148
|
+
response.log_id # => 42
|
|
149
|
+
response.duplicate? # => false on fresh POST, true on idempotent replay
|
|
150
|
+
response.used_previous_secret? # => true during the 24h grace window after rotation
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `events.detail(event_id)` → `EventResponse`
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
result = SIMPLECONNECT.events.detail("pp_pay_42")
|
|
158
|
+
|
|
159
|
+
if result.success?
|
|
160
|
+
event = result.data
|
|
161
|
+
event.event_key # => "customer_payment_received"
|
|
162
|
+
event.dispatched? # => true / false
|
|
163
|
+
event.failed? # => true / false
|
|
164
|
+
event.skipped? # => true when status starts with "skipped_"
|
|
165
|
+
event.occurred_at # => Time, or nil if unparseable
|
|
166
|
+
event.payload # => original envelope hash (minus top-level metadata)
|
|
167
|
+
event.error_text # => nil on success statuses; populated when status == "failed"
|
|
168
|
+
|
|
169
|
+
if event.message?
|
|
170
|
+
msg = event.message # => MessageResponse (see below)
|
|
171
|
+
msg.incoming? # => true for user-initiated inbound messages
|
|
172
|
+
msg.status_callback? # => true for message.status callbacks
|
|
173
|
+
msg.message_id # => "wamid.HBgL..."
|
|
174
|
+
msg.status # => "delivered" / "read" / ...
|
|
175
|
+
msg.timestamp # => Time (or nil if unparseable)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `integrations.verify` → `VerifyResponse`
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
result = SIMPLECONNECT.integrations.verify
|
|
184
|
+
|
|
185
|
+
if result.success?
|
|
186
|
+
verify = result.data
|
|
187
|
+
verify.connected? # => true
|
|
188
|
+
verify.provider # => "purepani"
|
|
189
|
+
|
|
190
|
+
verify.event_flows.each do |flow|
|
|
191
|
+
flow.event_key # => "customer_payment_received"
|
|
192
|
+
flow.state # => "enabled" / "not_configured" / "needs_attention" / ...
|
|
193
|
+
flow.enabled? # => true / false
|
|
194
|
+
flow.needs_attention? # => true / false
|
|
195
|
+
flow.configured? # => true when a template is linked
|
|
196
|
+
|
|
197
|
+
if flow.configured?
|
|
198
|
+
flow.template.name # => "pay_rcvd"
|
|
199
|
+
flow.template.language # => "en"
|
|
200
|
+
flow.template.approved? # => true / false
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Lookup by event_key:
|
|
205
|
+
flow = verify.event_flow("customer_payment_received")
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Error path → `ErrorResponse`
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
result = SIMPLECONNECT.events.deliver("some_event", fields)
|
|
213
|
+
|
|
214
|
+
unless result.success?
|
|
215
|
+
if result.data # ErrorResponse (or nil for non-JSON / network errors)
|
|
216
|
+
Rails.logger.warn("deliver rejected: #{result.data.message}")
|
|
217
|
+
# result.data.to_h → full parsed error body for any un-surfaced fields
|
|
218
|
+
else
|
|
219
|
+
# Network error or non-JSON body from a proxy.
|
|
220
|
+
Rails.logger.error("deliver transport-failed: #{result.error}")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Unsurfaced fields — `#to_h` escape hatch
|
|
226
|
+
|
|
227
|
+
Every response class exposes `#to_h` returning a duplicate of the raw parsed
|
|
228
|
+
JSON. If the server adds a new field we haven't surfaced as a method yet,
|
|
229
|
+
reach for it via `response.to_h["new_field"]` instead of waiting for a gem
|
|
230
|
+
release.
|
|
231
|
+
|
|
232
|
+
## Run in a background job
|
|
233
|
+
|
|
234
|
+
HTTP delivery is synchronous and may retry (up to 3 attempts, linear backoff).
|
|
235
|
+
Wrap `events.deliver` in ActiveJob / Sidekiq so the calling request isn't
|
|
236
|
+
blocked on endpoint latency.
|
|
237
|
+
|
|
238
|
+
**Recommended — disable library retries when wrapping in a job queue.** The
|
|
239
|
+
queue has its own retry layer; keeping both means one 500 becomes dozens of
|
|
240
|
+
HTTP calls (job retries × library retries). Pass `max_attempts: 1` to the
|
|
241
|
+
Client so each job invocation does a single POST:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
# config/initializers/simple_connect.rb
|
|
245
|
+
SIMPLECONNECT = SimpleConnect::Client.new(
|
|
246
|
+
endpoint_url: ENV.fetch("SIMPLECONNECT_ENDPOINT_URL"),
|
|
247
|
+
key_id: ENV.fetch("SIMPLECONNECT_KEY_ID"),
|
|
248
|
+
secret: ENV.fetch("SIMPLECONNECT_SECRET"),
|
|
249
|
+
logger: Rails.logger,
|
|
250
|
+
max_attempts: 1 # Sidekiq / ActiveJob will retry for us
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
class DeliverSimpleConnectEventJob < ApplicationJob
|
|
254
|
+
def perform(event_key, fields, event_id:)
|
|
255
|
+
SIMPLECONNECT.events.deliver(event_key, fields, event_id: event_id)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
DeliverSimpleConnectEventJob.perform_later(
|
|
260
|
+
"customer_payment_received",
|
|
261
|
+
{ customer_name: "...", ... },
|
|
262
|
+
event_id: "pp_payment_#{payment.id}"
|
|
263
|
+
)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
If you're *not* wrapping in a queue — e.g., calling from a one-off script or
|
|
267
|
+
a synchronous backend — leave the default (`max_attempts: 3`, linear 1s/2s
|
|
268
|
+
backoff, retries on 5xx and network errors).
|
|
269
|
+
|
|
270
|
+
### Custom retry policy
|
|
271
|
+
|
|
272
|
+
For exponential backoff, jitter, or a longer window, pass a `Retryable`
|
|
273
|
+
instance instead. `max_attempts:` and `retryable:` are mutually exclusive.
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
SimpleConnect::Client.new(
|
|
277
|
+
endpoint_url: ..., key_id: ..., secret: ...,
|
|
278
|
+
retryable: SimpleConnect::Retryable.new(
|
|
279
|
+
max_attempts: 5,
|
|
280
|
+
delay: ->(n) { (2**n) + rand } # exponential + jitter
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Error hierarchy
|
|
286
|
+
|
|
287
|
+
All gem-specific errors inherit from `SimpleConnect::Error < StandardError`:
|
|
288
|
+
|
|
289
|
+
| Error class | Raised when |
|
|
290
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------- |
|
|
291
|
+
| `SimpleConnect::ConfigurationError` | `Client.new` is given blank/conflicting config. |
|
|
292
|
+
| `SimpleConnect::UnknownEventError` | `events.deliver` called with a key outside the configured `event_keys:` list. |
|
|
293
|
+
| `SimpleConnect::MalformedResponseError` | A 2xx response body wasn't valid JSON (server bug — not retryable). |
|
|
294
|
+
| `ArgumentError` (stdlib, not in hierarchy) | Programmer misuse: empty `event_key`, empty `event_id`, bad HTTP method, etc. |
|
|
295
|
+
|
|
296
|
+
## Signing scheme
|
|
297
|
+
|
|
298
|
+
The client signs every request:
|
|
299
|
+
|
|
300
|
+
- `X-SimpleConnect-Key-Id` — the `key_id` passed to the client
|
|
301
|
+
- `X-SimpleConnect-Timestamp` — current unix time in seconds
|
|
302
|
+
- `X-SimpleConnect-Signature` — `sha256=<hex>` where `<hex>` is
|
|
303
|
+
HMAC-SHA256 of `"{timestamp}.{raw_body}"` using the secret.
|
|
304
|
+
|
|
305
|
+
For GET requests (no body), the signed string is `"{timestamp}."` (timestamp
|
|
306
|
+
+ dot + empty body).
|
|
307
|
+
|
|
308
|
+
## Development
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
bundle install
|
|
312
|
+
bundle exec rspec # run the spec suite
|
|
313
|
+
bundle exec rubocop --config .rubocop.yml # lint (the --config flag is needed when the gem is developed inside a parent Rails app with its own .rubocop.yml)
|
|
314
|
+
bundle exec rake build # build the .gem into pkg/
|
|
315
|
+
bin/console # IRB with the gem preloaded
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## Contributing
|
|
319
|
+
|
|
320
|
+
Bug reports and pull requests welcome on GitHub at the repo URL in the
|
|
321
|
+
gemspec. Please:
|
|
322
|
+
|
|
323
|
+
1. Fork and create a feature branch.
|
|
324
|
+
2. Add or update specs for anything you change in `lib/`. The spec suite
|
|
325
|
+
should stay green across Ruby 3.2, 3.3, and 3.4 — CI runs the matrix.
|
|
326
|
+
3. Run `bundle exec rubocop --config .rubocop.yml` and fix offenses before
|
|
327
|
+
opening the PR.
|
|
328
|
+
4. Update `CHANGELOG.md` under `[Unreleased]`.
|
|
329
|
+
5. Keep the gem dependency-free (stdlib only). New runtime deps need a
|
|
330
|
+
compelling reason and a discussion on the issue tracker first.
|
|
331
|
+
|
|
332
|
+
Don't include unrelated refactors in the same PR — separate concerns land
|
|
333
|
+
more predictably.
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Api
|
|
5
|
+
# Events resource. Holds the shared Request and the endpoint URI; exposes
|
|
6
|
+
# #deliver (POST a domain event) and #detail (GET a previously-ingested
|
|
7
|
+
# event by its event_id).
|
|
8
|
+
#
|
|
9
|
+
# Event-key validation is opt-in. Pass `event_keys:` to Client.new to
|
|
10
|
+
# enforce a whitelist client-side; omit it and the server becomes the
|
|
11
|
+
# source of truth (unknown keys return 422 from the server). The gem
|
|
12
|
+
# ships no hardcoded event list — each provider owns its own taxonomy.
|
|
13
|
+
class Events
|
|
14
|
+
include ResponseAttachment
|
|
15
|
+
|
|
16
|
+
DEFAULT_LANGUAGE = "en"
|
|
17
|
+
|
|
18
|
+
def initialize(request:, endpoint_uri:, event_keys: nil)
|
|
19
|
+
@request = request
|
|
20
|
+
@endpoint_uri = endpoint_uri
|
|
21
|
+
@event_keys = event_keys&.map(&:to_s)&.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def deliver(event_key, fields = {}, event_id: nil, language: DEFAULT_LANGUAGE, occurred_at: nil, **extra_fields)
|
|
25
|
+
event_key = event_key.to_s
|
|
26
|
+
raise ArgumentError, "event_key is required" if event_key.empty?
|
|
27
|
+
if @event_keys && !@event_keys.include?(event_key)
|
|
28
|
+
raise SimpleConnect::UnknownEventError,
|
|
29
|
+
"Unknown event_key '#{event_key}'. Must be one of: #{@event_keys.join(", ")}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
merged_fields = stringify_keys(fields).merge(stringify_keys(extra_fields))
|
|
33
|
+
body = build_body(event_key, merged_fields, event_id: event_id, language: language, occurred_at: occurred_at)
|
|
34
|
+
attach_response(@request.post(@endpoint_uri, body: body), Responses::DeliverResponse)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def detail(event_id)
|
|
38
|
+
raise ArgumentError, "event_id is required" if event_id.to_s.strip.empty?
|
|
39
|
+
|
|
40
|
+
attach_response(@request.get(detail_uri(event_id)), Responses::EventResponse)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def detail_uri(event_id)
|
|
46
|
+
URI.parse("#{@endpoint_uri}/#{URI.encode_www_form_component(event_id.to_s)}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build_body(event_key, fields, event_id:, language:, occurred_at:)
|
|
50
|
+
envelope = {
|
|
51
|
+
"event" => event_key,
|
|
52
|
+
"event_id" => resolve_event_id(event_id),
|
|
53
|
+
"occurred_at" => format_timestamp(occurred_at),
|
|
54
|
+
"language" => resolve_language(language)
|
|
55
|
+
}
|
|
56
|
+
envelope.merge(stringify_keys(fields)).to_json
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_event_id(value)
|
|
60
|
+
v = value.to_s.strip
|
|
61
|
+
v.empty? ? "evt_#{SecureRandom.hex(8)}" : v
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_language(value)
|
|
65
|
+
v = value.to_s.strip
|
|
66
|
+
v.empty? ? DEFAULT_LANGUAGE : v
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def format_timestamp(value)
|
|
70
|
+
value ||= Time.now
|
|
71
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stringify_keys(hash)
|
|
75
|
+
return {} if hash.nil?
|
|
76
|
+
|
|
77
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Api
|
|
5
|
+
# Integrations resource. Holds the shared Request and the endpoint URI;
|
|
6
|
+
# exposes #verify (GET the status endpoint, derived by swapping the
|
|
7
|
+
# trailing "/events" segment of the endpoint URL with "/status").
|
|
8
|
+
class Integrations
|
|
9
|
+
include ResponseAttachment
|
|
10
|
+
|
|
11
|
+
EVENTS_PATH_SUFFIX = "/events"
|
|
12
|
+
STATUS_PATH_SUFFIX = "/status"
|
|
13
|
+
|
|
14
|
+
def initialize(request:, endpoint_uri:)
|
|
15
|
+
@request = request
|
|
16
|
+
@endpoint_uri = endpoint_uri
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def verify
|
|
20
|
+
attach_response(@request.get(status_uri), Responses::VerifyResponse)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def status_uri
|
|
26
|
+
@status_uri ||= URI.parse(@endpoint_uri.to_s.sub(/#{Regexp.escape(EVENTS_PATH_SUFFIX)}\z/, STATUS_PATH_SUFFIX))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Api
|
|
5
|
+
# Shared private helper for Api::Events and Api::Integrations. Parses the
|
|
6
|
+
# response body and returns a new Result with `.data` populated:
|
|
7
|
+
# 2xx + valid JSON → the caller-supplied success class
|
|
8
|
+
# 4xx/5xx + valid JSON → Responses::ErrorResponse
|
|
9
|
+
# nil / empty body → returned unchanged (data stays nil)
|
|
10
|
+
# 4xx/5xx + non-JSON → returned unchanged (data stays nil)
|
|
11
|
+
# 2xx + unparseable JSON → raises MalformedResponseError (server bug)
|
|
12
|
+
#
|
|
13
|
+
# Result is immutable (Data.define); this helper builds a new Result via
|
|
14
|
+
# `#with_data` rather than mutating the original.
|
|
15
|
+
module ResponseAttachment
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def attach_response(result, success_klass)
|
|
19
|
+
return result if result.response_body.nil? || result.response_body.empty?
|
|
20
|
+
|
|
21
|
+
parsed = parse_json_body(result.response_body, on_success: result.success?)
|
|
22
|
+
return result if parsed.nil?
|
|
23
|
+
|
|
24
|
+
data = result.success? ? success_klass.new(parsed) : Responses::ErrorResponse.new(parsed)
|
|
25
|
+
result.with_data(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_json_body(str, on_success:)
|
|
29
|
+
JSON.parse(str)
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
raise SimpleConnect::MalformedResponseError, "Unparseable JSON body: #{str[0, 120]}" if on_success
|
|
32
|
+
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
# Entry point for the SimpleWaConnect integration client. Assembles the
|
|
5
|
+
# signed-request transport once, then exposes two resource objects:
|
|
6
|
+
#
|
|
7
|
+
# SIMPLECONNECT.events.deliver(event_key, fields, event_id: ...)
|
|
8
|
+
# SIMPLECONNECT.events.detail(event_id)
|
|
9
|
+
# SIMPLECONNECT.integrations.verify
|
|
10
|
+
#
|
|
11
|
+
# See README.md for usage, envelope format, and signing scheme.
|
|
12
|
+
class Client
|
|
13
|
+
DEFAULT_TIMEOUT = 10
|
|
14
|
+
|
|
15
|
+
attr_reader :events, :integrations
|
|
16
|
+
|
|
17
|
+
# @param endpoint_url [String] the integration events URL, e.g.
|
|
18
|
+
# "https://app.simplewaconnect.com/api/v1/integrations/purepani/events".
|
|
19
|
+
# @param key_id [String] signing-secret prefix shown on the integration
|
|
20
|
+
# Security card (the `wa_sec_…` string, NOT the raw secret).
|
|
21
|
+
# @param secret [String] raw signing secret shown once at connect /
|
|
22
|
+
# rotation.
|
|
23
|
+
# @param timeout [Integer] HTTP read/open timeout in seconds.
|
|
24
|
+
# @param logger [#info, #warn, nil] optional logger for transport events.
|
|
25
|
+
# @param event_keys [Array<String>, nil] optional client-side whitelist of
|
|
26
|
+
# event_keys. When nil (default), `events.deliver` accepts any non-empty
|
|
27
|
+
# event_key and the server is the source of truth.
|
|
28
|
+
# @param max_attempts [Integer] total HTTP attempts (including the first).
|
|
29
|
+
# Default 3. Pass `1` to disable library-level retries (appropriate when
|
|
30
|
+
# wrapping in a job queue that has its own retry layer). Mutually
|
|
31
|
+
# exclusive with `retryable:`.
|
|
32
|
+
# @param retryable [SimpleConnect::Retryable, nil] power-user escape
|
|
33
|
+
# hatch for custom retry policy (exponential backoff, jitter, etc.).
|
|
34
|
+
# Mutually exclusive with `max_attempts:`.
|
|
35
|
+
# @param user_agent [String, nil] override the default User-Agent header
|
|
36
|
+
# (`simple_connect-client/<version> ruby/<version>`).
|
|
37
|
+
def initialize(endpoint_url:, key_id:, secret:,
|
|
38
|
+
timeout: DEFAULT_TIMEOUT, logger: nil, event_keys: nil,
|
|
39
|
+
max_attempts: nil, retryable: nil, user_agent: nil)
|
|
40
|
+
raise ConfigurationError, "endpoint_url is required" if endpoint_url.to_s.strip.empty?
|
|
41
|
+
raise ConfigurationError, "key_id is required" if key_id.to_s.strip.empty?
|
|
42
|
+
raise ConfigurationError, "secret is required" if secret.to_s.strip.empty?
|
|
43
|
+
|
|
44
|
+
if max_attempts && retryable
|
|
45
|
+
raise ConfigurationError,
|
|
46
|
+
"pass either `max_attempts:` or `retryable:`, not both"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
endpoint_uri = URI.parse(endpoint_url)
|
|
50
|
+
request = Request.new(
|
|
51
|
+
headers: Headers.new(key_id: key_id, secret: secret, user_agent: user_agent),
|
|
52
|
+
timeout: timeout,
|
|
53
|
+
logger: logger,
|
|
54
|
+
retryable: retryable || Retryable.new(max_attempts: max_attempts || Retryable::DEFAULT_MAX_ATTEMPTS)
|
|
55
|
+
)
|
|
56
|
+
@events = Api::Events.new(request: request, endpoint_uri: endpoint_uri, event_keys: event_keys)
|
|
57
|
+
@integrations = Api::Integrations.new(request: request, endpoint_uri: endpoint_uri)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
# Base class for all gem-specific errors. Inherits from StandardError so
|
|
5
|
+
# a plain `rescue` catches them.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when `Client.new` is given missing or conflicting configuration
|
|
9
|
+
# (empty endpoint_url/key_id/secret, both `max_attempts:` and `retryable:`
|
|
10
|
+
# at once, etc.).
|
|
11
|
+
class ConfigurationError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when `events.deliver` is called with an event_key that's not in
|
|
14
|
+
# the caller's `event_keys:` whitelist. Only fires when the whitelist is
|
|
15
|
+
# configured; without a whitelist, the server is the source of truth and
|
|
16
|
+
# validation is skipped.
|
|
17
|
+
class UnknownEventError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a 2xx response body cannot be parsed as JSON. Signals a
|
|
20
|
+
# server bug (the endpoint promised JSON but returned something else) —
|
|
21
|
+
# retrying won't help. On 4xx/5xx with an unparseable body, the parse is
|
|
22
|
+
# silently skipped and `Result#data` stays nil.
|
|
23
|
+
class MalformedResponseError < Error; end
|
|
24
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
# Builds the signed request headers (Content-Type + User-Agent + key id +
|
|
5
|
+
# timestamp + HMAC signature) for a given body. One instance per Client;
|
|
6
|
+
# holds the key_id and secret so callers never see them.
|
|
7
|
+
class Headers
|
|
8
|
+
KEY_ID_HEADER = "X-SimpleConnect-Key-Id"
|
|
9
|
+
TIMESTAMP_HEADER = "X-SimpleConnect-Timestamp"
|
|
10
|
+
SIGNATURE_HEADER = "X-SimpleConnect-Signature"
|
|
11
|
+
|
|
12
|
+
def self.default_user_agent
|
|
13
|
+
"simple_connect-client/#{SimpleConnect::VERSION} ruby/#{RUBY_VERSION}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(key_id:, secret:, user_agent: nil)
|
|
17
|
+
@key_id = key_id.to_s
|
|
18
|
+
@secret = secret.to_s
|
|
19
|
+
@user_agent = (user_agent || self.class.default_user_agent).to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build_for(body)
|
|
23
|
+
timestamp = Time.now.to_i.to_s
|
|
24
|
+
{
|
|
25
|
+
"Content-Type" => "application/json",
|
|
26
|
+
"User-Agent" => @user_agent,
|
|
27
|
+
KEY_ID_HEADER => @key_id,
|
|
28
|
+
TIMESTAMP_HEADER => timestamp,
|
|
29
|
+
SIGNATURE_HEADER => "sha256=#{compute_signature(timestamp, body)}"
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def compute_signature(timestamp, body)
|
|
36
|
+
OpenSSL::HMAC.hexdigest("sha256", @secret, "#{timestamp}.#{body}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
# Transport layer. Holds the Headers + retry policy + timeout/logger, and
|
|
5
|
+
# exposes #get / #post which return an immutable Result. Retries are
|
|
6
|
+
# delegated to the injected Retryable; per-retry sleep / delay policy lives
|
|
7
|
+
# there (see SimpleConnect::Retryable).
|
|
8
|
+
class Request
|
|
9
|
+
def initialize(headers:, timeout:, retryable:, logger: nil)
|
|
10
|
+
@headers = headers
|
|
11
|
+
@timeout = timeout
|
|
12
|
+
@logger = logger
|
|
13
|
+
@retryable = retryable
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(uri, body: "")
|
|
17
|
+
request_with_retry(http_method: :get, body: body, uri: uri)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post(uri, body:)
|
|
21
|
+
request_with_retry(http_method: :post, body: body, uri: uri)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def request_with_retry(http_method:, body:, uri:)
|
|
27
|
+
@retryable.call(retriable_if: method(:retriable?)) do |attempt|
|
|
28
|
+
do_request(http_method: http_method, body: body, uri: uri, attempt: attempt)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def do_request(http_method:, body:, uri:, attempt:)
|
|
33
|
+
headers = @headers.build_for(body)
|
|
34
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
35
|
+
http.use_ssl = uri.scheme == "https"
|
|
36
|
+
http.read_timeout = @timeout
|
|
37
|
+
http.open_timeout = @timeout
|
|
38
|
+
|
|
39
|
+
request = build_net_http_request(http_method, uri, headers, body)
|
|
40
|
+
|
|
41
|
+
log(:info, "#{http_method.to_s.upcase} #{uri.host}#{uri.path} (#{body.bytesize}B signed body)")
|
|
42
|
+
response = http.request(request)
|
|
43
|
+
code = response.code.to_i
|
|
44
|
+
ok = (200..299).cover?(code)
|
|
45
|
+
|
|
46
|
+
log(ok ? :info : :warn, "Response #{code}")
|
|
47
|
+
Result.new(
|
|
48
|
+
success: ok,
|
|
49
|
+
status_code: code,
|
|
50
|
+
response_body: response.body,
|
|
51
|
+
error: ok ? nil : "HTTP #{code}: #{truncate(response.body)}",
|
|
52
|
+
attempts: attempt
|
|
53
|
+
)
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
log(:warn, "#{http_method.to_s.upcase} failed: #{e.class}: #{e.message}")
|
|
56
|
+
Result.new(
|
|
57
|
+
success: false,
|
|
58
|
+
status_code: 0,
|
|
59
|
+
response_body: nil,
|
|
60
|
+
error: "#{e.class}: #{e.message}",
|
|
61
|
+
attempts: attempt
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_net_http_request(http_method, uri, headers, body)
|
|
66
|
+
case http_method
|
|
67
|
+
when :get
|
|
68
|
+
Net::HTTP::Get.new(uri.request_uri, headers)
|
|
69
|
+
when :post
|
|
70
|
+
Net::HTTP::Post.new(uri.request_uri, headers).tap { |r| r.body = body }
|
|
71
|
+
else
|
|
72
|
+
raise ArgumentError, "Unsupported HTTP method: #{http_method.inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Retry on network errors (status_code == 0) and 5xx. Never on 4xx — the
|
|
77
|
+
# server told us the request is bad; retrying won't help.
|
|
78
|
+
def retriable?(result)
|
|
79
|
+
result.status_code.zero? || (500..599).cover?(result.status_code)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def truncate(text, max = 300)
|
|
83
|
+
return "" if text.nil?
|
|
84
|
+
|
|
85
|
+
text.length > max ? "#{text[0, max]}…" : text
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def log(level, message)
|
|
89
|
+
return unless @logger
|
|
90
|
+
|
|
91
|
+
@logger.public_send(level, "[SimpleConnect] #{message}")
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# Never let logging break delivery.
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Responses
|
|
5
|
+
# Wraps the `events.deliver` acknowledgement.
|
|
6
|
+
#
|
|
7
|
+
# Server returns:
|
|
8
|
+
# 202 on a new event → { status, log_id, event_id }
|
|
9
|
+
# 200 on an idempotent replay → { status, log_id, event_id, duplicate: true }
|
|
10
|
+
# Either code is a success; consumers check `#duplicate?` to distinguish.
|
|
11
|
+
# If the request was signed with a recently-rotated previous secret, the
|
|
12
|
+
# server adds `"used_previous_secret": true` at the top level — a hint
|
|
13
|
+
# to rotate credentials before the grace window ends.
|
|
14
|
+
class DeliverResponse
|
|
15
|
+
attr_reader :status, :log_id, :event_id
|
|
16
|
+
|
|
17
|
+
def initialize(json)
|
|
18
|
+
@json = json.is_a?(Hash) ? json : {}
|
|
19
|
+
@status = @json["status"]
|
|
20
|
+
@log_id = @json["log_id"]
|
|
21
|
+
@event_id = @json["event_id"]
|
|
22
|
+
@duplicate = @json["duplicate"] == true
|
|
23
|
+
@used_previous_secret = @json["used_previous_secret"] == true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def duplicate?
|
|
27
|
+
@duplicate
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def used_previous_secret?
|
|
31
|
+
@used_previous_secret
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
@json.dup
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Responses
|
|
5
|
+
# Wraps the JSON error body returned on 4xx / 5xx responses. Server error
|
|
6
|
+
# envelopes today are uniform (`{"error": "..."}` possibly with
|
|
7
|
+
# `{"success": false}`). If the server grows richer error shapes later,
|
|
8
|
+
# this class can be extended in place without adding per-endpoint
|
|
9
|
+
# error subclasses.
|
|
10
|
+
#
|
|
11
|
+
# Construction is defensive: a non-Hash argument, nil, or a Hash missing
|
|
12
|
+
# the "error" key all produce `message == ""` — never raises. Callers
|
|
13
|
+
# always have `result.error` (the short HTTP-level string) as a fallback.
|
|
14
|
+
class ErrorResponse
|
|
15
|
+
attr_reader :message
|
|
16
|
+
|
|
17
|
+
def initialize(json)
|
|
18
|
+
@json = json.is_a?(Hash) ? json : {}
|
|
19
|
+
@message = @json["error"].to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
@json.dup
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Responses
|
|
5
|
+
# Wraps the `events.detail` success payload. Holds the event-log row
|
|
6
|
+
# and, optionally, a nested `MessageResponse` if a WhatsApp message is
|
|
7
|
+
# linked to the event.
|
|
8
|
+
class EventResponse
|
|
9
|
+
attr_reader :event_id, :event_key, :status, :occurred_at, :created_at,
|
|
10
|
+
:updated_at, :error_text, :payload, :message
|
|
11
|
+
|
|
12
|
+
def initialize(json)
|
|
13
|
+
@json = json.is_a?(Hash) ? json : {}
|
|
14
|
+
event_log = @json["event_log"].is_a?(Hash) ? @json["event_log"] : {}
|
|
15
|
+
@event_id = event_log["event_id"]
|
|
16
|
+
@event_key = event_log["event_key"]
|
|
17
|
+
@status = event_log["status"]
|
|
18
|
+
@occurred_at = parse_time(event_log["occurred_at"])
|
|
19
|
+
@created_at = parse_time(event_log["created_at"])
|
|
20
|
+
@updated_at = parse_time(event_log["updated_at"])
|
|
21
|
+
@error_text = event_log["error_text"]
|
|
22
|
+
@used_previous_secret = event_log["used_previous_secret"] == true
|
|
23
|
+
@payload = event_log["payload"].is_a?(Hash) ? event_log["payload"] : {}
|
|
24
|
+
@message = @json["message"] ? MessageResponse.new(@json["message"]) : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dispatched?
|
|
28
|
+
@status == "dispatched"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def failed?
|
|
32
|
+
@status == "failed"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def skipped?
|
|
36
|
+
@status.to_s.start_with?("skipped")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def message?
|
|
40
|
+
!@message.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def used_previous_secret?
|
|
44
|
+
@used_previous_secret
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
@json.dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_time(value)
|
|
54
|
+
return nil if value.nil? || value.to_s.empty?
|
|
55
|
+
|
|
56
|
+
Time.parse(value.to_s)
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Responses
|
|
5
|
+
# Wraps a `message` block — returned nested inside an `EventResponse` or,
|
|
6
|
+
# when we add future endpoints that return message payloads, as a top-
|
|
7
|
+
# level response. Discriminated on the `event` field:
|
|
8
|
+
# "message.status" → status_callback? (delivered / read / failed)
|
|
9
|
+
# "message.incoming" → incoming? (user-initiated inbound msg)
|
|
10
|
+
class MessageResponse
|
|
11
|
+
EVENT_STATUS = "message.status"
|
|
12
|
+
EVENT_INCOMING = "message.incoming"
|
|
13
|
+
|
|
14
|
+
attr_reader :event, :data
|
|
15
|
+
|
|
16
|
+
def initialize(json)
|
|
17
|
+
@json = json.is_a?(Hash) ? json : {}
|
|
18
|
+
@event = @json["event"]
|
|
19
|
+
@data = @json["data"].is_a?(Hash) ? @json["data"] : {}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def status_callback?
|
|
23
|
+
@event == EVENT_STATUS
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def incoming?
|
|
27
|
+
@event == EVENT_INCOMING
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Common fields pulled from `data` for convenience. Exact shape mirrors
|
|
31
|
+
# the outbound status callback — see SimpleWaConnect's status-callback
|
|
32
|
+
# docs for the full field list. Use `#data` or `#to_h` for anything
|
|
33
|
+
# not surfaced here.
|
|
34
|
+
def message_id
|
|
35
|
+
@data["message_id"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def status
|
|
39
|
+
@data["status"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def timestamp
|
|
43
|
+
parse_time(@data["timestamp"])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def recipient
|
|
47
|
+
@data["recipient"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
@json.dup
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def parse_time(value)
|
|
57
|
+
return nil if value.nil? || value.to_s.empty?
|
|
58
|
+
|
|
59
|
+
Time.parse(value.to_s)
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
module Responses
|
|
5
|
+
# Wraps the `integrations.verify` success payload. Exposes the integration
|
|
6
|
+
# metadata (provider, status) plus an array of `EventFlow` objects — one
|
|
7
|
+
# per event key the integration supports, with its enabled/attention state
|
|
8
|
+
# and (optional) linked template.
|
|
9
|
+
class VerifyResponse
|
|
10
|
+
attr_reader :provider, :status, :event_flows
|
|
11
|
+
|
|
12
|
+
def initialize(json)
|
|
13
|
+
@json = json.is_a?(Hash) ? json : {}
|
|
14
|
+
integration = @json["integration"].is_a?(Hash) ? @json["integration"] : {}
|
|
15
|
+
@provider = integration["provider"]
|
|
16
|
+
@status = integration["status"]
|
|
17
|
+
@event_flows = Array(@json["event_flows"]).map { |ef| EventFlow.new(ef) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def connected?
|
|
21
|
+
@status == "connected"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def event_flow(event_key)
|
|
25
|
+
key = event_key.to_s
|
|
26
|
+
@event_flows.find { |f| f.event_key == key }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
@json.dup
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Per-event-key flow state. Mirrors one element of the server's
|
|
34
|
+
# `event_flows` array.
|
|
35
|
+
class EventFlow
|
|
36
|
+
attr_reader :event_key, :state, :template
|
|
37
|
+
|
|
38
|
+
def initialize(json)
|
|
39
|
+
json = {} unless json.is_a?(Hash)
|
|
40
|
+
@event_key = json["event_key"]
|
|
41
|
+
@state = json["state"]
|
|
42
|
+
@enabled = json["enabled"] == true
|
|
43
|
+
@needs_attention = json["needs_attention"] == true
|
|
44
|
+
@template = json["template"] ? Template.new(json["template"]) : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enabled?
|
|
48
|
+
@enabled
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def needs_attention?
|
|
52
|
+
@needs_attention
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def configured?
|
|
56
|
+
!@template.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The template linked to this event flow (nil if none assigned).
|
|
60
|
+
class Template
|
|
61
|
+
attr_reader :name, :language, :status
|
|
62
|
+
|
|
63
|
+
def initialize(json)
|
|
64
|
+
json = {} unless json.is_a?(Hash)
|
|
65
|
+
@name = json["name"]
|
|
66
|
+
@language = json["language"]
|
|
67
|
+
@status = json["status"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def approved?
|
|
71
|
+
@status == "approved"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
# Envelope returned from every resource method. Immutable value type
|
|
5
|
+
# (`Data.define`) — consumers cannot mutate it post-construction.
|
|
6
|
+
#
|
|
7
|
+
# Holds the HTTP-level outcome plus the typed response object (when the
|
|
8
|
+
# server body was parseable).
|
|
9
|
+
#
|
|
10
|
+
# `#data` is polymorphic by outcome:
|
|
11
|
+
# - 2xx + valid JSON → endpoint-specific success class (DeliverResponse, etc.)
|
|
12
|
+
# - 4xx/5xx + valid JSON → ErrorResponse
|
|
13
|
+
# - anything else (non-JSON body, network error) → nil
|
|
14
|
+
#
|
|
15
|
+
# Consumers always check `#success?` first, then read `#data` or fall back
|
|
16
|
+
# to `#error` + `#response_body`.
|
|
17
|
+
Result = Data.define(
|
|
18
|
+
:success, :status_code, :response_body, :error, :attempts, :data
|
|
19
|
+
) do
|
|
20
|
+
# Defaults so internal construction sites don't have to pass every field.
|
|
21
|
+
def initialize(success:, status_code: 0, response_body: nil, error: nil, attempts: 1, data: nil)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def success?
|
|
26
|
+
success
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def failed?
|
|
30
|
+
!success
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns a new Result with `data` replaced. Used by ResponseAttachment
|
|
34
|
+
# (Data instances are immutable; #with from Data.define creates a copy).
|
|
35
|
+
def with_data(new_data)
|
|
36
|
+
with(data: new_data)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimpleConnect
|
|
4
|
+
# Library-level retry policy for transient transport failures (5xx, network
|
|
5
|
+
# errors). Lives next to `Request`; `Client.new(max_attempts: N)` is the
|
|
6
|
+
# ergonomic knob, and power users can pass a custom instance via
|
|
7
|
+
# `retryable:` for exponential backoff / jitter / caps.
|
|
8
|
+
#
|
|
9
|
+
# Use `max_attempts: 1` to disable retries entirely — appropriate when
|
|
10
|
+
# `events.deliver` is wrapped in a job queue (Sidekiq, ActiveJob) that has
|
|
11
|
+
# its own retry layer. Avoids double-retry math (1 job retry × 3 lib
|
|
12
|
+
# retries = 75 HTTP calls per logical failure).
|
|
13
|
+
class Retryable
|
|
14
|
+
DEFAULT_MAX_ATTEMPTS = 3
|
|
15
|
+
|
|
16
|
+
# @param max_attempts [Integer] total attempts (1 = no retries).
|
|
17
|
+
# @param delay [#call] lambda/proc taking the attempt number (1-indexed),
|
|
18
|
+
# returning seconds to sleep before the next attempt. Default is
|
|
19
|
+
# linear: 1s, 2s.
|
|
20
|
+
# @param sleep [#call] sleep function. Injectable for specs so the suite
|
|
21
|
+
# doesn't actually wait.
|
|
22
|
+
def initialize(max_attempts: DEFAULT_MAX_ATTEMPTS, delay: ->(n) { n.to_f }, sleep: Kernel.method(:sleep))
|
|
23
|
+
raise ArgumentError, "max_attempts must be >= 1" if max_attempts < 1
|
|
24
|
+
|
|
25
|
+
@max_attempts = max_attempts
|
|
26
|
+
@delay = delay
|
|
27
|
+
@sleep = sleep
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Call the block up to `max_attempts` times. The block receives the
|
|
31
|
+
# 1-indexed attempt number and must return an object responding to
|
|
32
|
+
# `#success?`. `retriable_if` decides whether a failed result is
|
|
33
|
+
# retryable (5xx / network → yes; 4xx → no).
|
|
34
|
+
def call(retriable_if:)
|
|
35
|
+
attempt = 0
|
|
36
|
+
loop do
|
|
37
|
+
attempt += 1
|
|
38
|
+
result = yield(attempt)
|
|
39
|
+
return result if result.success? || !retriable_if.call(result) || attempt >= @max_attempts
|
|
40
|
+
|
|
41
|
+
@sleep.call(@delay.call(attempt))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
require_relative "simple_connect/version"
|
|
11
|
+
require_relative "simple_connect/errors"
|
|
12
|
+
require_relative "simple_connect/responses/error_response"
|
|
13
|
+
require_relative "simple_connect/responses/message_response"
|
|
14
|
+
require_relative "simple_connect/responses/event_response"
|
|
15
|
+
require_relative "simple_connect/responses/verify_response"
|
|
16
|
+
require_relative "simple_connect/responses/deliver_response"
|
|
17
|
+
require_relative "simple_connect/result"
|
|
18
|
+
require_relative "simple_connect/retryable"
|
|
19
|
+
require_relative "simple_connect/headers"
|
|
20
|
+
require_relative "simple_connect/request"
|
|
21
|
+
require_relative "simple_connect/api/response_attachment"
|
|
22
|
+
require_relative "simple_connect/api/events"
|
|
23
|
+
require_relative "simple_connect/api/integrations"
|
|
24
|
+
require_relative "simple_connect/client"
|
|
25
|
+
|
|
26
|
+
# Top-level namespace. See SimpleConnect::Client for usage.
|
|
27
|
+
module SimpleConnect
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: simple_connect-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ramkrishan Patidar
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rake
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '13.2'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '13.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.13'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.13'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rubocop
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.66'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.66'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: webmock
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.24'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.24'
|
|
69
|
+
description: |
|
|
70
|
+
SimpleConnect::Client is a dependency-free (stdlib-only) Ruby client for
|
|
71
|
+
the SimpleWaConnect integration endpoints. Ships domain events (POST),
|
|
72
|
+
fetches event details (GET), and verifies integration health — all signed
|
|
73
|
+
with HMAC-SHA256, with built-in retries on 5xx and network errors.
|
|
74
|
+
email:
|
|
75
|
+
- ram@gemsessence.com
|
|
76
|
+
executables: []
|
|
77
|
+
extensions: []
|
|
78
|
+
extra_rdoc_files: []
|
|
79
|
+
files:
|
|
80
|
+
- CHANGELOG.md
|
|
81
|
+
- LICENSE.txt
|
|
82
|
+
- README.md
|
|
83
|
+
- lib/simple_connect.rb
|
|
84
|
+
- lib/simple_connect/api/events.rb
|
|
85
|
+
- lib/simple_connect/api/integrations.rb
|
|
86
|
+
- lib/simple_connect/api/response_attachment.rb
|
|
87
|
+
- lib/simple_connect/client.rb
|
|
88
|
+
- lib/simple_connect/errors.rb
|
|
89
|
+
- lib/simple_connect/headers.rb
|
|
90
|
+
- lib/simple_connect/request.rb
|
|
91
|
+
- lib/simple_connect/responses/deliver_response.rb
|
|
92
|
+
- lib/simple_connect/responses/error_response.rb
|
|
93
|
+
- lib/simple_connect/responses/event_response.rb
|
|
94
|
+
- lib/simple_connect/responses/message_response.rb
|
|
95
|
+
- lib/simple_connect/responses/verify_response.rb
|
|
96
|
+
- lib/simple_connect/result.rb
|
|
97
|
+
- lib/simple_connect/retryable.rb
|
|
98
|
+
- lib/simple_connect/version.rb
|
|
99
|
+
homepage: https://github.com/GemsEssence/SimpleWaConnect/tree/main/gems/simple_connect-client
|
|
100
|
+
licenses:
|
|
101
|
+
- MIT
|
|
102
|
+
metadata:
|
|
103
|
+
homepage_uri: https://github.com/GemsEssence/SimpleWaConnect/tree/main/gems/simple_connect-client
|
|
104
|
+
source_code_uri: https://github.com/GemsEssence/SimpleWaConnect/tree/main/gems/simple_connect-client
|
|
105
|
+
changelog_uri: https://github.com/GemsEssence/SimpleWaConnect/blob/main/gems/simple_connect-client/CHANGELOG.md
|
|
106
|
+
bug_tracker_uri: https://github.com/GemsEssence/SimpleWaConnect/issues
|
|
107
|
+
rubygems_mfa_required: 'true'
|
|
108
|
+
post_install_message:
|
|
109
|
+
rdoc_options: []
|
|
110
|
+
require_paths:
|
|
111
|
+
- lib
|
|
112
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: 3.2.0
|
|
117
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '0'
|
|
122
|
+
requirements: []
|
|
123
|
+
rubygems_version: 3.4.10
|
|
124
|
+
signing_key:
|
|
125
|
+
specification_version: 4
|
|
126
|
+
summary: Ruby client for SimpleWaConnect integration endpoints.
|
|
127
|
+
test_files: []
|