walinko 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 +43 -0
- data/README.md +136 -0
- data/lib/walinko/configuration.rb +41 -0
- data/lib/walinko/errors.rb +141 -0
- data/lib/walinko/http_client.rb +229 -0
- data/lib/walinko/messages.rb +177 -0
- data/lib/walinko/result.rb +116 -0
- data/lib/walinko/version.rb +5 -0
- data/lib/walinko.rb +52 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fab8530917c42b499678f18a29388a6a022aa95db4244b52aab4521f86769103
|
|
4
|
+
data.tar.gz: 7229de0b6795beb1cad9e3f3ce3194e48a008029c0b0ea085b035a7df8bc0540
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9632be9a36b8603403f9bb32007856f0524a6d107ad917103f6e7b3303eebabbd131a49a1269664be6a5a49bcb4c416b9d6cd47c02768dbc35ec0344f6b730dd
|
|
7
|
+
data.tar.gz: 5f86b31f8b0e9d1aec9fafc54e1d14db11c3abb23bc64e8424415b221f75741b5a9450d2fe726ca773344bddf8993e130a5626b2a9e867f45bbb1a99fef9d0a6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog — walinko (Ruby)
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-05-01
|
|
8
|
+
|
|
9
|
+
First publishable release. Targets Walinko public API
|
|
10
|
+
`POST /api/v1/public/messages` and `GET /api/v1/public/messages/:tracking_id`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Walinko::Client.new(api_key:, base_url:, timeout:, open_timeout:,
|
|
14
|
+
max_retries:, logger:)`.
|
|
15
|
+
- `client.messages.send` (sync) returning `Walinko::SyncResult`.
|
|
16
|
+
- `client.messages.enqueue` (async) returning `Walinko::AsyncJob`.
|
|
17
|
+
- `client.messages.fetch(tracking_id)` returning `Walinko::MessageStatus`.
|
|
18
|
+
- `client.messages.wait_until_done(tracking_id, timeout:, interval:)` —
|
|
19
|
+
polls until the delivery reaches a terminal state, raises
|
|
20
|
+
`Walinko::TimeoutError` if it doesn't.
|
|
21
|
+
- Typed exception hierarchy (`Walinko::Error` →
|
|
22
|
+
`Walinko::ApiError` / `Walinko::ConnectionError`), with specialized
|
|
23
|
+
classes for every documented `error_code` (`AuthenticationError`,
|
|
24
|
+
`BadRequestError`, `ForbiddenError`, `TenantSuspendedError`,
|
|
25
|
+
`QuotaExceededError`, `NotFoundError`, `ConflictError`,
|
|
26
|
+
`DeviceDisconnectedError`, `IdempotencyConflictError`,
|
|
27
|
+
`ValidationError`, `RateLimitError`, `ServerError`,
|
|
28
|
+
`TimeoutError`).
|
|
29
|
+
- Automatic `Idempotency-Key` generation for `send` / `enqueue` (use
|
|
30
|
+
`idempotency_key:` to override).
|
|
31
|
+
- Idempotent retry policy: network errors, 429 (honouring `Retry-After`),
|
|
32
|
+
and 5xx are retried up to `max_retries` with exponential backoff +
|
|
33
|
+
jitter. The same `Idempotency-Key` is reused on every retry.
|
|
34
|
+
- `client.last_rate_limit` and `client.last_request_id` reflect the
|
|
35
|
+
most recent response's `X-RateLimit-*` and `X-Request-Id` headers.
|
|
36
|
+
- `Idempotent-Replayed: true` is surfaced on `SyncResult#idempotent_replayed`
|
|
37
|
+
/ `AsyncJob#idempotent_replayed`.
|
|
38
|
+
|
|
39
|
+
### Internal
|
|
40
|
+
- 100% stdlib transport — no Faraday / HTTPX dependency.
|
|
41
|
+
- Ruby 3.1+ required.
|
|
42
|
+
- 66 RSpec examples covering every documented error code, retry path,
|
|
43
|
+
and idempotency edge case.
|
data/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# walinko (Ruby)
|
|
2
|
+
|
|
3
|
+
Official Ruby client for the [Walinko](https://walinko.com) public API.
|
|
4
|
+
|
|
5
|
+
Send transactional WhatsApp messages from your Ruby app with idempotent
|
|
6
|
+
retries, structured errors, and a tiny dependency footprint (Ruby stdlib
|
|
7
|
+
only).
|
|
8
|
+
|
|
9
|
+
* Ruby 3.1+
|
|
10
|
+
* Zero runtime dependencies (only `net/http` + `json` from stdlib)
|
|
11
|
+
* MIT licensed
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install walinko
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
…or in a `Gemfile`:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'walinko', '~> 0.1'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require 'walinko'
|
|
29
|
+
|
|
30
|
+
client = Walinko::Client.new(
|
|
31
|
+
api_key: ENV.fetch('WALINKO_API_KEY'),
|
|
32
|
+
base_url: 'https://api.walinko.com', # optional
|
|
33
|
+
timeout: 30, # optional, seconds
|
|
34
|
+
max_retries: 2 # optional
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Sync send — blocks until the message is delivered (or 504 timeout).
|
|
38
|
+
result = client.messages.send(
|
|
39
|
+
device_id: 1,
|
|
40
|
+
template_id: 12,
|
|
41
|
+
variant_index: 0, # optional, nil = primary
|
|
42
|
+
phone: '+8801617738431',
|
|
43
|
+
variables: { name: 'Kazi', dist: 'Dhaka' }
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
puts result.tracking_id # tx_...
|
|
47
|
+
puts result.wa_message_id # 3EB0...
|
|
48
|
+
puts result.status # "sent"
|
|
49
|
+
|
|
50
|
+
# Async enqueue + poll.
|
|
51
|
+
job = client.messages.enqueue(
|
|
52
|
+
device_id: 1, template_id: 12,
|
|
53
|
+
phone: '+8801617738431',
|
|
54
|
+
variables: { name: 'Kazi', dist: 'Dhaka' }
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
final = client.messages.wait_until_done(job.tracking_id, timeout: 60)
|
|
58
|
+
puts final.status # "sent" | "failed"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Looking up a delivery
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
status = client.messages.fetch('tx_767fd2faca0f4037b2a2bbcb91e5735f')
|
|
65
|
+
status.sent? # true / false
|
|
66
|
+
status.error_code # nil if sent, e.g. "phone_not_on_whatsapp" if failed
|
|
67
|
+
status.wa_message_id # WhatsApp's id, set on success
|
|
68
|
+
status.created_at # Time
|
|
69
|
+
status.sent_at # Time, nil while pending
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Errors
|
|
73
|
+
|
|
74
|
+
Every error is a subclass of `Walinko::Error`. See
|
|
75
|
+
[`docs/error-codes.md`](../../docs/error-codes.md) for the full mapping.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
begin
|
|
79
|
+
client.messages.send(...)
|
|
80
|
+
rescue Walinko::RateLimitError => e
|
|
81
|
+
sleep e.retry_after
|
|
82
|
+
retry
|
|
83
|
+
rescue Walinko::ValidationError => e
|
|
84
|
+
warn e.fields # { phone: ['must match pattern …'] }
|
|
85
|
+
rescue Walinko::DeviceDisconnectedError
|
|
86
|
+
# tell the user to reconnect their device
|
|
87
|
+
rescue Walinko::Error => e
|
|
88
|
+
Rails.logger.error("Walinko send failed: #{e.message}")
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Idempotency
|
|
93
|
+
|
|
94
|
+
The SDK auto-generates a UUID `Idempotency-Key` for every `send` / `enqueue`
|
|
95
|
+
call so retries are safe end-to-end. Pass `idempotency_key:` to set your own
|
|
96
|
+
(e.g. tying a send to your domain object).
|
|
97
|
+
|
|
98
|
+
## Retries
|
|
99
|
+
|
|
100
|
+
The SDK auto-retries idempotently on:
|
|
101
|
+
|
|
102
|
+
| Trigger | Behaviour |
|
|
103
|
+
| ----------------------- | ----------------------------------------------- |
|
|
104
|
+
| Network errors | Exponential backoff with jitter |
|
|
105
|
+
| HTTP 429 | Honours `Retry-After` (capped at 60s) |
|
|
106
|
+
| HTTP 500/502/503/504 | Exponential backoff with jitter |
|
|
107
|
+
|
|
108
|
+
`max_retries` (default 2) controls how many additional attempts are made.
|
|
109
|
+
4xx responses (other than 429) are surfaced immediately — the request is
|
|
110
|
+
malformed or the server has rejected it on application grounds, and no
|
|
111
|
+
amount of retrying will help.
|
|
112
|
+
|
|
113
|
+
## Rate limits
|
|
114
|
+
|
|
115
|
+
The server enforces 30 req/min/key (sliding window). The SDK exposes the
|
|
116
|
+
latest known window state via:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
client.last_rate_limit
|
|
120
|
+
# => #<Walinko::RateLimitSnapshot limit=30 remaining=29>
|
|
121
|
+
client.last_request_id
|
|
122
|
+
# => "req_4f2c..." (handy for support tickets)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
cd sdks/ruby
|
|
129
|
+
bundle install
|
|
130
|
+
bundle exec rspec
|
|
131
|
+
bundle exec rubocop
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
[MIT](../../LICENSE)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Walinko
|
|
4
|
+
# Immutable configuration captured by `Walinko::Client.new`. Validates
|
|
5
|
+
# required fields and applies defaults.
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_BASE_URL = 'https://api.walinko.com'
|
|
8
|
+
DEFAULT_TIMEOUT = 30
|
|
9
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
10
|
+
DEFAULT_MAX_RETRIES = 2
|
|
11
|
+
|
|
12
|
+
attr_reader :api_key, :base_url, :timeout, :open_timeout,
|
|
13
|
+
:max_retries, :logger
|
|
14
|
+
|
|
15
|
+
# @param api_key [String] required, e.g. "walk_live_<keyId>.<secret>"
|
|
16
|
+
# @param base_url [String] defaults to "https://api.walinko.com"
|
|
17
|
+
# @param timeout [Integer] per-request read timeout in seconds
|
|
18
|
+
# @param open_timeout [Integer] TCP/TLS connection-setup timeout in seconds
|
|
19
|
+
# @param max_retries [Integer] retries on idempotent failures (network, 429, 5xx)
|
|
20
|
+
# @param logger [#info,#warn,#error] optional structured logger
|
|
21
|
+
def initialize(api_key:,
|
|
22
|
+
base_url: DEFAULT_BASE_URL,
|
|
23
|
+
timeout: DEFAULT_TIMEOUT,
|
|
24
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
25
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
26
|
+
logger: nil)
|
|
27
|
+
raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.to_s.empty?
|
|
28
|
+
raise ArgumentError, 'base_url is required' if base_url.nil? || base_url.to_s.empty?
|
|
29
|
+
raise ArgumentError, 'timeout must be > 0' if timeout.to_i <= 0
|
|
30
|
+
raise ArgumentError, 'open_timeout must be > 0' if open_timeout.to_i <= 0
|
|
31
|
+
raise ArgumentError, 'max_retries must be >= 0' if max_retries.to_i.negative?
|
|
32
|
+
|
|
33
|
+
@api_key = api_key.to_s
|
|
34
|
+
@base_url = base_url.to_s.sub(%r{/+$}, '')
|
|
35
|
+
@timeout = timeout.to_i
|
|
36
|
+
@open_timeout = open_timeout.to_i
|
|
37
|
+
@max_retries = max_retries.to_i
|
|
38
|
+
@logger = logger
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Walinko
|
|
4
|
+
# Base class for every exception raised by the SDK. Catch this to handle
|
|
5
|
+
# anything Walinko throws.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when we couldn't reach the API at all (DNS failure, connection
|
|
9
|
+
# refused, TLS handshake failed, socket reset, etc.). These are retried
|
|
10
|
+
# automatically up to `max_retries`; if you see one, the retry budget was
|
|
11
|
+
# exhausted.
|
|
12
|
+
class ConnectionError < Error
|
|
13
|
+
attr_reader :cause_class
|
|
14
|
+
|
|
15
|
+
def initialize(message, cause_class: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@cause_class = cause_class
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Base class for every error response returned by the API. Carries the
|
|
22
|
+
# full diagnostic payload — HTTP status, server-side `error_code`, the
|
|
23
|
+
# `X-Request-Id` (handy for support tickets), and the optional `details`
|
|
24
|
+
# blob.
|
|
25
|
+
class ApiError < Error
|
|
26
|
+
attr_reader :http_status, :error_code, :request_id, :details, :body
|
|
27
|
+
|
|
28
|
+
def initialize(message, http_status:, error_code: nil, request_id: nil,
|
|
29
|
+
details: nil, body: nil)
|
|
30
|
+
super(message)
|
|
31
|
+
@http_status = http_status
|
|
32
|
+
@error_code = error_code
|
|
33
|
+
@request_id = request_id
|
|
34
|
+
@details = details || {}
|
|
35
|
+
@body = body
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect
|
|
39
|
+
parts = [self.class.name, "status=#{http_status}"]
|
|
40
|
+
parts << "code=#{error_code}" if error_code
|
|
41
|
+
parts << "request_id=#{request_id}" if request_id
|
|
42
|
+
"#<#{parts.join(' ')} message=#{message.inspect}>"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 401 — missing / malformed / expired / revoked / unknown API key.
|
|
47
|
+
# All five are returned with the same generic message on purpose.
|
|
48
|
+
class AuthenticationError < ApiError; end
|
|
49
|
+
|
|
50
|
+
# 400 — malformed request, unknown body shape, or `variant_index` out of
|
|
51
|
+
# range. Validation errors are raised as `ValidationError` instead.
|
|
52
|
+
class BadRequestError < ApiError; end
|
|
53
|
+
|
|
54
|
+
# 403 — generic forbidden. See specialized subclasses below.
|
|
55
|
+
class ForbiddenError < ApiError; end
|
|
56
|
+
|
|
57
|
+
# 403 + `error_code: tenant_suspended` — tenant account is suspended or
|
|
58
|
+
# scheduled for deletion.
|
|
59
|
+
class TenantSuspendedError < ForbiddenError; end
|
|
60
|
+
|
|
61
|
+
# 403 + `error_code: quota_exceeded` — tenant has hit its monthly /
|
|
62
|
+
# daily / balance message limit. `details[:resets_at]` (when present)
|
|
63
|
+
# tells you when the limit resets.
|
|
64
|
+
class QuotaExceededError < ForbiddenError; end
|
|
65
|
+
|
|
66
|
+
# 404 — device, template, or tracking id not found for this tenant.
|
|
67
|
+
class NotFoundError < ApiError; end
|
|
68
|
+
|
|
69
|
+
# 409 — generic conflict. See specialized subclasses below.
|
|
70
|
+
class ConflictError < ApiError; end
|
|
71
|
+
|
|
72
|
+
# 409 + `error_code: device_disconnected` — device session is not
|
|
73
|
+
# connected. Reconnect from the dashboard, then retry.
|
|
74
|
+
class DeviceDisconnectedError < ConflictError; end
|
|
75
|
+
|
|
76
|
+
# 409 + `error_code: idempotency_conflict` — `Idempotency-Key` was
|
|
77
|
+
# previously used with a *different* payload. Use a new key.
|
|
78
|
+
class IdempotencyConflictError < ConflictError; end
|
|
79
|
+
|
|
80
|
+
# 422 — semantic validation failure. For DTO validation errors (the
|
|
81
|
+
# default class-validator path), `#fields` returns a `{field => [reason,
|
|
82
|
+
# ...]}` map. For `phone_not_on_whatsapp` the map is empty and the
|
|
83
|
+
# reason is in `#message`.
|
|
84
|
+
class ValidationError < ApiError
|
|
85
|
+
# @return [Hash{String => Array<String>}]
|
|
86
|
+
def fields
|
|
87
|
+
f = details[:fields] || details['fields']
|
|
88
|
+
f.is_a?(Hash) ? f : {}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 429 — sliding-window rate limit (default 30 req/min/key) exceeded.
|
|
93
|
+
# `#retry_after` is the recommended sleep in seconds (parsed from
|
|
94
|
+
# `Retry-After` header).
|
|
95
|
+
class RateLimitError < ApiError
|
|
96
|
+
attr_reader :retry_after
|
|
97
|
+
|
|
98
|
+
def initialize(message, retry_after: nil, **kwargs)
|
|
99
|
+
super(message, **kwargs)
|
|
100
|
+
@retry_after = retry_after
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 5xx — server-side failure. Outcome of the underlying send may or may
|
|
105
|
+
# not have happened; replay with the same `Idempotency-Key` is safe.
|
|
106
|
+
class ServerError < ApiError; end
|
|
107
|
+
|
|
108
|
+
# 504 — WhatsApp send did not complete within the server's 15s window.
|
|
109
|
+
# Outcome unknown; replay with the same `Idempotency-Key` is safe.
|
|
110
|
+
class TimeoutError < ApiError; end
|
|
111
|
+
|
|
112
|
+
# Internal: maps an HTTP status + server `error_code` to one of the
|
|
113
|
+
# typed exception classes above. Public so users can introspect the
|
|
114
|
+
# mapping in tests if they want.
|
|
115
|
+
module ErrorMapping
|
|
116
|
+
BY_HTTP_STATUS = {
|
|
117
|
+
400 => BadRequestError,
|
|
118
|
+
401 => AuthenticationError,
|
|
119
|
+
403 => ForbiddenError,
|
|
120
|
+
404 => NotFoundError,
|
|
121
|
+
409 => ConflictError,
|
|
122
|
+
422 => ValidationError,
|
|
123
|
+
429 => RateLimitError,
|
|
124
|
+
504 => TimeoutError
|
|
125
|
+
}.freeze
|
|
126
|
+
|
|
127
|
+
BY_ERROR_CODE = {
|
|
128
|
+
'tenant_suspended' => TenantSuspendedError,
|
|
129
|
+
'quota_exceeded' => QuotaExceededError,
|
|
130
|
+
'device_disconnected' => DeviceDisconnectedError,
|
|
131
|
+
'idempotency_conflict' => IdempotencyConflictError
|
|
132
|
+
}.freeze
|
|
133
|
+
|
|
134
|
+
# @return [Class<ApiError>]
|
|
135
|
+
def self.for(http_status:, error_code: nil)
|
|
136
|
+
BY_ERROR_CODE[error_code.to_s] ||
|
|
137
|
+
BY_HTTP_STATUS[http_status] ||
|
|
138
|
+
(http_status.between?(500, 599) ? ServerError : ApiError)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
require_relative 'errors'
|
|
8
|
+
require_relative 'result'
|
|
9
|
+
|
|
10
|
+
module Walinko
|
|
11
|
+
# Internal HTTP transport. Owns the wire format (JSON in/out, header
|
|
12
|
+
# naming), retry policy, and error mapping. The `Messages` resource is
|
|
13
|
+
# the only consumer.
|
|
14
|
+
#
|
|
15
|
+
# Public methods are documented but treat anything outside `request` as
|
|
16
|
+
# internal — minor versions may refactor.
|
|
17
|
+
class HttpClient
|
|
18
|
+
Response = Struct.new(
|
|
19
|
+
:status,
|
|
20
|
+
:body,
|
|
21
|
+
:request_id,
|
|
22
|
+
:rate_limit,
|
|
23
|
+
:idempotent_replayed,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Statuses we consider transient and retry automatically.
|
|
28
|
+
RETRYABLE_HTTP_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
29
|
+
|
|
30
|
+
# Backoff curve (seconds). Index = attempt number (0 = first retry).
|
|
31
|
+
# Caps at the last entry.
|
|
32
|
+
BACKOFF_SECONDS = [0.25, 0.75, 1.75, 3.75].freeze
|
|
33
|
+
|
|
34
|
+
# Cap on `Retry-After` honoring — don't let the server make us sleep
|
|
35
|
+
# forever.
|
|
36
|
+
MAX_RETRY_AFTER_SECONDS = 60
|
|
37
|
+
|
|
38
|
+
attr_reader :last_request_id, :last_rate_limit
|
|
39
|
+
|
|
40
|
+
# @param config [Walinko::Configuration]
|
|
41
|
+
def initialize(config)
|
|
42
|
+
@config = config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Issues an HTTP request with the configured retry policy.
|
|
46
|
+
#
|
|
47
|
+
# @param method [Symbol] :get / :post
|
|
48
|
+
# @param path [String] e.g. "/api/v1/public/messages"
|
|
49
|
+
# @param body [Hash, nil] JSON-encoded into the request body
|
|
50
|
+
# @param headers [Hash{String => String}] extra headers to merge
|
|
51
|
+
# @return [Response]
|
|
52
|
+
# @raise [Walinko::ApiError, Walinko::ConnectionError]
|
|
53
|
+
def request(method:, path:, body: nil, headers: {})
|
|
54
|
+
attempt = 0
|
|
55
|
+
|
|
56
|
+
loop do
|
|
57
|
+
result = perform(method: method, path: path, body: body, headers: headers)
|
|
58
|
+
return result if result.is_a?(Response)
|
|
59
|
+
|
|
60
|
+
# `result` is a retryable error (Exception subclass) — decide
|
|
61
|
+
# whether to retry.
|
|
62
|
+
attempt += 1
|
|
63
|
+
raise result if attempt > @config.max_retries
|
|
64
|
+
|
|
65
|
+
sleep_for = sleep_seconds(result, attempt)
|
|
66
|
+
log(:warn,
|
|
67
|
+
"retrying after #{sleep_for}s (attempt #{attempt}/#{@config.max_retries}): #{result.message}")
|
|
68
|
+
sleep(sleep_for) if sleep_for.positive?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Returns either a `Response` (success) or an Exception instance
|
|
75
|
+
# representing a retryable failure. Non-retryable failures are
|
|
76
|
+
# raised directly.
|
|
77
|
+
def perform(method:, path:, body:, headers:)
|
|
78
|
+
uri = URI.join("#{@config.base_url}/", path.sub(%r{^/}, ''))
|
|
79
|
+
req = build_request(method, uri, body: body, headers: headers)
|
|
80
|
+
|
|
81
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
82
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
83
|
+
http.read_timeout = @config.timeout
|
|
84
|
+
http.open_timeout = @config.open_timeout
|
|
85
|
+
http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
|
|
86
|
+
|
|
87
|
+
raw = http.request(req)
|
|
88
|
+
handle_response(raw)
|
|
89
|
+
rescue *connection_error_classes => e
|
|
90
|
+
ConnectionError.new("#{e.class}: #{e.message}", cause_class: e.class)
|
|
91
|
+
rescue ConnectionError => e
|
|
92
|
+
e
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Network/timeout errors that should map to ConnectionError and be
|
|
96
|
+
# retried.
|
|
97
|
+
def connection_error_classes
|
|
98
|
+
[
|
|
99
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
|
|
100
|
+
Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
|
|
101
|
+
SocketError, IOError,
|
|
102
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
103
|
+
OpenSSL::SSL::SSLError,
|
|
104
|
+
EOFError
|
|
105
|
+
]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_request(method, uri, body:, headers:)
|
|
109
|
+
request_class =
|
|
110
|
+
case method
|
|
111
|
+
when :get then Net::HTTP::Get
|
|
112
|
+
when :post then Net::HTTP::Post
|
|
113
|
+
else raise ArgumentError, "Unsupported method #{method.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
req = request_class.new(uri.request_uri)
|
|
117
|
+
req['Authorization'] = "Bearer #{@config.api_key}"
|
|
118
|
+
req['Accept'] = 'application/json'
|
|
119
|
+
headers.each { |k, v| req[k] = v unless v.nil? }
|
|
120
|
+
if body
|
|
121
|
+
req['Content-Type'] = 'application/json'
|
|
122
|
+
req.body = JSON.generate(body)
|
|
123
|
+
end
|
|
124
|
+
req
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def handle_response(raw)
|
|
128
|
+
status = raw.code.to_i
|
|
129
|
+
request_id = raw['X-Request-Id'] || raw['x-request-id']
|
|
130
|
+
rate_limit = parse_rate_limit(raw)
|
|
131
|
+
replayed = raw['Idempotent-Replayed'] == 'true'
|
|
132
|
+
|
|
133
|
+
@last_request_id = request_id
|
|
134
|
+
@last_rate_limit = rate_limit
|
|
135
|
+
|
|
136
|
+
parsed = safe_parse_json(raw.body)
|
|
137
|
+
|
|
138
|
+
if (200..299).cover?(status)
|
|
139
|
+
return Response.new(
|
|
140
|
+
status: status,
|
|
141
|
+
body: parsed,
|
|
142
|
+
request_id: request_id,
|
|
143
|
+
rate_limit: rate_limit,
|
|
144
|
+
idempotent_replayed: replayed
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
build_api_error(status, parsed, raw)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Builds the typed exception. For retryable statuses returns the
|
|
152
|
+
# exception (caller decides whether to retry); for non-retryable
|
|
153
|
+
# statuses raises immediately.
|
|
154
|
+
def build_api_error(status, body, raw)
|
|
155
|
+
message = (body.is_a?(Hash) && body['message']) || "HTTP #{status}"
|
|
156
|
+
error_code = body.is_a?(Hash) ? body['error_code'] : nil
|
|
157
|
+
details = body.is_a?(Hash) ? body['details'] : nil
|
|
158
|
+
request_id = raw['X-Request-Id'] || raw['x-request-id']
|
|
159
|
+
|
|
160
|
+
klass = ErrorMapping.for(http_status: status, error_code: error_code)
|
|
161
|
+
|
|
162
|
+
err =
|
|
163
|
+
if klass <= RateLimitError
|
|
164
|
+
retry_after = parse_retry_after(raw)
|
|
165
|
+
klass.new(message,
|
|
166
|
+
http_status: status, error_code: error_code,
|
|
167
|
+
request_id: request_id, details: stringify_keys(details),
|
|
168
|
+
body: body, retry_after: retry_after)
|
|
169
|
+
else
|
|
170
|
+
klass.new(message,
|
|
171
|
+
http_status: status, error_code: error_code,
|
|
172
|
+
request_id: request_id, details: stringify_keys(details),
|
|
173
|
+
body: body)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return err if RETRYABLE_HTTP_STATUSES.include?(status)
|
|
177
|
+
|
|
178
|
+
raise err
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse_rate_limit(raw)
|
|
182
|
+
limit_h = raw['X-RateLimit-Limit']
|
|
183
|
+
remaining_h = raw['X-RateLimit-Remaining']
|
|
184
|
+
return nil if limit_h.nil? && remaining_h.nil?
|
|
185
|
+
|
|
186
|
+
RateLimitSnapshot.new(
|
|
187
|
+
limit: limit_h&.to_i,
|
|
188
|
+
remaining: remaining_h&.to_i
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def parse_retry_after(raw)
|
|
193
|
+
v = raw['Retry-After']
|
|
194
|
+
return nil if v.nil?
|
|
195
|
+
|
|
196
|
+
v.to_i.positive? ? v.to_i : nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def safe_parse_json(body)
|
|
200
|
+
return nil if body.nil? || body.empty?
|
|
201
|
+
|
|
202
|
+
JSON.parse(body)
|
|
203
|
+
rescue JSON::ParserError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def stringify_keys(hash)
|
|
208
|
+
return {} unless hash.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def sleep_seconds(error, attempt)
|
|
214
|
+
if error.is_a?(RateLimitError) && error.retry_after
|
|
215
|
+
return [error.retry_after, MAX_RETRY_AFTER_SECONDS].min
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
base = BACKOFF_SECONDS[[attempt - 1, BACKOFF_SECONDS.size - 1].min]
|
|
219
|
+
base + (rand * 0.1) # tiny jitter
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def log(level, message)
|
|
223
|
+
logger = @config.logger
|
|
224
|
+
return unless logger.respond_to?(level)
|
|
225
|
+
|
|
226
|
+
logger.public_send(level, "[walinko] #{message}")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'http_client'
|
|
7
|
+
require_relative 'result'
|
|
8
|
+
|
|
9
|
+
module Walinko
|
|
10
|
+
# The `messages` resource on `Walinko::Client`. Wraps:
|
|
11
|
+
#
|
|
12
|
+
# POST /api/v1/public/messages
|
|
13
|
+
# GET /api/v1/public/messages/:tracking_id
|
|
14
|
+
class Messages
|
|
15
|
+
SEND_PATH = '/api/v1/public/messages'
|
|
16
|
+
|
|
17
|
+
# @api private
|
|
18
|
+
def initialize(http_client)
|
|
19
|
+
@http = http_client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Synchronous send: blocks until the WhatsApp gateway acknowledges
|
|
23
|
+
# delivery (or the server's 15s timeout fires).
|
|
24
|
+
#
|
|
25
|
+
# @param device_id [Integer] required
|
|
26
|
+
# @param template_id [Integer] required
|
|
27
|
+
# @param phone [String] required, E.164 (`+8801617738431`)
|
|
28
|
+
# @param variables [Hash] optional `{ name: 'value' }` map
|
|
29
|
+
# @param variant_index [Integer] optional, 0 = primary
|
|
30
|
+
# @param idempotency_key [String] optional; auto-generated otherwise
|
|
31
|
+
# @return [Walinko::SyncResult]
|
|
32
|
+
def send(device_id:, template_id:, phone:,
|
|
33
|
+
variables: nil, variant_index: nil,
|
|
34
|
+
idempotency_key: nil)
|
|
35
|
+
payload = build_payload(
|
|
36
|
+
device_id: device_id, template_id: template_id,
|
|
37
|
+
phone: phone, variables: variables,
|
|
38
|
+
variant_index: variant_index, async: false
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
response = post(payload, idempotency_key: idempotency_key)
|
|
42
|
+
data = extract_data(response.body)
|
|
43
|
+
|
|
44
|
+
SyncResult.new(
|
|
45
|
+
data: data,
|
|
46
|
+
request_id: response.request_id,
|
|
47
|
+
rate_limit: response.rate_limit,
|
|
48
|
+
idempotent_replayed: response.idempotent_replayed
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Asynchronous enqueue: server returns immediately with a tracking id;
|
|
53
|
+
# the actual WhatsApp send happens out-of-band. Poll `fetch` (or use
|
|
54
|
+
# `wait_until_done`) for the final state.
|
|
55
|
+
#
|
|
56
|
+
# @return [Walinko::AsyncJob]
|
|
57
|
+
def enqueue(device_id:, template_id:, phone:,
|
|
58
|
+
variables: nil, variant_index: nil,
|
|
59
|
+
idempotency_key: nil)
|
|
60
|
+
payload = build_payload(
|
|
61
|
+
device_id: device_id, template_id: template_id,
|
|
62
|
+
phone: phone, variables: variables,
|
|
63
|
+
variant_index: variant_index, async: true
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
response = post(payload, idempotency_key: idempotency_key)
|
|
67
|
+
data = extract_data(response.body)
|
|
68
|
+
|
|
69
|
+
AsyncJob.new(
|
|
70
|
+
data: data,
|
|
71
|
+
request_id: response.request_id,
|
|
72
|
+
rate_limit: response.rate_limit,
|
|
73
|
+
idempotent_replayed: response.idempotent_replayed
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Look up a delivery by its tracking id.
|
|
78
|
+
#
|
|
79
|
+
# @param tracking_id [String] e.g. `tx_767fd2faca0f4037b2a2bbcb91e5735f`
|
|
80
|
+
# @return [Walinko::MessageStatus]
|
|
81
|
+
def fetch(tracking_id)
|
|
82
|
+
raise ArgumentError, 'tracking_id is required' if tracking_id.nil? || tracking_id.to_s.empty?
|
|
83
|
+
|
|
84
|
+
response = @http.request(method: :get, path: "#{SEND_PATH}/#{tracking_id}")
|
|
85
|
+
data = extract_data(response.body)
|
|
86
|
+
|
|
87
|
+
MessageStatus.new(data: data, request_id: response.request_id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Poll `fetch` until the delivery reaches a terminal state (sent /
|
|
91
|
+
# failed) or the timeout expires.
|
|
92
|
+
#
|
|
93
|
+
# @param tracking_id [String]
|
|
94
|
+
# @param timeout [Integer] max seconds to wait, default 60
|
|
95
|
+
# @param interval [Numeric] seconds between polls, default 2
|
|
96
|
+
# @return [Walinko::MessageStatus]
|
|
97
|
+
# @raise [Walinko::TimeoutError] if the message is still pending
|
|
98
|
+
# when `timeout` elapses (the message is *not* failed —
|
|
99
|
+
# continue polling later if you need to)
|
|
100
|
+
def wait_until_done(tracking_id, timeout: 60, interval: 2)
|
|
101
|
+
raise ArgumentError, 'timeout must be > 0' if timeout.to_i <= 0
|
|
102
|
+
raise ArgumentError, 'interval must be > 0' if interval.to_f <= 0
|
|
103
|
+
|
|
104
|
+
deadline = monotonic_now + timeout.to_f
|
|
105
|
+
loop do
|
|
106
|
+
status = fetch(tracking_id)
|
|
107
|
+
return status if status.done?
|
|
108
|
+
|
|
109
|
+
if monotonic_now + interval.to_f >= deadline
|
|
110
|
+
raise TimeoutError.new(
|
|
111
|
+
"Timed out waiting for #{tracking_id} after #{timeout}s (still #{status.status})",
|
|
112
|
+
http_status: 504,
|
|
113
|
+
error_code: 'send_timeout',
|
|
114
|
+
request_id: status.request_id,
|
|
115
|
+
details: { 'last_status' => status.status }
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sleep(interval.to_f)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def post(payload, idempotency_key:)
|
|
126
|
+
key = idempotency_key&.to_s
|
|
127
|
+
key = generate_idempotency_key if key.nil? || key.empty?
|
|
128
|
+
|
|
129
|
+
@http.request(
|
|
130
|
+
method: :post,
|
|
131
|
+
path: SEND_PATH,
|
|
132
|
+
body: payload,
|
|
133
|
+
headers: { 'Idempotency-Key' => key }
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_payload(device_id:, template_id:, phone:,
|
|
138
|
+
variables:, variant_index:, async:)
|
|
139
|
+
raise ArgumentError, 'device_id is required' if device_id.nil?
|
|
140
|
+
raise ArgumentError, 'template_id is required' if template_id.nil?
|
|
141
|
+
raise ArgumentError, 'phone is required' if phone.nil? || phone.to_s.empty?
|
|
142
|
+
|
|
143
|
+
payload = {
|
|
144
|
+
'device_id' => Integer(device_id),
|
|
145
|
+
'template_id' => Integer(template_id),
|
|
146
|
+
'phone' => phone.to_s,
|
|
147
|
+
'async' => async
|
|
148
|
+
}
|
|
149
|
+
payload['variant_index'] = Integer(variant_index) unless variant_index.nil?
|
|
150
|
+
payload['variables'] = stringify_variables(variables) if variables
|
|
151
|
+
payload
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def stringify_variables(variables)
|
|
155
|
+
raise ArgumentError, 'variables must be a Hash' unless variables.is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
variables.each_with_object({}) do |(k, v), out|
|
|
158
|
+
out[k.to_s] = v.nil? ? '' : v.to_s
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def extract_data(body)
|
|
163
|
+
return {} unless body.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
data = body['data']
|
|
166
|
+
data.is_a?(Hash) ? data : {}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def generate_idempotency_key
|
|
170
|
+
"walinko-rb-#{SecureRandom.uuid}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def monotonic_now
|
|
174
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Walinko
|
|
6
|
+
# Returned by `client.messages.send(...)` (sync mode). Wraps the
|
|
7
|
+
# `data` block of a 200 OK response.
|
|
8
|
+
class SyncResult
|
|
9
|
+
attr_reader :tracking_id, :status, :device_id, :template_id,
|
|
10
|
+
:variant_index, :phone, :sent_at, :wa_message_id,
|
|
11
|
+
:request_id, :rate_limit, :idempotent_replayed
|
|
12
|
+
|
|
13
|
+
def initialize(data:, request_id:, rate_limit:, idempotent_replayed:)
|
|
14
|
+
@tracking_id = data['tracking_id']
|
|
15
|
+
@status = data['status']
|
|
16
|
+
@device_id = data['device_id']
|
|
17
|
+
@template_id = data['template_id']
|
|
18
|
+
@variant_index = data['variant_index']
|
|
19
|
+
@phone = data['phone']
|
|
20
|
+
@sent_at = parse_time(data['sent_at'])
|
|
21
|
+
@wa_message_id = data['wa_message_id']
|
|
22
|
+
@request_id = request_id
|
|
23
|
+
@rate_limit = rate_limit
|
|
24
|
+
@idempotent_replayed = idempotent_replayed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sent?
|
|
28
|
+
status == 'sent'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def parse_time(value)
|
|
34
|
+
return nil if value.nil? || value.to_s.empty?
|
|
35
|
+
|
|
36
|
+
Time.iso8601(value)
|
|
37
|
+
rescue ArgumentError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returned by `client.messages.enqueue(...)` (async mode). Wraps the
|
|
43
|
+
# `data` block of a 202 Accepted response.
|
|
44
|
+
class AsyncJob
|
|
45
|
+
attr_reader :tracking_id, :status, :status_url,
|
|
46
|
+
:request_id, :rate_limit, :idempotent_replayed
|
|
47
|
+
|
|
48
|
+
def initialize(data:, request_id:, rate_limit:, idempotent_replayed:)
|
|
49
|
+
@tracking_id = data['tracking_id']
|
|
50
|
+
@status = data['status']
|
|
51
|
+
@status_url = data['status_url']
|
|
52
|
+
@request_id = request_id
|
|
53
|
+
@rate_limit = rate_limit
|
|
54
|
+
@idempotent_replayed = idempotent_replayed
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def queued?
|
|
58
|
+
status == 'queued'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returned by `client.messages.fetch(tracking_id)`. Wraps the `data`
|
|
63
|
+
# block of `GET /messages/:trackingId`.
|
|
64
|
+
class MessageStatus
|
|
65
|
+
attr_reader :tracking_id, :status, :device_id, :template_id,
|
|
66
|
+
:variant_index, :phone, :wa_message_id,
|
|
67
|
+
:error_code, :error_message,
|
|
68
|
+
:sent_at, :created_at,
|
|
69
|
+
:request_id
|
|
70
|
+
|
|
71
|
+
def initialize(data:, request_id:)
|
|
72
|
+
@tracking_id = data['tracking_id']
|
|
73
|
+
@status = data['status']
|
|
74
|
+
@device_id = data['device_id']
|
|
75
|
+
@template_id = data['template_id']
|
|
76
|
+
@variant_index = data['variant_index']
|
|
77
|
+
@phone = data['phone']
|
|
78
|
+
@wa_message_id = data['wa_message_id']
|
|
79
|
+
@error_code = data['error_code']
|
|
80
|
+
@error_message = data['error_message']
|
|
81
|
+
@sent_at = parse_time(data['sent_at'])
|
|
82
|
+
@created_at = parse_time(data['created_at'])
|
|
83
|
+
@request_id = request_id
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sent?; status == 'sent'; end
|
|
87
|
+
def failed?; status == 'failed'; end
|
|
88
|
+
def pending?; %w[queued sending].include?(status); end
|
|
89
|
+
def done?; sent? || failed?; end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def parse_time(value)
|
|
94
|
+
return nil if value.nil? || value.to_s.empty?
|
|
95
|
+
|
|
96
|
+
Time.iso8601(value)
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Snapshot of the server-reported rate-limit window from the most
|
|
103
|
+
# recent response.
|
|
104
|
+
class RateLimitSnapshot
|
|
105
|
+
attr_reader :limit, :remaining
|
|
106
|
+
|
|
107
|
+
def initialize(limit:, remaining:)
|
|
108
|
+
@limit = limit
|
|
109
|
+
@remaining = remaining
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def saturated?
|
|
113
|
+
remaining.is_a?(Integer) && remaining <= 0
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/walinko.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'walinko/version'
|
|
4
|
+
require_relative 'walinko/errors'
|
|
5
|
+
require_relative 'walinko/configuration'
|
|
6
|
+
require_relative 'walinko/result'
|
|
7
|
+
require_relative 'walinko/http_client'
|
|
8
|
+
require_relative 'walinko/messages'
|
|
9
|
+
|
|
10
|
+
# Walinko Ruby SDK — server-to-server client for the Walinko public API.
|
|
11
|
+
#
|
|
12
|
+
# See the README for a quick-start; the contract is pinned in
|
|
13
|
+
# `walinko-sdk/docs/openapi.yaml`.
|
|
14
|
+
#
|
|
15
|
+
# TODO(walinko-webhooks): when the server starts emitting webhooks,
|
|
16
|
+
# `Walinko::Client#webhooks` (e.g. `client.webhooks.verify(payload, sig)`)
|
|
17
|
+
# will land here. Reserving the namespace so v1 callers don't break.
|
|
18
|
+
module Walinko
|
|
19
|
+
class Client
|
|
20
|
+
# @return [Walinko::Configuration]
|
|
21
|
+
attr_reader :config
|
|
22
|
+
|
|
23
|
+
# @return [Walinko::Messages]
|
|
24
|
+
attr_reader :messages
|
|
25
|
+
|
|
26
|
+
# @param api_key [String] required, e.g. "walk_live_<keyId>.<secret>"
|
|
27
|
+
# @param base_url [String] optional, defaults to "https://api.walinko.com"
|
|
28
|
+
# @param timeout [Integer] read timeout in seconds (default 30)
|
|
29
|
+
# @param open_timeout [Integer] connection timeout in seconds (default 10)
|
|
30
|
+
# @param max_retries [Integer] retries on idempotent failures (default 2)
|
|
31
|
+
# @param logger [#info,#warn,#error] optional structured logger
|
|
32
|
+
def initialize(api_key:, **opts)
|
|
33
|
+
@config = Configuration.new(api_key: api_key, **opts)
|
|
34
|
+
@http = HttpClient.new(@config)
|
|
35
|
+
@messages = Messages.new(@http)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Snapshot of the rate-limit window from the most recent response.
|
|
39
|
+
# Returns `nil` until the first call has completed.
|
|
40
|
+
# @return [Walinko::RateLimitSnapshot, nil]
|
|
41
|
+
def last_rate_limit
|
|
42
|
+
@http.last_rate_limit
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The `X-Request-Id` from the most recent response (or `nil`).
|
|
46
|
+
# Useful when filing support tickets.
|
|
47
|
+
# @return [String, nil]
|
|
48
|
+
def last_request_id
|
|
49
|
+
@http.last_request_id
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: walinko
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Walinko
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rubocop
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.65'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.65'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: webmock
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.20'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.20'
|
|
55
|
+
description: |
|
|
56
|
+
Server-to-server Ruby client for the Walinko public API. Provides
|
|
57
|
+
ergonomic helpers for sending transactional WhatsApp messages, idempotent
|
|
58
|
+
retries, structured errors, and lookups by tracking id.
|
|
59
|
+
email:
|
|
60
|
+
- support@walinko.com
|
|
61
|
+
executables: []
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- CHANGELOG.md
|
|
66
|
+
- README.md
|
|
67
|
+
- lib/walinko.rb
|
|
68
|
+
- lib/walinko/configuration.rb
|
|
69
|
+
- lib/walinko/errors.rb
|
|
70
|
+
- lib/walinko/http_client.rb
|
|
71
|
+
- lib/walinko/messages.rb
|
|
72
|
+
- lib/walinko/result.rb
|
|
73
|
+
- lib/walinko/version.rb
|
|
74
|
+
homepage: https://github.com/walinko/walinko-sdk
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
homepage_uri: https://github.com/walinko/walinko-sdk
|
|
79
|
+
source_code_uri: https://github.com/walinko/walinko-sdk/tree/main/sdks/ruby
|
|
80
|
+
changelog_uri: https://github.com/walinko/walinko-sdk/blob/main/sdks/ruby/CHANGELOG.md
|
|
81
|
+
documentation_uri: https://walinko.com/docs/api
|
|
82
|
+
bug_tracker_uri: https://github.com/walinko/walinko-sdk/issues
|
|
83
|
+
rubygems_mfa_required: 'true'
|
|
84
|
+
post_install_message:
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 3.1.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.5.22
|
|
100
|
+
signing_key:
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: Official Ruby SDK for the Walinko public API.
|
|
103
|
+
test_files: []
|