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.