Code Generation with the Manifest

Copy Markdown View Source

Code generation extensions like AshGraphql, AshJsonApi, AshTypescript, and OpenAPI generators have historically each had to walk Ash's internal DSL state to discover what to emit. That works — but every extension re-derives the same shape: which resources exist, what fields they have, what types those fields have, which operators apply, which actions are reachable, and so on. The introspection logic ends up duplicated across extensions, drift accumulates, and the dependency on private Ash internals makes each extension fragile to framework refactors.

Ash.Info.Manifest is a single normalized data structure that captures all of that shape in one pass. It's the recommended starting point for any new code-generation extension. This guide describes the pattern.

The manifest

Ash.Info.Manifest.generate/1 walks an OTP app's domains and produces a struct that contains:

  • resources — every reachable resource (modules whose types you reach transitively from public actions), with their public fields, relationships, identities, and multitenancy config.
  • types — full definitions of every named type referenced (enums, NewTypes, embedded resources).
  • entrypoints — one per public action on each reachable resource.
  • filter_capabilities — the universe of operators, functions, and custom expressions available, with per-field applicability already resolved.
  • sort_capabilities — sort directions.
{:ok, manifest} = Ash.Info.Manifest.generate(otp_app: :my_app)

The struct is plain data — no Ash runtime APIs — and can be serialized to JSON via Ash.Info.Manifest.JsonSerializer.to_map/1 for use by non-Elixir tools.

A taste of the output

Given a resource like:

defmodule MyApp.Post do
  use Ash.Resource

  attributes do
    uuid_primary_key :id
    attribute :title, :string, public?: true, allow_nil?: false
    attribute :status, MyApp.Post.Status, public?: true
  end

  relationships do
    belongs_to :author, MyApp.User, public?: true
  end

  actions do
    defaults [:read, create: [:title, :status, :author_id]]
  end
end

Ash.Info.Manifest.generate(otp_app: :my_app) produces (abridged) roughly:

%Ash.Info.Manifest{
  resources: [
    %Ash.Info.Manifest.Resource{
      name: "Post",
      module: MyApp.Post,
      primary_key: [:id],
      fields: %{
        id: %Ash.Info.Manifest.Field{
          name: :id,
          kind: :attribute,
          type: %Ash.Info.Manifest.Type{kind: :uuid, name: "UUID", module: Ash.Type.UUID},
          allow_nil?: false,
          primary_key?: true,
          filter_operators: [
            %Ash.Info.Manifest.ApplicableOperator{name: :==, rhs: :same},
            %Ash.Info.Manifest.ApplicableOperator{name: :in, rhs: {:array, :same}},
            %Ash.Info.Manifest.ApplicableOperator{name: :is_nil,
              rhs: {:concrete, Ash.Type.Boolean}}
          ],
          # ...
        },
        title: %Ash.Info.Manifest.Field{
          name: :title,
          kind: :attribute,
          type: %Ash.Info.Manifest.Type{kind: :string, name: "String", module: Ash.Type.String},
          allow_nil?: false,
          filter_operators: [...],
          filter_functions: [
            %Ash.Info.Manifest.ApplicableFunction{name: :contains,
              rhs: {:concrete, Ash.Type.String}},
            # ...
          ]
        },
        status: %Ash.Info.Manifest.Field{
          name: :status,
          type: %Ash.Info.Manifest.Type{
            kind: :type_ref,    # named-type reference; full def lives in `types`
            name: "Status",
            module: MyApp.Post.Status
          }
        }
      },
      relationships: %{
        author: %Ash.Info.Manifest.Relationship{
          name: :author,
          type: :belongs_to,
          cardinality: :one,
          destination: MyApp.User
        }
      }
    },
    # ... MyApp.User, reached transitively through :author
  ],
  types: [
    %Ash.Info.Manifest.Type{
      kind: :enum,
      name: "Status",
      module: MyApp.Post.Status,
      values: [:draft, :published, :archived]
    }
  ],
  entrypoints: [
    %Ash.Info.Manifest.Entrypoint{
      resource: MyApp.Post,
      action: %Ash.Info.Manifest.Action{
        name: :create,
        type: :create,
        accept: [:title, :status, :author_id],
        arguments: [],
        # ...
      }
    },
    %Ash.Info.Manifest.Entrypoint{
      resource: MyApp.Post,
      action: %Ash.Info.Manifest.Action{name: :read, type: :read, ...}
    }
    # ... entrypoints for MyApp.User
  ],
  filter_capabilities: %Ash.Info.Manifest.FilterCapabilities{
    operators: [%Ash.Info.Manifest.Operator{name: :==, ...}, ...],
    functions: [%Ash.Info.Manifest.Function{name: :contains, ...}, ...],
    predicate_operators: [:==, :!=, :<, :<=, :>, :>=, :in, :is_nil, ...],
    # ...
  },
  sort_capabilities: %Ash.Info.Manifest.SortCapabilities{
    directions: [:asc, :desc, :asc_nils_first, ...]
  }
}

Things to notice:

  • The reachable MyApp.User appears in resources even though we never asked for it directly — reachability followed the :author relationship.
  • Type references on fields (%Type{kind: :uuid, module: Ash.Type.UUID}) carry the canonical module. Short-name aliases like :string are resolved at construction time and never appear in the output.
  • Per-field filter_operators / filter_functions are already resolved — consumers don't re-derive applicability from operator signatures.
  • Named types (like the :status enum) are stored as a :type_ref on the field with the full definition in the top-level types list, so a tool emitting client code can render the enum once and reference it from everywhere.

For a complete overview of the available fields, see Ash.Info.Manifest, Ash.Info.Manifest.Resource, Ash.Info.Manifest.Field, etc.

The pattern

Code-generation extensions typically follow this five-step pattern:

  1. Generate the manifest for the app you're building against.
  2. Verify it has what you need — fail loudly if a resource is missing a required field, an action doesn't exist, etc.
  3. Walk the manifest and apply your DSL's configuration into the custom key on each struct. Every manifest struct has a custom: map() slot specifically for this.
  4. Verify the resulting shape — your extension's invariants on top of Ash's invariants.
  5. Persist the result. The natural homes are a module attribute (for compile-time codegen) or a JSON file (for cross-language tools).

Then either:

  • Compile the saved manifest into something else (TypeScript types, a GraphQL schema, an OpenAPI document), or
  • Introspect the saved manifest at runtime to drive behavior (route dispatch, validation, documentation rendering).

The split between "build + verify" and "compile/introspect" is the load-bearing idea: you do all the cross-checking once, then either path operates against a pre-validated artifact.

A worked example

Suppose you're building MyApp.RpcGen, an extension that emits a JSON schema for an RPC interface. Each entrypoint becomes an RPC method, and your DSL lets the user override the wire name per action:

defmodule MyApp.Posts.Post do
  use Ash.Resource, extensions: [MyApp.RpcGen.Resource]

  rpc_gen do
    method :read,   wire_name: "posts.list"
    method :create, wire_name: "posts.create"
  end
end

1. Generate

defmodule MyApp.RpcGen do
  def build!(otp_app) do
    {:ok, manifest} = Ash.Info.Manifest.generate(otp_app: otp_app)
    manifest
  end
end

2. Verify it has what you need

Walk the manifest and fail loud if an invariant breaks. Don't wait until compilation downstream — surface the problem at manifest-time:

defp validate!(manifest) do
  for entrypoint <- manifest.entrypoints do
    if entrypoint.action.type == :update and entrypoint.action.primary? == false do
      raise "RpcGen: non-primary update action #{inspect(entrypoint.action.name)} " <>
              "on #{inspect(entrypoint.resource)} is not supported"
    end
  end

  manifest
end

3. Walk and apply your DSL into custom

Each entrypoint, resource, field, etc. has a custom: %{} map. Populate it under your extension key:

defp apply_config(manifest) do
  entrypoints =
    Enum.map(manifest.entrypoints, fn entrypoint ->
      wire_name =
        MyApp.RpcGen.Info.method_wire_name(
          entrypoint.resource,
          entrypoint.action.name
        ) || default_wire_name(entrypoint)

      put_in(
        entrypoint.action.custom,
        Map.put(entrypoint.action.custom, :rpc_gen, %{wire_name: wire_name})
      )
      |> then(&%{entrypoint | action: &1})
    end)

  %{manifest | entrypoints: entrypoints}
end

After this step, every action carries its RPC config alongside its Ash config, in the same struct. Downstream code never re-reads the DSL.

4. Verify the resulting shape

Your extension's invariants on top of Ash's. For an RPC system, wire names must be unique:

defp validate_wire_names!(manifest) do
  manifest.entrypoints
  |> Enum.group_by(& &1.action.custom.rpc_gen.wire_name)
  |> Enum.each(fn
    {_, [_]} -> :ok
    {wire_name, dupes} ->
      raise "RpcGen: wire_name #{inspect(wire_name)} used by multiple actions: " <>
              inspect(Enum.map(dupes, &{&1.resource, &1.action.name}))
  end)
end

5. Persist

For a compile-time extension, store the validated manifest in a module attribute. Spark.Dsl.Extension transformers run at compile time, so this is free:

defmodule MyApp.RpcGen.Schema do
  @manifest Macro.escape(MyApp.RpcGen.build_validated!(:my_app))

  def manifest, do: @manifest

  def wire_names do
    Enum.map(@manifest.entrypoints, & &1.action.custom.rpc_gen.wire_name)
  end
end

For a runtime/CLI generator, write JSON to disk:

manifest
|> Ash.Info.Manifest.JsonSerializer.to_map()
|> Jason.encode!(pretty: true)
|> then(&File.write!("priv/rpc_schema.json", &1))

Compile or introspect

From here, you have two choices. To compile the manifest into a target language, walk it and emit:

def emit_typescript(manifest) do
  for resource <- manifest.resources, into: "" do
    """
    export interface #{resource.name} {
    #{Enum.map_join(Map.values(resource.fields), "\n", &emit_field/1)}
    }
    """
  end
end

To introspect at runtime — for a dispatcher, a docs page, a request validator — query the same persisted manifest:

def dispatch(wire_name, payload) do
  entrypoint =
    Enum.find(@manifest.entrypoints, fn e ->
      e.action.custom.rpc_gen.wire_name == wire_name
    end)

  case entrypoint do
    nil -> {:error, :unknown_method}
    %{resource: resource, action: action} -> run(resource, action, payload)
  end
end

What goes in custom

The custom slot is intentionally untyped. Convention:

  • Namespace under your extension's key: entrypoint.action.custom.ash_graphql, not entrypoint.action.custom.queryable?. Extensions don't conflict.
  • Treat it as data, not behavior — store strings, atoms, booleans, maps. Don't store functions or PIDs; they don't serialize and prevent persistence to JSON.
  • Symmetric structures get symmetric keys. If you put RPC config on entrypoint.action.custom.rpc_gen, put per-field RPC config on field.custom.rpc_gen.

Visibility options

By default the manifest only contains items marked public?: true. For most extensions this is what you want — private fields and actions are internal implementation. If your extension does need to see private items (e.g. a documentation generator that should emit internal-only fields under a separate header), opt in explicitly:

Ash.Info.Manifest.generate(
  otp_app: :my_app,
  include_private_attributes?: true,
  include_private_calculations?: true,
  include_private_aggregates?: true,
  include_private_relationships?: true,
  include_private_arguments?: true,
  include_private_actions?: true
)

The defaults exist so generated artifacts can't accidentally leak intentionally private API surface.

Filter and sort capabilities

manifest.filter_capabilities carries every operator, function, and custom expression in the app. manifest.sort_capabilities carries the sort directions. These are the universe — what's actually applicable to a field is on the field itself:

field = Ash.Info.Manifest.get_field(resource_lookup, MyApp.Post, :title)
field.filter_operators
# => [%ApplicableOperator{name: :==, rhs: :same}, ..., %ApplicableOperator{name: :is_nil, rhs: {:concrete, Ash.Type.Boolean}}]
field.filter_functions
# => [%ApplicableFunction{name: :contains, rhs: {:concrete, Ash.Type.String}}, ...]

The rhs tells you the right-hand-side type. :same means the same type as the field; {:concrete, module} is a specific Ash type module (always a module — never a short-name alias like :string); {:array, rhs} is an array whose element follows the nested shape. Consumers can render these directly without re-deriving from operator signatures.

Why module references, not short-name atoms

Throughout the manifest, type references are Ash type modules (Ash.Type.String) rather than short-name atoms (:string). Short names are user-facing aliases — users register :slug to point at MyApp.Types.Slug in their config — so emitting them in the manifest would leak a per-app naming convention that consumers can't reliably interpret. The manifest resolves short names through Ash.Type.get_type/1 at construction time so consumers always work against canonical modules.

If you want to display a friendly short name in generated output, look up the mapping yourself from Ash.Type.short_names/0 — that keeps the choice of display name local to your extension rather than baked into the IR.

Performance

Ash.Info.Manifest.generate/1 walks every reachable resource and type, which is non-trivial for large apps. The intended usage is:

  • Compile-time extensions: call it once during compilation, persist the result in a module attribute. Subsequent module attribute reads are free.
  • Runtime/CLI tools: call it once at boot, hold the manifest in a process or :persistent_term.

Don't call generate/1 per-request.

Versioning

Ash.Info.Manifest.schema_version/0 returns the schema version of the JSON output. Cross-language consumers should record this and refuse incompatible versions. The version bumps when the JSON shape changes in a way downstream tools need to detect; pure Elixir consumers reading the structs typically don't need to check.

See also