SferaDoc (sfera_doc v0.1.0)

Copy Markdown View Source

PDF generation with versioned Liquid templates.

SferaDoc combines:

  • Storage: Templates in Ecto/ETS/Redis with automatic versioning
  • Parsing: Liquid templates via solid (default, pluggable), cached in ETS
  • Rendering: HTML to PDF via chromic_pdf (default, pluggable)
  • Cache: Optional fast in-memory PDF cache (Redis/ETS)
  • Object Store: Optional durable PDF storage (S3/Azure/FileSystem)

Quick Start

# 1. Configure storage
config :sfera_doc, :store,
  adapter: SferaDoc.Store.Ecto,
  repo: MyApp.Repo

# 2. Add migration
defmodule MyApp.Repo.Migrations.CreateSferaDocTemplates do
  use SferaDoc.Store.Ecto.Migration
end

# 3. Create template
{:ok, template} = SferaDoc.create_template(
  "invoice",
  "<h1>Invoice for {{ customer_name }}</h1><p>Amount: {{ amount }}</p>",
  variables_schema: %{"required" => ["customer_name", "amount"]}
)

# 4. Render to PDF
{:ok, pdf_binary} = SferaDoc.render("invoice", %{
  "customer_name" => "Acme Corp",
  "amount" => "$1,200.00"
})

File.write!("invoice.pdf", pdf_binary)

Storage Backends

Storage backends persist template source code and its metadata (name, version, variables_schema). This is separate from PDF storage templates are the input, PDFs are the output.

AdapterUse case
SferaDoc.Store.EctoProduction (PostgreSQL, MySQL, SQLite)
SferaDoc.Store.ETSDevelopment/testing only
SferaDoc.Store.RedisDistributed systems

Two-Tier PDF Storage

SferaDoc uses a two-tier storage system for rendered PDFs:

  1. Cache (fast, in-memory) - First lookup, short TTL
  2. Object store (durable storage) - Second lookup, survives restarts

Cache

Fast in-memory cache. Supports Redis or ETS. Disabled by default.

# Redis (multi-node, production)
config :sfera_doc, :pdf_hot_cache,
  adapter: :redis,
  ttl: 60

# ETS (single-node, development)
config :sfera_doc, :pdf_hot_cache,
  adapter: :ets,
  ttl: 300

Override Redis connection (reuses :redis config by default):

config :sfera_doc, :pdf_hot_cache,
  adapter: :redis,
  ttl: 60,
  redis: [host: "cache.example.com", port: 6379]

Object Store

Durable storage for rendered PDFs. Available adapters:

AdapterStorage
SferaDoc.Pdf.ObjectStore.S3Amazon S3 / S3-compatible
SferaDoc.Pdf.ObjectStore.AzureAzure Blob Storage
SferaDoc.Pdf.ObjectStore.FileSystemLocal/shared filesystem

Example S3 configuration:

config :sfera_doc, :pdf_object_store,
  adapter: SferaDoc.Pdf.ObjectStore.S3,
  bucket: "my-pdfs",
  region: "us-east-1"

For custom object store adapters, see Pluggable Engines below.

Warning

PDFs can be 100 KB – 10 MB+. For Redis cache, set explicit TTL and maxmemory-policy allkeys-lru to prevent memory issues.

Versioning

Calling create_template/3 with the same name creates a new version. Previous versions are preserved.

iex> SferaDoc.create_template("template_name", "<h1>v1</h1>")
{:ok,
 %SferaDoc.Template{
   id: "c41ee418-e479-4751-8331-b55af0f8ef97",
   name: "template_name",
   body: "<h1>v1</h1>",
   version: 1,
   is_active: true,
   variables_schema: nil,
   inserted_at: ~U[2026-03-06 20:26:41Z],
   updated_at: ~U[2026-03-06 20:26:41Z]
 }}

iex> SferaDoc.create_template("template_name", "<h1>v2</h1>")
{:ok,
 %SferaDoc.Template{
   id: "942ba9af-a542-43e8-9b71-1313e2c551ef",
   name: "template_name",
   body: "<h1>v2</h1>",
   version: 2,
   is_active: true,
   variables_schema: nil,
   inserted_at: ~U[2026-03-06 20:28:25Z],
   updated_at: ~U[2026-03-06 20:28:25Z]
 }}

iex> SferaDoc.list_versions("template_name")
{:ok,
 [
   %SferaDoc.Template{
     id: "942ba9af-a542-43e8-9b71-1313e2c551ef",
     name: "template_name",
     body: "<h1>v2</h1>",
     version: 2,
     is_active: true,
     variables_schema: nil,
     inserted_at: ~U[2026-03-06 20:28:25Z],
     updated_at: ~U[2026-03-06 20:28:25Z]
   },
   %SferaDoc.Template{
     id: "c41ee418-e479-4751-8331-b55af0f8ef97",
     name: "template_name",
     body: "<h1>v1</h1>",
     version: 1,
     is_active: false,
     variables_schema: nil,
     inserted_at: ~U[2026-03-06 20:26:41Z],
     updated_at: ~U[2026-03-06 20:26:41Z]
   }
 ]}

iex> SferaDoc.activate_version("template_name", 1)  # rollback
{:ok,
 %SferaDoc.Template{
   id: "c41ee418-e479-4751-8331-b55af0f8ef97",
   name: "template_name",
   body: "<h1>v1</h1>",
   version: 1,
   is_active: true,
   variables_schema: nil,
   inserted_at: ~U[2026-03-06 20:26:41Z],
   updated_at: ~U[2026-03-06 20:31:45Z]
 }}

iex> SferaDoc.list_versions("template_name")
{:ok,
 [
   %SferaDoc.Template{
     id: "942ba9af-a542-43e8-9b71-1313e2c551ef",
     name: "template_name",
     body: "<h1>v2</h1>",
     version: 2,
     is_active: false,
     variables_schema: nil,
     inserted_at: ~U[2026-03-06 20:28:25Z],
     updated_at: ~U[2026-03-06 20:28:25Z]
   },
   %SferaDoc.Template{
     id: "c41ee418-e479-4751-8331-b55af0f8ef97",
     name: "template_name",
     body: "<h1>v1</h1>",
     version: 1,
     is_active: true,
     variables_schema: nil,
     inserted_at: ~U[2026-03-06 20:26:41Z],
     updated_at: ~U[2026-03-06 20:31:45Z]
   }
 ]}

Pluggable Engines

Storage backends, template engines, and PDF engines are all swappable via behavior adapters.

Storage Backend - Implement SferaDoc.Store.Adapter:

defmodule MyApp.MongoAdapter do
  @behaviour SferaDoc.Store.Adapter

  def worker_spec, do: nil  # Assuming Mongo supervised elsewhere

  def get(name), do: # Fetch active template by name
  def get_version(name, version), do: # Fetch specific version
  def put(template), do: # Insert/update with versioning
  def list(), do: # All templates (active only)
  def list_versions(name), do: # All versions for name
  def activate_version(name, version), do: # Make version active
  def delete(name), do: # Delete all versions
end

config :sfera_doc, :store,
  adapter: MyApp.MongoAdapter

Template Engine - Implement SferaDoc.TemplateEngine.Adapter:

defmodule MyApp.CustomTemplateEngine do
  @behaviour SferaDoc.TemplateEngine.Adapter

  def parse(template), do: {:ok, Mustache.compile(template)}
  def render(ast, vars), do: {:ok, Mustache.render(ast, vars)}
end

config :sfera_doc, :template_engine,
  adapter: MyApp.CustomTemplateEngine

PDF Engine - Implement SferaDoc.PdfEngine.Adapter:

defmodule MyApp.CustomPdfEngine do
  @behaviour SferaDoc.PdfEngine.Adapter

  def render(html, _opts) do
    # Shell out to WeasyPrint, wkhtmltopdf, etc.
    {:ok, pdf_binary}
  end
end

config :sfera_doc, :pdf_engine,
  adapter: MyApp.CustomPdfEngine

PDF Object Store - Implement SferaDoc.Pdf.ObjectStore.Adapter:

defmodule MyApp.GCSAdapter do
  @behaviour SferaDoc.Pdf.ObjectStore.Adapter

  def worker_spec, do: nil  # HTTP client, no supervision needed

  def get(name, version, hash) do
    # Fetch from Google Cloud Storage
    {:ok, pdf_binary}  # or :miss
  end

  def put(name, version, hash, binary) do
    # Upload to GCS
    :ok
  end
end

config :sfera_doc, :pdf_object_store,
  adapter: MyApp.GCSAdapter

Summary

Functions

Activates a specific version of a template, deactivating the current one.

Creates a new template or adds a new version to an existing template.

Deletes all versions of a template by name.

Returns the active template for name, or a specific version.

Returns a list of all templates (latest active version per name).

Returns all versions of a template, ordered by version descending.

Renders the active version of a template to a PDF binary.

Functions

activate_version(name, version)

@spec activate_version(String.t(), pos_integer()) ::
  {:ok, SferaDoc.Template.t()} | {:error, any()}

Activates a specific version of a template, deactivating the current one.

Useful for rolling back to a previous version.

Example

{:ok, template} = SferaDoc.activate_version("invoice", 1)

create_template(name, body, opts \\ [])

@spec create_template(String.t(), String.t(), keyword()) ::
  {:ok, SferaDoc.Template.t()} | {:error, any()}

Creates a new template or adds a new version to an existing template.

  • If the template name doesn't exist, creates version 1
  • If the template name exists, creates version N+1 and marks it as active

Previous versions are preserved and can be restored with activate_version/2.

Options

  • :variables_schema: map with "required" and/or "optional" lists: %{"required" => ["name"], "optional" => ["footer"]}

Examples

# First time - creates version 1
{:ok,
 %SferaDoc.Template{
   id: "cd940533-52ee-4b6a-bb14-902f21d234b6",
   name: "welcome_email",
   body: "<p>Hello {{ name }}!</p>",
   version: 1,
   is_active: true,
   variables_schema: %{"required" => ["name"]},
   inserted_at: ~U[2026-03-06 19:31:09Z],
   updated_at: ~U[2026-03-06 19:31:09Z]
 }} = SferaDoc.create_template(
         "welcome_email",
         "<p>Hello {{ name }}!</p>",
         variables_schema: %{"required" => ["name"]}
       )

# Subsequent call - creates version 2
{:ok,
 %SferaDoc.Template{
   id: "942ba9af-a542-43e8-9b71-1313e2c551ef",
   name: "welcome_email",
   body: "<p>Hi {{ name }}! Welcome aboard.</p>",
   version: 2,
   is_active: true,
   variables_schema: %{"required" => ["name"]},
   inserted_at: ~U[2026-03-06 19:35:22Z],
   updated_at: ~U[2026-03-06 19:35:22Z]
 }} = SferaDoc.create_template(
         "welcome_email",
         "<p>Hi {{ name }}! Welcome aboard.</p>",
         variables_schema: %{"required" => ["name"]}
       )

delete_template(name)

@spec delete_template(String.t()) :: :ok | {:error, any()}

Deletes all versions of a template by name.

This operation is irreversible.

get_template(name, opts \\ [])

@spec get_template(
  String.t(),
  keyword()
) :: {:ok, SferaDoc.Template.t()} | {:error, any()}

Returns the active template for name, or a specific version.

Options

  • :version: return a specific version number instead of the active one

Examples

{:ok, template} = SferaDoc.get_template("invoice")
{:ok, v2} = SferaDoc.get_template("invoice", version: 2)

list_templates()

@spec list_templates() :: {:ok, [SferaDoc.Template.t()]} | {:error, any()}

Returns a list of all templates (latest active version per name).

list_versions(name)

@spec list_versions(String.t()) :: {:ok, [SferaDoc.Template.t()]} | {:error, any()}

Returns all versions of a template, ordered by version descending.

render(name, assigns, opts \\ [])

@spec render(String.t(), map(), keyword()) :: {:ok, binary()} | {:error, any()}

Renders the active version of a template to a PDF binary.

Options

  • :version: render a specific version instead of the currently active one
  • :chromic_pdf: extra options forwarded to the PDF engine (e.g. to ChromicPDF.print_to_pdf/2)

Returns

  • {:ok, pdf_binary} on success
  • {:error, :not_found} if the template does not exist
  • {:error, {:missing_variables, [String.t()]}} if required variables are absent
  • {:error, {:template_parse_error, error}} if the Liquid template has syntax errors
  • {:error, {:template_render_error, errors, partial_html}} on render-time errors
  • {:error, {:chromic_pdf_error, reason}} on PDF generation failure

Examples

{:ok, pdf} = SferaDoc.render(
         "welcome_email",
         %{"name" => "Alice"}
       )