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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/CODE_OF_CONDUCT.md +38 -0
  4. data/LICENSE +21 -0
  5. data/MIGRATION.md +68 -0
  6. data/README.md +376 -0
  7. data/SECURITY.md +100 -0
  8. data/lib/poli_page/client.rb +228 -0
  9. data/lib/poli_page/documents.rb +148 -0
  10. data/lib/poli_page/errors.rb +157 -0
  11. data/lib/poli_page/inputs/inline_mode_input.rb +31 -0
  12. data/lib/poli_page/inputs/project_mode_input.rb +38 -0
  13. data/lib/poli_page/inputs/thumbnail_options.rb +25 -0
  14. data/lib/poli_page/internal/constants.rb +38 -0
  15. data/lib/poli_page/internal/http.rb +123 -0
  16. data/lib/poli_page/internal/presigned_fetch.rb +89 -0
  17. data/lib/poli_page/internal/transport.rb +98 -0
  18. data/lib/poli_page/internal/uuid.rb +20 -0
  19. data/lib/poli_page/internal/wire.rb +49 -0
  20. data/lib/poli_page/models/document_descriptor.rb +52 -0
  21. data/lib/poli_page/models/document_preview_result.rb +10 -0
  22. data/lib/poli_page/models/orientation.rb +15 -0
  23. data/lib/poli_page/models/page_format.rb +23 -0
  24. data/lib/poli_page/models/preview_result.rb +9 -0
  25. data/lib/poli_page/models/thumbnail.rb +8 -0
  26. data/lib/poli_page/render.rb +163 -0
  27. data/lib/poli_page/render_to_file.rb +52 -0
  28. data/lib/poli_page/request_event.rb +17 -0
  29. data/lib/poli_page/response_event.rb +15 -0
  30. data/lib/poli_page/retry_event.rb +12 -0
  31. data/lib/poli_page/version.rb +5 -0
  32. data/lib/poli_page.rb +28 -0
  33. data/sig/poli_page/client.rbs +46 -0
  34. data/sig/poli_page/documents.rbs +12 -0
  35. data/sig/poli_page/errors.rbs +84 -0
  36. data/sig/poli_page/models.rbs +106 -0
  37. data/sig/poli_page/render.rbs +32 -0
  38. data/sig/poli_page/request_event.rbs +9 -0
  39. data/sig/poli_page/response_event.rbs +9 -0
  40. data/sig/poli_page/retry_event.rbs +9 -0
  41. data/sig/poli_page.rbs +3 -0
  42. 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