renderscreenshot 1.0.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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module RenderScreenshot
7
+ # HTTP client wrapper using Faraday
8
+ class HttpClient
9
+ USER_AGENT = "renderscreenshot-ruby/#{VERSION}"
10
+
11
+ # Default base delay for exponential backoff (in seconds)
12
+ DEFAULT_RETRY_DELAY = 1.0
13
+ # Maximum delay between retries (in seconds)
14
+ MAX_RETRY_DELAY = 30.0
15
+
16
+ attr_reader :api_key, :base_url, :timeout, :max_retries, :retry_delay
17
+
18
+ # Initialize a new HTTP client
19
+ # @param api_key [String] API key for authentication
20
+ # @param base_url [String, nil] Custom base URL
21
+ # @param timeout [Integer, nil] Request timeout in seconds
22
+ # @param max_retries [Integer] Maximum number of retries for retryable errors (default: 0)
23
+ # @param retry_delay [Float] Base delay between retries in seconds (default: 1.0)
24
+ def initialize(api_key, base_url: nil, timeout: nil, max_retries: 0, retry_delay: DEFAULT_RETRY_DELAY)
25
+ @api_key = api_key
26
+ @base_url = base_url || RenderScreenshot.configuration.base_url
27
+ @timeout = timeout || RenderScreenshot.configuration.timeout
28
+ @max_retries = max_retries
29
+ @retry_delay = retry_delay
30
+ @connection_mutex = Mutex.new
31
+ end
32
+
33
+ def get(path, params: {}, headers: {})
34
+ request(:get, path, params: params, headers: headers)
35
+ end
36
+
37
+ def get_binary(path, params: {}, headers: {})
38
+ request(:get, path, params: params, headers: headers, binary: true)
39
+ end
40
+
41
+ def post(path, body: nil, headers: {})
42
+ request(:post, path, body: body, headers: headers)
43
+ end
44
+
45
+ def post_binary(path, body: nil, headers: {})
46
+ request(:post, path, body: body, headers: headers, binary: true)
47
+ end
48
+
49
+ def delete(path, params: {}, headers: {})
50
+ request(:delete, path, params: params, headers: headers)
51
+ end
52
+
53
+ private
54
+
55
+ def connection
56
+ @connection || @connection_mutex.synchronize do
57
+ @connection ||= Faraday.new(url: base_url) do |faraday|
58
+ faraday.options.timeout = timeout
59
+ faraday.options.open_timeout = 10
60
+ faraday.adapter Faraday.default_adapter
61
+ end
62
+ end
63
+ end
64
+
65
+ def request(method, path, params: {}, body: nil, headers: {}, binary: false)
66
+ attempts = 0
67
+
68
+ begin
69
+ attempts += 1
70
+ response = execute_request(method, path, params, body, headers)
71
+ handle_response(response, binary)
72
+ rescue Faraday::TimeoutError
73
+ error = TimeoutError.timeout
74
+ raise error unless should_retry?(error, attempts)
75
+
76
+ sleep_before_retry(error, attempts)
77
+ retry
78
+ rescue Faraday::ConnectionFailed => e
79
+ error = ConnectionError.connection_failed(e.message)
80
+ raise error unless should_retry?(error, attempts)
81
+
82
+ sleep_before_retry(error, attempts)
83
+ retry
84
+ rescue Error => e
85
+ raise e unless should_retry?(e, attempts)
86
+
87
+ sleep_before_retry(e, attempts)
88
+ retry
89
+ end
90
+ end
91
+
92
+ def should_retry?(error, attempts)
93
+ return false unless error.retryable?
94
+ return false if attempts > max_retries
95
+
96
+ true
97
+ end
98
+
99
+ def sleep_before_retry(error, attempts)
100
+ # Use retry_after header if available (from rate limit errors)
101
+ delay = if error.respond_to?(:retry_after) && error.retry_after
102
+ error.retry_after.to_f
103
+ else
104
+ # Exponential backoff with jitter: base_delay * 2^(attempt-1) + random jitter
105
+ calculated = retry_delay * (2**(attempts - 1))
106
+ jitter = rand * retry_delay * 0.5
107
+ [calculated + jitter, MAX_RETRY_DELAY].min
108
+ end
109
+
110
+ sleep(delay)
111
+ end
112
+
113
+ def execute_request(method, path, params, body, headers)
114
+ connection.send(method) do |req|
115
+ req.url path
116
+ req.headers['Authorization'] = "Bearer #{api_key}"
117
+ req.headers['User-Agent'] = USER_AGENT
118
+ req.headers['Content-Type'] = 'application/json' if body
119
+
120
+ headers.each { |k, v| req.headers[k] = v }
121
+ req.params = params if params.any?
122
+ req.body = body.is_a?(String) ? body : JSON.generate(body) if body
123
+ end
124
+ end
125
+
126
+ def handle_response(response, binary)
127
+ retry_after = parse_retry_after(response.headers['Retry-After'])
128
+ request_id = response.headers['X-Request-Id']
129
+
130
+ unless response.success?
131
+ body = parse_body(response)
132
+ raise Error.from_response(response.status, body, retry_after: retry_after, request_id: request_id)
133
+ end
134
+
135
+ if binary
136
+ {
137
+ body: response.body,
138
+ headers: response.headers.to_h
139
+ }
140
+ else
141
+ parse_body(response)
142
+ end
143
+ end
144
+
145
+ def parse_body(response)
146
+ return {} if response.body.nil? || response.body.empty?
147
+
148
+ content_type = response.headers['Content-Type'] || ''
149
+ if content_type.include?('application/json')
150
+ JSON.parse(response.body)
151
+ else
152
+ response.body
153
+ end
154
+ rescue JSON::ParserError
155
+ response.body
156
+ end
157
+
158
+ def parse_retry_after(value)
159
+ return nil unless value
160
+
161
+ Integer(value)
162
+ rescue ArgumentError
163
+ nil
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,442 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module RenderScreenshot
6
+ # Immutable fluent builder for screenshot options
7
+ # All methods return a new instance, preserving immutability
8
+ class TakeOptions
9
+ attr_reader :config
10
+
11
+ def initialize(config = {})
12
+ @config = config.dup.freeze
13
+ end
14
+
15
+ # Factory methods
16
+ def self.url(url)
17
+ new(url: url)
18
+ end
19
+
20
+ def self.html(html)
21
+ new(html: html)
22
+ end
23
+
24
+ def self.from(hash)
25
+ new(hash)
26
+ end
27
+
28
+ # Viewport methods
29
+ def width(value)
30
+ with(width: value)
31
+ end
32
+
33
+ def height(value)
34
+ with(height: value)
35
+ end
36
+
37
+ def scale(value)
38
+ with(scale: value)
39
+ end
40
+
41
+ def mobile(value = true)
42
+ with(mobile: value)
43
+ end
44
+
45
+ # Capture methods
46
+ def full_page(value = true)
47
+ with(full_page: value)
48
+ end
49
+
50
+ def element(selector)
51
+ with(element: selector)
52
+ end
53
+
54
+ def format(value)
55
+ with(format: value)
56
+ end
57
+
58
+ def quality(value)
59
+ with(quality: value)
60
+ end
61
+
62
+ # Wait methods
63
+ def wait_for(value)
64
+ with(wait_for: value)
65
+ end
66
+
67
+ def delay(value)
68
+ with(delay: value)
69
+ end
70
+
71
+ def wait_for_selector(selector)
72
+ with(wait_for_selector: selector)
73
+ end
74
+
75
+ def wait_for_timeout(value)
76
+ with(wait_for_timeout: value)
77
+ end
78
+
79
+ # Preset methods
80
+ def preset(value)
81
+ with(preset: value)
82
+ end
83
+
84
+ def device(value)
85
+ with(device: value)
86
+ end
87
+
88
+ # Blocking methods
89
+ def block_ads(value = true)
90
+ with(block_ads: value)
91
+ end
92
+
93
+ def block_trackers(value = true)
94
+ with(block_trackers: value)
95
+ end
96
+
97
+ def block_cookie_banners(value = true)
98
+ with(block_cookie_banners: value)
99
+ end
100
+
101
+ def block_chat_widgets(value = true)
102
+ with(block_chat_widgets: value)
103
+ end
104
+
105
+ def block_urls(patterns)
106
+ with(block_urls: patterns)
107
+ end
108
+
109
+ def block_resources(types)
110
+ with(block_resources: types)
111
+ end
112
+
113
+ # Page manipulation methods
114
+ def inject_script(script)
115
+ with(inject_script: script)
116
+ end
117
+
118
+ def inject_style(style)
119
+ with(inject_style: style)
120
+ end
121
+
122
+ def click(selector)
123
+ with(click: selector)
124
+ end
125
+
126
+ def hide(selectors)
127
+ with(hide: Array(selectors))
128
+ end
129
+
130
+ def remove(selectors)
131
+ with(remove: Array(selectors))
132
+ end
133
+
134
+ # Browser emulation methods
135
+ def dark_mode(value = true)
136
+ with(dark_mode: value)
137
+ end
138
+
139
+ def reduced_motion(value = true)
140
+ with(reduced_motion: value)
141
+ end
142
+
143
+ def media_type(value)
144
+ with(media_type: value)
145
+ end
146
+
147
+ def user_agent(value)
148
+ with(user_agent: value)
149
+ end
150
+
151
+ def timezone(value)
152
+ with(timezone: value)
153
+ end
154
+
155
+ def locale(value)
156
+ with(locale: value)
157
+ end
158
+
159
+ def geolocation(latitude, longitude, accuracy: nil)
160
+ geo = { latitude: latitude, longitude: longitude }
161
+ geo[:accuracy] = accuracy if accuracy
162
+ with(geolocation: geo)
163
+ end
164
+
165
+ # Network methods
166
+ def headers(value)
167
+ with(headers: value)
168
+ end
169
+
170
+ def cookies(value)
171
+ with(cookies: value)
172
+ end
173
+
174
+ def auth_basic(username, password)
175
+ with(auth_basic: { username: username, password: password })
176
+ end
177
+
178
+ def auth_bearer(token)
179
+ with(auth_bearer: token)
180
+ end
181
+
182
+ def bypass_csp(value = true)
183
+ with(bypass_csp: value)
184
+ end
185
+
186
+ # Cache methods
187
+ def cache_ttl(value)
188
+ with(cache_ttl: value)
189
+ end
190
+
191
+ def cache_refresh(value = true)
192
+ with(cache_refresh: value)
193
+ end
194
+
195
+ # PDF methods
196
+ def pdf_paper_size(value)
197
+ with(pdf_paper_size: value)
198
+ end
199
+
200
+ def pdf_width(value)
201
+ with(pdf_width: value)
202
+ end
203
+
204
+ def pdf_height(value)
205
+ with(pdf_height: value)
206
+ end
207
+
208
+ def pdf_landscape(value = true)
209
+ with(pdf_landscape: value)
210
+ end
211
+
212
+ def pdf_margin(value)
213
+ with(pdf_margin: value)
214
+ end
215
+
216
+ def pdf_margin_top(value)
217
+ with(pdf_margin_top: value)
218
+ end
219
+
220
+ def pdf_margin_right(value)
221
+ with(pdf_margin_right: value)
222
+ end
223
+
224
+ def pdf_margin_bottom(value)
225
+ with(pdf_margin_bottom: value)
226
+ end
227
+
228
+ def pdf_margin_left(value)
229
+ with(pdf_margin_left: value)
230
+ end
231
+
232
+ def pdf_scale(value)
233
+ with(pdf_scale: value)
234
+ end
235
+
236
+ def pdf_print_background(value = true)
237
+ with(pdf_print_background: value)
238
+ end
239
+
240
+ def pdf_page_ranges(value)
241
+ with(pdf_page_ranges: value)
242
+ end
243
+
244
+ def pdf_header(value)
245
+ with(pdf_header: value)
246
+ end
247
+
248
+ def pdf_footer(value)
249
+ with(pdf_footer: value)
250
+ end
251
+
252
+ def pdf_fit_one_page(value = true)
253
+ with(pdf_fit_one_page: value)
254
+ end
255
+
256
+ def pdf_prefer_css_page_size(value = true)
257
+ with(pdf_prefer_css_page_size: value)
258
+ end
259
+
260
+ # Storage methods
261
+ def storage_enabled(value = true)
262
+ with(storage_enabled: value)
263
+ end
264
+
265
+ def storage_path(value)
266
+ with(storage_path: value)
267
+ end
268
+
269
+ def storage_acl(value)
270
+ with(storage_acl: value)
271
+ end
272
+
273
+ # Output methods
274
+ def to_h
275
+ config.dup
276
+ end
277
+
278
+ # Convert to nested JSON params for POST requests
279
+ def to_params
280
+ result = {}
281
+
282
+ # Top-level params
283
+ %i[url html preset].each do |key|
284
+ result[key] = config[key] if config.key?(key)
285
+ end
286
+
287
+ # Viewport group
288
+ viewport = {}
289
+ { width: :width, height: :height, scale: :scale, mobile: :mobile, device: :device }.each do |config_key, api_key|
290
+ viewport[api_key] = config[config_key] if config.key?(config_key)
291
+ end
292
+ result[:viewport] = viewport unless viewport.empty?
293
+
294
+ # Capture group
295
+ capture = {}
296
+ capture[:mode] = 'full_page' if config[:full_page]
297
+ capture[:selector] = config[:element] if config[:element]
298
+ result[:capture] = capture unless capture.empty?
299
+
300
+ # Output group
301
+ output = {}
302
+ output[:format] = config[:format] if config[:format]
303
+ output[:quality] = config[:quality] if config[:quality]
304
+ result[:output] = output unless output.empty?
305
+
306
+ # Wait group
307
+ wait = {}
308
+ wait[:until] = config[:wait_for] if config[:wait_for]
309
+ wait[:delay] = config[:delay] if config[:delay]
310
+ wait[:for_selector] = config[:wait_for_selector] if config[:wait_for_selector]
311
+ wait[:timeout] = config[:wait_for_timeout] if config[:wait_for_timeout]
312
+ result[:wait] = wait unless wait.empty?
313
+
314
+ # Block group
315
+ block = {}
316
+ block[:ads] = config[:block_ads] if config.key?(:block_ads)
317
+ block[:trackers] = config[:block_trackers] if config.key?(:block_trackers)
318
+ block[:cookie_banners] = config[:block_cookie_banners] if config.key?(:block_cookie_banners)
319
+ block[:chat_widgets] = config[:block_chat_widgets] if config.key?(:block_chat_widgets)
320
+ block[:requests] = config[:block_urls] if config[:block_urls]
321
+ block[:resources] = config[:block_resources] if config[:block_resources]
322
+ result[:block] = block unless block.empty?
323
+
324
+ # Page group
325
+ page = {}
326
+ page[:scripts] = [config[:inject_script]] if config[:inject_script]
327
+ page[:styles] = [config[:inject_style]] if config[:inject_style]
328
+ page[:click] = config[:click] if config[:click]
329
+ page[:hide] = config[:hide] if config[:hide]
330
+ page[:remove] = config[:remove] if config[:remove]
331
+ result[:page] = page unless page.empty?
332
+
333
+ # Browser group
334
+ browser = {}
335
+ browser[:dark_mode] = config[:dark_mode] if config.key?(:dark_mode)
336
+ browser[:reduced_motion] = config[:reduced_motion] if config.key?(:reduced_motion)
337
+ browser[:media] = config[:media_type] if config[:media_type]
338
+ browser[:user_agent] = config[:user_agent] if config[:user_agent]
339
+ browser[:timezone] = config[:timezone] if config[:timezone]
340
+ browser[:locale] = config[:locale] if config[:locale]
341
+ browser[:geolocation] = config[:geolocation] if config[:geolocation]
342
+ result[:browser] = browser unless browser.empty?
343
+
344
+ # Network group
345
+ network = {}
346
+ network[:headers] = config[:headers] if config[:headers]
347
+ network[:cookies] = config[:cookies] if config[:cookies]
348
+ network[:bypass_csp] = config[:bypass_csp] if config.key?(:bypass_csp)
349
+ if config[:auth_basic]
350
+ network[:auth] = { type: 'basic' }.merge(config[:auth_basic])
351
+ elsif config[:auth_bearer]
352
+ network[:auth] = { type: 'bearer', token: config[:auth_bearer] }
353
+ end
354
+ result[:network] = network unless network.empty?
355
+
356
+ # Cache group
357
+ cache = {}
358
+ cache[:ttl] = config[:cache_ttl] if config[:cache_ttl]
359
+ cache[:refresh] = config[:cache_refresh] if config.key?(:cache_refresh)
360
+ result[:cache] = cache unless cache.empty?
361
+
362
+ # PDF group
363
+ pdf = {}
364
+ pdf[:paper] = config[:pdf_paper_size] if config[:pdf_paper_size]
365
+ pdf[:width] = config[:pdf_width] if config[:pdf_width]
366
+ pdf[:height] = config[:pdf_height] if config[:pdf_height]
367
+ pdf[:landscape] = config[:pdf_landscape] if config.key?(:pdf_landscape)
368
+ pdf[:scale] = config[:pdf_scale] if config[:pdf_scale]
369
+ pdf[:background] = config[:pdf_print_background] if config.key?(:pdf_print_background)
370
+ pdf[:page_ranges] = config[:pdf_page_ranges] if config[:pdf_page_ranges]
371
+ pdf[:header] = config[:pdf_header] if config[:pdf_header]
372
+ pdf[:footer] = config[:pdf_footer] if config[:pdf_footer]
373
+ pdf[:fit_one_page] = config[:pdf_fit_one_page] if config.key?(:pdf_fit_one_page)
374
+ pdf[:prefer_css_page_size] = config[:pdf_prefer_css_page_size] if config.key?(:pdf_prefer_css_page_size)
375
+
376
+ # PDF margins - supports uniform margin (string like "2cm") or individual margins (hash)
377
+ if config[:pdf_margin]
378
+ # Uniform margin specified - use directly (string or hash)
379
+ pdf[:margin] = config[:pdf_margin]
380
+ else
381
+ # Build margin hash from individual margin settings
382
+ margin = {}
383
+ margin[:top] = config[:pdf_margin_top] if config[:pdf_margin_top]
384
+ margin[:right] = config[:pdf_margin_right] if config[:pdf_margin_right]
385
+ margin[:bottom] = config[:pdf_margin_bottom] if config[:pdf_margin_bottom]
386
+ margin[:left] = config[:pdf_margin_left] if config[:pdf_margin_left]
387
+ pdf[:margin] = margin unless margin.empty?
388
+ end
389
+ result[:pdf] = pdf unless pdf.empty?
390
+
391
+ # Storage group
392
+ storage = {}
393
+ storage[:enabled] = config[:storage_enabled] if config.key?(:storage_enabled)
394
+ storage[:path] = config[:storage_path] if config[:storage_path]
395
+ storage[:acl] = config[:storage_acl] if config[:storage_acl]
396
+ result[:storage] = storage unless storage.empty?
397
+
398
+ result
399
+ end
400
+
401
+ # Convert to flat query string for GET requests
402
+ def to_query_string
403
+ params = []
404
+
405
+ # Map config keys to query param names
406
+ query_mappings = {
407
+ url: 'url',
408
+ html: 'html',
409
+ preset: 'preset',
410
+ width: 'width',
411
+ height: 'height',
412
+ device: 'device',
413
+ scale: 'scale',
414
+ full_page: 'full_page',
415
+ element: 'selector',
416
+ format: 'format',
417
+ quality: 'quality',
418
+ delay: 'delay',
419
+ wait_for_timeout: 'timeout',
420
+ block_ads: 'block_ads',
421
+ block_cookie_banners: 'block_cookies',
422
+ dark_mode: 'dark_mode',
423
+ cache_ttl: 'cache_ttl'
424
+ }
425
+
426
+ query_mappings.each do |config_key, param_name|
427
+ next unless config.key?(config_key)
428
+
429
+ value = config[config_key]
430
+ params << "#{param_name}=#{CGI.escape(value.to_s)}"
431
+ end
432
+
433
+ params.join('&')
434
+ end
435
+
436
+ private
437
+
438
+ def with(updates)
439
+ self.class.new(config.merge(updates))
440
+ end
441
+ end
442
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderScreenshot
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'json'
5
+
6
+ module RenderScreenshot
7
+ # Webhook verification and parsing utilities
8
+ module Webhook
9
+ module_function
10
+
11
+ SIGNATURE_HEADER = 'X-Webhook-Signature'
12
+ TIMESTAMP_HEADER = 'X-Webhook-Timestamp'
13
+ ID_HEADER = 'X-Webhook-ID'
14
+ DEFAULT_TOLERANCE = 300 # 5 minutes
15
+
16
+ # Verify webhook signature using HMAC-SHA256
17
+ # @param payload [String] Raw request body
18
+ # @param signature [String] Signature from header (format: "sha256=...")
19
+ # @param timestamp [String] Timestamp from header
20
+ # @param secret [String] Webhook secret
21
+ # @param tolerance [Integer] Max age in seconds (default 300)
22
+ # @return [Boolean] true if valid
23
+ def verify(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE)
24
+ return false if payload.nil? || signature.nil? || timestamp.nil? || secret.nil?
25
+
26
+ # Check timestamp is within tolerance (replay attack prevention)
27
+ ts = Integer(timestamp)
28
+ age = Time.now.to_i - ts
29
+ return false if age.abs > tolerance
30
+
31
+ # Compute expected signature
32
+ signed_payload = "#{timestamp}.#{payload}"
33
+ expected_hash = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
34
+ expected = "sha256=#{expected_hash}"
35
+
36
+ # Timing-safe comparison
37
+ secure_compare(expected, signature)
38
+ rescue ArgumentError
39
+ false
40
+ end
41
+
42
+ # Parse webhook payload into structured data
43
+ # @param payload [String, Hash] JSON payload or parsed hash
44
+ # @return [Hash] { event: String, id: String, timestamp: Integer, data: Hash }
45
+ def parse(payload)
46
+ data = payload.is_a?(String) ? JSON.parse(payload) : payload
47
+
48
+ {
49
+ event: data['type'] || data['event'],
50
+ id: data['id'],
51
+ timestamp: data['timestamp'],
52
+ data: data['data'] || {}
53
+ }
54
+ rescue JSON::ParserError
55
+ raise ValidationError.invalid_request('Invalid webhook payload')
56
+ end
57
+
58
+ # Extract signature, timestamp, and ID from request headers
59
+ # @param headers [Hash] Request headers
60
+ # @return [Hash] { signature:, timestamp:, id: }
61
+ def extract_headers(headers)
62
+ # Normalize header keys (handle different header formats)
63
+ normalized = normalize_headers(headers)
64
+
65
+ {
66
+ signature: normalized['x-webhook-signature'],
67
+ timestamp: normalized['x-webhook-timestamp'],
68
+ id: normalized['x-webhook-id']
69
+ }
70
+ end
71
+
72
+ # Timing-safe string comparison
73
+ def secure_compare(a, b)
74
+ return false unless a.bytesize == b.bytesize
75
+
76
+ l = a.unpack('C*')
77
+ res = 0
78
+ b.each_byte { |byte| res |= byte ^ l.shift }
79
+ res.zero?
80
+ end
81
+
82
+ private_class_method :secure_compare
83
+
84
+ def normalize_headers(headers)
85
+ result = {}
86
+ headers.each do |key, value|
87
+ normalized_key = key.to_s.downcase.tr('_', '-')
88
+ result[normalized_key] = value
89
+ end
90
+ result
91
+ end
92
+
93
+ private_class_method :normalize_headers
94
+ end
95
+ end