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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zenrows
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
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
@@ -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