html2img-client 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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+
6
+ require_relative "client"
7
+
8
+ module Html2img
9
+ # Command line interface, installed as the `html2img` executable.
10
+ #
11
+ # html2img test
12
+ # html2img html card.html --width 1200 --height 630 --out card.png
13
+ # html2img screenshot https://example.com --fullpage --out shot.png
14
+ # html2img template invoice-image --data '{"number": 1042}'
15
+ class CLI
16
+ TEST_DOCUMENT = <<~HTML.gsub(/\n\s*/, "")
17
+ <!doctype html><html><body style="font-family:system-ui;display:flex;align-items:center;
18
+ justify-content:center;height:180px;margin:0;background:#0f172a;color:#fff">
19
+ <h1>html2img is configured</h1></body></html>
20
+ HTML
21
+
22
+ COMMANDS = %w[test html screenshot template].freeze
23
+
24
+ RENDER_KEYS = %i[css width height dpi ms_delay wait_for_selector webhook_url fullpage
25
+ format].freeze
26
+
27
+ # @return [Integer] a process exit code
28
+ def self.run(argv, out: $stdout, err: $stderr)
29
+ new(out: out, err: err).run(argv)
30
+ end
31
+
32
+ def initialize(out: $stdout, err: $stderr)
33
+ @out = out
34
+ @err = err
35
+ @options = {}
36
+ end
37
+
38
+ # @return [Integer] a process exit code
39
+ def run(argv)
40
+ argv = argv.dup
41
+ parser = build_parser
42
+ parser.order!(argv)
43
+ command = argv.shift
44
+
45
+ return version if @options[:version]
46
+ return usage(parser) if @options[:help] || command.nil?
47
+ return unknown(command) unless COMMANDS.include?(command)
48
+
49
+ parser.parse!(argv)
50
+
51
+ execute(command, argv)
52
+ rescue OptionParser::ParseError => e
53
+ fail_with(e.message)
54
+ end
55
+
56
+ private
57
+
58
+ def execute(command, argv)
59
+ client = Html2img::Client.new(api_key: @options[:api_key], timeout: @options[:timeout])
60
+
61
+ report(client, command, dispatch(client, command, argv))
62
+ rescue Html2img::ValidationError => e
63
+ fail_with("Request failed: #{e.message}")
64
+ e.details.each { |field, messages| messages.each { |m| @err.puts(" #{field}: #{m}") } }
65
+ 1
66
+ rescue Html2img::Error => e
67
+ fail_with("Request failed: #{e.message}")
68
+ rescue ArgumentError, SystemCallError, JSON::ParserError => e
69
+ fail_with(e.message)
70
+ end
71
+
72
+ def dispatch(client, command, argv)
73
+ case command
74
+ when "test"
75
+ client.html(TEST_DOCUMENT, width: 600, height: 200)
76
+ when "html"
77
+ client.html(read_document(argv.shift), **render_options)
78
+ when "screenshot"
79
+ client.screenshot(argument!(argv.shift, "url"), **render_options(screenshot: true))
80
+ when "template"
81
+ client.template(argument!(argv.shift, "slug"), template_data)
82
+ end
83
+ end
84
+
85
+ def report(client, command, response)
86
+ @out.puts("Test render succeeded.") if command == "test"
87
+
88
+ if response.processing?
89
+ @out.puts("Accepted. The final URL will be delivered to your webhook.")
90
+ return 0
91
+ end
92
+
93
+ return fail_with("The API returned no image URL.") if response.url.to_s.empty?
94
+
95
+ @out.puts(response.url)
96
+ @out.puts("Saved to #{client.save(response, @options[:out])}") if @options[:out]
97
+ @out.puts("Credits remaining: #{response.credits_remaining}") if response.credits_remaining
98
+
99
+ 0
100
+ end
101
+
102
+ def render_options(screenshot: false)
103
+ keys = screenshot ? RENDER_KEYS + %i[selector] : RENDER_KEYS
104
+
105
+ @options.slice(*keys)
106
+ end
107
+
108
+ def read_document(source)
109
+ argument!(source, "file")
110
+
111
+ source == "-" ? $stdin.read : File.read(source)
112
+ end
113
+
114
+ def template_data
115
+ raw = @options[:data]
116
+
117
+ return {} if raw.nil?
118
+
119
+ raw = $stdin.read if raw == "-"
120
+ raw = File.read(raw.delete_prefix("@")) if raw.start_with?("@")
121
+
122
+ parsed = JSON.parse(raw)
123
+
124
+ raise ArgumentError, "Template data must be a JSON object." unless parsed.is_a?(Hash)
125
+
126
+ parsed
127
+ end
128
+
129
+ def argument!(value, name)
130
+ raise ArgumentError, "Missing required argument: #{name}." if value.nil?
131
+
132
+ value
133
+ end
134
+
135
+ def usage(parser)
136
+ @out.puts(parser.help)
137
+ 0
138
+ end
139
+
140
+ def version
141
+ @out.puts("html2img #{Html2img::VERSION}")
142
+ 0
143
+ end
144
+
145
+ def unknown(command)
146
+ fail_with("Unknown command: #{command}. Expected one of: #{COMMANDS.join(", ")}.")
147
+ end
148
+
149
+ def fail_with(message)
150
+ @err.puts(message)
151
+ 1
152
+ end
153
+
154
+ def build_parser
155
+ OptionParser.new do |opts|
156
+ opts.banner = banner
157
+ define_client_options(opts)
158
+ define_render_options(opts)
159
+ define_capture_options(opts)
160
+ define_meta_options(opts)
161
+ end
162
+ end
163
+
164
+ def banner
165
+ <<~BANNER
166
+ Render HTML, capture screenshots and export PDFs with html2img.com.
167
+
168
+ Usage: html2img <command> [options]
169
+
170
+ Commands:
171
+ test Render a small test image to verify your setup (uses one credit)
172
+ html <file|-> Render an HTML file, or stdin, to an image or PDF
173
+ screenshot <url> Capture a screenshot of a live URL
174
+ template <slug> Render a named template
175
+
176
+ Options:
177
+ BANNER
178
+ end
179
+
180
+ def define_client_options(opts)
181
+ opts.on("--api-key KEY", "API key. Defaults to $HTML2IMG_API_KEY.") do |value|
182
+ @options[:api_key] = value
183
+ end
184
+ opts.on("--timeout SECONDS", Float, "Request timeout (default 35).") do |value|
185
+ @options[:timeout] = value
186
+ end
187
+ end
188
+
189
+ def define_render_options(opts)
190
+ opts.on("--width PIXELS", Integer, "Viewport width (1 to 5000).") { |v| @options[:width] = v }
191
+ opts.on("--height PIXELS", Integer, "Viewport height (1 to 5000).") { |v| @options[:height] = v }
192
+ opts.on("--dpi FACTOR", Integer, "Device pixel ratio (1 to 4).") { |v| @options[:dpi] = v }
193
+ opts.on("--fullpage", "Capture the full page height.") { @options[:fullpage] = true }
194
+ opts.on("--format FORMAT", %w[png pdf], "Output format: png or pdf.") { |v| @options[:format] = v }
195
+ end
196
+
197
+ def define_capture_options(opts)
198
+ opts.on("--css CSS", "Extra CSS injected after load.") { |v| @options[:css] = v }
199
+ opts.on("--selector SEL", "Crop the capture to this selector.") { |v| @options[:selector] = v }
200
+ opts.on("--ms-delay MS", Integer, "Delay before capture.") { |v| @options[:ms_delay] = v }
201
+ opts.on("--wait-for-selector SEL", "Wait for this selector.") do |value|
202
+ @options[:wait_for_selector] = value
203
+ end
204
+ opts.on("--webhook-url URL", "Deliver the result to this webhook.") do |value|
205
+ @options[:webhook_url] = value
206
+ end
207
+ end
208
+
209
+ def define_meta_options(opts)
210
+ opts.on("--data JSON", "Template data: JSON, @file.json, or - for stdin.") do |value|
211
+ @options[:data] = value
212
+ end
213
+ opts.on("-o", "--out PATH", "Also save the render to this path.") { |v| @options[:out] = v }
214
+ opts.on("-v", "--version", "Print the version.") { @options[:version] = true }
215
+ opts.on("-h", "--help", "Print this help.") { @options[:help] = true }
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "version"
6
+ require_relative "errors"
7
+ require_relative "configuration"
8
+ require_relative "request"
9
+ require_relative "render_response"
10
+ require_relative "transport"
11
+
12
+ module Html2img
13
+ # Client for the html2img.com API.
14
+ #
15
+ # Render HTML documents you control, capture live URLs, or render named
16
+ # templates, each returning a {RenderResponse}. Every failure surfaces as an
17
+ # {Html2img::Error}; no raw Net::HTTP exception escapes.
18
+ #
19
+ # client = Html2img::Client.new # reads HTML2IMG_API_KEY
20
+ # response = client.html("<h1>Hello</h1>", width: 1200, height: 630)
21
+ # response.url # => "https://i.html2img.com/abc123.png"
22
+ #
23
+ # A client is cheap to build and safe to share between threads.
24
+ class Client
25
+ HTML_PATH = "/api/html"
26
+ SCREENSHOT_PATH = "/api/screenshot"
27
+ TEMPLATE_PATH = "/api/v1/templates"
28
+
29
+ # @return [String] the API base URL in use
30
+ attr_reader :base_url
31
+
32
+ # @return [Float] the per-request timeout in seconds
33
+ attr_reader :timeout
34
+
35
+ # @param api_key [String, nil] defaults to {Html2img.configuration}, which
36
+ # itself defaults to the HTML2IMG_API_KEY environment variable
37
+ # @param base_url [String, nil] override only for testing or a private deployment
38
+ # @param timeout [Numeric, nil] request timeout in seconds
39
+ # @param transport [#call, nil] see {Transport}
40
+ def initialize(api_key: nil, base_url: nil, timeout: nil, transport: nil)
41
+ config = Html2img.configuration
42
+
43
+ @api_key = resolve_api_key(api_key || config.api_key)
44
+ @base_url = resolve_base_url(base_url || config.base_url)
45
+ @timeout = resolve_timeout(timeout || config.timeout)
46
+ @transport = transport || config.transport || Transport.new
47
+ end
48
+
49
+ # Render an HTML document to an image or PDF (POST /api/html).
50
+ #
51
+ # client.html(document, width: 1200, height: 630, dpi: 2)
52
+ # client.html(document, format: "pdf")
53
+ #
54
+ # @param html [String] a complete HTML document
55
+ # @return [RenderResponse]
56
+ # @raise [Html2img::Error] on any API or transport failure
57
+ def html(html, **options)
58
+ post(HTML_PATH, Request.html_body(html, options))
59
+ end
60
+
61
+ # Capture a screenshot of a live URL (POST /api/screenshot).
62
+ #
63
+ # client.screenshot("https://example.com", fullpage: true, selector: "#hero")
64
+ #
65
+ # @param url [String] a publicly reachable URL
66
+ # @return [RenderResponse]
67
+ # @raise [Html2img::Error] on any API or transport failure
68
+ def screenshot(url, **options)
69
+ post(SCREENSHOT_PATH, Request.screenshot_body(url, options))
70
+ end
71
+
72
+ # Render a named template from a data payload.
73
+ #
74
+ # client.template("invoice-image", number: 1042, amount: "£240.00")
75
+ # client.template("invoice-image", { "number" => 1042 })
76
+ #
77
+ # The data is validated server-side per template. Templates output PNG only.
78
+ #
79
+ # @param slug [String] the template slug, for example "invoice-image"
80
+ # @param data [Hash] template data
81
+ # @param fields [Hash] template data as keyword arguments, merged over data
82
+ # @return [RenderResponse]
83
+ # @raise [Html2img::Error] on any API or transport failure
84
+ def template(slug, data = {}, **fields)
85
+ Request.required_string!("slug", slug)
86
+
87
+ post("#{TEMPLATE_PATH}/#{encode(slug)}", data.merge(fields))
88
+ end
89
+
90
+ # Download the rendered bytes from a render's CDN URL.
91
+ #
92
+ # @param image [RenderResponse, String] a response or a URL
93
+ # @return [String] the binary body
94
+ # @raise [Html2img::Error] if the render has no URL yet, or the download fails
95
+ def download(image)
96
+ url = url_for(image)
97
+
98
+ status, body = @transport.call(
99
+ method: "GET",
100
+ url: url,
101
+ headers: { "Accept" => "*/*", "User-Agent" => user_agent },
102
+ body: nil,
103
+ timeout: timeout
104
+ )
105
+
106
+ if status >= 400
107
+ raise Error.new("Could not download the render from #{url}: HTTP #{status}.",
108
+ status_code: status)
109
+ end
110
+
111
+ body
112
+ end
113
+
114
+ # Download a render and write it to a local file.
115
+ #
116
+ # Parent directories are created for you.
117
+ #
118
+ # client.save(response, "og/post-42.png")
119
+ #
120
+ # @return [String] the path written
121
+ # @raise [Html2img::Error] if the render has no URL yet, or the download fails
122
+ def save(image, path)
123
+ require "fileutils"
124
+
125
+ contents = download(image)
126
+ FileUtils.mkdir_p(File.dirname(path))
127
+ File.binwrite(path, contents)
128
+
129
+ path
130
+ end
131
+
132
+ def inspect
133
+ "#<Html2img::Client base_url=#{base_url.inspect} timeout=#{timeout.inspect}>"
134
+ end
135
+
136
+ private
137
+
138
+ def post(path, body)
139
+ status, raw = @transport.call(
140
+ method: "POST",
141
+ url: "#{base_url}#{path}",
142
+ headers: headers,
143
+ body: JSON.generate(body),
144
+ timeout: timeout
145
+ )
146
+
147
+ payload = decode(raw)
148
+
149
+ raise error_for(status, payload) if status >= 400
150
+
151
+ RenderResponse.from_hash(payload)
152
+ end
153
+
154
+ def headers
155
+ {
156
+ "X-API-Key" => @api_key,
157
+ "Accept" => "application/json",
158
+ "Content-Type" => "application/json",
159
+ "User-Agent" => user_agent
160
+ }
161
+ end
162
+
163
+ def user_agent
164
+ "html2img-ruby/#{Html2img::VERSION}"
165
+ end
166
+
167
+ def encode(slug)
168
+ require "erb"
169
+
170
+ ERB::Util.url_encode(slug)
171
+ end
172
+
173
+ def decode(body)
174
+ return {} if body.nil? || body.empty?
175
+
176
+ parsed = JSON.parse(body)
177
+
178
+ parsed.is_a?(Hash) ? parsed : {}
179
+ rescue JSON::ParserError
180
+ {}
181
+ end
182
+
183
+ def url_for(image)
184
+ return Request.required_string!("image url", image) if image.is_a?(String)
185
+
186
+ unless image.respond_to?(:url)
187
+ raise ArgumentError, "Expected a RenderResponse or a URL String, got #{image.class}."
188
+ end
189
+
190
+ if image.url.nil? || image.url.empty?
191
+ raise Error, "The render has no image URL yet. Async jobs deliver their URL to the " \
192
+ "configured webhook."
193
+ end
194
+
195
+ image.url
196
+ end
197
+
198
+ def resolve_api_key(key)
199
+ if key.nil? || key.to_s.empty?
200
+ raise ArgumentError,
201
+ "No html2img API key. Pass api_key:, set Html2img.configure, or set the " \
202
+ "HTML2IMG_API_KEY environment variable. Create a free key at " \
203
+ "https://app.html2img.com/register."
204
+ end
205
+
206
+ key.to_s
207
+ end
208
+
209
+ def resolve_base_url(value)
210
+ (value || DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, "")
211
+ end
212
+
213
+ def resolve_timeout(value)
214
+ timeout = Float(value || DEFAULT_TIMEOUT)
215
+
216
+ raise ArgumentError, "The timeout must be positive, got #{value}." unless timeout.positive?
217
+
218
+ timeout
219
+ rescue TypeError, ArgumentError => e
220
+ raise ArgumentError, e.message
221
+ end
222
+
223
+ # The exception class for each status the API documents.
224
+ ERRORS = {
225
+ 400 => ValidationError,
226
+ 401 => AuthenticationError,
227
+ 402 => InsufficientCreditsError,
228
+ 403 => NotSubscribedError,
229
+ 404 => NotFoundError,
230
+ 408 => TimeoutError,
231
+ 422 => ValidationError,
232
+ 429 => RateLimitError,
233
+ 504 => TimeoutError
234
+ }.freeze
235
+ private_constant :ERRORS
236
+
237
+ def error_for(status, payload)
238
+ kind = ERRORS[status] || (status >= 500 ? ServerError : Error)
239
+ options = {
240
+ status_code: status,
241
+ payload: payload,
242
+ error_code: (payload["code"] if payload["code"].is_a?(String))
243
+ }
244
+
245
+ return kind.new(message_from(payload, status), details: details_from(payload), **options) if
246
+ kind == ValidationError
247
+
248
+ kind.new(message_from(payload, status), **options)
249
+ end
250
+
251
+ def message_from(payload, status)
252
+ %w[error message].each do |key|
253
+ value = payload[key]
254
+
255
+ return value if value.is_a?(String) && !value.empty?
256
+ end
257
+
258
+ "The html2img API returned HTTP #{status}."
259
+ end
260
+
261
+ def details_from(payload)
262
+ details = payload["details"]
263
+
264
+ return {} unless details.is_a?(Hash)
265
+
266
+ details.each_with_object({}) do |(field, messages), result|
267
+ result[field.to_s] = Array(messages).map(&:to_s)
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Official Ruby client for the html2img.com HTML to Image API.
4
+ #
5
+ # See {Html2img::Client} for the API surface, and {Html2img.configure} for
6
+ # process-wide defaults.
7
+ module Html2img
8
+ # Base URL of the API. Override only for testing or a private deployment.
9
+ DEFAULT_BASE_URL = "https://app.html2img.com"
10
+
11
+ # Request timeout in seconds, just over the 30 second synchronous render budget.
12
+ DEFAULT_TIMEOUT = 35.0
13
+
14
+ # Process-wide defaults, for applications that would rather configure once
15
+ # than pass a client around.
16
+ #
17
+ # Html2img.configure do |config|
18
+ # config.api_key = ENV.fetch("HTML2IMG_API_KEY")
19
+ # config.timeout = 45
20
+ # end
21
+ #
22
+ # In a Rails app this belongs in an initializer.
23
+ class Configuration
24
+ # @return [String, nil] sent as the `X-API-Key` header
25
+ attr_accessor :api_key
26
+
27
+ # @return [String] the API base URL
28
+ attr_accessor :base_url
29
+
30
+ # @return [Float] request timeout in seconds
31
+ attr_accessor :timeout
32
+
33
+ # @return [#call, nil] a custom transport, see {Transport}
34
+ attr_accessor :transport
35
+
36
+ def initialize
37
+ @api_key = ENV.fetch("HTML2IMG_API_KEY", nil)
38
+ @base_url = ENV.fetch("HTML2IMG_BASE_URI", nil) || DEFAULT_BASE_URL
39
+ @timeout = DEFAULT_TIMEOUT
40
+ @transport = nil
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # The process-wide configuration.
46
+ def configuration
47
+ @configuration ||= Configuration.new
48
+ end
49
+
50
+ # Configure the defaults used by {Html2img.client}.
51
+ #
52
+ # @yieldparam config [Configuration]
53
+ def configure
54
+ yield(configuration) if block_given?
55
+
56
+ # A reconfigured process should not keep handing out the old client.
57
+ @client = nil
58
+
59
+ configuration
60
+ end
61
+
62
+ # A memoised client built from {configuration}.
63
+ def client
64
+ @client ||= Client.new
65
+ end
66
+
67
+ # Forget the configuration and the memoised client. Mostly for tests.
68
+ def reset!
69
+ @configuration = nil
70
+ @client = nil
71
+ end
72
+
73
+ # Render an HTML document with the default client.
74
+ def html(...)
75
+ client.html(...)
76
+ end
77
+
78
+ # Capture a screenshot with the default client.
79
+ def screenshot(...)
80
+ client.screenshot(...)
81
+ end
82
+
83
+ # Render a named template with the default client.
84
+ def template(...)
85
+ client.template(...)
86
+ end
87
+
88
+ # Download a render with the default client.
89
+ def download(...)
90
+ client.download(...)
91
+ end
92
+
93
+ # Save a render to disk with the default client.
94
+ def save(...)
95
+ client.save(...)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2img
4
+ # Base type for every error raised by the client at request time.
5
+ #
6
+ # Rescuing this single type is enough to handle any failure originating from
7
+ # the gem. No raw Net::HTTP exception is ever allowed to escape the public
8
+ # API. Invalid arguments are reported before any request is sent, as a plain
9
+ # ArgumentError.
10
+ class Error < StandardError
11
+ # @return [Integer, nil] the HTTP status, when the failure came from a response
12
+ attr_reader :status_code
13
+
14
+ # @return [String, nil] the machine-readable `code` from the API body
15
+ attr_reader :error_code
16
+
17
+ # @return [Hash] the decoded JSON response body, when available
18
+ attr_reader :payload
19
+
20
+ def initialize(message, status_code: nil, payload: {}, error_code: nil)
21
+ super(message)
22
+ @status_code = status_code
23
+ @payload = payload || {}
24
+ @error_code = error_code
25
+ end
26
+
27
+ # The message with the API's error code appended, when there is one.
28
+ def to_s
29
+ error_code ? "#{super} (#{error_code})" : super
30
+ end
31
+ end
32
+
33
+ # Raised on a 401: the API key is missing or not recognised.
34
+ # The API `code` is `missing_api_key` or `invalid_api_key`.
35
+ class AuthenticationError < Error; end
36
+
37
+ # Raised on a 402: authenticated, but out of credits for the period.
38
+ # The API `code` is `insufficient_credits`.
39
+ class InsufficientCreditsError < Error
40
+ # @return [Integer, nil] credits left on the account. Zero on a 402.
41
+ def credits_remaining
42
+ value = payload["credits_remaining"]
43
+
44
+ value.is_a?(Integer) ? value : nil
45
+ end
46
+ end
47
+
48
+ # Raised on a 403: the key is valid but the account has no active subscription.
49
+ # The API `code` is `not_subscribed`.
50
+ class NotSubscribedError < Error; end
51
+
52
+ # Raised on a 404, for example when a template slug does not exist.
53
+ # The API `code` is `template_not_found`.
54
+ class NotFoundError < Error; end
55
+
56
+ # Raised on a 400 or 422 when one or more request fields fail validation.
57
+ # The API `code` is `validation_error`.
58
+ class ValidationError < Error
59
+ # @return [Hash{String => Array<String>}] per-field validation messages
60
+ attr_reader :details
61
+
62
+ def initialize(message, details: {}, **options)
63
+ super(message, **options)
64
+ @details = details || {}
65
+ end
66
+ end
67
+
68
+ # Raised on a 429: too many requests, or a plan quota was exceeded.
69
+ class RateLimitError < Error
70
+ # @return [Integer, nil] seconds to wait before retrying, when supplied
71
+ def retry_after
72
+ value = payload["retry_after"]
73
+
74
+ value.is_a?(Integer) ? value : nil
75
+ end
76
+ end
77
+
78
+ # Raised on a 408 or 504, or when the local timeout elapses.
79
+ #
80
+ # The API `code` is `timeout_error` or `api_timeout_error`. For captures that
81
+ # routinely take this long, pass a `webhook_url` to switch to asynchronous
82
+ # delivery.
83
+ class TimeoutError < Error; end
84
+
85
+ # Raised on a 5xx when the renderer returns an unexpected error.
86
+ # The API `code` is `service_error`.
87
+ class ServerError < Error; end
88
+
89
+ # Raised when the request never reaches a response: DNS failure, refused
90
+ # connection or a TLS error.
91
+ class ConnectionError < Error; end
92
+ end