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.
@@ -1,9 +1,12 @@
1
1
  class Zenrows::Backends::Base
2
2
  attr_reader proxy: Zenrows::Proxy
3
3
  attr_reader config: Zenrows::Configuration
4
+ attr_reader hooks: Zenrows::Hooks
4
5
 
5
- def initialize: (proxy: Zenrows::Proxy, config: Zenrows::Configuration) -> void
6
+ def initialize: (proxy: Zenrows::Proxy, config: Zenrows::Configuration, ?hooks: Zenrows::Hooks?) -> void
6
7
  def build_client: (?Hash[Symbol, untyped] options) -> untyped
7
8
  def ssl_context: () -> OpenSSL::SSL::SSLContext
8
9
  def calculate_timeouts: (?Hash[Symbol, untyped] options) -> Hash[Symbol, Integer]
10
+ def wrap_client: (untyped client, Hash[Symbol, untyped] options) -> untyped
11
+ def backend_name: () -> Symbol
9
12
  end
@@ -2,8 +2,9 @@ class Zenrows::Client
2
2
  attr_reader config: Zenrows::Configuration
3
3
  attr_reader proxy: Zenrows::Proxy
4
4
  attr_reader backend: Zenrows::Backends::Base
5
+ attr_reader hooks: Zenrows::Hooks
5
6
 
6
- def initialize: (?api_key: String?, ?host: String?, ?port: Integer?, ?backend: Symbol?) -> void
7
+ def initialize: (?api_key: String?, ?host: String?, ?port: Integer?, ?backend: Symbol?) ?{ (Zenrows::HookConfigurator) -> void } -> void
7
8
  def http: (?Hash[Symbol, untyped] options) -> untyped
8
9
  def ssl_context: () -> OpenSSL::SSL::SSLContext
9
10
  def proxy_config: (?Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
@@ -11,10 +11,19 @@ class Zenrows::Configuration
11
11
  attr_accessor read_timeout: Integer
12
12
  attr_accessor backend: Symbol
13
13
  attr_accessor logger: Logger?
14
+ attr_reader hooks: Zenrows::Hooks
14
15
 
15
16
  def initialize: () -> void
16
17
  def reset!: () -> void
17
18
  def validate!: () -> true
18
19
  def valid?: () -> bool
19
20
  def to_h: () -> Hash[Symbol, untyped]
21
+
22
+ # Hook registration methods
23
+ def before_request: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
24
+ def after_request: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
25
+ def on_response: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
26
+ def on_error: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
27
+ def around_request: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
28
+ def add_subscriber: (untyped subscriber) -> self
20
29
  end
@@ -0,0 +1,9 @@
1
+ class Zenrows::HookConfigurator
2
+ def initialize: (Zenrows::Hooks hooks) -> void
3
+ def before_request: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
4
+ def after_request: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
5
+ def on_response: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
6
+ def on_error: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
7
+ def around_request: (?untyped? callable) ?{ (*untyped) -> untyped } -> self
8
+ def add_subscriber: (untyped subscriber) -> self
9
+ end
@@ -0,0 +1,6 @@
1
+ class Zenrows::Hooks::Context
2
+ ZENROWS_HEADERS: Hash[String, Symbol]
3
+
4
+ def self.for_request: (method: Symbol, url: String, options: Hash[Symbol, untyped], backend: Symbol) -> Hash[Symbol, untyped]
5
+ def self.enrich_with_response: (Hash[Symbol, untyped] context, untyped response) -> Hash[Symbol, untyped]
6
+ end
@@ -0,0 +1,15 @@
1
+ class Zenrows::Hooks::LogSubscriber
2
+ attr_reader logger: Logger?
3
+
4
+ def initialize: (?logger: Logger?) -> void
5
+ def before_request: (Hash[Symbol, untyped] context) -> void
6
+ def on_response: (untyped response, Hash[Symbol, untyped] context) -> void
7
+ def on_error: (Exception error, Hash[Symbol, untyped] context) -> void
8
+
9
+ private
10
+
11
+ def effective_logger: () -> Logger?
12
+ def log: (Symbol level) { () -> String } -> void
13
+ def extract_status: (untyped response) -> (Integer | String)
14
+ def format_duration: (Float? duration) -> String?
15
+ end
@@ -0,0 +1,23 @@
1
+ class Zenrows::Hooks
2
+ include MonitorMixin
3
+
4
+ EVENTS: Array[Symbol]
5
+
6
+ def initialize: () -> void
7
+ def register: (Symbol event, ?_Callable? callable) ?{ (*untyped) -> untyped } -> self
8
+ def add_subscriber: (untyped subscriber) -> self
9
+ def run: (Symbol event, *untyped args) -> void
10
+ def run_around: (Hash[Symbol, untyped] context) { () -> untyped } -> untyped
11
+ def empty?: () -> bool
12
+ def merge: (Zenrows::Hooks? other) -> self
13
+ def dup: () -> Zenrows::Hooks
14
+
15
+ private
16
+
17
+ def validate_event!: (Symbol event) -> void
18
+ def execute_chain: (Array[untyped] chain, Hash[Symbol, untyped] context) { () -> untyped } -> untyped
19
+
20
+ interface _Callable
21
+ def call: (*untyped) -> untyped
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ class Zenrows::InstrumentedClient
2
+ HTTP_METHODS: Array[Symbol]
3
+
4
+ attr_reader http: untyped
5
+ attr_reader hooks: Zenrows::Hooks
6
+ attr_reader context_base: Hash[Symbol, untyped]
7
+
8
+ def initialize: (untyped http, hooks: Zenrows::Hooks, context_base: Hash[Symbol, untyped]) -> void
9
+ def get: (String url, **untyped options) -> untyped
10
+ def post: (String url, **untyped options) -> untyped
11
+ def put: (String url, **untyped options) -> untyped
12
+ def patch: (String url, **untyped options) -> untyped
13
+ def delete: (String url, **untyped options) -> untyped
14
+ def head: (String url, **untyped options) -> untyped
15
+ def options: (String url, **untyped options) -> untyped
16
+
17
+ private
18
+
19
+ def instrument: (Symbol method, String url, Hash[Symbol, untyped] options) { () -> untyped } -> untyped
20
+ def build_context: (Symbol method, String url, Hash[Symbol, untyped] options) -> Hash[Symbol, untyped]
21
+ def execute_request: (Hash[Symbol, untyped] context) { () -> untyped } -> untyped
22
+ end
data/test/test_helper.rb CHANGED
@@ -5,3 +5,45 @@ require "zenrows"
5
5
 
6
6
  require "minitest/autorun"
7
7
  require "webmock/minitest"
8
+
9
+ # Simple mock helper for tests
10
+ class MockResponse
11
+ attr_reader :status, :headers
12
+
13
+ def initialize(status, headers = {})
14
+ @status = status
15
+ @headers = headers
16
+ end
17
+ end
18
+
19
+ class MockHttpClient
20
+ attr_reader :calls
21
+
22
+ def initialize
23
+ @calls = []
24
+ @responses = {}
25
+ end
26
+
27
+ def stub_response(method, url, response)
28
+ @responses[[method, url]] = response
29
+ end
30
+
31
+ def get(url, **options)
32
+ @calls << [:get, url, options]
33
+ @responses[[:get, url]] || MockResponse.new(200)
34
+ end
35
+
36
+ def post(url, **options)
37
+ @calls << [:post, url, options]
38
+ @responses[[:post, url]] || MockResponse.new(200)
39
+ end
40
+
41
+ def respond_to?(method, include_private = false)
42
+ [:get, :post, :custom_method].include?(method) || super
43
+ end
44
+
45
+ def custom_method(*args)
46
+ @calls << [:custom_method, *args]
47
+ :result
48
+ end
49
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ClientHooksTest < Minitest::Test
6
+ def setup
7
+ Zenrows.reset_configuration!
8
+ Zenrows.configure do |c|
9
+ c.api_key = "test_api_key"
10
+ end
11
+ end
12
+
13
+ def teardown
14
+ Zenrows.reset_configuration!
15
+ end
16
+
17
+ def test_client_inherits_global_hooks
18
+ global_called = false
19
+ Zenrows.configure do |c|
20
+ c.on_response { global_called = true }
21
+ end
22
+
23
+ client = Zenrows::Client.new
24
+
25
+ refute_empty client.hooks
26
+ end
27
+
28
+ def test_client_with_per_client_hooks
29
+ client = Zenrows::Client.new do |c|
30
+ c.on_response {}
31
+ end
32
+
33
+ refute_empty client.hooks
34
+ end
35
+
36
+ def test_per_client_hooks_dont_modify_global
37
+ global_empty_before = Zenrows.configuration.hooks.empty?
38
+
39
+ Zenrows.configure do |c|
40
+ c.on_response {}
41
+ end
42
+
43
+ global_not_empty = !Zenrows.configuration.hooks.empty?
44
+
45
+ client = Zenrows::Client.new do |c|
46
+ c.on_error {}
47
+ end
48
+
49
+ # Global hooks should still have same state
50
+ global_still_not_empty = !Zenrows.configuration.hooks.empty?
51
+
52
+ assert global_empty_before
53
+ assert global_not_empty
54
+ assert global_still_not_empty
55
+ refute_empty client.hooks
56
+ end
57
+
58
+ def test_multiple_clients_have_independent_hooks
59
+ client1 = Zenrows::Client.new do |c|
60
+ c.on_response { :client1 }
61
+ end
62
+
63
+ client2 = Zenrows::Client.new do |c|
64
+ c.on_error { :client2 }
65
+ end
66
+
67
+ # Each client should have its own hooks instance
68
+ refute_same client1.hooks, client2.hooks
69
+ end
70
+
71
+ def test_http_returns_instrumented_client_when_hooks_present
72
+ client = Zenrows::Client.new do |c|
73
+ c.on_response {}
74
+ end
75
+
76
+ http = client.http(js_render: true)
77
+
78
+ assert_instance_of Zenrows::InstrumentedClient, http
79
+ end
80
+
81
+ def test_http_returns_raw_client_when_no_hooks
82
+ # Reset to ensure no global hooks
83
+ Zenrows.reset_configuration!
84
+ Zenrows.configure { |c| c.api_key = "test" }
85
+
86
+ client = Zenrows::Client.new
87
+ http = client.http(js_render: true)
88
+
89
+ assert_instance_of HTTP::Client, http
90
+ end
91
+
92
+ def test_per_client_hook_configurator_methods
93
+ # Test all DSL methods are available
94
+ client = Zenrows::Client.new do |c|
95
+ c.before_request {}
96
+ c.after_request {}
97
+ c.on_response {}
98
+ c.on_error {}
99
+ c.around_request { |ctx, &block| block.call }
100
+ c.add_subscriber(Object.new)
101
+ end
102
+
103
+ refute_empty client.hooks
104
+ end
105
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ConfigurationHooksTest < Minitest::Test
6
+ def setup
7
+ Zenrows.reset_configuration!
8
+ end
9
+
10
+ def teardown
11
+ Zenrows.reset_configuration!
12
+ end
13
+
14
+ def test_configuration_has_hooks
15
+ assert_instance_of Zenrows::Hooks, Zenrows.configuration.hooks
16
+ end
17
+
18
+ def test_on_response_registers_hook
19
+ called = false
20
+ Zenrows.configure do |c|
21
+ c.api_key = "test"
22
+ c.on_response { called = true }
23
+ end
24
+
25
+ refute_empty Zenrows.configuration.hooks
26
+ end
27
+
28
+ def test_on_error_registers_hook
29
+ called = false
30
+ Zenrows.configure do |c|
31
+ c.api_key = "test"
32
+ c.on_error { called = true }
33
+ end
34
+
35
+ refute_empty Zenrows.configuration.hooks
36
+ end
37
+
38
+ def test_before_request_registers_hook
39
+ Zenrows.configure do |c|
40
+ c.api_key = "test"
41
+ c.before_request {}
42
+ end
43
+
44
+ refute_empty Zenrows.configuration.hooks
45
+ end
46
+
47
+ def test_after_request_registers_hook
48
+ Zenrows.configure do |c|
49
+ c.api_key = "test"
50
+ c.after_request {}
51
+ end
52
+
53
+ refute_empty Zenrows.configuration.hooks
54
+ end
55
+
56
+ def test_around_request_registers_hook
57
+ Zenrows.configure do |c|
58
+ c.api_key = "test"
59
+ c.around_request { |ctx, &block| block.call }
60
+ end
61
+
62
+ refute_empty Zenrows.configuration.hooks
63
+ end
64
+
65
+ def test_add_subscriber_registers_subscriber
66
+ subscriber = Object.new
67
+ def subscriber.on_response(resp, ctx)
68
+ end
69
+
70
+ Zenrows.configure do |c|
71
+ c.api_key = "test"
72
+ c.add_subscriber(subscriber)
73
+ end
74
+
75
+ refute_empty Zenrows.configuration.hooks
76
+ end
77
+
78
+ def test_reset_clears_hooks
79
+ Zenrows.configure do |c|
80
+ c.api_key = "test"
81
+ c.on_response {}
82
+ end
83
+
84
+ refute_empty Zenrows.configuration.hooks
85
+
86
+ Zenrows.reset_configuration!
87
+
88
+ assert_empty Zenrows.configuration.hooks
89
+ end
90
+
91
+ def test_hook_methods_return_self_for_chaining
92
+ Zenrows.configure do |c|
93
+ result = c.on_response {}
94
+ .on_error {}
95
+ .before_request {}
96
+ .after_request {}
97
+
98
+ assert_equal c, result
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class HooksContextTest < Minitest::Test
6
+ def test_for_request_builds_context
7
+ context = Zenrows::Hooks::Context.for_request(
8
+ method: :get,
9
+ url: "https://example.com/path",
10
+ options: {js_render: true},
11
+ backend: :http_rb
12
+ )
13
+
14
+ assert_equal :get, context[:method]
15
+ assert_equal "https://example.com/path", context[:url]
16
+ assert_equal "example.com", context[:host]
17
+ assert_equal({js_render: true}, context[:options])
18
+ assert_equal :http_rb, context[:backend]
19
+ end
20
+
21
+ def test_for_request_initializes_timing_and_headers
22
+ context = Zenrows::Hooks::Context.for_request(
23
+ method: :get,
24
+ url: "https://example.com",
25
+ options: {},
26
+ backend: :http_rb
27
+ )
28
+
29
+ assert_instance_of Float, context[:started_at]
30
+ assert_empty(context[:zenrows_headers])
31
+ end
32
+
33
+ def test_for_request_with_invalid_url
34
+ context = Zenrows::Hooks::Context.for_request(
35
+ method: :get,
36
+ url: "not a valid url::::",
37
+ options: {},
38
+ backend: :http_rb
39
+ )
40
+
41
+ assert_nil context[:host]
42
+ end
43
+
44
+ def test_for_request_freezes_options
45
+ options = {js_render: true}
46
+ context = Zenrows::Hooks::Context.for_request(
47
+ method: :get,
48
+ url: "https://example.com",
49
+ options: options,
50
+ backend: :http_rb
51
+ )
52
+
53
+ assert_predicate context[:options], :frozen?
54
+ # Original should not be frozen
55
+ refute_predicate options, :frozen?
56
+ end
57
+
58
+ def test_enrich_with_response_parses_zenrows_headers
59
+ context = Zenrows::Hooks::Context.for_request(
60
+ method: :get,
61
+ url: "https://example.com",
62
+ options: {},
63
+ backend: :http_rb
64
+ )
65
+
66
+ response = MockResponse.new(200, {
67
+ "Concurrency-Limit" => "25",
68
+ "Concurrency-Remaining" => "23",
69
+ "X-Request-Cost" => "5.5",
70
+ "X-Request-Id" => "abc123",
71
+ "Zr-Final-Url" => "https://example.com/final"
72
+ })
73
+
74
+ Zenrows::Hooks::Context.enrich_with_response(context, response)
75
+
76
+ assert_equal 25, context[:zenrows_headers][:concurrency_limit]
77
+ assert_equal 23, context[:zenrows_headers][:concurrency_remaining]
78
+ assert_in_delta(5.5, context[:zenrows_headers][:request_cost])
79
+ assert_equal "abc123", context[:zenrows_headers][:request_id]
80
+ assert_equal "https://example.com/final", context[:zenrows_headers][:final_url]
81
+ end
82
+
83
+ def test_enrich_with_response_sets_timing
84
+ context = Zenrows::Hooks::Context.for_request(
85
+ method: :get,
86
+ url: "https://example.com",
87
+ options: {},
88
+ backend: :http_rb
89
+ )
90
+
91
+ response = MockResponse.new(200, {})
92
+
93
+ Zenrows::Hooks::Context.enrich_with_response(context, response)
94
+
95
+ assert_instance_of Float, context[:completed_at]
96
+ assert_instance_of Float, context[:duration]
97
+ end
98
+
99
+ def test_enrich_with_response_handles_missing_headers
100
+ context = Zenrows::Hooks::Context.for_request(
101
+ method: :get,
102
+ url: "https://example.com",
103
+ options: {},
104
+ backend: :http_rb
105
+ )
106
+
107
+ response = MockResponse.new(200, {})
108
+
109
+ Zenrows::Hooks::Context.enrich_with_response(context, response)
110
+
111
+ assert_empty(context[:zenrows_headers])
112
+ end
113
+
114
+ def test_enrich_with_response_handles_lowercase_headers
115
+ context = Zenrows::Hooks::Context.for_request(
116
+ method: :get,
117
+ url: "https://example.com",
118
+ options: {},
119
+ backend: :http_rb
120
+ )
121
+
122
+ response = MockResponse.new(200, {
123
+ "concurrency-limit" => "10",
124
+ "x-request-cost" => "2.0"
125
+ })
126
+
127
+ Zenrows::Hooks::Context.enrich_with_response(context, response)
128
+
129
+ assert_equal 10, context[:zenrows_headers][:concurrency_limit]
130
+ assert_in_delta(2.0, context[:zenrows_headers][:request_cost])
131
+ end
132
+
133
+ def test_enrich_calculates_duration
134
+ context = Zenrows::Hooks::Context.for_request(
135
+ method: :get,
136
+ url: "https://example.com",
137
+ options: {},
138
+ backend: :http_rb
139
+ )
140
+
141
+ sleep 0.01 # Small delay
142
+
143
+ response = MockResponse.new(200, {})
144
+
145
+ Zenrows::Hooks::Context.enrich_with_response(context, response)
146
+
147
+ assert_operator context[:duration], :>=, 0.01
148
+ assert_operator context[:completed_at], :>, context[:started_at]
149
+ end
150
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "logger"
5
+ require "stringio"
6
+
7
+ class LogSubscriberTest < Minitest::Test
8
+ def setup
9
+ @output = StringIO.new
10
+ @logger = Logger.new(@output)
11
+ @logger.level = Logger::DEBUG
12
+ @subscriber = Zenrows::Hooks::LogSubscriber.new(logger: @logger)
13
+ end
14
+
15
+ def test_before_request_logs_debug
16
+ context = {method: :get, url: "https://example.com"}
17
+
18
+ @subscriber.before_request(context)
19
+
20
+ assert_includes @output.string, "ZenRows request: GET https://example.com"
21
+ end
22
+
23
+ def test_on_response_logs_info_with_status
24
+ response = MockResponse.new(200)
25
+
26
+ context = {
27
+ url: "https://example.com",
28
+ duration: 1.5,
29
+ zenrows_headers: {request_cost: 5.0}
30
+ }
31
+
32
+ @subscriber.on_response(response, context)
33
+
34
+ assert_includes @output.string, "ZenRows https://example.com -> 200"
35
+ assert_includes @output.string, "1.5s"
36
+ assert_includes @output.string, "cost: 5.0"
37
+ end
38
+
39
+ def test_on_response_formats_milliseconds
40
+ response = MockResponse.new(200)
41
+
42
+ context = {
43
+ url: "https://example.com",
44
+ duration: 0.150,
45
+ zenrows_headers: {}
46
+ }
47
+
48
+ @subscriber.on_response(response, context)
49
+
50
+ assert_includes @output.string, "150ms"
51
+ end
52
+
53
+ def test_on_error_logs_error
54
+ error = StandardError.new("Connection failed")
55
+ context = {
56
+ url: "https://example.com",
57
+ zenrows_headers: {request_id: "req123"}
58
+ }
59
+
60
+ @subscriber.on_error(error, context)
61
+
62
+ assert_includes @output.string, "ZenRows https://example.com failed"
63
+ assert_includes @output.string, "StandardError"
64
+ assert_includes @output.string, "Connection failed"
65
+ assert_includes @output.string, "request_id: req123"
66
+ end
67
+
68
+ def test_uses_global_logger_when_none_provided
69
+ Zenrows.configure do |c|
70
+ c.api_key = "test"
71
+ c.logger = @logger
72
+ end
73
+
74
+ subscriber = Zenrows::Hooks::LogSubscriber.new
75
+ subscriber.before_request({method: :get, url: "https://test.com"})
76
+
77
+ assert_includes @output.string, "https://test.com"
78
+ ensure
79
+ Zenrows.reset_configuration!
80
+ end
81
+
82
+ def test_handles_nil_logger
83
+ subscriber = Zenrows::Hooks::LogSubscriber.new(logger: nil)
84
+
85
+ # Should not raise
86
+ subscriber.before_request({method: :get, url: "https://example.com"})
87
+ subscriber.on_response(MockResponse.new(200), {url: "x", zenrows_headers: {}})
88
+ subscriber.on_error(StandardError.new, {url: "x", zenrows_headers: {}})
89
+ end
90
+
91
+ def test_handles_status_object_with_code
92
+ # Create a response with a status object that has a code method
93
+ status = Object.new
94
+ status.define_singleton_method(:code) { 201 }
95
+
96
+ response = Object.new
97
+ response.define_singleton_method(:status) { status }
98
+
99
+ context = {url: "https://example.com", zenrows_headers: {}}
100
+
101
+ @subscriber.on_response(response, context)
102
+
103
+ assert_includes @output.string, "201"
104
+ end
105
+ end