patient_http 1.0.0 → 1.1.1
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 +12 -0
- data/README.md +36 -0
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +4 -2
- data/lib/patient_http/configuration.rb +47 -19
- data/lib/patient_http/processor.rb +11 -0
- data/lib/patient_http/request.rb +54 -4
- data/lib/patient_http/secret_manager.rb +97 -0
- data/lib/patient_http/secret_reference.rb +83 -0
- data/lib/patient_http/synchronous_executor.rb +11 -3
- data/lib/patient_http.rb +18 -3
- 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: dbb8a32f746745abcfd6728c31f6460793440abc3b5bf42f4e0702b7cdd2987e
|
|
4
|
+
data.tar.gz: 482823627d13df33a1a262341c2dd2f8512d466f595edbb2414c573d96d7cd8d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e31bfe3ec727df7c534a93b74c4bb66ee36e45b5ba587fb11751198f360d82d9d0ca01ac43c186244bf5b125f73cfb7588e1e438508124f0c25000bdb7b3e42
|
|
7
|
+
data.tar.gz: 573b8de414ef3ca0251b7cbb946c33f7e9a4a24eeefa1f54823f8c86454243998a3517935f8dc9612c4f61bcc4a1d7d778d7df5ab74c33cbaad47614d26cf96a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 1.1.1
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Connection pools are now closed gracefully inside the reactor during shutdown. Previously the reactor stopped with open pools, causing async-pool to force-cancel each connection pool's background gardener task mid-wait and emit a noisy (but harmless) `ThreadError: Attempt to unlock a mutex which is not locked` warning when the process was killed.
|
|
12
|
+
|
|
13
|
+
## 1.1.0
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Secret manager for referencing sensitive headers and query parameters indirectly. Register secrets on the `Configuration` with `register_secret` (static value or lazy block), then reference them when building a request via `PatientHttp.secret(name)`. The serialized request stores only a `{"$secret" => name}` reference; the value is resolved by the processor when the request is sent, keeping sensitive values out of the job queue and logs.
|
|
18
|
+
|
|
7
19
|
## 1.0.0
|
|
8
20
|
|
|
9
21
|
### Added
|
data/README.md
CHANGED
|
@@ -485,6 +485,39 @@ end
|
|
|
485
485
|
|
|
486
486
|
Encrypted data is stored as `{"__encrypted__" => true, "value" => "<base64>"}`. The `Encryptor` JSON-serializes the original hash, passes the bytes to your callable, and Base64-encodes the result. Decryption reverses the process. Hashes without the `"__encrypted__"` key are passed through unchanged, so un-encrypted historical data continues to work while you roll out encryption.
|
|
487
487
|
|
|
488
|
+
## Secrets
|
|
489
|
+
|
|
490
|
+
Requests are serialized into your job queue before they run. If you put a sensitive value — an API token in an `Authorization` header, or an API key in a query parameter — directly on the request, that value is written into the queue. Requests can be encrypted in the queue, but a better practice is to avoid putting sensitive values on the request at all.
|
|
491
|
+
|
|
492
|
+
The secret manager lets you reference a sensitive values in headers or query parameters by name instead. The serialized request stores only a reference marker (`{"$secret" => "name"}`), never the value. The actual value lives on the `Configuration` (which exists on the processor side) and is resolved at the moment the request is sent.
|
|
493
|
+
|
|
494
|
+
### Defining secrets
|
|
495
|
+
|
|
496
|
+
Register named secrets on the `Configuration`. A value can be given directly, or as a block that is evaluated lazily each time the secret is resolved (useful for reading from the environment on demand):
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
config = PatientHttp::Configuration.new
|
|
500
|
+
config.register_secret(:authorization, "Bearer #{ENV['API_TOKEN']}") # static value
|
|
501
|
+
config.register_secret(:api_key) { ENV["MY_API_KEY"] } # lazy block
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
I a secret is not found when resolving a request, a `PatientHttp::SecretManager::SecretNotFoundError` is raised, which surfaces through the normal request error path.
|
|
505
|
+
|
|
506
|
+
### Referencing secrets when building a request
|
|
507
|
+
|
|
508
|
+
Use `PatientHttp.secret(name)` anywhere you would put a sensitive header or query parameter value. No value is needed (or available) at build time:
|
|
509
|
+
|
|
510
|
+
```ruby
|
|
511
|
+
PatientHttp.get(
|
|
512
|
+
"https://api.example.com/data",
|
|
513
|
+
callback: MyCallback,
|
|
514
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
515
|
+
params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
|
|
516
|
+
)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
The request serializes the secret header as `{"$secret" => "api_token"}` and keeps the secret query parameter out of the URL (non-secret params like `page` are still folded into the URL as usual). The processor dereferences both just before sending: the header is set to its resolved value and the resolved query parameter is appended to the URL.
|
|
520
|
+
|
|
488
521
|
## Configuration
|
|
489
522
|
|
|
490
523
|
```ruby
|
|
@@ -525,6 +558,9 @@ config = PatientHttp::Configuration.new(
|
|
|
525
558
|
# Logger instance (default: Logger to STDERR at ERROR level)
|
|
526
559
|
logger: Logger.new($stdout)
|
|
527
560
|
)
|
|
561
|
+
|
|
562
|
+
# Register named secrets to reference sensitive headers/params indirectly (see Secrets)
|
|
563
|
+
config.register_secret(:api_token, ENV["MY_API_TOKEN"])
|
|
528
564
|
```
|
|
529
565
|
|
|
530
566
|
### Tuning Tips
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.1.1
|
data/lib/patient_http/client.rb
CHANGED
|
@@ -23,11 +23,12 @@ module PatientHttp
|
|
|
23
23
|
|
|
24
24
|
begin
|
|
25
25
|
headers = request_headers(request, request_id)
|
|
26
|
+
url = config.secret_manager.resolve_url(request.url, request.secret_params)
|
|
26
27
|
body = Protocol::HTTP::Body::Buffered.wrap([request.body.to_s]) if request.body
|
|
27
28
|
timeout = request.timeout || config.request_timeout
|
|
28
29
|
|
|
29
30
|
Async::Task.current.with_timeout(timeout) do
|
|
30
|
-
async_response = @client_pool.request(request.http_method,
|
|
31
|
+
async_response = @client_pool.request(request.http_method, url, headers, body)
|
|
31
32
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
32
33
|
body = @response_reader.read_body(async_response, headers_hash)
|
|
33
34
|
|
|
@@ -72,7 +73,8 @@ module PatientHttp
|
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
def request_headers(request, request_id)
|
|
75
|
-
headers = request.headers.to_h
|
|
76
|
+
headers = config.secret_manager.resolve_headers(request.headers.to_h)
|
|
77
|
+
headers["x-request-id"] = request_id
|
|
76
78
|
headers["user-agent"] ||= config.user_agent if config.user_agent
|
|
77
79
|
headers
|
|
78
80
|
end
|
|
@@ -46,6 +46,9 @@ module PatientHttp
|
|
|
46
46
|
# @return [Integer] Number of retries for failed requests
|
|
47
47
|
attr_reader :retries
|
|
48
48
|
|
|
49
|
+
# @return [SecretManager] the secret manager instance
|
|
50
|
+
attr_reader :secret_manager
|
|
51
|
+
|
|
49
52
|
# Initializes a new Configuration with the specified options.
|
|
50
53
|
#
|
|
51
54
|
# @param max_connections [Integer] Maximum number of concurrent connections
|
|
@@ -75,10 +78,15 @@ module PatientHttp
|
|
|
75
78
|
retries: 3,
|
|
76
79
|
encryption_key: nil
|
|
77
80
|
)
|
|
81
|
+
@mutex = Mutex.new
|
|
82
|
+
|
|
78
83
|
# Initialize payload store configuration
|
|
79
84
|
@payload_stores = {}
|
|
80
85
|
@default_payload_store_name = nil
|
|
81
|
-
|
|
86
|
+
|
|
87
|
+
# Initialize secret configuration
|
|
88
|
+
@secrets = {}
|
|
89
|
+
@secret_manager = SecretManager.new
|
|
82
90
|
|
|
83
91
|
@encryptor = nil
|
|
84
92
|
|
|
@@ -215,6 +223,33 @@ module PatientHttp
|
|
|
215
223
|
@encryptor ||= Encryptor.new(encryption: @encryption, decryption: @decryption)
|
|
216
224
|
end
|
|
217
225
|
|
|
226
|
+
# Register a named secret whose value can be referenced indirectly when building
|
|
227
|
+
# requests via {PatientHttp.secret}.
|
|
228
|
+
#
|
|
229
|
+
# The value can be provided directly or as a block (callable). A block is invoked
|
|
230
|
+
# lazily with the secret name each time the secret is resolved, which is useful for
|
|
231
|
+
# values that should be read on demand (for example, from the environment).
|
|
232
|
+
#
|
|
233
|
+
# @param name [String, Symbol] the secret name
|
|
234
|
+
# @param value [Object, nil] the secret value (omit when providing a block)
|
|
235
|
+
# @yield [name] a block that returns the secret value (omit when providing a value)
|
|
236
|
+
# @raise [ArgumentError] if neither or both of value and block are provided
|
|
237
|
+
# @return [void]
|
|
238
|
+
def register_secret(name, value = nil, &block)
|
|
239
|
+
if value.nil? && block.nil?
|
|
240
|
+
raise ArgumentError.new("register_secret requires a value or a block")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if !value.nil? && block
|
|
244
|
+
raise ArgumentError.new("register_secret accepts either a value or a block, not both")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
@mutex.synchronize do
|
|
248
|
+
@secrets[name.to_s] = block || value
|
|
249
|
+
@secret_manager = SecretManager.new(secrets: @secrets.dup)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
218
253
|
# Register a payload store for external storage of large payloads.
|
|
219
254
|
#
|
|
220
255
|
# The name is included in the serialized references to the stored data.
|
|
@@ -243,8 +278,8 @@ module PatientHttp
|
|
|
243
278
|
|
|
244
279
|
store = PayloadStore::Base.create(adapter, **options)
|
|
245
280
|
|
|
246
|
-
@
|
|
247
|
-
@payload_stores
|
|
281
|
+
@mutex.synchronize do
|
|
282
|
+
@payload_stores = @payload_stores.merge(name => store)
|
|
248
283
|
@default_payload_store_name = name
|
|
249
284
|
end
|
|
250
285
|
end
|
|
@@ -254,33 +289,25 @@ module PatientHttp
|
|
|
254
289
|
# @param name [Symbol, String, nil] Store name. If nil, returns the default store.
|
|
255
290
|
# @return [PayloadStore::Base, nil] The store instance or nil if not found
|
|
256
291
|
def payload_store(name = nil)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return nil unless @default_payload_store_name
|
|
292
|
+
if name.nil?
|
|
293
|
+
return nil unless @default_payload_store_name
|
|
260
294
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
end
|
|
295
|
+
@payload_stores[@default_payload_store_name]
|
|
296
|
+
else
|
|
297
|
+
@payload_stores[name.to_sym]
|
|
265
298
|
end
|
|
266
299
|
end
|
|
267
300
|
|
|
268
301
|
# Get the name of the default payload store.
|
|
269
302
|
#
|
|
270
303
|
# @return [Symbol, nil] The default store name or nil if none registered
|
|
271
|
-
|
|
272
|
-
@payload_store_mutex.synchronize do
|
|
273
|
-
@default_payload_store_name
|
|
274
|
-
end
|
|
275
|
-
end
|
|
304
|
+
attr_reader :default_payload_store_name
|
|
276
305
|
|
|
277
306
|
# Get all registered payload stores.
|
|
278
307
|
#
|
|
279
308
|
# @return [Hash{Symbol => PayloadStore::Base}] Copy of registered stores
|
|
280
309
|
def payload_stores
|
|
281
|
-
@
|
|
282
|
-
@payload_stores.dup
|
|
283
|
-
end
|
|
310
|
+
@payload_stores.dup
|
|
284
311
|
end
|
|
285
312
|
|
|
286
313
|
# Convert to hash for inspection
|
|
@@ -300,7 +327,8 @@ module PatientHttp
|
|
|
300
327
|
"proxy_url" => proxy_url,
|
|
301
328
|
"retries" => retries,
|
|
302
329
|
"payload_stores" => payload_stores.keys,
|
|
303
|
-
"default_payload_store" => default_payload_store_name
|
|
330
|
+
"default_payload_store" => default_payload_store_name,
|
|
331
|
+
"secrets" => @mutex.synchronize { @secrets.keys }
|
|
304
332
|
}
|
|
305
333
|
end
|
|
306
334
|
|
|
@@ -328,6 +328,17 @@ module PatientHttp
|
|
|
328
328
|
@config.logger&.info("[PatientHttp] Reactor received stop signal")
|
|
329
329
|
rescue => e
|
|
330
330
|
@config.logger&.error("[PatientHttp] Reactor loop error: #{e.inspect}\n#{e.backtrace.join("\n")}")
|
|
331
|
+
ensure
|
|
332
|
+
# Close the HTTP connection pools while still inside the reactor so the
|
|
333
|
+
# pools' background gardener tasks shut down gracefully. Otherwise the
|
|
334
|
+
# reactor stops with open pools and async-pool force-cancels each
|
|
335
|
+
# gardener mid-wait, emitting a noisy (but harmless) ThreadError:
|
|
336
|
+
# "Attempt to unlock a mutex which is not locked".
|
|
337
|
+
begin
|
|
338
|
+
@http_client.close
|
|
339
|
+
rescue => e
|
|
340
|
+
@config.logger&.error("[PatientHttp] Error closing HTTP client: #{e.inspect}")
|
|
341
|
+
end
|
|
331
342
|
end
|
|
332
343
|
end
|
|
333
344
|
|
data/lib/patient_http/request.rb
CHANGED
|
@@ -34,6 +34,10 @@ module PatientHttp
|
|
|
34
34
|
# @return [Integer, nil] Maximum number of redirects to follow (nil uses config default, 0 disables)
|
|
35
35
|
attr_reader :max_redirects
|
|
36
36
|
|
|
37
|
+
# @return [Hash{String, Symbol => SecretReference}] Query parameters whose values are
|
|
38
|
+
# secret references, kept out of the serialized URL and resolved at send time
|
|
39
|
+
attr_reader :secret_params
|
|
40
|
+
|
|
37
41
|
class << self
|
|
38
42
|
# Reconstruct a Request from a hash
|
|
39
43
|
#
|
|
@@ -43,12 +47,31 @@ module PatientHttp
|
|
|
43
47
|
new(
|
|
44
48
|
hash["http_method"].to_sym,
|
|
45
49
|
hash["url"],
|
|
46
|
-
headers: hash["headers"],
|
|
50
|
+
headers: load_headers(hash["headers"]),
|
|
47
51
|
body: Payload.load(hash["body"])&.value,
|
|
52
|
+
params: load_secret_params(hash["secret_params"]),
|
|
48
53
|
timeout: hash["timeout"],
|
|
49
54
|
max_redirects: hash["max_redirects"]
|
|
50
55
|
)
|
|
51
56
|
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Convert serialized secret-reference header markers back into SecretReference
|
|
61
|
+
# objects, leaving plain header values unchanged.
|
|
62
|
+
def load_headers(headers)
|
|
63
|
+
return headers if headers.nil?
|
|
64
|
+
|
|
65
|
+
headers.transform_values { |value| SecretReference.load(value) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Reconstruct secret params from their serialized markers. Returned as a params
|
|
69
|
+
# hash so the constructor folds them back into the request's secret params.
|
|
70
|
+
def load_secret_params(secret_params)
|
|
71
|
+
return nil if secret_params.nil? || secret_params.empty?
|
|
72
|
+
|
|
73
|
+
secret_params.transform_values { |value| SecretReference.load(value) }
|
|
74
|
+
end
|
|
52
75
|
end
|
|
53
76
|
|
|
54
77
|
# Initializes a new Request.
|
|
@@ -77,6 +100,7 @@ module PatientHttp
|
|
|
77
100
|
raise ArgumentError.new("url must be a String or URI, got: #{url.class}")
|
|
78
101
|
end
|
|
79
102
|
|
|
103
|
+
@secret_params = {}
|
|
80
104
|
@url = normalized_url(url, params)
|
|
81
105
|
@headers = headers.is_a?(HttpHeaders) ? headers : HttpHeaders.new(headers)
|
|
82
106
|
@body = (body == "") ? nil : body
|
|
@@ -109,23 +133,49 @@ module PatientHttp
|
|
|
109
133
|
#
|
|
110
134
|
# @return [Hash]
|
|
111
135
|
def as_json
|
|
112
|
-
{
|
|
136
|
+
hash = {
|
|
113
137
|
"http_method" => @http_method.to_s,
|
|
114
138
|
"url" => @url.to_s,
|
|
115
|
-
"headers" =>
|
|
139
|
+
"headers" => serialized_headers,
|
|
116
140
|
"body" => @payload&.as_json,
|
|
117
141
|
"timeout" => @timeout,
|
|
118
142
|
"max_redirects" => @max_redirects
|
|
119
143
|
}
|
|
144
|
+
|
|
145
|
+
if @secret_params.any?
|
|
146
|
+
hash["secret_params"] = @secret_params.transform_values(&:as_json)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
hash
|
|
120
150
|
end
|
|
121
151
|
|
|
122
152
|
private
|
|
123
153
|
|
|
154
|
+
# Header values may be SecretReference objects; serialize those as markers.
|
|
155
|
+
def serialized_headers
|
|
156
|
+
@headers.to_h.transform_values do |value|
|
|
157
|
+
value.is_a?(SecretReference) ? value.as_json : value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
124
161
|
def normalized_url(url, params)
|
|
125
162
|
uri = url.is_a?(URI::Generic) ? url.dup : URI(url.to_s)
|
|
126
163
|
return uri.to_s unless params&.any?
|
|
127
164
|
|
|
128
|
-
|
|
165
|
+
# Partition out secret params: they are kept off the serialized URL and resolved
|
|
166
|
+
# at send time by the processor. Only non-secret params are folded into the URL.
|
|
167
|
+
regular_params = {}
|
|
168
|
+
params.each do |key, value|
|
|
169
|
+
if SecretReference.reference?(value)
|
|
170
|
+
@secret_params[key] = SecretReference.load(value)
|
|
171
|
+
else
|
|
172
|
+
regular_params[key] = value
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return uri.to_s if regular_params.empty?
|
|
177
|
+
|
|
178
|
+
serialized_params = URI.encode_www_form(regular_params)
|
|
129
179
|
uri.query = [uri.query, serialized_params].compact.reject(&:empty?).join("&")
|
|
130
180
|
uri.to_s
|
|
131
181
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Resolves {SecretReference} values into their actual secret values when a request
|
|
5
|
+
# is sent by the processor.
|
|
6
|
+
#
|
|
7
|
+
# A SecretManager is built from the secrets registered on the {Configuration}.
|
|
8
|
+
#
|
|
9
|
+
# @see Configuration#secret_manager
|
|
10
|
+
class SecretManager
|
|
11
|
+
# Raised when a referenced secret cannot be resolved.
|
|
12
|
+
class SecretNotFoundError < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Initialize a new SecretManager.
|
|
15
|
+
#
|
|
16
|
+
# @param secrets [Hash{String => Object}] static registry mapping names to values
|
|
17
|
+
# (a value may be a callable, which is invoked with the name to produce the value)
|
|
18
|
+
# secret not found in the static registry
|
|
19
|
+
def initialize(secrets: {})
|
|
20
|
+
@secrets = secrets || {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check if a secret name is registered in the static registry.
|
|
24
|
+
#
|
|
25
|
+
# @param name [String, Symbol] the secret name
|
|
26
|
+
# @return [Boolean] true if the name is registered, false otherwise
|
|
27
|
+
def include?(name)
|
|
28
|
+
@secrets.include?(name.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolve a secret by name.
|
|
32
|
+
#
|
|
33
|
+
# The static registry is checked first; if the registered value responds to #call
|
|
34
|
+
# it is invoked with the name. If the name is not in the registry, an error is raised.
|
|
35
|
+
#
|
|
36
|
+
# @param name [String, Symbol] the secret name
|
|
37
|
+
# @return [String] the resolved secret value
|
|
38
|
+
# @raise [SecretNotFoundError] if the secret cannot be resolved
|
|
39
|
+
def resolve(name)
|
|
40
|
+
name = name.to_s
|
|
41
|
+
|
|
42
|
+
unless @secrets.include?(name)
|
|
43
|
+
raise SecretNotFoundError.new("No secret registered for #{name.inspect}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
value = @secrets[name]
|
|
47
|
+
value = value.call(name) if value.respond_to?(:call)
|
|
48
|
+
value&.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Resolve any secret references in a headers hash, returning a new hash.
|
|
52
|
+
#
|
|
53
|
+
# @param headers [Hash, nil] header name/value pairs
|
|
54
|
+
# @return [Hash, nil] a new hash with secret references replaced by resolved values
|
|
55
|
+
def resolve_headers(headers)
|
|
56
|
+
resolve_values(headers)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolve any secret references in a params hash, returning a new hash.
|
|
60
|
+
#
|
|
61
|
+
# @param params [Hash, nil] param name/value pairs
|
|
62
|
+
# @return [Hash, nil] a new hash with secret references replaced by resolved values
|
|
63
|
+
def resolve_params(params)
|
|
64
|
+
resolve_values(params)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Append resolved secret params to a URL's query string.
|
|
68
|
+
#
|
|
69
|
+
# @param url [String] the request URL
|
|
70
|
+
# @param secret_params [Hash, nil] secret param name/value (SecretReference) pairs
|
|
71
|
+
# @return [String] the URL with resolved secret params appended (unchanged if none)
|
|
72
|
+
def resolve_url(url, secret_params)
|
|
73
|
+
return url if secret_params.nil? || secret_params.empty?
|
|
74
|
+
|
|
75
|
+
serialized_params = URI.encode_www_form(resolve_params(secret_params))
|
|
76
|
+
uri = URI(url)
|
|
77
|
+
uri.query = [uri.query, serialized_params].compact.reject(&:empty?).join("&")
|
|
78
|
+
uri.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Return a new hash with any secret-reference values replaced by their resolved
|
|
84
|
+
# values. Non-secret values are passed through unchanged.
|
|
85
|
+
def resolve_values(hash)
|
|
86
|
+
return hash if hash.nil?
|
|
87
|
+
|
|
88
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
89
|
+
result[key] = if SecretReference.reference?(value)
|
|
90
|
+
resolve(SecretReference.load(value).name)
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# A reference to a named secret that can be used as a header or query parameter
|
|
5
|
+
# value when building a {Request}.
|
|
6
|
+
#
|
|
7
|
+
# A SecretReference holds only the secret's name -- never its value. When a request
|
|
8
|
+
# is serialized (for example, to be enqueued in a job system), the reference is
|
|
9
|
+
# serialized as a lightweight marker (`{"$secret" => name}`) so the sensitive value
|
|
10
|
+
# is never written to the queue or logs. The actual value is resolved on the
|
|
11
|
+
# processor side at the moment the request is sent, using the secrets registered on
|
|
12
|
+
# the {Configuration}.
|
|
13
|
+
#
|
|
14
|
+
# @example Referencing a secret when building a request
|
|
15
|
+
# PatientHttp.get(
|
|
16
|
+
# "https://api.example.com/data",
|
|
17
|
+
# callback: MyCallback,
|
|
18
|
+
# headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
19
|
+
# params: {"api_key" => PatientHttp.secret(:api_key)}
|
|
20
|
+
# )
|
|
21
|
+
class SecretReference
|
|
22
|
+
# Key used in serialized JSON to indicate a secret reference.
|
|
23
|
+
REFERENCE_KEY = "$secret"
|
|
24
|
+
|
|
25
|
+
# @return [String] the name of the referenced secret
|
|
26
|
+
attr_reader :name
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
# Check if a value is a secret reference (either a SecretReference instance or a
|
|
30
|
+
# serialized marker hash).
|
|
31
|
+
#
|
|
32
|
+
# @param value [Object] the value to check
|
|
33
|
+
# @return [Boolean] true if the value is a secret reference
|
|
34
|
+
def reference?(value)
|
|
35
|
+
value.is_a?(SecretReference) ||
|
|
36
|
+
(value.is_a?(Hash) && value.key?(REFERENCE_KEY))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Reconstruct a SecretReference from a serialized marker hash. Any other value
|
|
40
|
+
# (including an existing SecretReference) is returned unchanged.
|
|
41
|
+
#
|
|
42
|
+
# @param value [Object] a serialized marker hash or any other value
|
|
43
|
+
# @return [Object] a SecretReference for a marker hash, otherwise the original value
|
|
44
|
+
def load(value)
|
|
45
|
+
return value unless value.is_a?(Hash) && value.key?(REFERENCE_KEY)
|
|
46
|
+
|
|
47
|
+
new(value[REFERENCE_KEY])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Initialize a new SecretReference.
|
|
52
|
+
#
|
|
53
|
+
# @param name [String, Symbol] the name of the secret to reference
|
|
54
|
+
# @raise [ArgumentError] if the name is empty
|
|
55
|
+
def initialize(name)
|
|
56
|
+
@name = name.to_s
|
|
57
|
+
raise ArgumentError.new("secret name cannot be empty") if @name.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Serialize to a marker hash. Only the name is included; the value is never present.
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] the marker hash
|
|
63
|
+
def as_json
|
|
64
|
+
{REFERENCE_KEY => name}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ==(other)
|
|
68
|
+
other.is_a?(SecretReference) && other.name == name
|
|
69
|
+
end
|
|
70
|
+
alias_method :eql?, :==
|
|
71
|
+
|
|
72
|
+
def hash
|
|
73
|
+
[self.class, name].hash
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Inspect the reference. Only the name is shown (there is no value to leak).
|
|
77
|
+
#
|
|
78
|
+
# @return [String]
|
|
79
|
+
def inspect
|
|
80
|
+
"#<PatientHttp::SecretReference name=#{name.inspect}>"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -39,11 +39,12 @@ module PatientHttp
|
|
|
39
39
|
timeout = @task.request.timeout || @config.request_timeout
|
|
40
40
|
|
|
41
41
|
response_data = Async::Task.current.with_timeout(timeout) do
|
|
42
|
-
headers = @task.request.headers.to_h
|
|
42
|
+
headers = @config.secret_manager.resolve_headers(@task.request.headers.to_h)
|
|
43
|
+
headers["x-request-id"] = @task.id
|
|
43
44
|
headers["user-agent"] ||= @config.user_agent if @config.user_agent
|
|
44
45
|
body = Protocol::HTTP::Body::Buffered.wrap([@task.request.body.to_s]) if @task.request.body
|
|
45
46
|
|
|
46
|
-
endpoint = Async::HTTP::Endpoint.parse(
|
|
47
|
+
endpoint = Async::HTTP::Endpoint.parse(request_url)
|
|
47
48
|
endpoint = configure_endpoint(endpoint) if @config.connection_timeout
|
|
48
49
|
|
|
49
50
|
verb = @task.request.http_method.to_s.upcase
|
|
@@ -124,11 +125,18 @@ module PatientHttp
|
|
|
124
125
|
|
|
125
126
|
private
|
|
126
127
|
|
|
128
|
+
# Resolve the current task's request URL, appending any secret query params.
|
|
129
|
+
#
|
|
130
|
+
# @return [String] the resolved request URL
|
|
131
|
+
def request_url
|
|
132
|
+
@config.secret_manager.resolve_url(@task.request.url, @task.request.secret_params)
|
|
133
|
+
end
|
|
134
|
+
|
|
127
135
|
# Create HTTP client with config settings (retries, proxy, connection timeout).
|
|
128
136
|
#
|
|
129
137
|
# @return [Protocol::HTTP::AcceptEncoding] wrapped HTTP client
|
|
130
138
|
def create_http_client
|
|
131
|
-
endpoint = Async::HTTP::Endpoint.parse(
|
|
139
|
+
endpoint = Async::HTTP::Endpoint.parse(request_url)
|
|
132
140
|
endpoint = configure_endpoint(endpoint) if @config.connection_timeout
|
|
133
141
|
|
|
134
142
|
client = if @config.proxy_url
|
data/lib/patient_http.rb
CHANGED
|
@@ -66,6 +66,8 @@ module PatientHttp
|
|
|
66
66
|
autoload :RequestTemplate, File.join(__dir__, "patient_http/request_template")
|
|
67
67
|
autoload :Response, File.join(__dir__, "patient_http/response")
|
|
68
68
|
autoload :ResponseReader, File.join(__dir__, "patient_http/response_reader")
|
|
69
|
+
autoload :SecretManager, File.join(__dir__, "patient_http/secret_manager")
|
|
70
|
+
autoload :SecretReference, File.join(__dir__, "patient_http/secret_reference")
|
|
69
71
|
autoload :ServerError, File.join(__dir__, "patient_http/http_error")
|
|
70
72
|
autoload :SynchronousExecutor, File.join(__dir__, "patient_http/synchronous_executor")
|
|
71
73
|
autoload :TaskHandler, File.join(__dir__, "patient_http/task_handler")
|
|
@@ -258,6 +260,19 @@ module PatientHttp
|
|
|
258
260
|
)
|
|
259
261
|
end
|
|
260
262
|
|
|
263
|
+
# Build a reference to a named secret for use as a sensitive header or query
|
|
264
|
+
# parameter value when building a request.
|
|
265
|
+
#
|
|
266
|
+
# The reference holds only the secret's name; the value is resolved on the
|
|
267
|
+
# processor side at send time using the secrets registered on the configuration.
|
|
268
|
+
#
|
|
269
|
+
# @param name [String, Symbol] the name of the secret to reference
|
|
270
|
+
# @return [SecretReference] a reference to the named secret
|
|
271
|
+
# @see Configuration#register_secret
|
|
272
|
+
def secret(name)
|
|
273
|
+
SecretReference.new(name)
|
|
274
|
+
end
|
|
275
|
+
|
|
261
276
|
private
|
|
262
277
|
|
|
263
278
|
# Validates that the handler accepts the required keyword arguments.
|
|
@@ -292,8 +307,8 @@ module PatientHttp
|
|
|
292
307
|
if missing_keywords.any?
|
|
293
308
|
raise ArgumentError.new(
|
|
294
309
|
"Handler must accept keyword arguments: " \
|
|
295
|
-
"#{required_keywords.
|
|
296
|
-
"Missing: #{missing_keywords.
|
|
310
|
+
"#{required_keywords.join(", ")}. " \
|
|
311
|
+
"Missing: #{missing_keywords.join(", ")}"
|
|
297
312
|
)
|
|
298
313
|
end
|
|
299
314
|
|
|
@@ -306,7 +321,7 @@ module PatientHttp
|
|
|
306
321
|
|
|
307
322
|
raise ArgumentError.new(
|
|
308
323
|
"Handler must not have extra required keyword parameters. " \
|
|
309
|
-
"Found: #{extra_required_keywords.
|
|
324
|
+
"Found: #{extra_required_keywords.join(", ")}"
|
|
310
325
|
)
|
|
311
326
|
end
|
|
312
327
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: patient_http
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
@@ -129,6 +129,8 @@ files:
|
|
|
129
129
|
- lib/patient_http/request_template.rb
|
|
130
130
|
- lib/patient_http/response.rb
|
|
131
131
|
- lib/patient_http/response_reader.rb
|
|
132
|
+
- lib/patient_http/secret_manager.rb
|
|
133
|
+
- lib/patient_http/secret_reference.rb
|
|
132
134
|
- lib/patient_http/synchronous_executor.rb
|
|
133
135
|
- lib/patient_http/task_handler.rb
|
|
134
136
|
- lib/patient_http/time_helper.rb
|