hookd-client 1.5.1 → 1.6.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 +28 -1
- data/lib/hookd/client.rb +19 -13
- data/lib/hookd/hook.rb +8 -5
- data/lib/hookd/owner_secret.rb +60 -0
- 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: 34ae0dd926717518a70cad0cb175f770147117b2154e758d4c2923d365d1b38a
|
|
4
|
+
data.tar.gz: 879aef64cc1a3bdae6c98c8206bd975f1352a03dc56b1267951445a37edc0e3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c2ee6ea4abf3dd624ec309c0c8a59b0245ba4f630504f4a2e3fb9d06c7c9449a6e057ee6aa0907a55b988f567ae6c93addb2c6f4c2a2762ac353c1d96814d46e
|
|
7
|
+
data.tar.gz: f11ab8a0431425559449126b85a87d158257c56f7714f6a67fd0b70862e6ffae561ec04d74d7ff72fec69f41ecd8647f272f0b8bf6330e5f3f4ff8cf799f1e13
|
data/README.md
CHANGED
|
@@ -110,13 +110,40 @@ end
|
|
|
110
110
|
|
|
111
111
|
The client requires two configuration parameters:
|
|
112
112
|
|
|
113
|
-
- `server`: The Hookd server URL (e.g., `https://hookd.example.com`)
|
|
113
|
+
- `server`: The Hookd server URL (e.g., `https://hookd.example.com`). When the server runs its private API listener (`server.api.listener`), use that listener's address instead, e.g. `https://hookd.example.com:8443`
|
|
114
114
|
- `token`: Authentication token for API access
|
|
115
115
|
|
|
116
116
|
An optional `max_response_bytes:` caps the response payload the client will read
|
|
117
117
|
(64 MiB by default; zero or less disables it). Exceeding it raises
|
|
118
118
|
`Hookd::ResponseTooLargeError`.
|
|
119
119
|
|
|
120
|
+
### Owner Secrets
|
|
121
|
+
|
|
122
|
+
When the server enables owner secrets (`server.api.owner_secret.enabled`), each
|
|
123
|
+
hook is readable only with the secret it was registered under, sent as the
|
|
124
|
+
`X-Hookd-Secret` header. Without a `secret:`, the first registration adopts the
|
|
125
|
+
one the server generates; every later request, registrations included, sends
|
|
126
|
+
it, so all the client's hooks share one owner. Store it to read them later:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
hook = client.register
|
|
130
|
+
secret = client.secret # generated by the server, returned only once
|
|
131
|
+
|
|
132
|
+
# Later, or from another process:
|
|
133
|
+
client = Hookd::Client.new(server: server, token: token, secret: secret)
|
|
134
|
+
client.poll(hook.id)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Every hook `#register` and `#register_batch` return also carries its owner
|
|
138
|
+
secret in `hook.secret`, so it is never lost, even if the client's secret
|
|
139
|
+
changes in the meantime.
|
|
140
|
+
|
|
141
|
+
To use your own secret (32 to 256 visible ASCII characters, at least 128 random
|
|
142
|
+
bits), pass `secret:` or set `client.secret =` before registering. For one owner
|
|
143
|
+
per hook, set `client.secret = nil` before each registration and keep each
|
|
144
|
+
`hook.secret`. On a server without owner secrets, nothing changes and
|
|
145
|
+
`client.secret` stays `nil`.
|
|
146
|
+
|
|
120
147
|
### API Reference
|
|
121
148
|
|
|
122
149
|
#### `Hookd::Client`
|
data/lib/hookd/client.rb
CHANGED
|
@@ -7,6 +7,8 @@ require 'uri'
|
|
|
7
7
|
module Hookd
|
|
8
8
|
# HTTP client for interacting with Hookd server
|
|
9
9
|
class Client
|
|
10
|
+
include OwnerSecret
|
|
11
|
+
|
|
10
12
|
# Caps the payload materialised as a String. Generous, since a poll can
|
|
11
13
|
# return many interactions with full bodies. Zero or less disables it.
|
|
12
14
|
DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
|
|
@@ -16,10 +18,13 @@ module Hookd
|
|
|
16
18
|
|
|
17
19
|
attr_reader :server, :token, :max_response_bytes
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
# @param secret [String, nil] owner secret sent with every request; when nil,
|
|
22
|
+
# the first registration adopts the one the server generates
|
|
23
|
+
def initialize(server:, token:, secret: nil, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
|
|
20
24
|
@server = server
|
|
21
25
|
@token = token
|
|
22
26
|
@max_response_bytes = max_response_bytes
|
|
27
|
+
init_secret(secret)
|
|
23
28
|
@http = HTTPX.with(
|
|
24
29
|
headers: { 'X-API-Key' => token },
|
|
25
30
|
timeout: {
|
|
@@ -44,7 +49,7 @@ module Hookd
|
|
|
44
49
|
def register(count: nil, ttl: nil, metadata: nil)
|
|
45
50
|
raise ArgumentError, 'count must be a positive integer' if count && (!count.is_a?(Integer) || count < 1)
|
|
46
51
|
|
|
47
|
-
parse_register_response(
|
|
52
|
+
parse_register_response(*post_register(register_body(count, ttl, metadata)))
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
# Register one hook per spec in a single request, each with its own ttl and
|
|
@@ -56,7 +61,7 @@ module Hookd
|
|
|
56
61
|
raise ArgumentError, 'specs must be a non-empty array' unless specs.is_a?(Array) && !specs.empty?
|
|
57
62
|
|
|
58
63
|
body = specs.map { |spec| register_body(nil, spec[:ttl], spec[:metadata]) || {} }
|
|
59
|
-
hook_list(
|
|
64
|
+
hook_list(*post_register({ hooks: body }))
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
# List long-lived hooks, without interactions, whose metadata matches every
|
|
@@ -98,7 +103,7 @@ module Hookd
|
|
|
98
103
|
validate_hook_ids(hook_ids)
|
|
99
104
|
|
|
100
105
|
url = "#{@server}/poll"
|
|
101
|
-
options = { headers: { 'Content-Type' => 'application/json' }, json: hook_ids }
|
|
106
|
+
options = { headers: { 'Content-Type' => 'application/json' }.merge(secret_headers), json: hook_ids }
|
|
102
107
|
response = @http.post(url, **options)
|
|
103
108
|
response_data = handle_response(response)
|
|
104
109
|
|
|
@@ -135,7 +140,8 @@ module Hookd
|
|
|
135
140
|
# @raise [ArgumentError] if through is invalid
|
|
136
141
|
def ack(hook_id, through:)
|
|
137
142
|
validate_cursor(through, 'through')
|
|
138
|
-
|
|
143
|
+
url = "#{@server}/poll/#{hook_id}?through=#{through}"
|
|
144
|
+
handle_response(@http.delete(url, headers: secret_headers))['acknowledged']
|
|
139
145
|
end
|
|
140
146
|
|
|
141
147
|
# Read several hooks past their cursors in one request
|
|
@@ -200,25 +206,25 @@ module Hookd
|
|
|
200
206
|
body.empty? ? nil : body
|
|
201
207
|
end
|
|
202
208
|
|
|
203
|
-
def parse_register_response(response)
|
|
209
|
+
def parse_register_response(response, secret)
|
|
204
210
|
# Single hook response (backward compatible)
|
|
205
|
-
return Hook.from_hash(response) if response.key?('id')
|
|
211
|
+
return Hook.from_hash(response, secret: secret) if response.key?('id')
|
|
206
212
|
|
|
207
213
|
# Multiple hooks response
|
|
208
214
|
return [] if response['hooks'].nil? || response['hooks'].empty?
|
|
209
215
|
|
|
210
|
-
response['hooks'].map { |h| Hook.from_hash(h) }
|
|
216
|
+
response['hooks'].map { |h| Hook.from_hash(h, secret: secret) }
|
|
211
217
|
end
|
|
212
218
|
|
|
213
219
|
def get(path)
|
|
214
220
|
url = "#{@server}#{path}"
|
|
215
|
-
response = @http.get(url)
|
|
221
|
+
response = @http.get(url, headers: secret_headers)
|
|
216
222
|
handle_response(response)
|
|
217
223
|
end
|
|
218
224
|
|
|
219
|
-
def post(path, body = nil)
|
|
225
|
+
def post(path, body = nil, as: secret)
|
|
220
226
|
url = "#{@server}#{path}"
|
|
221
|
-
options = { headers: { 'Content-Type' => 'application/json' } }
|
|
227
|
+
options = { headers: { 'Content-Type' => 'application/json' }.merge(secret_headers(as)) }
|
|
222
228
|
options[:json] = body if body
|
|
223
229
|
|
|
224
230
|
response = @http.post(url, **options)
|
|
@@ -231,11 +237,11 @@ module Hookd
|
|
|
231
237
|
"?#{URI.encode_www_form(metadata.to_h { |k, v| ["metadata.#{k}", v.to_s] })}"
|
|
232
238
|
end
|
|
233
239
|
|
|
234
|
-
def hook_list(response)
|
|
240
|
+
def hook_list(response, secret = nil)
|
|
235
241
|
hooks = response['hooks']
|
|
236
242
|
raise Error, 'Invalid response format: missing hooks' unless hooks.is_a?(Array)
|
|
237
243
|
|
|
238
|
-
hooks.map { |h| Hook.from_hash(h) }
|
|
244
|
+
hooks.map { |h| Hook.from_hash(h, secret: secret) }
|
|
239
245
|
end
|
|
240
246
|
|
|
241
247
|
def validate_cursor(seq, name)
|
data/lib/hookd/hook.rb
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
module Hookd
|
|
4
4
|
# Represents a registered hook with its endpoints. smtp is set only when the
|
|
5
5
|
# server runs a mail listener. expires_at and metadata are populated for
|
|
6
|
-
# long-lived hooks (registered with a ttl) and nil otherwise.
|
|
6
|
+
# long-lived hooks (registered with a ttl) and nil otherwise. secret is the
|
|
7
|
+
# owner secret a registered hook belongs to, on servers with owner secrets.
|
|
7
8
|
class Hook
|
|
8
|
-
attr_reader :id, :dns, :http, :https, :smtp, :created_at, :expires_at, :metadata
|
|
9
|
+
attr_reader :id, :dns, :http, :https, :smtp, :created_at, :expires_at, :metadata, :secret
|
|
9
10
|
|
|
10
|
-
def initialize(id:, dns:, http:, https:, created_at:, smtp: nil, expires_at: nil, metadata: nil)
|
|
11
|
+
def initialize(id:, dns:, http:, https:, created_at:, smtp: nil, expires_at: nil, metadata: nil, secret: nil)
|
|
11
12
|
@id = id
|
|
12
13
|
@dns = dns
|
|
13
14
|
@http = http
|
|
@@ -16,10 +17,11 @@ module Hookd
|
|
|
16
17
|
@created_at = created_at
|
|
17
18
|
@expires_at = expires_at
|
|
18
19
|
@metadata = metadata
|
|
20
|
+
@secret = secret
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# Create a Hook from API response hash
|
|
22
|
-
def self.from_hash(hash)
|
|
24
|
+
def self.from_hash(hash, secret: nil)
|
|
23
25
|
raise ArgumentError, "Invalid hash: expected Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
24
26
|
|
|
25
27
|
new(
|
|
@@ -30,7 +32,8 @@ module Hookd
|
|
|
30
32
|
smtp: hash['smtp'],
|
|
31
33
|
created_at: hash['created_at'],
|
|
32
34
|
expires_at: hash['expires_at'],
|
|
33
|
-
metadata: hash['metadata']
|
|
35
|
+
metadata: hash['metadata'],
|
|
36
|
+
secret: secret
|
|
34
37
|
)
|
|
35
38
|
end
|
|
36
39
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hookd
|
|
4
|
+
# Owner secret handling for servers with owner secrets enabled, where each
|
|
5
|
+
# hook is readable only with the secret it was registered under.
|
|
6
|
+
module OwnerSecret
|
|
7
|
+
# Header carrying the owner secret.
|
|
8
|
+
SECRET_HEADER = 'X-Hookd-Secret'
|
|
9
|
+
|
|
10
|
+
# The owner secret in use, possibly generated by the server on the first
|
|
11
|
+
# registration. Store it to read the hooks later.
|
|
12
|
+
# @return [String, nil]
|
|
13
|
+
def secret
|
|
14
|
+
@secret_lock.synchronize { @secret }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Set the owner secret; nil lets the next registration adopt a new one.
|
|
18
|
+
# @param value [String, nil]
|
|
19
|
+
def secret=(value)
|
|
20
|
+
value = nil if value == ''
|
|
21
|
+
@secret_lock.synchronize do
|
|
22
|
+
@secret = value
|
|
23
|
+
@settled = !value.nil?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def init_secret(value)
|
|
30
|
+
@secret_lock = Mutex.new
|
|
31
|
+
@register_lock = Mutex.new
|
|
32
|
+
self.secret = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Registers and returns the response with the secret its hooks belong to:
|
|
36
|
+
# the one sent, or the one the server generated, adopted when the client
|
|
37
|
+
# has none. Until a secret is known, registrations run one at a time, so
|
|
38
|
+
# concurrent first calls end up under the same owner.
|
|
39
|
+
def post_register(body)
|
|
40
|
+
return register_as(body) if @secret_lock.synchronize { @settled }
|
|
41
|
+
|
|
42
|
+
@register_lock.synchronize { register_as(body) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def register_as(body)
|
|
46
|
+
sent = secret
|
|
47
|
+
response = post('/register', body, as: sent)
|
|
48
|
+
owner = (response['secret'] if response.is_a?(Hash)) || sent
|
|
49
|
+
@secret_lock.synchronize do
|
|
50
|
+
@secret ||= owner
|
|
51
|
+
@settled = true
|
|
52
|
+
end
|
|
53
|
+
[response, owner]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def secret_headers(value = secret)
|
|
57
|
+
value ? { SECRET_HEADER => value } : {}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/hookd/version.rb
CHANGED
data/lib/hookd.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'hookd/hook'
|
|
|
6
6
|
require_relative 'hookd/hook_activity'
|
|
7
7
|
require_relative 'hookd/interaction'
|
|
8
8
|
require_relative 'hookd/cursor_read'
|
|
9
|
+
require_relative 'hookd/owner_secret'
|
|
9
10
|
require_relative 'hookd/client'
|
|
10
11
|
|
|
11
12
|
# 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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua MARTINELLE
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- lib/hookd/hook.rb
|
|
40
40
|
- lib/hookd/hook_activity.rb
|
|
41
41
|
- lib/hookd/interaction.rb
|
|
42
|
+
- lib/hookd/owner_secret.rb
|
|
42
43
|
- lib/hookd/version.rb
|
|
43
44
|
homepage: https://github.com/JoshuaMart/Hookd
|
|
44
45
|
licenses:
|