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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5ff2c6d9ea446cd5631e2c3f4d3f27a0586a130acaf6bf9e10f9a7fb6613efc3
4
- data.tar.gz: 38021e8c18baee9ff374c60d305cc6312cf024a2fff120e926cfa89d9d44ea52
3
+ metadata.gz: 85c9d3bb1f54b986026f6815e255ecdb515ad9e9bae6a4c78708fcf0f5d133b2
4
+ data.tar.gz: a8a93a75b73de9eba86444c0104643388f64fb71969c19588465071a6aa2272a
5
5
  SHA512:
6
- metadata.gz: 3cc77be594c69256a2de2e31a23a7654dddbf94c50f0402fa327e3865e2fa25af4c4c4dfab6e9ca641aa611b20da6e22760646d570676a4a87e92d08382f3af8
7
- data.tar.gz: 1d2da85d42bfff6ba7bcf2e1211b418c7bc8944ce8119169388e1b51cec085d64a54ceb4372ea5b5869714c792836cd193b1600b526c40021ead72cf8a492d28
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
- ##### `#activity`
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; drain the details with `#poll`.
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
- # Drain the details with #poll. Returns an empty array when none have fired
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
- def activity
107
- response = get('/activity')
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
@@ -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
- attr_reader :hook, :pending_count, :last_interaction_at
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
 
@@ -3,9 +3,12 @@
3
3
  module Hookd
4
4
  # Represents a captured DNS, HTTP or SMTP interaction
5
5
  class Interaction
6
- attr_reader :type, :timestamp, :source_ip, :data
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)} @type=#{type.inspect}, @timestamp=#{timestamp.inspect}, " \
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hookd
4
- VERSION = '1.4.0'
4
+ VERSION = '1.5.0'
5
5
  end
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.0
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