poli-page 0.9.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 +62 -0
- data/CODE_OF_CONDUCT.md +38 -0
- data/LICENSE +21 -0
- data/MIGRATION.md +68 -0
- data/README.md +376 -0
- data/SECURITY.md +100 -0
- data/lib/poli_page/client.rb +228 -0
- data/lib/poli_page/documents.rb +148 -0
- data/lib/poli_page/errors.rb +157 -0
- data/lib/poli_page/inputs/inline_mode_input.rb +31 -0
- data/lib/poli_page/inputs/project_mode_input.rb +38 -0
- data/lib/poli_page/inputs/thumbnail_options.rb +25 -0
- data/lib/poli_page/internal/constants.rb +38 -0
- data/lib/poli_page/internal/http.rb +123 -0
- data/lib/poli_page/internal/presigned_fetch.rb +89 -0
- data/lib/poli_page/internal/transport.rb +98 -0
- data/lib/poli_page/internal/uuid.rb +20 -0
- data/lib/poli_page/internal/wire.rb +49 -0
- data/lib/poli_page/models/document_descriptor.rb +52 -0
- data/lib/poli_page/models/document_preview_result.rb +10 -0
- data/lib/poli_page/models/orientation.rb +15 -0
- data/lib/poli_page/models/page_format.rb +23 -0
- data/lib/poli_page/models/preview_result.rb +9 -0
- data/lib/poli_page/models/thumbnail.rb +8 -0
- data/lib/poli_page/render.rb +163 -0
- data/lib/poli_page/render_to_file.rb +52 -0
- data/lib/poli_page/request_event.rb +17 -0
- data/lib/poli_page/response_event.rb +15 -0
- data/lib/poli_page/retry_event.rb +12 -0
- data/lib/poli_page/version.rb +5 -0
- data/lib/poli_page.rb +28 -0
- data/sig/poli_page/client.rbs +46 -0
- data/sig/poli_page/documents.rbs +12 -0
- data/sig/poli_page/errors.rbs +84 -0
- data/sig/poli_page/models.rbs +106 -0
- data/sig/poli_page/render.rbs +32 -0
- data/sig/poli_page/request_event.rbs +9 -0
- data/sig/poli_page/response_event.rbs +9 -0
- data/sig/poli_page/retry_event.rbs +9 -0
- data/sig/poli_page.rbs +3 -0
- metadata +87 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "constants"
|
|
7
|
+
|
|
8
|
+
module PoliPage
|
|
9
|
+
module Internal
|
|
10
|
+
# @api private
|
|
11
|
+
#
|
|
12
|
+
# Pure transport helpers — no I/O, no socket access. Mirrors
|
|
13
|
+
# `sdk-node/src/internal/http.ts` (sdk-ruby-plan.md §13 Phase 1).
|
|
14
|
+
module HTTP
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param base [String] e.g. "https://api.poli.page"
|
|
18
|
+
# @param path [String] e.g. "/v1/render"
|
|
19
|
+
# @return [String] the joined URL with exactly one slash between the two
|
|
20
|
+
def build_url(base, path)
|
|
21
|
+
"#{base.chomp("/")}/#{path.sub(%r{\A/}, "")}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param method [Symbol] :get, :post, or :delete
|
|
25
|
+
# @param api_key [String]
|
|
26
|
+
# @param idempotency_key [String, nil] only used on POST
|
|
27
|
+
# @param user_agent [String]
|
|
28
|
+
# @return [Hash{String=>String}]
|
|
29
|
+
def build_headers(method:, api_key:, idempotency_key:, user_agent:)
|
|
30
|
+
headers = {
|
|
31
|
+
Constants::HEADER_ACCEPT => "application/json",
|
|
32
|
+
Constants::HEADER_AUTHORIZATION => "Bearer #{api_key}",
|
|
33
|
+
Constants::HEADER_USER_AGENT => user_agent
|
|
34
|
+
}
|
|
35
|
+
if method == :post
|
|
36
|
+
headers[Constants::HEADER_CONTENT_TYPE] = "application/json"
|
|
37
|
+
headers[Constants::HEADER_IDEMPOTENCY_KEY] = idempotency_key if idempotency_key
|
|
38
|
+
end
|
|
39
|
+
headers
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse a non-2xx response body into a `[code, message]` pair. Falls
|
|
43
|
+
# back to `INTERNAL_ERROR` when the body is not parseable JSON. The
|
|
44
|
+
# fallback chain (`code → message → error → 'unknown_error'`) is
|
|
45
|
+
# ported verbatim from Node `parseErrorBody` (sdk-ruby-plan.md §7.3).
|
|
46
|
+
#
|
|
47
|
+
# @param body [String]
|
|
48
|
+
# @param status [Integer]
|
|
49
|
+
# @return [Array(String, String)] `[code, message]`
|
|
50
|
+
def parse_error_body(body, status)
|
|
51
|
+
parsed = parse_json_or_nil(body)
|
|
52
|
+
unless parsed.is_a?(Hash)
|
|
53
|
+
return ["INTERNAL_ERROR",
|
|
54
|
+
"HTTP #{status}: response body was not valid JSON"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# RFC 7807: prefer `detail` (specific reason) over `title` (generic name)
|
|
58
|
+
# over the legacy `message` field; fall back to a canned status string.
|
|
59
|
+
# Code is verbatim from the API — never inferred from message.
|
|
60
|
+
code = parsed.values_at("code", "error").compact.first || "unknown_error"
|
|
61
|
+
message = parsed.values_at("detail", "title", "message").compact.first || "HTTP #{status}"
|
|
62
|
+
[code, message]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_json_or_nil(body)
|
|
66
|
+
JSON.parse(body)
|
|
67
|
+
rescue JSON::ParserError, TypeError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse the `Retry-After` response header. Accepts an integer number
|
|
72
|
+
# of seconds or an HTTP-date. Returns the delay in **seconds** (Ruby's
|
|
73
|
+
# `Kernel#sleep` and `Net::HTTP#*_timeout` units), capped at
|
|
74
|
+
# `RETRY_AFTER_CAP` (30 s). Returns `nil` when the header is missing or
|
|
75
|
+
# unparseable. Mirrors Node `parseRetryAfter` modulo unit (ms → s).
|
|
76
|
+
def parse_retry_after(header_value)
|
|
77
|
+
return nil if header_value.nil? || header_value.empty?
|
|
78
|
+
|
|
79
|
+
return header_value.to_i.clamp(0, Constants::RETRY_AFTER_CAP) if /\A\d+\z/.match?(header_value)
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
target = Time.httpdate(header_value)
|
|
83
|
+
rescue ArgumentError
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
(target - Time.now).floor.clamp(0, Constants::RETRY_AFTER_CAP)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Compute the delay (in seconds) before the next retry attempt. When
|
|
90
|
+
# `retry_after` is non-nil, it is returned as-is (server-explicit, no
|
|
91
|
+
# jitter). Otherwise: `base_delay * 2**(attempt-1) * (0.5 + rand)`.
|
|
92
|
+
# `attempt` is 1-based: 1 means the first retry.
|
|
93
|
+
def compute_backoff(attempt:, base_delay:, retry_after: nil)
|
|
94
|
+
return retry_after unless retry_after.nil?
|
|
95
|
+
|
|
96
|
+
exp = base_delay * (2**(attempt - 1))
|
|
97
|
+
exp * (0.5 + rand)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Map an API response (status + parsed code/message) to the most
|
|
101
|
+
# specific `PoliPage::Error` subclass (sdk-ruby-plan.md §7).
|
|
102
|
+
#
|
|
103
|
+
# @param status [Integer]
|
|
104
|
+
# @param code [String]
|
|
105
|
+
# @param message [String]
|
|
106
|
+
# @param request_id [String, nil]
|
|
107
|
+
# @return [PoliPage::Error]
|
|
108
|
+
def classify(status:, code:, message:, request_id:)
|
|
109
|
+
klass = STATUS_MAP.fetch(status, PoliPage::APIError)
|
|
110
|
+
klass.new(message, code: code, status: status, request_id: request_id)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
STATUS_MAP = {
|
|
114
|
+
400 => PoliPage::ValidationError,
|
|
115
|
+
401 => PoliPage::AuthenticationError,
|
|
116
|
+
403 => PoliPage::PermissionDeniedError,
|
|
117
|
+
404 => PoliPage::NotFoundError,
|
|
118
|
+
410 => PoliPage::GoneError,
|
|
119
|
+
429 => PoliPage::RateLimitError
|
|
120
|
+
}.freeze
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module PoliPage
|
|
8
|
+
module Internal
|
|
9
|
+
# @api private
|
|
10
|
+
#
|
|
11
|
+
# Mixin used by `PoliPage::Client` for the unauthenticated presigned-URL
|
|
12
|
+
# second hop (DocumentDescriptor#download_pdf, render.pdf, pdf_stream).
|
|
13
|
+
# The presigned URL is signed by S3 and MUST NOT be retried by the SDK
|
|
14
|
+
# (sdk-ruby-plan.md §5.5).
|
|
15
|
+
#
|
|
16
|
+
# Honors the same proxy/CA configuration as {PoliPage::Internal::Transport}
|
|
17
|
+
# — reads `@proxy`, `@ca_file`, `@ca_path`, `@timeout` from the including
|
|
18
|
+
# `Client` instance.
|
|
19
|
+
module PresignedFetch
|
|
20
|
+
# Fetch the entire body of `url`. Returns the raw bytes; raises
|
|
21
|
+
# PoliPage::DownloadError on non-2xx or network failure.
|
|
22
|
+
def fetch_bytes(url)
|
|
23
|
+
uri = URI.parse(url)
|
|
24
|
+
response = start_presigned(uri) { |http| http.request(Net::HTTP::Get.new(uri.request_uri)) }
|
|
25
|
+
guard_presigned_status!(response)
|
|
26
|
+
response.body
|
|
27
|
+
rescue *PRESIGNED_NETWORK_ERRORS => e
|
|
28
|
+
raise PoliPage::DownloadError.new(message: e.message)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stream the body of `url` to the block. Raises PoliPage::DownloadError
|
|
32
|
+
# on non-2xx and PoliPage::InternalError on Content-Length: 0 (port of
|
|
33
|
+
# Node `render.ts:128-130` `!response.body` guard).
|
|
34
|
+
def stream_bytes(url, &block)
|
|
35
|
+
uri = URI.parse(url)
|
|
36
|
+
start_presigned(uri) do |http|
|
|
37
|
+
http.request_get(uri.request_uri) { |response| yield_stream_chunks(response, &block) }
|
|
38
|
+
end
|
|
39
|
+
rescue *PRESIGNED_NETWORK_ERRORS => e
|
|
40
|
+
raise PoliPage::DownloadError.new(message: e.message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
PRESIGNED_NETWORK_ERRORS = [
|
|
44
|
+
SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
45
|
+
Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
46
|
+
].freeze
|
|
47
|
+
private_constant :PRESIGNED_NETWORK_ERRORS
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def start_presigned(uri, &)
|
|
52
|
+
p_addr, p_port, p_user, p_pass = presigned_proxy_args
|
|
53
|
+
opts = {
|
|
54
|
+
use_ssl: uri.scheme == "https",
|
|
55
|
+
open_timeout: @timeout,
|
|
56
|
+
read_timeout: @timeout
|
|
57
|
+
}
|
|
58
|
+
opts[:ca_file] = @ca_file if @ca_file
|
|
59
|
+
opts[:ca_path] = @ca_path if @ca_path
|
|
60
|
+
Net::HTTP.start(uri.host, uri.port, p_addr, p_port, p_user, p_pass, opts, &)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def presigned_proxy_args
|
|
64
|
+
return [:ENV, nil, nil, nil] if @proxy.nil?
|
|
65
|
+
|
|
66
|
+
pu = URI.parse(@proxy)
|
|
67
|
+
[pu.host, pu.port, pu.user, pu.password]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def guard_presigned_status!(response)
|
|
71
|
+
status = response.code.to_i
|
|
72
|
+
return if (200..299).cover?(status)
|
|
73
|
+
|
|
74
|
+
raise PoliPage::DownloadError.new(
|
|
75
|
+
message: "Failed to download: #{status} #{response.message}",
|
|
76
|
+
status: status
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def yield_stream_chunks(response, &)
|
|
81
|
+
guard_presigned_status!(response)
|
|
82
|
+
status = response.code.to_i
|
|
83
|
+
raise PoliPage::InternalError.new("response has no body", status: status) if response["Content-Length"] == "0"
|
|
84
|
+
|
|
85
|
+
response.read_body(&)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "http"
|
|
8
|
+
|
|
9
|
+
module PoliPage
|
|
10
|
+
module Internal
|
|
11
|
+
# @api private
|
|
12
|
+
#
|
|
13
|
+
# Thin wrapper around `Net::HTTP`. The ONLY module that opens sockets
|
|
14
|
+
# (sdk-ruby-plan.md §3.2). Translates network-level exceptions into the
|
|
15
|
+
# `PoliPage::Error` hierarchy at the seam (§8 error mapping).
|
|
16
|
+
#
|
|
17
|
+
# Honors `http_proxy`, `https_proxy`, and `no_proxy` environment variables
|
|
18
|
+
# by default (via `Net::HTTP`'s `:ENV` proxy resolution). Pass `proxy:` to
|
|
19
|
+
# force an explicit proxy URL or `ca_file:` / `ca_path:` to point at a
|
|
20
|
+
# custom CA bundle (e.g., a corporate MITM TLS-terminating proxy).
|
|
21
|
+
class Transport
|
|
22
|
+
Response = Data.define(:status, :headers, :body)
|
|
23
|
+
|
|
24
|
+
VERBS = {
|
|
25
|
+
get: Net::HTTP::Get,
|
|
26
|
+
post: Net::HTTP::Post,
|
|
27
|
+
delete: Net::HTTP::Delete
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(base_url:, timeout:, proxy: nil, ca_file: nil, ca_path: nil)
|
|
31
|
+
@base_url = base_url
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
@proxy = proxy
|
|
34
|
+
@ca_file = ca_file
|
|
35
|
+
@ca_path = ca_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param method [Symbol] :get, :post, or :delete
|
|
39
|
+
# @param path [String] e.g. "/v1/render"
|
|
40
|
+
# @param headers [Hash{String=>String}]
|
|
41
|
+
# @param body [String, nil] JSON-encoded body or nil
|
|
42
|
+
# @return [Response]
|
|
43
|
+
# @raise [PoliPage::TimeoutError, PoliPage::ConnectionError]
|
|
44
|
+
def execute(method:, path:, headers:, body: nil)
|
|
45
|
+
uri = URI.parse(HTTP.build_url(@base_url, path))
|
|
46
|
+
build_response(perform_request(uri, build_request(method, uri, headers, body)))
|
|
47
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
48
|
+
raise PoliPage::TimeoutError.new(timeout: @timeout)
|
|
49
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
50
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, OpenSSL::SSL::SSLError => e
|
|
51
|
+
raise PoliPage::ConnectionError.new(message: e.message, cause: e)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def perform_request(uri, request)
|
|
57
|
+
p_addr, p_port, p_user, p_pass = proxy_args
|
|
58
|
+
opts = {
|
|
59
|
+
use_ssl: uri.scheme == "https",
|
|
60
|
+
open_timeout: @timeout,
|
|
61
|
+
read_timeout: @timeout,
|
|
62
|
+
write_timeout: @timeout
|
|
63
|
+
}
|
|
64
|
+
opts[:ca_file] = @ca_file if @ca_file
|
|
65
|
+
opts[:ca_path] = @ca_path if @ca_path
|
|
66
|
+
Net::HTTP.start(uri.host, uri.port, p_addr, p_port, p_user, p_pass, opts) do |http|
|
|
67
|
+
http.request(request)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Default `:ENV` lets `Net::HTTP` resolve `http_proxy` / `https_proxy` /
|
|
72
|
+
# `no_proxy` (via `URI#find_proxy`) per-request. Returning a tuple lets
|
|
73
|
+
# us pass positionally without keyword/positional ambiguity.
|
|
74
|
+
def proxy_args
|
|
75
|
+
return [:ENV, nil, nil, nil] if @proxy.nil?
|
|
76
|
+
|
|
77
|
+
pu = URI.parse(@proxy)
|
|
78
|
+
[pu.host, pu.port, pu.user, pu.password]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_response(response)
|
|
82
|
+
Response.new(
|
|
83
|
+
status: response.code.to_i,
|
|
84
|
+
headers: response.to_hash.transform_values { |v| v.is_a?(Array) ? v.first : v },
|
|
85
|
+
body: response.body
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_request(method, uri, headers, body)
|
|
90
|
+
verb_class = VERBS.fetch(method) { raise ArgumentError, "unsupported method: #{method.inspect}" }
|
|
91
|
+
request = verb_class.new(uri.request_uri)
|
|
92
|
+
headers.each { |k, v| request[k] = v }
|
|
93
|
+
request.body = body if body && method == :post
|
|
94
|
+
request
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module PoliPage
|
|
6
|
+
module Internal
|
|
7
|
+
# @api private
|
|
8
|
+
#
|
|
9
|
+
# Thin wrapper over `SecureRandom.uuid`. Indirection exists so a future
|
|
10
|
+
# swap (e.g. UUIDv7 once the stdlib lands `SecureRandom.uuid_v7`) is a
|
|
11
|
+
# one-file change (sdk-ruby-plan.md §13 Phase 1).
|
|
12
|
+
module UUID
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def generate
|
|
16
|
+
SecureRandom.uuid
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
module Internal
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# snake_case ↔ camelCase translator (sdk-ruby-plan.md §5.4). Pure, no
|
|
8
|
+
# runtime dependency on metaprogramming DSLs. Outgoing: hash keys are
|
|
9
|
+
# stringified and camelized. Incoming: hash keys are snake_cased and
|
|
10
|
+
# symbolized.
|
|
11
|
+
module Wire
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def to_wire(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.each_with_object({}) do |(k, v), h|
|
|
18
|
+
h[snake_to_camel(k.to_s)] = to_wire(v)
|
|
19
|
+
end
|
|
20
|
+
when Array
|
|
21
|
+
value.map { |v| to_wire(v) }
|
|
22
|
+
else
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def from_wire(value)
|
|
28
|
+
case value
|
|
29
|
+
when Hash
|
|
30
|
+
value.each_with_object({}) do |(k, v), h|
|
|
31
|
+
h[camel_to_snake(k.to_s).to_sym] = from_wire(v)
|
|
32
|
+
end
|
|
33
|
+
when Array
|
|
34
|
+
value.map { |v| from_wire(v) }
|
|
35
|
+
else
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def snake_to_camel(str)
|
|
41
|
+
str.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def camel_to_snake(str)
|
|
45
|
+
str.gsub(/([A-Z])/, '_\1').downcase
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# The stored-document descriptor returned by `client.render.document` and
|
|
5
|
+
# the `client.documents.*` methods. Carries every wire field plus an
|
|
6
|
+
# SDK-attached `_client` back-reference used by `#download_pdf`.
|
|
7
|
+
#
|
|
8
|
+
# The `_client` field is hidden from `#to_h` and `#inspect` so it doesn't
|
|
9
|
+
# leak into logs or marshalled representations (sdk-ruby-plan.md §3.4).
|
|
10
|
+
DocumentDescriptor = Data.define(
|
|
11
|
+
:document_id,
|
|
12
|
+
:organization_id,
|
|
13
|
+
:project_id, # nullable on the wire
|
|
14
|
+
:project_slug,
|
|
15
|
+
:template_id,
|
|
16
|
+
:template_slug,
|
|
17
|
+
:version,
|
|
18
|
+
:environment, # "sandbox" or "live"
|
|
19
|
+
:api_key_id,
|
|
20
|
+
:format,
|
|
21
|
+
:orientation, # nullable
|
|
22
|
+
:locale, # nullable
|
|
23
|
+
:page_count,
|
|
24
|
+
:size_bytes,
|
|
25
|
+
:created_at, # ISO 8601 String — not parsed (no extra deps)
|
|
26
|
+
:metadata, # Hash; SDK normalises nil → {} after from_wire
|
|
27
|
+
:presigned_pdf_url,
|
|
28
|
+
:expires_at, # ISO 8601 String
|
|
29
|
+
:_client # SDK back-reference; not part of the wire shape
|
|
30
|
+
) do
|
|
31
|
+
# Fetch the PDF bytes from `presigned_pdf_url`. The URL has a ~15-minute
|
|
32
|
+
# TTL — if it expired, call `client.documents.get(document_id)` to
|
|
33
|
+
# refresh and retry.
|
|
34
|
+
#
|
|
35
|
+
# @return [String] raw PDF bytes (binary-encoded)
|
|
36
|
+
# @raise [PoliPage::DownloadError] on non-2xx or network failure
|
|
37
|
+
# @raise [PoliPage::InternalError] if the descriptor lacks a client back-reference
|
|
38
|
+
def download_pdf
|
|
39
|
+
raise PoliPage::InternalError, "DocumentDescriptor missing client back-reference" if _client.nil?
|
|
40
|
+
|
|
41
|
+
_client.fetch_bytes(presigned_pdf_url)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_h
|
|
45
|
+
super.except(:_client)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inspect
|
|
49
|
+
"#<PoliPage::DocumentDescriptor document_id=#{document_id.inspect} ...>"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Return value of `client.documents.preview`. Distinct from
|
|
5
|
+
# `PoliPage::PreviewResult`: this carries `page_count` (NOT `total_pages`)
|
|
6
|
+
# because the documents.preview endpoint exposes the page count via the
|
|
7
|
+
# `X-Document-Page-Count` response header on a `text/html` body
|
|
8
|
+
# (sdk-ruby-plan.md §13 Phase 4; mirrors Node `documents.ts:75-77`).
|
|
9
|
+
DocumentPreviewResult = Data.define(:html, :page_count)
|
|
10
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Valid `orientation:` strings for the rendering endpoints
|
|
5
|
+
# (sdk-specification.md §4.1).
|
|
6
|
+
module Orientation
|
|
7
|
+
ORIENTATIONS = Set["portrait", "landscape"].freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def valid?(value)
|
|
12
|
+
ORIENTATIONS.include?(value)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Valid `format:` strings for the rendering endpoints (sdk-specification.md
|
|
5
|
+
# §4.1). Ruby has no native enum; a frozen Set of strings is the most
|
|
6
|
+
# idiomatic representation and matches the wire format directly. The set
|
|
7
|
+
# is used both for input validation (in resource methods) and for
|
|
8
|
+
# documentation.
|
|
9
|
+
module PageFormat
|
|
10
|
+
FORMATS = Set[
|
|
11
|
+
"A3", "A4", "A5", "A6",
|
|
12
|
+
"Letter", "Legal", "Tabloid",
|
|
13
|
+
"Executive", "B4", "B5",
|
|
14
|
+
"Statement", "Folio"
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def valid?(value)
|
|
20
|
+
FORMATS.include?(value)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Return value of `client.render.preview`. The HTML is the rendered output;
|
|
5
|
+
# `total_pages` is the page count the rendering engine reports;
|
|
6
|
+
# `environment` is either `"sandbox"` or `"live"` and reflects the key the
|
|
7
|
+
# request was made with.
|
|
8
|
+
PreviewResult = Data.define(:html, :total_pages, :environment)
|
|
9
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# A single page thumbnail returned by `client.documents.thumbnails`.
|
|
5
|
+
# `data` is base64-encoded image bytes; decode with `Base64.decode64(data)`
|
|
6
|
+
# (stdlib).
|
|
7
|
+
Thumbnail = Data.define(:page, :width, :height, :content_type, :data)
|
|
8
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models/preview_result"
|
|
4
|
+
require_relative "models/document_descriptor"
|
|
5
|
+
|
|
6
|
+
module PoliPage
|
|
7
|
+
module Resources
|
|
8
|
+
# `client.render` namespace. Each method captures a back-reference to the
|
|
9
|
+
# parent `PoliPage::Client` and delegates HTTP execution through it
|
|
10
|
+
# (sdk-ruby-plan.md §3.1).
|
|
11
|
+
class Render
|
|
12
|
+
def initialize(client)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# POST /v1/render/preview — render and return the HTML, total page
|
|
17
|
+
# count, and the environment ("sandbox" / "live") inferred from the
|
|
18
|
+
# API key. Accepts both project mode and inline mode (the only render-*
|
|
19
|
+
# method that does).
|
|
20
|
+
#
|
|
21
|
+
# @param template [String] template slug (project mode) OR raw HTML (inline mode)
|
|
22
|
+
# @param data [Hash] template data
|
|
23
|
+
# @param project [String, nil] project slug; required for project mode
|
|
24
|
+
# @param version [String, nil] exact semver (e.g. "1.0.0") or "draft"
|
|
25
|
+
# @param format [String, nil] one of `PoliPage::PageFormat::FORMATS`; default A4
|
|
26
|
+
# @param orientation [String, nil] "portrait" or "landscape"; default portrait
|
|
27
|
+
# @param locale [String, nil] BCP 47 (e.g. "en-US")
|
|
28
|
+
# @param metadata [Hash, nil] primitive-valued metadata echoed on render.document responses
|
|
29
|
+
# @param idempotency_key [String, nil] caller-supplied UUID; auto-generated if nil
|
|
30
|
+
# @return [PoliPage::PreviewResult]
|
|
31
|
+
# @raise [PoliPage::ValidationError] on 400
|
|
32
|
+
# @raise [PoliPage::AuthenticationError] on 401
|
|
33
|
+
#
|
|
34
|
+
# @example Project mode
|
|
35
|
+
# result = client.render.preview(
|
|
36
|
+
# project: "billing", template: "invoice", version: "1.0.0",
|
|
37
|
+
# data: { invoice_number: "INV-001" }
|
|
38
|
+
# )
|
|
39
|
+
# puts result.html
|
|
40
|
+
# puts "#{result.total_pages} page(s) in #{result.environment} mode"
|
|
41
|
+
#
|
|
42
|
+
# @example Inline mode (raw HTML; only render.preview accepts this)
|
|
43
|
+
# result = client.render.preview(
|
|
44
|
+
# template: "<h1>Hello {{ name }}</h1>",
|
|
45
|
+
# data: { name: "World" }
|
|
46
|
+
# )
|
|
47
|
+
def preview(template:, data:, project: nil, version: nil, format: nil,
|
|
48
|
+
orientation: nil, locale: nil, metadata: nil,
|
|
49
|
+
idempotency_key: nil)
|
|
50
|
+
validate_render_kwargs!(format: format, orientation: orientation)
|
|
51
|
+
body = { project: project, template: template, data: data, version: version,
|
|
52
|
+
format: format, orientation: orientation, locale: locale, metadata: metadata }.compact
|
|
53
|
+
parsed = @client.execute_post(Internal::Constants::PATH_RENDER_PREVIEW,
|
|
54
|
+
body: body, idempotency_key: idempotency_key)
|
|
55
|
+
PoliPage::PreviewResult.new(**parsed)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /v1/render — render and store the document, returning a
|
|
59
|
+
# `PoliPage::DocumentDescriptor` with the client back-reference attached
|
|
60
|
+
# so that `#download_pdf` works. Project mode only.
|
|
61
|
+
#
|
|
62
|
+
# @return [PoliPage::DocumentDescriptor]
|
|
63
|
+
#
|
|
64
|
+
# @example
|
|
65
|
+
# doc = client.render.document(
|
|
66
|
+
# project: "billing",
|
|
67
|
+
# template: "invoice",
|
|
68
|
+
# version: "1.0.0",
|
|
69
|
+
# data: { invoice_number: "INV-001" },
|
|
70
|
+
# metadata: { customer_id: "cust_123" } # echoed on the descriptor
|
|
71
|
+
# )
|
|
72
|
+
# db.invoices.update(id: "INV-001", document_id: doc.document_id)
|
|
73
|
+
# pdf = doc.download_pdf
|
|
74
|
+
def document(project:, template:, data:, version: nil, format: nil,
|
|
75
|
+
orientation: nil, locale: nil, metadata: nil,
|
|
76
|
+
idempotency_key: nil)
|
|
77
|
+
validate_render_kwargs!(format: format, orientation: orientation)
|
|
78
|
+
body = { project: project, template: template, data: data, version: version,
|
|
79
|
+
format: format, orientation: orientation, locale: locale, metadata: metadata }.compact
|
|
80
|
+
parsed = @client.execute_post(Internal::Constants::PATH_RENDER,
|
|
81
|
+
body: body, idempotency_key: idempotency_key)
|
|
82
|
+
parsed[:metadata] ||= {}
|
|
83
|
+
PoliPage::DocumentDescriptor.new(**parsed, _client: @client)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Two-hop: render the document, then fetch the presigned PDF URL and
|
|
87
|
+
# return the raw bytes (binary-encoded String). The presigned fetch
|
|
88
|
+
# is unauthenticated and NOT subject to the retry policy.
|
|
89
|
+
#
|
|
90
|
+
# @return [String] raw PDF bytes
|
|
91
|
+
# @raise [PoliPage::DownloadError] on second-hop failure
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# pdf = client.render.pdf(
|
|
95
|
+
# project: "billing", template: "invoice", version: "1.0.0",
|
|
96
|
+
# data: { invoice_number: "INV-001" }
|
|
97
|
+
# )
|
|
98
|
+
# File.binwrite("invoice.pdf", pdf)
|
|
99
|
+
def pdf(project:, template:, data:, version: nil, format: nil,
|
|
100
|
+
orientation: nil, locale: nil, metadata: nil, idempotency_key: nil)
|
|
101
|
+
desc = document(project: project, template: template, data: data,
|
|
102
|
+
version: version, format: format, orientation: orientation,
|
|
103
|
+
locale: locale, metadata: metadata, idempotency_key: idempotency_key)
|
|
104
|
+
desc.download_pdf
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Streaming form of `#pdf`. With a block, yields raw chunks (binary
|
|
108
|
+
# bytes) as they arrive. Without a block, returns an `Enumerator` so
|
|
109
|
+
# the caller can `.each`, `.first(n)`, pipe into `Enumerable` chains,
|
|
110
|
+
# etc.
|
|
111
|
+
#
|
|
112
|
+
# @yieldparam chunk [String] raw bytes
|
|
113
|
+
# @return [Enumerator, nil]
|
|
114
|
+
# @raise [PoliPage::DownloadError]
|
|
115
|
+
#
|
|
116
|
+
# @example Block form — pipe straight to a file
|
|
117
|
+
# File.open("invoice.pdf", "wb") do |io|
|
|
118
|
+
# client.render.pdf_stream(
|
|
119
|
+
# project: "billing", template: "invoice", version: "1.0.0", data: data
|
|
120
|
+
# ) { |chunk| io.write(chunk) }
|
|
121
|
+
# end
|
|
122
|
+
#
|
|
123
|
+
# @example Enumerator form
|
|
124
|
+
# enum = client.render.pdf_stream(
|
|
125
|
+
# project: "billing", template: "invoice", version: "1.0.0", data: data
|
|
126
|
+
# )
|
|
127
|
+
# total = enum.sum(&:bytesize)
|
|
128
|
+
def pdf_stream(project:, template:, data:, version: nil, format: nil,
|
|
129
|
+
orientation: nil, locale: nil, metadata: nil, idempotency_key: nil, &block)
|
|
130
|
+
unless block
|
|
131
|
+
return Enumerator.new do |yielder|
|
|
132
|
+
pdf_stream(project: project, template: template, data: data,
|
|
133
|
+
version: version, format: format, orientation: orientation,
|
|
134
|
+
locale: locale, metadata: metadata,
|
|
135
|
+
idempotency_key: idempotency_key) { |chunk| yielder << chunk }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
desc = document(project: project, template: template, data: data,
|
|
140
|
+
version: version, format: format, orientation: orientation,
|
|
141
|
+
locale: locale, metadata: metadata, idempotency_key: idempotency_key)
|
|
142
|
+
@client.stream_bytes(desc.presigned_pdf_url, &block)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Local arg validation — fail fast with a clear `InvalidOptionsError`
|
|
148
|
+
# before issuing the HTTP request. The deployed API rejects these too,
|
|
149
|
+
# but raising client-side saves a round-trip and surfaces a friendlier
|
|
150
|
+
# error message (sdk-ruby-plan.md §9.1).
|
|
151
|
+
def validate_render_kwargs!(format:, orientation:)
|
|
152
|
+
if format && !PoliPage::PageFormat.valid?(format)
|
|
153
|
+
raise PoliPage::InvalidOptionsError,
|
|
154
|
+
"invalid format: #{format.inspect}; expected one of #{PoliPage::PageFormat::FORMATS.to_a}"
|
|
155
|
+
end
|
|
156
|
+
return unless orientation && !PoliPage::Orientation.valid?(orientation)
|
|
157
|
+
|
|
158
|
+
raise PoliPage::InvalidOptionsError,
|
|
159
|
+
"invalid orientation: #{orientation.inspect}; expected 'portrait' or 'landscape'"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|