Spec Modules

View Source

A spec module turns your Ash domains into a long-lived OpenAPI spec that plugs into the whole oaskit toolchain. It is the recommended way to use AshOaskit.

Defining a spec module

defmodule MyAppWeb.ApiSpec do
  use AshOaskit,
    domains: [MyApp.Blog, MyApp.Accounts],
    title: "My API",
    api_version: "1.0.0"
end

use AshOaskit implements the Oaskit behaviour: MyAppWeb.ApiSpec.spec/0 returns the generated spec map, cached in :persistent_term so the Ash domain walk runs once — not on every request.

All options:

OptionTypeDefaultDescription
:domains[module()]requiredAsh domains to include
:version"3.0" or "3.1""3.1"OpenAPI version
:titleString.t()"API"info.title
:api_versionString.t()"1.0.0"info.version
:descriptionString.t()nilinfo.description
:terms_of_serviceString.t()nilinfo.termsOfService
:contactmap()nilinfo.contact
:licensemap()nilinfo.license
:serverslist[]servers array
:security[map()]nilTop-level security requirements
:external_docsmap()nilExternal documentation object
:routermodule()nilPhoenix router for controller introspection
:modify_open_apifunction/MFAnilPost-generation hook
:spec_buildermodule()nilAshOaskit.SpecBuilder implementation
:cacheboolean()trueCache the generated spec

Serving the spec

Through the router macro (Phoenix or Plug.Router):

use AshOaskit.Router,
  spec: MyAppWeb.ApiSpec,
  open_api: "/openapi",
  redoc: "/redoc"

This serves GET /openapi.json (append ?pretty=1 for readable output) and a Redoc UI at GET /redoc.

Or wire Oaskit.SpecController directly:

get "/openapi.json", Oaskit.SpecController, spec: MyAppWeb.ApiSpec
get "/redoc", Oaskit.SpecController, redoc: "/openapi.json"

Scoped routers

Redoc fetches the spec by absolute URL. If the router macro runs inside a scope "/api", pass redoc_spec_url: "/api/openapi.json".

Customizing the generated spec

Override modify_spec/1 — it runs after generation and its result is what gets cached:

defmodule MyAppWeb.ApiSpec do
  use AshOaskit, domains: [MyApp.Blog]

  @impl AshOaskit.Spec
  def modify_spec(spec) do
    spec
    |> put_in(["components", "securitySchemes"], %{
      "bearerAuth" => %{"type" => "http", "scheme" => "bearer"}
    })
    |> Map.put("security", [%{"bearerAuth" => []}])
  end
end

The Oaskit callbacks are also overridable:

  • cache/1 — swap :persistent_term for another cache backend
  • cache_variant/0 — key the cache, e.g. per tenant
  • jsv_opts/0 — JSV validation options for request validation

Caching in development

:persistent_term survives code reloads, so a stale spec can linger in dev. Disable caching there:

# config/dev.exs
config :ash_oaskit, cache_specs: false

The switch is read at runtime on every call, so no recompilation is needed. A single module can also opt out with use AshOaskit, cache: false.

Dual-version output

An Oaskit spec module is one spec by contract. To serve OpenAPI 3.0 and 3.1 side by side, define two modules and register both:

defmodule MyAppWeb.ApiSpecV31 do
  use AshOaskit, domains: [MyApp.Blog], version: "3.1"
end

defmodule MyAppWeb.ApiSpecV30 do
  use AshOaskit, domains: [MyApp.Blog], version: "3.0"
end

use AshOaskit.Router,
  spec: [{"3.1", MyAppWeb.ApiSpecV31}, {"3.0", MyAppWeb.ApiSpecV30}],
  open_api: "/openapi"

# GET /openapi.json     -> 3.1 (first entry)
# GET /openapi/3.1.json -> 3.1
# GET /openapi/3.0.json -> 3.0

Exporting the spec

mix openapi.dump MyAppWeb.ApiSpec --pretty -o priv/static/openapi.json

oaskit's dump task uses the exact spec your application serves — including modify_spec/1 post-processing.