poli_page-rails 0.13.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fc6c84ba2e602d0b343aab8a74fc7f1cd34b65d087891c7a74640c558ba70194
4
+ data.tar.gz: 7218a8e03696761feeba15cf51822bf669910997373a4a163e7a703036e1fd94
5
+ SHA512:
6
+ metadata.gz: 823d3338569c4d2dc5d89ee67d4ce1018c8368f9cc4ffcb6f123ec05f6c21793f6b2a845fb945b6650b29a4a4add6be990deb437cdcf1894e70c7f15a7d69f47
7
+ data.tar.gz: 1dd17a7968b5e0bcb3fea62225e61bb594613380a921c2189304398c206c74f0326dc49d8242df24ee50da6f73277970cbe1a09044c1dc27a1713ae379bbae5e
data/CHANGELOG.md ADDED
@@ -0,0 +1,139 @@
1
+ # Changelog
2
+
3
+ All notable changes to `poli_page-rails` are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
5
+ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.13.0] - 2026-11-04
10
+
11
+ First release on RubyGems. It requires the `poli-page` SDK 0.13
12
+ (`~> 0.13.0`) and passes the Poli Page API contract v0.13.0. The gem shares
13
+ its major.minor with the Poli Page API, so its first version is 0.13.0; a
14
+ 0.11.0 was prepared and never published, and neither was the 0.1.0
15
+ development line. The Changed, Removed and Fixed entries below describe
16
+ changes made during those lines.
17
+
18
+ ### Added
19
+
20
+ - Rails engine + Railtie wrapping the official `poli-page` Ruby SDK.
21
+ - `PoliPage.client` — lazy memoised class-level accessor (Monitor-guarded;
22
+ shared across threads per the SDK's thread-safety guarantee).
23
+ `PoliPage.reset_client!` clears the memo (test-only).
24
+ - `Rails.application.config.poli_page` — `Configuration` object exposing every
25
+ SDK kwarg as an `attr_accessor` plus a `notifications` opt-out switch.
26
+ `to_client_kwargs.compact` lets the SDK's own defaults apply for `nil` keys.
27
+ - `PoliPage::Rails::ConfigurationValidator` — validates `api_key` (see
28
+ Changed), `base_url` (http/https), `timeout`
29
+ (1..600), `max_retries` (0..10), `retry_delay` (0..30), `notifications`
30
+ (boolean), and `on_retry` / `on_error` (callable). Runs lazily on first
31
+ `PoliPage.client` call — keeps `assets:precompile` and `db:create` working
32
+ in containers without secrets at boot.
33
+ - `PoliPage::Rails::Renderable` — controller concern providing `render_pdf`,
34
+ `render_preview`, `redirect_to_document`. Sets `Cache-Control: private,
35
+ no-store`, `X-Content-Type-Options: nosniff`, and an RFC 5987 / RFC 6266
36
+ `Content-Disposition` that handles ASCII + non-ASCII filenames (`résumé.pdf`,
37
+ `発票.pdf`, `🦀.pdf`).
38
+ - `PoliPage::Rails::FilenameEncoder` — thin wrapper over
39
+ `ActionDispatch::Http::ContentDisposition.format`; single seam if Rails
40
+ changes the formatting API across versions.
41
+ - `ActiveSupport::Notifications` bridge for the SDK's retry/error hooks
42
+ (`poli_page.retry`, `poli_page.error`). **Default-on**, opt-out via
43
+ `c.notifications = false`. Zero overhead with no subscribers.
44
+ - `rails generate poli_page:install` — writes
45
+ `config/initializers/poli_page.rb` with a commented template documenting
46
+ every configuration key. Zero custom options to avoid Thor-reserved-name
47
+ shadowing (`--force`, `--skip`, `--pretend`, `--quiet`, etc.).
48
+ - Example Rails 8 app at [`example-app/`](./example-app) with an interactive
49
+ single-page demo UI covering every SDK method (`render.pdf`,
50
+ `render.pdf_stream`, `render.document`, `documents.get`,
51
+ `documents.thumbnails`, `documents.preview`, `documents.delete`, an
52
+ error demo, and a `bin/rake demo:render_to_file` task for
53
+ `render_to_file`).
54
+ - RBS signatures in `sig/` mirroring the public API surface.
55
+ - A default error handler, on by default: `PoliPage::Rails::ErrorHandler` is
56
+ included into `ActionController::Base` and `ActionController::API` and
57
+ answers every unrescued `PoliPage::Error` with the SDK's `to_payload`, as is,
58
+ as JSON `{ "code", "message", "status", "requestId" }` and the payload's
59
+ status (`503` for `network_error`, `504` for `timeout`, `502` for
60
+ `invalid_response`), or `500` with `"status": null` when the status is
61
+ unknown (`invalid_options`, `io_error`), always as `application/json`. Set
62
+ `config.poli_page.error_handler = false` to leave it out. An error raised
63
+ after a response is committed (an `ActionController::Live` stream that wrote
64
+ its first chunk or was closed) is raised again, for Live to log and report;
65
+ close a Live stream only once it has written, or an error before the first
66
+ byte ends as an empty `200`.
67
+ - The API contract's integration cases (`spec/contract`), run in CI against
68
+ the example app on every Ruby and Rails version of the matrix, with the
69
+ contract pinned to v0.13.0 by the `poli-page/api-contract` action, including
70
+ `impl.invalid-response`.
71
+ - Release workflow: the tag's major.minor must be the pinned API contract's (a
72
+ stable contract tag on the pinned commit, not the comment next to it) and
73
+ the required `poli-page` SDK's (`~> X.Y.0` in the gemspec and the example
74
+ app), and the example app's lockfile must lock the tag's version
75
+ (`scripts/check-release-versions.sh`); the error names both versions.
76
+
77
+ ### Changed
78
+
79
+ - Requires the `poli-page` SDK 0.13 (`~> 0.13.0`, that is `>= 0.13.0,
80
+ < 0.14`). With it, a 2xx from the API whose body is not JSON (usually from a
81
+ proxy) raises `PoliPage::InvalidResponseError`, code `invalid_response` (was
82
+ `INTERNAL_ERROR`), which the default error handler answers `502`; a
83
+ `render_to_file` that cannot write its file raises
84
+ `PoliPage::FileWriteError`, code `io_error` (was `invalid_options` or the
85
+ raw Ruby exception), answered `500` with `"status": null`; and the PDF
86
+ download is bounded by the `timeout` option (an idle timeout for
87
+ `render.pdf_stream` and `render_to_file`).
88
+ - The default error handler forwards the SDK's `to_payload` as is: the SDK
89
+ 0.13 names the request id `requestId` itself, so the gem's own renaming of
90
+ `request_id` and its `ErrorHandler::WIRE_KEYS` are gone.
91
+ `ErrorHandler.body` returns the payload's Symbol-keyed Hash.
92
+ - Environment keys and no template versions, as in the SDK since 0.11:
93
+ render calls take no `version:`, a key belongs to one environment
94
+ (`pp_{environment}_{32 lowercase hex}`, for example `pp_sandbox_…`,
95
+ `pp_live_…`, `pp_staging_…`), and the key alone selects the environment and
96
+ so the published copy that renders.
97
+ - `ConfigurationValidator` accepts any environment key. It raises
98
+ `ConfigurationError` only for a missing or empty `api_key`; a value that does
99
+ not match `pp_{environment}_{32 lowercase hex}` logs one warning through
100
+ `config.logger` that names the expected format and shows no part of the
101
+ key, not even its `pp_{environment}_` prefix, and the client starts anyway,
102
+ leaving the verdict to the API.
103
+ `API_KEY_PATTERN` is replaced by `API_KEY_FORMAT`.
104
+ - `render_preview` now documents `DocumentPreviewResult` (`documents.preview`)
105
+ only.
106
+ - Example app: the demo routes move from `/api/*` to the demo-contract
107
+ paths (`GET /render/pdf`, `GET /documents/:id`, `GET /errors/not-found`, …).
108
+ `GET /api/errors/bad-version` (`INVALID_VERSION_FORMAT`) is replaced by
109
+ `GET /errors/not-found`, which renders the template `does-not-exist` and,
110
+ through the default error handler, answers `404 TEMPLATE_NOT_FOUND` with the
111
+ error's code, message, status and request id. `GET /documents/:id/preview`
112
+ now goes through `render_preview`. `GET /documents/:id` still answers a
113
+ `302` to the presigned PDF URL.
114
+ - Example app: `GET /render/stream` sets its PDF headers when the first chunk
115
+ arrives, after the render and the start of the download, so a render error
116
+ or a network error before that answers through the default error handler
117
+ (JSON, its own status) instead of a `200 application/pdf`.
118
+ - The integration spec runs only with a `pp_sandbox_` key.
119
+ - CI: the live API job runs on `main` (and on demand) against the production API with the sandbox key from the `POLI_PAGE_API_KEY` secret, and skips with a notice when the key is absent or not a `pp_sandbox_` key.
120
+ - The live API CI job passes the optional `POLI_PAGE_BASE_URL` secret to the integration spec (unset: the SDK default), next to the `pp_sandbox_` key in `POLI_PAGE_API_KEY`. The spec sets `config.base_url` from `POLI_PAGE_BASE_URL` when it is set.
121
+
122
+ ### Removed
123
+
124
+ - Example app: `GET /api/render/preview` (`render.preview`) and its "HTML
125
+ preview" button; the SDK no longer has `render.preview`.
126
+ - Example app: `DocumentsController`'s own `rescue_from PoliPage::Error`; the
127
+ default error handler answers instead.
128
+
129
+ ### Fixed
130
+
131
+ - `render_pdf` / `FilenameEncoder.disposition`: control characters (CR/LF, TAB,
132
+ DEL, C1) are now stripped from the `Content-Disposition` filename before
133
+ `ActionDispatch::Http::ContentDisposition` formats it, instead of surviving as
134
+ `%0D%0A`-style escapes that clients decode back into the saved filename.
135
+ - Example app: the demo page's status row no longer shows the first 8
136
+ characters of `POLI_PAGE_API_KEY`; no part of the key is shown.
137
+
138
+ [Unreleased]: https://github.com/poli-page/rubyonrails/compare/v0.13.0...HEAD
139
+ [0.13.0]: https://github.com/poli-page/rubyonrails/releases/tag/v0.13.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Poli Page
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # poli_page-rails
2
+
3
+ > Render Poli Page documents from Rails controllers.
4
+
5
+ ## About
6
+
7
+ This gem wires the official [`poli-page`](https://rubygems.org/gems/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.
8
+
9
+ **When to use this:**
10
+
11
+ - 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.
12
+ - You want SDK retry and error events on the standard `ActiveSupport::Notifications` bus.
13
+ - You want a generator that writes a fully commented `config/initializers/poli_page.rb`.
14
+
15
+ **When not to:**
16
+
17
+ - You are outside Rails — use the [`poli-page`](https://rubygems.org/gems/poli-page) SDK directly.
18
+ - You need to reimplement HTTP transport, retry policy, idempotency, or error mapping. Those live in the SDK; this gem deliberately does not duplicate them.
19
+
20
+ ## Requirements
21
+
22
+ - Ruby 3.2 or newer
23
+ - Rails 7.0, 7.1, 7.2, or 8.0
24
+ - `poli-page` SDK 0.13.x (`~> 0.13.0`); Bundler installs it with the gem
25
+
26
+ ## Install
27
+
28
+ Add the gem to your `Gemfile`:
29
+
30
+ ```ruby
31
+ # Gemfile
32
+ gem "poli_page-rails", "~> 0.13.0"
33
+ ```
34
+
35
+ Then run:
36
+
37
+ ```ruby
38
+ # shell
39
+ bundle install
40
+ bin/rails generate poli_page:install
41
+ ```
42
+
43
+ The generator writes `config/initializers/poli_page.rb` with a commented template. Set `POLI_PAGE_API_KEY` in your environment, then restart the app.
44
+
45
+ ### API keys
46
+
47
+ 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.
48
+
49
+ | Environment | Key | Watermark | Counted |
50
+ | ------------------------------------- | -------------- | --------- | ------- |
51
+ | `sandbox` (every org) | `pp_sandbox_…` | yes | no |
52
+ | `live` (every org, deletable on Pro+) | `pp_live_…` | no | yes |
53
+ | created on Pro+ (e.g. `staging`) | `pp_staging_…` | no | yes |
54
+
55
+ "Counted" means the render counts toward the organization's monthly PDF quota.
56
+
57
+ 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_`).
58
+
59
+ ## Quick start
60
+
61
+ Return a generated PDF from any controller action.
62
+
63
+ ```ruby
64
+ # app/controllers/invoices_controller.rb
65
+ class InvoicesController < ApplicationController
66
+ include PoliPage::Rails::Renderable
67
+
68
+ def show
69
+ invoice = Invoice.find(params[:id])
70
+ bytes = PoliPage.client.render.pdf(
71
+ project: "billing",
72
+ template: "invoice",
73
+ data: invoice.to_template_data
74
+ )
75
+ render_pdf(bytes, filename: "#{invoice.number}.pdf", inline: false)
76
+ end
77
+ end
78
+ ```
79
+
80
+ `render_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.
81
+
82
+ ## Configuration
83
+
84
+ Every key is optional except `api_key`. The SDK's own defaults apply when a value is left `nil`.
85
+
86
+ | Option | Default | Description |
87
+ |---|---|---|
88
+ | `api_key` | — | Required. A key for one environment: `pp_sandbox_…`, `pp_live_…`, or `pp_{environment}_…` (see [API keys](#api-keys)). |
89
+ | `base_url` | `https://api.poli.page` | Override for the develop environment or a private proxy. |
90
+ | `timeout` | SDK default | Request timeout in seconds. Must be in `1..600`. |
91
+ | `max_retries` | SDK default | Retry attempts on retryable failures. Must be in `0..10`. |
92
+ | `retry_delay` | SDK default | Base backoff delay in seconds. Must be in `0..30`. |
93
+ | `logger` | `Rails.logger` | Any `Logger`-compatible instance. |
94
+ | `proxy` | `nil` | HTTP proxy URL. Honoured by `Net::HTTP` via the SDK. |
95
+ | `ca_file` | `nil` | Path to a CA bundle for corporate egress. |
96
+ | `ca_path` | `nil` | Path to a CA directory. |
97
+ | `notifications` | `true` | Set to `false` to disable the `ActiveSupport::Notifications` bridge. |
98
+ | `on_retry` | bridge | Custom callable. Setting it replaces the notifications bridge. |
99
+ | `on_error` | bridge | Custom callable. Setting it replaces the notifications bridge. |
100
+ | `error_handler` | `true` | Set to `false` to leave out the default error handler (see [Default error handler](#default-error-handler)). |
101
+
102
+ A minimal initializer:
103
+
104
+ ```ruby
105
+ # config/initializers/poli_page.rb
106
+ Rails.application.config.poli_page.tap do |c|
107
+ c.api_key = ENV.fetch("POLI_PAGE_API_KEY")
108
+ end
109
+ ```
110
+
111
+ Configuration 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.
112
+
113
+ ## API at a glance
114
+
115
+ | Symbol | Purpose |
116
+ |---|---|
117
+ | `PoliPage.client` | Lazy-memoised, thread-safe SDK `PoliPage::Client`. |
118
+ | `PoliPage::Rails::Renderable` | Controller concern exposing `render_pdf`, `render_preview`, `redirect_to_document`. |
119
+ | `PoliPage::Rails::Configuration` | The object behind `Rails.application.config.poli_page`. |
120
+ | `PoliPage::Rails::Engine` | Rails engine subclass auto-loaded from your `Gemfile`. |
121
+ | `PoliPage::Rails::ErrorHandler` | Controller concern that answers a `PoliPage::Error` with JSON; included by default. |
122
+ | `PoliPage::Rails::Railtie` | Installs the configuration, the logger default, the notifications bridge, and the error handler. |
123
+ | `"poli_page.retry"` | `ActiveSupport::Notifications` event fired before each SDK retry. |
124
+ | `"poli_page.error"` | `ActiveSupport::Notifications` event fired on terminal SDK failure. |
125
+ | `rails generate poli_page:install` | Writes `config/initializers/poli_page.rb`. |
126
+
127
+ Full reference: [docs/api.md](docs/api.md) (forthcoming).
128
+
129
+ ## Errors
130
+
131
+ The SDK raises a fixed hierarchy under `PoliPage::Error`. The four categories you typically branch on:
132
+
133
+ - **Auth** — `PoliPage::AuthenticationError`, `PoliPage::PermissionDeniedError`. Bad or revoked key, blocked organization.
134
+ - **Rate limit** — `PoliPage::RateLimitError`. The API returned `429`; honour `Retry-After`.
135
+ - **Request rejected** — `PoliPage::ValidationError`. The template, project, or `data` payload was rejected at `400`.
136
+ - **Network / transport** — `PoliPage::ConnectionError`, `PoliPage::TimeoutError`. Network failure, DNS, TLS, or timeout.
137
+
138
+ 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.
139
+
140
+ A `PoliPage::Error` you do not rescue reaches the [default error handler](#default-error-handler).
141
+
142
+ 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.
143
+
144
+ ```ruby
145
+ # app/controllers/invoices_controller.rb
146
+ def show
147
+ bytes = PoliPage.client.render.pdf(project: "billing", template: "invoice", data: invoice_data)
148
+ render_pdf(bytes, filename: "invoice.pdf")
149
+ rescue PoliPage::ValidationError => e
150
+ Rails.logger.warn("PoliPage validation failed: #{e.message}")
151
+ head :unprocessable_entity
152
+ rescue PoliPage::RateLimitError
153
+ head :too_many_requests
154
+ rescue PoliPage::AuthenticationError, PoliPage::PermissionDeniedError
155
+ head :forbidden
156
+ rescue PoliPage::ConnectionError, PoliPage::TimeoutError
157
+ head :bad_gateway
158
+ end
159
+ ```
160
+
161
+ ### Default error handler
162
+
163
+ 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:
164
+
165
+ ```json
166
+ { "code": "TEMPLATE_NOT_FOUND", "message": "Template 'does-not-exist' not found in project 'getting-started'.", "status": 404, "requestId": "req_…" }
167
+ ```
168
+
169
+ - The HTTP status is the payload's `status`: the API's status for an API error, `503` for `network_error`, `504` for `timeout`, `502` for `invalid_response` (the 2xx a proxy sent never reaches your client). When the status is unknown (for example `configuration_error` or `io_error`), the answer is `500` and the body's `status` is `null`.
170
+ - `requestId` is the API's `x-request-id`, or `null` when there is none.
171
+ - The `Content-Type` is `application/json`, even when the action had set another one (a PDF route, say) before the error.
172
+ - The keys are the SDK's, spelled the same by every Poli Page SDK: your app's inflections (an `ID` acronym, for example) do not change them.
173
+
174
+ 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.
175
+
176
+ So that an error before the first byte still gets the JSON answer, in a Live action:
177
+
178
+ - set the PDF headers and write the first chunk only after the SDK call has started;
179
+ - close the stream only once you have written to it (`ensure response.stream.close if started`). The usual unconditional `ensure response.stream.close` commits an empty `200` before the handler runs, and the error then ends as that empty `200`.
180
+
181
+ The example app's `StreamsController` does both.
182
+
183
+ 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:
184
+
185
+ ```ruby
186
+ # config/initializers/poli_page.rb
187
+ Rails.application.config.poli_page.tap do |c|
188
+ c.error_handler = false
189
+ end
190
+ ```
191
+
192
+ The value must be `true` or `false`; the app refuses to boot with anything else.
193
+
194
+ ## Example app
195
+
196
+ A runnable Rails 8 app lives in [`example-app/`](./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.
197
+
198
+ ```ruby
199
+ # shell
200
+ cd example-app
201
+ bundle install
202
+ export POLI_PAGE_API_KEY=pp_sandbox_... # a sandbox key
203
+ # export POLI_PAGE_BASE_URL=https://api.poli.page # optional
204
+ bin/rails server
205
+ ```
206
+
207
+ ## Going further
208
+
209
+ - ActiveSupport::Notifications — subscribe to `poli_page.retry` and `poli_page.error` for logging, metrics, and alerting (forthcoming `docs/notifications.md`).
210
+ - Filename encoding — how `PoliPage::Rails::FilenameEncoder` produces RFC 5987 `Content-Disposition` values for non-ASCII names (forthcoming `docs/filenames.md`).
211
+ - Background rendering — running renders through Active Job for slow templates (forthcoming `docs/active_job.md`).
212
+ - Testing helpers — stubbing `PoliPage.client` in request specs and system tests (forthcoming `docs/testing.md`).
213
+
214
+ ## Compatibility
215
+
216
+ | Gem | `poli-page` SDK | Rails | Ruby |
217
+ |---|---|---|---|
218
+ | 0.13.x | 0.13.x | 7.0 / 7.1 / 7.2 / 8.0 | 3.2 – 3.4 |
219
+
220
+ The gem shares its major.minor with the Poli Page API and requires the SDK of the same minor (see [RELEASING.md](RELEASING.md#versions)).
221
+
222
+ You receive fixes for the latest two Rails majors. Older majors receive security fixes for six months after their upstream EOL.
223
+
224
+ ## Contributing
225
+
226
+ Run the lint and the specs for one Rails version with Appraisal:
227
+
228
+ ```sh
229
+ bundle install
230
+ bundle exec appraisal generate
231
+ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle install
232
+ bundle exec rubocop
233
+ bundle exec appraisal rails-8.0 rspec --exclude-pattern "spec/integration/**/*"
234
+ ```
235
+
236
+ `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`.
237
+
238
+ ### Contract tests
239
+
240
+ `spec/contract/impl_contract_spec.rb` runs the integration cases of the [Poli Page API contract](https://github.com/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:
241
+
242
+ ```sh
243
+ POLI_CONTRACT=<path to contract.json> bundle exec rspec spec/contract
244
+ ```
245
+
246
+ Without `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`.
247
+
248
+ ### Releasing
249
+
250
+ Releases are published by pushing a `vX.Y.Z` tag; see [RELEASING.md](RELEASING.md).
251
+
252
+ ## License
253
+
254
+ Released under the MIT License — see [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/base"
5
+
6
+ module PoliPage
7
+ module Generators
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Writes config/initializers/poli_page.rb with a commented template."
12
+
13
+ # NOTE: Zero custom options on this generator. Rails::Generators::Base
14
+ # inherits Thor-reserved flags (--force, --pretend, --quiet, --skip,
15
+ # --skip-namespace, --skip-collision-check). Defining a custom option
16
+ # with any of those names would silently shadow them and make the
17
+ # generator appear broken.
18
+
19
+ argument :initializer_path, type: :string, required: false,
20
+ default: "config/initializers/poli_page.rb",
21
+ banner: "PATH"
22
+
23
+ def copy_initializer
24
+ template "poli_page.rb.tt", initializer_path
25
+ end
26
+
27
+ def show_post_install_hint
28
+ say ""
29
+ say "Created #{initializer_path}.", :green
30
+ say "Set POLI_PAGE_API_KEY in your environment, then restart the app.", :green
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configuration for the poli_page-rails gem (https://poli.page).
4
+ #
5
+ # Set POLI_PAGE_API_KEY in your environment, then customise below as needed.
6
+ # Get a key at https://app.poli.page/settings/api-keys.
7
+
8
+ Rails.application.config.poli_page.tap do |c|
9
+ c.api_key = ENV.fetch("POLI_PAGE_API_KEY")
10
+
11
+ # Optional — defaults to https://api.poli.page (the SDK applies its default
12
+ # when this is nil).
13
+ # c.base_url = ENV["POLI_PAGE_BASE_URL"]
14
+
15
+ # Optional — request timeout in seconds (Float). SDK default applies when nil.
16
+ # c.timeout = 30.0
17
+
18
+ # Optional — retry policy. SDK defaults apply when nil.
19
+ # c.max_retries = 3
20
+ # c.retry_delay = 0.5
21
+
22
+ # Optional — pass any Logger-compatible instance. Defaults to Rails.logger.
23
+ # c.logger = Rails.logger
24
+
25
+ # Optional — corporate-egress controls. Honoured by Net::HTTP via the SDK.
26
+ # c.proxy = ENV["POLI_PAGE_HTTP_PROXY"]
27
+ # c.ca_file = ENV["POLI_PAGE_CA_FILE"]
28
+ # c.ca_path = ENV["POLI_PAGE_CA_PATH"]
29
+
30
+ # Optional — set to false to disable the ActiveSupport::Notifications bridge.
31
+ # The bridge is default-on; it costs nothing when no subscribers attach.
32
+ # c.notifications = false
33
+
34
+ # Optional — set to false to remove the default error handler. It answers
35
+ # every PoliPage::Error a controller raises with JSON
36
+ # { code, message, status, requestId } (the SDK's to_payload) and the
37
+ # payload's status, or 500 when the status is unknown.
38
+ # c.error_handler = false
39
+
40
+ # Optional — custom retry/error callables. Setting these REPLACES the
41
+ # ActiveSupport::Notifications bridge entirely (you opt out of the default
42
+ # bridge by providing your own callable). To preserve the bridge AND add
43
+ # custom behaviour, subscribe to AS::Notifications instead:
44
+ #
45
+ # ActiveSupport::Notifications.subscribe("poli_page.retry") do |*args|
46
+ # event = ActiveSupport::Notifications::Event.new(*args)
47
+ # Rails.logger.warn("PoliPage retry: #{event.payload}")
48
+ # end
49
+ #
50
+ # c.on_retry = ->(event) { Rails.logger.warn("retry: #{event.attempt}") }
51
+ # c.on_error = ->(err) { Sentry.capture_exception(err) }
52
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "poli_page"
5
+
6
+ require_relative "configuration_validator"
7
+
8
+ module PoliPage
9
+ # Re-opened from the SDK. Adds the Rails-friendly lazy accessor.
10
+ class << self
11
+ def client
12
+ Rails::Client.instance
13
+ end
14
+
15
+ # Test-only: clear the memoised instance so the next #client call rebuilds.
16
+ # Marked private API; do NOT use in application code.
17
+ def reset_client!
18
+ Rails::Client.reset!
19
+ end
20
+ end
21
+
22
+ module Rails
23
+ # Holds the lazy memoised PoliPage::Client. Thread-safe via a Monitor.
24
+ # The SDK documents PoliPage::Client as thread-safe, so memoising a single
25
+ # instance and sharing it across threads is correct.
26
+ module Client
27
+ LOCK = Monitor.new
28
+ private_constant :LOCK
29
+
30
+ class << self
31
+ def instance
32
+ LOCK.synchronize do
33
+ @instance ||= build!
34
+ end
35
+ end
36
+
37
+ def reset!
38
+ LOCK.synchronize do
39
+ @instance = nil
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def build!
46
+ config = ::Rails.application.config.poli_page
47
+ ConfigurationValidator.validate!(config)
48
+ PoliPage::Client.new(**config.to_client_kwargs)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoliPage
4
+ module Rails
5
+ class Configuration
6
+ attr_accessor :api_key, :base_url, :timeout, :user_agent,
7
+ :max_retries, :retry_delay,
8
+ :logger, :on_retry, :on_error,
9
+ :proxy, :ca_file, :ca_path,
10
+ :notifications, :error_handler
11
+
12
+ def initialize
13
+ @api_key = nil
14
+ @base_url = nil
15
+ @timeout = nil
16
+ @user_agent = nil
17
+ @max_retries = nil
18
+ @retry_delay = nil
19
+ @logger = nil
20
+ @on_retry = nil
21
+ @on_error = nil
22
+ @proxy = nil
23
+ @ca_file = nil
24
+ @ca_path = nil
25
+ @notifications = true
26
+ @error_handler = true
27
+ end
28
+
29
+ # Single source of truth for SDK kwargs. Compact drops every nil entry,
30
+ # so the SDK's own defaults take over for every unset key. user_agent
31
+ # is intentionally NOT included — PoliPage::Client#initialize does not
32
+ # accept it (poli-page 0.11).
33
+ def to_client_kwargs
34
+ {
35
+ api_key: api_key,
36
+ base_url: base_url,
37
+ max_retries: max_retries,
38
+ retry_delay: retry_delay,
39
+ timeout: timeout,
40
+ logger: logger,
41
+ on_retry: on_retry,
42
+ on_error: on_error,
43
+ proxy: proxy,
44
+ ca_file: ca_file,
45
+ ca_path: ca_path
46
+ }.compact
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require_relative "errors"
6
+
7
+ module PoliPage
8
+ module Rails
9
+ module ConfigurationValidator
10
+ module_function
11
+
12
+ # pp_{environment}_{32 lowercase hex}. Only drives a startup warning: the
13
+ # API is the authority on keys, so a mismatch never blocks the client.
14
+ API_KEY_FORMAT = /\App_(?=[a-z][a-z0-9-]{0,19}_)[a-z][a-z0-9]*(?:-[a-z0-9]+)*_[0-9a-f]{32}\z/
15
+
16
+ def validate!(config)
17
+ validate_api_key!(config.api_key, config.logger)
18
+ validate_base_url!(config.base_url)
19
+ validate_timeout!(config.timeout)
20
+ validate_max_retries!(config.max_retries)
21
+ validate_retry_delay!(config.retry_delay)
22
+ validate_notifications!(config.notifications)
23
+ validate_callable!(config.on_retry, "on_retry")
24
+ validate_callable!(config.on_error, "on_error")
25
+ end
26
+
27
+ def validate_api_key!(value, logger)
28
+ unless value.is_a?(String) && !value.empty?
29
+ raise ConfigurationError, "Poli Page api_key is required (a non-empty String). " \
30
+ "Get one at https://app.poli.page/settings/api-keys."
31
+ end
32
+ return if API_KEY_FORMAT.match?(value)
33
+
34
+ # No part of the key reaches the log: even a well-formed pp_{environment}_
35
+ # can be a pasted secret fragment.
36
+ logger&.warn("Poli Page api_key does not match the expected format " \
37
+ "pp_{environment}_{32 lowercase hex characters}; starting anyway, " \
38
+ "the API will reject it if it is not a valid key.")
39
+ end
40
+
41
+ def validate_base_url!(value)
42
+ return if value.nil?
43
+
44
+ uri = URI.parse(value.to_s)
45
+ return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
46
+
47
+ raise ConfigurationError, "Poli Page base_url must be an http or https URL (got #{value.inspect})."
48
+ rescue URI::InvalidURIError
49
+ raise ConfigurationError, "Poli Page base_url must be an http or https URL (got #{value.inspect})."
50
+ end
51
+
52
+ def validate_timeout!(value)
53
+ return if value.nil?
54
+
55
+ unless value.is_a?(Numeric) && value.positive?
56
+ raise ConfigurationError, "Poli Page timeout must be a positive number (got #{value.inspect})."
57
+ end
58
+
59
+ return if value <= 600
60
+
61
+ raise ConfigurationError, "Poli Page timeout must be <= 600 seconds (got #{value.inspect})."
62
+ end
63
+
64
+ def validate_max_retries!(value)
65
+ return if value.nil?
66
+ return if value.is_a?(Integer) && (0..10).cover?(value)
67
+
68
+ raise ConfigurationError,
69
+ "Poli Page max_retries must be an integer between 0 and 10 (got #{value.inspect})."
70
+ end
71
+
72
+ def validate_retry_delay!(value)
73
+ return if value.nil?
74
+ return if value.is_a?(Numeric) && (0..30).cover?(value)
75
+
76
+ raise ConfigurationError, "Poli Page retry_delay must be a number between 0 and 30 seconds " \
77
+ "(got #{value.inspect})."
78
+ end
79
+
80
+ def validate_notifications!(value)
81
+ return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
82
+
83
+ raise ConfigurationError, "Poli Page notifications must be true or false (got #{value.inspect})."
84
+ end
85
+
86
+ # Checked at boot by the Railtie, when it decides whether to install the
87
+ # error handler (not in validate!, which runs when the client is built).
88
+ def validate_error_handler!(value)
89
+ return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
90
+
91
+ raise ConfigurationError, "Poli Page error_handler must be true or false (got #{value.inspect})."
92
+ end
93
+
94
+ def validate_callable!(value, name)
95
+ return if value.nil?
96
+ return if value.respond_to?(:call) && !value.is_a?(String) && !value.is_a?(Symbol)
97
+
98
+ raise ConfigurationError, "Poli Page #{name} must respond to #call (got #{value.class})."
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module PoliPage
6
+ module Rails
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace PoliPage::Rails
9
+
10
+ # config.poli_page is wired by the Railtie (Task 5). This Engine class
11
+ # exists primarily so future routes / generators / assets can ship as
12
+ # part of v0.2 without re-architecting.
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "active_support/concern"
5
+
6
+ module PoliPage
7
+ module Rails
8
+ # Turns every PoliPage::Error a controller action raises into a JSON answer:
9
+ #
10
+ # status: the payload's status, or 500 when it is nil
11
+ # body: { "code", "message", "status", "requestId" }
12
+ #
13
+ # The body is the SDK's Error#to_payload as is: the SDK names its keys
14
+ # code, message, status and requestId, the same in every Poli Page SDK.
15
+ # The Content-Type is application/json even when the action had set
16
+ # another one before the error (a PDF route, for example). Its status is
17
+ # the API status, 503 for network_error, 504 for timeout, 502 for
18
+ # invalid_response, and null when unknown (io_error, for example); the
19
+ # HTTP status falls back to 500 only in that last case.
20
+ #
21
+ # The Railtie includes this concern into ActionController::Base and
22
+ # ActionController::API unless the app sets
23
+ # `config.poli_page.error_handler = false`. A rescue_from declared later in
24
+ # a controller (for PoliPage::Error, a subclass, or StandardError) takes
25
+ # precedence over this one, as usual in Rails.
26
+ module ErrorHandler
27
+ extend ::ActiveSupport::Concern
28
+
29
+ included do
30
+ rescue_from ::PoliPage::Error, with: :render_poli_page_error
31
+ end
32
+
33
+ # The JSON body for `error`: its to_payload, forwarded as is. JSON.generate
34
+ # writes the Symbol keys as they are spelled, so the host app's
35
+ # inflections (an "ID" acronym, say) cannot change them.
36
+ def self.body(error)
37
+ error.to_payload
38
+ end
39
+
40
+ # The HTTP status for `error`: the payload's status, or 500 when unknown.
41
+ def self.status(error)
42
+ error.to_payload[:status] || 500
43
+ end
44
+
45
+ private
46
+
47
+ # A response already committed (an ActionController::Live stream that
48
+ # sent its first chunk) cannot change its status or body: the error is
49
+ # raised again so Live logs it and calls the stream's on_error, instead
50
+ # of being reported as an answer that was never sent.
51
+ #
52
+ # Why JSON.generate: the body holds only strings, integers and nil, and
53
+ # it keeps the answer independent of ActiveSupport's JSON encoder (whose
54
+ # Rails 7.0, 7.1 and 8.0 releases pass an option the json 3 gem refuses).
55
+ def render_poli_page_error(error)
56
+ raise error if response.committed?
57
+
58
+ render json: JSON.generate(ErrorHandler.body(error)),
59
+ status: ErrorHandler.status(error),
60
+ content_type: "application/json"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "poli_page/errors"
4
+
5
+ module PoliPage
6
+ module Rails
7
+ # Raised by ConfigurationValidator when Rails.application.config.poli_page
8
+ # contains an invalid value. Inherits from PoliPage::Error so existing
9
+ # `rescue PoliPage::Error` clauses catch it.
10
+ class ConfigurationError < PoliPage::Error
11
+ def initialize(message)
12
+ super(message, code: "configuration_error", status: nil, request_id: nil)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch/http/content_disposition"
4
+
5
+ module PoliPage
6
+ module Rails
7
+ # Builds a Content-Disposition header value with RFC 5987 / RFC 6266
8
+ # encoding for non-ASCII filenames. Thin wrapper over Rails's own
9
+ # ActionDispatch::Http::ContentDisposition.format — what ActiveStorage
10
+ # uses to set the same header for downloaded blobs.
11
+ #
12
+ # Why a wrapper at all: single seam if Rails changes the formatting API
13
+ # across versions (stable since Rails 5.2; one place to patch if it
14
+ # changes).
15
+ #
16
+ # Control characters are stripped before formatting: ContentDisposition
17
+ # percent-escapes them (%0D%0A), which keeps the header intact but hands
18
+ # CR/LF/TAB back to any client that decodes filename*.
19
+ module FilenameEncoder
20
+ # C0 controls (incl. TAB, CR, LF), DEL and C1 controls.
21
+ CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/
22
+
23
+ module_function
24
+
25
+ def disposition(filename, inline:)
26
+ disposition_type = inline ? "inline" : "attachment"
27
+ ::ActionDispatch::Http::ContentDisposition.format(
28
+ disposition: disposition_type,
29
+ filename: filename.gsub(CONTROL_CHARS, "")
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module PoliPage
6
+ module Rails
7
+ # Bridges the SDK's on_retry / on_error Closure hooks into Rails-idiomatic
8
+ # ActiveSupport::Notifications events.
9
+ #
10
+ # Event names follow Rails conventions (sql.active_record, deliver.action_mailer):
11
+ # - "poli_page.retry" — fired before each retry attempt
12
+ # - "poli_page.error" — fired on terminal failure (post-retries)
13
+ #
14
+ # Default-on: the Railtie installs the bridges as the default on_retry /
15
+ # on_error config values, so retries and failures reach the usual Rails
16
+ # instrumentation subscribers without extra wiring. Users opt out via
17
+ # c.notifications = false in the initializer.
18
+ module Notifications
19
+ RETRY_EVENT_NAME = "poli_page.retry"
20
+ ERROR_EVENT_NAME = "poli_page.error"
21
+
22
+ def self.retry_bridge
23
+ @retry_bridge ||= lambda do |event|
24
+ ::ActiveSupport::Notifications.instrument(
25
+ RETRY_EVENT_NAME,
26
+ attempt: event.attempt,
27
+ delay_ms: event.delay_ms,
28
+ reason: event.reason
29
+ )
30
+ end
31
+ end
32
+
33
+ def self.error_bridge
34
+ @error_bridge ||= lambda do |error|
35
+ ::ActiveSupport::Notifications.instrument(
36
+ ERROR_EVENT_NAME,
37
+ error: error
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ require_relative "configuration_validator"
6
+ require_relative "error_handler"
7
+ require_relative "notifications"
8
+
9
+ module PoliPage
10
+ module Rails
11
+ class Railtie < ::Rails::Railtie
12
+ initializer "poli_page.configure", before: :load_config_initializers do |app|
13
+ # Why: Rails::Application::Configuration#method_missing raises
14
+ # NoMethodError on read when the key has never been set, so the
15
+ # idiomatic `||=` expansion (which reads first) blows up at boot.
16
+ # Assign unconditionally — user code in config/initializers/poli_page.rb
17
+ # runs AFTER this (we use before: :load_config_initializers) and
18
+ # mutates the existing Configuration object, it never reassigns.
19
+ app.config.poli_page = Configuration.new
20
+ end
21
+
22
+ # Runs after config/initializers/*.rb, so `config.poli_page.error_handler
23
+ # = false` there turns it off. The on_load hook includes the concern into
24
+ # ActionController::Base and ActionController::API when they load, before
25
+ # any application controller inherits from them.
26
+ initializer "poli_page.error_handler", after: :load_config_initializers do |app|
27
+ enabled = app.config.poli_page.error_handler
28
+ ConfigurationValidator.validate_error_handler!(enabled)
29
+ next unless enabled
30
+
31
+ ::ActiveSupport.on_load(:action_controller) do
32
+ include PoliPage::Rails::ErrorHandler
33
+ end
34
+ end
35
+
36
+ config.after_initialize do |app|
37
+ c = app.config.poli_page
38
+ c.logger ||= ::Rails.logger
39
+
40
+ if c.notifications
41
+ c.on_retry ||= PoliPage::Rails::Notifications.retry_bridge
42
+ c.on_error ||= PoliPage::Rails::Notifications.error_bridge
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ require_relative "filename_encoder"
6
+
7
+ module PoliPage
8
+ module Rails
9
+ module Renderable
10
+ extend ::ActiveSupport::Concern
11
+
12
+ # Sends raw PDF bytes with PDF headers + RFC 5987 Content-Disposition.
13
+ #
14
+ # @param bytes [String] raw PDF bytes (binary-encoded)
15
+ # @param filename [String] used in Content-Disposition; ASCII and
16
+ # non-ASCII both handled per RFC 5987 / RFC 6266
17
+ # @param inline [Boolean] inline (browser viewer) vs attachment (download)
18
+ def render_pdf(bytes, filename: "document.pdf", inline: false)
19
+ send_data(
20
+ bytes,
21
+ type: "application/pdf",
22
+ disposition: inline ? "inline" : "attachment",
23
+ filename: filename
24
+ )
25
+ # Why: send_data sets Content-Disposition using Rails's plain ASCII
26
+ # formatting. Overwrite with our RFC 5987 encoder so non-ASCII filenames
27
+ # survive. Cache-Control + nosniff are independent additions.
28
+ response.headers["Content-Disposition"] = FilenameEncoder.disposition(filename, inline: inline)
29
+ response.headers["Cache-Control"] = "private, no-store"
30
+ response.headers["X-Content-Type-Options"] = "nosniff"
31
+ end
32
+
33
+ # Renders the .html attribute of a DocumentPreviewResult (documents.preview).
34
+ # The HTML is a complete document from the Poli Page render pipeline; use
35
+ # render body: to skip template processing and layouts entirely — no
36
+ # html_safe needed (Rails only escapes inside templates, not for body:).
37
+ def render_preview(result)
38
+ html = result.respond_to?(:html) ? result.html : result.to_s
39
+ response.headers["Cache-Control"] = "private, no-store"
40
+ response.headers["X-Content-Type-Options"] = "nosniff"
41
+ render body: html, content_type: "text/html; charset=utf-8"
42
+ end
43
+
44
+ # 302 to the descriptor's presigned URL. Adds Cache-Control so the
45
+ # redirect itself never gets intermediary-cached.
46
+ def redirect_to_document(descriptor, status: :found)
47
+ response.headers["Cache-Control"] = "private, no-store"
48
+ redirect_to descriptor.presigned_pdf_url, status: status, allow_other_host: true
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PoliPage
4
+ module Rails
5
+ VERSION = "0.13.0"
6
+ end
7
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "poli_page"
4
+ require_relative "poli_page/rails/version"
5
+ require_relative "poli_page/rails/errors"
6
+ require_relative "poli_page/rails/configuration"
7
+ require_relative "poli_page/rails/configuration_validator"
8
+ require_relative "poli_page/rails/client"
9
+ require_relative "poli_page/rails/filename_encoder"
10
+ require_relative "poli_page/rails/renderable"
11
+ require_relative "poli_page/rails/error_handler"
12
+ require_relative "poli_page/rails/notifications"
13
+ require_relative "poli_page/rails/engine"
14
+ require_relative "poli_page/rails/railtie"
@@ -0,0 +1,11 @@
1
+ module PoliPage
2
+ def self.client: () -> PoliPage::Client
3
+ def self.reset_client!: () -> void
4
+
5
+ module Rails
6
+ module Client
7
+ def self.instance: () -> PoliPage::Client
8
+ def self.reset!: () -> void
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ module PoliPage
2
+ module Rails
3
+ class Configuration
4
+ attr_accessor api_key: String?
5
+ attr_accessor base_url: String?
6
+ attr_accessor timeout: Numeric?
7
+ attr_accessor user_agent: String?
8
+ attr_accessor max_retries: Integer?
9
+ attr_accessor retry_delay: Numeric?
10
+ attr_accessor logger: untyped
11
+ attr_accessor on_retry: (^(untyped) -> void)?
12
+ attr_accessor on_error: (^(untyped) -> void)?
13
+ attr_accessor proxy: String?
14
+ attr_accessor ca_file: String?
15
+ attr_accessor ca_path: String?
16
+ attr_accessor notifications: bool
17
+ attr_accessor error_handler: bool
18
+
19
+ def initialize: () -> void
20
+ def to_client_kwargs: () -> Hash[Symbol, untyped]
21
+ end
22
+
23
+ module ConfigurationValidator
24
+ API_KEY_FORMAT: Regexp
25
+
26
+ def self.validate!: (Configuration) -> void
27
+ def self.validate_api_key!: (untyped, untyped logger) -> void
28
+ def self.validate_base_url!: (untyped) -> void
29
+ def self.validate_timeout!: (untyped) -> void
30
+ def self.validate_max_retries!: (untyped) -> void
31
+ def self.validate_retry_delay!: (untyped) -> void
32
+ def self.validate_notifications!: (untyped) -> void
33
+ def self.validate_error_handler!: (untyped) -> void
34
+ def self.validate_callable!: (untyped, String) -> void
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,6 @@
1
+ module PoliPage
2
+ module Rails
3
+ class Engine < ::Rails::Engine
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,14 @@
1
+ module PoliPage
2
+ module Rails
3
+ module ErrorHandler
4
+ extend ActiveSupport::Concern
5
+
6
+ def self.body: (PoliPage::Error error) -> { code: String, message: String, status: Integer?, requestId: String? }
7
+ def self.status: (PoliPage::Error error) -> Integer
8
+
9
+ private
10
+
11
+ def render_poli_page_error: (PoliPage::Error error) -> void
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ module PoliPage
2
+ module Rails
3
+ class ConfigurationError < PoliPage::Error
4
+ def initialize: (String message) -> void
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module PoliPage
2
+ module Rails
3
+ module FilenameEncoder
4
+ CONTROL_CHARS: Regexp
5
+
6
+ def self.disposition: (String filename, inline: bool) -> String
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ module PoliPage
2
+ module Rails
3
+ module Notifications
4
+ RETRY_EVENT_NAME: String
5
+ ERROR_EVENT_NAME: String
6
+
7
+ def self.retry_bridge: () -> ^(untyped) -> void
8
+ def self.error_bridge: () -> ^(untyped) -> void
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ module PoliPage
2
+ module Rails
3
+ class Railtie < ::Rails::Railtie
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,11 @@
1
+ module PoliPage
2
+ module Rails
3
+ module Renderable
4
+ extend ActiveSupport::Concern
5
+
6
+ def render_pdf: (String bytes, ?filename: String, ?inline: bool) -> void
7
+ def render_preview: (untyped result) -> void
8
+ def redirect_to_document: (untyped descriptor, ?status: Symbol | Integer) -> void
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module PoliPage
2
+ module Rails
3
+ VERSION: String
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: poli_page-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.13.0
5
+ platform: ruby
6
+ authors:
7
+ - Poli Page
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: poli-page
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.13.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.13.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '9'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '7.0'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '9'
46
+ - !ruby/object:Gem::Dependency
47
+ name: actionpack
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '7.0'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '9'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '7.0'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '9'
66
+ - !ruby/object:Gem::Dependency
67
+ name: activesupport
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '7.0'
73
+ - - "<"
74
+ - !ruby/object:Gem::Version
75
+ version: '9'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '7.0'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: '9'
86
+ description: Rails engine + controller concern + ActiveSupport::Notifications bridge
87
+ over the official poli-page Ruby SDK. Lazy-memoised PoliPage.client, RFC 5987 Content-Disposition
88
+ handling, opt-out notifications instrumentation, and a rails generate poli_page:install
89
+ generator that writes config/initializers/poli_page.rb.
90
+ email: support@poli.page
91
+ executables: []
92
+ extensions: []
93
+ extra_rdoc_files: []
94
+ files:
95
+ - CHANGELOG.md
96
+ - LICENSE
97
+ - README.md
98
+ - lib/generators/poli_page/install/install_generator.rb
99
+ - lib/generators/poli_page/install/templates/poli_page.rb.tt
100
+ - lib/poli_page-rails.rb
101
+ - lib/poli_page/rails/client.rb
102
+ - lib/poli_page/rails/configuration.rb
103
+ - lib/poli_page/rails/configuration_validator.rb
104
+ - lib/poli_page/rails/engine.rb
105
+ - lib/poli_page/rails/error_handler.rb
106
+ - lib/poli_page/rails/errors.rb
107
+ - lib/poli_page/rails/filename_encoder.rb
108
+ - lib/poli_page/rails/notifications.rb
109
+ - lib/poli_page/rails/railtie.rb
110
+ - lib/poli_page/rails/renderable.rb
111
+ - lib/poli_page/rails/version.rb
112
+ - sig/poli_page/rails/client.rbs
113
+ - sig/poli_page/rails/configuration.rbs
114
+ - sig/poli_page/rails/engine.rbs
115
+ - sig/poli_page/rails/error_handler.rbs
116
+ - sig/poli_page/rails/errors.rbs
117
+ - sig/poli_page/rails/filename_encoder.rbs
118
+ - sig/poli_page/rails/notifications.rbs
119
+ - sig/poli_page/rails/railtie.rbs
120
+ - sig/poli_page/rails/renderable.rbs
121
+ - sig/poli_page/rails/version.rbs
122
+ homepage: https://poli.page
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ homepage_uri: https://poli.page
127
+ source_code_uri: https://github.com/poli-page/rubyonrails
128
+ changelog_uri: https://github.com/poli-page/rubyonrails/blob/main/CHANGELOG.md
129
+ bug_tracker_uri: https://github.com/poli-page/rubyonrails/issues
130
+ rubygems_mfa_required: 'true'
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '3.2'
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 3.6.7
146
+ specification_version: 4
147
+ summary: Rails engine for the Poli Page PDF rendering API
148
+ test_files: []