screenshotscout 0.1.0.rc1

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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module ScreenshotScout
6
+ # Reusable blocking client for the inline Screenshot Scout capture operation.
7
+ class Client
8
+ CAPTURE_ENDPOINT = "https://api.screenshotscout.com/v1/capture"
9
+ ACCESS_KEY_PATTERN = %r{\A[A-Za-z0-9\-._~+/]+=*\z}
10
+ MISSING_ACCESS_KEY = Object.new.freeze
11
+
12
+ def initialize(access_key: MISSING_ACCESS_KEY, secret_key: nil, transport: nil)
13
+ validate_access_key(access_key)
14
+ validate_secret_key(secret_key)
15
+ validate_transport(transport)
16
+
17
+ @access_key = access_key.dup.freeze
18
+ @secret_key = secret_key&.dup&.freeze
19
+ @transport = transport || NetHttpTransport.new
20
+ end
21
+
22
+ # Sends one GET or POST request and waits for the final inline response.
23
+ def capture(url, options = nil, method: CaptureHttpMethod::POST)
24
+ serialized = Internal::Serializer.serialize(url, options)
25
+ method = CaptureHttpMethod::POST if method.nil?
26
+ validate_method(method)
27
+ expected_kind = expected_response_kind(options)
28
+ signature = create_signature(serialized.pairs)
29
+ request = create_request(serialized, method, signature)
30
+ raw_response = execute_request(request)
31
+
32
+ raise create_api_error(raw_response) unless raw_response.status.between?(200, 299)
33
+
34
+ decode_success_response(raw_response, expected_kind)
35
+ end
36
+
37
+ # Builds a sensitive GET capture URL without performing network I/O.
38
+ def build_capture_url(url, options = nil)
39
+ serialized = Internal::Serializer.serialize(url, options)
40
+ signature = create_signature(serialized.pairs)
41
+ pairs = [Internal::WirePair.new(name: "access_key", value: @access_key), *serialized.pairs]
42
+ pairs << Internal::WirePair.new(name: "signature", value: signature) unless signature.nil?
43
+ "#{CAPTURE_ENDPOINT}?#{Internal::Serializer.encode_query(pairs)}"
44
+ end
45
+
46
+ private
47
+
48
+ def validate_access_key(access_key)
49
+ unless access_key.is_a?(String) && !access_key.strip.empty?
50
+ raise ConfigurationError, "A non-blank Screenshot Scout access key is required."
51
+ end
52
+ return if ACCESS_KEY_PATTERN.match?(access_key)
53
+
54
+ raise ConfigurationError, "The Screenshot Scout access key is not a valid Bearer credential."
55
+ end
56
+
57
+ def validate_secret_key(secret_key)
58
+ return if secret_key.nil?
59
+ return if secret_key.is_a?(String) && !secret_key.strip.empty?
60
+
61
+ raise ConfigurationError, "The Screenshot Scout secret key must be a non-blank string when provided."
62
+ end
63
+
64
+ def validate_transport(transport)
65
+ return if transport.nil? || transport.respond_to?(:call)
66
+
67
+ raise ConfigurationError, "The Screenshot Scout transport must respond to #call."
68
+ end
69
+
70
+ def validate_method(method)
71
+ return if [CaptureHttpMethod::GET, CaptureHttpMethod::POST].include?(method)
72
+
73
+ raise SerializationError.new(
74
+ "The capture method must be CaptureHttpMethod::GET or CaptureHttpMethod::POST.",
75
+ option: :method
76
+ )
77
+ end
78
+
79
+ def create_signature(pairs)
80
+ return nil if @secret_key.nil?
81
+
82
+ canonical_query = Internal::Serializer.build_canonical_query(pairs, @access_key)
83
+ OpenSSL::HMAC.hexdigest("SHA256", @secret_key, canonical_query)
84
+ rescue Error
85
+ raise
86
+ rescue StandardError => e
87
+ raise SerializationError.new("The capture request could not be signed."), cause: e
88
+ end
89
+
90
+ def create_request(serialized, method, signature)
91
+ headers = { "Authorization" => "Bearer #{@access_key}" }
92
+ if method == CaptureHttpMethod::GET
93
+ pairs = serialized.pairs.dup
94
+ pairs << Internal::WirePair.new(name: "signature", value: signature) unless signature.nil?
95
+ url = "#{CAPTURE_ENDPOINT}?#{Internal::Serializer.encode_query(pairs)}"
96
+ TransportRequest.new(method: method, url: url, headers: headers)
97
+ else
98
+ body = serialized.body.dup
99
+ body["signature"] = signature unless signature.nil?
100
+ headers["Content-Type"] = "application/json"
101
+ TransportRequest.new(
102
+ method: method,
103
+ url: CAPTURE_ENDPOINT,
104
+ headers: headers,
105
+ body: Internal::JsonCodec.encode_wire_object(body)
106
+ )
107
+ end
108
+ rescue Error
109
+ raise
110
+ rescue StandardError => e
111
+ raise SerializationError.new("The Screenshot Scout HTTP request could not be constructed."), cause: e
112
+ end
113
+
114
+ def execute_request(request)
115
+ raw_response = @transport.call(request)
116
+ return raw_response if raw_response.is_a?(RawResponse)
117
+
118
+ raise TypeError, "The Screenshot Scout transport must return ScreenshotScout::RawResponse."
119
+ rescue Error
120
+ raise
121
+ rescue StandardError => e
122
+ raise TransportError, "Screenshot Scout request failed before a complete HTTP response body was received.",
123
+ cause: e
124
+ end
125
+
126
+ def expected_response_kind(options)
127
+ response_type = options&.response_type
128
+ return :binary if response_type.nil? || response_type == CaptureResponseType::BINARY
129
+ return :json if response_type == CaptureResponseType::JSON
130
+
131
+ nil
132
+ end
133
+
134
+ def decode_success_response(raw_response, expected_kind)
135
+ actual_kind = json_media_type?(raw_response.content_type) ? :json : :binary
136
+ if !expected_kind.nil? && actual_kind != expected_kind
137
+ message = "Screenshot Scout returned a successful #{actual_kind} response " \
138
+ "when #{expected_kind} was requested."
139
+ cause = TypeError.new("Expected a #{expected_kind} response but received #{actual_kind}.")
140
+ raise ResponseDecodingError.new(message, raw_response), cause: cause
141
+ end
142
+
143
+ actual_kind == :json ? decode_json_response(raw_response) : decode_binary_response(raw_response)
144
+ end
145
+
146
+ def decode_binary_response(raw_response)
147
+ BinaryCaptureResponse.new(
148
+ raw_response: raw_response,
149
+ screenshot_url: raw_response.header("Screenshot-Scout-Screenshot-URL").first,
150
+ screenshot_url_expires_at: raw_response.header("Screenshot-Scout-Screenshot-URL-Expires-At").first,
151
+ cache_status: raw_response.header("Screenshot-Scout-Cache-Status").first
152
+ )
153
+ end
154
+
155
+ def decode_json_response(raw_response)
156
+ value = parse_json_success(raw_response)
157
+ unless value.is_a?(Hash)
158
+ cause = TypeError.new("Expected a JSON object.")
159
+ raise ResponseDecodingError.new(
160
+ "Screenshot Scout returned a successful JSON response that was not an object.",
161
+ raw_response
162
+ ), cause: cause
163
+ end
164
+
165
+ screenshot_url = read_optional_string(value, "screenshot_url", raw_response)
166
+ expires_at = read_optional_string(value, "screenshot_url_expires_at", raw_response)
167
+ cache_status = read_optional_string(value, "cache_status", raw_response)
168
+ additional_fields = value.except("screenshot_url", "screenshot_url_expires_at", "cache_status")
169
+ result = CaptureResult.new(
170
+ screenshot_url: screenshot_url,
171
+ screenshot_url_expires_at: expires_at,
172
+ cache_status: cache_status,
173
+ additional_fields: additional_fields
174
+ )
175
+ JsonCaptureResponse.new(result: result, raw_response: raw_response)
176
+ end
177
+
178
+ def parse_json_success(raw_response)
179
+ Internal::JsonCodec.parse(raw_response.body)
180
+ rescue JSON::ParserError, EncodingError => e
181
+ raise ResponseDecodingError.new(
182
+ "Screenshot Scout returned a successful JSON response that could not be decoded.",
183
+ raw_response
184
+ ), cause: e
185
+ end
186
+
187
+ def read_optional_string(object, key, raw_response)
188
+ return nil unless object.key?(key)
189
+ return object[key] if object[key].is_a?(String)
190
+
191
+ cause = TypeError.new("Expected \"#{key}\" to be a string.")
192
+ raise ResponseDecodingError.new(
193
+ "Screenshot Scout returned a non-string \"#{key}\" JSON field.",
194
+ raw_response
195
+ ), cause: cause
196
+ end
197
+
198
+ def create_api_error(raw_response)
199
+ available, response_body = try_parse_json(raw_response.body)
200
+ object = response_body if response_body.is_a?(Hash)
201
+ error_code = object["error_code"] if object&.fetch("error_code", nil).is_a?(String)
202
+ error_message = object["error_message"] if object&.fetch("error_message", nil).is_a?(String)
203
+ errors = object["errors"] if object&.fetch("errors", nil).is_a?(Array)
204
+
205
+ APIError.new(
206
+ raw_response: raw_response,
207
+ response_body_available: available,
208
+ response_body: response_body,
209
+ error_code: error_code,
210
+ error_message: error_message,
211
+ errors: errors
212
+ )
213
+ end
214
+
215
+ def try_parse_json(body)
216
+ [true, Internal::JsonCodec.parse(body)]
217
+ rescue JSON::ParserError, EncodingError
218
+ [false, nil]
219
+ end
220
+
221
+ def json_media_type?(content_type)
222
+ return false if content_type.nil?
223
+
224
+ media_type = content_type.partition(";").first.strip.downcase
225
+ media_type == "application/json" || media_type.end_with?("+json")
226
+ end
227
+
228
+ private_constant :CAPTURE_ENDPOINT, :ACCESS_KEY_PATTERN, :MISSING_ACCESS_KEY
229
+ end
230
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotScout
4
+ # Base class for every exception raised by the SDK.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when client credentials or capture-call controls are unusable.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when a capture option cannot be represented safely on the wire.
11
+ class SerializationError < Error
12
+ attr_reader :option
13
+
14
+ def initialize(message, option: nil)
15
+ super(message)
16
+ @option = option
17
+ end
18
+ end
19
+
20
+ # Raised when the configured transport cannot complete an HTTP exchange.
21
+ class TransportError < Error; end
22
+
23
+ # Raised for every non-2xx response returned by the Screenshot Scout API.
24
+ class APIError < Error
25
+ attr_reader :status, :error_code, :error_message, :errors, :response_body, :raw_response
26
+
27
+ def initialize(
28
+ raw_response:,
29
+ response_body_available:,
30
+ response_body: nil,
31
+ error_code: nil,
32
+ error_message: nil,
33
+ errors: nil
34
+ )
35
+ super(error_message || "Screenshot Scout API request failed with status #{raw_response.status}.")
36
+ @status = raw_response.status
37
+ @error_code = error_code
38
+ @error_message = error_message
39
+ @errors = errors
40
+ @response_body_available = response_body_available
41
+ @response_body = response_body
42
+ @raw_response = raw_response
43
+ end
44
+
45
+ def response_body?
46
+ @response_body_available
47
+ end
48
+ end
49
+
50
+ # Raised when a successful response cannot be decoded as its expected type.
51
+ class ResponseDecodingError < Error
52
+ attr_reader :raw_response
53
+
54
+ def initialize(message, raw_response)
55
+ super(message)
56
+ @raw_response = raw_response
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotScout
4
+ module Internal
5
+ # Formats IEEE-754 values like JavaScript String(number) and JSON.stringify.
6
+ module EcmaScriptNumberFormatter
7
+ module_function
8
+
9
+ def format(value)
10
+ raise ArgumentError, "Expected a finite floating-point value." unless value.finite?
11
+ return "0" if value.zero?
12
+
13
+ encoded = value.to_s.downcase
14
+ return normalize_decimal(encoded) unless encoded.include?("e")
15
+
16
+ match = /\A(-?)([0-9])(?:\.([0-9]+))?e([+-]?[0-9]+)\z/.match(encoded)
17
+ raise ArgumentError, "Unexpected floating-point encoding." if match.nil?
18
+
19
+ negative = match[1] == "-"
20
+ digits = (match[2] + match[3].to_s).sub(/0+\z/, "")
21
+ digits = "0" if digits.empty?
22
+ exponent = Integer(match[4], 10)
23
+ number = expand_digits(digits, exponent)
24
+ negative ? "-#{number}" : number
25
+ end
26
+
27
+ def normalize_decimal(encoded)
28
+ return encoded unless encoded.include?(".")
29
+
30
+ encoded.sub(/0+\z/, "").sub(/\.\z/, "")
31
+ end
32
+ private_class_method :normalize_decimal
33
+
34
+ def expand_digits(digits, exponent)
35
+ decimal_position = exponent + 1
36
+ if decimal_position.positive? && decimal_position <= 21
37
+ if digits.length <= decimal_position
38
+ digits + ("0" * (decimal_position - digits.length))
39
+ else
40
+ "#{digits[0, decimal_position]}.#{digits[decimal_position..]}"
41
+ end
42
+ elsif decimal_position > -6 && decimal_position <= 0
43
+ "0.#{'0' * -decimal_position}#{digits}"
44
+ else
45
+ mantissa = digits.length == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
46
+ "#{mantissa}e#{'+' if exponent >= 0}#{exponent}"
47
+ end
48
+ end
49
+ private_class_method :expand_digits
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ScreenshotScout
6
+ module Internal
7
+ # Encodes already-validated capture values using API-compatible JSON numbers.
8
+ module JsonCodec
9
+ module_function
10
+
11
+ def encode_wire_object(object)
12
+ members = object.map do |name, value|
13
+ "#{JSON.generate(name)}:#{encode_wire_value(value)}"
14
+ end
15
+ "{#{members.join(',')}}"
16
+ end
17
+
18
+ def parse(body)
19
+ JSON.parse(body, create_additions: false)
20
+ end
21
+
22
+ def encode_wire_value(value)
23
+ case value
24
+ when String
25
+ JSON.generate(value)
26
+ when Integer
27
+ EcmaScriptNumberFormatter.format(value.to_f)
28
+ when Float
29
+ EcmaScriptNumberFormatter.format(value)
30
+ when true
31
+ "true"
32
+ when false
33
+ "false"
34
+ when Array
35
+ "[#{value.map { |item| JSON.generate(item) }.join(',')}]"
36
+ else
37
+ raise TypeError, "Unsupported JSON wire value #{value.class}."
38
+ end
39
+ end
40
+ private_class_method :encode_wire_value
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotScout
4
+ module Internal
5
+ WirePair = Data.define(:name, :value)
6
+ SerializedCaptureOptions = Data.define(:pairs, :body)
7
+
8
+ # Converts CaptureOptions into the API's exact ordered wire representation.
9
+ module Serializer
10
+ WIRE_NAMES = {
11
+ format: "format",
12
+ response_type: "response_type",
13
+ country: "country",
14
+ proxy: "proxy",
15
+ geolocation_latitude: "geolocation_latitude",
16
+ geolocation_longitude: "geolocation_longitude",
17
+ geolocation_accuracy: "geolocation_accuracy",
18
+ cookies: "cookies",
19
+ headers: "headers",
20
+ timeout: "timeout",
21
+ wait_until: "wait_until",
22
+ navigation_timeout: "navigation_timeout",
23
+ delay: "delay",
24
+ device: "device",
25
+ device_viewport_width: "device_viewport_width",
26
+ device_viewport_height: "device_viewport_height",
27
+ device_scale_factor: "device_scale_factor",
28
+ device_is_mobile: "device_is_mobile",
29
+ device_has_touch: "device_has_touch",
30
+ device_user_agent: "device_user_agent",
31
+ timezone: "timezone",
32
+ media_type: "media_type",
33
+ color_scheme: "color_scheme",
34
+ reduced_motion: "reduced_motion",
35
+ full_page: "full_page",
36
+ full_page_pre_scroll: "full_page_pre_scroll",
37
+ full_page_pre_scroll_step: "full_page_pre_scroll_step",
38
+ full_page_pre_scroll_step_delay: "full_page_pre_scroll_step_delay",
39
+ full_page_max_height: "full_page_max_height",
40
+ block_cookie_banners: "block_cookie_banners",
41
+ block_ads: "block_ads",
42
+ block_chat_widgets: "block_chat_widgets",
43
+ hide_selectors: "hide_selectors",
44
+ click_selectors: "click_selectors",
45
+ click_all_selectors: "click_all_selectors",
46
+ inject_css: "inject_css",
47
+ inject_js: "inject_js",
48
+ bypass_csp: "bypass_csp",
49
+ selector: "selector",
50
+ clip_x: "clip_x",
51
+ clip_y: "clip_y",
52
+ clip_width: "clip_width",
53
+ clip_height: "clip_height",
54
+ image_width: "image_width",
55
+ image_height: "image_height",
56
+ image_mode: "image_mode",
57
+ image_anchor: "image_anchor",
58
+ image_allow_upscale: "image_allow_upscale",
59
+ image_background: "image_background",
60
+ image_quality: "image_quality",
61
+ pdf_paper_format: "pdf_paper_format",
62
+ pdf_landscape: "pdf_landscape",
63
+ pdf_print_background: "pdf_print_background",
64
+ pdf_margin: "pdf_margin",
65
+ pdf_margin_top: "pdf_margin_top",
66
+ pdf_margin_right: "pdf_margin_right",
67
+ pdf_margin_bottom: "pdf_margin_bottom",
68
+ pdf_margin_left: "pdf_margin_left",
69
+ pdf_scale: "pdf_scale",
70
+ cache: "cache",
71
+ cache_ttl: "cache_ttl",
72
+ cache_key: "cache_key",
73
+ storage_mode: "storage_mode",
74
+ storage_endpoint: "storage_endpoint",
75
+ storage_bucket: "storage_bucket",
76
+ storage_region: "storage_region",
77
+ storage_object_key: "storage_object_key"
78
+ }.freeze
79
+
80
+ REPEATED_OPTIONS = %i[
81
+ cookies headers hide_selectors click_selectors click_all_selectors inject_css inject_js
82
+ ].freeze
83
+
84
+ module_function
85
+
86
+ def serialize(target_url, options)
87
+ url = string_value(target_url, :url, target: true)
88
+ unless options.nil? || options.is_a?(CaptureOptions)
89
+ raise SerializationError, "Capture options must be a ScreenshotScout::CaptureOptions instance when provided."
90
+ end
91
+
92
+ pairs = [WirePair.new(name: "url", value: url)]
93
+ body = { "url" => url }
94
+ WIRE_NAMES.each do |property, wire_name|
95
+ value = options&.public_send(property)
96
+ next if value.nil?
97
+
98
+ if REPEATED_OPTIONS.include?(property)
99
+ append_repeated(pairs, body, property, wire_name, value)
100
+ else
101
+ append_scalar(pairs, body, property, wire_name, value)
102
+ end
103
+ end
104
+
105
+ SerializedCaptureOptions.new(pairs: pairs.freeze, body: body.freeze)
106
+ rescue Error
107
+ raise
108
+ rescue StandardError => e
109
+ raise SerializationError.new("Capture options could not be serialized."), cause: e
110
+ end
111
+
112
+ def build_canonical_query(pairs, access_key)
113
+ indexed = (pairs + [WirePair.new(name: "access_key", value: access_key)]).each_with_index.map do |pair, index|
114
+ [pair, index]
115
+ end
116
+ indexed.sort! do |(left, left_index), (right, right_index)|
117
+ comparison = left.name <=> right.name
118
+ comparison.zero? ? left_index <=> right_index : comparison
119
+ end
120
+ encode_query(indexed.map(&:first))
121
+ end
122
+
123
+ def encode_query(pairs)
124
+ pairs.map { |pair| "#{form_encode(pair.name)}=#{form_encode(pair.value)}" }.join("&")
125
+ end
126
+
127
+ def append_repeated(pairs, body, property, wire_name, value)
128
+ unless value.is_a?(Array)
129
+ raise SerializationError.new(
130
+ "The capture option \"#{property}\" must be an array of strings.",
131
+ option: property
132
+ )
133
+ end
134
+ return if value.empty?
135
+
136
+ copied = value.map do |item|
137
+ string_value(item, property)
138
+ end
139
+ copied.each { |item| pairs << WirePair.new(name: wire_name, value: item) }
140
+ body[wire_name] = copied.freeze
141
+ end
142
+ private_class_method :append_repeated
143
+
144
+ def append_scalar(pairs, body, property, wire_name, value)
145
+ pair_value, body_value = scalar_value(value, property)
146
+ pairs << WirePair.new(name: wire_name, value: pair_value)
147
+ body[wire_name] = body_value
148
+ end
149
+ private_class_method :append_scalar
150
+
151
+ def scalar_value(value, property)
152
+ case value
153
+ when String
154
+ string = string_value(value, property)
155
+ [string, string]
156
+ when Integer
157
+ number = integer_value(value, property)
158
+ [number, value]
159
+ when Float
160
+ unless value.finite?
161
+ raise SerializationError.new(
162
+ "The capture option \"#{property}\" must be a finite number.",
163
+ option: property
164
+ )
165
+ end
166
+ [EcmaScriptNumberFormatter.format(value), value]
167
+ when true
168
+ ["true", true]
169
+ when false
170
+ ["false", false]
171
+ else
172
+ raise SerializationError.new(
173
+ "The capture option \"#{property}\" must be a string, finite number, or boolean.",
174
+ option: property
175
+ )
176
+ end
177
+ end
178
+ private_class_method :scalar_value
179
+
180
+ def integer_value(value, property)
181
+ float_value = value.to_f
182
+ return EcmaScriptNumberFormatter.format(float_value) if float_value.finite? && float_value.to_i == value
183
+
184
+ raise SerializationError.new(
185
+ "The capture option \"#{property}\" integer cannot be represented safely by the API.",
186
+ option: property
187
+ )
188
+ end
189
+ private_class_method :integer_value
190
+
191
+ def string_value(value, property, target: false)
192
+ unless value.is_a?(String)
193
+ message = if target
194
+ "The capture target URL must be a string."
195
+ else
196
+ "The capture option \"#{property}\" must be a string."
197
+ end
198
+ raise SerializationError.new(message, option: property)
199
+ end
200
+
201
+ value.encode(Encoding::UTF_8)
202
+ rescue EncodingError => e
203
+ message = if target
204
+ "The capture target URL must contain valid UTF-8."
205
+ else
206
+ "The capture option \"#{property}\" must contain valid UTF-8."
207
+ end
208
+ raise SerializationError.new(message, option: property), cause: e
209
+ end
210
+ private_class_method :string_value
211
+
212
+ def form_encode(value)
213
+ value.bytes.map do |byte|
214
+ if byte.between?(65, 90) || byte.between?(97, 122) ||
215
+ byte.between?(48, 57) || [42, 45, 46, 95].include?(byte)
216
+ byte.chr
217
+ elsif byte == 32
218
+ "+"
219
+ else
220
+ format("%%%02X", byte)
221
+ end
222
+ end.join
223
+ end
224
+ private_class_method :form_encode
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotScout
4
+ # Exact buffered HTTP response data retained on successes and API failures.
5
+ class RawResponse
6
+ attr_reader :status, :reason_phrase, :headers, :content_type, :body
7
+
8
+ def initialize(status:, reason_phrase:, headers:, content_type:, body:)
9
+ @status = status
10
+ @reason_phrase = reason_phrase.dup.freeze
11
+ @headers = copy_headers(headers)
12
+ @normalized_headers = @headers.to_h do |name, values|
13
+ [name.downcase, values]
14
+ end.freeze
15
+ @content_type = content_type&.dup&.freeze
16
+ @body = body.dup.freeze
17
+ freeze
18
+ end
19
+
20
+ def header(name)
21
+ @normalized_headers.fetch(name.downcase, EMPTY_HEADER_VALUES)
22
+ end
23
+
24
+ EMPTY_HEADER_VALUES = [].freeze
25
+ private_constant :EMPTY_HEADER_VALUES
26
+
27
+ private
28
+
29
+ def copy_headers(headers)
30
+ headers.to_h do |name, values|
31
+ copied_values = Array(values).map { |value| value.to_s.dup.freeze }.freeze
32
+ [name.to_s.dup.freeze, copied_values]
33
+ end.freeze
34
+ end
35
+ end
36
+
37
+ # Shared response base that exposes the exact buffered HTTP response.
38
+ class CaptureResponse
39
+ attr_reader :raw_response
40
+
41
+ def initialize(raw_response:)
42
+ @raw_response = raw_response
43
+ end
44
+ end
45
+
46
+ # Successful binary capture, including response metadata and image/PDF bytes.
47
+ class BinaryCaptureResponse < CaptureResponse
48
+ attr_reader :bytes, :screenshot_url, :screenshot_url_expires_at, :cache_status
49
+
50
+ def initialize(raw_response:, screenshot_url:, screenshot_url_expires_at:, cache_status:)
51
+ super(raw_response: raw_response)
52
+ @bytes = raw_response.body
53
+ @screenshot_url = screenshot_url
54
+ @screenshot_url_expires_at = screenshot_url_expires_at
55
+ @cache_status = cache_status
56
+ freeze
57
+ end
58
+ end
59
+
60
+ # Structured capture result returned when response_type is json.
61
+ class CaptureResult
62
+ attr_reader :screenshot_url, :screenshot_url_expires_at, :cache_status, :additional_fields
63
+
64
+ def initialize(screenshot_url:, screenshot_url_expires_at:, cache_status:, additional_fields:)
65
+ @screenshot_url = screenshot_url
66
+ @screenshot_url_expires_at = screenshot_url_expires_at
67
+ @cache_status = cache_status
68
+ @additional_fields = additional_fields.freeze
69
+ freeze
70
+ end
71
+ end
72
+
73
+ # Successful JSON capture response and its structured result.
74
+ class JsonCaptureResponse < CaptureResponse
75
+ attr_reader :result
76
+
77
+ def initialize(result:, raw_response:)
78
+ super(raw_response: raw_response)
79
+ @result = result
80
+ freeze
81
+ end
82
+ end
83
+ end