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,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "retry_event"
|
|
7
|
+
require_relative "internal/constants"
|
|
8
|
+
require_relative "internal/http"
|
|
9
|
+
require_relative "internal/presigned_fetch"
|
|
10
|
+
require_relative "internal/transport"
|
|
11
|
+
require_relative "internal/uuid"
|
|
12
|
+
require_relative "internal/wire"
|
|
13
|
+
require_relative "render"
|
|
14
|
+
require_relative "documents"
|
|
15
|
+
|
|
16
|
+
module PoliPage
|
|
17
|
+
# Main entry point — orchestrates retries, fires hooks, and delegates HTTP
|
|
18
|
+
# execution to `PoliPage::Internal::Transport`. The `render` and `documents`
|
|
19
|
+
# resource namespaces hold a reference to this client and delegate request
|
|
20
|
+
# execution back through it.
|
|
21
|
+
#
|
|
22
|
+
# Thread-safety: configuration is immutable after `#initialize`; each
|
|
23
|
+
# request opens its own `Net::HTTP` connection. A single `Client` may be
|
|
24
|
+
# safely shared across threads — build one at boot and reuse it.
|
|
25
|
+
#
|
|
26
|
+
# @example Construct with defaults
|
|
27
|
+
# client = PoliPage::Client.new(api_key: ENV.fetch("POLI_PAGE_API_KEY"))
|
|
28
|
+
#
|
|
29
|
+
# @example Construct with full configuration
|
|
30
|
+
# client = PoliPage::Client.new(
|
|
31
|
+
# api_key: ENV.fetch("POLI_PAGE_API_KEY"),
|
|
32
|
+
# base_url: "https://api.example.com",
|
|
33
|
+
# max_retries: 3,
|
|
34
|
+
# retry_delay: 0.5, # seconds
|
|
35
|
+
# timeout: 30, # seconds
|
|
36
|
+
# logger: Logger.new($stdout),
|
|
37
|
+
# on_retry: ->(e) { metrics.increment("polipage.retry") },
|
|
38
|
+
# on_error: ->(err) { Sentry.capture_exception(err) },
|
|
39
|
+
# proxy: "http://user:pass@proxy.example.com:8080",
|
|
40
|
+
# ca_file: "/etc/ssl/corp-mitm-ca.pem"
|
|
41
|
+
# )
|
|
42
|
+
#
|
|
43
|
+
# @example Behind a corporate egress proxy (env-detected)
|
|
44
|
+
# # `http_proxy` / `https_proxy` / `no_proxy` env vars are honoured
|
|
45
|
+
# # automatically — no `proxy:` kwarg needed.
|
|
46
|
+
# client = PoliPage::Client.new(api_key: ENV.fetch("POLI_PAGE_API_KEY"))
|
|
47
|
+
class Client
|
|
48
|
+
include Internal::PresignedFetch
|
|
49
|
+
|
|
50
|
+
attr_reader :base_url, :max_retries, :retry_delay, :timeout, :render, :documents
|
|
51
|
+
|
|
52
|
+
def initialize(api_key:, base_url: Internal::Constants::DEFAULT_BASE_URL,
|
|
53
|
+
max_retries: Internal::Constants::DEFAULT_MAX_RETRIES,
|
|
54
|
+
retry_delay: Internal::Constants::DEFAULT_RETRY_DELAY,
|
|
55
|
+
timeout: Internal::Constants::DEFAULT_TIMEOUT,
|
|
56
|
+
logger: nil, on_request: nil, on_response: nil,
|
|
57
|
+
on_retry: nil, on_error: nil,
|
|
58
|
+
proxy: nil, ca_file: nil, ca_path: nil)
|
|
59
|
+
raise InvalidOptionsError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
60
|
+
|
|
61
|
+
@api_key = api_key
|
|
62
|
+
@base_url = base_url
|
|
63
|
+
@max_retries = max_retries
|
|
64
|
+
@retry_delay = retry_delay
|
|
65
|
+
@timeout = timeout
|
|
66
|
+
@logger = logger
|
|
67
|
+
@on_request = on_request
|
|
68
|
+
@on_response = on_response
|
|
69
|
+
@on_retry = on_retry
|
|
70
|
+
@on_error = on_error
|
|
71
|
+
@proxy = proxy
|
|
72
|
+
@ca_file = ca_file
|
|
73
|
+
@ca_path = ca_path
|
|
74
|
+
init_collaborators
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Redacted `#inspect`. Ruby's default would dump `@api_key` into any
|
|
78
|
+
# `puts client` or exception backtrace — that's how secrets end up in
|
|
79
|
+
# logs (sdk-ruby-plan.md §10.1 redaction rule).
|
|
80
|
+
def inspect
|
|
81
|
+
"#<PoliPage::Client base_url=#{@base_url.inspect} timeout=#{@timeout} max_retries=#{@max_retries}>"
|
|
82
|
+
end
|
|
83
|
+
alias to_s inspect
|
|
84
|
+
|
|
85
|
+
# Execute a POST request against `path` with `body` (snake_case hash;
|
|
86
|
+
# caller's responsibility to compact nils). Returns the parsed
|
|
87
|
+
# snake_case-symbol-keyed Hash on 2xx, or raises a `PoliPage::Error`.
|
|
88
|
+
#
|
|
89
|
+
# @api private
|
|
90
|
+
def execute_post(path, body:, idempotency_key: nil)
|
|
91
|
+
wire_body = JSON.generate(Internal::Wire.to_wire(body))
|
|
92
|
+
response = run_with_retry(method: :post, path: path, body: wire_body,
|
|
93
|
+
idempotency_key: idempotency_key || Internal::UUID.generate)
|
|
94
|
+
parse_json_response(response)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Execute a GET request against `path`. Returns the parsed
|
|
98
|
+
# snake_case-symbol-keyed Hash on 2xx, or raises a `PoliPage::Error`.
|
|
99
|
+
#
|
|
100
|
+
# @api private
|
|
101
|
+
def execute_get(path)
|
|
102
|
+
response = run_with_retry(method: :get, path: path, body: nil, idempotency_key: nil)
|
|
103
|
+
parse_json_response(response)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Execute a DELETE request against `path`. Returns nil on 2xx; the
|
|
107
|
+
# response body is ignored. Raises `PoliPage::Error` on non-2xx.
|
|
108
|
+
#
|
|
109
|
+
# @api private
|
|
110
|
+
def execute_delete(path)
|
|
111
|
+
run_with_retry(method: :delete, path: path, body: nil, idempotency_key: nil)
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Execute a GET request and return the raw `Internal::Transport::Response`
|
|
116
|
+
# (body + headers) without JSON parsing. Used by `documents.preview` which
|
|
117
|
+
# gets `text/html` directly plus the page count via the
|
|
118
|
+
# `X-Document-Page-Count` response header.
|
|
119
|
+
#
|
|
120
|
+
# @api private
|
|
121
|
+
def execute_get_raw(path)
|
|
122
|
+
run_with_retry(method: :get, path: path, body: nil, idempotency_key: nil)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def init_collaborators
|
|
128
|
+
@transport = Internal::Transport.new(base_url: @base_url, timeout: @timeout,
|
|
129
|
+
proxy: @proxy, ca_file: @ca_file, ca_path: @ca_path)
|
|
130
|
+
@render = Resources::Render.new(self)
|
|
131
|
+
@documents = Resources::Documents.new(self)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run_with_retry(method:, path:, body:, idempotency_key:)
|
|
135
|
+
state = { last_error: nil, next_retry_after: nil }
|
|
136
|
+
|
|
137
|
+
(0..@max_retries).each do |attempt|
|
|
138
|
+
sleep_before_retry(attempt, state) if attempt.positive?
|
|
139
|
+
result = send_once(method: method, path: path, body: body,
|
|
140
|
+
idempotency_key: idempotency_key, attempt: attempt + 1)
|
|
141
|
+
return result[:response] if result[:ok]
|
|
142
|
+
|
|
143
|
+
record_failure(state, result)
|
|
144
|
+
raise_terminal(state[:last_error]) unless result[:retryable]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
raise_terminal(state[:last_error])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def record_failure(state, result)
|
|
151
|
+
state[:last_error] = result[:error]
|
|
152
|
+
state[:next_retry_after] = result[:retry_after]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def sleep_before_retry(attempt, state)
|
|
156
|
+
delay = Internal::HTTP.compute_backoff(attempt: attempt, base_delay: @retry_delay,
|
|
157
|
+
retry_after: state[:next_retry_after])
|
|
158
|
+
fire_hook(@on_retry,
|
|
159
|
+
RetryEvent.new(attempt: attempt + 1, delay_ms: (delay * 1000).round,
|
|
160
|
+
reason: state[:last_error]))
|
|
161
|
+
sleep(delay)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def raise_terminal(error)
|
|
165
|
+
fire_hook(@on_error, error)
|
|
166
|
+
raise error
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def send_once(method:, path:, body:, idempotency_key:, attempt:)
|
|
170
|
+
headers = build_request_headers(method, idempotency_key)
|
|
171
|
+
url = Internal::HTTP.build_url(@base_url, path)
|
|
172
|
+
fire_hook(@on_request,
|
|
173
|
+
RequestEvent.new(method: method.to_s.upcase, url: url, attempt: attempt))
|
|
174
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
+
response = @transport.execute(method: method, path: path, headers: headers, body: body)
|
|
176
|
+
return success_result(response, started_at) if (200..299).cover?(response.status)
|
|
177
|
+
|
|
178
|
+
build_error_result(response)
|
|
179
|
+
rescue PoliPage::TimeoutError, PoliPage::ConnectionError => e
|
|
180
|
+
{ ok: false, error: e, retry_after: nil, retryable: true }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def build_request_headers(method, idempotency_key)
|
|
184
|
+
Internal::HTTP.build_headers(
|
|
185
|
+
method: method, api_key: @api_key,
|
|
186
|
+
idempotency_key: idempotency_key,
|
|
187
|
+
user_agent: "poli-page-sdk-ruby/#{PoliPage::VERSION}"
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def success_result(response, started_at)
|
|
192
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
193
|
+
fire_hook(@on_response,
|
|
194
|
+
ResponseEvent.new(
|
|
195
|
+
status: response.status,
|
|
196
|
+
request_id: response.headers[Internal::Constants::HEADER_REQUEST_ID],
|
|
197
|
+
duration_ms: duration_ms
|
|
198
|
+
))
|
|
199
|
+
{ ok: true, response: response }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def build_error_result(response)
|
|
203
|
+
status = response.status
|
|
204
|
+
request_id = response.headers[Internal::Constants::HEADER_REQUEST_ID]
|
|
205
|
+
code, message = Internal::HTTP.parse_error_body(response.body.to_s, status)
|
|
206
|
+
error = Internal::HTTP.classify(status: status, code: code, message: message, request_id: request_id)
|
|
207
|
+
retryable = status >= 500 || status == 429
|
|
208
|
+
retry_after_header = response.headers[Internal::Constants::HEADER_RETRY_AFTER]
|
|
209
|
+
retry_after = retryable ? Internal::HTTP.parse_retry_after(retry_after_header) : nil
|
|
210
|
+
{ ok: false, error: error, retry_after: retry_after, retryable: retryable }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse_json_response(response)
|
|
214
|
+
parsed = JSON.parse(response.body)
|
|
215
|
+
Internal::Wire.from_wire(parsed)
|
|
216
|
+
rescue JSON::ParserError => e
|
|
217
|
+
raise PoliPage::InternalError.new("response body was not valid JSON: #{e.message}", status: response.status)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def fire_hook(hook, event)
|
|
221
|
+
return unless hook
|
|
222
|
+
|
|
223
|
+
hook.call(event)
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
@logger&.warn("polipage hook raised: #{e.class}: #{e.message}")
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require_relative "models/document_descriptor"
|
|
6
|
+
require_relative "models/document_preview_result"
|
|
7
|
+
require_relative "models/thumbnail"
|
|
8
|
+
|
|
9
|
+
module PoliPage
|
|
10
|
+
module Resources
|
|
11
|
+
# `client.documents` namespace (sdk-ruby-plan.md §13 Phase 4, port of
|
|
12
|
+
# sdk-node/src/documents.ts).
|
|
13
|
+
class Documents
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# GET /v1/documents/:id — returns a `DocumentDescriptor` with the
|
|
19
|
+
# client back-reference attached so `#download_pdf` works.
|
|
20
|
+
#
|
|
21
|
+
# @param id [String] the document id (e.g. `"doc_abc123"`)
|
|
22
|
+
# @return [PoliPage::DocumentDescriptor]
|
|
23
|
+
# @raise [PoliPage::NotFoundError] on 404
|
|
24
|
+
# @raise [PoliPage::GoneError] on 410 (soft-deleted)
|
|
25
|
+
#
|
|
26
|
+
# @example Re-fetch a stored document and download its PDF
|
|
27
|
+
# doc = client.documents.get("doc_abc123")
|
|
28
|
+
# File.binwrite("invoice.pdf", doc.download_pdf)
|
|
29
|
+
def get(id)
|
|
30
|
+
parsed = @client.execute_get(path_for(id))
|
|
31
|
+
parsed[:metadata] ||= {}
|
|
32
|
+
PoliPage::DocumentDescriptor.new(**parsed, _client: @client)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# GET /v1/documents/:id/preview — returns the stored paginated HTML
|
|
36
|
+
# plus the page count carried by the `X-Document-Page-Count` response
|
|
37
|
+
# header. The body is `text/html`, NOT a JSON envelope. No engine work
|
|
38
|
+
# — this is a snapshot read of the stored document.
|
|
39
|
+
#
|
|
40
|
+
# @param id [String]
|
|
41
|
+
# @return [PoliPage::DocumentPreviewResult] `{ html:, page_count: }`
|
|
42
|
+
#
|
|
43
|
+
# @example
|
|
44
|
+
# stored = client.documents.preview("doc_abc123")
|
|
45
|
+
# File.write("preview.html", stored.html)
|
|
46
|
+
# puts "#{stored.page_count} pages"
|
|
47
|
+
def preview(id)
|
|
48
|
+
response = @client.execute_get_raw("#{path_for(id)}/preview")
|
|
49
|
+
page_count_header = response.headers[Internal::Constants::HEADER_DOCUMENT_PAGE_COUNT]
|
|
50
|
+
page_count = page_count_header.to_s.match?(/\A\d+\z/) ? page_count_header.to_i : 0
|
|
51
|
+
PoliPage::DocumentPreviewResult.new(html: response.body.to_s, page_count: page_count)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# POST /v1/documents/:id/thumbnails — the deployed API expects the
|
|
55
|
+
# options nested under a `thumbnails` key. The response envelope
|
|
56
|
+
# `{ thumbnails: [...] }` is unwrapped here. Returns an Array of
|
|
57
|
+
# `PoliPage::Thumbnail`; each carries base64-encoded image bytes in
|
|
58
|
+
# `data` (decode with `Base64.decode64(thumb.data)`).
|
|
59
|
+
#
|
|
60
|
+
# @param id [String]
|
|
61
|
+
# @param options [Hash] Forwarded to the wire under `thumbnails:`.
|
|
62
|
+
# - `width:` [Integer] (required)
|
|
63
|
+
# - `format:` ["png" | "jpeg"]
|
|
64
|
+
# - `quality:` [Integer] JPEG quality 1-100 (jpeg only)
|
|
65
|
+
# - `pages:` [Array<Integer>] 1-based page indices; default all pages
|
|
66
|
+
# @return [Array<PoliPage::Thumbnail>]
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# thumbs = client.documents.thumbnails("doc_abc123", width: 320, format: "png", pages: [1])
|
|
70
|
+
# File.binwrite("page1.png", Base64.decode64(thumbs.first.data))
|
|
71
|
+
def thumbnails(id, **options)
|
|
72
|
+
validate_thumbnail_options!(options)
|
|
73
|
+
body = { thumbnails: options.compact }
|
|
74
|
+
parsed = @client.execute_post("#{path_for(id)}/thumbnails", body: body)
|
|
75
|
+
parsed[:thumbnails].map { |t| PoliPage::Thumbnail.new(**t) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# DELETE /v1/documents/:id — returns nil. Re-deleting an
|
|
79
|
+
# already-deleted document surfaces as `PoliPage::GoneError` (HTTP
|
|
80
|
+
# 410) — no special handling here.
|
|
81
|
+
#
|
|
82
|
+
# @param id [String]
|
|
83
|
+
# @return [nil]
|
|
84
|
+
# @raise [PoliPage::GoneError] if already deleted
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# client.documents.delete("doc_abc123")
|
|
88
|
+
def delete(id)
|
|
89
|
+
@client.execute_delete(path_for(id))
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Ruby 3.2+ `URI.encode_uri_component` matches JS `encodeURIComponent`
|
|
96
|
+
# (used by sdk-node). `CGI.escape` would form-encode spaces as `+`,
|
|
97
|
+
# which the deployed API rejects in path segments.
|
|
98
|
+
def path_for(id)
|
|
99
|
+
"#{Internal::Constants::PATH_DOCUMENTS}/#{URI.encode_uri_component(id)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Local arg validation — fail fast with a clear `InvalidOptionsError`
|
|
103
|
+
# before issuing the HTTP request. Mirrors the server's invariants for
|
|
104
|
+
# `documents.thumbnails` (sdk-specification.md / Node SDK).
|
|
105
|
+
def validate_thumbnail_options!(options)
|
|
106
|
+
validate_thumbnail_width!(options[:width])
|
|
107
|
+
validate_thumbnail_format!(options[:format])
|
|
108
|
+
validate_thumbnail_quality!(options[:quality], options[:format])
|
|
109
|
+
validate_thumbnail_pages!(options[:pages])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_thumbnail_width!(width)
|
|
113
|
+
return if width.is_a?(Integer) && width.positive?
|
|
114
|
+
|
|
115
|
+
raise PoliPage::InvalidOptionsError,
|
|
116
|
+
"thumbnails: width is required and must be a positive Integer (got #{width.inspect})"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_thumbnail_format!(format)
|
|
120
|
+
return if format.nil? || %w[png jpeg].include?(format)
|
|
121
|
+
|
|
122
|
+
raise PoliPage::InvalidOptionsError,
|
|
123
|
+
"thumbnails: format must be 'png' or 'jpeg' (got #{format.inspect})"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_thumbnail_quality!(quality, format)
|
|
127
|
+
return if quality.nil?
|
|
128
|
+
|
|
129
|
+
unless format == "jpeg"
|
|
130
|
+
raise PoliPage::InvalidOptionsError,
|
|
131
|
+
"thumbnails: quality is only valid with format: 'jpeg'"
|
|
132
|
+
end
|
|
133
|
+
return if quality.is_a?(Integer) && (1..100).cover?(quality)
|
|
134
|
+
|
|
135
|
+
raise PoliPage::InvalidOptionsError,
|
|
136
|
+
"thumbnails: quality must be an Integer between 1 and 100 (got #{quality.inspect})"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_thumbnail_pages!(pages)
|
|
140
|
+
return if pages.nil?
|
|
141
|
+
return if pages.is_a?(Array) && pages.all? { |p| p.is_a?(Integer) && p.positive? }
|
|
142
|
+
|
|
143
|
+
raise PoliPage::InvalidOptionsError,
|
|
144
|
+
"thumbnails: pages must be an Array of positive Integers (got #{pages.inspect})"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Base for all SDK-raised errors. Inherits from `StandardError` so that
|
|
5
|
+
# `rescue => e` catches it (whereas inheriting from `Exception` would
|
|
6
|
+
# bypass the default rescue and surprise callers).
|
|
7
|
+
#
|
|
8
|
+
# The rescue-friendly path is the class hierarchy
|
|
9
|
+
# (`rescue PoliPage::AuthenticationError`); the predicate methods below are
|
|
10
|
+
# kept for spec parity with the Node SDK and for callers who want a single
|
|
11
|
+
# rescue clause backed by introspection.
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :code, :status, :request_id
|
|
14
|
+
|
|
15
|
+
def initialize(message = nil, code:, status: nil, request_id: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@code = code
|
|
18
|
+
@status = status
|
|
19
|
+
@request_id = request_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def auth_error?
|
|
23
|
+
is_a?(AuthenticationError) || is_a?(PermissionDeniedError)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rate_limit_error?
|
|
27
|
+
is_a?(RateLimitError)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validation_error?
|
|
31
|
+
is_a?(ValidationError)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def network_error?
|
|
35
|
+
is_a?(ConnectionError) || is_a?(TimeoutError)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def retryable?
|
|
39
|
+
return true if network_error?
|
|
40
|
+
return true if status && (status >= 500 || status == 429)
|
|
41
|
+
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Canonical wire payload for framework integrations:
|
|
46
|
+
# `{code:, message:, status:, request_id:}`. `status` surfaces 503 for
|
|
47
|
+
# connection failures, 504 for timeouts, the API HTTP status for
|
|
48
|
+
# status-bearing errors. The {#status} reader itself stays nil for
|
|
49
|
+
# transport-error instances — only the payload surfaces 503/504.
|
|
50
|
+
def to_payload
|
|
51
|
+
{
|
|
52
|
+
code: @code,
|
|
53
|
+
message: message,
|
|
54
|
+
status: payload_status,
|
|
55
|
+
request_id: @request_id
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def payload_status
|
|
60
|
+
@status
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# API status errors. `status` carries the HTTP status; `code` is the
|
|
65
|
+
# machine-readable string from the response body. The subclass picked for a
|
|
66
|
+
# given status is decided by `PoliPage::Internal::HTTP.classify`.
|
|
67
|
+
#
|
|
68
|
+
# 400 → ValidationError
|
|
69
|
+
# 401 → AuthenticationError
|
|
70
|
+
# 403 → PermissionDeniedError
|
|
71
|
+
# 404 → NotFoundError
|
|
72
|
+
# 410 → GoneError
|
|
73
|
+
# 429 → RateLimitError
|
|
74
|
+
# other 4xx / 5xx → APIError
|
|
75
|
+
class ValidationError < Error; end
|
|
76
|
+
class AuthenticationError < Error; end
|
|
77
|
+
class PermissionDeniedError < Error; end
|
|
78
|
+
class NotFoundError < Error; end
|
|
79
|
+
class GoneError < Error; end
|
|
80
|
+
class RateLimitError < Error; end
|
|
81
|
+
class APIError < Error; end
|
|
82
|
+
|
|
83
|
+
# --- SDK-internal errors. `status` is nil except where the SDK observed a
|
|
84
|
+
# status from a non-API hop (e.g., the S3 second-hop for DownloadError). ---
|
|
85
|
+
|
|
86
|
+
class InvalidOptionsError < Error
|
|
87
|
+
def initialize(message, code: "invalid_options")
|
|
88
|
+
super(message, code: code, status: nil, request_id: nil)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class ConnectionError < Error
|
|
93
|
+
attr_reader :cause
|
|
94
|
+
|
|
95
|
+
def initialize(message:, cause: nil)
|
|
96
|
+
super(message, code: "network_error", status: nil, request_id: nil)
|
|
97
|
+
@cause = cause
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def payload_status
|
|
101
|
+
503
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class TimeoutError < Error
|
|
106
|
+
attr_reader :timeout
|
|
107
|
+
|
|
108
|
+
def initialize(timeout:)
|
|
109
|
+
super("request timed out after #{timeout}s",
|
|
110
|
+
code: "timeout", status: nil, request_id: nil)
|
|
111
|
+
@timeout = timeout
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def payload_status
|
|
115
|
+
504
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class DownloadError < Error
|
|
120
|
+
def initialize(message:, status: nil)
|
|
121
|
+
super(message, code: "DOWNLOAD_FAILED", status: status, request_id: nil)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class InternalError < Error
|
|
126
|
+
def initialize(message, status: nil)
|
|
127
|
+
super(message, code: "INTERNAL_ERROR", status: status, request_id: nil)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Known API codes (sdk-ruby-plan.md §7.4). The SDK passes through whatever
|
|
132
|
+
# the API returns — callers may still see codes not in this list. Note:
|
|
133
|
+
# `STORAGE_REQUIRED` was retired from the deployed API and is NOT exported.
|
|
134
|
+
module ErrorCodes
|
|
135
|
+
MISSING_API_KEY = "MISSING_API_KEY"
|
|
136
|
+
INVALID_API_KEY = "INVALID_API_KEY"
|
|
137
|
+
PAYMENT_REQUIRED = "PAYMENT_REQUIRED"
|
|
138
|
+
FORBIDDEN = "FORBIDDEN"
|
|
139
|
+
ORGANIZATION_CANCELLED = "ORGANIZATION_CANCELLED"
|
|
140
|
+
ORGANIZATION_PURGED = "ORGANIZATION_PURGED"
|
|
141
|
+
NOT_FOUND = "NOT_FOUND"
|
|
142
|
+
VERSION_NOT_FOUND = "VERSION_NOT_FOUND"
|
|
143
|
+
DOCUMENT_NOT_FOUND = "DOCUMENT_NOT_FOUND"
|
|
144
|
+
GONE = "GONE"
|
|
145
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
146
|
+
MISSING_DATA = "MISSING_DATA"
|
|
147
|
+
MISSING_PROJECT_OR_TEMPLATE = "MISSING_PROJECT_OR_TEMPLATE"
|
|
148
|
+
MISSING_TEMPLATE_SLUG = "MISSING_TEMPLATE_SLUG"
|
|
149
|
+
PROJECT_REQUIRED_FOR_DOCUMENT = "PROJECT_REQUIRED_FOR_DOCUMENT"
|
|
150
|
+
INVALID_VERSION_FORMAT = "INVALID_VERSION_FORMAT"
|
|
151
|
+
VERSION_REQUIRED = "VERSION_REQUIRED"
|
|
152
|
+
INVALID_VERSION_FOR_KEY_ENV = "INVALID_VERSION_FOR_KEY_ENV"
|
|
153
|
+
QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
|
|
154
|
+
OVERAGE_CAP_EXCEEDED = "OVERAGE_CAP_EXCEEDED"
|
|
155
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Convenience value-object form of the inline-mode render input. Inline
|
|
5
|
+
# mode is only accepted by `client.render.preview` (sdk-ruby-plan.md §13
|
|
6
|
+
# Phase 2); the render-to-document methods require project mode.
|
|
7
|
+
InlineModeInput = Data.define(
|
|
8
|
+
:template,
|
|
9
|
+
:data,
|
|
10
|
+
:format,
|
|
11
|
+
:orientation,
|
|
12
|
+
:locale,
|
|
13
|
+
:metadata,
|
|
14
|
+
:idempotency_key
|
|
15
|
+
) do
|
|
16
|
+
def to_h
|
|
17
|
+
super.compact
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << InlineModeInput
|
|
22
|
+
alias _strict_new new
|
|
23
|
+
|
|
24
|
+
def new(template:, data:, format: nil, orientation: nil, locale: nil,
|
|
25
|
+
metadata: nil, idempotency_key: nil)
|
|
26
|
+
_strict_new(template: template, data: data, format: format,
|
|
27
|
+
orientation: orientation, locale: locale, metadata: metadata,
|
|
28
|
+
idempotency_key: idempotency_key)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Convenience value-object form of the project-mode render input. Methods
|
|
5
|
+
# accept bare kwargs directly; this wrapper is for callers who want
|
|
6
|
+
# `Data.define`'s equality, frozen-by-default semantics, and a single
|
|
7
|
+
# marshallable type to thread through their own code.
|
|
8
|
+
ProjectModeInput = Data.define(
|
|
9
|
+
:project,
|
|
10
|
+
:template,
|
|
11
|
+
:data,
|
|
12
|
+
:version,
|
|
13
|
+
:format,
|
|
14
|
+
:orientation,
|
|
15
|
+
:locale,
|
|
16
|
+
:metadata,
|
|
17
|
+
:idempotency_key
|
|
18
|
+
) do
|
|
19
|
+
# @return [Hash] kwargs ready to splat into the matching `client.render.*` method
|
|
20
|
+
def to_h
|
|
21
|
+
super.compact
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Allow positional-as-keyword construction with optional fields defaulted to nil
|
|
26
|
+
# — Data.define is strict by default. We re-open the class only to add a
|
|
27
|
+
# forgiving constructor that lets callers omit the optional knobs.
|
|
28
|
+
class << ProjectModeInput
|
|
29
|
+
alias _strict_new new
|
|
30
|
+
|
|
31
|
+
def new(project:, template:, data:, version: nil, format: nil,
|
|
32
|
+
orientation: nil, locale: nil, metadata: nil, idempotency_key: nil)
|
|
33
|
+
_strict_new(project: project, template: template, data: data,
|
|
34
|
+
version: version, format: format, orientation: orientation,
|
|
35
|
+
locale: locale, metadata: metadata, idempotency_key: idempotency_key)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
# Convenience value-object form of the `client.documents.thumbnails`
|
|
5
|
+
# options hash. Methods accept bare kwargs directly; this wrapper is for
|
|
6
|
+
# callers who want `Data.define`'s equality + frozen-by-default semantics.
|
|
7
|
+
#
|
|
8
|
+
# - `width` [Integer] thumbnail width in pixels (required)
|
|
9
|
+
# - `format` ["png", "jpeg", nil] output format; default "png"
|
|
10
|
+
# - `quality` [Integer, nil] JPEG quality 1-100; only valid when format is "jpeg"
|
|
11
|
+
# - `pages` [Array<Integer>, nil] 1-based page indices to render; nil means all pages
|
|
12
|
+
ThumbnailOptions = Data.define(:width, :format, :quality, :pages) do
|
|
13
|
+
def to_h
|
|
14
|
+
super.compact
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class << ThumbnailOptions
|
|
19
|
+
alias _strict_new new
|
|
20
|
+
|
|
21
|
+
def new(width:, format: nil, quality: nil, pages: nil)
|
|
22
|
+
_strict_new(width: width, format: format, quality: quality, pages: pages)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PoliPage
|
|
4
|
+
module Internal
|
|
5
|
+
# @api private
|
|
6
|
+
#
|
|
7
|
+
# Internal constants. Convention-private; do not depend on these from
|
|
8
|
+
# outside the gem (no semver protection).
|
|
9
|
+
module Constants
|
|
10
|
+
# API paths (relative to the configured base URL).
|
|
11
|
+
PATH_RENDER = "/v1/render"
|
|
12
|
+
PATH_RENDER_PREVIEW = "/v1/render/preview"
|
|
13
|
+
PATH_DOCUMENTS = "/v1/documents"
|
|
14
|
+
|
|
15
|
+
# Defaults (in seconds where applicable — Ruby's `Kernel#sleep`,
|
|
16
|
+
# `Net::HTTP#open_timeout`, etc. all use seconds).
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.poli.page"
|
|
18
|
+
DEFAULT_MAX_RETRIES = 2
|
|
19
|
+
DEFAULT_RETRY_DELAY = 0.5 # Node: 500 ms.
|
|
20
|
+
DEFAULT_TIMEOUT = 60 # Node: 60_000 ms.
|
|
21
|
+
RETRY_AFTER_CAP = 30 # Node: 30_000 ms.
|
|
22
|
+
|
|
23
|
+
# Header names (lowercase form — Net::HTTP normalises to lowercase on
|
|
24
|
+
# the way back; matches the Node `x-request-id` convention).
|
|
25
|
+
HEADER_AUTHORIZATION = "Authorization"
|
|
26
|
+
HEADER_ACCEPT = "Accept"
|
|
27
|
+
HEADER_CONTENT_TYPE = "Content-Type"
|
|
28
|
+
HEADER_USER_AGENT = "User-Agent"
|
|
29
|
+
HEADER_IDEMPOTENCY_KEY = "Idempotency-Key"
|
|
30
|
+
HEADER_REQUEST_ID = "x-request-id"
|
|
31
|
+
HEADER_RETRY_AFTER = "retry-after"
|
|
32
|
+
|
|
33
|
+
# Document-preview response header carrying the page count
|
|
34
|
+
# (mirrors Node `documents.ts:75-77`).
|
|
35
|
+
HEADER_DOCUMENT_PAGE_COUNT = "x-document-page-count"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|