Powered by AppSignal & Oban Pro

Serving Artifacts

notebooks/serving-artifacts.livemd

Serving Artifacts

DocShell's primary job is generation: turn documentation sources into versioned JSON. Serving those JSON files is optional and belongs to the host application.

This tutorial shows the three normal serving choices:

  • serve the files statically
  • load them into DocShell.Web.Cache and serve from memory
  • call DocShell.Web.Controller.show/2 from a host-owned pipeline

The examples build artifacts in a temporary directory so the notebook is safe to run repeatedly.

Setup

DocShell.Web.Cache is always available. DocShell.Web.Plug and DocShell.Web.Controller are compiled only when Plug is installed.

Run this notebook from Livebook's default standalone runtime. The setup cell installs Plug before DocShell so the optional web modules are available in the tutorial cells that exercise request handling.

Mix.install([
  {:plug, "~> 1.16"},
  {:doc_shell, github: "futhr/doc_shell", branch: "main"}
])

Application.ensure_all_started(:doc_shell)

Build artifacts to serve

First create a tiny guide and build the public artifact tree.

workspace =
  Path.join(
    System.tmp_dir!(),
    "doc_shell_serving_#{System.unique_integer([:positive])}"
  )

File.rm_rf!(workspace)

guide_dir = Path.join(workspace, "guides")
public_dir = Path.join(workspace, "public")
private_dir = Path.join(workspace, "private")

Enum.each([guide_dir, public_dir, private_dir], &File.mkdir_p!/1)

guide_path = Path.join(guide_dir, "serving-intro.md")

File.write!(guide_path, """
---
id: serving-intro
title: Serving Intro
audience: operators
---

# Serving Intro

Serve the generated JSON in the host application.
""")

build_opts = [
  modules: [],
  guide_bases: [guide_dir],
  livebook_base: Path.join(workspace, "notebooks"),
  public_dir: public_dir,
  private_dir: private_dir
]

{:ok, _result} = DocShell.Build.run(build_opts)

Path.wildcard(Path.join(public_dir, "*.json"))
|> Enum.map(&Path.basename/1)
|> Enum.sort()

Option 1: serve files statically

The simplest answer is often correct: put priv/doc_shell/public/ behind a static file handler, CDN, or frontend build step and let clients fetch JSON.

The files are plain JSON, but every payload is wrapped in DocShell's envelope.

{:ok, navigation_envelope} =
  DocShell.Artifact.read_envelope(Path.join(public_dir, "navigation.json"))

Map.take(navigation_envelope, ["schema_version", "generated_at", "generation_id"])

Static serving works well when documentation is public and changes only on deploy. It does not require any module under DocShell.Web.

Option 2: load artifacts into the runtime cache

Use DocShell.Web.Cache when documentation should update without a full redeploy, or when requests need to go through authorization before JSON is served.

The cache reads the whole artifact directory once, validates the manifest and generation ids, and stores the envelopes in ETS.

cache_name = :"doc_shell_livebook_cache_#{System.unique_integer([:positive])}"

{:ok, _pid} = DocShell.Web.Cache.start_link(name: cache_name, dir: public_dir)

DocShell.Web.Cache.fetch("navigation.json", cache_name)

fetch/2 returns the artifact payload. fetch_envelope/2 returns the stored envelope, which is what HTTP serving uses so generated_at stays the build time rather than the request time.

{:ok, search_payload} = DocShell.Web.Cache.fetch("search-index.json", cache_name)
{:ok, search_envelope} = DocShell.Web.Cache.fetch_envelope("search-index.json", cache_name)

%{
  payload_is_list?: is_list(search_payload),
  envelope_keys: Map.keys(search_envelope) |> Enum.sort()
}

Reload after a rebuild

reload/1 is all-or-nothing. If the rebuilt directory is invalid, the previous cache generation remains active.

Update the guide, rebuild into the same directory, and reload.

File.write!(guide_path, """
---
id: serving-intro
title: Serving Intro
audience: operators
---

# Serving Intro

Serve the generated JSON in the host application.

This paragraph was added after the first cache load.
""")

{:ok, _updated_result} = DocShell.Build.run(build_opts)
:ok = DocShell.Web.Cache.reload(cache_name)

{:ok, updated_content} = DocShell.Web.Cache.fetch("content.json", cache_name)

updated_content["serving-intro"]
|> Enum.map(& &1["tag"])

Because DocShell.Artifact.write/3 writes files atomically and the cache validates one generation id across the directory, readers see the old complete snapshot or the new complete snapshot. They do not see a half-written build.

Serve through DocShell.Web.Plug

When Plug is installed, mount DocShell.Web.Plug at the route where JSON artifacts should be served.

forward "/docs/api",
  to: DocShell.Web.Plug,
  init_opts: [cache: :docs_public, gate: &MyApp.Auth.allow_docs?/1]

The final path segment names the artifact. .json is optional:

Request path under the forward Artifact
/navigation navigation.json
/navigation.json navigation.json
/search-index search-index.json
/content content.json

The gate is the authorization boundary. Return :ok or true to allow; return anything else to deny.

If Plug is available in this runtime, the next cell exercises the plug directly with Plug.Test.

plug_test = Module.concat([Plug, Test])
plug = Module.concat([DocShell, Web, Plug])

if Code.ensure_loaded?(plug_test) and Code.ensure_loaded?(plug) do
  opts = apply(plug, :init, [[cache: cache_name, gate: fn _conn -> :ok end]])

  conn =
    plug_test
    |> apply(:conn, [:get, "/navigation", nil])
    |> then(&apply(plug, :call, [&1, opts]))

  %{
    status: conn.status,
    halted?: conn.halted,
    response_keys: conn.resp_body |> Jason.decode!() |> Map.keys() |> Enum.sort()
  }
else
  :plug_not_installed
end

A denied gate yields 403.

plug_test = Module.concat([Plug, Test])
plug = Module.concat([DocShell, Web, Plug])

if Code.ensure_loaded?(plug_test) and Code.ensure_loaded?(plug) do
  opts = apply(plug, :init, [[cache: cache_name, gate: fn _conn -> false end]])

  conn =
    plug_test
    |> apply(:conn, [:get, "/navigation", nil])
    |> then(&apply(plug, :call, [&1, opts]))

  %{status: conn.status, body: conn.resp_body, halted?: conn.halted}
else
  :plug_not_installed
end

Omitting a gate serves everything to everyone. That is correct for public documentation and wrong for internal documentation. Decide deliberately.

Run public and internal caches side by side

The cache name is also the ETS table name. Use separate names for separate artifact directories.

children = [
  {DocShell.Web.Cache, name: :docs_public, dir: "priv/doc_shell/public"},
  {DocShell.Web.Cache, name: :docs_internal, dir: "priv/doc_shell/private"}
]

Mount them with different routes and gates:

forward "/docs",
  to: DocShell.Web.Plug,
  init_opts: [cache: :docs_public]

forward "/internal/docs",
  to: DocShell.Web.Plug,
  init_opts: [cache: :docs_internal, gate: &MyApp.Auth.employee?/1]

DocShell does not decide which reader belongs in which audience. It only gives the host a clean place to enforce that decision.

Serve from a host controller

If the host already owns the pipeline — authentication, tenant loading, telemetry, rate limiting — keep that pipeline and call DocShell.Web.Controller.show/2 for the response.

defmodule MyAppWeb.DocsController do
  use MyAppWeb, :controller

  plug :require_authenticated_user

  def show(conn, params), do: DocShell.Web.Controller.show(conn, params)
end
get "/docs/api/:artifact", MyAppWeb.DocsController, :show

There is no gate option in the controller helper because authorization already belongs in the host pipeline before the helper is called.

Response behavior

DocShell.Web.Plug and DocShell.Web.Controller return the same kinds of responses:

Status Meaning
200 The stored envelope as application/json
403 The plug gate refused the request
404 No such artifact, or an invalid artifact path
500 The artifact was cached but could not be encoded

Multi-segment paths are rejected. A request can ask for one artifact name; it cannot walk the filesystem.

Common deployment mistakes

Mistake Fix
Serving an artifact directory before manifest.json is written Run mix doc_shell.build before asset packaging or cache reload
Writing a bare OpenAPI file inside the artifact directory Use :openapi_spec_path outside priv/doc_shell/public and private
Forgetting to reload after an out-of-band rebuild Call DocShell.Web.Cache.reload/1 from the deploy hook or admin action
Omitting a gate on internal docs Add a host authorization function or use a host controller pipeline
Sharing one cache name for two directories Give each cache a distinct :name

Generation stays renderer-neutral. Serving stays host-controlled.