coolhand 0.6.0 → 0.8.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/AGENTS.harness.md +49 -14
- data/CHANGELOG.md +48 -0
- data/README.md +45 -4
- data/docs/configuration.md +27 -2
- data/docs/feedback-links.md +48 -0
- data/docs/log-search.md +102 -0
- data/docs/openai.md +1 -1
- data/docs/template-search.md +46 -4
- data/docs/vertex.md +3 -0
- data/docs/workload-search.md +116 -0
- data/lib/coolhand/api_service.rb +4 -3
- data/lib/coolhand/base_interceptor.rb +42 -7
- data/lib/coolhand/configuration.rb +2 -1
- data/lib/coolhand/default_exclude_api_patterns.yml +11 -0
- data/lib/coolhand/default_intercept_addresses.yml +54 -0
- data/lib/coolhand/errors.rb +2 -1
- data/lib/coolhand/log_queue.rb +179 -0
- data/lib/coolhand/log_service.rb +67 -0
- data/lib/coolhand/net_http_interceptor.rb +144 -109
- data/lib/coolhand/open_ai/batch_result_processor.rb +30 -29
- data/lib/coolhand/open_ai/webhook_validator.rb +2 -1
- data/lib/coolhand/optimization_feedback_link_service.rb +85 -0
- data/lib/coolhand/read_query.rb +61 -0
- data/lib/coolhand/read_requests.rb +26 -13
- data/lib/coolhand/template_service.rb +13 -29
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +9 -1
- data/lib/coolhand/workload_service.rb +46 -0
- data/lib/coolhand/write_requests.rb +47 -0
- data/lib/coolhand.rb +33 -0
- metadata +11 -2
|
@@ -4,8 +4,6 @@ require "stringio"
|
|
|
4
4
|
|
|
5
5
|
module Coolhand
|
|
6
6
|
module NetHttpInterceptor
|
|
7
|
-
include BaseInterceptor
|
|
8
|
-
|
|
9
7
|
# Response streaming interceptor nested under NetHttpInterceptor
|
|
10
8
|
module ResponseInterceptor
|
|
11
9
|
def read_body(dest = nil, &block)
|
|
@@ -70,7 +68,7 @@ module Coolhand
|
|
|
70
68
|
next if @patch_count.positive? || !@patched
|
|
71
69
|
|
|
72
70
|
@patched = false
|
|
73
|
-
Coolhand.log "🔌
|
|
71
|
+
Coolhand.log "🔌 Net::HTTP interceptor unpatched (capture stopped)"
|
|
74
72
|
end
|
|
75
73
|
end
|
|
76
74
|
|
|
@@ -93,9 +91,8 @@ module Coolhand
|
|
|
93
91
|
active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
|
|
94
92
|
return super if active.key?(self)
|
|
95
93
|
|
|
96
|
-
url =
|
|
97
|
-
return super unless
|
|
98
|
-
return super unless should_capture?
|
|
94
|
+
url = Helpers.capturable_url(self, req)
|
|
95
|
+
return super unless url
|
|
99
96
|
|
|
100
97
|
# Capture body before setting the guard — if this raises we skip logging cleanly
|
|
101
98
|
# and the guard is never set, so there is no leak. A failure here (e.g. an
|
|
@@ -103,7 +100,7 @@ module Coolhand
|
|
|
103
100
|
# from being attempted — this gem must never be the reason the host
|
|
104
101
|
# app's actual LLM call doesn't happen.
|
|
105
102
|
captured_body = begin
|
|
106
|
-
capture_request_body(req, body)
|
|
103
|
+
Helpers.capture_request_body(req, body)
|
|
107
104
|
rescue StandardError => e
|
|
108
105
|
Coolhand.log "❌ Error capturing request body: #{e.message}"
|
|
109
106
|
nil
|
|
@@ -130,9 +127,9 @@ module Coolhand
|
|
|
130
127
|
body_content = Thread.current[:coolhand_stream_buffer] || response&.body
|
|
131
128
|
body_content = body_content.dup.force_encoding("UTF-8") if body_content.is_a?(String)
|
|
132
129
|
status_code = response.respond_to?(:code) ? response.code.to_i : nil
|
|
133
|
-
response_body = parse_json(body_content)
|
|
130
|
+
response_body = BaseInterceptor.parse_json(body_content)
|
|
134
131
|
rescue StandardError => e
|
|
135
|
-
status_code = extract_status_from_exception(e)
|
|
132
|
+
status_code = Helpers.extract_status_from_exception(e)
|
|
136
133
|
response_body = { "error" => { "class" => e.class.name, "message" => e.message } }
|
|
137
134
|
raise
|
|
138
135
|
ensure
|
|
@@ -142,13 +139,13 @@ module Coolhand
|
|
|
142
139
|
end_time = Time.now
|
|
143
140
|
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
144
141
|
|
|
145
|
-
send_complete_request_log(
|
|
142
|
+
BaseInterceptor.send_complete_request_log(
|
|
146
143
|
request_id: request_id,
|
|
147
144
|
method: req.method,
|
|
148
145
|
url: url,
|
|
149
|
-
request_headers: sanitize_headers(req),
|
|
146
|
+
request_headers: BaseInterceptor.sanitize_headers(req),
|
|
150
147
|
request_body: captured_body,
|
|
151
|
-
response_headers: sanitize_headers(response),
|
|
148
|
+
response_headers: BaseInterceptor.sanitize_headers(response),
|
|
152
149
|
response_body: response_body,
|
|
153
150
|
status_code: status_code,
|
|
154
151
|
start_time: start_time,
|
|
@@ -161,133 +158,171 @@ module Coolhand
|
|
|
161
158
|
response
|
|
162
159
|
end
|
|
163
160
|
|
|
164
|
-
|
|
161
|
+
# Stateless helpers for #request. They live in their own namespaced module (not on the
|
|
162
|
+
# prepended module itself) so the only method NetHttpInterceptor adds to Net::HTTP is
|
|
163
|
+
# `request` — generically named helpers like `intercept?` would otherwise pollute
|
|
164
|
+
# Net::HTTP and could collide with, or be shadowed by, other code.
|
|
165
|
+
module Helpers
|
|
166
|
+
module_function
|
|
165
167
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
def should_capture?
|
|
169
|
+
return true if Coolhand.configuration.debug_mode
|
|
168
170
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
override = Thread.current[:coolhand_capture_override]
|
|
172
|
+
return override unless override.nil?
|
|
171
173
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
Coolhand.configuration.capture
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def capture_request_body(req, body)
|
|
178
|
+
# Check content-type before touching body_stream at all — for a binary
|
|
179
|
+
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
180
|
+
# stream into memory a second time just to build a log entry no one
|
|
181
|
+
# can read anyway.
|
|
182
|
+
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
183
|
+
|
|
184
|
+
content = body || req.body
|
|
185
|
+
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
186
|
+
content = req.body_stream.read
|
|
187
|
+
req.body_stream = StringIO.new(content)
|
|
188
|
+
end
|
|
189
|
+
return nil if content.nil?
|
|
174
190
|
|
|
175
|
-
|
|
176
|
-
# Check content-type before touching body_stream at all — for a binary
|
|
177
|
-
# upload (multipart/form-data, audio/*, etc.) this avoids reading the
|
|
178
|
-
# stream into memory a second time just to build a log entry no one
|
|
179
|
-
# can read anyway.
|
|
180
|
-
return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
|
|
181
|
-
|
|
182
|
-
content = body || req.body
|
|
183
|
-
if content.nil? && req.respond_to?(:body_stream) && req.body_stream
|
|
184
|
-
content = req.body_stream.read
|
|
185
|
-
req.body_stream = StringIO.new(content)
|
|
191
|
+
cap_and_parse(content, req)
|
|
186
192
|
end
|
|
187
|
-
return nil if content.nil?
|
|
188
193
|
|
|
189
|
-
|
|
190
|
-
|
|
194
|
+
def binary_upload?(req)
|
|
195
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
196
|
+
content_type && !content_type.match?(/json/i)
|
|
197
|
+
end
|
|
191
198
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
def cap_and_parse(content, req)
|
|
200
|
+
max_bytes = Coolhand.configuration.max_captured_body_bytes
|
|
201
|
+
if max_bytes && content.bytesize > max_bytes
|
|
202
|
+
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
203
|
+
end
|
|
196
204
|
|
|
197
|
-
|
|
198
|
-
max_bytes = Coolhand.configuration.max_captured_body_bytes
|
|
199
|
-
if max_bytes && content.bytesize > max_bytes
|
|
200
|
-
return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
|
|
205
|
+
BaseInterceptor.parse_json(content)
|
|
201
206
|
end
|
|
202
207
|
|
|
203
|
-
|
|
204
|
-
|
|
208
|
+
def skipped_capture_marker(req, reason, extra = {})
|
|
209
|
+
marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
|
|
210
|
+
content_type = req.respond_to?(:content_type) ? req.content_type : nil
|
|
211
|
+
marker["content_type"] = content_type if content_type
|
|
212
|
+
marker
|
|
213
|
+
end
|
|
205
214
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
marker["content_type"] = content_type if content_type
|
|
210
|
-
marker
|
|
211
|
-
end
|
|
215
|
+
def extract_status_from_exception(e)
|
|
216
|
+
return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
|
|
217
|
+
return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
|
|
220
|
+
match ? match[1].to_i : nil
|
|
221
|
+
end
|
|
216
222
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
end
|
|
223
|
+
def intercept?(url)
|
|
224
|
+
return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
|
|
220
225
|
|
|
221
|
-
|
|
222
|
-
|
|
226
|
+
uri = safe_parse(url)
|
|
227
|
+
return false unless uri&.host
|
|
223
228
|
|
|
224
|
-
|
|
225
|
-
return false unless uri&.host
|
|
229
|
+
return false if excluded_by_pattern?(uri)
|
|
226
230
|
|
|
227
|
-
|
|
231
|
+
host = uri.host.downcase.chomp(".")
|
|
232
|
+
path = uri.path.to_s
|
|
233
|
+
addresses = Coolhand.configuration.intercept_addresses
|
|
234
|
+
authorities = [host, "#{host}:#{uri.port}"]
|
|
235
|
+
return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
|
|
228
236
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return true if addresses.any? { |a| host_matches?(host, a) }
|
|
237
|
+
return false unless google_api_host_configured?(addresses)
|
|
238
|
+
return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
|
|
232
239
|
|
|
233
|
-
|
|
234
|
-
|
|
240
|
+
Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
|
|
241
|
+
end
|
|
235
242
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
# The capture decision runs before the host's real request, so a bad config value (e.g. a
|
|
244
|
+
# non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
|
|
245
|
+
def capturable_url(http, req)
|
|
246
|
+
url = build_url_for_request(http, req)
|
|
247
|
+
url if intercept?(url) && should_capture?
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
239
252
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
253
|
+
def excluded_by_pattern?(uri)
|
|
254
|
+
patterns = Coolhand.configuration.exclude_api_patterns
|
|
255
|
+
return false if patterns.nil? || patterns.empty?
|
|
243
256
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
path = uri.path.to_s
|
|
258
|
+
matched = patterns.find { |pattern| path.include?(pattern) }
|
|
259
|
+
if matched && Coolhand.configuration.debug_mode
|
|
260
|
+
Coolhand.log "🚫 Skipping capture for #{BaseInterceptor.sanitize_url(uri.to_s)} " \
|
|
261
|
+
"(matched exclude_api_pattern: \"#{matched}\")"
|
|
262
|
+
end
|
|
263
|
+
!!matched
|
|
248
264
|
end
|
|
249
|
-
!!matched
|
|
250
|
-
end
|
|
251
265
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
266
|
+
# intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
|
|
267
|
+
# user still wants Google API traffic intercepted at all — otherwise overriding
|
|
268
|
+
# intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
|
|
269
|
+
def google_api_host_configured?(addresses)
|
|
270
|
+
addresses.any? do |a|
|
|
271
|
+
a = a.to_s.downcase
|
|
272
|
+
a == "googleapis.com" || a.end_with?(".googleapis.com")
|
|
273
|
+
end
|
|
259
274
|
end
|
|
260
|
-
end
|
|
261
275
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
276
|
+
# An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
|
|
277
|
+
# path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
|
|
278
|
+
# that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
|
|
279
|
+
# matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
|
|
280
|
+
# host aren't swept in alongside the ones we mean to capture.
|
|
281
|
+
# The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
|
|
282
|
+
# stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
|
|
283
|
+
# a same-prefix-but-different-segment path like "/openaiz".
|
|
284
|
+
def address_matches?(host, path, pattern)
|
|
285
|
+
host_pattern, sep, path_pattern = pattern.to_s.partition("/")
|
|
286
|
+
return host_matches?(host, host_pattern) if sep.empty?
|
|
287
|
+
return false unless host_matches?(host, host_pattern)
|
|
288
|
+
|
|
289
|
+
path_pattern = path_pattern.delete_suffix("/")
|
|
290
|
+
return true if path_pattern.empty?
|
|
291
|
+
|
|
292
|
+
prefix = "/#{path_pattern}"
|
|
293
|
+
path == prefix || path.start_with?("#{prefix}/")
|
|
294
|
+
end
|
|
268
295
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
296
|
+
# Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
|
|
297
|
+
# A single "*" in `pattern` matches exactly one host label, e.g.
|
|
298
|
+
# "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
|
|
299
|
+
def host_matches?(host, pattern)
|
|
300
|
+
pattern = pattern.to_s.downcase
|
|
301
|
+
return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
|
|
272
302
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
nil
|
|
277
|
-
end
|
|
303
|
+
regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
|
|
304
|
+
!!(host =~ regex)
|
|
305
|
+
end
|
|
278
306
|
|
|
279
|
-
|
|
280
|
-
|
|
307
|
+
def safe_parse(url)
|
|
308
|
+
URI.parse(url)
|
|
309
|
+
rescue URI::InvalidURIError
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def build_url_for_request(http, req)
|
|
314
|
+
return req.path if %r{\Ahttps?://}.match?(req.path)
|
|
281
315
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
316
|
+
scheme = http.use_ssl? ? "https" : "http"
|
|
317
|
+
host = http.address
|
|
318
|
+
port = http.port
|
|
319
|
+
default = http.use_ssl? ? 443 : 80
|
|
286
320
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
321
|
+
url = "#{scheme}://#{host}"
|
|
322
|
+
url << ":#{port}" if port != default
|
|
323
|
+
url << req.path
|
|
324
|
+
url
|
|
325
|
+
end
|
|
291
326
|
end
|
|
292
327
|
end
|
|
293
328
|
end
|
|
@@ -16,7 +16,9 @@ module Coolhand
|
|
|
16
16
|
|
|
17
17
|
case batch_info["status"]
|
|
18
18
|
when "completed"
|
|
19
|
-
|
|
19
|
+
# Downloads two files and POSTs one log per item — run it on the background log queue so
|
|
20
|
+
# the webhook response isn't held up for the whole batch.
|
|
21
|
+
LogQueue.submit(lane: :batch) { process_completed_batch_logging_errors }
|
|
20
22
|
when "failed", "expired", "cancelled"
|
|
21
23
|
handle_failed_batch
|
|
22
24
|
when "in_progress", "validating", "finalizing"
|
|
@@ -30,6 +32,14 @@ module Coolhand
|
|
|
30
32
|
|
|
31
33
|
private
|
|
32
34
|
|
|
35
|
+
# Runs on the batch lane's worker thread, where an escaping error would only reach
|
|
36
|
+
# Coolhand.log (silenceable) rather than the Rails log `call` reports to.
|
|
37
|
+
def process_completed_batch_logging_errors
|
|
38
|
+
process_completed_batch
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Rails.logger.error("[Interceptor] Failed to process OpenAI batch results for #{event_data}: #{e.message}")
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
def process_completed_batch
|
|
34
44
|
input_file_id = batch_info["input_file_id"]
|
|
35
45
|
return unless input_file_id
|
|
@@ -43,8 +53,12 @@ module Coolhand
|
|
|
43
53
|
# Download and process results
|
|
44
54
|
batch_response_items = download_batch_results(output_file_id)
|
|
45
55
|
|
|
56
|
+
request_items_by_id = batch_request_items.each_with_object({}) do |item, index|
|
|
57
|
+
index[item["custom_id"]] ||= item
|
|
58
|
+
end
|
|
59
|
+
|
|
46
60
|
batch_response_items.each do |response_item|
|
|
47
|
-
request_item =
|
|
61
|
+
request_item = request_items_by_id[response_item["custom_id"]]
|
|
48
62
|
|
|
49
63
|
next unless request_item
|
|
50
64
|
|
|
@@ -89,33 +103,20 @@ module Coolhand
|
|
|
89
103
|
|
|
90
104
|
def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
|
|
91
105
|
start_time:, end_time:)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
status_code: status_code,
|
|
107
|
-
duration_ms: duration_ms,
|
|
108
|
-
completed_at: completed_at,
|
|
109
|
-
is_streaming: false
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
api_service = Coolhand::ApiService.new
|
|
114
|
-
api_service.send_llm_request_log(request_data)
|
|
115
|
-
|
|
116
|
-
Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
|
|
117
|
-
rescue StandardError => e
|
|
118
|
-
Coolhand.log "❌ Error sending complete request log: #{e.message}"
|
|
106
|
+
BaseInterceptor.send_complete_request_log(
|
|
107
|
+
request_id: request_id,
|
|
108
|
+
method: method,
|
|
109
|
+
url: url,
|
|
110
|
+
request_headers: {},
|
|
111
|
+
request_body: request_body,
|
|
112
|
+
response_headers: {},
|
|
113
|
+
response_body: response_body,
|
|
114
|
+
status_code: status_code,
|
|
115
|
+
start_time: Time.at(start_time),
|
|
116
|
+
end_time: Time.at(end_time),
|
|
117
|
+
duration_ms: ((end_time - start_time) * 1000).to_i,
|
|
118
|
+
is_streaming: false
|
|
119
|
+
)
|
|
119
120
|
end
|
|
120
121
|
|
|
121
122
|
def batch_info
|
|
@@ -98,7 +98,8 @@ module Coolhand
|
|
|
98
98
|
signed_payload = "#{webhook_id}.#{webhook_timestamp}.#{@payload}"
|
|
99
99
|
expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
# An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
|
|
102
|
+
signature_valid = !secret_bytes.empty? && webhook_signature.start_with?("v1,") &&
|
|
102
103
|
secure_compare(webhook_signature[3..], expected_signature)
|
|
103
104
|
|
|
104
105
|
unless signature_valid
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "api_service"
|
|
5
|
+
require_relative "write_requests"
|
|
6
|
+
|
|
7
|
+
module Coolhand
|
|
8
|
+
# Requires the client's **private** key; the public key gets a 401. Unlike the logging writes,
|
|
9
|
+
# every method raises {Coolhand::Error} / {Coolhand::HttpError} on failure. See docs/feedback-links.md.
|
|
10
|
+
class OptimizationFeedbackLinkService < ApiService
|
|
11
|
+
include WriteRequests
|
|
12
|
+
|
|
13
|
+
ERROR_NOUN = "Feedback link"
|
|
14
|
+
# The server rejects more ids than this per request with a 422.
|
|
15
|
+
BULK_BATCH_SIZE = 100
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
super("v2/optimizations")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns `{ id:, optimization_id:, feedback_id:, note:, created_at: }`; `id` is the link's hashid.
|
|
22
|
+
def link_feedback(optimization_id, feedback_id, note: nil)
|
|
23
|
+
validate_id!("link_feedback", "feedback_id", feedback_id)
|
|
24
|
+
url = links_url("link_feedback", optimization_id)
|
|
25
|
+
ensure_object!(request_json(:post, url, ERROR_NOUN, body: { feedback_id: feedback_id.strip, note: note }.compact))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Lists over 100 ids are sent in batches; a failing batch raises and earlier ones stay applied,
|
|
29
|
+
# so repeating the call is safe. Returns `{ linked:, already_linked:, errored:, not_found: }` summed
|
|
30
|
+
# across batches.
|
|
31
|
+
def bulk_link_feedback(optimization_id, feedback_ids, note: nil)
|
|
32
|
+
unless feedback_ids.is_a?(Array) && !feedback_ids.empty?
|
|
33
|
+
raise Error, "bulk_link_feedback: feedback_ids must be a non-empty array of hashids"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
feedback_ids.each { |id| validate_id!("bulk_link_feedback", "feedback_ids entries", id) }
|
|
37
|
+
feedback_ids = feedback_ids.map(&:strip)
|
|
38
|
+
url = links_url("bulk_link_feedback", optimization_id)
|
|
39
|
+
|
|
40
|
+
totals = { linked: 0, already_linked: 0, errored: 0, not_found: [] }
|
|
41
|
+
feedback_ids.each_slice(BULK_BATCH_SIZE) do |batch|
|
|
42
|
+
result = request_json(:post, url, ERROR_NOUN, body: { feedback_ids: batch, note: note }.compact)
|
|
43
|
+
merge_bulk_result!(totals, result)
|
|
44
|
+
end
|
|
45
|
+
totals
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `link_id` is the `id` returned by {#link_feedback}, not the feedback's. Returns true.
|
|
49
|
+
def unlink_feedback(optimization_id, link_id)
|
|
50
|
+
validate_id!("unlink_feedback", "link_id", link_id)
|
|
51
|
+
url = URI.parse("#{links_url('unlink_feedback', optimization_id)}/#{escape_path_segment(link_id.strip)}")
|
|
52
|
+
request_json(:delete, url, ERROR_NOUN)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def merge_bulk_result!(totals, result)
|
|
59
|
+
ensure_object!(result)
|
|
60
|
+
|
|
61
|
+
%i[linked already_linked errored].each { |key| totals[key] += result[key].to_i }
|
|
62
|
+
totals[:not_found].concat(Array(result[:not_found]))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ensure_object!(result)
|
|
66
|
+
raise Error, "#{ERROR_NOUN} response was not a JSON object" unless result.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def links_url(method_name, optimization_id)
|
|
72
|
+
validate_id!(method_name, "optimization_id", optimization_id)
|
|
73
|
+
URI.parse("#{api_endpoint}/#{escape_path_segment(optimization_id.strip)}/feedback_links")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A blank id would resolve to a different route and a bare dot segment would retarget the
|
|
77
|
+
# request; neither 404s, so both are rejected before any request is made.
|
|
78
|
+
def validate_id!(method_name, label, value)
|
|
79
|
+
trimmed = value.is_a?(String) ? value.strip : ""
|
|
80
|
+
return unless trimmed.empty? || [".", ".."].include?(trimmed)
|
|
81
|
+
|
|
82
|
+
raise Error, "#{method_name}: #{label} must be a non-empty hashid"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Coolhand
|
|
9
|
+
# URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
|
|
10
|
+
# `+` UTC offset reaches the server as `%2B` instead of being read as a space.
|
|
11
|
+
module ReadQuery
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
def list_url(query)
|
|
15
|
+
uri = URI.parse(api_endpoint)
|
|
16
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
17
|
+
uri
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resource_url(id, blank_message, query = {})
|
|
21
|
+
raise Error, blank_message unless id.is_a?(String)
|
|
22
|
+
|
|
23
|
+
trimmed = id.strip
|
|
24
|
+
# A blank id resolves to the index route (bare array, not one record); a bare dot segment
|
|
25
|
+
# retargets the request at another path. Neither would 404, so both are rejected here.
|
|
26
|
+
raise Error, blank_message if trimmed.empty? || [".", ".."].include?(trimmed)
|
|
27
|
+
|
|
28
|
+
uri = URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
|
|
29
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
30
|
+
uri
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `until` is a reserved word, so a method taking an `until:` keyword cannot read it by name;
|
|
34
|
+
# callers pass `binding.local_variable_get(:until)`.
|
|
35
|
+
def window_params(since, until_)
|
|
36
|
+
{ since: time_param(:since, since), until: time_param(:until, until_) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Accepts Time, DateTime, Date or an ISO8601 String. Strings go through untouched so the server
|
|
40
|
+
# stays the one validator. Raises ArgumentError before any request for anything else.
|
|
41
|
+
def time_param(name, value)
|
|
42
|
+
case value
|
|
43
|
+
when nil, String then value
|
|
44
|
+
when DateTime then utc_iso8601(value.to_time)
|
|
45
|
+
when Time then utc_iso8601(value)
|
|
46
|
+
when Date then value.iso8601
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "#{name} must be a Time, DateTime, Date or ISO8601 String, got #{value.class}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Whole seconds keep the plain form; a fractional instant keeps its microseconds so a window bound
|
|
55
|
+
# is not silently truncated.
|
|
56
|
+
def utc_iso8601(time)
|
|
57
|
+
utc = time.getutc
|
|
58
|
+
utc.subsec.zero? ? utc.iso8601 : utc.iso8601(6)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -6,9 +6,10 @@ require "json"
|
|
|
6
6
|
require_relative "errors"
|
|
7
7
|
|
|
8
8
|
module Coolhand
|
|
9
|
-
# The GET half of {ApiService},
|
|
9
|
+
# The GET half of {ApiService}, plus the HTTP helpers {WriteRequests} shares, split out to keep
|
|
10
|
+
# that class inside this repo's 200-line budget.
|
|
10
11
|
#
|
|
11
|
-
# Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
|
|
12
|
+
# Reads raise where the logging writes log-and-return-nil, on purpose: a write is instrumentation inline in
|
|
12
13
|
# the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
|
|
13
14
|
module ReadRequests
|
|
14
15
|
# 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
|
|
@@ -32,13 +33,7 @@ module Coolhand
|
|
|
32
33
|
|
|
33
34
|
response = perform_get(url, noun)
|
|
34
35
|
|
|
35
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
36
|
-
raise HttpError.new(
|
|
37
|
-
"#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
|
|
38
|
-
status: response.code.to_i,
|
|
39
|
-
body: retained_error_body(response.body)
|
|
40
|
-
)
|
|
41
|
-
end
|
|
36
|
+
raise_http_error(response, noun) unless response.is_a?(Net::HTTPSuccess)
|
|
42
37
|
|
|
43
38
|
[parse_json_body(response.body, noun), response]
|
|
44
39
|
end
|
|
@@ -47,10 +42,7 @@ module Coolhand
|
|
|
47
42
|
|
|
48
43
|
def perform_get(url, noun)
|
|
49
44
|
uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
|
|
50
|
-
http =
|
|
51
|
-
http.use_ssl = (uri.scheme == "https")
|
|
52
|
-
http.open_timeout = READ_OPEN_TIMEOUT
|
|
53
|
-
http.read_timeout = READ_TIMEOUT
|
|
45
|
+
http = build_http(uri)
|
|
54
46
|
|
|
55
47
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
56
48
|
apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
|
|
@@ -62,6 +54,27 @@ module Coolhand
|
|
|
62
54
|
raise Error, "#{noun} request failed: #{e.message}"
|
|
63
55
|
end
|
|
64
56
|
|
|
57
|
+
def build_http(uri)
|
|
58
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
59
|
+
http.use_ssl = (uri.scheme == "https")
|
|
60
|
+
http.open_timeout = READ_OPEN_TIMEOUT
|
|
61
|
+
http.read_timeout = READ_TIMEOUT
|
|
62
|
+
http
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def raise_http_error(response, noun)
|
|
66
|
+
raise HttpError.new(
|
|
67
|
+
"#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
|
|
68
|
+
status: response.code.to_i,
|
|
69
|
+
body: retained_error_body(response.body)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
|
|
74
|
+
def escape_path_segment(value)
|
|
75
|
+
URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
|
|
76
|
+
end
|
|
77
|
+
|
|
65
78
|
def retained_error_body(body)
|
|
66
79
|
return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
|
|
67
80
|
|