polipagegin

package module
v0.13.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 4, 2026 License: MIT Imports: 15 Imported by: 0

README

Poli Page for Gin

Render Poli Page documents as Gin HTTP responses.

About

This package wires the Poli Page Go SDK into Gin. You install a middleware that attaches a *polipage.Client to the gin context, return PDFs and HTML previews through typed response helpers that set the right headers, load configuration from environment variables, and get *polipage.Error answered as JSON by the error handler the middleware installs by default.

When to use this:

  • You want a single *polipage.Client shared by every handler, plumbed through *gin.Context rather than a package-level singleton.
  • You want PDF and HTML preview responses with the correct Content-Type, Cache-Control, X-Content-Type-Options, and RFC 5987 Content-Disposition headers without writing them by hand.
  • You want *polipage.Error mapped to JSON responses by the middleware instead of a switch block in every handler.

When not to:

  • You're not on Gin — install github.com/poli-page/sdk-go directly. The SDK is framework-agnostic and works fine inside net/http, Chi, Echo, Fiber, or anything else.
  • You need to reimplement transport, retries, or error mapping — that belongs in the SDK, not in a wrapper on top of it.

Requirements

  • Go 1.25+ (matches github.com/poli-page/sdk-go)
  • The Poli Page Go SDK github.com/poli-page/sdk-go v0.13.x (the module requires v0.13.0). This package shares its major.minor with the Poli Page API and the SDK: 0.13.x is built and tested against SDK 0.13.x. Go modules cannot cap a version, so keep the SDK on 0.13.x while you use 0.13.x of this package.
  • Gin v1.10 (the module requires v1.10.0)
  • A Poli Page API key from app.poli.page

Install

go get github.com/poli-page/gin@v0.13.0 github.com/poli-page/sdk-go@v0.13.0

Set your key in the environment:

POLI_PAGE_API_KEY=pp_sandbox_your_key_here

An API key is pp_{environment}_{32 lowercase hex}. It belongs to one environment of your organization, and that environment's published copy of the project is the one rendered:

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.

Verify everything is wired end-to-end with the bundled smoke-test binary:

go run github.com/poli-page/gin/cmd/polipage-render \
    --project=getting-started \
    --template=welcome \
    --data='{"name":"World"}' \
    -o welcome.pdf

Quick start

Install the middleware, then return a PDF from a handler.

// main.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/poli-page/sdk-go"
	"github.com/poli-page/sdk-go/option"
	polipagegin "github.com/poli-page/gin"
)

func main() {
	client := polipage.NewClient(
		option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")),
	)

	r := gin.Default()
	r.Use(polipagegin.Middleware(client))

	r.GET("/invoices/:id", invoiceHandler)

	log.Fatal(r.Run(":8080"))
}

func invoiceHandler(c *gin.Context) {
	id := c.Param("id")
	client := polipagegin.ClientFrom(c)

	pdf, err := client.Render.PDF(c.Request.Context(), polipage.RenderInput{
		Project:  "billing",
		Template: "invoice",
		Data:     map[string]any{"invoiceId": id},
	})
	if err != nil {
		_ = c.Error(err) // answered by the middleware's error handler
		return
	}

	polipagegin.PDF(c, pdf, polipagegin.PDFOptions{
		Filename: "invoice-" + id + ".pdf",
	})
	// → 200 application/pdf, Content-Disposition: attachment; filename="invoice-…"
	_ = http.StatusOK
}

Configuration

You either construct the SDK client yourself with option.With* and pass it to polipagegin.Middleware, or call polipagegin.FromEnv() to load everything from the environment.

Variable Default Description
POLI_PAGE_API_KEY required Your API key, pp_{environment}_… (see the key table above). A missing or empty value is an error. Any other value is accepted: one that does not match pp_{environment}_{32 lowercase hex} logs one log/slog warning (it names the expected format and shows no part of the key, not even its pp_{environment}_ part) and the API decides whether the key is valid (401 INVALID_API_KEY).
POLI_PAGE_BASE_URL SDK default Override the API origin.
POLI_PAGE_TIMEOUT SDK default Per-request timeout, parsed by time.ParseDuration (30s, 2m).
POLI_PAGE_MAX_RETRIES SDK default Integer in [0, 10].
POLI_PAGE_RETRY_DELAY SDK default Base retry delay, parsed by time.ParseDuration.
// main.go
cfg, err := polipagegin.FromEnv()
if err != nil {
	log.Fatal(err) // validation errors include the offending variable name
}
client := polipage.NewClient(cfg.Options()...)

cfg.Options() returns the []option.RequestOption slice you pass to polipage.NewClient — handy when you want to combine env-loaded options with programmatic ones like option.WithLogger.

API at a glance

Symbol Purpose
polipagegin.Middleware(client, opts...) gin.HandlerFunc that attaches the SDK client to the gin context and answers a queued *polipage.Error (the error handler, on by default).
polipagegin.WithoutErrorHandler() Middleware option that turns the error handler off.
polipagegin.ClientFrom(c) Returns the per-request *polipage.Client; panics with a clear message if the middleware was not installed.
polipagegin.PDF(c, bytes, opts) Writes a buffered PDF response with the right headers.
polipagegin.PDFStream(c, reader, opts) Streams an io.Reader (typically from Render.PDFStream) to the client.
polipagegin.Preview(c, html) Writes an HTML preview response (text/html; charset=utf-8, no-store, private).
polipagegin.DocumentRedirect(c, descriptor) 302 redirect to a DocumentDescriptor.PresignedPDFURL.
polipagegin.ErrorMiddleware() The error handler alone, for an app that turned it off in Middleware and installs it elsewhere.
polipagegin.WriteError(c, err) Per-handler version of the error handler for code paths that prefer explicit error rendering.
polipagegin.Config / polipagegin.FromEnv() Env-driven configuration loader.
cmd/polipage-render Smoke-test binary that renders a template end-to-end.

Full reference: pkg.go.dev/github.com/poli-page/gin.

Errors

The SDK returns a single *polipage.Error type with predicate methods rather than separate error types per category. The taxonomy you typically discriminate on:

  • Auth — err.IsAuthError() (HTTP 401/403, sentinels polipage.ErrUnauthorized / polipage.ErrForbidden).
  • Rate limit — err.IsRateLimitError() (HTTP 429, sentinel polipage.ErrRateLimit).
  • Validation — err.IsValidationError() (HTTP 400, sentinel polipage.ErrValidation). Template or data rejected.
  • Network / transport — err.IsNetworkError() (connection, DNS, TLS, or timeout).

Handle them inline:

// invoice_handler.go
import (
	"errors"

	"github.com/poli-page/sdk-go"
)

pdf, err := client.Render.PDF(c.Request.Context(), input)
if err != nil {
	var pe *polipage.Error
	if errors.As(err, &pe) {
		switch {
		case pe.IsAuthError():       c.AbortWithStatus(http.StatusUnauthorized)
		case pe.IsRateLimitError():  c.AbortWithStatus(http.StatusTooManyRequests)
		case pe.IsNetworkError():    c.AbortWithStatus(http.StatusBadGateway)
		default:                     c.AbortWithStatus(http.StatusInternalServerError)
		}
		return
	}
	c.AbortWithStatus(http.StatusInternalServerError)
	return
}

Or queue the error with c.Error(err) and let the error handler answer.

Error handler

polipagegin.Middleware(client) installs an error handler by default. When a handler queues a *polipage.Error with c.Error(err) and writes no response, the middleware answers with Content-Type: application/json and the SDK's err.ToPayload() as the body, unchanged:

{ "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, 503 for network_error, 504 for timeout, and 502 for invalid_response (a 2xx from the API whose body is not JSON, usually from a proxy).
  • An SDK error with no status (for example aborted, or io_error from polipage.RenderToFile) answers 500, and the body keeps "status": null. requestId is null when the API sent none.
  • code and message are the SDK's, verbatim.
  • Only *polipage.Error is handled (the last error on c.Errors); any other error is left for your own error handling, and a response the handler already wrote is never overwritten. Headers set for another answer (Content-Disposition, X-Accel-Buffering, Content-Length, a PDF Content-Type) are dropped from the JSON answer.

Turn it off with one option:

r.Use(polipagegin.Middleware(client, polipagegin.WithoutErrorHandler()))

polipagegin.ErrorMiddleware() is the same handler on its own, for when you turn it off in Middleware and want it somewhere else in the chain. polipagegin.WriteError(c, err) writes the same answer from inside a handler.

Example app

A runnable Gin application that exercises every SDK demo step lives in example-app/. It serves an interactive dashboard at http://127.0.0.1:8080/ with one button per SDK feature, inline <iframe> previews for PDFs, and pretty-printed JSON for stored-document operations. It reads POLI_PAGE_API_KEY (a pp_sandbox_… key) and, optionally, POLI_PAGE_BASE_URL, and renders the getting-started project's welcome template. It serves the demo routes at the root: GET /render/pdf answers the PDF, GET /documents/{id} answers a 302 redirect to the document's presigned PDF URL, GET /documents/{id}/preview, GET /documents/{id}/thumbnails and DELETE /documents/{id} call the matching SDK methods, and GET /errors/not-found renders the does-not-exist template and answers 404 with code TEMPLATE_NOT_FOUND through the default error handler. Use a fresh organization: one that renamed, deleted or removed getting-started from sandbox gets 404 PROJECT_NOT_FOUND or 404 PROJECT_NOT_IN_ENVIRONMENT. The app also reads a .env file from its working directory, without overriding variables already set.

cd example-app
go run .
# http://127.0.0.1:8080

Going further

  • docs/middleware.md — Middleware lifecycle, ClientFrom semantics, multiple-client patterns, request-scoped context propagation.
  • docs/responses.md — All four response helpers, their headers, and the RFC 5987 filename encoding.
  • docs/streaming.md — Stream multi-MB PDFs through Render.PDFStream and polipagegin.PDFStream.
  • docs/events.md — Wire option.WithOnRetry / option.WithOnError into your logger, metrics, or gin.Context for per-request audit logs.
  • docs/cli.md — Every polipage-render flag, exit codes, and CI recipes.
  • docs/testing.md — Stub the SDK client behind a domain interface so handler tests never touch the network.

Compatibility

Package Gin Go
v0.11.x v1.10 1.25+

go.mod requires Gin v1.10.0, so Go's minimal version selection never picks an older Gin for your build. CI also runs the suite against v1.9.1 by downgrading inside this repository.

The supported Go versions follow the upstream policy of "two latest stable releases".

Contributing

See CONTRIBUTING.md.

Contract tests

example-app/contract_test.go runs the integration cases (impl.*) of the Poli Page API contract against the example app, with its SDK pointed at a local server. CI runs them against the pinned contract version. Locally, from example-app/:

POLI_CONTRACT=<path to contract.json> go test ./...

Without POLI_CONTRACT the test is skipped (and fails when CI is set). Cases allowed to fail are listed, with the reason, in example-app/testdata/contract-skip.json.

License

Released under the MIT License.

Documentation

Overview

Package polipagegin integrates the Poli Page Go SDK with the Gin HTTP framework.

Install the middleware once on your router, then pull the per-request *polipage.Client off the gin context with ClientFrom. See README for the canonical example.

Index

Constants

View Source
const ContextKey = "polipagegin.client"

ContextKey is the gin.Context key under which Middleware stores the per-request *polipage.Client. Exported so callers writing custom middleware (e.g. swapping the client per tenant) can read or write the same slot ClientFrom looks at.

Variables

View Source
var ErrInvalidConfig = errors.New("polipagegin: invalid configuration")

ErrInvalidConfig is the sentinel every FromEnv error wraps. Use errors.Is(err, polipagegin.ErrInvalidConfig) for a single check when the specific failure does not matter to the caller.

Functions

func ClientFrom

func ClientFrom(c *gin.Context) *polipage.Client

ClientFrom returns the *polipage.Client stored on c by Middleware.

Panics with a clear message when the middleware was not installed on the route the request travelled through, or when something else has overwritten ContextKey with a value of the wrong type. Both cases are programmer errors, never expected at runtime — fail loud so the bug shows up in the first request a developer sends.

func DocumentRedirect

func DocumentRedirect(c *gin.Context, descriptor *polipage.DocumentDescriptor)

DocumentRedirect issues a 302 redirect to descriptor.PresignedPDFURL, with Cache-Control "no-store, private". Useful when the caller wants the browser to fetch the PDF directly from object storage, saving a hop through the application.

The presigned URL has a ~15-minute TTL; once expired, re-fetch the descriptor via Documents.Get to refresh.

No body is written: a 302 + Location is sufficient, and a body would just add latency to a response the browser never displays.

func ErrorMiddleware

func ErrorMiddleware() gin.HandlerFunc

ErrorMiddleware returns a gin.HandlerFunc that converts the last *polipage.Error queued on c.Errors into a JSON response:

{"code": "...", "message": "...", "status": 401, "requestId": "..."}

Middleware(client) already does this by default, so most applications never install ErrorMiddleware. Use it when the error handler was turned off with WithoutErrorHandler and should run somewhere else in the chain (for example on a different router group).

The middleware runs after the handler chain (it issues c.Next() and inspects c.Errors when control returns) and reads c.Errors.Last(), the convention downstream logging/tracing middleware typically follows. Multiple errors are tolerated; the most recent one drives the response.

Narrow on purpose: only *polipage.Error is intercepted. Any other error type is left untouched for the caller's existing error-handling middleware (or Gin's default) to deal with. Widening to all `error` values would swallow non-Poli-Page failures and destroy observability.

The body is the SDK's (*polipage.Error).ToPayload(), unchanged, with Content-Type: application/json. Its status is the API's HTTP status, 503 for network_error, 504 for timeout and 502 for invalid_response (a 2xx whose body is not JSON); the response uses the same status, or 500 when the payload has none, as for aborted or io_error (the body then carries "status": null). Nothing is written when the handler already wrote a response.

func Middleware

func Middleware(client *polipage.Client, opts ...MiddlewareOption) gin.HandlerFunc

Middleware returns a gin.HandlerFunc that attaches client to every request's *gin.Context. Pull it back out inside a handler with ClientFrom.

The middleware also ships the error handler, on by default: when a handler queues a *polipage.Error with c.Error(err) and writes no response, the middleware answers with the error's ToPayload() body ({code, message, status, requestId}) and its status (500 when the payload has none). See ErrorMiddleware for the details. Pass WithoutErrorHandler() to turn it off.

Passing a nil client panics at install time — the failure mode we want is "the app fails to boot" not "the first request crashes deep inside the SDK". Reuse one *polipage.Client per process (it is safe for concurrent use); do not construct a fresh one per request.

The application must install gin.Recovery() so a ClientFrom panic from a missing middleware surfaces as HTTP 500 instead of crashing the process. gin.Default() includes Recovery; gin.New() does not.

func PDF

func PDF(c *gin.Context, body []byte, opts PDFOptions)

PDF writes a buffered PDF response: status 200, Content-Type application/pdf, Content-Length, Cache-Control "no-store, private", X-Content-Type-Options nosniff, and an RFC 5987 Content-Disposition (attachment by default, inline when opts.Inline is true).

Status is set unconditionally to 200; a c.Status(...) call before PDF is silently overwritten. Callers that need a non-200 PDF response (rare) must roll the headers and body by hand.

func PDFStream

func PDFStream(c *gin.Context, body io.Reader, opts PDFOptions)

PDFStream writes a chunked PDF response — same headers as PDF plus X-Accel-Buffering: no (opt nginx-style proxies out of response buffering), and no Content-Length (the response is chunked). Reads from body 32 KB at a time, writing and flushing each chunk so bytes leave the process as they arrive rather than sitting in a buffer until EOF.

PDFStream does NOT call body.Close() — the handler that obtained the io.ReadCloser from client.Render.PDFStream is responsible for `defer body.Close()` before invoking this helper. The signature takes io.Reader (not io.ReadCloser) to advertise that policy.

Passing a nil reader panics: a nil reader is a programmer error, not a runtime fallback. The panic message says so; the gin.Recovery prerequisite (see Middleware) turns it into a 500 in the unlikely event it slips into production.

PDFStream reads the first chunk before it writes any header. When that first read fails, nothing is set or written: the error is queued on c.Errors and the middleware's error handler (or the caller) answers it as a normal error instead of a PDF response.

Later failures (Read returns a non-EOF error, or Write fails) are attached to c.Errors via c.Error(...), and the response is cut short so the client sees a truncated transfer instead of a complete-looking PDF: the status and the bytes already sent cannot be taken back, so PDFStream closes the connection without ending the chunked body. When the connection cannot be hijacked (HTTP/2, a ResponseRecorder, or a writer wrapper without Unwrap whose Hijack fails or panics), it panics with http.ErrAbortHandler, net/http's signal to abort the response. gin.Recovery() catches that panic, so behind Recovery such a stream ends as a short 200 (and gin v1.9/v1.10 Recovery logs a stack trace for it). On the panic path, middleware code after c.Next() (gin.Logger, for one) does not run unless a recovery middleware sits inside it.

func Preview

func Preview(c *gin.Context, html string)

Preview writes an HTML preview response: status 200, Content-Type "text/html; charset=utf-8", Cache-Control "no-store, private", X-Content-Type-Options nosniff. No Content-Disposition — the response is meant to be rendered, not downloaded.

func WriteError

func WriteError(c *gin.Context, err error)

WriteError is the per-handler version of the error handler: same JSON body and status. Use it inside a handler when the error flow is explicit rather than queued on c.Errors.

A non-*polipage.Error argument produces an HTTP 500 with no body: the caller's logging middleware owns the message. WriteError is intentionally opaque to anything it does not recognise; widening it would re-introduce the destroyed-observability hazard the error handler already avoids.

Types

type Config

type Config struct {
	// APIKey is the Poli Page API key, pp_{environment}_{32 lowercase hex}
	// (pp_sandbox_…, pp_live_…, pp_staging_…). Required and non-empty; any
	// other value is accepted, because the API is the only authority on keys.
	APIKey string
	// BaseURL overrides the API base URL.
	// Must be an http:// or https:// URL when set.
	BaseURL string
	// Timeout is the per-request deadline applied when the caller's
	// context has no deadline of its own. Must be in (0, 10m] when set.
	Timeout time.Duration
	// MaxRetries is the retry budget on top of the initial attempt.
	// Must be in [0, 10] when set. Explicit 0 (no retries) is honored
	// — see Options().
	MaxRetries int
	// RetryDelay is the base exponential-backoff delay. Must be in
	// [0, 30s] when set. Explicit 0 (immediate retry, no backoff) is
	// honored — see Options().
	RetryDelay time.Duration
	// contains filtered or unexported fields
}

Config is the env-driven configuration for the Poli Page SDK client. Populate via FromEnv (which parses POLI_PAGE_* environment variables and validates them) and pass Options() to polipage.NewClient.

Fields left at their zero value after FromEnv mean "the env var was not set, fall back to the SDK default"; Options omits the corresponding option.With* call so polipage.NewClient applies its own defaults (DefaultMaxRetries, DefaultRetryDelay, etc.).

func FromEnv

func FromEnv() (Config, error)

FromEnv loads Config from the documented POLI_PAGE_* environment variables, validating each one. On failure the returned error wraps ErrInvalidConfig and names the offending variable in its message.

FromEnv uses os.LookupEnv (not os.Getenv) so an empty value can be distinguished from an unset variable for the optional fields; either state is treated as "fall back to the SDK default".

func (Config) Options

func (c Config) Options() []option.RequestOption

Options returns the option.RequestOption slice to pass to polipage.NewClient. Only fields the user explicitly set (via env) contribute — fields left at their zero value defer to the SDK's own defaults. Explicit zero for MaxRetries / RetryDelay is honored thanks to the internal set-tracking.

type MiddlewareOption

type MiddlewareOption func(*middlewareConfig)

MiddlewareOption configures Middleware.

func WithoutErrorHandler

func WithoutErrorHandler() MiddlewareOption

WithoutErrorHandler turns off the error handler Middleware installs by default: a *polipage.Error queued on c.Errors is then left for the application's own error handling (or ErrorMiddleware, installed elsewhere).

type PDFOptions

type PDFOptions struct {
	// Filename is the filename presented to the User-Agent. Empty defaults
	// to "document.pdf".
	Filename string
	// Inline picks "inline" instead of "attachment". Useful when the
	// response is rendered inside an <iframe> (the demo dashboard does
	// this) so the browser views the PDF in place instead of triggering
	// a download.
	Inline bool
}

PDFOptions controls Content-Disposition for the PDF and PDFStream helpers.

Directories

Path Synopsis
cmd
polipage-render command
Package main implements the polipage-render CLI binary.
Package main implements the polipage-render CLI binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL