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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleConnect
4
+ VERSION = "0.1.0"
5
+ 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: []