ankusa-sdk 0.3.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +39 -1
- data/README.md +120 -17
- data/lib/ankusa/admin.rb +32 -5
- data/lib/ankusa/claim_check.rb +12 -4
- data/lib/ankusa/message.rb +217 -0
- data/lib/ankusa/sdk/version.rb +1 -1
- data/lib/ankusa/sdk.rb +2 -0
- data/lib/ankusa/signature.rb +101 -0
- data/lib/ankusa/sources.rb +7 -2
- data/lib/ankusa/webhook.rb +41 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 817a62bfe2b9d6f6f4dc513d32cb6a4e4091de4e79bd37ef9b5432a44eca6a60
|
|
4
|
+
data.tar.gz: 74ac90d9dccf5b681519f6870221bcddc11a96d015d945cffc68200606e2c808
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca0aa04bed9460cc20cc1db4b262ff711c65762b284b60486bdcf49825800e03e4173ca319553eaa3ab21f4151aa68fce7a5350e861bb3626d29e48028c02b91
|
|
7
|
+
data.tar.gz: 290a4cd6343778e9236772c3394877302ce97180c751c80a80420127698bfede89a859c413ea919280870bf57f355f6019628740cf4427c76ad2a39e70e1457b
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,43 @@ this project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-10-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Ankusa.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)`
|
|
14
|
+
verifies the Standard Webhooks signature an HTTP sink with a `secret` adds,
|
|
15
|
+
comparing with `OpenSSL.fixed_length_secure_compare`; failures raise
|
|
16
|
+
`Ankusa::InvalidSignatureError` (`code`, `field`, never retryable).
|
|
17
|
+
- `Ankusa::Message` and `Ankusa.decode_message(data)`, plus
|
|
18
|
+
`Ankusa::InvalidMessageError` (with `code`, `field` and `retryable?` false):
|
|
19
|
+
decode and validate the v1 queue message every sink delivers — the inline
|
|
20
|
+
(`body_base64`) and claim-check (`claim`) body forms, `sha256`,
|
|
21
|
+
`dedupe_key`, `replay_id`, and the forwarded `headers`, ignoring unknown
|
|
22
|
+
keys.
|
|
23
|
+
- `Message#idempotency_key` / `HookHeaders#idempotency_key`, the key to store
|
|
24
|
+
in a processed-ids table: the key Ankusa shipped (the message's
|
|
25
|
+
`idempotency_key` field, or the `x-ankusa-idempotency-key` header), and for a
|
|
26
|
+
message or delivery that predates it `tenant:source_id:dedupe_key` (tenant
|
|
27
|
+
`default` when there is none) when a `dedupe_key` is set, else `id`, with an
|
|
28
|
+
optional `#replay:<replay_id>` suffix (`include_replay: true`). The decoded
|
|
29
|
+
field stays in `to_h[:idempotency_key]`.
|
|
30
|
+
- `HookHeaders#dedupe_key` / `#replay_id` / `#idempotency_key`'s field, parsed
|
|
31
|
+
from `x-ankusa-dedupe-key` / `x-ankusa-replay-id` / `x-ankusa-idempotency-key`.
|
|
32
|
+
- `AdminClient#create_replay`, `#get_replay`, `#list_replays` and
|
|
33
|
+
`#update_replay` for the `POST/GET/PATCH /v1/replays` replay-job API (kinds
|
|
34
|
+
`dlq`, `archive`, `quarantine`).
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- `ClaimCheckClient#redeem` raises `ClaimCheckUnavailableError` (retryable)
|
|
39
|
+
for a gateway `408` or `429` instead of `ClaimRejectedError`.
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
- `AdminClient#replay_dead_letters`, replaced by the replay-job API above.
|
|
44
|
+
`POST /v1/dlq/replay` no longer exists.
|
|
45
|
+
|
|
9
46
|
## [0.3.0] - 2026-10-01
|
|
10
47
|
|
|
11
48
|
### Added
|
|
@@ -37,5 +74,6 @@ this project follows [Semantic Versioning](https://semver.org/).
|
|
|
37
74
|
family, which carries `.status` and `.body` instead), so a consumer needs
|
|
38
75
|
exactly one bit to route a failure to dead-letter or retry.
|
|
39
76
|
|
|
40
|
-
[Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.
|
|
77
|
+
[Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.5.0...HEAD
|
|
78
|
+
[0.5.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...sdk-ruby-v0.5.0
|
|
41
79
|
[0.3.0]: https://github.com/jamescarr/ankusa/releases/tag/sdk-ruby-v0.3.0
|
data/README.md
CHANGED
|
@@ -5,8 +5,8 @@ deployments: one gem meant to bundle everything a non-Elixir consumer needs to
|
|
|
5
5
|
talk to an Ankusa deployment. Today that's the
|
|
6
6
|
[claim-check gateway](https://github.com/jamescarr/ankusa/blob/main/docs/claim-check.md)
|
|
7
7
|
client, the route-management and operator (admin) clients, the
|
|
8
|
-
source-management client,
|
|
9
|
-
(ingest) land here as they're built.
|
|
8
|
+
source-management client, a webhook-receiving header helper, and the queue
|
|
9
|
+
message decoder; more clients (ingest) land here as they're built.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -81,9 +81,9 @@ consumer needs exactly one bit to decide dead-letter vs. retry:
|
|
|
81
81
|
| --- | --- | --- |
|
|
82
82
|
| `InvalidClaimRefError` | `false` | `ref` isn't a well-formed claim-check URN, or `sha256` isn't 64-char lowercase hex |
|
|
83
83
|
| `ClaimNotFoundError` | `false` | gateway `404`: expired by retention, or never written |
|
|
84
|
-
| `ClaimRejectedError` | `false` | gateway `4xx` other than `404` (`.status`, `.body`) |
|
|
84
|
+
| `ClaimRejectedError` | `false` | gateway `4xx` other than `404`, `408`, `429` (`.status`, `.body`) |
|
|
85
85
|
| `ClaimIntegrityError` | `false` | sha256 of the returned bytes doesn't match |
|
|
86
|
-
| `ClaimCheckUnavailableError` | `true` | gateway `5xx
|
|
86
|
+
| `ClaimCheckUnavailableError` | `true` | gateway `5xx`, `408`, `429`, or unreachable |
|
|
87
87
|
|
|
88
88
|
`health` hits `GET /health` for a liveness probe. There's no `close`: the
|
|
89
89
|
default transport opens one connection per request.
|
|
@@ -124,11 +124,103 @@ def call(env)
|
|
|
124
124
|
end
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
`HookHeaders#
|
|
127
|
+
`HookHeaders#idempotency_key` is what a receiver dedupes on, not the hook id
|
|
128
|
+
(`x-ankusa-id` stays required): delivery is at-least-once (see
|
|
128
129
|
"HTTP handoff" in
|
|
129
130
|
[`docs/integrations.md`](https://github.com/jamescarr/ankusa/blob/main/docs/integrations.md)),
|
|
130
|
-
so the same hook can arrive twice after a retry.
|
|
131
|
-
|
|
131
|
+
so the same hook can arrive twice after a retry. When the source extracted the
|
|
132
|
+
provider's own event key, `HookHeaders#dedupe_key` carries it (and `replay_id`
|
|
133
|
+
marks a replay); `HookHeaders#idempotency_key` returns the tenant-scoped key
|
|
134
|
+
Ankusa computed and shipped in `x-ankusa-idempotency-key` — see "Consuming
|
|
135
|
+
queue messages". Header lookup is always case-insensitive, regardless of
|
|
136
|
+
whether the mapping passed in already is.
|
|
137
|
+
|
|
138
|
+
### Verifying signed deliveries
|
|
139
|
+
|
|
140
|
+
An HTTP sink with a `secret` signs every delivery the
|
|
141
|
+
[Standard Webhooks](https://www.standardwebhooks.com/) way:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# Inside a Rack app's `call(env)`, before anything parses the body:
|
|
145
|
+
begin
|
|
146
|
+
body = env["rack.input"].read # the raw request body, exactly as received
|
|
147
|
+
Ankusa.verify_signature(header_map(env), body, [ENV.fetch("ANKUSA_WHSEC")])
|
|
148
|
+
rescue Ankusa::InvalidSignatureError => e
|
|
149
|
+
return [401, {"content-type" => "application/json"},
|
|
150
|
+
[JSON.generate(error: "invalid_signature", code: e.code)]]
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Secrets are `whsec_` + base64, or any other string used as its own bytes;
|
|
155
|
+
pass several during a rotation. `tolerance_seconds:` (default 300) bounds the
|
|
156
|
+
`webhook-timestamp` window. Failures are `Ankusa::InvalidSignatureError` with
|
|
157
|
+
`code` and `field`, never retryable.
|
|
158
|
+
|
|
159
|
+
## Consuming queue messages
|
|
160
|
+
|
|
161
|
+
Every message sink — RabbitMQ, Kafka, NATS, Redis — delivers one JSON message
|
|
162
|
+
per hook (the HTTP sink sends the original body plus `x-ankusa-*` headers
|
|
163
|
+
instead):
|
|
164
|
+
the identity fields, the body (inline `body_base64` or a claim-check `claim`),
|
|
165
|
+
`sha256`, and, when present, `dedupe_key`, `replay_id`, `idempotency_key` and
|
|
166
|
+
the forwarded provider `headers`. `Ankusa.decode_message` validates all of it
|
|
167
|
+
and `Message#idempotency_key` gives the value to store in a processed-ids
|
|
168
|
+
table:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
require "ankusa/sdk"
|
|
172
|
+
|
|
173
|
+
CLAIM_CHECK = Ankusa::ClaimCheckClient.new(ENV.fetch("CLAIM_CHECK_URL", "http://localhost:4001"))
|
|
174
|
+
DB = PG.connect(ENV.fetch("DATABASE_URL"))
|
|
175
|
+
DB.exec("CREATE TABLE IF NOT EXISTS processed (key text primary key)")
|
|
176
|
+
|
|
177
|
+
def consume(raw)
|
|
178
|
+
begin
|
|
179
|
+
message = Ankusa.decode_message(raw)
|
|
180
|
+
rescue Ankusa::InvalidMessageError => e
|
|
181
|
+
# Poison message: dead-letter, never requeue. `e.retryable?` is false;
|
|
182
|
+
# `e.code` is e.g. "integrity"; `e.field` names the offending key.
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
key = message.idempotency_key # the key Ankusa shipped (tenant:source_id:dedupe_key, else id)
|
|
187
|
+
return if DB.exec_params("SELECT 1 FROM processed WHERE key = $1", [key]).any?
|
|
188
|
+
|
|
189
|
+
body = message.claim ? CLAIM_CHECK.redeem(message.claim, message.sha256) : message.body
|
|
190
|
+
handle(body)
|
|
191
|
+
DB.exec_params("INSERT INTO processed (key) VALUES ($1)", [key])
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
- `idempotency_key` is the key Ankusa computed once for the hook:
|
|
196
|
+
`tenant:source_id:dedupe_key` when the source extracted the provider's own
|
|
197
|
+
event key, else `id` — so a provider retry that arrives with a fresh Ankusa
|
|
198
|
+
`id` still collapses onto the same row, and two tenants that share a provider
|
|
199
|
+
event id do not. For a message from a node that predates the field the
|
|
200
|
+
helper computes the same key itself (tenant `default` when there is none).
|
|
201
|
+
The decoded field alone is `message.to_h[:idempotency_key]`.
|
|
202
|
+
- A replayed delivery is dropped by default. To reprocess replays instead, pass
|
|
203
|
+
`include_replay: true`: the key then ends in `#replay:<replay_id>`.
|
|
204
|
+
- `message.body` is the decoded inline bytes (nil for the claim form);
|
|
205
|
+
`message.headers` holds the forwarded provider request headers (lowercased).
|
|
206
|
+
`message.sha256` is checked against the inline bytes by `decode_message`
|
|
207
|
+
itself, and against the claim's bytes by `CLAIM_CHECK.redeem`.
|
|
208
|
+
- `decode_message` raises `InvalidMessageError` before you act on a message:
|
|
209
|
+
bad JSON, a non-object, an unsupported `v`, a bad field type, both or neither
|
|
210
|
+
body form, invalid base64, a length/sha256 mismatch, or a claim whose tenant
|
|
211
|
+
differs from `tenant_id`.
|
|
212
|
+
|
|
213
|
+
A webhook receiver can take the same shortcut straight off the HTTP sink's
|
|
214
|
+
headers with `Ankusa.parse_headers(headers).idempotency_key`, which reads
|
|
215
|
+
`x-ankusa-idempotency-key` (falling back to computing it, where `source` and
|
|
216
|
+
`tenant` play `source_id` and `tenant_id`).
|
|
217
|
+
|
|
218
|
+
When a sink has grown a backlog, or a downstream processor failed after the
|
|
219
|
+
sink accepted a batch, re-drive it with a replay job over the admin client (see
|
|
220
|
+
"Admin client" below): `"kind" => "dlq"` re-sends rows that dead-lettered,
|
|
221
|
+
`"kind" => "archive"` re-sends hooks over a time window, `"kind" => "quarantine"`
|
|
222
|
+
re-verifies held hooks and releases those that pass. Replays keep the
|
|
223
|
+
original `id` and `dedupe_key` and add `replay_id`.
|
|
132
224
|
|
|
133
225
|
## Routes client
|
|
134
226
|
|
|
@@ -162,8 +254,8 @@ unfollowed redirect, a non-JSON success body, or unreachable; retryable).
|
|
|
162
254
|
## Admin client
|
|
163
255
|
|
|
164
256
|
The operator API on `admin.port` (default 4002): health, Prometheus metrics, the
|
|
165
|
-
redacted config, the DLQ, and the quarantine list — the
|
|
166
|
-
`quarantine` tags of `admin.v1.yaml`.
|
|
257
|
+
redacted config, the DLQ, replay jobs, and the quarantine list — the
|
|
258
|
+
`operations`, `dlq`, `replays`, and `quarantine` tags of `admin.v1.yaml`.
|
|
167
259
|
|
|
168
260
|
```ruby
|
|
169
261
|
require "ankusa/sdk"
|
|
@@ -172,14 +264,24 @@ admin = Ankusa::AdminClient.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
|
|
|
172
264
|
|
|
173
265
|
admin.health # {"status" => "ok", "instance" => ..., "roles" => [...]}
|
|
174
266
|
admin.list_dead_letters({"limit" => 10}) # {"total" => ..., "entries" => [...]}
|
|
175
|
-
admin.replay_dead_letters({"source_id" => "demo"})
|
|
176
267
|
admin.list_quarantined
|
|
268
|
+
|
|
269
|
+
job = admin.create_replay({"kind" => "dlq", "source_id" => "stripe", "rate" => 500})
|
|
270
|
+
admin.get_replay(job["id"])
|
|
271
|
+
admin.list_replays # {"replays" => [Replay, ...]}, newest first
|
|
272
|
+
admin.update_replay(job["id"], {"state" => "paused"}) # resume, pause or cancel
|
|
177
273
|
```
|
|
178
274
|
|
|
275
|
+
`create_replay` is idempotent for retries: a second POST of the same filter
|
|
276
|
+
while the job is `running`/`paused` returns the existing job (`200`) instead of
|
|
277
|
+
starting another (`202`). `list_replays` returns `{"replays" => [...]}` exactly
|
|
278
|
+
as the API sends it.
|
|
279
|
+
|
|
179
280
|
Methods: `health`, `metrics` (Prometheus text), `config`, `list_dead_letters`,
|
|
180
|
-
`
|
|
181
|
-
`
|
|
182
|
-
`AdminRejectedError` (any other 4xx
|
|
281
|
+
`create_replay`, `get_replay`, `list_replays`, `update_replay`,
|
|
282
|
+
`list_quarantined`. Failures are `AdminError` subclasses: `RoleNotEnabledError`
|
|
283
|
+
(409 `role_not_enabled`, carrying `role`), `AdminRejectedError` (any other 4xx —
|
|
284
|
+
a missing replay is a 404 with `code` `replay_not_found`), and
|
|
183
285
|
`AdminUnavailableError` (5xx, an unfollowed redirect, or unreachable;
|
|
184
286
|
retryable).
|
|
185
287
|
|
|
@@ -236,15 +338,16 @@ lib/ankusa/
|
|
|
236
338
|
connection.rb # shared URL/query/body/JSON rules (@api private)
|
|
237
339
|
webhook.rb # x-ankusa-* header parsing for HTTP-sink receivers
|
|
238
340
|
claim_check.rb # the claim-check gateway client
|
|
341
|
+
message.rb # decode the v1 queue message + the idempotency key
|
|
239
342
|
routes.rb # the route-management client (routes.admin.port)
|
|
240
343
|
admin.rb # the operator client (admin.port)
|
|
241
344
|
sources.rb # the tenant-scoped source-management client (admin.port)
|
|
242
345
|
test/
|
|
243
346
|
test_helper.rb
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
347
|
+
conformance_test.rb # runs the language-neutral vectors in conformance/
|
|
348
|
+
routes_test.rb
|
|
349
|
+
admin_test.rb
|
|
350
|
+
sources_test.rb
|
|
248
351
|
```
|
|
249
352
|
|
|
250
353
|
## Develop
|
data/lib/ankusa/admin.rb
CHANGED
|
@@ -81,12 +81,33 @@ module Ankusa
|
|
|
81
81
|
json(request("GET", "/v1/dlq", query: params))
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# POST /v1/
|
|
84
|
+
# POST /v1/replays -> the created Replay, as a Hash.
|
|
85
85
|
#
|
|
86
|
-
# `
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
# `spec` is the replay spec: `{"kind" => "dlq", ...}`,
|
|
87
|
+
# `{"kind" => "archive", "from" => ms, "to" => ms, ...}` or
|
|
88
|
+
# `{"kind" => "quarantine", ...}`. A retried POST
|
|
89
|
+
# with the same normalized filter returns the running job with 200 instead
|
|
90
|
+
# of starting a second one.
|
|
91
|
+
def create_replay(spec)
|
|
92
|
+
json(request("POST", "/v1/replays", json: spec))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# GET /v1/replays/{id} -> the Replay, as a Hash.
|
|
96
|
+
def get_replay(id)
|
|
97
|
+
json(request("GET", "/v1/replays/#{replay_path(id)}"))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# GET /v1/replays -> {"replays" => [Replay, ...]}, newest first.
|
|
101
|
+
def list_replays
|
|
102
|
+
json(request("GET", "/v1/replays"))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# PATCH /v1/replays/{id} -> the updated Replay, as a Hash.
|
|
106
|
+
#
|
|
107
|
+
# `patch` may carry `state` (`running`/`paused`/`cancelled`), `rate` and
|
|
108
|
+
# `max_lag_ms`.
|
|
109
|
+
def update_replay(id, patch)
|
|
110
|
+
json(request("PATCH", "/v1/replays/#{replay_path(id)}", json: patch))
|
|
90
111
|
end
|
|
91
112
|
|
|
92
113
|
# GET /v1/quarantine -> recent quarantined hooks, newest first.
|
|
@@ -99,6 +120,12 @@ module Ankusa
|
|
|
99
120
|
|
|
100
121
|
private
|
|
101
122
|
|
|
123
|
+
# Percent-encodes a replay id as one path segment, so a `/`, `?` or `#` in
|
|
124
|
+
# an id can't reshape the URL.
|
|
125
|
+
def replay_path(id)
|
|
126
|
+
id.to_s.b.gsub(/[^A-Za-z0-9_.~-]/) { |char| format("%%%02X", char.ord) }
|
|
127
|
+
end
|
|
128
|
+
|
|
102
129
|
def request(http_method, path, query: nil, json: nil)
|
|
103
130
|
response = @connection.request(http_method, path, query: query, json: json)
|
|
104
131
|
raise_for_status(response)
|
data/lib/ankusa/claim_check.rb
CHANGED
|
@@ -11,8 +11,9 @@ module Ankusa
|
|
|
11
11
|
# right.
|
|
12
12
|
#
|
|
13
13
|
# Non-retryable: the ref or expected sha256 is malformed, the gateway said
|
|
14
|
-
# 404
|
|
15
|
-
#
|
|
14
|
+
# 404 or another 4xx (except 408/429), or the bytes that came back don't
|
|
15
|
+
# match the sha256.
|
|
16
|
+
# Retryable: the gateway said 5xx, 408 or 429, or the request never completed
|
|
16
17
|
# (network error, timeout).
|
|
17
18
|
class ClaimCheckError < Error
|
|
18
19
|
def retryable? = false
|
|
@@ -28,7 +29,7 @@ module Ankusa
|
|
|
28
29
|
class ClaimNotFoundError < ClaimCheckError
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
# The gateway rejected the request (400 or any other
|
|
32
|
+
# The gateway rejected the request (400 or any other 4xx but 404, 408 and 429).
|
|
32
33
|
class ClaimRejectedError < ClaimCheckError
|
|
33
34
|
attr_reader :status, :body
|
|
34
35
|
|
|
@@ -46,7 +47,7 @@ module Ankusa
|
|
|
46
47
|
class ClaimIntegrityError < ClaimCheckError
|
|
47
48
|
end
|
|
48
49
|
|
|
49
|
-
# The gateway is unreachable, or answered 5xx
|
|
50
|
+
# The gateway is unreachable, or answered 5xx, 408 or 429. Safe to retry.
|
|
50
51
|
class ClaimCheckUnavailableError < ClaimCheckError
|
|
51
52
|
def retryable? = true
|
|
52
53
|
end
|
|
@@ -133,6 +134,13 @@ module Ankusa
|
|
|
133
134
|
raise ClaimNotFoundError, "claim not found: #{parsed.tenant_id}/#{parsed.claim_id}"
|
|
134
135
|
end
|
|
135
136
|
|
|
137
|
+
# A gateway (or a proxy in front of it) that is throttling or timing out
|
|
138
|
+
# is telling the caller to come back, not that the claim is gone.
|
|
139
|
+
if [408, 429].include?(status)
|
|
140
|
+
raise ClaimCheckUnavailableError,
|
|
141
|
+
"claim-check gateway busy (#{status}): #{Connection.error_body(response).inspect}"
|
|
142
|
+
end
|
|
143
|
+
|
|
136
144
|
if status >= 400 && status < 500
|
|
137
145
|
body = Connection.error_body(response)
|
|
138
146
|
raise ClaimRejectedError.new(
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ankusa
|
|
7
|
+
# Raised by `Ankusa.decode_message` when a queue message fails one of the v1
|
|
8
|
+
# decode rules. Always worth dead-lettering, never worth retrying: the bytes
|
|
9
|
+
# will not change on a redelivery.
|
|
10
|
+
#
|
|
11
|
+
# `code` names the rule that failed (`invalid_json`, `unsupported_version`,
|
|
12
|
+
# `invalid_field`, `ambiguous_body`, `missing_body`, `invalid_body_base64`,
|
|
13
|
+
# `size_mismatch`, `integrity`, `tenant_mismatch`); `field` names the offending
|
|
14
|
+
# key when the rule is about one, and is nil otherwise.
|
|
15
|
+
class InvalidMessageError < Error
|
|
16
|
+
attr_reader :code, :field
|
|
17
|
+
|
|
18
|
+
def initialize(message, code:, field: nil)
|
|
19
|
+
super(message)
|
|
20
|
+
@code = code
|
|
21
|
+
@field = field
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def retryable? = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The one idempotency-key rule, shared by `Message` and `HookHeaders` so it
|
|
28
|
+
# lives in one place.
|
|
29
|
+
#
|
|
30
|
+
# The key Ankusa shipped (`shipped`) when it is a non-empty string; otherwise,
|
|
31
|
+
# for a hook from a node that predates the field, `tenant:source:dedupe_key`
|
|
32
|
+
# (tenant "default" when nil) for a non-empty `dedupe_key`, else `id`. With
|
|
33
|
+
# `include_replay` and a `replay_id`, `#replay:<replay_id>` is appended.
|
|
34
|
+
module IdempotencyKey
|
|
35
|
+
def self.build(shipped:, tenant:, source:, dedupe_key:, id:, replay_id:, include_replay:)
|
|
36
|
+
key =
|
|
37
|
+
if !shipped.nil? && !shipped.empty?
|
|
38
|
+
shipped
|
|
39
|
+
elsif !dedupe_key.nil? && !dedupe_key.empty?
|
|
40
|
+
"#{tenant || "default"}:#{source}:#{dedupe_key}"
|
|
41
|
+
else
|
|
42
|
+
id
|
|
43
|
+
end
|
|
44
|
+
key += "#replay:#{replay_id}" if include_replay && !replay_id.nil?
|
|
45
|
+
key
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
private_constant :IdempotencyKey
|
|
49
|
+
|
|
50
|
+
# One decoded v1 queue message. `body` holds the inline bytes (or nil for the
|
|
51
|
+
# claim form, where the bytes are fetched with `Ankusa::ClaimCheckClient`);
|
|
52
|
+
# `claim` holds the ref string in that case. `headers` is the forwarded
|
|
53
|
+
# provider request headers, `{}` when the message carried none.
|
|
54
|
+
#
|
|
55
|
+
# `idempotency_key` is the one value a consumer should dedupe on, as
|
|
56
|
+
# `Message#idempotency_key` (the field is nil only for a message from a node
|
|
57
|
+
# that predates it); see `Ankusa.decode_message` for the decode rules.
|
|
58
|
+
Message = Data.define(
|
|
59
|
+
:v, :id, :source_id, :tenant_id, :received_at, :content_type, :size,
|
|
60
|
+
:body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers
|
|
61
|
+
) do
|
|
62
|
+
# `Data.define` made `idempotency_key` the reader of the decoded field; keep
|
|
63
|
+
# that value reachable here and let the helper below take over the name.
|
|
64
|
+
# The decoded field itself is still in `to_h[:idempotency_key]`.
|
|
65
|
+
alias_method :shipped_idempotency_key, :idempotency_key
|
|
66
|
+
private :shipped_idempotency_key
|
|
67
|
+
|
|
68
|
+
# The idempotency key for this message: the key Ankusa shipped in the
|
|
69
|
+
# `idempotency_key` field when it is non-empty; for a message that predates
|
|
70
|
+
# the field, `tenant:source_id:dedupe_key` (tenant "default" when there is
|
|
71
|
+
# none) when a non-empty `dedupe_key` is set, else `id`.
|
|
72
|
+
#
|
|
73
|
+
# A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
|
|
74
|
+
# by default a replay produces the same key as the delivery it replays and a
|
|
75
|
+
# consumer that already processed it drops it. Pass `include_replay: true`
|
|
76
|
+
# to reprocess replays instead.
|
|
77
|
+
def idempotency_key(include_replay: false)
|
|
78
|
+
IdempotencyKey.build(
|
|
79
|
+
shipped: shipped_idempotency_key, tenant: tenant_id, source: source_id,
|
|
80
|
+
dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The optional string-keyed fields that may be a string, null, or absent.
|
|
86
|
+
# Checked in this order, so the first offending key is the one reported.
|
|
87
|
+
MESSAGE_STRING_FIELDS = %w[tenant_id content_type dedupe_key replay_id idempotency_key].freeze
|
|
88
|
+
private_constant :MESSAGE_STRING_FIELDS
|
|
89
|
+
|
|
90
|
+
# Decodes one v1 queue message (the JSON body a sink or broker delivers) into
|
|
91
|
+
# a `Message`, applying every rule in "Consuming queue messages" of the SDK
|
|
92
|
+
# README in order; the first failure wins.
|
|
93
|
+
#
|
|
94
|
+
# Raises `InvalidMessageError` for anything malformed. The `sha256` is checked
|
|
95
|
+
# against the inline bytes here, but a claim's bytes are only checked by
|
|
96
|
+
# `ClaimCheckClient#redeem`, after it fetches them.
|
|
97
|
+
def self.decode_message(data)
|
|
98
|
+
parsed =
|
|
99
|
+
begin
|
|
100
|
+
JSON.parse(data)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise InvalidMessageError.new("message is not JSON", code: "invalid_json")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
unless parsed.is_a?(Hash)
|
|
106
|
+
raise InvalidMessageError.new("message is not a JSON object", code: "not_an_object")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
version = parsed["v"]
|
|
110
|
+
unless version.is_a?(Integer) && version == 1
|
|
111
|
+
raise InvalidMessageError.new("unsupported message version: #{version.inspect}", code: "unsupported_version")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
id = parsed["id"]
|
|
115
|
+
invalid_field("id") unless id.is_a?(String) && !id.empty?
|
|
116
|
+
|
|
117
|
+
source_id = parsed["source_id"]
|
|
118
|
+
invalid_field("source_id") unless source_id.is_a?(String)
|
|
119
|
+
|
|
120
|
+
received_at = parsed["received_at"]
|
|
121
|
+
invalid_field("received_at") unless received_at.is_a?(Integer)
|
|
122
|
+
|
|
123
|
+
size = parsed["size"]
|
|
124
|
+
invalid_field("size") unless size.is_a?(Integer) && size >= 0
|
|
125
|
+
|
|
126
|
+
MESSAGE_STRING_FIELDS.each do |field|
|
|
127
|
+
value = parsed[field]
|
|
128
|
+
invalid_field(field) unless value.nil? || value.is_a?(String)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
headers = parsed.key?("headers") ? parsed["headers"] : {}
|
|
132
|
+
invalid_field("headers") unless headers.is_a?(Hash) && headers.each_value.all? { |value| value.is_a?(String) }
|
|
133
|
+
|
|
134
|
+
sha256 = parsed["sha256"]
|
|
135
|
+
valid_sha256 = sha256.nil? || (sha256.is_a?(String) && SHA256_PATTERN.match?(sha256))
|
|
136
|
+
invalid_field("sha256") unless valid_sha256
|
|
137
|
+
|
|
138
|
+
body_base64 = parsed["body_base64"]
|
|
139
|
+
claim = parsed["claim"]
|
|
140
|
+
|
|
141
|
+
unless body_base64.nil? || claim.nil?
|
|
142
|
+
raise InvalidMessageError.new("message has both body_base64 and claim", code: "ambiguous_body")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if body_base64.nil? && claim.nil?
|
|
146
|
+
raise InvalidMessageError.new("message has neither body_base64 nor claim", code: "missing_body")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
body = nil
|
|
150
|
+
parsed_claim = nil
|
|
151
|
+
|
|
152
|
+
if body_base64.nil?
|
|
153
|
+
parsed_claim = parse_claim(claim)
|
|
154
|
+
invalid_field("sha256") if sha256.nil?
|
|
155
|
+
else
|
|
156
|
+
body = decode_body(body_base64)
|
|
157
|
+
if body.bytesize != size
|
|
158
|
+
raise InvalidMessageError.new("body is #{body.bytesize} bytes, size says #{size}", code: "size_mismatch")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if !sha256.nil? && Digest::SHA256.hexdigest(body) != sha256
|
|
162
|
+
raise InvalidMessageError.new("body sha256 does not match", code: "integrity")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
tenant_id = parsed["tenant_id"]
|
|
167
|
+
if !parsed_claim.nil? && !tenant_id.nil? && parsed_claim.tenant_id != tenant_id
|
|
168
|
+
raise InvalidMessageError.new(
|
|
169
|
+
"claim tenant #{parsed_claim.tenant_id} does not match tenant_id #{tenant_id}",
|
|
170
|
+
code: "tenant_mismatch"
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
Message.new(
|
|
175
|
+
v: 1,
|
|
176
|
+
id: id,
|
|
177
|
+
source_id: source_id,
|
|
178
|
+
tenant_id: tenant_id,
|
|
179
|
+
received_at: received_at,
|
|
180
|
+
content_type: parsed["content_type"],
|
|
181
|
+
size: size,
|
|
182
|
+
body: body,
|
|
183
|
+
claim: claim,
|
|
184
|
+
sha256: sha256,
|
|
185
|
+
dedupe_key: parsed["dedupe_key"],
|
|
186
|
+
replay_id: parsed["replay_id"],
|
|
187
|
+
idempotency_key: parsed["idempotency_key"],
|
|
188
|
+
headers: headers
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.invalid_field(field)
|
|
193
|
+
raise InvalidMessageError.new("invalid message field: #{field}", code: "invalid_field", field: field)
|
|
194
|
+
end
|
|
195
|
+
private_class_method :invalid_field
|
|
196
|
+
|
|
197
|
+
def self.decode_body(body_base64)
|
|
198
|
+
unless body_base64.is_a?(String)
|
|
199
|
+
raise InvalidMessageError.new("body_base64 is not a string", code: "invalid_body_base64")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
body_base64.unpack1("m0")
|
|
203
|
+
rescue ArgumentError
|
|
204
|
+
raise InvalidMessageError.new("body_base64 is not valid base64", code: "invalid_body_base64")
|
|
205
|
+
end
|
|
206
|
+
private_class_method :decode_body
|
|
207
|
+
|
|
208
|
+
# A claim that doesn't parse is an invalid *field*, not an invalid claim-ref
|
|
209
|
+
# error: the message itself is what a consumer rejects, so it carries the same
|
|
210
|
+
# `invalid_field` code as every other bad key.
|
|
211
|
+
def self.parse_claim(claim)
|
|
212
|
+
Ankusa.parse_claim_ref(claim)
|
|
213
|
+
rescue InvalidClaimRefError
|
|
214
|
+
invalid_field("claim")
|
|
215
|
+
end
|
|
216
|
+
private_class_method :parse_claim
|
|
217
|
+
end
|
data/lib/ankusa/sdk/version.rb
CHANGED
data/lib/ankusa/sdk.rb
CHANGED
|
@@ -5,7 +5,9 @@ require_relative "error"
|
|
|
5
5
|
require_relative "transport"
|
|
6
6
|
require_relative "connection"
|
|
7
7
|
require_relative "webhook"
|
|
8
|
+
require_relative "signature"
|
|
8
9
|
require_relative "claim_check"
|
|
10
|
+
require_relative "message"
|
|
9
11
|
require_relative "routes"
|
|
10
12
|
require_relative "admin"
|
|
11
13
|
require_relative "sources"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Ankusa
|
|
6
|
+
# A delivery whose Standard Webhooks signature does not verify. Never
|
|
7
|
+
# retryable: answer `401`; the sender retries with the same bytes.
|
|
8
|
+
#
|
|
9
|
+
# `code` is one of `invalid_secret`, `missing_header`, `invalid_timestamp`,
|
|
10
|
+
# `timestamp_out_of_tolerance`, `no_matching_signature`; `field` names the
|
|
11
|
+
# header at fault (nil for `invalid_secret`).
|
|
12
|
+
class InvalidSignatureError < Error
|
|
13
|
+
attr_reader :code, :field
|
|
14
|
+
|
|
15
|
+
def initialize(message, code:, field: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@code = code
|
|
18
|
+
@field = field
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def retryable? = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A verified delivery's `webhook-id` and `webhook-timestamp`.
|
|
25
|
+
VerifiedSignature = Data.define(:id, :timestamp)
|
|
26
|
+
|
|
27
|
+
BASE64_PATTERN = %r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z}
|
|
28
|
+
private_constant :BASE64_PATTERN
|
|
29
|
+
|
|
30
|
+
# Verifies the Standard Webhooks signature an HTTP sink with a `secret` adds
|
|
31
|
+
# (https://www.standardwebhooks.com/).
|
|
32
|
+
#
|
|
33
|
+
# `webhook-signature` holds space-separated `v1,<base64>` entries, each an
|
|
34
|
+
# HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`. Any `v1` entry
|
|
35
|
+
# matching any secret (`whsec_` + base64, or any other string used as its own
|
|
36
|
+
# bytes) passes, compared in constant time, provided `webhook-timestamp` is
|
|
37
|
+
# within `tolerance_seconds` of `now` (the clock by default). `body` must be
|
|
38
|
+
# the raw bytes received. Raises `InvalidSignatureError`.
|
|
39
|
+
def self.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)
|
|
40
|
+
keys = signature_keys(Array(secrets))
|
|
41
|
+
lowered = headers.to_h { |key, value| [key.to_s.downcase, value] }
|
|
42
|
+
|
|
43
|
+
required = lambda do |name|
|
|
44
|
+
value = lowered[name]
|
|
45
|
+
raise InvalidSignatureError.new("missing #{name} header", code: "missing_header", field: name) if value.nil? || value.empty?
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
hook_id = required.call("webhook-id")
|
|
51
|
+
raw_timestamp = required.call("webhook-timestamp")
|
|
52
|
+
signature = required.call("webhook-signature")
|
|
53
|
+
|
|
54
|
+
unless raw_timestamp.match?(/\A[0-9]+\z/)
|
|
55
|
+
raise InvalidSignatureError.new("webhook-timestamp is not a unix time",
|
|
56
|
+
code: "invalid_timestamp", field: "webhook-timestamp")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
timestamp = Integer(raw_timestamp, 10)
|
|
60
|
+
current = now || Time.now.to_i
|
|
61
|
+
if (current - timestamp).abs > tolerance_seconds
|
|
62
|
+
raise InvalidSignatureError.new("webhook-timestamp is outside the tolerance window",
|
|
63
|
+
code: "timestamp_out_of_tolerance", field: "webhook-timestamp")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
signed = "#{hook_id}.#{raw_timestamp}.".b + body.to_s.b
|
|
67
|
+
candidates = signature.split(" ").filter_map { |entry| entry.delete_prefix("v1,") if entry.start_with?("v1,") }
|
|
68
|
+
|
|
69
|
+
keys.each do |key|
|
|
70
|
+
expected = [OpenSSL::HMAC.digest("SHA256", key, signed)].pack("m0")
|
|
71
|
+
candidates.each do |candidate|
|
|
72
|
+
next unless candidate.bytesize == expected.bytesize
|
|
73
|
+
|
|
74
|
+
return VerifiedSignature.new(id: hook_id, timestamp: timestamp) if OpenSSL.fixed_length_secure_compare(candidate, expected)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise InvalidSignatureError.new("no webhook-signature entry matches",
|
|
79
|
+
code: "no_matching_signature", field: "webhook-signature")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.signature_keys(secrets)
|
|
83
|
+
raise InvalidSignatureError.new("no secret configured", code: "invalid_secret") if secrets.empty?
|
|
84
|
+
|
|
85
|
+
secrets.map do |secret|
|
|
86
|
+
if secret.start_with?("whsec_")
|
|
87
|
+
encoded = secret.delete_prefix("whsec_")
|
|
88
|
+
unless !encoded.empty? && BASE64_PATTERN.match?(encoded)
|
|
89
|
+
raise InvalidSignatureError.new("a whsec_ secret is not valid base64", code: "invalid_secret")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
encoded.unpack1("m0")
|
|
93
|
+
elsif secret.empty?
|
|
94
|
+
raise InvalidSignatureError.new("an empty secret", code: "invalid_secret")
|
|
95
|
+
else
|
|
96
|
+
secret.b
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_class_method :signature_keys
|
|
101
|
+
end
|
data/lib/ankusa/sources.rb
CHANGED
|
@@ -21,7 +21,9 @@ module Ankusa
|
|
|
21
21
|
class SourceNotFoundError < SourcesError
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# 409 source_exists: a source with that name already exists
|
|
24
|
+
# 409 source_exists: a source with that name already exists; or
|
|
25
|
+
# 409 source_has_deliveries: a delete found the source still has undelivered
|
|
26
|
+
# hooks. The body's "error" says which.
|
|
25
27
|
class SourceConflictError < SourcesError
|
|
26
28
|
end
|
|
27
29
|
|
|
@@ -169,7 +171,10 @@ module Ankusa
|
|
|
169
171
|
# Delete a source: DELETE /v1/tenants/<tenant>/sources/<name>.
|
|
170
172
|
#
|
|
171
173
|
# Succeeds with no return value (the server answers 204 with an empty body);
|
|
172
|
-
# a missing source raises `SourceNotFoundError`.
|
|
174
|
+
# a missing source raises `SourceNotFoundError`. A source that still has
|
|
175
|
+
# undelivered hooks on the server's node raises `SourceConflictError` whose
|
|
176
|
+
# body's "error" is "source_has_deliveries" (with "pending"/"inflight"
|
|
177
|
+
# counts); this method never sends the admin API's ?deliveries=dead_letter.
|
|
173
178
|
def delete_source(tenant, name)
|
|
174
179
|
validate_tenant(tenant)
|
|
175
180
|
validate_name(name)
|
data/lib/ankusa/webhook.rb
CHANGED
|
@@ -4,9 +4,36 @@ module Ankusa
|
|
|
4
4
|
# The identity of one HTTP-sink delivery: the headers Ankusa's HTTP sink
|
|
5
5
|
# attaches to every delivery.
|
|
6
6
|
#
|
|
7
|
-
# `content_type` and `
|
|
8
|
-
# (a tenant only travels when the
|
|
9
|
-
|
|
7
|
+
# `content_type`, `tenant`, `dedupe_key`, `replay_id` and `idempotency_key` are
|
|
8
|
+
# nil when the delivery had no such header (a tenant only travels when the
|
|
9
|
+
# source has one; `dedupe_key` only when the source declared a dedupe key;
|
|
10
|
+
# `replay_id` only when the delivery is a replay; `idempotency_key` is
|
|
11
|
+
# missing only from a sender that predates the header).
|
|
12
|
+
HookHeaders = Data.define(
|
|
13
|
+
:id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key
|
|
14
|
+
) do
|
|
15
|
+
# `Data.define` made `idempotency_key` the reader of the parsed header; keep
|
|
16
|
+
# that value reachable here and let the helper below take over the name.
|
|
17
|
+
# The parsed header itself is still in `to_h[:idempotency_key]`.
|
|
18
|
+
alias_method :shipped_idempotency_key, :idempotency_key
|
|
19
|
+
private :shipped_idempotency_key
|
|
20
|
+
|
|
21
|
+
# The idempotency key for this delivery: the key Ankusa shipped in
|
|
22
|
+
# `x-ankusa-idempotency-key` when it is non-empty; for a delivery that
|
|
23
|
+
# predates the header, `tenant:source:dedupe_key` (tenant "default" when
|
|
24
|
+
# there is none) when a non-empty `dedupe_key` is set, else `id`.
|
|
25
|
+
#
|
|
26
|
+
# A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
|
|
27
|
+
# by default a replay produces the same key as the delivery it replays and a
|
|
28
|
+
# receiver that already processed it drops it. Pass `include_replay: true`
|
|
29
|
+
# to reprocess replays instead.
|
|
30
|
+
def idempotency_key(include_replay: false)
|
|
31
|
+
IdempotencyKey.build(
|
|
32
|
+
shipped: shipped_idempotency_key, tenant: tenant, source: source,
|
|
33
|
+
dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
10
37
|
|
|
11
38
|
# Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
|
|
12
39
|
#
|
|
@@ -32,7 +59,17 @@ module Ankusa
|
|
|
32
59
|
id: hook_id,
|
|
33
60
|
source: lowered.fetch("x-ankusa-source", ""),
|
|
34
61
|
tenant: lowered["x-ankusa-tenant"],
|
|
35
|
-
content_type: lowered["content-type"]
|
|
62
|
+
content_type: lowered["content-type"],
|
|
63
|
+
dedupe_key: presence(lowered["x-ankusa-dedupe-key"]),
|
|
64
|
+
replay_id: presence(lowered["x-ankusa-replay-id"]),
|
|
65
|
+
idempotency_key: presence(lowered["x-ankusa-idempotency-key"])
|
|
36
66
|
)
|
|
37
67
|
end
|
|
68
|
+
|
|
69
|
+
# An empty header counts as absent: Ankusa only sends these when it has a
|
|
70
|
+
# value, and a proxy that rewrites a header to "" should not fabricate one.
|
|
71
|
+
def self.presence(value)
|
|
72
|
+
(value.nil? || value.empty?) ? nil : value
|
|
73
|
+
end
|
|
74
|
+
private_class_method :presence
|
|
38
75
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ankusa-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Carr
|
|
@@ -24,9 +24,11 @@ files:
|
|
|
24
24
|
- lib/ankusa/claim_check.rb
|
|
25
25
|
- lib/ankusa/connection.rb
|
|
26
26
|
- lib/ankusa/error.rb
|
|
27
|
+
- lib/ankusa/message.rb
|
|
27
28
|
- lib/ankusa/routes.rb
|
|
28
29
|
- lib/ankusa/sdk.rb
|
|
29
30
|
- lib/ankusa/sdk/version.rb
|
|
31
|
+
- lib/ankusa/signature.rb
|
|
30
32
|
- lib/ankusa/sources.rb
|
|
31
33
|
- lib/ankusa/transport.rb
|
|
32
34
|
- lib/ankusa/webhook.rb
|