shotwolf 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 00d361d43de9b07008915a8608e0627fde4404af97c404107923954d6fc6ffb1
4
+ data.tar.gz: 9aaae813882af6789f98ec7e02383ac0206da3f6960cf83f5b212a88e3f15aef
5
+ SHA512:
6
+ metadata.gz: 6e8b2d63c50dbb61b8ca0bc4e862b460d58e5e3564740744d596f615604a98ce169c549ab472340f317ea2b220496ee7374735a75b69eceaf3787e70657c7565
7
+ data.tar.gz: d51af6312dbb4c138fca01cf10fe70da8a33ec88ceadf09d815e34834348914f105e6a750d74a9db83934d62cc2fa3cfe0b3db212e815fd5464aa78c9862879a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShotWolf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # ShotWolf Ruby SDK
2
+
3
+ Official Ruby client for the [ShotWolf](https://shotwolf.com) API - capture screenshots, render device mockups, generate Open Graph cards, convert HTML to PDF, and run pixel-level visual regression diffs through a single JSON HTTP API.
4
+
5
+ - Zero dependencies (standard library only, Ruby 3.0+)
6
+ - Built-in retries with backoff, idempotency keys, and an async polling helper
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ gem install shotwolf
12
+ ```
13
+
14
+ Or in a Gemfile:
15
+
16
+ ```ruby
17
+ gem "shotwolf"
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ Get an API key at [shotwolf.com/dashboard/api_keys](https://shotwolf.com/dashboard/api_keys).
23
+
24
+ ```ruby
25
+ require "shotwolf"
26
+
27
+ sw = Shotwolf::Client.new # reads SHOTWOLF_API_KEY, or pass Shotwolf::Client.new("sw_live_...")
28
+
29
+ shot = sw.capture(url: "https://example.com", full_page: true, dismiss_cookies: true)
30
+ puts shot["image_url"]
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Screenshots
36
+
37
+ ```ruby
38
+ # Sync - returns the image URL inline (~1-4s)
39
+ shot = sw.capture(url: "https://stripe.com", device: "iphone_15_pro")
40
+
41
+ # Async - queue and poll (or pass webhook_url for a callback)
42
+ queued = sw.capture_async(url: "https://stripe.com")
43
+ done = sw.wait_for(queued["id"]) # polls until status is done
44
+
45
+ # Batch - one page, many viewports (cheaper than N calls)
46
+ batch = sw.batch(url: "https://stripe.com", devices: ["default", "iphone_15_pro", "ipad_pro_12_9"])
47
+ ```
48
+
49
+ ### Device & social mockups
50
+
51
+ ```ruby
52
+ mockup = sw.mockup(url: "https://stripe.com", frame: "macbook_air", bg: "gradient")
53
+ ```
54
+
55
+ ### Open Graph cards
56
+
57
+ ```ruby
58
+ og = sw.og(template: "blog_card", variables: { title: "Ship faster", author: "Jane Doe" })
59
+ ```
60
+
61
+ ### HTML / URL to PDF
62
+
63
+ ```ruby
64
+ pdf = sw.pdf(html: "<h1>Invoice #42</h1>", page_format: "A4", print_background: true)
65
+ ```
66
+
67
+ ### Visual regression diff
68
+
69
+ ```ruby
70
+ diff = sw.diff(
71
+ before_url: "https://staging.example.com",
72
+ after_url: "https://example.com",
73
+ ignore_regions: [{ x: 0, y: 0, width: 1280, height: 80 }] # mask a dynamic header
74
+ )
75
+ puts "#{(diff['diff_ratio'] * 100).round(2)}% changed" unless diff["passed"]
76
+ ```
77
+
78
+ ### Account balance
79
+
80
+ ```ruby
81
+ account = sw.account
82
+ puts account["balance_cr"], account["tier"]["name"]
83
+ ```
84
+
85
+ ## Idempotency
86
+
87
+ Pass `idempotency_key:` to make money-charging POSTs safe to retry. A repeat with
88
+ the same key and body within 24h replays the original response instead of
89
+ charging again.
90
+
91
+ ```ruby
92
+ require "securerandom"
93
+ sw.capture(url: "https://example.com", idempotency_key: SecureRandom.uuid)
94
+ ```
95
+
96
+ ## Error handling
97
+
98
+ Any non-2xx response raises `Shotwolf::Error` carrying the API error `type`, HTTP
99
+ `status`, and `message`.
100
+
101
+ ```ruby
102
+ begin
103
+ sw.capture(url: "https://example.com")
104
+ rescue Shotwolf::Error => e
105
+ # e.type == "insufficient_credits" -> top up at https://shotwolf.com/pricing
106
+ warn [e.status, e.type, e.message, e.request_id].inspect
107
+ end
108
+ ```
109
+
110
+ ## Configuration
111
+
112
+ ```ruby
113
+ sw = Shotwolf::Client.new(
114
+ "sw_live_xxx",
115
+ base_url: "https://shotwolf.com", # override for self-host / testing
116
+ timeout: 60, # per-request timeout in seconds
117
+ max_retries: 2, # retries on 429 / 5xx
118
+ retry_backoff: 0.5 # base backoff, doubled per attempt
119
+ )
120
+ ```
121
+
122
+ Retries honor the `Retry-After` header on `429` responses. `4xx` responses
123
+ other than `429` are never retried.
124
+
125
+ ## API coverage
126
+
127
+ | Method | Endpoint |
128
+ | --- | --- |
129
+ | `capture(**params)` | `POST /api/v1/captures` |
130
+ | `capture_async(**params)` | `POST /api/v1/captures/async` |
131
+ | `batch(**params)` | `POST /api/v1/capture/batch` |
132
+ | `mockup(**params)` | `POST /api/v1/mockups` |
133
+ | `og(**params)` | `POST /api/v1/og` |
134
+ | `pdf(**params)` | `POST /api/v1/pdf` |
135
+ | `diff(**params)` | `POST /api/v1/diffs` |
136
+ | `get_screenshot(id)` | `GET /api/v1/screenshots/:id` |
137
+ | `wait_for(id)` | polls `get_screenshot` until `done`/`failed` |
138
+ | `account` | `GET /api/v1/account` |
139
+
140
+ Full parameter reference: [shotwolf.com/docs](https://shotwolf.com/docs).
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ require_relative "version"
8
+ require_relative "errors"
9
+
10
+ module Shotwolf
11
+ # Client for the ShotWolf API. Pass an API key (+sw_live_...+) or set the
12
+ # +SHOTWOLF_API_KEY+ environment variable.
13
+ #
14
+ # sw = Shotwolf::Client.new
15
+ # shot = sw.capture(url: "https://example.com", device: "iphone_15_pro")
16
+ # puts shot["image_url"]
17
+ class Client
18
+ DEFAULT_BASE_URL = "https://shotwolf.com"
19
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
20
+
21
+ def initialize(api_key = nil, base_url: DEFAULT_BASE_URL, timeout: 60, max_retries: 2,
22
+ retry_backoff: 0.5, transport: nil)
23
+ @api_key = api_key || ENV.fetch("SHOTWOLF_API_KEY", nil)
24
+ if @api_key.nil? || @api_key.empty?
25
+ raise Error.new("Missing API key. Pass api_key or set SHOTWOLF_API_KEY.", type: "unauthorized")
26
+ end
27
+
28
+ @base_url = base_url.sub(%r{/+\z}, "")
29
+ @timeout = timeout
30
+ @max_retries = max_retries
31
+ @retry_backoff = retry_backoff
32
+ @transport = transport || method(:net_http_transport)
33
+ end
34
+
35
+ # Capture a URL or HTML synchronously. POST /api/v1/captures
36
+ def capture(idempotency_key: nil, timeout: nil, **params)
37
+ request("POST", "/api/v1/captures", body: params, idempotency_key: idempotency_key, timeout: timeout)
38
+ end
39
+
40
+ # Queue a capture and return immediately. POST /api/v1/captures/async
41
+ def capture_async(idempotency_key: nil, timeout: nil, **params)
42
+ request("POST", "/api/v1/captures/async", body: params, idempotency_key: idempotency_key, timeout: timeout)
43
+ end
44
+
45
+ # Render one URL across 2-6 devices. POST /api/v1/capture/batch
46
+ def batch(idempotency_key: nil, timeout: nil, **params)
47
+ request("POST", "/api/v1/capture/batch", body: params, idempotency_key: idempotency_key, timeout: timeout)
48
+ end
49
+
50
+ # Render a URL inside a device/social mockup. POST /api/v1/mockups
51
+ def mockup(idempotency_key: nil, timeout: nil, **params)
52
+ request("POST", "/api/v1/mockups", body: params, idempotency_key: idempotency_key, timeout: timeout)
53
+ end
54
+
55
+ # Generate an Open Graph card from a template. POST /api/v1/og
56
+ def og(idempotency_key: nil, timeout: nil, **params)
57
+ request("POST", "/api/v1/og", body: params, idempotency_key: idempotency_key, timeout: timeout)
58
+ end
59
+
60
+ # Convert a URL or HTML string to PDF. POST /api/v1/pdf
61
+ def pdf(idempotency_key: nil, timeout: nil, **params)
62
+ request("POST", "/api/v1/pdf", body: params, idempotency_key: idempotency_key, timeout: timeout)
63
+ end
64
+
65
+ # Pixel-diff two screenshots. POST /api/v1/diffs
66
+ def diff(idempotency_key: nil, timeout: nil, **params)
67
+ request("POST", "/api/v1/diffs", body: params, idempotency_key: idempotency_key, timeout: timeout)
68
+ end
69
+
70
+ # Retrieve a single screenshot by ID. GET /api/v1/screenshots/:id
71
+ def get_screenshot(id, timeout: nil)
72
+ request("GET", "/api/v1/screenshots/#{id.to_i}", timeout: timeout)
73
+ end
74
+
75
+ # Get balance, tier and webhook secret. GET /api/v1/account
76
+ def account(timeout: nil)
77
+ request("GET", "/api/v1/account", timeout: timeout)
78
+ end
79
+
80
+ # Poll +get_screenshot+ until the shot is +done+ or +failed+. Returns the
81
+ # final screenshot Hash; raises Shotwolf::Error on render failure or
82
+ # Shotwolf::TimeoutError if the deadline passes.
83
+ def wait_for(id, interval: 2.0, timeout: 120.0)
84
+ deadline = monotonic + timeout
85
+ loop do
86
+ shot = get_screenshot(id)
87
+ return shot if shot["status"] == "done"
88
+ if shot["status"] == "failed"
89
+ raise Error.new("Screenshot #{id} failed to render", type: "render_failed", status: 422)
90
+ end
91
+ raise TimeoutError.new(timeout) if monotonic + interval > deadline
92
+
93
+ sleep(interval)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def monotonic
100
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ end
102
+
103
+ def request(method, path, body: nil, idempotency_key: nil, timeout: nil)
104
+ url = @base_url + path
105
+ req_timeout = timeout || @timeout
106
+ headers = {
107
+ "Authorization" => "Bearer #{@api_key}",
108
+ "Accept" => "application/json",
109
+ "User-Agent" => "shotwolf-ruby/#{VERSION}"
110
+ }
111
+ payload = nil
112
+ unless body.nil?
113
+ headers["Content-Type"] = "application/json"
114
+ payload = JSON.generate(body)
115
+ end
116
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
117
+
118
+ last_error = nil
119
+ (0..@max_retries).each do |attempt|
120
+ begin
121
+ status, resp_headers, resp_body = @transport.call(method, url, headers, payload, req_timeout)
122
+ rescue StandardError => e
123
+ last_error = timeout_error?(e) ? TimeoutError.new(req_timeout) : e
124
+ if attempt < @max_retries
125
+ sleep(backoff(attempt))
126
+ next
127
+ end
128
+ raise last_error
129
+ end
130
+
131
+ if status >= 200 && status < 300
132
+ return resp_body.nil? || resp_body.empty? ? {} : JSON.parse(resp_body)
133
+ end
134
+
135
+ if RETRYABLE_STATUSES.include?(status) && attempt < @max_retries
136
+ sleep(parse_retry_after(resp_headers) || backoff(attempt))
137
+ next
138
+ end
139
+
140
+ raise to_error(status, resp_headers, resp_body)
141
+ end
142
+
143
+ raise(last_error || Error.new("Request failed"))
144
+ end
145
+
146
+ def net_http_transport(method, url, headers, body, timeout)
147
+ uri = URI(url)
148
+ http = Net::HTTP.new(uri.host, uri.port)
149
+ http.use_ssl = uri.scheme == "https"
150
+ http.open_timeout = timeout
151
+ http.read_timeout = timeout
152
+
153
+ klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
154
+ req = klass.new(uri.request_uri)
155
+ headers.each { |k, v| req[k] = v }
156
+ req.body = body if body
157
+
158
+ resp = http.request(req)
159
+ resp_headers = {}
160
+ resp.each_header { |k, v| resp_headers[k.downcase] = v }
161
+ [resp.code.to_i, resp_headers, resp.body]
162
+ end
163
+
164
+ def timeout_error?(err)
165
+ err.is_a?(Timeout::Error) || err.is_a?(Net::OpenTimeout) || err.is_a?(Net::ReadTimeout)
166
+ end
167
+
168
+ def backoff(attempt)
169
+ @retry_backoff * (2**attempt)
170
+ end
171
+
172
+ def parse_retry_after(headers)
173
+ raw = headers["retry-after"]
174
+ return nil unless raw
175
+
176
+ Float(raw)
177
+ rescue ArgumentError, TypeError
178
+ nil
179
+ end
180
+
181
+ def to_error(status, headers, body)
182
+ type = nil
183
+ message = "HTTP #{status}"
184
+ begin
185
+ data = JSON.parse(body.to_s)
186
+ if data.is_a?(Hash) && data["error"].is_a?(Hash)
187
+ type = data["error"]["type"]
188
+ message = data["error"]["message"] || message
189
+ end
190
+ rescue JSON::ParserError
191
+ # non-JSON body; keep the HTTP status message
192
+ end
193
+ Error.new(
194
+ message,
195
+ type: type || "unknown",
196
+ status: status,
197
+ request_id: headers["x-request-id"],
198
+ retry_after: parse_retry_after(headers)
199
+ )
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shotwolf
4
+ # Raised for any non-2xx API response. +type+ mirrors the API error envelope
5
+ # (invalid_params, insufficient_credits, rate_limited, ...). +status+ is the
6
+ # HTTP status code (0 for client-side/transport errors).
7
+ class Error < StandardError
8
+ attr_reader :type, :status, :request_id, :retry_after
9
+
10
+ def initialize(message, type: "unknown", status: 0, request_id: nil, retry_after: nil)
11
+ super(message)
12
+ @type = type
13
+ @status = status
14
+ @request_id = request_id
15
+ @retry_after = retry_after
16
+ end
17
+ end
18
+
19
+ # Raised when a request exceeds the configured timeout.
20
+ class TimeoutError < Error
21
+ def initialize(timeout)
22
+ super("Request timed out after #{timeout}s", type: "timeout", status: 0)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shotwolf
4
+ VERSION = "0.1.0"
5
+ end
data/lib/shotwolf.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shotwolf/version"
4
+ require_relative "shotwolf/errors"
5
+ require_relative "shotwolf/client"
6
+
7
+ # Official Ruby SDK for the ShotWolf API - https://shotwolf.com
8
+ module Shotwolf
9
+ # Convenience: Shotwolf.new(...) == Shotwolf::Client.new(...)
10
+ def self.new(...)
11
+ Client.new(...)
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shotwolf
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ShotWolf
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-21 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby client for the ShotWolf API - capture screenshots, render device
14
+ mockups, generate Open Graph cards, convert HTML to PDF, and run pixel-level visual
15
+ regression diffs through a single JSON HTTP API. Zero dependencies.
16
+ email:
17
+ - hello@shotwolf.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/shotwolf.rb
25
+ - lib/shotwolf/client.rb
26
+ - lib/shotwolf/errors.rb
27
+ - lib/shotwolf/version.rb
28
+ homepage: https://shotwolf.com
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ homepage_uri: https://shotwolf.com
33
+ source_code_uri: https://github.com/shotwolf/ruby
34
+ documentation_uri: https://shotwolf.com/docs/sdks/
35
+ bug_tracker_uri: https://github.com/shotwolf/ruby/issues
36
+ rubygems_mfa_required: 'true'
37
+ post_install_message:
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '3.0'
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 3.4.20
53
+ signing_key:
54
+ specification_version: 4
55
+ summary: Official Ruby SDK for the ShotWolf screenshot, mockup, OG image, HTML-to-PDF
56
+ and visual-diff API.
57
+ test_files: []