Generates a language-agnostic API specification from Ash resources and actions.
Given an OTP app (or a list of {resource, action_name} tuples), AshApiSpec traverses the type graph to discover all reachable resources and types, producing structured Elixir structs that fully describe your API surface. The spec can also be serialized to JSON for consumption by downstream code generators (TypeScript clients, OpenAPI docs, etc.).
Add ash_api_spec to your list of dependencies in mix.exs:
def deps do
[
{:ash_api_spec, "~> 0.1.0"}
]
end{:ok, spec} = AshApiSpec.generate(otp_app: :my_app)
# spec is an %AshApiSpec{} struct containing:
# resources: - all reachable resource definitions (fields, relationships, identities)
# types: - named type definitions (enums, NewTypes)
# entrypoints: - resource-action pairs that clients can invoke# Print to stdout
mix ash_api_spec.dump
# Write to file
mix ash_api_spec.dump -o api_spec.jsonThe spec is a tree of plain Elixir structs. Here's what each part looks like.
Each Ash resource becomes an %AshApiSpec.Resource{} with its fields, relationships, and identities — a complete description of the resource's shape:
%AshApiSpec.Resource{
name: "Todo",
module: MyApp.Todo,
embedded?: false,
primary_key: [:id],
fields: %{
title: %AshApiSpec.Field{
name: :title,
kind: :attribute,
type: %AshApiSpec.Type{kind: :string, name: "String"},
allow_nil?: false,
writable?: true,
has_default?: false,
filterable?: true,
sortable?: true,
primary_key?: false,
sensitive?: false,
select_by_default?: true
},
status: %AshApiSpec.Field{
name: :status,
kind: :attribute,
type: %AshApiSpec.Type{kind: :type_ref, name: "Status", module: MyApp.Todo.Status},
# ...
},
comment_count: %AshApiSpec.Field{
name: :comment_count,
kind: :aggregate,
aggregate_kind: :count,
# ...
}
},
relationships: %{
comments: %AshApiSpec.Relationship{
name: :comments,
type: :has_many,
cardinality: :many,
destination: MyApp.TodoComment
},
user: %AshApiSpec.Relationship{
name: :user,
type: :belongs_to,
cardinality: :one,
destination: MyApp.User
}
}
}Fields include attributes, calculations, and aggregates — distinguished by the kind field. Relationships are stored separately with their type and destination.
Named types (enums, NewTypes) are collected in spec.types with their full definitions. Fields and arguments reference them via kind: :type_ref:
# In spec.types — the full definition:
%AshApiSpec.Type{
kind: :enum,
name: "Status",
module: MyApp.Todo.Status,
values: [:pending, :ongoing, :finished, :cancelled]
}
# In a field — just a reference:
%AshApiSpec.Type{kind: :type_ref, name: "Status", module: MyApp.Todo.Status}The type system covers all Ash types: primitives (:string, :integer, :uuid, ...), containers (:array, :map, :tuple, :keyword), unions, structs, and resource references. See the type system docs for the full list.
Each action exposed to clients is an entrypoint — a {resource, action} pair:
%AshApiSpec.Entrypoint{
resource: MyApp.Todo,
action: %AshApiSpec.Action{
name: :create,
type: :create,
primary?: true,
get?: false,
arguments: [
%AshApiSpec.Argument{name: :user_id, type: %AshApiSpec.Type{kind: :uuid}, allow_nil?: false}
],
accept: [:title, :description, :status, :priority],
metadata: [],
returns: nil, # nil for CRUD actions (returns the resource)
pagination: nil # only present on read list actions
}
}Build lookup maps for efficient access:
{:ok, spec} = AshApiSpec.generate(otp_app: :my_app)
# Resource lookup (by module)
lookup = AshApiSpec.resource_lookup(spec)
todo = AshApiSpec.get_resource!(lookup, MyApp.Todo)
# Access fields and relationships
title = AshApiSpec.Resource.get_field(todo, :title)
title.type.kind #=> :string
title.allow_nil? #=> false
comments = AshApiSpec.Resource.get_relationship(todo, :comments)
comments.cardinality #=> :many
comments.destination #=> MyApp.TodoComment
# Filter fields by kind
calcs = AshApiSpec.Resource.fields_by_kind(todo, :calculation)
aggs = AshApiSpec.Resource.fields_by_kind(todo, :aggregate)
# Action lookup
actions = AshApiSpec.action_lookup(spec)
create = AshApiSpec.get_action(actions, MyApp.Todo, :create)
create.type #=> :create
create.accept #=> [:title, :description, :status, ...]The spec serializes to JSON for use by external tools. Here's what the output looks like:
{
"version": "1.0.0",
"resources": [
{
"name": "Todo",
"module": "MyApp.Todo",
"embedded": false,
"primary_key": ["id"],
"fields": {
"title": {
"kind": "attribute",
"type": { "kind": "string", "name": "String" },
"allow_nil": false,
"writable": true,
"has_default": false,
"filterable": true,
"sortable": true,
"primary_key": false,
"sensitive": false,
"select_by_default": true
},
"status": {
"kind": "attribute",
"type": { "kind": "type_ref", "name": "Status", "module": "MyApp.Todo.Status" },
"allow_nil": true,
"writable": true,
"has_default": true
}
},
"relationships": {
"comments": {
"type": "has_many",
"cardinality": "many",
"destination": "MyApp.TodoComment",
"allow_nil": true,
"filterable": true,
"sortable": true
}
}
}
],
"types": [
{
"kind": "enum",
"name": "Status",
"module": "MyApp.Todo.Status",
"values": ["pending", "ongoing", "finished", "cancelled"]
}
],
"entrypoints": [
{
"resource": "MyApp.Todo",
"action": {
"type": "create",
"primary": true,
"get": false,
"accept": ["title", "description", "status", "priority"],
"arguments": [
{
"name": "user_id",
"type": { "kind": "uuid", "name": "UUID" },
"allow_nil": false,
"has_default": false,
"sensitive": false
}
]
}
}
]
}AshApiSpec.generate(
otp_app: :my_app,
action_entrypoints: [
{MyApp.Todo, :create},
{MyApp.Todo, :read},
{MyApp.Todo, :update}
]
)Only the listed actions become entrypoints. Related resources discovered via relationships are still included in the spec.
AshApiSpec.generate(
otp_app: :my_app,
overrides: [
always: [
resources: [MyApp.SharedTypes],
types: [MyApp.CustomEnum]
]
]
)By default, only public fields and arguments are included. Override per category:
AshApiSpec.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
)MIT