coolhand 0.5.1 → 0.7.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.
@@ -24,36 +24,77 @@ module Coolhand
24
24
  end
25
25
  end
26
26
 
27
+ @patch_mutex = Mutex.new
28
+ @patch_count = 0
29
+ @patched = false
30
+
31
+ # patch!/unpatch! are reference-counted so concurrent/nested callers (e.g. overlapping
32
+ # Coolhand.capture blocks across threads) compose safely — the interceptor only actually
33
+ # unpatches once every outstanding caller has released it. Coolhand.configure's patch! is
34
+ # never balanced by an unpatch!, so it permanently holds the count at >=1 for the process
35
+ # lifetime once the gem is enabled; that's intentional, not a leak.
36
+ #
37
+ # Unlike before, patch! is no longer idempotent on its own — every call must be matched by
38
+ # exactly one unpatch! to release it. A host app that calls Coolhand.configure more than once
39
+ # (e.g. a reloader re-running an initializer) will hold an extra, permanent reference each
40
+ # time rather than no-op'ing; harmless (interception simply stays on), but worth knowing.
27
41
  def self.patch!
28
- return if @patched
29
-
30
- Net::HTTP.prepend(self)
31
- Net::HTTPResponse.prepend(ResponseInterceptor)
32
-
33
- @patched = true
34
- Coolhand.log "🔗 Net::HTTP interceptor patched"
42
+ @patch_mutex.synchronize do
43
+ @patch_count += 1
44
+ next if @patched
45
+
46
+ begin
47
+ Net::HTTP.prepend(self)
48
+ Net::HTTPResponse.prepend(ResponseInterceptor)
49
+
50
+ @patched = true
51
+ Coolhand.log "🔗 Net::HTTP interceptor patched"
52
+ rescue StandardError
53
+ # Roll back this call's hold — it never actually took effect, so it must not count
54
+ # toward the refcount or a legitimate later unpatch! would underflow against it. This
55
+ # branch only runs when @patched was false on entry (see `next if @patched` above), so
56
+ # forcing it back to false here is correct regardless of which line above raised —
57
+ # including a failure in the log call itself, after prepend already succeeded.
58
+ @patch_count -= 1
59
+ @patched = false
60
+ raise
61
+ end
62
+ end
35
63
  end
36
64
 
37
65
  def self.unpatch!
38
66
  # NOTE: With prepend, there's no clean way to unpatch
39
67
  # We'll mark it as unpatched so it can be re-patched
40
- @patched = false
41
- Coolhand.log "🔌 Faraday monitoring disabled ..."
68
+ @patch_mutex.synchronize do
69
+ @patch_count -= 1 if @patch_count.positive?
70
+ next if @patch_count.positive? || !@patched
71
+
72
+ @patched = false
73
+ Coolhand.log "🔌 Faraday monitoring disabled ..."
74
+ end
42
75
  end
43
76
 
44
77
  def self.patched?
45
78
  @patched
46
79
  end
47
80
 
81
+ # Testing-only: force a clean slate. Real callers should only ever use balanced
82
+ # patch!/unpatch! pairs.
83
+ def self.reset!
84
+ @patch_mutex.synchronize do
85
+ @patch_count = 0
86
+ @patched = false
87
+ end
88
+ end
89
+
48
90
  def request(req, body = nil, &block)
49
91
  return super unless NetHttpInterceptor.patched?
50
92
 
51
93
  active = (Thread.current[:coolhand_active_requests] ||= {}.compare_by_identity)
52
94
  return super if active.key?(self)
53
95
 
54
- url = build_url_for_request(self, req)
55
- return super unless intercept?(url)
56
- return super unless should_capture?
96
+ url = capturable_url(req)
97
+ return super unless url
57
98
 
58
99
  # Capture body before setting the guard — if this raises we skip logging cleanly
59
100
  # and the guard is never set, so there is no leak. A failure here (e.g. an
@@ -131,16 +172,41 @@ module Coolhand
131
172
  end
132
173
 
133
174
  def capture_request_body(req, body)
134
- return parse_json(body) if body
135
- return parse_json(req.body) if req.body
136
-
137
- if req.respond_to?(:body_stream) && req.body_stream
175
+ # Check content-type before touching body_stream at all — for a binary
176
+ # upload (multipart/form-data, audio/*, etc.) this avoids reading the
177
+ # stream into memory a second time just to build a log entry no one
178
+ # can read anyway.
179
+ return skipped_capture_marker(req, "non_json_content_type") if binary_upload?(req)
180
+
181
+ content = body || req.body
182
+ if content.nil? && req.respond_to?(:body_stream) && req.body_stream
138
183
  content = req.body_stream.read
139
184
  req.body_stream = StringIO.new(content)
140
- return parse_json(content)
141
185
  end
186
+ return nil if content.nil?
142
187
 
143
- nil
188
+ cap_and_parse(content, req)
189
+ end
190
+
191
+ def binary_upload?(req)
192
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
193
+ content_type && !content_type.match?(/json/i)
194
+ end
195
+
196
+ def cap_and_parse(content, req)
197
+ max_bytes = Coolhand.configuration.max_captured_body_bytes
198
+ if max_bytes && content.bytesize > max_bytes
199
+ return skipped_capture_marker(req, "body_too_large", size_bytes: content.bytesize, max_bytes: max_bytes)
200
+ end
201
+
202
+ parse_json(content)
203
+ end
204
+
205
+ def skipped_capture_marker(req, reason, extra = {})
206
+ marker = { "_coolhand_capture_skipped" => reason }.merge(extra.transform_keys(&:to_s))
207
+ content_type = req.respond_to?(:content_type) ? req.content_type : nil
208
+ marker["content_type"] = content_type if content_type
209
+ marker
144
210
  end
145
211
 
146
212
  def extract_status_from_exception(e)
@@ -153,22 +219,93 @@ module Coolhand
153
219
 
154
220
  def intercept?(url)
155
221
  return false unless url && Coolhand.configuration.respond_to?(:intercept_addresses)
156
- return false if excluded_by_pattern?(url)
157
222
 
158
- Coolhand.configuration.intercept_addresses.any? { |a| url.include?(a) }
223
+ uri = safe_parse(url)
224
+ return false unless uri&.host
225
+
226
+ return false if excluded_by_pattern?(uri)
227
+
228
+ host = uri.host.downcase.chomp(".")
229
+ path = uri.path.to_s
230
+ addresses = Coolhand.configuration.intercept_addresses
231
+ authorities = [host, "#{host}:#{uri.port}"]
232
+ return true if addresses.any? { |a| authorities.any? { |authority| address_matches?(authority, path, a) } }
233
+
234
+ return false unless google_api_host_configured?(addresses)
235
+ return false unless host == "googleapis.com" || host.end_with?(".googleapis.com")
236
+
237
+ Coolhand.configuration.intercept_path_patterns.any? { |p| path.include?(p) }
238
+ end
239
+
240
+ # The capture decision runs before the host's real request, so a bad config value (e.g. a
241
+ # non-array exclude_api_patterns) must degrade to "don't capture", never raise into the host.
242
+ def capturable_url(req)
243
+ url = build_url_for_request(self, req)
244
+ url if intercept?(url) && should_capture?
245
+ rescue StandardError => e
246
+ Coolhand.log "⚠️ Skipping capture, could not evaluate intercept rules: #{e.class}"
247
+ nil
159
248
  end
160
249
 
161
- def excluded_by_pattern?(url)
250
+ def excluded_by_pattern?(uri)
162
251
  patterns = Coolhand.configuration.exclude_api_patterns
163
252
  return false if patterns.nil? || patterns.empty?
164
253
 
165
- matched = patterns.find { |pattern| url.include?(pattern) }
254
+ path = uri.path.to_s
255
+ matched = patterns.find { |pattern| path.include?(pattern) }
166
256
  if matched && Coolhand.configuration.debug_mode
167
- Coolhand.log "🚫 Skipping capture for #{sanitize_url(url)} (matched exclude_api_pattern: \"#{matched}\")"
257
+ Coolhand.log "🚫 Skipping capture for #{sanitize_url(uri.to_s)} (matched exclude_api_pattern: \"#{matched}\")"
168
258
  end
169
259
  !!matched
170
260
  end
171
261
 
262
+ # intercept_path_patterns only ever applies to googleapis.com hosts, and only when the
263
+ # user still wants Google API traffic intercepted at all — otherwise overriding
264
+ # intercept_addresses to exclude Google hosts wouldn't actually stop Google API capture.
265
+ def google_api_host_configured?(addresses)
266
+ addresses.any? do |a|
267
+ a = a.to_s.downcase
268
+ a == "googleapis.com" || a.end_with?(".googleapis.com")
269
+ end
270
+ end
271
+
272
+ # An intercept_addresses entry may optionally pin a port ("host:port") and/or anchor to a
273
+ # path prefix by embedding a "/" — e.g. "api.cohere.com/v2/chat" only matches requests to
274
+ # that host whose path starts with "/v2/chat", and "cognitiveservices.azure.com/openai/" only
275
+ # matches that multi-service Azure host's OpenAI paths, so unrelated endpoints on a shared
276
+ # host aren't swept in alongside the ones we mean to capture.
277
+ # The match is on a path *segment* boundary (trailing "/" on the pattern is optional and
278
+ # stripped before comparing), so "host.com/openai" matches "/openai" and "/openai/x" but not
279
+ # a same-prefix-but-different-segment path like "/openaiz".
280
+ def address_matches?(host, path, pattern)
281
+ host_pattern, sep, path_pattern = pattern.to_s.partition("/")
282
+ return host_matches?(host, host_pattern) if sep.empty?
283
+ return false unless host_matches?(host, host_pattern)
284
+
285
+ path_pattern = path_pattern.delete_suffix("/")
286
+ return true if path_pattern.empty?
287
+
288
+ prefix = "/#{path_pattern}"
289
+ path == prefix || path.start_with?("#{prefix}/")
290
+ end
291
+
292
+ # Host-boundary match: exact, or a dot-delimited suffix (case-insensitive).
293
+ # A single "*" in `pattern` matches exactly one host label, e.g.
294
+ # "bedrock-runtime.*.amazonaws.com" matches "bedrock-runtime.us-east-1.amazonaws.com".
295
+ def host_matches?(host, pattern)
296
+ pattern = pattern.to_s.downcase
297
+ return host == pattern || host.end_with?(".#{pattern}") unless pattern.include?("*")
298
+
299
+ regex = /\A#{pattern.split('*', -1).map { |part| Regexp.escape(part) }.join('[^.]+')}\z/
300
+ !!(host =~ regex)
301
+ end
302
+
303
+ def safe_parse(url)
304
+ URI.parse(url)
305
+ rescue URI::InvalidURIError
306
+ nil
307
+ end
308
+
172
309
  def build_url_for_request(http, req)
173
310
  return req.path if %r{\Ahttps?://}.match?(req.path)
174
311
 
@@ -98,7 +98,7 @@ module Coolhand
98
98
  id: request_id,
99
99
  timestamp: timestamp,
100
100
  method: method.to_s.downcase,
101
- url: url,
101
+ url: BaseInterceptor.sanitize_url(url),
102
102
  headers: {},
103
103
  request_body: request_body,
104
104
  response_headers: {},
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module OpenAi
5
+ # Thread-safe, in-memory, TTL-bounded store used to detect replayed
6
+ # `webhook-id` values. This is the default for
7
+ # `Coolhand.configuration.webhook_id_store` and only dedupes within a
8
+ # single process - deployments running multiple processes/dynos should
9
+ # supply their own store (e.g. backed by Rails.cache) via configuration.
10
+ class WebhookIdStore
11
+ def initialize
12
+ @entries = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Atomically checks-and-records `id` in a single critical section, so
17
+ # two concurrent replays of the same id can't both observe "unseen"
18
+ # before either records it. Returns true the first time `id` is
19
+ # claimed, false if it was already claimed within its TTL.
20
+ def claim!(id, ttl_seconds)
21
+ @mutex.synchronize do
22
+ prune
23
+ return false if @entries.key?(id)
24
+
25
+ @entries[id] = Time.now.to_i + ttl_seconds
26
+ true
27
+ end
28
+ end
29
+
30
+ def seen?(id)
31
+ @mutex.synchronize do
32
+ prune
33
+ @entries.key?(id)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def prune
40
+ now = Time.now.to_i
41
+ @entries.delete_if { |_id, expires_at| expires_at < now }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "openssl"
4
4
 
5
+ require_relative "webhook_id_store"
6
+
5
7
  module Coolhand
6
8
  module OpenAi
7
9
  class WebhookValidator
@@ -23,7 +25,7 @@ module Coolhand
23
25
  secret_bytes = extract_secret_bytes
24
26
  webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
25
27
 
26
- return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp
28
+ return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp && webhook_id
27
29
 
28
30
  verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
29
31
  end
@@ -40,7 +42,8 @@ module Coolhand
40
42
  return true if @payload
41
43
 
42
44
  if should_enforce_strict_validation?
43
- @errors << "Empty webhook payload - rejecting webhook in production/staging"
45
+ @errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
46
+ "not in development/test allowlist)"
44
47
  Rails.logger.error(@errors.last)
45
48
  false
46
49
  else
@@ -51,7 +54,8 @@ module Coolhand
51
54
 
52
55
  def validate_in_non_production_env
53
56
  if should_enforce_strict_validation?
54
- @errors << "OpenAI webhook secret not configured - rejecting webhook in production/staging"
57
+ @errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
58
+ "not in development/test allowlist)"
55
59
  Rails.logger.error(@errors.last)
56
60
  false
57
61
  else
@@ -80,8 +84,8 @@ module Coolhand
80
84
 
81
85
  def validate_headers_in_non_production_env
82
86
  if should_enforce_strict_validation?
83
- @errors << "Missing OpenAI webhook signature or timestamp headers - " \
84
- "rejecting webhook in production/staging"
87
+ @errors << "Missing OpenAI webhook signature, timestamp, or id headers - " \
88
+ "rejecting webhook (Rails.env=#{Rails.env.inspect} not in development/test allowlist)"
85
89
  Rails.logger.error(@errors.last)
86
90
  false
87
91
  else
@@ -94,15 +98,46 @@ module Coolhand
94
98
  signed_payload = "#{webhook_id}.#{webhook_timestamp}.#{@payload}"
95
99
  expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
96
100
 
97
- 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,") &&
98
103
  secure_compare(webhook_signature[3..], expected_signature)
99
- if signature_valid
100
- true
101
- else
104
+
105
+ unless signature_valid
102
106
  @errors << "OpenAI webhook signature verification failed"
103
107
  Rails.logger.error(@errors.last)
104
- false
108
+ return false
105
109
  end
110
+
111
+ return false unless timestamp_fresh?(webhook_timestamp)
112
+ return false unless webhook_id_unused?(webhook_id)
113
+
114
+ true
115
+ end
116
+
117
+ # Checked only after the signature is confirmed valid, so an
118
+ # unsigned/forged request can't poison the id-dedup store (or fail a
119
+ # freshness check) and DoS a later legitimate webhook with the same id.
120
+ def timestamp_fresh?(webhook_timestamp)
121
+ tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
122
+ age = (Time.now.to_i - webhook_timestamp.to_i).abs
123
+ return true if age <= tolerance
124
+
125
+ @errors << "OpenAI webhook timestamp outside replay-protection tolerance window"
126
+ Rails.logger.error(@errors.last)
127
+ false
128
+ end
129
+
130
+ def webhook_id_unused?(webhook_id)
131
+ tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
132
+ return true if id_store.claim!(webhook_id, tolerance)
133
+
134
+ @errors << "OpenAI webhook id already processed (replay protection)"
135
+ Rails.logger.error(@errors.last)
136
+ false
137
+ end
138
+
139
+ def id_store
140
+ Coolhand.configuration.webhook_id_store
106
141
  end
107
142
 
108
143
  def secure_compare(a, b)
@@ -124,7 +159,7 @@ module Coolhand
124
159
  end
125
160
 
126
161
  def should_enforce_strict_validation?
127
- ["production", "staging"].include?(Rails.env)
162
+ !%w[development test].include?(Rails.env)
128
163
  end
129
164
  end
130
165
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Paging state for a v2 list endpoint. These send a bare JSON array and carry paging in the
5
+ # `X-Page`, `X-Per-Page`, `X-Total-Count` and `X-Total-Pages` headers, never in the body.
6
+ Pagination = Struct.new(
7
+ :current_page,
8
+ :per_page,
9
+ :total_count,
10
+ :total_pages,
11
+ :has_next_page,
12
+ :has_prev_page,
13
+ keyword_init: true
14
+ )
15
+
16
+ class Pagination
17
+ # Mirrors of the v2 controllers' values, used only to fill a header the server did not send.
18
+ DEFAULT_PER_PAGE = 25
19
+ MAX_PER_PAGE = 100
20
+
21
+ class << self
22
+ def from_headers(response, items:, page: nil, per: nil)
23
+ requested_page = positive_int(page) || 1
24
+ requested_per = [positive_int(per) || DEFAULT_PER_PAGE, MAX_PER_PAGE].min
25
+
26
+ current_page = header_int(response, "X-Page") || requested_page
27
+ per_page = header_int(response, "X-Per-Page") || requested_per
28
+ reported_total_pages = header_int(response, "X-Total-Pages")
29
+ total_count = header_int(response, "X-Total-Count") || fallback_total_count(current_page, per_page, items)
30
+ total_pages = reported_total_pages || fallback_total_pages(total_count, per_page)
31
+
32
+ new(
33
+ current_page: current_page,
34
+ per_page: per_page,
35
+ total_count: total_count,
36
+ total_pages: total_pages,
37
+ has_next_page: next_page?(reported_total_pages, current_page, per_page, items),
38
+ has_prev_page: current_page > 1
39
+ ).freeze
40
+ end
41
+
42
+ private
43
+
44
+ # Falling back to the computed totals here would report "no next page" for a full page, and
45
+ # silently truncate a caller's loop.
46
+ def next_page?(reported_total_pages, current_page, per_page, items)
47
+ return current_page < reported_total_pages if reported_total_pages
48
+ return false unless per_page.positive?
49
+
50
+ items.size >= per_page
51
+ end
52
+
53
+ # A lower bound, not a count: every earlier page assumed full, plus this page.
54
+ def fallback_total_count(current_page, per_page, items)
55
+ return items.size unless per_page.positive?
56
+
57
+ [((current_page - 1) * per_page) + items.size, items.size].max
58
+ end
59
+
60
+ def fallback_total_pages(total_count, per_page)
61
+ return total_count.positive? ? 1 : 0 unless per_page.positive?
62
+
63
+ (total_count.to_f / per_page).ceil
64
+ end
65
+
66
+ # Neither `Integer()` nor `to_i` is safe alone: the first raises on `""`, the second turns
67
+ # `"3.5"` into `3` and `"nonsense"` into `0` — a fabricated, legitimate-looking count.
68
+ def header_int(response, name)
69
+ raw = response[name]
70
+ return nil if raw.nil?
71
+
72
+ value = raw.strip
73
+ value.match?(/\A\d+\z/) ? value.to_i : nil
74
+ end
75
+
76
+ def positive_int(value)
77
+ integer = Integer(value, exception: false)
78
+ integer&.positive? ? integer : nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "errors"
7
+
8
+ module Coolhand
9
+ # The GET half of {ApiService}, split out to keep that class inside this repo's 200-line budget.
10
+ #
11
+ # Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
12
+ # the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
13
+ module ReadRequests
14
+ # 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
15
+ # and one response runs several. A tighter read timeout pre-empts the 504 callers should retry.
16
+ READ_OPEN_TIMEOUT = 5
17
+ READ_TIMEOUT = 60
18
+
19
+ # ERROR_BODY_LIMIT caps the message; this caps the body the exception object itself carries.
20
+ RETAINED_ERROR_BODY_LIMIT = 8_000
21
+
22
+ protected
23
+
24
+ def get_json(url, noun)
25
+ body, = get_json_with_headers(url, noun)
26
+ body
27
+ end
28
+
29
+ # Also returns the response, for endpoints that carry pagination in headers rather than the body.
30
+ def get_json_with_headers(url, noun)
31
+ raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
32
+
33
+ response = perform_get(url, noun)
34
+
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
42
+
43
+ [parse_json_body(response.body, noun), response]
44
+ end
45
+
46
+ private
47
+
48
+ def perform_get(url, noun)
49
+ 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
54
+
55
+ request = Net::HTTP::Get.new(uri.request_uri)
56
+ apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
57
+
58
+ # Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
59
+ # an unapproved host. without_capture is the same recursion guard send_request uses.
60
+ Coolhand.without_capture { http.request(request) }
61
+ rescue StandardError => e
62
+ raise Error, "#{noun} request failed: #{e.message}"
63
+ end
64
+
65
+ def retained_error_body(body)
66
+ return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
67
+
68
+ "#{body[0, RETAINED_ERROR_BODY_LIMIT]}... [truncated]"
69
+ end
70
+
71
+ def parse_json_body(body, noun)
72
+ JSON.parse(body.to_s, symbolize_names: true)
73
+ rescue JSON::ParserError
74
+ raise Error, "#{noun} response was not valid JSON: #{format_error_body(body)}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "api_service"
5
+ require_relative "pagination"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
10
+
11
+ # Read-only. Requires the client's **private** key — the public key is write-only here and is
12
+ # rejected like an invalid one.
13
+ #
14
+ # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
+ # See docs/template-search.md.
16
+ class TemplateService < ApiService
17
+ ERROR_NOUN = "Template"
18
+ BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
19
+
20
+ def initialize
21
+ super("v2/llm_request_templates")
22
+ end
23
+
24
+ # Filters, error semantics and server behaviour: docs/template-search.md.
25
+ def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
26
+ include_system: nil, page: nil, per: nil)
27
+ query = {
28
+ search: search,
29
+ workload_id: workload_id,
30
+ status: status,
31
+ include_deprecated: include_deprecated,
32
+ include_system: include_system,
33
+ page: page,
34
+ per: per
35
+ }.compact
36
+
37
+ templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
+ raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
39
+
40
+ TemplateSearchResult.new(
41
+ templates: templates,
42
+ pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
43
+ ).freeze
44
+ end
45
+
46
+ # Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
47
+ # list reaches deprecated and system templates by id with no opt-in flag.
48
+ def get_template(id)
49
+ get_json(resource_url(id), ERROR_NOUN)
50
+ end
51
+
52
+ private
53
+
54
+ def list_url(query)
55
+ uri = URI.parse(api_endpoint)
56
+ uri.query = URI.encode_www_form(query) unless query.empty?
57
+ uri
58
+ end
59
+
60
+ def resource_url(id)
61
+ raise Error, BLANK_ID_MESSAGE unless id.is_a?(String)
62
+
63
+ trimmed = id.strip
64
+ # A blank id resolves to the index route (bare array, not one template); a bare dot segment
65
+ # retargets the request at another path. Neither would 404, so both are rejected here.
66
+ raise Error, BLANK_ID_MESSAGE if trimmed.empty? || [".", ".."].include?(trimmed)
67
+
68
+ URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
69
+ end
70
+
71
+ # Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
72
+ def escape_path_segment(value)
73
+ URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.5.1"
4
+ VERSION = "0.7.0"
5
5
  end