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.
@@ -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 "🔌 Faraday monitoring disabled ..."
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 = build_url_for_request(self, req)
97
- return super unless intercept?(url)
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
- private
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
- def should_capture?
167
- return true if Coolhand.configuration.debug_mode
168
+ def should_capture?
169
+ return true if Coolhand.configuration.debug_mode
168
170
 
169
- override = Thread.current[:coolhand_capture_override]
170
- return override unless override.nil?
171
+ override = Thread.current[:coolhand_capture_override]
172
+ return override unless override.nil?
171
173
 
172
- Coolhand.configuration.capture
173
- end
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
- def capture_request_body(req, body)
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
- cap_and_parse(content, req)
190
- end
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
- def binary_upload?(req)
193
- content_type = req.respond_to?(:content_type) ? req.content_type : nil
194
- content_type && !content_type.match?(/json/i)
195
- end
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
- def cap_and_parse(content, req)
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
- parse_json(content)
204
- end
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
- def skipped_capture_marker(req, reason, extra = {})
207
- marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
208
- content_type = req.respond_to?(:content_type) ? req.content_type : nil
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
- def extract_status_from_exception(e)
214
- return e.status if e.respond_to?(:status) && e.status.is_a?(Integer)
215
- return e.response.status if e.respond_to?(:response) && e.response.respond_to?(:status)
219
+ match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
220
+ match ? match[1].to_i : nil
221
+ end
216
222
 
217
- match = e.message.to_s.match(/status[=:\s]+(\d{3})/)
218
- match ? match[1].to_i : nil
219
- end
223
+ def intercept?(url)
224
+ return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
220
225
 
221
- def intercept?(url)
222
- return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
226
+ uri = safe_parse(url)
227
+ return false unless uri&.host
223
228
 
224
- uri = safe_parse(url)
225
- return false unless uri&.host
229
+ return false if excluded_by_pattern?(uri)
226
230
 
227
- return false if excluded_by_pattern?(uri)
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
- host = uri.host.downcase
230
- addresses = Coolhand.configuration.intercept_addresses
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
- return false unless google_api_host_configured?(addresses)
234
- return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
240
+ Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
241
+ end
235
242
 
236
- path = uri.path.to_s
237
- Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
238
- end
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
- def excluded_by_pattern?(uri)
241
- patterns = Coolhand.configuration.exclude_api_patterns
242
- return false if patterns.nil? || patterns.empty?
253
+ def excluded_by_pattern?(uri)
254
+ patterns = Coolhand.configuration.exclude_api_patterns
255
+ return false if patterns.nil? || patterns.empty?
243
256
 
244
- path = uri.path.to_s
245
- matched = patterns.find { |pattern| path.include?(pattern) }
246
- if matched && Coolhand.configuration.debug_mode
247
- Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
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
- # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
253
- # user still wants Google API traffic intercepted at all — otherwise overriding
254
- # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
255
- def google_api_host_configured?(addresses)
256
- addresses.any? do |a|
257
- a = a.to_s.downcase
258
- a == "googleapis.com" || a.end_with?(".googleapis.com")
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
- # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
263
- # A single "*" in `pattern` matches exactly one host label, e.g.
264
- # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
265
- def host_matches?(host, pattern)
266
- pattern = pattern.to_s.downcase
267
- return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
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
- regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
270
- !!(host =~ regex)
271
- end
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
- def safe_parse(url)
274
- URI.parse(url)
275
- rescue URI::InvalidURIError
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
- def build_url_for_request(http, req)
280
- return req.path if %r{\Ahttps?://}.match?(req.path)
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
- scheme = http.use_ssl? ? "https" : "http"
283
- host = http.address
284
- port = http.port
285
- default = http.use_ssl? ? 443 : 80
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
- url = "#{scheme}://#{host}"
288
- url << ":#{port}" if port != default
289
- url << req.path
290
- url
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
- process_completed_batch
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 = batch_request_items.detect { |item| item["custom_id"] == response_item["custom_id"] }
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
- timestamp = Time.at(start_time).iso8601
93
- completed_at = Time.at(end_time).iso8601
94
- duration_ms = ((end_time - start_time) * 1000).to_i
95
-
96
- request_data = {
97
- raw_request: {
98
- id: request_id,
99
- timestamp: timestamp,
100
- method: method.to_s.downcase,
101
- url: BaseInterceptor.sanitize_url(url),
102
- headers: {},
103
- request_body: request_body,
104
- response_headers: {},
105
- response_body: response_body,
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
- signature_valid = webhook_signature.start_with?("v1,") &&
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}, split out to keep that class inside this repo's 200-line budget.
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 = Net::HTTP.new(uri.host, uri.port)
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