screenshotfreeapi 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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module ScreenshotFreeAPI
8
+ # Low-level HTTP client responsible for:
9
+ # - Building requests with correct headers
10
+ # - Parsing JSON responses
11
+ # - Mapping HTTP error statuses to typed exceptions
12
+ # - Retrying transient failures (429, 5xx) with exponential backoff
13
+ class HttpClient
14
+ DEFAULT_BASE_URL = "https://api.screenshotfreeapi.com"
15
+ DEFAULT_TIMEOUT = 30
16
+ DEFAULT_RETRIES = 3
17
+ DEFAULT_DELAY = 1 # seconds — doubles on each retry: 1s, 2s, 4s
18
+
19
+ # @param api_key [String] Bearer token (API key or JWT)
20
+ # @param base_url [String] Override for testing / staging
21
+ # @param timeout [Integer] Open/read timeout in seconds
22
+ # @param max_retries [Integer] Maximum number of retry attempts (not counting the first try)
23
+ # @param retry_delay [Numeric] Base delay in seconds; doubles each retry
24
+ def initialize(
25
+ api_key:,
26
+ base_url: DEFAULT_BASE_URL,
27
+ timeout: DEFAULT_TIMEOUT,
28
+ max_retries: DEFAULT_RETRIES,
29
+ retry_delay: DEFAULT_DELAY
30
+ )
31
+ raise ArgumentError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
32
+
33
+ @api_key = api_key
34
+ @base_url = base_url.to_s.chomp("/")
35
+ @timeout = timeout
36
+ @max_retries = max_retries
37
+ @retry_delay = retry_delay
38
+ end
39
+
40
+ # Perform an HTTP request with automatic retry on transient errors.
41
+ #
42
+ # @param method [Symbol] :get, :post, :patch, :delete
43
+ # @param path [String] Path starting with "/"
44
+ # @param body [Hash, nil] Request body; serialised to JSON automatically
45
+ # @param query [Hash] Query-string parameters
46
+ # @param auth_header [String, nil] Override the Authorization header value
47
+ #
48
+ # @return [Hash] Parsed JSON response body
49
+ def request(method, path, body: nil, query: {}, auth_header: nil)
50
+ uri = build_uri(path, query)
51
+
52
+ attempts = 0
53
+ delay = @retry_delay
54
+
55
+ begin
56
+ attempts += 1
57
+ response = execute(uri, method, body, auth_header)
58
+ handle_response(response)
59
+ rescue RateLimitError => e
60
+ # Respect Retry-After if present; always retry rate-limit errors up to max_retries
61
+ if attempts <= @max_retries
62
+ wait = e.retry_after || delay
63
+ sleep(wait)
64
+ delay *= 2
65
+ retry
66
+ end
67
+ raise
68
+ rescue ScreenshotFreeAPIError => e
69
+ # Retry 5xx errors with exponential backoff
70
+ if e.status_code.to_i >= 500 && attempts <= @max_retries
71
+ sleep(delay)
72
+ delay *= 2
73
+ retry
74
+ end
75
+ raise
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Build a URI object from the base URL, path, and optional query params.
82
+ def build_uri(path, query)
83
+ url = "#{@base_url}#{path}"
84
+ uri = URI.parse(url)
85
+
86
+ unless query.nil? || query.empty?
87
+ params = query.map { |k, v| "#{URI.encode_www_form_component(k.to_s)}=#{URI.encode_www_form_component(v.to_s)}" }
88
+ uri.query = params.join("&")
89
+ end
90
+
91
+ uri
92
+ end
93
+
94
+ # Execute a single HTTP call and return the Net::HTTP response object.
95
+ def execute(uri, method, body, auth_header)
96
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
97
+ open_timeout: @timeout,
98
+ read_timeout: @timeout) do |http|
99
+ req = build_request(method, uri, body, auth_header)
100
+ http.request(req)
101
+ end
102
+ end
103
+
104
+ # Instantiate the correct Net::HTTP request class and set headers/body.
105
+ def build_request(method, uri, body, auth_header)
106
+ klass = case method
107
+ when :get then Net::HTTP::Get
108
+ when :post then Net::HTTP::Post
109
+ when :patch then Net::HTTP::Patch
110
+ when :delete then Net::HTTP::Delete
111
+ when :put then Net::HTTP::Put
112
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
113
+ end
114
+
115
+ req = klass.new(uri.request_uri)
116
+ # auth_header: nil => use default API key Bearer token
117
+ # auth_header: "" => omit the Authorization header (public endpoints)
118
+ # auth_header: "Bearer <jwt>" => use the supplied value as-is
119
+ if auth_header.nil?
120
+ req["Authorization"] = "Bearer #{@api_key}"
121
+ elsif !auth_header.empty?
122
+ req["Authorization"] = auth_header
123
+ end
124
+ req["Content-Type"] = "application/json"
125
+ req["Accept"] = "application/json"
126
+ req["User-Agent"] = "screenshotfreeapi-ruby/#{ScreenshotFreeAPI::VERSION}"
127
+
128
+ if body
129
+ req.body = JSON.generate(body)
130
+ end
131
+
132
+ req
133
+ end
134
+
135
+ # Parse the HTTP response and raise typed errors for non-2xx statuses.
136
+ def handle_response(response)
137
+ status = response.code.to_i
138
+ body = parse_body(response.body)
139
+
140
+ return body if status >= 200 && status < 300
141
+
142
+ # Extract structured error info from the API's error envelope.
143
+ error_code = body.is_a?(Hash) ? (body["error"] || body["code"] || "UnknownError").to_s : "UnknownError"
144
+ message = body.is_a?(Hash) ? (body["message"] || response.message || "Unknown error") : (response.message || "Unknown error")
145
+ details = body.is_a?(Hash) ? body["details"] : nil
146
+
147
+ case status
148
+ when 400
149
+ raise ValidationError.new(message, details)
150
+ when 401
151
+ raise AuthenticationError.new(message)
152
+ when 402
153
+ raise PaymentRequiredError.new(message)
154
+ when 403
155
+ raise ForbiddenError.new(message)
156
+ when 404
157
+ raise NotFoundError.new(message)
158
+ when 429
159
+ retry_after_header = response["Retry-After"]
160
+ retry_after = retry_after_header ? retry_after_header.to_f : nil
161
+
162
+ if error_code == "QuotaExceeded"
163
+ raise QuotaExceededError.new(message)
164
+ else
165
+ raise RateLimitError.new(retry_after, message)
166
+ end
167
+ else
168
+ raise ScreenshotFreeAPIError.new(status, error_code, message, details)
169
+ end
170
+ end
171
+
172
+ # Attempt to parse a JSON response body; return raw string on failure.
173
+ def parse_body(raw)
174
+ return nil if raw.nil? || raw.empty?
175
+ JSON.parse(raw)
176
+ rescue JSON::ParserError
177
+ raw
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ module Resources
5
+ # Authentication endpoints — registration, API key management, and JWT tokens.
6
+ #
7
+ # Note: `token` and `refresh` return JWTs that should be passed as the
8
+ # `jwt:` parameter to billing, workspace, and monitor resource methods.
9
+ class Auth
10
+ def initialize(http)
11
+ @http = http
12
+ end
13
+
14
+ # Register a new account.
15
+ #
16
+ # @param email [String] User email address
17
+ # @param password [String] Password (min 8 chars on the server)
18
+ # @param name [String] Display name
19
+ #
20
+ # @return [Hash] { "userId", "email", "apiKey" }
21
+ # NOTE: apiKey is shown ONCE — store it securely.
22
+ def register(email:, password:, name:)
23
+ @http.request(:post, "/auth/register", body: {
24
+ email: email,
25
+ password: password,
26
+ name: name
27
+ })
28
+ end
29
+
30
+ # Obtain a management JWT by providing account credentials.
31
+ #
32
+ # The returned token is required for billing, workspace, and monitor
33
+ # endpoints. Pass it as `jwt:` to those resource methods.
34
+ #
35
+ # @param email [String]
36
+ # @param password [String]
37
+ #
38
+ # @return [Hash] { "token", "expiresAt" }
39
+ def token(email:, password:)
40
+ @http.request(:post, "/auth/token", body: {
41
+ email: email,
42
+ password: password
43
+ })
44
+ end
45
+
46
+ # Refresh an expired JWT using a refresh token.
47
+ #
48
+ # @param refresh_token [String] The refresh token from a previous `token` call
49
+ #
50
+ # @return [Hash] { "token", "expiresAt" }
51
+ def refresh(refresh_token:)
52
+ @http.request(:post, "/auth/refresh", body: {
53
+ refreshToken: refresh_token
54
+ })
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ module Resources
5
+ # Billing, plan, and subscription management.
6
+ #
7
+ # Most methods require a JWT (obtained via Auth#token) passed in the
8
+ # `jwt:` keyword argument. The client will send it as the Bearer token
9
+ # for that single request instead of the API key.
10
+ #
11
+ # The only exception is `plans`, which is publicly accessible.
12
+ class Billing
13
+ def initialize(http)
14
+ @http = http
15
+ end
16
+
17
+ # List all available subscription plans (no auth required).
18
+ #
19
+ # @return [Array<Hash>] Array of plan objects with name, price, limits, etc.
20
+ def plans
21
+ @http.request(:get, "/billing/plans", auth_header: "")
22
+ end
23
+
24
+ # Get the current user's active plan and usage summary.
25
+ #
26
+ # @param jwt [String] Management JWT from Auth#token
27
+ #
28
+ # @return [Hash] { "plan", "status", "screenshotsUsed", "screenshotsLimit", ... }
29
+ def plan(jwt:)
30
+ @http.request(:get, "/billing/plan", auth_header: "Bearer #{jwt}")
31
+ end
32
+
33
+ # Retrieve the 30-day daily usage history.
34
+ #
35
+ # @param jwt [String] Management JWT
36
+ #
37
+ # @return [Hash] { "usage" => Array of { "date", "count" } }
38
+ def usage(jwt:)
39
+ @http.request(:get, "/billing/usage", auth_header: "Bearer #{jwt}")
40
+ end
41
+
42
+ # Initiate a plan upgrade via Flutterwave checkout.
43
+ #
44
+ # @param jwt [String] Management JWT
45
+ # @param plan [String] Target plan tier: "STARTER" | "GROWTH" | "BUSINESS" | "ENTERPRISE"
46
+ # @param options [Hash] Additional upgrade options
47
+ #
48
+ # @return [Hash] { "checkoutUrl", "transactionRef" } — redirect user to checkoutUrl
49
+ def upgrade(jwt:, plan:, **options)
50
+ body = { plan: plan }.merge(options)
51
+ @http.request(:post, "/billing/upgrade", body: body, auth_header: "Bearer #{jwt}")
52
+ end
53
+
54
+ # Server-side verification of a completed Flutterwave payment.
55
+ #
56
+ # Call this after the user returns from the Flutterwave checkout redirect.
57
+ #
58
+ # @param jwt [String] Management JWT
59
+ # @param transaction_ref [String] The transactionRef from `upgrade`
60
+ #
61
+ # @return [Hash] { "success", "plan", "message" }
62
+ def verify(jwt:, transaction_ref: nil)
63
+ query = transaction_ref ? { transaction_ref: transaction_ref } : {}
64
+ @http.request(:get, "/billing/verify", query: query, auth_header: "Bearer #{jwt}")
65
+ end
66
+
67
+ # Cancel the active subscription.
68
+ #
69
+ # @param jwt [String] Management JWT
70
+ #
71
+ # @return [Hash] { "message", "effectiveAt" }
72
+ def cancel(jwt:)
73
+ @http.request(:post, "/billing/cancel", body: {}, auth_header: "Bearer #{jwt}")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ module Resources
5
+ # Third-party integration endpoints.
6
+ #
7
+ # Currently supports Zapier REST Hooks for no-code workflow automation.
8
+ # All methods use API key authentication (no JWT required).
9
+ class Integrations
10
+ def initialize(http)
11
+ @http = http
12
+ end
13
+
14
+ # Register a Zapier webhook subscription (REST Hook subscribe).
15
+ #
16
+ # Zapier calls this endpoint when a Zap is turned on. You can also call
17
+ # it directly to register any webhook for screenshot-complete events.
18
+ #
19
+ # @param target_url [String] URL Zapier will POST events to
20
+ # @param event [String] Event type to subscribe to, e.g. "screenshot.completed"
21
+ # @param options [Hash] Additional subscription options
22
+ #
23
+ # @return [Hash] { "id", "targetUrl", "event", "createdAt" }
24
+ def zapier_subscribe(target_url:, event:, **options)
25
+ body = { targetUrl: target_url, event: event }.merge(options)
26
+ @http.request(:post, "/integrations/zapier/subscribe", body: body)
27
+ end
28
+
29
+ # Unregister a Zapier webhook subscription (REST Hook unsubscribe).
30
+ #
31
+ # Zapier calls this when the Zap is turned off.
32
+ #
33
+ # @param subscription_id [String] ID returned by `zapier_subscribe`
34
+ #
35
+ # @return [Hash] Confirmation object
36
+ def zapier_unsubscribe(subscription_id:)
37
+ @http.request(:delete, "/integrations/zapier/unsubscribe/#{subscription_id}")
38
+ end
39
+
40
+ # Retrieve a sample payload for a Zapier trigger event.
41
+ #
42
+ # Zapier uses this to show users a preview of the data they can map in their Zap.
43
+ #
44
+ # @param event [String] Event type, e.g. "screenshot.completed"
45
+ #
46
+ # @return [Array<Hash>] Array of sample event payloads (Zapier polling format)
47
+ def zapier_triggers(event:)
48
+ @http.request(:get, "/integrations/zapier/triggers/#{event}")
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ module Resources
5
+ # Job polling endpoints.
6
+ #
7
+ # All screenshot requests are asynchronous — the POST endpoints return a
8
+ # jobId immediately. Use these methods to check progress and retrieve
9
+ # results once the job reaches the "completed" state.
10
+ class Jobs
11
+ def initialize(http)
12
+ @http = http
13
+ end
14
+
15
+ # Retrieve the current status of a job.
16
+ #
17
+ # @param job_id [String] The jobId returned by a screenshot POST
18
+ #
19
+ # @return [Hash]
20
+ # {
21
+ # "jobId" => String,
22
+ # "status" => "queued" | "processing" | "completed" | "failed",
23
+ # "progress" => Integer (0-100),
24
+ # "error" => String | nil,
25
+ # "estimatedSeconds" => Integer | nil,
26
+ # "createdAt" => String (ISO 8601),
27
+ # "updatedAt" => String (ISO 8601)
28
+ # }
29
+ def status(job_id)
30
+ @http.request(:get, "/jobs/#{job_id}/status")
31
+ end
32
+
33
+ # Retrieve the completed result of a job.
34
+ #
35
+ # Only available once `status` returns "completed". Raises NotFoundError
36
+ # if the job is not yet done or was not found.
37
+ #
38
+ # @param job_id [String]
39
+ #
40
+ # @return [Hash]
41
+ # {
42
+ # "jobId" => String,
43
+ # "type" => "web" | "mobile" | "html",
44
+ # "screenshots" => Array of {
45
+ # "url" => String (presigned S3 URL, 15-min TTL),
46
+ # "format" => "png" | "jpeg" | "webp" | "pdf",
47
+ # "width" => Integer,
48
+ # "height" => Integer,
49
+ # "capturedAt" => String,
50
+ # "selector" => String | nil
51
+ # },
52
+ # "metadata" => Hash (AI info, processingMs, etc.)
53
+ # }
54
+ def result(job_id)
55
+ @http.request(:get, "/jobs/#{job_id}/result")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ module Resources
5
+ # App monitoring — schedule recurring captures and receive diff alerts.
6
+ #
7
+ # Monitors run on a cron schedule and alert you when an app's screenshots
8
+ # change (e.g., new version, UI refresh). Requires a BUSINESS+ plan.
9
+ #
10
+ # All methods require a management JWT passed as `jwt:`.
11
+ class Monitors
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Create a new app monitor.
17
+ #
18
+ # @param jwt [String] Management JWT
19
+ # @param app_name [String] App display name to monitor
20
+ # @param platform [String] "ios" | "android" | "both"
21
+ # @param schedule [String] Cron expression, e.g. "0 9 * * *" (daily at 9 AM UTC)
22
+ # @param webhook_url [String] URL to notify on detected changes
23
+ # @param options [Hash] Additional monitor options
24
+ #
25
+ # @return [Hash] Monitor object with "id", "appName", "platform", "schedule"
26
+ def create(jwt:, app_name:, platform:, schedule:, webhook_url: nil, **options)
27
+ body = {
28
+ appName: app_name,
29
+ platform: platform,
30
+ schedule: schedule,
31
+ webhookUrl: webhook_url
32
+ }.compact.merge(options)
33
+
34
+ @http.request(:post, "/monitors/app", body: body, auth_header: "Bearer #{jwt}")
35
+ end
36
+
37
+ # List all monitors for the authenticated user.
38
+ #
39
+ # @param jwt [String] Management JWT
40
+ #
41
+ # @return [Array<Hash>] Array of monitor objects
42
+ def list(jwt:)
43
+ @http.request(:get, "/monitors/app", auth_header: "Bearer #{jwt}")
44
+ end
45
+
46
+ # Get a single monitor's details.
47
+ #
48
+ # @param jwt [String] Management JWT
49
+ # @param monitor_id [String] Monitor ID
50
+ #
51
+ # @return [Hash] Monitor object
52
+ def get(jwt:, monitor_id:)
53
+ @http.request(:get, "/monitors/app/#{monitor_id}", auth_header: "Bearer #{jwt}")
54
+ end
55
+
56
+ # Delete a monitor.
57
+ #
58
+ # @param jwt [String] Management JWT
59
+ # @param monitor_id [String] Monitor ID
60
+ #
61
+ # @return [Hash] Confirmation object
62
+ def delete(jwt:, monitor_id:)
63
+ @http.request(:delete, "/monitors/app/#{monitor_id}", auth_header: "Bearer #{jwt}")
64
+ end
65
+
66
+ # Retrieve the run history and diff results for a monitor.
67
+ #
68
+ # @param jwt [String] Management JWT
69
+ # @param monitor_id [String] Monitor ID
70
+ # @param limit [Integer] Number of history entries to return (optional)
71
+ # @param offset [Integer] Pagination offset (optional)
72
+ #
73
+ # @return [Hash] { "history" => Array of run result hashes }
74
+ def history(jwt:, monitor_id:, limit: nil, offset: nil)
75
+ query = {}
76
+ query[:limit] = limit if limit
77
+ query[:offset] = offset if offset
78
+
79
+ @http.request(
80
+ :get,
81
+ "/monitors/app/#{monitor_id}/history",
82
+ query: query,
83
+ auth_header: "Bearer #{jwt}"
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScreenshotFreeAPI
4
+ module Resources
5
+ # Screenshot capture endpoints with optional synchronous polling helpers.
6
+ #
7
+ # All `web`, `mobile`, and `html` methods return a 202 job receipt.
8
+ # The `*_and_wait` variants block until the job completes and return the
9
+ # full result hash directly.
10
+ class Screenshots
11
+ # @param http [HttpClient]
12
+ def initialize(http)
13
+ @http = http
14
+ end
15
+
16
+ # Enqueue a web screenshot job.
17
+ #
18
+ # @param url [String] Required. Public URL to capture.
19
+ # @param description [String] Plain-English description of the element
20
+ # to target (triggers AI vision path).
21
+ # @param element [String] CSS selector to capture instead of full page.
22
+ # @param dimensions [Hash] { width: Integer, height: Integer }
23
+ # @param full_page [Boolean] Capture the entire scrollable page.
24
+ # @param scrolling [Boolean] Enable scroll-based capture.
25
+ # @param format [String] "png" | "jpeg" | "webp" | "pdf" (default: "png")
26
+ # @param paper_size [String] PDF paper size, e.g. "A4", "Letter"
27
+ # @param block_ads [Boolean] Block ad scripts before capture (STARTER+)
28
+ # @param accept_cookies [Boolean] Auto-dismiss cookie banners (STARTER+)
29
+ # @param stealth [Boolean] Enable stealth mode (BUSINESS+)
30
+ # @param proxy_location [String] Proxy region, e.g. "eu-west" (BUSINESS+)
31
+ # @param video [Hash] { duration: Integer, fps: Integer } (GROWTH+)
32
+ # @param bypass_cache [Boolean] Skip the response cache.
33
+ # @param storage [Hash] Custom S3 config (BUSINESS+)
34
+ # @param webhook_url [String] URL to POST completion event to.
35
+ #
36
+ # @return [Hash] { "jobId", "status", "statusUrl", "estimatedSeconds" }
37
+ def web(options = {})
38
+ @http.request(:post, "/screenshots/web", body: camelize_keys(options))
39
+ end
40
+
41
+ # Enqueue a mobile app screenshot job.
42
+ #
43
+ # @param app_name [String] App display name (e.g. "Instagram")
44
+ # @param platform [String] "ios" | "android" | "both"
45
+ # @param bundle_id [String] Bundle/package ID
46
+ # @param include_store_listing [Boolean] Also download store listing images
47
+ # @param device_emulation [String] Playwright device preset (e.g. "iPhone 12")
48
+ # @param webhook_url [String]
49
+ #
50
+ # @return [Hash] { "jobId", "status", "statusUrl", "estimatedSeconds" }
51
+ def mobile(options = {})
52
+ @http.request(:post, "/screenshots/mobile", body: camelize_keys(options))
53
+ end
54
+
55
+ # Enqueue an HTML-string screenshot job.
56
+ #
57
+ # @param html [String] Raw HTML to render and capture.
58
+ # @param dimensions [Hash] { width: Integer, height: Integer }
59
+ # @param full_page [Boolean]
60
+ # @param format [String] "png" | "jpeg" | "webp" | "pdf"
61
+ # @param webhook_url [String]
62
+ #
63
+ # @return [Hash] { "jobId", "status", "statusUrl", "estimatedSeconds" }
64
+ def html(options = {})
65
+ @http.request(:post, "/screenshots/html", body: camelize_keys(options))
66
+ end
67
+
68
+ # Capture a web screenshot and block until it completes.
69
+ #
70
+ # Combines `web` + `wait` in a single call.
71
+ #
72
+ # @param options [Hash] Same keyword arguments as `web`.
73
+ # @param poll_interval [Numeric] Seconds between status polls (default: 2)
74
+ # @param timeout [Numeric] Maximum seconds to wait (default: 120)
75
+ # @yieldparam status [Hash] Called on each poll with the current status hash.
76
+ #
77
+ # @return [Hash] Full result hash (same as Jobs#result)
78
+ def web_and_wait(options = {}, poll_interval: 2, timeout: 120, &on_progress)
79
+ job = web(options)
80
+ wait(job["jobId"], poll_interval: poll_interval, timeout: timeout, &on_progress)
81
+ end
82
+
83
+ # Capture a mobile screenshot and block until it completes.
84
+ #
85
+ # @param options [Hash] Same keyword arguments as `mobile`.
86
+ # @param poll_interval [Numeric]
87
+ # @param timeout [Numeric]
88
+ #
89
+ # @return [Hash] Full result hash
90
+ def mobile_and_wait(options = {}, poll_interval: 2, timeout: 120)
91
+ job = mobile(options)
92
+ wait(job["jobId"], poll_interval: poll_interval, timeout: timeout)
93
+ end
94
+
95
+ # Render an HTML string and block until the screenshot is ready.
96
+ #
97
+ # @param options [Hash] Same keyword arguments as `html`.
98
+ # @param poll_interval [Numeric]
99
+ # @param timeout [Numeric]
100
+ #
101
+ # @return [Hash] Full result hash
102
+ def html_and_wait(options = {}, poll_interval: 2, timeout: 120)
103
+ job = html(options)
104
+ wait(job["jobId"], poll_interval: poll_interval, timeout: timeout)
105
+ end
106
+
107
+ # Poll a job until it reaches a terminal state.
108
+ #
109
+ # @param job_id [String]
110
+ # @param poll_interval [Numeric] Seconds between status polls (default: 2)
111
+ # @param timeout [Numeric] Maximum seconds to wait (default: 120)
112
+ # @yieldparam status [Hash] Called on each poll (optional progress callback)
113
+ #
114
+ # @return [Hash] Full result hash (from /jobs/:id/result)
115
+ # @raise [JobFailedError] If the job transitions to "failed"
116
+ # @raise [JobTimeoutError] If the job does not complete within `timeout` seconds
117
+ def wait(job_id, poll_interval: 2, timeout: 120, &on_progress)
118
+ deadline = Time.now + timeout
119
+
120
+ loop do
121
+ status_data = @http.request(:get, "/jobs/#{job_id}/status")
122
+
123
+ on_progress.call(status_data) if on_progress
124
+
125
+ case status_data["status"]
126
+ when "completed"
127
+ return @http.request(:get, "/jobs/#{job_id}/result")
128
+ when "failed"
129
+ reason = status_data["error"] || "Unknown failure"
130
+ raise JobFailedError.new(job_id, reason)
131
+ end
132
+
133
+ if Time.now >= deadline
134
+ raise JobTimeoutError.new(job_id, timeout)
135
+ end
136
+
137
+ sleep(poll_interval)
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ # Convert Ruby snake_case keys to camelCase for the JSON API.
144
+ # Nested hashes and arrays are recursively converted.
145
+ def camelize_keys(obj)
146
+ case obj
147
+ when Hash
148
+ obj.each_with_object({}) do |(k, v), memo|
149
+ camel_key = snake_to_camel(k.to_s)
150
+ memo[camel_key] = camelize_keys(v)
151
+ end
152
+ when Array
153
+ obj.map { |item| camelize_keys(item) }
154
+ else
155
+ obj
156
+ end
157
+ end
158
+
159
+ # "some_key_name" => "someKeyName"
160
+ def snake_to_camel(str)
161
+ parts = str.split("_")
162
+ parts[0] + parts[1..].map(&:capitalize).join
163
+ end
164
+ end
165
+ end
166
+ end