Powered by AppSignal & Oban Pro

OpenAPI Adapters

notebooks/openapi-adapters.livemd

OpenAPI Adapters

DocShell includes openapi.json in every build, but it does not assume where the API description comes from. Some hosts derive OpenAPI from Ash domains, some already expose an OpenApiSpex module, and some receive a JSON file from another language's build pipeline.

The boundary is one callback: c:DocShell.Generate.OpenApi.Adapter.load/1.

This tutorial shows the default behavior, the three shipped adapters, a custom adapter, and the shallow validation DocShell applies before accepting a document.

Setup

Run this notebook from Livebook's default standalone runtime. The setup cell installs DocShell from this repository's main branch so the examples match the notebook you opened.

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

Application.ensure_all_started(:doc_shell)

No adapter: the supported default

Leaving :open_api_adapter unset is a valid configuration. DocShell emits an empty OpenAPI 3.1 document so renderers can always rely on openapi.json existing.

{:ok, default_result} =
  DocShell.Build.run(
    write: false,
    title: "Tutorial API"
  )

Map.take(default_result.openapi, ["openapi", "info", "paths"])

Use this when the project has no public API yet, or when the API reference is handled somewhere else and the renderer simply needs a parseable artifact.

Raw JSON from a map

DocShell.Generate.OpenApi.Adapters.RawJson is the escape hatch for anything that already has an OpenAPI map.

sample_spec = %{
  "openapi" => "3.1.0",
  "info" => %{
    "title" => "Payments API",
    "version" => "2026.08"
  },
  "paths" => %{
    "/charges" => %{
      "get" => %{
        "responses" => %{
          "200" => %{"description" => "OK"}
        }
      }
    }
  }
}

{:ok, raw_map_result} =
  DocShell.Build.run(
    write: false,
    open_api_adapter: DocShell.Generate.OpenApi.Adapters.RawJson,
    open_api_options: [spec: sample_spec]
  )

raw_map_result.openapi["paths"] |> Map.keys()

The adapter does not transform the map. DocShell only checks that the result claims to be OpenAPI 3.0 or 3.1.

Raw JSON from a file

The same adapter can read a JSON file. This is the usual path when another toolchain writes the API description.

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

File.rm_rf!(workspace)
File.mkdir_p!(workspace)

spec_path = Path.join(workspace, "openapi.json")
File.write!(spec_path, Jason.encode!(sample_spec))

{:ok, raw_file_result} =
  DocShell.Build.run(
    write: false,
    open_api_adapter: DocShell.Generate.OpenApi.Adapters.RawJson,
    open_api_options: [path: spec_path]
  )

raw_file_result.openapi["info"]

If both :spec and :path are provided, :spec wins. That rule makes tests straightforward because they can pass an in-memory document even when production configuration points at a file.

Ash domains through AshOaskit

For Ash applications, the API description is usually implied by domains, resources, actions, and routes. The AshOaskit adapter keeps DocShell out of those details.

Use this in host configuration:

config :doc_shell,
  open_api_adapter: DocShell.Generate.OpenApi.Adapters.AshOaskit,
  domains: [MyApp.Blog, MyApp.Accounts],
  title: "My API",
  api_version: "1.0.0"

Add {:ash_oaskit, "~> 0.3"} to the host's dependencies when using real Ash domains. DocShell keeps the dependency optional and resolves it at runtime.

With no domains configured, the adapter returns a valid empty document. That is useful while wiring a host incrementally.

{:ok, ash_placeholder_result} =
  DocShell.Build.run(
    write: false,
    open_api_adapter: DocShell.Generate.OpenApi.Adapters.AshOaskit,
    domains: [],
    title: "Ash Tutorial API",
    api_version: "1.0.0",
    security_schemes: %{"bearer" => %{"type" => "http", "scheme" => "bearer"}}
  )

Map.take(ash_placeholder_result.openapi, ["openapi", "info", "components", "paths"])

If domains are configured but AshOaskit is not installed, the adapter returns {:error, :ash_oaskit_not_available} instead of pretending the API has no endpoints.

Pass AshOaskit-specific options through :open_api_options:

config :doc_shell,
  open_api_adapter: DocShell.Generate.OpenApi.Adapters.AshOaskit,
  domains: [MyApp.Blog],
  open_api_options: [version: "3.0", resource_scope: :routed]

Existing OpenApiSpex module

Phoenix applications often already have a module exporting spec/0. Point DocShell at that module instead of describing the API twice.

The real OpenApiSpex path works with %OpenApiSpex.OpenApi{} structs. This tutorial uses a plain map so the cell stays runnable without requiring OpenApiSpex.

defmodule DocShellLivebook.ExistingSpec do
  def spec do
    %{
      "openapi" => "3.0.3",
      "info" => %{"title" => "Existing Spec", "version" => "1.0.0"},
      "paths" => %{}
    }
  end
end

{:ok, spex_result} =
  DocShell.Build.run(
    write: false,
    open_api_adapter: DocShell.Generate.OpenApi.Adapters.OpenApiSpex,
    open_api_options: [module: DocShellLivebook.ExistingSpec]
  )

Map.take(spex_result.openapi, ["openapi", "info", "paths"])

Host configuration looks like this:

config :doc_shell,
  open_api_adapter: DocShell.Generate.OpenApi.Adapters.OpenApiSpex,
  open_api_options: [module: MyAppWeb.ApiSpec]

The adapter round-trips structs through the library's JSON encoder, so the artifact matches what the application would serve over HTTP.

Write a custom adapter

A custom adapter is a module that implements load/1 and returns either {:ok, document} or {:error, reason}. Keep the error reason specific enough for a failed build log.

defmodule DocShellLivebook.GatewaySpec do
  @behaviour DocShell.Generate.OpenApi.Adapter

  @impl true
  def load(opts) do
    path = Keyword.fetch!(opts, :cached_spec_path)

    with {:ok, json} <- File.read(path),
         {:ok, document} <- Jason.decode(json) do
      {:ok, document}
    else
      {:error, reason} -> {:error, {:gateway_spec_unavailable, path, reason}}
    end
  end
end

gateway_spec_path = Path.join(workspace, "gateway-openapi.json")
File.write!(gateway_spec_path, Jason.encode!(sample_spec))

{:ok, custom_result} =
  DocShell.Build.run(
    write: false,
    open_api_adapter: DocShellLivebook.GatewaySpec,
    open_api_options: [cached_spec_path: gateway_spec_path]
  )

custom_result.openapi["info"]

In a host, configure the module directly:

config :doc_shell,
  open_api_adapter: MyApp.Docs.GatewaySpec,
  open_api_options: [cached_spec_path: "priv/gateway/openapi.json"]

What load/1 receives

DocShell passes adapter options after merging known OpenAPI configuration with :open_api_options. The latter wins.

Key Source
:domains config :doc_shell, :domains
:title config :doc_shell, :title
:api_version config :doc_shell, :api_version
:security_schemes config :doc_shell, :security_schemes
anything else config :doc_shell, :open_api_options

Ignore keys that do not matter to your source.

Validation

DocShell.Generate.OpenApi.validate/1 is a shallow shape check. It accepts OpenAPI 3.0 and 3.1 with either string or atom openapi keys.

%{
  valid_31: DocShell.Generate.OpenApi.validate(%{"openapi" => "3.1.0"}),
  valid_30_atom_key: DocShell.Generate.OpenApi.validate(%{openapi: "3.0.3"}),
  invalid_swagger: DocShell.Generate.OpenApi.validate(%{"swagger" => "2.0"})
}

It is not full schema validation. The library or tool that produced the document should own that. DocShell's check catches common integration mistakes: returning config, an enveloped artifact, a struct that was never JSON-normalized, or a non-OpenAPI document.

Error vocabulary

These are the common errors from DocShell.Generate.OpenApi.extract/2:

%{
  missing_adapter_module: DocShell.Generate.OpenApi.extract(Nonexistent.OpenApiAdapter, []),
  loaded_module_without_load_callback: DocShell.Generate.OpenApi.extract(String, []),
  invalid_document:
    DocShell.Generate.OpenApi.extract(
      DocShell.Generate.OpenApi.Adapters.RawJson,
      spec: %{"swagger" => "2.0"}
    ),
  missing_raw_json_source:
    DocShell.Generate.OpenApi.extract(DocShell.Generate.OpenApi.Adapters.RawJson, [])
}

Interpret them as follows:

Error Cause
{:error, :nofile} The adapter module could not be loaded
{:error, :invalid_adapter} The loaded module does not export load/1
{:error, :invalid_openapi_source} The adapter returned neither {:ok, map} nor {:error, reason}
{:error, :invalid_openapi_document} No usable OpenAPI 3.0 or 3.1 version key
{:error, {:openapi_adapter_failed, message}} The adapter raised
any other {:error, reason} The adapter returned its own error

Optional dependency rule

Adapters for optional libraries must check availability at runtime. A compile time reference to a library the host did not install produces warnings, and this project compiles with --warnings-as-errors.

defmodule DocShellLivebook.OptionalLibraryAdapter do
  @behaviour DocShell.Generate.OpenApi.Adapter

  @impl true
  def load(opts) do
    if Code.ensure_loaded?(SomeSpecLibrary) and
         function_exported?(SomeSpecLibrary, :spec, 1) do
      {:ok, SomeSpecLibrary.spec(opts)}
    else
      {:error, :some_spec_library_not_available}
    end
  end
end

That pattern is what lets DocShell ship AshOaskit and OpenApiSpex adapters without forcing every host to depend on AshOaskit or OpenApiSpex.

Choosing an adapter

Source of truth Adapter
No API yet, or API docs handled elsewhere no adapter
Ash domains DocShell.Generate.OpenApi.Adapters.AshOaskit
Existing OpenApiSpex spec/0 module DocShell.Generate.OpenApi.Adapters.OpenApiSpex
Checked-in JSON or another toolchain's output DocShell.Generate.OpenApi.Adapters.RawJson
Anything else a small custom DocShell.Generate.OpenApi.Adapter

The main design rule is that the build pipeline talks to adapters, not to host frameworks. That keeps DocShell renderer-neutral and integration-neutral.