KilnClient

Hex.pm HexDocs

Official Elixir client for the KilnCMS APIs:

Extracted from the client Verscienta's production site built and hardened against a live Kiln (kiln_cms#300). Its job is to encode the safe defaults once, so consumers don't rediscover the traps (kiln_cms#297) one production incident at a time.

Installation

def deps do
  [
    {:kiln_client, "~> 0.5"}
  ]
end

Requires Elixir 1.15+. The only runtime dependency is Req, 0.6.1 or later (earlier versions have published security advisories). Before 1.0, a minor version may change behaviour; the changelog says when.

Configuration

config :kiln_client,
  base_url: "https://cms.example.com",
  api_key: System.get_env("KILN_API_KEY"),   # optional bearer key
  public_url: "https://cms.example.com",     # optional, defaults to base_url
  req_options: []                            # merged into every Req request

Mint delivery keys on a :viewer account (see Kiln's docs/api.md → "API keys") so a leaked credential can't widen visibility anywhere.

Every function that makes a request also takes a per-call :req option: Req options merged into that one request last, after the defaults and req_options. Use it to put a time limit on a call that must not hang:

KilnClient.semantic_search("posts", q, req: [receive_timeout: 1_500, retry: false])

When the embedding backend is degraded, the semantic routes stall instead of failing: one production instance took about 70 s per call and still answered 200. If you have a keyword fallback, set a timeout on semantic_search/3 and search/2 well above their healthy latency, and turn retries off. Retrying a timeout only adds load to a backend that is already struggling.

Published-only by default

Kiln's read policy authorizes any :editor/:admin identity for every workflow state — including a service API key minted on such an account. This client therefore reads the server-side filtered surfaces by default:

Call Route
list/2, one/3, by_ids/3 GET /api/json/:plural/published
text_search/3 GET /api/json/:plural/search/published
semantic_search/3 GET /api/json/:plural/semantic-search/published
autocomplete/3 GET /api/json/:plural/autocomplete/published

The state == :published filter lives in the server action, so it holds whatever identity your key carries. Editor-facing callers that genuinely need drafts opt out per call with published: false (which uses the plain routes and requires an editor/admin bearer key).

The hybrid search/2 (GET /api/search) has no published-only variant — its visibility follows the credential, which is why viewer-minted keys matter.

Usage

# Lists, filters, includes, pagination
{:ok, %{items: posts, total: total}} =
  KilnClient.list("posts", filter: %{locale: "en"}, include: ["tags"], limit: 10)

# First match, or {:error, %KilnClient.Error{reason: :not_found}}
{:ok, post} = KilnClient.one("posts", %{slug: "hello-world", locale: "en"})

# By id, in the order given; misses are dropped (chunked at the server's 100-row cap)
{:ok, featured} = KilnClient.by_ids("posts", featured_ids)

# Admin-defined custom fields (filter[…] can't reach into custom_fields)
{:ok, %{items: cheap}} =
  KilnClient.list("entries",
    filter: %{type_name: "product"},
    custom_filter: %{price: {:lte, 10}}
  )

# The dynamic-type registry (editor-or-above key) and its custom-field schema
{:ok, product_type} =
  KilnClient.one("type-definitions", %{name: "product"}, include: ["field_definitions"])

# Search
{:ok, %{items: hits}} = KilnClient.text_search("posts", "elixir", locale: "en")
{:ok, %{items: near}} = KilnClient.semantic_search("posts", "functional programming")
{:ok, %{items: sugg}} = KilnClient.autocomplete("posts", "eli")
{:ok, sections} = KilnClient.search("kiln", facets: true)

# Rendered content (fired artifact; retries once on a cold cache)
{:ok, artifact} = KilnClient.artifact("posts", "hello-world", surface: "json")

# Mirror the public site: a snapshot the first time, then upserts AND
# deletes since the stored cursor (anonymous view, whatever key is set)
{:ok, %{items: items, cursor: cursor}} = KilnClient.sync(cursor: stored_cursor)

# Join relationships through the included lookup
{:ok, %{items: [post | _], included: included}} =
  KilnClient.list("posts", include: ["tags"], limit: 1)

tags = KilnClient.resolve(post, "tags", included)

Results are flattened JSON:API resources: the attributes map (string keys) plus "id"/"type", with relationships reduced to {type, id} ref maps.

Image transforms

KilnClient.Image builds URLs for Kiln's on-the-fly image transforms (GET /media/:id/t/:ops) from a media item as the client returns it. Pure string building — no request is made.

# `media` is a media item as the client returns it — e.g. joined through
# `resolve/3` — needing "id", "url", "focal_x"/"focal_y" (and "width"/"height"
# for a srcset); atom keys work too.
KilnClient.Image.url(media, width: 800, aspect_ratio: "16:9", format: :auto)
#=> "https://cms.example.com/media/<id>/t/w_828,ar_16:9,fm_auto,v_4b87b277"

KilnClient.Image.srcset(media, aspect_ratio: "16:9", format: :auto)
#=> "https://cms.example.com/media/<id>/t/w_256,ar_16:9,fm_auto,v_… 256w, …"

Options: :width, :height, :aspect_ratio ("16:9" or {16, 9}), :dpr (1–3), :fit (:cover/:contain), :crop (:focal, :center, :top, :bottom, :left, :right), :format (:auto, :jpg, :png, :webp, :avif), :quality (1–100). srcset/2 also takes :widths. path/2 gives the root-relative path; KilnClient.image_url/2 and KilnClient.image_srcset/2 are shorthands.

Unsigned (the default) URLs are served only for allowlisted values, so :width/:height snap up to Kiln's size ladder (pass :sizes if the server's ladder was changed). Signed URLs take any size exactly as given. Signing needs the server's KILN_IMAGE_TRANSFORM_KEY, which is a server-side secret — configure it only in code that runs on your server, never in a browser bundle:

config :kiln_client, image_transform_key: System.get_env("KILN_IMAGE_TRANSFORM_KEY")

Pass sign_key: nil on a call to build an unsigned URL anyway.

Verifying webhooks

KilnClient.Webhook.verify/4 checks a delivery's x-kilncms-webhook-signature (t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>") against the endpoint's signing secret. It refuses a t more than five minutes from your clock, so a captured request can't be replayed later. Verify the raw body. A re-encoded parse won't match.

with [header] <- Plug.Conn.get_req_header(conn, KilnClient.Webhook.signature_header()),
     :ok <- KilnClient.Webhook.verify(secret, conn.assigns.raw_body, header) do
  %{"event" => event, "delivery_id" => delivery_id, "data" => data} =
    Jason.decode!(conn.assigns.raw_body)

  # delivery_id is stable across retries: remember it for the window to drop duplicates.
else
  _ -> send_resp(conn, 400, "bad signature")
end

The error reasons are :malformed, :expired and :mismatch. Pass tolerance: seconds to change the window.

Media uploads

Files enter the library over REST, not JSON:API — but they are writes like any other: they take a :read_write API key per call (api_key:) or from config, refuse with {:error, %KilnClient.Error{reason: :no_api_key}} before sending anything when there is none, and report failures as the same error struct (a read-only key is reason: :forbidden).

# Multipart, streamed from disk; metadata optional.
{:ok, item} = KilnClient.upload_media("priv/kiln.jpg", alt: "The kiln at dusk", focal_x: 0.3)
item["processing"]  # true while a video's metadata strip is pending — its url isn't live yet

# Server-side fetch of a public URL (SSRF-guarded, ≤ 25 MB).
{:ok, item} = KilnClient.import_media("https://example.com/cat.png", tag_ids: [tag_id])

# Metadata edits: alt/caption/decorative/focal point/tags (add_tag_ids / remove_tag_ids merge).
{:ok, item} = KilnClient.update_media(item["id"], caption: "Firing day", add_tag_ids: [tag_id])

# Large files straight to object storage (server on S3 with a private bucket; else a 501).
{:ok, item} = KilnClient.upload_media_direct("footage.mp4", alt: "Loading the kiln")

upload_media_direct/2 does three steps for you: presign, PUT, complete. To run the PUT yourself (from a browser, say), call the two server legs on their own: begin_direct_upload/3 returns the presigned "upload_url", "headers" and "token", and complete_direct_upload/2 takes the token plus the same metadata options.

The server byte-sniffs every file and runs it through the media library's own pipeline — metadata stripping, size caps, variants. See Kiln's docs/api.md → "Uploading media".

Editorial reads (editor-or-above key)

For tools about the content — migrations, audit exports, launch dashboards — never a delivery site's key. Anonymous calls are a 401, a viewer's key a 404.

# A document's version history, newest first (by id, not slug)
{:ok, %{"data" => revisions, "meta" => %{"next_cursor" => cursor}}} =
  KilnClient.list_revisions("post", post_id, limit: 50)

# One revision's changes + the full document as it stood then
{:ok, %{"snapshot" => snapshot}} = KilnClient.revision("post", post_id, version_id)

# Revert the content to it (a :read_write key; a read-only key gets a 403)
{:ok, %{"revision" => new_revision}} = KilnClient.restore_revision("post", post_id, version_id)

# Content releases (read-only) and what each will publish or take down
{:ok, %{items: releases, included: included}} =
  KilnClient.list_releases(filter: %{state: "scheduled"}, include: ["items"])

{:ok, release} = KilnClient.release(release_id, include: ["items"])
{:ok, %{items: items}} = KilnClient.list_release_items(filter: %{release_id: release_id})

list_revisions/3, revision/4 and restore_revision/4 take the singular type name and the document's id, not its slug.

Writing content

create/3, update/4, transition/4 (with submit_for_review/3, return_to_draft/3, publish/3, unpublish/3) and delete/3 drive Kiln's JSON:API write surface (see docs/json-api.md → Writing). They need a :read_write API key — editor-or-above to create, update and submit for review; admin to return to draft, publish, unpublish and delete. The key comes from the per-call :api_key option, else the configured :api_key; with neither, the call returns {:error, %KilnClient.Error{reason: :no_api_key}} without sending anything.

A writer's key is the opposite of the :viewer key delivery reads want, so keep the configured key for reads and pass the writer's per call:

writer = [api_key: System.fetch_env!("KILN_WRITE_KEY")]   # editor, :read_write
admin = [api_key: System.fetch_env!("KILN_ADMIN_KEY")]    # admin, :read_write

# Always created as a draft, attributed to the key's owner.
{:ok, post} =
  KilnClient.create(
    "posts",
    %{title: "Written over the API", slug: "hello-api", body_markdown: "# Hello"},
    writer
  )

# Only what you send changes. `tag_ids` REPLACES the set; merge with
# add_tag_ids / remove_tag_ids instead (not both styles in one call).
{:ok, _} = KilnClient.update("posts", post["id"], %{add_tag_ids: [tag_id]}, writer)

{:ok, _} = KilnClient.submit_for_review("posts", post["id"], writer)
{:ok, _} = KilnClient.publish("posts", post["id"], admin)   # fires the artifacts
:ok = KilnClient.delete("posts", post["id"], admin)         # reversible soft-delete

GraphQL

{:ok, %{"postBySlug" => post}} =
  KilnClient.graphql(
    "query ($slug: String!, $locale: String!) { postBySlug(slug: $slug, locale: $locale) { title } }",
    %{slug: "hello-world", locale: "en"}
  )

A minimal helper, not a GraphQL client: it posts {query, variables, operationName?} to /gql (operation_name: option), sends the API key when there is one (the published-content queries need none), and returns {:ok, data} — or {:error, %KilnClient.Error{reason: :graphql}} with the errors and any partial data when the response carries a top-level errors array. No codegen. Ash mutations report a refused write inside data — the payload's own errors field next to result: nil — so select errors { message code } on mutations and check it.

Errors

Every function returns {:error, %KilnClient.Error{}} on failure, reads and writes alike. Branch on :reason:

:reason When
:no_api_key a write with no key configured or passed — nothing was sent
:unauthorized / :forbidden 401 (no/invalid/expired key) / 403 (the key's owner lacks the right)
:not_found 404, or one/3 matched nothing (reported as 404 too)
:validation 400 / 422 — KilnClient.Error.pointers/1, field_errors/1. AshJsonApi answers most attribute errors with 400, not 422
:conflict 409 — a transition from the wrong state (code: "invalid_state_transition", KilnClient.Error.current_state/1) or a lost race
:rate_limited 429 — wait :retry_after seconds
:server 5xx (a 503 may carry :retry_after)
:transport no response (DNS, refused, TLS, timeout); the exception is in :exception
:graphql /gql answered with top-level errors
:storage_refused a direct upload's PUT to object storage answered non-2xx (e.g. a stale presigned URL); the store's raw body is in :body
:unexpected_body a 2xx whose body isn't the expected shape (a media write with no data resource); the decoded body is in :body
:http any other status

Every error also carries :status, :code (the first error's) and :errors (the JSON:API errors list). The API key is only ever sent as the Authorization header — no error carries it.

case KilnClient.one("posts", %{slug: slug}) do
  {:ok, post} -> render(post)
  {:error, %KilnClient.Error{reason: :not_found}} -> not_found()
  {:error, %KilnClient.Error{reason: :rate_limited, retry_after: s}} -> retry_in(s)
  {:error, error} -> raise error
end

Upgrading from 0.4: reads used to return {:error, {:http_status, status, body}}, a bare transport exception, or (from one/3) {:error, :not_found}. Match on the struct instead: {:http_status, 404, _} becomes %KilnClient.Error{status: 404}, :not_found becomes %KilnClient.Error{reason: :not_found}, and an exception becomes %KilnClient.Error{reason: :transport, exception: e}. KilnClient.Error.normalize/1 still accepts the old shapes.

Testing your integration

Every request honors req_options, so Req.Test stubs the whole client without a running Kiln:

# config/test.exs
config :kiln_client, req_options: [plug: {Req.Test, KilnClient}]

# in a test
Req.Test.stub(KilnClient, fn conn ->
  Req.Test.json(conn, %{"data" => [%{"id" => "1", "type" => "post", "attributes" => %{}}]})
end)

Releasing

Publishing runs from .github/workflows/release-clients.yml, triggered only by a tag named kiln_client-vX.Y.Z (it cannot match the core's vX.Y.Z release tags). The workflow checks the tag equals @version in mix.exs, runs the same format/compile/test gate as CI, builds the package with mix hex.build, and — after approval in the hex environment — attests the tarball's build provenance, proves a rebuild is byte-identical to it (Hex has no "publish this tarball" task, but its tarballs are reproducible), and runs mix hex.publish --yes (package and HexDocs).

  1. Bump @version in mix.exs, add a CHANGELOG.md entry, and merge.
  2. Tag the merge commit kiln_client-vX.Y.Z and push the tag.
  3. Approve the run in the hex environment.

The package is owned on Hex by the maintainers' account (the-verscienta); add co-owners with mix hex.owner add kiln_client <user>. The publish key is the HEX_API_KEY secret on the repository's hex environment. That environment needs a reviewer's approval for every run and only accepts kiln_client-v* tags. To rotate the key, generate a new api:write key on that account (hex.pm → Dashboard → Keys), replace the secret, then revoke the old key.