Packages
Official Elixir client for the KilnCMS APIs — published-by-default JSON:API reads, writes and workflow transitions, search, fired artifacts, media uploads, and GraphQL.
Current section
Files
Jump to
Current section
Files
kiln_client
README.md
README.md
# KilnClient
[](https://hex.pm/packages/kiln_client)
[](https://hexdocs.pm/kiln_client)
Official Elixir client for the [KilnCMS](https://github.com/The-Verscienta/kiln_cms)
APIs:
- the JSON:API read surface (`/api/json/*`), published-only by default
- the JSON:API **write** surface: create, update, workflow transitions, soft-delete
- per-type keyword / semantic search and autocomplete, and hybrid search (`/api/search`)
- fired artifacts (`/api/content/:type/:slug`) and the `/api/sync` delta feed
- media uploads (multipart, URL import, direct-to-storage) and metadata edits
- editorial reads: revision history, restore, content releases
- image-transform URLs (`KilnClient.Image`) and webhook signature checks (`KilnClient.Webhook`)
- a minimal GraphQL helper for `/gql`
Extracted from the client Verscienta's production site built and hardened
against a live Kiln ([kiln_cms#300](https://github.com/The-Verscienta/kiln_cms/issues/300)).
Its job is to encode the safe defaults once, so consumers don't rediscover the
traps ([kiln_cms#297](https://github.com/The-Verscienta/kiln_cms/issues/297))
one production incident at a time.
## Installation
```elixir
def deps do
[
{:kiln_client, "~> 0.5"}
]
end
```
Requires Elixir 1.15+. The only runtime dependency is [Req](https://hex.pm/packages/req),
0.6.1 or later (earlier versions have published security advisories).
Before 1.0, a minor version may change behaviour; the [changelog](https://github.com/The-Verscienta/kiln_cms/blob/main/clients/elixir/kiln_client/CHANGELOG.md)
says when.
## Configuration
```elixir
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:
```elixir
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
```elixir
# 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.
```elixir
# `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:
```elixir
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.
```elixir
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`).
```elixir
# 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.
```elixir
# 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](https://github.com/The-Verscienta/kiln_cms/blob/main/docs/json-api.md#writing-330)). 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:
```elixir
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
```
- The first argument is the plural route, as for reads. The JSON:API `type`
the server validates is derived from it (`"entries"` → `"entry"`); pass
`type: "person"` for an irregular plural.
- A dynamic-type entry is created on `"entries"` with its
`type_definition_id` — look it up with
`KilnClient.one("type-definitions", %{name: "product"})`.
- Body content goes in `block_tree` (a list of block maps) or `body_markdown`,
never both. When rewriting a `block_tree`, echo each block's `_id` (read them
with `fields: %{"post" => ["block_ids"]}`) so the server can tell an edit
from a replacement.
- Editing published content re-fires its artifacts; draft edits do not.
- `transition/4` takes any verb and kebab-cases it into the route, so a verb
a newer server adds is reachable before a client release names it.
## GraphQL
```elixir
{: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.
```elixir
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`](https://hexdocs.pm/req/Req.Test.html)
stubs the whole client without a running Kiln:
```elixir
# 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.