zenrows 0.2.1 → 0.3.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/CHANGELOG.md +17 -0
- data/README.md +40 -0
- data/lib/zenrows/api_client.rb +70 -7
- data/lib/zenrows/backends/base.rb +31 -1
- data/lib/zenrows/backends/http_rb.rb +10 -2
- data/lib/zenrows/backends/net_http.rb +10 -2
- data/lib/zenrows/client.rb +86 -3
- data/lib/zenrows/configuration.rb +111 -0
- data/lib/zenrows/hooks/context.rb +142 -0
- data/lib/zenrows/hooks/log_subscriber.rb +124 -0
- data/lib/zenrows/hooks.rb +213 -0
- data/lib/zenrows/instrumented_client.rb +187 -0
- data/lib/zenrows/version.rb +1 -1
- data/lib/zenrows.rb +4 -0
- data/sig/zenrows/api_client.rbs +4 -1
- data/sig/zenrows/backends/base.rbs +4 -1
- data/sig/zenrows/client.rbs +2 -1
- data/sig/zenrows/configuration.rbs +9 -0
- data/sig/zenrows/hook_configurator.rbs +9 -0
- data/sig/zenrows/hooks/context.rbs +6 -0
- data/sig/zenrows/hooks/log_subscriber.rbs +15 -0
- data/sig/zenrows/hooks.rbs +23 -0
- data/sig/zenrows/instrumented_client.rbs +22 -0
- data/test/test_helper.rb +42 -0
- data/test/zenrows/client_hooks_test.rb +105 -0
- data/test/zenrows/configuration_hooks_test.rb +101 -0
- data/test/zenrows/hooks/context_test.rb +150 -0
- data/test/zenrows/hooks/log_subscriber_test.rb +105 -0
- data/test/zenrows/hooks_test.rb +215 -0
- data/test/zenrows/instrumented_client_test.rb +153 -0
- metadata +18 -3
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zenrows
|
|
4
|
+
class Hooks
|
|
5
|
+
# Builds context hashes for hook callbacks
|
|
6
|
+
#
|
|
7
|
+
# Provides methods to create and enrich context objects passed to hooks.
|
|
8
|
+
# Handles parsing of ZenRows-specific response headers.
|
|
9
|
+
#
|
|
10
|
+
# @author Ernest Bursa
|
|
11
|
+
# @since 0.3.0
|
|
12
|
+
# @api private
|
|
13
|
+
class Context
|
|
14
|
+
# ZenRows response headers to parse
|
|
15
|
+
ZENROWS_HEADERS = {
|
|
16
|
+
"Concurrency-Limit" => :concurrency_limit,
|
|
17
|
+
"Concurrency-Remaining" => :concurrency_remaining,
|
|
18
|
+
"X-Request-Cost" => :request_cost,
|
|
19
|
+
"X-Request-Id" => :request_id,
|
|
20
|
+
"Zr-Final-Url" => :final_url
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Build context for a request
|
|
25
|
+
#
|
|
26
|
+
# @param method [Symbol] HTTP method (:get, :post, etc.)
|
|
27
|
+
# @param url [String] Target URL
|
|
28
|
+
# @param options [Hash] ZenRows options used for the request
|
|
29
|
+
# @param backend [Symbol] Backend name (:http_rb, :net_http)
|
|
30
|
+
# @return [Hash] Request context
|
|
31
|
+
def for_request(method:, url:, options:, backend:)
|
|
32
|
+
uri = parse_uri(url)
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
method: method,
|
|
36
|
+
url: url,
|
|
37
|
+
host: uri&.host,
|
|
38
|
+
options: options.dup.freeze,
|
|
39
|
+
started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
|
|
40
|
+
backend: backend,
|
|
41
|
+
zenrows_headers: {}
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enrich context with response data
|
|
46
|
+
#
|
|
47
|
+
# Adds timing information and parses ZenRows headers from response.
|
|
48
|
+
#
|
|
49
|
+
# @param context [Hash] Existing request context
|
|
50
|
+
# @param response [Object] HTTP response object
|
|
51
|
+
# @return [Hash] Enriched context
|
|
52
|
+
def enrich_with_response(context, response)
|
|
53
|
+
headers = extract_headers(response)
|
|
54
|
+
context[:zenrows_headers] = parse_zenrows_headers(headers)
|
|
55
|
+
context[:completed_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
56
|
+
context[:duration] = context[:completed_at] - context[:started_at]
|
|
57
|
+
context
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Safely parse URI
|
|
63
|
+
#
|
|
64
|
+
# @param url [String] URL to parse
|
|
65
|
+
# @return [URI, nil] Parsed URI or nil if invalid
|
|
66
|
+
def parse_uri(url)
|
|
67
|
+
URI.parse(url.to_s)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Extract headers from response object
|
|
73
|
+
#
|
|
74
|
+
# Handles different response object types (http.rb, Net::HTTP, etc.)
|
|
75
|
+
#
|
|
76
|
+
# @param response [Object] HTTP response object
|
|
77
|
+
# @return [Hash] Headers as hash
|
|
78
|
+
def extract_headers(response)
|
|
79
|
+
case response
|
|
80
|
+
when ->(r) { r.respond_to?(:headers) }
|
|
81
|
+
headers_to_hash(response.headers)
|
|
82
|
+
when ->(r) { r.respond_to?(:to_hash) }
|
|
83
|
+
response.to_hash
|
|
84
|
+
when ->(r) { r.respond_to?(:each_header) }
|
|
85
|
+
# Net::HTTPResponse
|
|
86
|
+
{}.tap { |h| response.each_header { |k, v| h[k] = v } }
|
|
87
|
+
else
|
|
88
|
+
{}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Convert headers object to hash
|
|
93
|
+
#
|
|
94
|
+
# @param headers [Object] Headers object
|
|
95
|
+
# @return [Hash] Headers as hash
|
|
96
|
+
def headers_to_hash(headers)
|
|
97
|
+
if headers.respond_to?(:to_h)
|
|
98
|
+
headers.to_h
|
|
99
|
+
elsif headers.respond_to?(:to_hash)
|
|
100
|
+
headers.to_hash
|
|
101
|
+
else
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parse ZenRows-specific headers
|
|
107
|
+
#
|
|
108
|
+
# @param headers [Hash] Response headers
|
|
109
|
+
# @return [Hash] Parsed ZenRows headers with typed values
|
|
110
|
+
def parse_zenrows_headers(headers)
|
|
111
|
+
result = {}
|
|
112
|
+
|
|
113
|
+
ZENROWS_HEADERS.each do |header, key|
|
|
114
|
+
# Try both exact case and lowercase
|
|
115
|
+
value = headers[header] || headers[header.downcase]
|
|
116
|
+
next unless value
|
|
117
|
+
|
|
118
|
+
result[key] = cast_header_value(key, value)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
result
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Cast header value to appropriate type
|
|
125
|
+
#
|
|
126
|
+
# @param key [Symbol] Header key
|
|
127
|
+
# @param value [String] Raw header value
|
|
128
|
+
# @return [Object] Typed value
|
|
129
|
+
def cast_header_value(key, value)
|
|
130
|
+
case key
|
|
131
|
+
when :request_cost
|
|
132
|
+
value.to_f
|
|
133
|
+
when :concurrency_limit, :concurrency_remaining
|
|
134
|
+
value.to_i
|
|
135
|
+
else
|
|
136
|
+
value.to_s
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zenrows
|
|
4
|
+
class Hooks
|
|
5
|
+
# Built-in logging subscriber for ZenRows requests
|
|
6
|
+
#
|
|
7
|
+
# Logs request lifecycle events using a configurable logger.
|
|
8
|
+
# Uses lazy evaluation (blocks) to avoid string interpolation overhead
|
|
9
|
+
# when log level is not enabled.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# Zenrows.configure do |c|
|
|
13
|
+
# c.logger = Logger.new(STDOUT)
|
|
14
|
+
# c.add_subscriber(Zenrows::Hooks::LogSubscriber.new)
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @example With custom logger
|
|
18
|
+
# subscriber = Zenrows::Hooks::LogSubscriber.new(logger: Rails.logger)
|
|
19
|
+
# Zenrows.configure { |c| c.add_subscriber(subscriber) }
|
|
20
|
+
#
|
|
21
|
+
# @author Ernest Bursa
|
|
22
|
+
# @since 0.3.0
|
|
23
|
+
# @api public
|
|
24
|
+
class LogSubscriber
|
|
25
|
+
# @return [Logger, nil] Logger instance
|
|
26
|
+
attr_reader :logger
|
|
27
|
+
|
|
28
|
+
# Create a new log subscriber
|
|
29
|
+
#
|
|
30
|
+
# @param logger [Logger, nil] Logger instance. If nil, uses Zenrows.configuration.logger
|
|
31
|
+
def initialize(logger: nil)
|
|
32
|
+
@logger = logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Log before request starts
|
|
36
|
+
#
|
|
37
|
+
# @param context [Hash] Request context
|
|
38
|
+
def before_request(context)
|
|
39
|
+
log(:debug) do
|
|
40
|
+
"ZenRows request: #{context[:method].to_s.upcase} #{context[:url]}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Log successful response
|
|
45
|
+
#
|
|
46
|
+
# @param response [Object] HTTP response
|
|
47
|
+
# @param context [Hash] Request context
|
|
48
|
+
def on_response(response, context)
|
|
49
|
+
status = extract_status(response)
|
|
50
|
+
duration = format_duration(context[:duration])
|
|
51
|
+
cost = context.dig(:zenrows_headers, :request_cost)
|
|
52
|
+
|
|
53
|
+
message = "ZenRows #{context[:url]} -> #{status}"
|
|
54
|
+
message += " (#{duration})" if duration
|
|
55
|
+
message += " [cost: #{cost}]" if cost
|
|
56
|
+
|
|
57
|
+
log(:info) { message }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Log error
|
|
61
|
+
#
|
|
62
|
+
# @param error [Exception] The error that occurred
|
|
63
|
+
# @param context [Hash] Request context
|
|
64
|
+
def on_error(error, context)
|
|
65
|
+
request_id = context.dig(:zenrows_headers, :request_id)
|
|
66
|
+
|
|
67
|
+
message = "ZenRows #{context[:url]} failed: #{error.class} - #{error.message}"
|
|
68
|
+
message += " [request_id: #{request_id}]" if request_id
|
|
69
|
+
|
|
70
|
+
log(:error) { message }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Get the effective logger
|
|
76
|
+
#
|
|
77
|
+
# @return [Logger, nil] Logger to use
|
|
78
|
+
def effective_logger
|
|
79
|
+
@logger || Zenrows.configuration.logger
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Log a message at the specified level
|
|
83
|
+
#
|
|
84
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error)
|
|
85
|
+
# @yield Block returning the message to log
|
|
86
|
+
def log(level, &block)
|
|
87
|
+
return unless effective_logger
|
|
88
|
+
|
|
89
|
+
if effective_logger.respond_to?(level)
|
|
90
|
+
effective_logger.public_send(level, &block)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract status from response object
|
|
95
|
+
#
|
|
96
|
+
# @param response [Object] HTTP response
|
|
97
|
+
# @return [String] Status string
|
|
98
|
+
def extract_status(response)
|
|
99
|
+
if response.respond_to?(:status)
|
|
100
|
+
status = response.status
|
|
101
|
+
status.respond_to?(:code) ? status.code : status.to_s
|
|
102
|
+
elsif response.respond_to?(:code)
|
|
103
|
+
response.code
|
|
104
|
+
else
|
|
105
|
+
"unknown"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Format duration for display
|
|
110
|
+
#
|
|
111
|
+
# @param duration [Float, nil] Duration in seconds
|
|
112
|
+
# @return [String, nil] Formatted duration
|
|
113
|
+
def format_duration(duration)
|
|
114
|
+
return nil unless duration
|
|
115
|
+
|
|
116
|
+
if duration < 1
|
|
117
|
+
"#{(duration * 1000).round}ms"
|
|
118
|
+
else
|
|
119
|
+
"#{duration.round(2)}s"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Zenrows
|
|
6
|
+
# Thread-safe hook registry for request lifecycle events
|
|
7
|
+
#
|
|
8
|
+
# Manages registration and execution of callbacks for HTTP request events.
|
|
9
|
+
# Supports both block-based callbacks and subscriber objects.
|
|
10
|
+
#
|
|
11
|
+
# @example Register a block callback
|
|
12
|
+
# hooks = Zenrows::Hooks.new
|
|
13
|
+
# hooks.register(:on_response) { |response, context| puts response.status }
|
|
14
|
+
#
|
|
15
|
+
# @example Register a subscriber object
|
|
16
|
+
# class MySubscriber
|
|
17
|
+
# def on_response(response, context)
|
|
18
|
+
# puts response.status
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# hooks.add_subscriber(MySubscriber.new)
|
|
22
|
+
#
|
|
23
|
+
# @author Ernest Bursa
|
|
24
|
+
# @since 0.3.0
|
|
25
|
+
# @api public
|
|
26
|
+
class Hooks
|
|
27
|
+
include MonitorMixin
|
|
28
|
+
|
|
29
|
+
# Available hook events
|
|
30
|
+
EVENTS = %i[before_request after_request on_response on_error around_request].freeze
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
super # Initialize MonitorMixin
|
|
34
|
+
@callbacks = Hash.new { |h, k| h[k] = [] }
|
|
35
|
+
@subscribers = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register a callback for an event
|
|
39
|
+
#
|
|
40
|
+
# @param event [Symbol] Event name (:before_request, :after_request, :on_response, :on_error, :around_request)
|
|
41
|
+
# @param callable [#call, nil] Callable object (proc, lambda, or object responding to #call)
|
|
42
|
+
# @yield Block to execute when event fires
|
|
43
|
+
# @return [self] Returns self for chaining
|
|
44
|
+
# @raise [ArgumentError] if event is unknown or handler doesn't respond to #call
|
|
45
|
+
#
|
|
46
|
+
# @example With block
|
|
47
|
+
# hooks.register(:on_response) { |response, ctx| log(response) }
|
|
48
|
+
#
|
|
49
|
+
# @example With callable
|
|
50
|
+
# hooks.register(:on_response, ->(response, ctx) { log(response) })
|
|
51
|
+
def register(event, callable = nil, &block)
|
|
52
|
+
validate_event!(event)
|
|
53
|
+
handler = callable || block
|
|
54
|
+
raise ArgumentError, "Handler must respond to #call" unless handler.respond_to?(:call)
|
|
55
|
+
|
|
56
|
+
synchronize { @callbacks[event] << handler }
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Add a subscriber object that responds to hook methods
|
|
61
|
+
#
|
|
62
|
+
# Subscriber objects can implement any of the hook methods:
|
|
63
|
+
# - before_request(context)
|
|
64
|
+
# - after_request(context)
|
|
65
|
+
# - on_response(response, context)
|
|
66
|
+
# - on_error(error, context)
|
|
67
|
+
# - around_request(context, &block)
|
|
68
|
+
#
|
|
69
|
+
# @param subscriber [Object] Object responding to one or more hook methods
|
|
70
|
+
# @return [self] Returns self for chaining
|
|
71
|
+
#
|
|
72
|
+
# @example
|
|
73
|
+
# class MetricsSubscriber
|
|
74
|
+
# def on_response(response, context)
|
|
75
|
+
# StatsD.increment('requests')
|
|
76
|
+
# end
|
|
77
|
+
# end
|
|
78
|
+
# hooks.add_subscriber(MetricsSubscriber.new)
|
|
79
|
+
def add_subscriber(subscriber)
|
|
80
|
+
unless EVENTS.any? { |e| subscriber.respond_to?(e) }
|
|
81
|
+
warn "ZenRows: Subscriber #{subscriber.class} doesn't respond to any hook events"
|
|
82
|
+
end
|
|
83
|
+
synchronize { @subscribers << subscriber }
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Run callbacks for an event
|
|
88
|
+
#
|
|
89
|
+
# @param event [Symbol] Event name
|
|
90
|
+
# @param args [Array] Arguments to pass to callbacks
|
|
91
|
+
# @return [void]
|
|
92
|
+
def run(event, *args)
|
|
93
|
+
handlers, subscribers = synchronize do
|
|
94
|
+
[@callbacks[event].dup, @subscribers.dup]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Run registered callbacks
|
|
98
|
+
handlers.each { |h| h.call(*args) }
|
|
99
|
+
|
|
100
|
+
# Run subscriber methods
|
|
101
|
+
subscribers.each do |sub|
|
|
102
|
+
sub.public_send(event, *args) if sub.respond_to?(event)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run around callbacks (wrapping)
|
|
107
|
+
#
|
|
108
|
+
# Executes a chain of around handlers, each wrapping the next.
|
|
109
|
+
# If no around handlers exist, simply yields to the block.
|
|
110
|
+
#
|
|
111
|
+
# @param context [Hash] Request context
|
|
112
|
+
# @yield Block to wrap (the actual HTTP request)
|
|
113
|
+
# @return [Object] Result of the wrapped block
|
|
114
|
+
def run_around(context, &block)
|
|
115
|
+
handlers, subscribers = synchronize do
|
|
116
|
+
[@callbacks[:around_request].dup, @subscribers.dup]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Build chain of around handlers
|
|
120
|
+
chain = handlers + subscribers.select { |s| s.respond_to?(:around_request) }
|
|
121
|
+
|
|
122
|
+
if chain.empty?
|
|
123
|
+
block.call
|
|
124
|
+
else
|
|
125
|
+
execute_chain(chain, context, &block)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Check if any hooks are registered
|
|
130
|
+
#
|
|
131
|
+
# @return [Boolean] true if no hooks registered
|
|
132
|
+
def empty?
|
|
133
|
+
synchronize { @callbacks.values.all?(&:empty?) && @subscribers.empty? }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Merge hooks from another registry
|
|
137
|
+
#
|
|
138
|
+
# Used for per-client hooks that inherit from global hooks.
|
|
139
|
+
#
|
|
140
|
+
# @param other [Hooks, nil] Another hooks registry to merge
|
|
141
|
+
# @return [self]
|
|
142
|
+
def merge(other)
|
|
143
|
+
return self unless other
|
|
144
|
+
|
|
145
|
+
synchronize do
|
|
146
|
+
EVENTS.each do |event|
|
|
147
|
+
@callbacks[event].concat(other.callbacks_for(event))
|
|
148
|
+
end
|
|
149
|
+
@subscribers.concat(other.subscribers_list)
|
|
150
|
+
end
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Duplicate the hooks registry
|
|
155
|
+
#
|
|
156
|
+
# @return [Hooks] New hooks registry with copied callbacks
|
|
157
|
+
def dup
|
|
158
|
+
copy = Hooks.new
|
|
159
|
+
synchronize do
|
|
160
|
+
EVENTS.each { |e| @callbacks[e].each { |h| copy.register(e, h) } }
|
|
161
|
+
@subscribers.each { |s| copy.add_subscriber(s) }
|
|
162
|
+
end
|
|
163
|
+
copy
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
protected
|
|
167
|
+
|
|
168
|
+
# Get callbacks for a specific event (for merging)
|
|
169
|
+
#
|
|
170
|
+
# @param event [Symbol] Event name
|
|
171
|
+
# @return [Array] Copy of callbacks for the event
|
|
172
|
+
def callbacks_for(event)
|
|
173
|
+
synchronize { @callbacks[event].dup }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Get subscribers list (for merging)
|
|
177
|
+
#
|
|
178
|
+
# @return [Array] Copy of subscribers
|
|
179
|
+
def subscribers_list
|
|
180
|
+
synchronize { @subscribers.dup }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# Validate event name
|
|
186
|
+
#
|
|
187
|
+
# @param event [Symbol] Event name
|
|
188
|
+
# @raise [ArgumentError] if event is unknown
|
|
189
|
+
def validate_event!(event)
|
|
190
|
+
return if EVENTS.include?(event)
|
|
191
|
+
|
|
192
|
+
raise ArgumentError, "Unknown event: #{event}. Valid events: #{EVENTS.join(", ")}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Execute chain of around handlers
|
|
196
|
+
#
|
|
197
|
+
# @param chain [Array] Array of around handlers
|
|
198
|
+
# @param context [Hash] Request context
|
|
199
|
+
# @yield Block to wrap
|
|
200
|
+
# @return [Object] Result of the wrapped block
|
|
201
|
+
def execute_chain(chain, context, &block)
|
|
202
|
+
chain.reverse.reduce(block) do |next_block, handler|
|
|
203
|
+
lambda {
|
|
204
|
+
if handler.respond_to?(:around_request)
|
|
205
|
+
handler.around_request(context, &next_block)
|
|
206
|
+
else
|
|
207
|
+
handler.call(context, &next_block)
|
|
208
|
+
end
|
|
209
|
+
}
|
|
210
|
+
end.call
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zenrows
|
|
4
|
+
# Wrapper around HTTP clients that executes hooks on requests
|
|
5
|
+
#
|
|
6
|
+
# Decorates an HTTP client (from http.rb or Net::HTTP) to add
|
|
7
|
+
# hook execution before, during, and after requests.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# client = InstrumentedClient.new(http_client,
|
|
11
|
+
# hooks: hooks_registry,
|
|
12
|
+
# context_base: { backend: :http_rb, options: { js_render: true } }
|
|
13
|
+
# )
|
|
14
|
+
# response = client.get("https://example.com")
|
|
15
|
+
#
|
|
16
|
+
# @author Ernest Bursa
|
|
17
|
+
# @since 0.3.0
|
|
18
|
+
# @api private
|
|
19
|
+
class InstrumentedClient
|
|
20
|
+
# HTTP methods to instrument
|
|
21
|
+
HTTP_METHODS = %i[get post put patch delete head options].freeze
|
|
22
|
+
|
|
23
|
+
# @return [Object] Underlying HTTP client
|
|
24
|
+
attr_reader :http
|
|
25
|
+
|
|
26
|
+
# @return [Hooks] Hook registry
|
|
27
|
+
attr_reader :hooks
|
|
28
|
+
|
|
29
|
+
# @return [Hash] Base context for all requests
|
|
30
|
+
attr_reader :context_base
|
|
31
|
+
|
|
32
|
+
# Create a new instrumented client
|
|
33
|
+
#
|
|
34
|
+
# @param http [Object] Underlying HTTP client (HTTP::Client, NetHttpClient, etc.)
|
|
35
|
+
# @param hooks [Hooks] Hook registry to use
|
|
36
|
+
# @param context_base [Hash] Base context to merge into all requests
|
|
37
|
+
def initialize(http, hooks:, context_base:)
|
|
38
|
+
@http = http
|
|
39
|
+
@hooks = hooks
|
|
40
|
+
@context_base = context_base
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Instrumented GET request
|
|
44
|
+
#
|
|
45
|
+
# @param url [String] URL to request
|
|
46
|
+
# @param options [Hash] Request options
|
|
47
|
+
# @return [Object] HTTP response
|
|
48
|
+
def get(url, **options)
|
|
49
|
+
instrument(:get, url, options) { @http.get(url, **options) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Instrumented POST request
|
|
53
|
+
#
|
|
54
|
+
# @param url [String] URL to request
|
|
55
|
+
# @param options [Hash] Request options
|
|
56
|
+
# @return [Object] HTTP response
|
|
57
|
+
def post(url, **options)
|
|
58
|
+
instrument(:post, url, options) { @http.post(url, **options) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Instrumented PUT request
|
|
62
|
+
#
|
|
63
|
+
# @param url [String] URL to request
|
|
64
|
+
# @param options [Hash] Request options
|
|
65
|
+
# @return [Object] HTTP response
|
|
66
|
+
def put(url, **options)
|
|
67
|
+
instrument(:put, url, options) { @http.put(url, **options) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Instrumented PATCH request
|
|
71
|
+
#
|
|
72
|
+
# @param url [String] URL to request
|
|
73
|
+
# @param options [Hash] Request options
|
|
74
|
+
# @return [Object] HTTP response
|
|
75
|
+
def patch(url, **options)
|
|
76
|
+
instrument(:patch, url, options) { @http.patch(url, **options) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Instrumented DELETE request
|
|
80
|
+
#
|
|
81
|
+
# @param url [String] URL to request
|
|
82
|
+
# @param options [Hash] Request options
|
|
83
|
+
# @return [Object] HTTP response
|
|
84
|
+
def delete(url, **options)
|
|
85
|
+
instrument(:delete, url, options) { @http.delete(url, **options) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Instrumented HEAD request
|
|
89
|
+
#
|
|
90
|
+
# @param url [String] URL to request
|
|
91
|
+
# @param options [Hash] Request options
|
|
92
|
+
# @return [Object] HTTP response
|
|
93
|
+
def head(url, **options)
|
|
94
|
+
instrument(:head, url, options) { @http.head(url, **options) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Instrumented OPTIONS request
|
|
98
|
+
#
|
|
99
|
+
# @param url [String] URL to request
|
|
100
|
+
# @param options [Hash] Request options
|
|
101
|
+
# @return [Object] HTTP response
|
|
102
|
+
def options(url, **options)
|
|
103
|
+
instrument(:options, url, options) { @http.options(url, **options) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Delegate unknown methods to underlying client
|
|
107
|
+
#
|
|
108
|
+
# @param method [Symbol] Method name
|
|
109
|
+
# @param args [Array] Method arguments
|
|
110
|
+
# @param kwargs [Hash] Keyword arguments
|
|
111
|
+
# @param block [Proc] Block to pass
|
|
112
|
+
# @return [Object] Result of delegated method
|
|
113
|
+
def method_missing(method, *args, **kwargs, &block)
|
|
114
|
+
if @http.respond_to?(method)
|
|
115
|
+
@http.public_send(method, *args, **kwargs, &block)
|
|
116
|
+
else
|
|
117
|
+
super
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Check if method is supported
|
|
122
|
+
#
|
|
123
|
+
# @param method [Symbol] Method name
|
|
124
|
+
# @param include_private [Boolean] Include private methods
|
|
125
|
+
# @return [Boolean] true if method is supported
|
|
126
|
+
def respond_to_missing?(method, include_private = false)
|
|
127
|
+
@http.respond_to?(method, include_private) || super
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Instrument an HTTP request with hooks
|
|
133
|
+
#
|
|
134
|
+
# @param method [Symbol] HTTP method
|
|
135
|
+
# @param url [String] Request URL
|
|
136
|
+
# @param options [Hash] Request options
|
|
137
|
+
# @yield Block that executes the actual HTTP request
|
|
138
|
+
# @return [Object] HTTP response
|
|
139
|
+
def instrument(method, url, options, &block)
|
|
140
|
+
context = build_context(method, url, options)
|
|
141
|
+
|
|
142
|
+
# Run before hooks
|
|
143
|
+
hooks.run(:before_request, context)
|
|
144
|
+
|
|
145
|
+
# Run around hooks and execute request
|
|
146
|
+
response = hooks.run_around(context) do
|
|
147
|
+
execute_request(context, &block)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
response
|
|
151
|
+
ensure
|
|
152
|
+
# Run after hooks (always, even on error)
|
|
153
|
+
hooks.run(:after_request, context) if context
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Build context for a request
|
|
157
|
+
#
|
|
158
|
+
# @param method [Symbol] HTTP method
|
|
159
|
+
# @param url [String] Request URL
|
|
160
|
+
# @param options [Hash] Request options
|
|
161
|
+
# @return [Hash] Request context
|
|
162
|
+
def build_context(method, url, options)
|
|
163
|
+
Hooks::Context.for_request(
|
|
164
|
+
method: method,
|
|
165
|
+
url: url,
|
|
166
|
+
options: context_base[:options].merge(options),
|
|
167
|
+
backend: context_base[:backend]
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Execute the actual request with error handling
|
|
172
|
+
#
|
|
173
|
+
# @param context [Hash] Request context
|
|
174
|
+
# @yield Block that executes the HTTP request
|
|
175
|
+
# @return [Object] HTTP response
|
|
176
|
+
def execute_request(context)
|
|
177
|
+
response = yield
|
|
178
|
+
Hooks::Context.enrich_with_response(context, response)
|
|
179
|
+
hooks.run(:on_response, response, context)
|
|
180
|
+
response
|
|
181
|
+
rescue => e
|
|
182
|
+
context[:error] = e
|
|
183
|
+
hooks.run(:on_error, e, context)
|
|
184
|
+
raise
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/zenrows/version.rb
CHANGED
data/lib/zenrows.rb
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "zenrows/version"
|
|
4
4
|
require_relative "zenrows/errors"
|
|
5
|
+
require_relative "zenrows/hooks"
|
|
6
|
+
require_relative "zenrows/hooks/context"
|
|
7
|
+
require_relative "zenrows/hooks/log_subscriber"
|
|
5
8
|
require_relative "zenrows/configuration"
|
|
6
9
|
require_relative "zenrows/proxy"
|
|
7
10
|
require_relative "zenrows/js_instructions"
|
|
11
|
+
require_relative "zenrows/instrumented_client"
|
|
8
12
|
require_relative "zenrows/backends/base"
|
|
9
13
|
require_relative "zenrows/backends/net_http"
|
|
10
14
|
begin
|
data/sig/zenrows/api_client.rbs
CHANGED
|
@@ -2,13 +2,16 @@ class Zenrows::ApiClient
|
|
|
2
2
|
attr_reader api_key: String
|
|
3
3
|
attr_reader api_endpoint: String
|
|
4
4
|
attr_reader config: Zenrows::Configuration
|
|
5
|
+
attr_reader hooks: Zenrows::Hooks
|
|
5
6
|
|
|
6
|
-
def initialize: (?api_key: String?, ?api_endpoint: String?) -> void
|
|
7
|
+
def initialize: (?api_key: String?, ?api_endpoint: String?) ?{ (Zenrows::HookConfigurator) -> void } -> void
|
|
7
8
|
def get: (String url, **untyped options) -> Zenrows::ApiResponse
|
|
8
9
|
def post: (String url, ?body: String?, **untyped options) -> Zenrows::ApiResponse
|
|
9
10
|
|
|
10
11
|
private
|
|
11
12
|
|
|
13
|
+
def build_hooks: () { (Zenrows::HookConfigurator) -> void } -> Zenrows::Hooks
|
|
14
|
+
def instrument: (Symbol method, String url, Hash[Symbol, untyped] options) { () -> untyped } -> untyped
|
|
12
15
|
def build_http_client: () -> untyped
|
|
13
16
|
def build_params: (String url, Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
|
|
14
17
|
def handle_response: (untyped http_response, Hash[Symbol, untyped] options) -> Zenrows::ApiResponse
|