poli_page-rails
Render Poli Page documents from Rails controllers.
→ Documentation: https://poli-page.github.io/rubyonrails/
About
This gem wires the official poli-page Ruby SDK into a Rails app as an Engine. A Railtie installs a Configuration object on Rails.application.config.poli_page, PoliPage.client returns a lazy-memoised SDK client, and a Renderable controller concern turns SDK responses into PDF, HTML, or 302 responses with RFC 5987 Content-Disposition headers. Retry and terminal-error hooks on the SDK bridge into ActiveSupport::Notifications so subscribers like lograge, appsignal, or scout_apm pick them up without extra wiring.
When to use this:
- You render PDFs or HTML previews from a Rails controller and want
send_data-shaped helpers that already set the right cache and content-disposition headers. - You want SDK retry and error events on the standard
ActiveSupport::Notificationsbus. - You want a generator that writes a fully commented
config/initializers/poli_page.rb.
When not to:
- You are outside Rails — use the
poli-pageSDK directly. - You need to reimplement HTTP transport, retry policy, idempotency, or error mapping. Those live in the SDK; this gem deliberately does not duplicate them.
Requirements
- Ruby 3.2 or newer
- Rails 7.0, 7.1, 7.2, or 8.0
-
poli-pageSDK 0.13.x (~> 0.13.0); Bundler installs it with the gem
Install
Add the gem to your Gemfile:
# Gemfile
gem "poli_page-rails", "~> 0.13.0"Then run:
# shell
bundle install
bin/rails generate poli_page:installThe generator writes config/initializers/poli_page.rb with a commented template. Set POLI_PAGE_API_KEY in your environment, then restart the app.
API keys
A key belongs to exactly one environment of your organization and reads pp_{environment}_{32 lowercase hex}. The key alone selects the environment, and so the published copy of each project that renders.
| Environment | Key | Watermark | Counted |
|---|---|---|---|
sandbox (every org) |
pp_sandbox_… |
yes | no |
live (every org, deletable on Pro+) |
pp_live_… |
no | yes |
created on Pro+ (e.g. staging) |
pp_staging_… |
no | yes |
"Counted" means the render counts toward the organization's monthly PDF quota.
The API is the only authority on keys. The gem raises ConfigurationError only when api_key is missing or empty. A value that does not match pp_{environment}_{32 lowercase hex} logs one warning through config.logger that names the expected format and shows no part of the key, then the client starts anyway and the API answers 401 if the key is wrong (INVALID_API_KEY, or MISSING_API_KEY for a value without pp_).
Quick start
Return a generated PDF from any controller action.
# app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
include PoliPage::Rails::Renderable
def show
invoice = Invoice.find(params[:id])
bytes = PoliPage.client.render.pdf(
project: "billing",
template: "invoice",
data: invoice.to_template_data
)
render_pdf(bytes, filename: "#{invoice.number}.pdf", inline: false)
end
endrender_pdf sets Content-Type: application/pdf, Cache-Control: private, no-store, X-Content-Type-Options: nosniff, and an RFC 5987 / RFC 6266 Content-Disposition that round-trips non-ASCII filenames.
Configuration
Every key is optional except api_key. The SDK's own defaults apply when a value is left nil.
| Option | Default | Description |
|---|---|---|
api_key |
— | Required. A key for one environment: pp_sandbox_…, pp_live_…, or pp_{environment}_… (see API keys). |
base_url |
https://api.poli.page |
Override for the develop environment or a private proxy. |
timeout |
SDK default | Request timeout in seconds. A positive number, at most 600. |
max_retries |
SDK default | Retry attempts on retryable failures. Must be in 0..10. |
retry_delay |
SDK default | Base backoff delay in seconds. Must be in 0..30. |
logger |
Rails.logger |
Any Logger-compatible instance. |
proxy |
nil |
HTTP proxy URL. Honoured by Net::HTTP via the SDK. |
ca_file |
nil |
Path to a CA bundle for corporate egress. |
ca_path |
nil |
Path to a CA directory. |
notifications |
true |
Set to false to disable the ActiveSupport::Notifications bridge. |
on_retry |
bridge | Custom callable. Setting it replaces the notifications bridge. |
on_error |
bridge | Custom callable. Setting it replaces the notifications bridge. |
error_handler |
true |
Set to false to leave out the default error handler (see Default error handler). |
A minimal initializer:
# config/initializers/poli_page.rb
Rails.application.config.poli_page.tap do |c|
c.api_key = ENV.fetch("POLI_PAGE_API_KEY")
endConfiguration is validated lazily on the first PoliPage.client call, so assets:precompile and db:create keep working in containers that have no secrets at boot.
API at a glance
| Symbol | Purpose |
|---|---|
PoliPage.client |
Lazy-memoised, thread-safe SDK PoliPage::Client. |
PoliPage::Rails::Renderable |
Controller concern exposing render_pdf, render_preview, redirect_to_document. |
PoliPage::Rails::Configuration |
The object behind Rails.application.config.poli_page. |
PoliPage::Rails::Engine |
Rails engine subclass auto-loaded from your Gemfile. |
PoliPage::Rails::ErrorHandler |
Controller concern that answers a PoliPage::Error with JSON; included by default. |
PoliPage::Rails::Railtie |
Installs the configuration, the logger default, the notifications bridge, and the error handler. |
"poli_page.retry" |
ActiveSupport::Notifications event fired before each SDK retry. |
"poli_page.error" |
ActiveSupport::Notifications event fired on terminal SDK failure. |
rails generate poli_page:install |
Writes config/initializers/poli_page.rb. |
Full reference: API reference.
Errors
The SDK raises a fixed hierarchy under PoliPage::Error. The four categories you typically branch on:
-
Auth —
PoliPage::AuthenticationError,PoliPage::PermissionDeniedError. Bad or revoked key, blocked organization. -
Rate limit —
PoliPage::RateLimitError. The API returned429; honourRetry-After. -
Request rejected —
PoliPage::ValidationError. The template, project, ordatapayload was rejected at400. -
Network / transport —
PoliPage::ConnectionError,PoliPage::TimeoutError. Network failure, DNS, TLS, or timeout.
Two more come from outside the API: PoliPage::InvalidResponseError (invalid_response) when a 2xx answer's body is not JSON, usually from a proxy between your app and the API, and PoliPage::FileWriteError (io_error) when render_to_file cannot write its file.
A PoliPage::Error you do not rescue reaches the default error handler.
This gem adds one Rails-specific error: PoliPage::Rails::ConfigurationError, raised by ConfigurationValidator on the first PoliPage.client call when the initializer is invalid. It inherits from PoliPage::Error, so a single rescue PoliPage::Error catches everything.
# app/controllers/invoices_controller.rb
def show
bytes = PoliPage.client.render.pdf(project: "billing", template: "invoice", data: invoice_data)
render_pdf(bytes, filename: "invoice.pdf")
rescue PoliPage::ValidationError => e
Rails.logger.warn("PoliPage validation failed: #{e.message}")
head :unprocessable_entity
rescue PoliPage::RateLimitError
head :too_many_requests
rescue PoliPage::AuthenticationError, PoliPage::PermissionDeniedError
head :forbidden
rescue PoliPage::ConnectionError, PoliPage::TimeoutError
head :bad_gateway
endDefault error handler
The gem answers every PoliPage::Error a controller action raises without rescuing it. The Railtie includes PoliPage::Rails::ErrorHandler into ActionController::Base and ActionController::API, which rescue_from PoliPage::Error and render the SDK's Error#to_payload as JSON, as is:
{ "code": "TEMPLATE_NOT_FOUND", "message": "Template 'does-not-exist' not found in project 'getting-started'.", "status": 404, "requestId": "req_…" }- The HTTP status is the payload's
status: the API's status for an API error,503fornetwork_error,504fortimeout,502forinvalid_response(the 2xx a proxy sent never reaches your client). When the status is unknown (for exampleconfiguration_errororio_error), the answer is500and the body'sstatusisnull. -
requestIdis the API'sx-request-id, ornullwhen there is none. - The
Content-Typeisapplication/json, even when the action had set another one (a PDF route, say) before the error. - The keys are the SDK's, spelled the same by every Poli Page SDK: your app's inflections (an
IDacronym, for example) do not change them.
The handler cannot answer once a response is committed: an ActionController::Live stream is committed when it writes its first chunk or when it is closed. The handler then raises the error again, so Live logs it and calls the stream's on_error. After PDF bytes went out, the client gets a short 200 (the PDF cut where the error happened); compare the bytes received with the descriptor's sizeBytes if you need to detect it.
So that an error before the first byte still gets the JSON answer, in a Live action:
- set the PDF headers and write the first chunk only after the SDK call has started;
- close the stream only once you have written to it (
ensure response.stream.close if started). The usual unconditionalensure response.stream.closecommits an empty200before the handler runs, and the error then ends as that empty200.
The example app's StreamsController does both.
A rescue_from in your own controllers (for PoliPage::Error, one of its subclasses, or StandardError) takes precedence, as usual in Rails. To leave the handler out entirely:
# config/initializers/poli_page.rb
Rails.application.config.poli_page.tap do |c|
c.error_handler = false
endThe value must be true or false; the app refuses to boot with anything else.
Example app
A runnable Rails 8 app lives in example-app/. It exercises every public SDK method behind a single-page UI and ships a bin/rake demo:render_to_file task for the render_to_file SDK path. It renders the welcome template of the getting-started project, which every organization has in its sandbox environment. Its routes follow the Poli Page demo contract: GET /render/pdf answers the PDF, GET /documents/:id redirects (302) to the document's presigned PDF URL, and GET /errors/not-found renders the template does-not-exist, so the default error handler answers 404 with the TEMPLATE_NOT_FOUND error's code, message, status and request id. If your organization renamed or removed getting-started from sandbox, the renders answer 404 PROJECT_NOT_FOUND or PROJECT_NOT_IN_ENVIRONMENT: use a fresh organization.
# shell
cd example-app
bundle install
export POLI_PAGE_API_KEY=pp_sandbox_... # a sandbox key
# export POLI_PAGE_BASE_URL=https://api.poli.page # optional
bin/rails serverGoing further
- ActiveSupport::Notifications — subscribe to
poli_page.retryandpoli_page.errorfor logging, metrics, and alerting (Observability). - Filename encoding — how
PoliPage::Rails::FilenameEncoderproduces RFC 5987Content-Dispositionvalues for non-ASCII names (Responses). - Background rendering — running renders through Active Job for slow templates (Client access has a job example).
- Testing helpers — stubbing
PoliPage.clientin request specs and system tests (Testing).
Compatibility
| Gem |
poli-page SDK |
Rails | Ruby |
|---|---|---|---|
| 0.13.x | 0.13.x | 7.0 / 7.1 / 7.2 / 8.0 | 3.2 – 3.4 |
The gem shares its major.minor with the Poli Page API and requires the SDK of the same minor (see RELEASING.md).
You receive fixes for the latest two Rails majors. Older majors receive security fixes for six months after their upstream EOL.
Contributing
Run the lint and the specs for one Rails version with Appraisal:
bundle install
bundle exec appraisal generate
BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle install
bundle exec rubocop
bundle exec appraisal rails-8.0 rspec --exclude-pattern "spec/integration/**/*"spec/integration renders against a real API and runs only with POLI_PAGE_API_KEY set to a pp_sandbox_ key. It targets the SDK's default API, or the one in POLI_PAGE_BASE_URL when that is set. The live API CI job reads both from repository secrets: POLI_PAGE_API_KEY (a pp_sandbox_ key) and the optional POLI_PAGE_BASE_URL.
Contract tests
spec/contract/impl_contract_spec.rb runs the integration cases of the Poli Page API contract against the example app's controllers, routes and initializer, with the SDK pointed at a local mock server. CI runs them on every Ruby and Rails version of the matrix, against the contract version pinned in .github/workflows/ci.yml. Locally, download a contract.json and run:
POLI_CONTRACT=<path to contract.json> bundle exec rspec spec/contractWithout POLI_CONTRACT the cases are pending (and fail when CI is set). Cases allowed to fail are listed with a reason in spec/contract-skip.json.
Releasing
Releases are published by pushing a vX.Y.Z tag; see RELEASING.md.
License
Released under the MIT License — see LICENSE.