hookd-client 1.4.0 → 1.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/README.md +79 -2
- data/lib/hookd/client.rb +106 -3
- data/lib/hookd/cursor_read.rb +24 -0
- data/lib/hookd/hook_activity.rb +6 -3
- data/lib/hookd/interaction.rb +10 -4
- data/lib/hookd/version.rb +1 -1
- data/lib/hookd.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85c9d3bb1f54b986026f6815e255ecdb515ad9e9bae6a4c78708fcf0f5d133b2
|
|
4
|
+
data.tar.gz: a8a93a75b73de9eba86444c0104643388f64fb71969c19588465071a6aa2272a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd9081da69578bdea39b76d8bedc0c6d71b1b2a82927b3fc07abadf345d20d10ad58797ea8689d0b84b0ea69d9609d23cb7bdcbd07b5f823fab0afb87eb2f8c7
|
|
7
|
+
data.tar.gz: caf8b630d270276df0d4a7b05b0bb30c5a96e446ff6b87f3eb9b2b5166789960a768c5545f733e91b58d6404ba5dcff1cf5b044b33798655df9dc5f186a0d1be
|
data/README.md
CHANGED
|
@@ -164,10 +164,39 @@ Raises:
|
|
|
164
164
|
- `Hookd::ServerError` - Server error (5xx)
|
|
165
165
|
- `Hookd::ConnectionError` - Connection failed
|
|
166
166
|
|
|
167
|
-
##### `#
|
|
167
|
+
##### `#register_batch(specs)`
|
|
168
|
+
|
|
169
|
+
Register one hook per spec in a single request — e.g. one per injectable field,
|
|
170
|
+
each carrying its own context. Hooks come back in spec order; the server
|
|
171
|
+
creates all of them or none (up to 500 per call).
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
hooks = client.register_batch([
|
|
175
|
+
{ ttl: "7d", metadata: { endpoint_id: "e_412", param: "bio" } },
|
|
176
|
+
{ ttl: "7d", metadata: { endpoint_id: "e_413", param: "name" } }
|
|
177
|
+
])
|
|
178
|
+
# => [#<Hookd::Hook ...>, #<Hookd::Hook ...>]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
##### `#hooks(metadata: nil)`
|
|
182
|
+
|
|
183
|
+
List the long-lived hooks (without interactions) whose top-level metadata
|
|
184
|
+
matches every key/value given; omit `metadata` to list all. Useful to rebuild
|
|
185
|
+
your hook list after losing local state.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
client.hooks(metadata: { run_id: "0f3a" })
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
##### `#activity(metadata: nil)`
|
|
192
|
+
|
|
193
|
+
Pass `metadata:` to keep only matching hooks, so workers sharing a server each
|
|
194
|
+
see only their own.
|
|
195
|
+
|
|
168
196
|
|
|
169
197
|
List the long-lived hooks that currently have pending interactions, so you can
|
|
170
|
-
discover which fired without polling each one;
|
|
198
|
+
discover which fired without polling each one; fetch the details with `#read`
|
|
199
|
+
(or `#poll` to drain). Skip hooks whose `last_seq` is not above your cursor.
|
|
171
200
|
|
|
172
201
|
```ruby
|
|
173
202
|
client.activity.each do |a|
|
|
@@ -248,6 +277,39 @@ Raises:
|
|
|
248
277
|
- **Efficiency**: Automatic connection reuse with HTTPX
|
|
249
278
|
- **Atomic**: Consistent snapshot of all hooks
|
|
250
279
|
|
|
280
|
+
##### `#read(hook_id, after:)` and `#ack(hook_id, through:)`
|
|
281
|
+
|
|
282
|
+
`#poll` deletes what it returns, so a crash before you store the result loses
|
|
283
|
+
it. `#read` leaves the interactions on the server; `#ack` them once your own
|
|
284
|
+
write has committed. Keep the cursor (the last `seq` stored) on your side.
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
read = client.read(hook_id, after: cursor)
|
|
288
|
+
warn "interactions up to seq #{read.dropped_through} were evicted unread" if read.lost?(cursor)
|
|
289
|
+
|
|
290
|
+
unless read.interactions.empty?
|
|
291
|
+
last = read.interactions.last.seq
|
|
292
|
+
store(read.interactions) # raises: nothing acknowledged, read again later
|
|
293
|
+
client.ack(hook_id, through: last) # => number of interactions removed
|
|
294
|
+
cursor = last
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`#read` returns a `Hookd::CursorRead`; `#ack` returns the number removed. Both
|
|
299
|
+
raise `Hookd::NotFoundError` for an unknown hook and `Hookd::ServerError` when
|
|
300
|
+
the server fails, so a failed ack is never mistaken for success.
|
|
301
|
+
|
|
302
|
+
##### `#read_batch(cursors)` and `#ack_batch(cursors)`
|
|
303
|
+
|
|
304
|
+
The same for many hooks in one request, keyed by hook ID:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
client.read_batch("abc123" => 4, "def456" => 0)
|
|
308
|
+
# => { "abc123" => { interactions: [...], dropped_through: 0, error: nil }, ... }
|
|
309
|
+
client.ack_batch("abc123" => 6)
|
|
310
|
+
# => { "abc123" => { acknowledged: 2, error: nil } }
|
|
311
|
+
```
|
|
312
|
+
|
|
251
313
|
##### `#metrics`
|
|
252
314
|
|
|
253
315
|
Get server metrics (requires authentication).
|
|
@@ -281,12 +343,27 @@ Attributes:
|
|
|
281
343
|
- `hook` (`Hookd::Hook`) - The long-lived hook that fired
|
|
282
344
|
- `pending_count` (Integer) - Number of interactions awaiting poll
|
|
283
345
|
- `last_interaction_at` (String) - Timestamp of the most recent interaction
|
|
346
|
+
- `last_seq` (Integer) - Seq of the most recent interaction
|
|
347
|
+
|
|
348
|
+
#### `Hookd::CursorRead`
|
|
349
|
+
|
|
350
|
+
Returned by `#read`.
|
|
351
|
+
|
|
352
|
+
Attributes:
|
|
353
|
+
- `interactions` (Array<`Hookd::Interaction`>) - Interactions past the cursor
|
|
354
|
+
- `dropped_through` (Integer) - Highest seq evicted before acknowledgement
|
|
355
|
+
- `metadata` (Hash, nil) - Metadata attached at registration
|
|
356
|
+
|
|
357
|
+
Methods:
|
|
358
|
+
- `#lost?(after)` - True if interactions past `after` were evicted unread
|
|
284
359
|
|
|
285
360
|
#### `Hookd::Interaction`
|
|
286
361
|
|
|
287
362
|
Represents a captured DNS, HTTP or SMTP interaction.
|
|
288
363
|
|
|
289
364
|
Attributes:
|
|
365
|
+
- `id` (String) - Interaction identifier
|
|
366
|
+
- `seq` (Integer) - Per-hook sequence number, the cursor for `#read` / `#ack`
|
|
290
367
|
- `type` (String) - Interaction type ("dns", "http" or "smtp")
|
|
291
368
|
- `timestamp` (String) - When the interaction was captured
|
|
292
369
|
- `data` (Hash) - Interaction details
|
data/lib/hookd/client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'httpx'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'uri'
|
|
5
6
|
|
|
6
7
|
module Hookd
|
|
7
8
|
# HTTP client for interacting with Hookd server
|
|
@@ -46,6 +47,26 @@ module Hookd
|
|
|
46
47
|
parse_register_response(post('/register', register_body(count, ttl, metadata)))
|
|
47
48
|
end
|
|
48
49
|
|
|
50
|
+
# Register one hook per spec in a single request, each with its own ttl and
|
|
51
|
+
# metadata. The server creates all of them or none.
|
|
52
|
+
# @param specs [Array<Hash>] entries of { ttl: String or nil, metadata: Hash or nil }
|
|
53
|
+
# @return [Array<Hookd::Hook>] hooks in spec order
|
|
54
|
+
# @raise [ArgumentError] if specs is empty or not an array
|
|
55
|
+
def register_batch(specs)
|
|
56
|
+
raise ArgumentError, 'specs must be a non-empty array' unless specs.is_a?(Array) && !specs.empty?
|
|
57
|
+
|
|
58
|
+
body = specs.map { |spec| register_body(nil, spec[:ttl], spec[:metadata]) || {} }
|
|
59
|
+
hook_list(post('/register', { hooks: body }))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List long-lived hooks, without interactions, whose metadata matches every
|
|
63
|
+
# key/value of the filter
|
|
64
|
+
# @param metadata [Hash, nil] filter on top-level metadata keys
|
|
65
|
+
# @return [Array<Hookd::Hook>]
|
|
66
|
+
def hooks(metadata: nil)
|
|
67
|
+
hook_list(get("/hooks#{metadata_query(metadata)}"))
|
|
68
|
+
end
|
|
69
|
+
|
|
49
70
|
# Poll for interactions on a hook
|
|
50
71
|
# @param hook_id [String] the hook ID to poll
|
|
51
72
|
# @return [Array<Hookd::Interaction>] array of interactions (may be empty)
|
|
@@ -86,6 +107,60 @@ module Hookd
|
|
|
86
107
|
raise Error, "Invalid response format: #{e.message}"
|
|
87
108
|
end
|
|
88
109
|
|
|
110
|
+
# Read interactions past a cursor without deleting them; acknowledge them
|
|
111
|
+
# with #ack once stored.
|
|
112
|
+
# @param hook_id [String] the hook ID to read
|
|
113
|
+
# @param after [Integer] return interactions with a seq above this
|
|
114
|
+
# @return [Hookd::CursorRead]
|
|
115
|
+
# @raise [Hookd::NotFoundError] if hook not found
|
|
116
|
+
# @raise [Hookd::ServerError] if server returns 5xx
|
|
117
|
+
# @raise [ArgumentError] if after is invalid
|
|
118
|
+
def read(hook_id, after:)
|
|
119
|
+
validate_cursor(after, 'after')
|
|
120
|
+
response = get("/poll/#{hook_id}?after=#{after}")
|
|
121
|
+
|
|
122
|
+
CursorRead.new(
|
|
123
|
+
interactions: map_interactions(response['interactions']),
|
|
124
|
+
dropped_through: response['dropped_through'] || 0,
|
|
125
|
+
metadata: response['metadata']
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Delete interactions up to and including a seq
|
|
130
|
+
# @param hook_id [String] the hook ID
|
|
131
|
+
# @param through [Integer] the last seq stored by the caller
|
|
132
|
+
# @return [Integer] number of interactions removed
|
|
133
|
+
# @raise [Hookd::NotFoundError] if hook not found
|
|
134
|
+
# @raise [Hookd::ServerError] if server returns 5xx
|
|
135
|
+
# @raise [ArgumentError] if through is invalid
|
|
136
|
+
def ack(hook_id, through:)
|
|
137
|
+
validate_cursor(through, 'through')
|
|
138
|
+
handle_response(@http.delete("#{@server}/poll/#{hook_id}?through=#{through}"))['acknowledged']
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Read several hooks past their cursors in one request
|
|
142
|
+
# @param cursors [Hash<String, Integer>] hook ID to seq
|
|
143
|
+
# @return [Hash<String, Hash>] hook ID to
|
|
144
|
+
# { interactions: [...], dropped_through: Integer, error: String or nil }
|
|
145
|
+
def read_batch(cursors)
|
|
146
|
+
cursor_batch('/read', 'after', cursors).transform_values do |result|
|
|
147
|
+
{
|
|
148
|
+
interactions: map_interactions(result['interactions']),
|
|
149
|
+
dropped_through: result['dropped_through'] || 0,
|
|
150
|
+
error: result['error']
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Acknowledge several hooks in one request
|
|
156
|
+
# @param cursors [Hash<String, Integer>] hook ID to seq
|
|
157
|
+
# @return [Hash<String, Hash>] hook ID to { acknowledged: Integer, error: String or nil }
|
|
158
|
+
def ack_batch(cursors)
|
|
159
|
+
cursor_batch('/ack', 'through', cursors).transform_values do |result|
|
|
160
|
+
{ acknowledged: result['acknowledged'] || 0, error: result['error'] }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
89
164
|
# Get server metrics (requires authentication)
|
|
90
165
|
# @return [Hash] metrics data
|
|
91
166
|
# @raise [Hookd::AuthenticationError] if authentication fails
|
|
@@ -97,14 +172,15 @@ module Hookd
|
|
|
97
172
|
|
|
98
173
|
# List long-lived hooks that currently have pending interactions, so you can
|
|
99
174
|
# discover which of your long-lived hooks fired without polling each one.
|
|
100
|
-
#
|
|
175
|
+
# Fetch the details with #read (or #poll to drain). Returns an empty array when none have fired
|
|
101
176
|
# (or the server has long-lived hooks disabled).
|
|
102
177
|
# @return [Array<Hookd::HookActivity>]
|
|
103
178
|
# @raise [Hookd::AuthenticationError] if authentication fails
|
|
104
179
|
# @raise [Hookd::ServerError] if server returns 5xx
|
|
105
180
|
# @raise [Hookd::ConnectionError] if connection fails
|
|
106
|
-
|
|
107
|
-
|
|
181
|
+
# @param metadata [Hash, nil] keep only hooks whose metadata matches
|
|
182
|
+
def activity(metadata: nil)
|
|
183
|
+
response = get("/activity#{metadata_query(metadata)}")
|
|
108
184
|
|
|
109
185
|
hooks = response['hooks']
|
|
110
186
|
return [] if hooks.nil? || hooks.empty? || !hooks.is_a?(Array)
|
|
@@ -149,6 +225,33 @@ module Hookd
|
|
|
149
225
|
handle_response(response)
|
|
150
226
|
end
|
|
151
227
|
|
|
228
|
+
def metadata_query(metadata)
|
|
229
|
+
return '' if metadata.nil? || metadata.empty?
|
|
230
|
+
|
|
231
|
+
"?#{URI.encode_www_form(metadata.to_h { |k, v| ["metadata.#{k}", v.to_s] })}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def hook_list(response)
|
|
235
|
+
hooks = response['hooks']
|
|
236
|
+
raise Error, 'Invalid response format: missing hooks' unless hooks.is_a?(Array)
|
|
237
|
+
|
|
238
|
+
hooks.map { |h| Hook.from_hash(h) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def validate_cursor(seq, name)
|
|
242
|
+
raise ArgumentError, "#{name} must be a non-negative integer" unless seq.is_a?(Integer) && seq >= 0
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def cursor_batch(path, field, cursors)
|
|
246
|
+
raise ArgumentError, "#{field} must be a non-empty hash" unless cursors.is_a?(Hash) && !cursors.empty?
|
|
247
|
+
|
|
248
|
+
cursors.each_value { |seq| validate_cursor(seq, field) }
|
|
249
|
+
results = post(path, { field => cursors })['results']
|
|
250
|
+
raise Error, 'Invalid response format: missing results' unless results.is_a?(Hash)
|
|
251
|
+
|
|
252
|
+
results
|
|
253
|
+
end
|
|
254
|
+
|
|
152
255
|
def validate_hook_ids(hook_ids)
|
|
153
256
|
raise ArgumentError, 'hook_ids must be an array' unless hook_ids.is_a?(Array)
|
|
154
257
|
raise ArgumentError, 'hook_ids cannot be empty' if hook_ids.empty?
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hookd
|
|
4
|
+
# Result of a non-destructive Client#read
|
|
5
|
+
class CursorRead
|
|
6
|
+
attr_reader :interactions, :dropped_through, :metadata
|
|
7
|
+
|
|
8
|
+
def initialize(interactions:, dropped_through:, metadata: nil)
|
|
9
|
+
@interactions = interactions
|
|
10
|
+
@dropped_through = dropped_through
|
|
11
|
+
@metadata = metadata
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# True when the server evicted interactions past the cursor before they
|
|
15
|
+
# were acknowledged.
|
|
16
|
+
def lost?(after)
|
|
17
|
+
dropped_through > after
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_s
|
|
21
|
+
"#<Hookd::CursorRead interactions=#{interactions.size} dropped_through=#{dropped_through}>"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/hookd/hook_activity.rb
CHANGED
|
@@ -4,12 +4,14 @@ module Hookd
|
|
|
4
4
|
# Summarises a long-lived hook that currently has pending interactions,
|
|
5
5
|
# returned by Client#activity.
|
|
6
6
|
class HookActivity
|
|
7
|
-
|
|
7
|
+
# last_seq is the newest seq held: at or below a cursor, nothing is new.
|
|
8
|
+
attr_reader :hook, :pending_count, :last_interaction_at, :last_seq
|
|
8
9
|
|
|
9
|
-
def initialize(hook:, pending_count:, last_interaction_at:)
|
|
10
|
+
def initialize(hook:, pending_count:, last_interaction_at:, last_seq: nil)
|
|
10
11
|
@hook = hook
|
|
11
12
|
@pending_count = pending_count
|
|
12
13
|
@last_interaction_at = last_interaction_at
|
|
14
|
+
@last_seq = last_seq
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
# Create a HookActivity from an API response hash
|
|
@@ -19,7 +21,8 @@ module Hookd
|
|
|
19
21
|
new(
|
|
20
22
|
hook: Hook.from_hash(hash['hook']),
|
|
21
23
|
pending_count: hash['pending_count'],
|
|
22
|
-
last_interaction_at: hash['last_interaction_at']
|
|
24
|
+
last_interaction_at: hash['last_interaction_at'],
|
|
25
|
+
last_seq: hash['last_seq']
|
|
23
26
|
)
|
|
24
27
|
end
|
|
25
28
|
|
data/lib/hookd/interaction.rb
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
module Hookd
|
|
4
4
|
# Represents a captured DNS, HTTP or SMTP interaction
|
|
5
5
|
class Interaction
|
|
6
|
-
|
|
6
|
+
# seq increases per hook and is the cursor for Client#read and #ack.
|
|
7
|
+
attr_reader :id, :seq, :type, :timestamp, :source_ip, :data
|
|
7
8
|
|
|
8
|
-
def initialize(type:, timestamp:, source_ip:, data:)
|
|
9
|
+
def initialize(type:, timestamp:, source_ip:, data:, id: nil, seq: nil)
|
|
10
|
+
@id = id
|
|
11
|
+
@seq = seq
|
|
9
12
|
@type = type
|
|
10
13
|
@timestamp = timestamp
|
|
11
14
|
@source_ip = source_ip
|
|
@@ -15,6 +18,8 @@ module Hookd
|
|
|
15
18
|
# Create an Interaction from API response hash
|
|
16
19
|
def self.from_hash(hash)
|
|
17
20
|
new(
|
|
21
|
+
id: hash['id'],
|
|
22
|
+
seq: hash['seq'],
|
|
18
23
|
type: hash['type'],
|
|
19
24
|
timestamp: hash['timestamp'],
|
|
20
25
|
source_ip: hash['source_ip'],
|
|
@@ -38,11 +43,12 @@ module Hookd
|
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def to_s
|
|
41
|
-
"#<Hookd::Interaction type=#{type} timestamp=#{timestamp} source_ip=#{source_ip}>"
|
|
46
|
+
"#<Hookd::Interaction seq=#{seq} type=#{type} timestamp=#{timestamp} source_ip=#{source_ip}>"
|
|
42
47
|
end
|
|
43
48
|
|
|
44
49
|
def inspect
|
|
45
|
-
"#<Hookd::Interaction:#{object_id.to_s(16)} @
|
|
50
|
+
"#<Hookd::Interaction:#{object_id.to_s(16)} @id=#{id.inspect}, @seq=#{seq.inspect}, " \
|
|
51
|
+
"@type=#{type.inspect}, @timestamp=#{timestamp.inspect}, " \
|
|
46
52
|
"@source_ip=#{source_ip.inspect}, @data=#{data.inspect}>"
|
|
47
53
|
end
|
|
48
54
|
end
|
data/lib/hookd/version.rb
CHANGED
data/lib/hookd.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'hookd/error'
|
|
|
5
5
|
require_relative 'hookd/hook'
|
|
6
6
|
require_relative 'hookd/hook_activity'
|
|
7
7
|
require_relative 'hookd/interaction'
|
|
8
|
+
require_relative 'hookd/cursor_read'
|
|
8
9
|
require_relative 'hookd/client'
|
|
9
10
|
|
|
10
11
|
# Hookd client library for interacting with Hookd DNS/HTTP interaction server
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hookd-client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua MARTINELLE
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- README.md
|
|
35
35
|
- lib/hookd.rb
|
|
36
36
|
- lib/hookd/client.rb
|
|
37
|
+
- lib/hookd/cursor_read.rb
|
|
37
38
|
- lib/hookd/error.rb
|
|
38
39
|
- lib/hookd/hook.rb
|
|
39
40
|
- lib/hookd/hook_activity.rb
|