coolhand 0.8.0 → 0.10.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.
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Declares the workload/template of the LLM calls a class makes, once, on the class, so passive
5
+ # capture picks them up without a `Coolhand.with_context` block at every call site. Wrapped methods
6
+ # run inside `with_context`, so nesting and precedence are exactly those documented in
7
+ # docs/declared_workloads.md. Settings are inherited by subclasses.
8
+ #
9
+ # class ApplicationService
10
+ # include Coolhand::Declarable
11
+ # coolhand_workload reporter: "my-app"
12
+ # coolhand_declare_all
13
+ # end
14
+ #
15
+ # class Parser < ApplicationService
16
+ # def call = client.chat(...) # workload "Parser#call", template "Parser#call"
17
+ # end
18
+ module Declarable
19
+ def self.included(base)
20
+ raise ArgumentError, "Coolhand::Declarable can only be included in a class" unless base.is_a?(Class)
21
+
22
+ base.extend(ClassMethods)
23
+ end
24
+
25
+ # Never an LLM call site, and wrapping them would tax every call or declare a junk workload.
26
+ SKIPPED_METHODS = %i[method_missing respond_to? respond_to_missing? == === eql? equal? hash to_s inspect
27
+ to_str to_ary to_proc coerce <=>].freeze
28
+ ATTRIBUTE_MACROS = %w[attr attr_reader attr_writer attr_accessor].freeze
29
+ private_constant :SKIPPED_METHODS, :ATTRIBUTE_MACROS
30
+
31
+ module ClassMethods
32
+ # Class-level defaults, inherited field by field (the nearest class that sets a field wins). With
33
+ # no `key`, each wrapped method declares its own workload, "ClassName#method"; a `key` makes every
34
+ # method share one workload. The class name is the runtime class, so an inherited method declares
35
+ # under the subclass.
36
+ def coolhand_workload(key: nil, name: nil, description: nil, reporter: nil)
37
+ workload = DeclaredContext.normalize_part(:workload, { key: key, name: name, description: description }.compact,
38
+ DeclaredContext::WORKLOAD_FIELDS)
39
+ @coolhand_settings = { workload: workload, reporter: DeclaredContext.normalize_reporter(reporter) }.compact
40
+ end
41
+
42
+ # Wraps the named instance methods (now, or when they are defined later). `template:` is a key or a
43
+ # `{ key:, name: }` Hash; by default the template key is "ClassName#method".
44
+ def coolhand_declare(*names, template: nil)
45
+ # Checked here, at class definition, so a bad value never surfaces inside the host's method.
46
+ template = DeclaredContext.normalize_part(:template, template.is_a?(String) ? { key: template } : template,
47
+ DeclaredContext::TEMPLATE_FIELDS)
48
+ names.flatten.each do |name|
49
+ name = name.to_sym
50
+ (@coolhand_declared ||= {})[name] = { template: template }
51
+ coolhand_wrap(name) if method_defined?(name) || private_method_defined?(name)
52
+ end
53
+ end
54
+
55
+ # Wraps every public instance method defined in this class and its subclasses after this call, so
56
+ # put it at the top of the class body. Private and protected methods, attribute readers/writers
57
+ # and object-protocol methods (see SKIPPED_METHODS) are left alone; `coolhand_declare` wraps one of
58
+ # those if you name it.
59
+ def coolhand_declare_all
60
+ @coolhand_declare_all = true
61
+ end
62
+
63
+ # @api private
64
+ def coolhand_identity(method_name)
65
+ key = "#{name}##{method_name}"
66
+ template = coolhand_template_for(method_name)
67
+ {
68
+ reporter: coolhand_setting(:reporter),
69
+ workload: { key: key }.merge(coolhand_setting(:workload) || {}),
70
+ template: template || { key: key }
71
+ }
72
+ end
73
+
74
+ # @api private
75
+ def coolhand_declare_call?(method_name)
76
+ return false if name.nil? || !Coolhand.configuration.enabled
77
+ return false unless DeclaredContext.valid_key?("#{name}##{method_name}")
78
+
79
+ public_method_defined?(method_name) || !coolhand_declaration(method_name).nil?
80
+ end
81
+
82
+ # @api private
83
+ def coolhand_own_state
84
+ [@coolhand_settings, @coolhand_declared, @coolhand_declare_all]
85
+ end
86
+
87
+ private
88
+
89
+ def method_added(method_name)
90
+ super
91
+ coolhand_wrap(method_name) if !@coolhand_wrapping && coolhand_wrap?(method_name)
92
+ end
93
+
94
+ def coolhand_wrap?(method_name)
95
+ return true if coolhand_declaration(method_name)
96
+
97
+ public_method_defined?(method_name) && !SKIPPED_METHODS.include?(method_name) &&
98
+ coolhand_declare_all? && !attribute_definition?
99
+ end
100
+
101
+ # True while `attr_reader`/`attr_writer`/`attr_accessor` is what is defining the method. Read from
102
+ # the stack rather than by overriding those macros, which would lose the class body's
103
+ # `private`/`protected` default visibility. A window of frames, not one fixed depth, so a
104
+ # `method_added` override that calls `super` does not hide the macro.
105
+ def attribute_definition?
106
+ caller_locations(1, 8)&.any? { |frame| ATTRIBUTE_MACROS.include?(frame.base_label) } || false
107
+ end
108
+
109
+ def coolhand_declare_all?
110
+ coolhand_each_class { |klass| return true if klass.coolhand_own_state[2] }
111
+ false
112
+ end
113
+
114
+ # Redefines the method in the same class, rather than prepending a wrapper, so a later
115
+ # `private :name` (or `private def name`) applies to the wrapper like it would to the original.
116
+ def coolhand_wrap(method_name)
117
+ original = instance_method(method_name)
118
+ wrappers = (@coolhand_wrappers ||= {})
119
+ return if wrappers[method_name] == original
120
+
121
+ visibility = coolhand_visibility(method_name)
122
+ @coolhand_wrapping = true
123
+ define_method(method_name) do |*args, **kwargs, &block|
124
+ klass = self.class
125
+ next original.bind_call(self, *args, **kwargs, &block) unless klass.coolhand_declare_call?(method_name)
126
+
127
+ Coolhand.with_context(**klass.coolhand_identity(method_name)) do
128
+ original.bind_call(self, *args, **kwargs, &block)
129
+ end
130
+ end
131
+ send(visibility, method_name)
132
+ wrappers[method_name] = instance_method(method_name)
133
+ ensure
134
+ @coolhand_wrapping = false
135
+ end
136
+
137
+ # Includes inherited methods: wrapping one a parent made private must not make it public here.
138
+ def coolhand_visibility(method_name)
139
+ return :private if private_method_defined?(method_name)
140
+ return :protected if protected_method_defined?(method_name)
141
+
142
+ :public
143
+ end
144
+
145
+ # Yields this class and its superclasses that include Declarable, nearest first. A loop rather
146
+ # than a collected Array, since the lookups below run on every wrapped call.
147
+ def coolhand_each_class
148
+ klass = self
149
+ while klass.respond_to?(:coolhand_own_state)
150
+ yield klass
151
+ klass = klass.superclass
152
+ end
153
+ end
154
+
155
+ def coolhand_declaration(method_name)
156
+ coolhand_nearest(1, method_name)
157
+ end
158
+
159
+ def coolhand_template_for(method_name)
160
+ coolhand_declaration(method_name)&.fetch(:template)
161
+ end
162
+
163
+ def coolhand_setting(field)
164
+ coolhand_nearest(0, field)
165
+ end
166
+
167
+ # The value for `key` in the nearest class whose own state (slot `slot` of `coolhand_own_state`)
168
+ # sets it, or nil.
169
+ def coolhand_nearest(slot, key)
170
+ coolhand_each_class do |klass|
171
+ state = klass.coolhand_own_state[slot]
172
+ return state[key] if state&.key?(key)
173
+ end
174
+ nil
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Ambient workload/template identity for captured calls, set with {Coolhand.with_context}. The
5
+ # interceptor snapshots {current} on the calling thread before a log is queued, because the
6
+ # background log worker does not inherit thread-locals. See docs/declared_workloads.md.
7
+ module DeclaredContext
8
+ THREAD_KEY = :coolhand_declared_context
9
+
10
+ # The fields the server accepts under `declared.workload` / `declared.template`; anything else
11
+ # is dropped here rather than sent.
12
+ WORKLOAD_FIELDS = %i[id key name description].freeze
13
+ TEMPLATE_FIELDS = %i[id key name].freeze
14
+
15
+ module_function
16
+
17
+ # A frozen Hash, or nil when no context is active.
18
+ def current
19
+ Thread.current[THREAD_KEY]
20
+ end
21
+
22
+ def clear!
23
+ Thread.current[THREAD_KEY] = nil
24
+ end
25
+
26
+ # Each field is replaced wholesale by the innermost block that sets it. Hashes are not merged
27
+ # key by key: `{ id: x }` merged with `{ key: y }` would declare both, and the server prefers `id`.
28
+ # A template belongs to a workload, so a block that declares a workload without a template does
29
+ # not inherit the outer one; it would pair the new workload with a template from another.
30
+ def with(reporter: nil, workload: nil, template: nil)
31
+ previous = current
32
+ declared = {
33
+ reporter: normalize_reporter(reporter),
34
+ workload: normalize_part(:workload, workload, WORKLOAD_FIELDS),
35
+ template: normalize_part(:template, template, TEMPLATE_FIELDS)
36
+ }.compact
37
+ inherited = previous || {}
38
+ inherited = inherited.except(:template) if declared.key?(:workload) && !declared.key?(:template)
39
+ merged = inherited.merge(declared)
40
+ ensure_template_has_workload!(merged)
41
+ Thread.current[THREAD_KEY] = merged.empty? ? nil : merged.freeze
42
+ yield
43
+ ensure
44
+ Thread.current[THREAD_KEY] = previous
45
+ end
46
+
47
+ def normalize_reporter(reporter)
48
+ return nil if reporter.nil?
49
+
50
+ reporter.to_s
51
+ end
52
+
53
+ # Mirrors the server's key rules: at most 255 characters, no control or invisible characters, no
54
+ # leading or trailing whitespace, and no leading, trailing or doubled "/". The server answers a
55
+ # bad key with a 422 that rejects the whole log, so it is checked here, before the call is made.
56
+ MAX_KEY_LENGTH = 255
57
+ KEY_FORMAT = %r{
58
+ \A(?!/)(?!.*//)
59
+ [^\p{Cntrl}\p{Cf}[:space:]]
60
+ (?:[^\p{Cntrl}\p{Cf}]*[^\p{Cntrl}\p{Cf}[:space:]])?
61
+ (?<!/)\z
62
+ }x
63
+
64
+ def valid_key?(key)
65
+ key.is_a?(String) && key.length <= MAX_KEY_LENGTH && KEY_FORMAT.match?(key)
66
+ rescue ArgumentError, EncodingError
67
+ false # a string with a broken or incompatible encoding is not a valid key
68
+ end
69
+
70
+ # A template given by key, with no workload to create it in, is a 422 that drops the log. A
71
+ # template `id` refers to an existing record and needs no workload.
72
+ def ensure_template_has_workload!(declared)
73
+ template = declared[:template]
74
+ return if template.nil? || template.key?(:id) || declared.key?(:workload)
75
+
76
+ raise ArgumentError, "template with a :key needs a workload; declare one in this or an enclosing block"
77
+ end
78
+
79
+ # Validated up front: the server answers a non-object `workload`/`template` with a 422 for the
80
+ # whole log, which would drop the call from Coolhand rather than just its identity.
81
+ def normalize_part(name, value, fields)
82
+ return nil if value.nil?
83
+ raise ArgumentError, "#{name} must be a Hash, got #{value.class}" unless value.is_a?(Hash)
84
+
85
+ part = value.each_with_object({}) do |(k, v), acc|
86
+ field = k.respond_to?(:to_sym) ? k.to_sym : nil
87
+ next unless fields.include?(field) && !v.nil?
88
+ # A blank id refers to nothing and would 422 the log, so it counts as not given.
89
+ next if field == :id && v.to_s.strip.empty?
90
+
91
+ acc[field] = v.to_s.dup.freeze
92
+ end
93
+ return nil if part.empty?
94
+
95
+ if part.key?(:key) && !valid_key?(part[:key])
96
+ raise ArgumentError, "#{name} key must be at most #{MAX_KEY_LENGTH} printable characters with no " \
97
+ "surrounding whitespace and no leading, trailing or doubled \"/\""
98
+ end
99
+ unless part.key?(:id) || part.key?(:key)
100
+ raise ArgumentError, "#{name} needs an :id or a :key, got #{value.keys.inspect}"
101
+ end
102
+
103
+ part.freeze
104
+ end
105
+ end
106
+ end
@@ -47,13 +47,7 @@ module Coolhand
47
47
  per: per
48
48
  }.compact
49
49
 
50
- logs, response = get_json_with_headers(list_url(query), ERROR_NOUN)
51
- raise Error, "#{ERROR_NOUN} response was not a JSON array" unless logs.is_a?(Array)
52
-
53
- LogSearchResult.new(
54
- logs: logs,
55
- pagination: Pagination.from_headers(response, items: logs, page: page, per: per)
56
- ).freeze
50
+ fetch_list(query, LogSearchResult, :logs, page: page, per: per)
57
51
  end
58
52
 
59
53
  # The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
@@ -15,24 +15,8 @@ module Coolhand
15
15
  # Helper method for forwarding webhook data to Coolhand
16
16
  def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
17
17
  # Validate required parameters
18
- unless Coolhand.required_field?(webhook_body)
19
- error_msg = "webhook_body is required and cannot be nil or empty"
20
- if Coolhand.configuration.silent
21
- puts "COOLHAND WARNING: #{error_msg}"
22
- return false
23
- else
24
- raise ArgumentError, error_msg
25
- end
26
- end
27
-
28
- unless Coolhand.required_field?(source)
29
- error_msg = "source is required and cannot be nil or empty"
30
- if Coolhand.configuration.silent
31
- puts "COOLHAND WARNING: #{error_msg}"
32
- return false
33
- else
34
- raise ArgumentError, error_msg
35
- end
18
+ { webhook_body: webhook_body, source: source }.each do |name, value|
19
+ return reject_webhook("#{name} is required and cannot be nil or empty") unless Coolhand.required_field?(value)
36
20
  end
37
21
 
38
22
  # Auto-generate required fields unless provided
@@ -40,7 +24,7 @@ module Coolhand
40
24
  id: options[:id] || SecureRandom.uuid,
41
25
  timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
42
26
  method: options[:method] || "POST",
43
- url: options[:url] || build_webhook_url(source, event_type),
27
+ url: BaseInterceptor.sanitize_url(options[:url] || build_webhook_url(source, event_type)),
44
28
  headers: sanitize_headers(headers),
45
29
  request_body: clean_webhook_body(webhook_body, source),
46
30
  response_body: options[:response_body],
@@ -55,6 +39,14 @@ module Coolhand
55
39
 
56
40
  private
57
41
 
42
+ # Silent mode warns and drops the webhook; otherwise the caller gets the error.
43
+ def reject_webhook(error_msg)
44
+ raise ArgumentError, error_msg unless Coolhand.configuration.silent
45
+
46
+ puts "COOLHAND WARNING: #{error_msg}"
47
+ false
48
+ end
49
+
58
50
  def build_webhook_url(source, event_type)
59
51
  base = "webhook://#{source}"
60
52
  event_type ? "#{base}/#{event_type}" : base
@@ -46,13 +46,12 @@ module Coolhand
46
46
  Net::HTTPResponse.prepend(ResponseInterceptor)
47
47
 
48
48
  @patched = true
49
- Coolhand.log "🔗 Net::HTTP interceptor patched"
50
49
  rescue StandardError
51
50
  # Roll back this call's hold — it never actually took effect, so it must not count
52
51
  # toward the refcount or a legitimate later unpatch! would underflow against it. This
53
52
  # branch only runs when @patched was false on entry (see `next if @patched` above), so
54
53
  # forcing it back to false here is correct regardless of which line above raised —
55
- # including a failure in the log call itself, after prepend already succeeded.
54
+ # including a failure in the second prepend, after the first already succeeded.
56
55
  @patch_count -= 1
57
56
  @patched = false
58
57
  raise
@@ -139,7 +138,9 @@ module Coolhand
139
138
  end_time = Time.now
140
139
  duration_ms = ((end_time - start_time) * 1000).round(2)
141
140
 
141
+ # Read here on the calling thread: the log worker thread has no access to our thread-locals.
142
142
  BaseInterceptor.send_complete_request_log(
143
+ declared: DeclaredContext.current,
143
144
  request_id: request_id,
144
145
  method: req.method,
145
146
  url: url,
@@ -7,6 +7,9 @@ require_relative "webhook_id_store"
7
7
  module Coolhand
8
8
  module OpenAi
9
9
  class WebhookValidator
10
+ SIGNATURE_VERSION_PREFIX = "v1,"
11
+ private_constant :SIGNATURE_VERSION_PREFIX
12
+
10
13
  attr_reader :request, :errors, :payload
11
14
 
12
15
  def initialize(request, webhook_secret)
@@ -41,27 +44,25 @@ module Coolhand
41
44
  def payload_valid?
42
45
  return true if @payload
43
46
 
44
- if should_enforce_strict_validation?
45
- @errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
46
- "not in development/test allowlist)"
47
- Rails.logger.error(@errors.last)
48
- false
49
- else
50
- Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
51
- true
52
- end
47
+ enforce_or_allow("Empty webhook payload",
48
+ "Empty webhook payload - allowing in #{Rails.env} environment")
53
49
  end
54
50
 
55
51
  def validate_in_non_production_env
52
+ enforce_or_allow("OpenAI webhook secret not configured",
53
+ "Webhook Secret is not configured - skipping signature verification in #{Rails.env}")
54
+ end
55
+
56
+ # Outside the development/test allowlist the webhook is rejected with `problem`; inside it the
57
+ # check is skipped with a warning.
58
+ def enforce_or_allow(problem, allow_warning)
56
59
  if should_enforce_strict_validation?
57
- @errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
60
+ @errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
58
61
  "not in development/test allowlist)"
59
62
  Rails.logger.error(@errors.last)
60
63
  false
61
64
  else
62
- Rails.logger.warn(
63
- "Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
64
- )
65
+ Rails.logger.warn(allow_warning)
65
66
  true
66
67
  end
67
68
  end
@@ -99,8 +100,7 @@ module Coolhand
99
100
  expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
100
101
 
101
102
  # 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,") &&
103
- secure_compare(webhook_signature[3..], expected_signature)
103
+ signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
104
104
 
105
105
  unless signature_valid
106
106
  @errors << "OpenAI webhook signature verification failed"
@@ -114,6 +114,18 @@ module Coolhand
114
114
  true
115
115
  end
116
116
 
117
+ # Standard Webhooks allows several space-separated signatures (e.g. during secret rotation);
118
+ # accept the request if any v1 entry matches. Every candidate is compared (`map`, not an
119
+ # `any?` block) so there is no early exit on the first match.
120
+ # Split as binary so a header that isn't valid UTF-8 is rejected rather than raising.
121
+ def signature_matches?(webhook_signature, expected_signature)
122
+ candidates = webhook_signature.b.split.select { |entry| entry.start_with?(SIGNATURE_VERSION_PREFIX) }
123
+ results = candidates.map do |entry|
124
+ secure_compare(entry.delete_prefix(SIGNATURE_VERSION_PREFIX), expected_signature)
125
+ end
126
+ results.any?
127
+ end
128
+
117
129
  # Checked only after the signature is confirmed valid, so an
118
130
  # unsigned/forged request can't poison the id-dedup store (or fail a
119
131
  # freshness check) and DoS a later legitimate webhook with the same id.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ HelpArticleSearchResult = Struct.new(:articles, :pagination, keyword_init: true)
10
+
11
+ # Read-only access to the Coolhand help center: the `public_content` endpoints (`GET /api/v2/public_content`,
12
+ # `/:slug` and `/categories`), the same pages served at `/help/:slug` and `/updates/:slug`. Create, update and
13
+ # archive are Coolhand-internal and not wrapped.
14
+ #
15
+ # Unlike every other service this one needs no API key. A configured `api_key` is sent as `X-API-Key` and lifts
16
+ # the anonymous limit of 30 requests per minute per IP (429 beyond); with none, no key header is sent. A key the
17
+ # server rejects raises a 401 {Coolhand::HttpError}; it is not retried anonymously. See docs/help-center.md.
18
+ class PublicContentService < ApiService
19
+ include ReadQuery
20
+
21
+ ERROR_NOUN = "Help center"
22
+ BLANK_SLUG_MESSAGE = "get_help_article: slug must be a non-empty string"
23
+
24
+ def initialize
25
+ super("v2/public_content")
26
+ end
27
+
28
+ # Relevance search with `query`, a full listing without. `category` filters to one section slug; the
29
+ # endpoint has no such parameter, so it is sent as `q[help_category_slug_eq]` and `query` moves to
30
+ # `q[full_text]` (the plain `q` string and the `q[...]` form cannot be mixed). A blank `query` or
31
+ # `category` counts as omitted. Returns a {HelpArticleSearchResult}; raises {Coolhand::HttpError} on a
32
+ # non-2xx response.
33
+ def search_help_articles(query: nil, category: nil, page: nil, per_page: nil)
34
+ params = { **search_params(query, category), page: page, per_page: per_page }.compact
35
+
36
+ fetch_list(params, HelpArticleSearchResult, :articles, page: page, per: per_page)
37
+ end
38
+
39
+ # One published article with its markdown `body`, `noindex` and `toc`. Raises {Coolhand::Error} before any
40
+ # request for a blank slug, and {Coolhand::HttpError} (404) for an unknown or unpublished one.
41
+ def get_help_article(slug)
42
+ get_json(resource_url(slug, BLANK_SLUG_MESSAGE), ERROR_NOUN)
43
+ end
44
+
45
+ # Every section an article can be filed under, with its `url_prefix` and `allowed_tags`.
46
+ def list_help_categories
47
+ get_json(URI.parse("#{api_endpoint}/categories"), ERROR_NOUN)
48
+ end
49
+
50
+ protected
51
+
52
+ def api_key_required?
53
+ false
54
+ end
55
+
56
+ private
57
+
58
+ def search_params(query, category)
59
+ query = nil if omitted?(query)
60
+ return { q: query } if omitted?(category)
61
+
62
+ { "q[help_category_slug_eq]": category, "q[full_text]": query }
63
+ end
64
+
65
+ def omitted?(value)
66
+ value.nil? || value.to_s.strip.empty?
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Opt-in (`config.auto_declare`) declaration of the workload and template for LLM calls made from a
5
+ # Rails job or controller action, keyed "JobClass#perform" / "ControllerClass#action". Installed from
6
+ # {Railtie} once the app has configured Coolhand. Kept free of any Rails require: it only touches
7
+ # ActiveSupport and ActiveJob when a target is enabled, and only through their own load hooks.
8
+ module RailsAutoDeclare
9
+ REPORTER = "rails"
10
+ TARGETS = %i[active_job controller].freeze
11
+
12
+ @installed = []
13
+
14
+ module_function
15
+
16
+ def install(targets = Coolhand.configuration.auto_declare)
17
+ TARGETS.each do |target|
18
+ next unless targets.include?(target) && !@installed.include?(target)
19
+
20
+ @installed << target
21
+ send(:"hook_#{target}")
22
+ end
23
+ end
24
+
25
+ def hook_active_job
26
+ ActiveSupport.on_load(:active_job) do
27
+ around_perform do |job, block|
28
+ Coolhand::RailsAutoDeclare.declare(job.class, "perform", &block)
29
+ end
30
+ end
31
+ end
32
+
33
+ def hook_controller
34
+ ActiveSupport.on_load(:action_controller) do
35
+ around_action do |controller, block|
36
+ Coolhand::RailsAutoDeclare.declare(controller.class, controller.action_name, &block)
37
+ end
38
+ end
39
+ end
40
+
41
+ # Runs the block inside a declared context. Anonymous classes have no stable key, so they are left
42
+ # undeclared rather than named after an object id.
43
+ def declare(klass, method_name, &block)
44
+ key = "#{klass.name}##{method_name}"
45
+ return yield if klass.name.nil? || !Coolhand.configuration.enabled || !DeclaredContext.valid_key?(key)
46
+
47
+ Coolhand.with_context(reporter: REPORTER, workload: { key: key }, template: { key: key }, &block)
48
+ end
49
+
50
+ # Testing-only: forget which targets were hooked.
51
+ def reset!
52
+ @installed.clear
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Loaded by lib/coolhand.rb only when Rails is present, so the gem keeps no Rails dependency.
5
+ class Railtie < ::Rails::Railtie
6
+ # After the app's initializers, so `config.auto_declare` has been set by `Coolhand.configure`.
7
+ config.after_initialize do
8
+ Coolhand::RailsAutoDeclare.install
9
+ end
10
+ end
11
+ end
@@ -4,6 +4,7 @@ require "date"
4
4
  require "time"
5
5
  require "uri"
6
6
  require_relative "errors"
7
+ require_relative "pagination"
7
8
 
8
9
  module Coolhand
9
10
  # URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
@@ -17,6 +18,19 @@ module Coolhand
17
18
  uri
18
19
  end
19
20
 
21
+ # GETs a list endpoint and wraps the JSON array and its pagination headers in `result_class`,
22
+ # keyed by `rows_key` (the field the result struct names its rows by).
23
+ def fetch_list(query, result_class, rows_key, page:, per:)
24
+ noun = self.class::ERROR_NOUN
25
+ rows, response = get_json_with_headers(list_url(query), noun)
26
+ raise Error, "#{noun} response was not a JSON array" unless rows.is_a?(Array)
27
+
28
+ result_class.new(
29
+ rows_key => rows,
30
+ pagination: Pagination.from_headers(response, items: rows, page: page, per: per)
31
+ ).freeze
32
+ end
33
+
20
34
  def resource_url(id, blank_message, query = {})
21
35
  raise Error, blank_message unless id.is_a?(String)
22
36
 
@@ -29,7 +29,9 @@ module Coolhand
29
29
 
30
30
  # Also returns the response, for endpoints that carry pagination in headers rather than the body.
31
31
  def get_json_with_headers(url, noun)
32
- raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
32
+ if api_key_required? && !Coolhand.required_field?(api_key)
33
+ raise Error, "#{noun} request failed: an API key is required"
34
+ end
33
35
 
34
36
  response = perform_get(url, noun)
35
37
 
@@ -38,6 +40,11 @@ module Coolhand
38
40
  [parse_json_body(response.body, noun), response]
39
41
  end
40
42
 
43
+ # Overridden by services over public endpoints, which work anonymously and send the key only if set.
44
+ def api_key_required?
45
+ true
46
+ end
47
+
41
48
  private
42
49
 
43
50
  def perform_get(url, noun)
@@ -45,7 +52,9 @@ module Coolhand
45
52
  http = build_http(uri)
46
53
 
47
54
  request = Net::HTTP::Get.new(uri.request_uri)
48
- apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
55
+ headers = { "Accept" => "application/json" }
56
+ headers["X-API-Key"] = api_key if Coolhand.required_field?(api_key)
57
+ apply_headers(request, headers)
49
58
 
50
59
  # Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
51
60
  # an unapproved host. without_capture is the same recursion guard send_request uses.