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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2img
4
+ # The result of a successful render call.
5
+ #
6
+ # Covers both the synchronous envelope (`url` populated) and the asynchronous
7
+ # acceptance envelope returned when a `webhook_url` was supplied (`status` is
8
+ # "processing" and `url` is nil until the webhook fires).
9
+ class RenderResponse
10
+ # @return [Boolean]
11
+ attr_reader :success
12
+
13
+ # @return [String, nil] the render id
14
+ attr_reader :id
15
+
16
+ # @return [String, nil] the CDN URL of the render
17
+ attr_reader :url
18
+
19
+ # @return [String, nil] when a hosted render expires, as an ISO 8601 string.
20
+ # Nil on paid plans, where renders stay hosted permanently; set on
21
+ # free-tier renders, which are hosted for seven days.
22
+ attr_reader :expires_at
23
+
24
+ # @return [Integer, nil] credits left after this call
25
+ attr_reader :credits_remaining
26
+
27
+ # @return [String, nil] "processing" for async jobs
28
+ attr_reader :status
29
+
30
+ # @return [String, nil]
31
+ attr_reader :message
32
+
33
+ # @return [String, nil] the template slug, when applicable
34
+ attr_reader :template
35
+
36
+ # @return [Hash] the full decoded JSON payload
37
+ attr_reader :raw
38
+
39
+ # Build a response from a decoded JSON payload.
40
+ def self.from_hash(data)
41
+ data = {} unless data.is_a?(Hash)
42
+
43
+ new(
44
+ success: data["success"] ? true : false,
45
+ id: string_or_nil(data["id"]),
46
+ url: string_or_nil(data["url"]),
47
+ expires_at: string_or_nil(data["expires_at"]),
48
+ credits_remaining: integer_or_nil(data["credits_remaining"]),
49
+ status: string_or_nil(data["status"]),
50
+ message: string_or_nil(data["message"]),
51
+ template: string_or_nil(data["template"]),
52
+ raw: data
53
+ )
54
+ end
55
+
56
+ def self.string_or_nil(value)
57
+ return nil if value.nil? || value.is_a?(Hash) || value.is_a?(Array)
58
+
59
+ value.to_s
60
+ end
61
+
62
+ def self.integer_or_nil(value)
63
+ return value if value.is_a?(Integer)
64
+ return value.to_i if value.is_a?(Float)
65
+ return Integer(value, exception: false) if value.is_a?(String)
66
+
67
+ nil
68
+ end
69
+
70
+ private_class_method :string_or_nil, :integer_or_nil
71
+
72
+ def initialize(success: false, id: nil, url: nil, expires_at: nil, credits_remaining: nil,
73
+ status: nil, message: nil, template: nil, raw: {})
74
+ @success = success
75
+ @id = id
76
+ @url = url
77
+ @expires_at = expires_at
78
+ @credits_remaining = credits_remaining
79
+ @status = status
80
+ @message = message
81
+ @template = template
82
+ @raw = raw
83
+ freeze
84
+ end
85
+
86
+ # Whether this is an async job still being rendered.
87
+ #
88
+ # When true, the final URL is delivered to the request's `webhook_url`
89
+ # rather than being available on this response.
90
+ def processing?
91
+ status == "processing"
92
+ end
93
+
94
+ # Whether the render came back as a PDF rather than an image.
95
+ def pdf?
96
+ return false if url.nil?
97
+
98
+ url.to_s.split("?").first.to_s.downcase.end_with?(".pdf")
99
+ end
100
+
101
+ def success?
102
+ success
103
+ end
104
+
105
+ # The URL, so a response drops straight into string interpolation.
106
+ def to_s
107
+ url.to_s
108
+ end
109
+
110
+ def inspect
111
+ "#<Html2img::RenderResponse url=#{url.inspect} status=#{status.inspect} " \
112
+ "credits_remaining=#{credits_remaining.inspect}>"
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2img
4
+ # Builds and validates the JSON bodies sent to the render endpoints.
5
+ #
6
+ # The range checks mirror the server-side validation rules, so an obvious
7
+ # mistake fails fast with a clear message before a request is sent, and
8
+ # before a credit is spent. Any option left nil is omitted from the body, so
9
+ # the server applies its own default.
10
+ #
11
+ # @api private
12
+ module Request
13
+ MIN_DIMENSION = 1
14
+ MAX_DIMENSION = 5000
15
+ MIN_DPI = 1
16
+ MAX_DPI = 4
17
+ MIN_MS_DELAY = 1
18
+ MAX_MS_DELAY = 5000
19
+
20
+ FORMATS = %w[png pdf].freeze
21
+
22
+ # Options accepted by both render endpoints.
23
+ COMMON_OPTIONS = %i[
24
+ css width height fullpage dpi webhook_url ms_delay wait_for_selector format scale_to_fit
25
+ ].freeze
26
+
27
+ # `selector` crops a screenshot to one element. There is no equivalent for
28
+ # an HTML render, since you control the markup.
29
+ SCREENSHOT_OPTIONS = (COMMON_OPTIONS + %i[selector]).freeze
30
+
31
+ module_function
32
+
33
+ # Build the body for POST /api/html.
34
+ #
35
+ # @param html [String] a complete HTML document
36
+ # @return [Hash]
37
+ def html_body(html, options)
38
+ validate_keys!(options, COMMON_OPTIONS)
39
+
40
+ body = { "html" => required_string!("html", html) }
41
+
42
+ body.merge(common(options)).compact
43
+ end
44
+
45
+ # Build the body for POST /api/screenshot.
46
+ #
47
+ # @param url [String] a publicly reachable URL
48
+ # @return [Hash]
49
+ def screenshot_body(url, options)
50
+ validate_keys!(options, SCREENSHOT_OPTIONS)
51
+
52
+ body = { "url" => required_string!("url", url) }
53
+ body["selector"] = optional_string!("selector", options[:selector])
54
+
55
+ body.merge(common(options)).compact
56
+ end
57
+
58
+ # The options shared by both endpoints, mapped onto their API field names.
59
+ def common(options)
60
+ {
61
+ "css" => optional_string!("css", options[:css]),
62
+ "width" => dimension!("width", options[:width]),
63
+ "height" => dimension!("height", options[:height]),
64
+ "fullpage" => boolean!("fullpage", options[:fullpage]),
65
+ "dpi" => dpi!(options[:dpi]),
66
+ "webhook_url" => optional_string!("webhook_url", options[:webhook_url]),
67
+ "ms_delay" => ms_delay!(options[:ms_delay]),
68
+ "wait_for_selector" => optional_string!("wait_for_selector", options[:wait_for_selector]),
69
+ "format" => format!(options[:format]),
70
+ "scale_to_fit" => boolean!("scale_to_fit", options[:scale_to_fit])
71
+ }
72
+ end
73
+
74
+ def validate_keys!(options, allowed)
75
+ unknown = options.keys - allowed
76
+
77
+ return if unknown.empty?
78
+
79
+ raise ArgumentError,
80
+ "Unknown option(s): #{unknown.join(", ")}. " \
81
+ "Valid options are: #{allowed.sort.join(", ")}."
82
+ end
83
+
84
+ def required_string!(name, value)
85
+ raise ArgumentError, "The #{name} must be a String, got #{value.class}." unless value.is_a?(String)
86
+
87
+ raise ArgumentError, "The #{name} must not be empty." if value.empty?
88
+
89
+ value
90
+ end
91
+
92
+ def optional_string!(name, value)
93
+ return nil if value.nil?
94
+
95
+ raise ArgumentError, "The #{name} must be a String, got #{value.class}." unless value.is_a?(String)
96
+
97
+ value
98
+ end
99
+
100
+ def boolean!(name, value)
101
+ return nil if value.nil?
102
+ return value if [true, false].include?(value)
103
+
104
+ raise ArgumentError, "The #{name} must be true or false, got #{value.inspect}."
105
+ end
106
+
107
+ def dimension!(name, value)
108
+ integer_in_range!(name, value, MIN_DIMENSION, MAX_DIMENSION)
109
+ end
110
+
111
+ def dpi!(value)
112
+ integer_in_range!("dpi", value, MIN_DPI, MAX_DPI)
113
+ end
114
+
115
+ def ms_delay!(value)
116
+ integer_in_range!("ms_delay", value, MIN_MS_DELAY, MAX_MS_DELAY)
117
+ end
118
+
119
+ def integer_in_range!(name, value, low, high)
120
+ return nil if value.nil?
121
+
122
+ raise ArgumentError, "The #{name} must be an Integer, got #{value.class}." unless value.is_a?(Integer)
123
+
124
+ unless value.between?(low, high)
125
+ raise ArgumentError, "The #{name} must be between #{low} and #{high}, got #{value}."
126
+ end
127
+
128
+ value
129
+ end
130
+
131
+ def format!(value)
132
+ return nil if value.nil?
133
+
134
+ normalised = value.to_s.downcase
135
+
136
+ return normalised if FORMATS.include?(normalised)
137
+
138
+ raise ArgumentError, "The format must be one of: #{FORMATS.join(", ")}. Got #{value.inspect}."
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Html2img
8
+ # HTTP transport for the client.
9
+ #
10
+ # The default is built on Net::HTTP from the standard library, so the gem has
11
+ # no runtime dependencies. Anything responding to #call with the same
12
+ # signature can be passed to {Client} as `transport:`, which is the seam for
13
+ # Faraday, HTTPX, a proxy, retry middleware, or a stub in your tests.
14
+ #
15
+ # A transport returns the raw [status, body] pair for *any* HTTP response,
16
+ # including 4xx and 5xx. Mapping a status onto a typed error is the client's
17
+ # job. A transport only raises when no response was received at all.
18
+ class Transport
19
+ # @param method [String] "POST" or "GET"
20
+ # @param url [String] the absolute URL
21
+ # @param headers [Hash{String => String}]
22
+ # @param body [String, nil] the request body
23
+ # @param timeout [Float] seconds
24
+ # @return [Array(Integer, String)] status and body
25
+ def call(method:, url:, headers:, body:, timeout:)
26
+ uri = URI.parse(url)
27
+ request = build_request(method, uri, headers, body)
28
+
29
+ response = http(uri, timeout).request(request)
30
+
31
+ [response.code.to_i, response.body.to_s]
32
+ # Net::OpenTimeout and Net::ReadTimeout both descend from Timeout::Error.
33
+ rescue Timeout::Error => e
34
+ raise TimeoutError, "The request to #{url} timed out after #{timeout} seconds: #{e.message}"
35
+ rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError, Net::HTTPBadResponse => e
36
+ raise ConnectionError, "Could not reach the html2img API: #{e.message}"
37
+ end
38
+
39
+ private
40
+
41
+ def http(uri, timeout)
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = uri.scheme == "https"
44
+ http.open_timeout = timeout
45
+ http.read_timeout = timeout
46
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
47
+ http
48
+ end
49
+
50
+ def build_request(method, uri, headers, body)
51
+ request =
52
+ case method.to_s.upcase
53
+ when "POST" then Net::HTTP::Post.new(uri)
54
+ when "GET" then Net::HTTP::Get.new(uri)
55
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
56
+ end
57
+
58
+ headers.each { |name, value| request[name] = value }
59
+ request.body = body if body
60
+
61
+ request
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2img
4
+ # Single source of truth for the gem version.
5
+ VERSION = "1.0.0"
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point matching the gem name, so `gem "html2img-client"` works with
4
+ # Bundler's default require. Everything lives under html2img/.
5
+ require "html2img/client"
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: html2img-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - html2img
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |-
13
+ Render HTML and CSS to images, capture screenshots of live URLs, render named
14
+ templates and export A4 PDFs, all in real Chrome. Zero runtime dependencies.
15
+ email:
16
+ - info@html2img.com
17
+ executables:
18
+ - html2img
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE
24
+ - README.md
25
+ - exe/html2img
26
+ - lib/html2img-client.rb
27
+ - lib/html2img/cli.rb
28
+ - lib/html2img/client.rb
29
+ - lib/html2img/configuration.rb
30
+ - lib/html2img/errors.rb
31
+ - lib/html2img/render_response.rb
32
+ - lib/html2img/request.rb
33
+ - lib/html2img/transport.rb
34
+ - lib/html2img/version.rb
35
+ homepage: https://html2img.com
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ homepage_uri: https://html2img.com
40
+ source_code_uri: https://github.com/html2img/html2img-ruby
41
+ changelog_uri: https://github.com/html2img/html2img-ruby/blob/main/CHANGELOG.md
42
+ bug_tracker_uri: https://github.com/html2img/html2img-ruby/issues
43
+ documentation_uri: https://html2img.com/docs/usage/ruby
44
+ rubygems_mfa_required: 'true'
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.1.0
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubygems_version: 3.6.9
60
+ specification_version: 4
61
+ summary: Official Ruby client for the html2img HTML to Image API.
62
+ test_files: []