Skip to content

Repository files navigation

AshApiSpec

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.).

Installation

Add ash_api_spec to your list of dependencies in mix.exs:

def deps do
  [
    {:ash_api_spec, "~> 0.1.0"}
  ]
end

Quick Start

Generate a spec

{: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

Dump as JSON

# Print to stdout
mix ash_api_spec.dump

# Write to file
mix ash_api_spec.dump -o api_spec.json

What's in the Spec?

The spec is a tree of plain Elixir structs. Here's what each part looks like.

Resources

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.

Types

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.

Entrypoints

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
  }
}

Working with the Spec Programmatically

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, ...]

JSON Output

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
          }
        ]
      }
    }
  ]
}

Advanced Options

Filter which actions are exposed

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.

Force-include resources or types

AshApiSpec.generate(
  otp_app: :my_app,
  overrides: [
    always: [
      resources: [MyApp.SharedTypes],
      types: [MyApp.CustomEnum]
    ]
  ]
)

Include private fields

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
)

License

MIT

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages