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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +472 -0
- data/exe/html2img +6 -0
- data/lib/html2img/cli.rb +218 -0
- data/lib/html2img/client.rb +271 -0
- data/lib/html2img/configuration.rb +98 -0
- data/lib/html2img/errors.rb +92 -0
- data/lib/html2img/render_response.rb +115 -0
- data/lib/html2img/request.rb +141 -0
- data/lib/html2img/transport.rb +64 -0
- data/lib/html2img/version.rb +6 -0
- data/lib/html2img-client.rb +5 -0
- metadata +62 -0
data/lib/html2img/cli.rb
ADDED
|
@@ -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
|