Architecture deep dive: the generator pipeline
This notebook is written as a talk track for maintainers and advanced users.
The previous notebooks show how to use AshOaskit. This one explains how the pieces fit together, why the boundaries exist, and where you should change the code when adding features.
Setup
repo_root = Path.expand("..", __DIR__)
mix_exs = Path.join(repo_root, "mix.exs")
ash_oaskit_dep =
if File.exists?(mix_exs) and File.read!(mix_exs) =~ "app: :ash_oaskit" do
{:ash_oaskit, path: repo_root, env: :prod}
else
{:ash_oaskit, github: "futhr/ash_oaskit", branch: "main"}
end
mix_install_opts =
if File.exists?(Path.join(repo_root, "mix.lock")) do
[lockfile: Path.join(repo_root, "mix.lock")]
else
[]
end
Mix.install(
[
ash_oaskit_dep,
{:ash_json_api, "~> 1.0"}
],
mix_install_opts
)
Application.put_env(:ash_oaskit, :cache_specs, false)
The mental model
AshOaskit is a translator.
It translates from:
- Ash domains and resources
- AshJsonApi route metadata
- Ash public field visibility
- Ash action input declarations
- optional Phoenix controller metadata
Into:
- OpenAPI 3.1 or 3.0
- JSON:API resource documents
- reusable component schemas
- request bodies
- response bodies
- query parameters
- links, metadata, errors, and security hooks
It should not guess application behavior that Ash does not expose. When the generator is unsure, a clear failure is usually better than a plausible but wrong public contract.
Pipeline map
flowchart TD
A[AshOaskit.spec/1 or spec module] --> B[AshOaskit.OpenApi]
B --> C{OpenAPI version}
C -->|3.1| D[Generators.V31]
C -->|3.0| E[Generators.V30]
D --> F[Generators.Shared]
E --> F
F --> G[Generator]
G --> H[InfoBuilder]
G --> I[PathBuilder]
G --> J[SchemaBuilder]
I --> K[Route operations]
I --> L[Relationship routes]
I --> M[Phoenix introspection]
J --> N[Resource schemas]
J --> O[Relationship schemas]
J --> P[Embedded schemas]
J --> Q[Property builders]
Q --> R[TypeMapper]
G --> S[Oaskit normalization and validation]
The version split happens early, but most work is shared. Version-specific behavior should be small and explicit.
A tiny spec for inspection
defmodule AshOaskit.Notebooks.Architecture.Note do
use Ash.Resource,
domain: AshOaskit.Notebooks.Architecture.Notes,
extensions: [AshJsonApi.Resource]
json_api do
type "note"
end
attributes do
uuid_primary_key :id
attribute :text, :string do
public? true
allow_nil? false
end
attribute :archived, :boolean do
public? true
default false
end
attribute :reviewed_at, :utc_datetime do
public? true
end
end
actions do
defaults [:read, :destroy]
create :create do
accept [:text, :archived]
end
update :update do
primary? true
accept [:text, :archived, :reviewed_at]
end
end
end
defmodule AshOaskit.Notebooks.Architecture.Notes do
use Ash.Domain,
validate_config_inclusion?: false,
extensions: [AshJsonApi.Domain]
resources do
resource AshOaskit.Notebooks.Architecture.Note
end
json_api do
routes do
base_route "/notes", AshOaskit.Notebooks.Architecture.Note do
get :read
index :read
post :create
patch :update
delete :destroy
end
end
end
end
spec = AshOaskit.spec_31(domains: [AshOaskit.Notebooks.Architecture.Notes])
%{
openapi: spec["openapi"],
path_count: map_size(spec["paths"]),
schema_count: map_size(spec["components"]["schemas"]),
tag_count: length(spec["tags"] || [])
}
Version routing
AshOaskit.OpenApi routes to the version-specific generator. The important outcome is that callers keep one API while downstream tools get the dialect they expect.
for version <- ["3.0", "3.1"] do
generated =
AshOaskit.spec(
domains: [AshOaskit.Notebooks.Architecture.Notes],
version: version
)
{version, generated["openapi"]}
end
The most common version-specific rule is nullability:
find_reviewed_at = fn spec ->
spec["components"]["schemas"]
|> Enum.find_value(fn {schema_name, schema} ->
property = get_in(schema, ["properties", "reviewed_at"])
if property do
{schema_name, property}
end
end)
end
%{
openapi_30:
find_reviewed_at.(
AshOaskit.spec(domains: [AshOaskit.Notebooks.Architecture.Notes], version: "3.0")
),
openapi_31:
find_reviewed_at.(
AshOaskit.spec(domains: [AshOaskit.Notebooks.Architecture.Notes], version: "3.1")
)
}
In the codebase, nullable handling is centralized. Avoid sprinkling version checks through unrelated builders.
Builder responsibilities
| Area | Module family | Responsibility |
|---|---|---|
| public API | AshOaskit, AshOaskit.Spec, AshOaskit.OpenApi |
caller-facing generation and spec modules |
| orchestration | AshOaskit.Generators.* |
version routing and final spec assembly |
| metadata | AshOaskit.Config, RouteGathering, PhoenixIntrospection |
read AshJsonApi and Phoenix metadata safely |
| paths | PathBuilder, route modules |
build path items, operations, request bodies, responses |
| parameters | FilterBuilder, SortBuilder, QueryParameters |
derive query parameters from public fields and route settings |
| schemas | SchemaBuilder, resource/relationship/embedded/property builders |
create reusable JSON:API components |
| types | TypeMapper |
translate Ash types to JSON Schema |
| support | Security, MultipartSupport, SpecModifier |
documented helpers around the generated document |
Good changes usually preserve these boundaries. If a new Ash type is needed, start in TypeMapper. If a new route shape is needed, start in path/route builders. If a new customization pattern is needed, prefer SpecModifier.
References are string-keyed on purpose
OpenAPI references must use the string key "$ref". AshOaskit routes all reference creation through AshOaskit.Core.SchemaRef so references are consistent and Oaskit can detect them.
AshOaskit.Core.SchemaRef.schema_ref("Note")
That looks tiny, but it prevents a real class of bugs: atom-keyed refs such as %{ref: ...} or %{"ref" => ...} are not OpenAPI references.
Counting refs in a generated spec
This small helper walks the generated document and counts $ref objects. It is a useful way to show that the generator is building a component graph rather than duplicating schemas inline.
defmodule AshOaskit.Notebooks.Architecture.Helpers do
def count_refs(%{"$ref" => _ref} = map) do
1 + count_refs(Map.delete(map, "$ref"))
end
def count_refs(%{} = map) do
map
|> Map.values()
|> Enum.map(&count_refs/1)
|> Enum.sum()
end
def count_refs(list) when is_list(list) do
list
|> Enum.map(&count_refs/1)
|> Enum.sum()
end
def count_refs(_value), do: 0
end
AshOaskit.Notebooks.Architecture.Helpers.count_refs(spec)
Component reuse is not only about smaller output. It also makes generated specs easier to validate and easier for client generators to consume.
What each generated section means
%{
top_level_keys: spec |> Map.keys() |> Enum.sort(),
component_groups: spec["components"] |> Map.keys() |> Enum.sort(),
paths:
spec["paths"]
|> Enum.map(fn {path, operations} -> {path, Map.keys(operations) |> Enum.sort()} end)
|> Enum.sort()
}
The major sections are:
openapi: target dialectinfo: title, version, contact, license, terms, descriptionservers: base URLstags: operation groupingpaths: HTTP operationscomponents.schemas: reusable JSON:API and JSON Schema shapescomponents.securitySchemes: added by options or customizationwebhooks: OpenAPI 3.1 callback-style operations when added through modifiers
Debugging workflow
When a generated spec looks wrong, avoid starting with the whole document. Work backwards:
- Is the field or relationship
public? true? - Does the Ash action accept the field or expose the argument?
- Is the resource routed through AshJsonApi, or only present in the domain?
- Is the JSON:API type unique enough to produce a distinct component name?
- Is the issue version-specific nullable behavior?
- Is a custom
json_schema/1callback returning a map? - Is a post-generation modifier changing the shape after generation?
That checklist usually lands you in the right module quickly.
How to extend the project
Add a new Ash type
Update:
AshOaskit.TypeMapperAshOaskit.SchemaBuilder.PropertyBuildersif calculations or aggregates need it- tests for OpenAPI 3.0 and 3.1 where nullable behavior matters
- README and usage rules tables
Add an OpenAPI feature
Start in the narrowest builder that owns the output. Keep version differences behind Schemas.Nullable or the version-specific generator entry points.
Add a customization helper
Add it to AshOaskit.SpecModifier and make invalid inputs warn instead of crashing where that is the established pattern.
Add serving behavior
Router behavior belongs in AshOaskit.Router and AshOaskit.Router.Plug. Controller behavior belongs in AshOaskit.Controller, AshOaskit.OpenApiController, or PhoenixIntrospection.
Speaker note
The architecture story is that AshOaskit is intentionally boring in the right places. It is a pipeline of small translators:
- collect metadata
- build schemas
- build paths
- normalize and validate
- expose a spec module
That shape is why the project can support both OpenAPI 3.0 and 3.1, AshJsonApi routes, Phoenix controller introspection, request validation, and customization hooks without turning every module into a ball of conditionals.