Current section
Files
Jump to
Current section
Files
lib/ash_phoenix_gen_api.ex
defmodule AshPhoenixGenApi do
@moduledoc """
Ash extension for generating PhoenixGenApi function configurations from Ash resources.
`AshPhoenixGenApi` bridges the Ash Framework and PhoenixGenApi by allowing you to
define PhoenixGenApi endpoints directly in your Ash resource and domain DSLs. It
automatically generates `PhoenixGenApi.Structs.FunConfig` structs from your Ash
actions, including type mappings, argument ordering, and configuration defaults.
## Architecture
The extension consists of two main parts:
### Resource Extension (`AshPhoenixGenApi.Resource`)
Added to Ash resources to define which actions should be exposed as PhoenixGenApi
endpoints. Each action is configured with routing, timeout, permission, and other
settings.
### Domain Extension (`AshPhoenixGenApi.Domain`)
Added to Ash domains to provide domain-level defaults and auto-generate a
"supporter" module that aggregates FunConfigs from all resources in the domain.
The supporter module implements the PhoenixGenApi client config interface
(`get_config/1`, `get_config_version/1`), allowing gateway nodes to pull API
configurations from service nodes.
## Quick Start
### 1. Add the extensions to your resource and domain
# In your resource:
defmodule MyApp.Chat.DirectMessage do
use Ash.Resource,
extensions: [AshPhoenixGenApi.Resource]
gen_api do
service "chat"
nodes {ClusterHelper, :get_nodes, [:chat]}
choose_node_mode :random
timeout 5_000
response_type :async
request_info true
action :send_direct_message do
request_type "send_direct_message"
timeout 10_000
check_permission {:arg, "from_user_id"}
end
action :get_conversation do
timeout 5_000
end
action :create do
# Minimal config — request_type and args are auto-derived
end
end
attributes do
uuid_primary_key :id
attribute :from_user_id, :uuid
attribute :to_user_id, :uuid
attribute :content, :string
attribute :reply_to_id, :uuid
attribute :file_id, :uuid
end
actions do
create :create do
accept [:from_user_id, :to_user_id, :content, :reply_to_id, :file_id]
end
read :read do
primary? true
end
end
end
# In your domain:
defmodule MyApp.Chat do
use Ash.Domain,
extensions: [AshPhoenixGenApi.Domain]
gen_api do
service "chat"
nodes {ClusterHelper, :get_nodes, [:chat]}
choose_node_mode :random
version "0.0.1"
supporter_module MyApp.Chat.GenApiSupporter
end
resources do
resource MyApp.Chat.DirectMessage
resource MyApp.Chat.GroupMessage
end
end
### 2. The supporter module is auto-generated
After compilation, `MyApp.Chat.GenApiSupporter` will be available with:
# Get all FunConfigs (for PhoenixGenApi pull)
MyApp.Chat.GenApiSupporter.fun_configs()
#=> [%PhoenixGenApi.Structs.FunConfig{request_type: "send_direct_message", ...}, ...]
# Get config for remote pull
MyApp.Chat.GenApiSupporter.get_config(:gateway_1)
#=> {:ok, [%PhoenixGenApi.Structs.FunConfig{...}, ...]}
# Get config version
MyApp.Chat.GenApiSupporter.get_config_version(:gateway_1)
#=> {:ok, "0.0.1"}
# Find a specific FunConfig by request_type
MyApp.Chat.GenApiSupporter.get_fun_config("send_direct_message")
#=> %PhoenixGenApi.Structs.FunConfig{request_type: "send_direct_message", ...}
# List all request types
MyApp.Chat.GenApiSupporter.list_request_types()
#=> ["send_direct_message", "get_conversation", ...]
# Push config to a gateway node (active push)
MyApp.Chat.GenApiSupporter.push_to_gateway(:"gateway1@host")
#=> {:ok, :accepted}
# Push config on application startup
MyApp.Chat.GenApiSupporter.push_on_startup(:"gateway1@host")
#=> {:ok, :accepted}
# Push to all configured push_nodes
MyApp.Chat.GenApiSupporter.push_to_configured_nodes()
#=> {:ok, [{:"gateway1@host", {:ok, :accepted}}, ...]}
### 3. Configure the gateway node
On the Phoenix gateway node, configure `phoenix_gen_api` in `config.exs`:
config :phoenix_gen_api, :gen_api,
service_configs: [
%{
service: "chat",
nodes: {ClusterHelper, :get_nodes, [:chat]},
module: MyApp.Chat.GenApiSupporter,
function: :get_config,
args: [:gateway_1]
}
]
## Code Interface
When `code_interface?` is `true` (the default), the extension auto-generates
Elixir functions on the resource module for each gen_api action. This allows
you to call actions directly without building queries or changesets manually.
# Create action — auto-generates create/2 and create!/2
{:ok, message} = MyApp.Chat.DirectMessage.create(%{content: "Hello"})
message = MyApp.Chat.DirectMessage.create!(%{content: "Hello"})
# Read action — auto-generates read/2 and read!/2
{:ok, messages} = MyApp.Chat.DirectMessage.read()
messages = MyApp.Chat.DirectMessage.read!()
# Update action — auto-generates update/3 and update!/3 (requires record)
{:ok, updated} = MyApp.Chat.DirectMessage.update(message, %{content: "Updated"})
updated = MyApp.Chat.DirectMessage.update!(message, %{content: "Updated"})
# Destroy action — auto-generates destroy/3 and destroy!/3 (requires record)
:ok = MyApp.Chat.DirectMessage.destroy(message)
:ok = MyApp.Chat.DirectMessage.destroy!(message)
# Generic action — auto-generates action_name/2 and action_name!/2
{:ok, result} = MyApp.Chat.DirectMessage.greet(%{name: "World"})
You can disable code interface generation at the section level or per-action:
gen_api do
service "chat"
code_interface? false # Disable for all actions
action :create do
code_interface? true # Re-enable for this action only
end
action :read # Inherits section-level false
end
## Permission Callback
In addition to the built-in permission modes (`false`, `:any_authenticated`,
`{:arg, "arg_name"}`, `{:role, ["admin"]}`), you can specify a custom
callback function for permission checking using `permission_callback`.
The callback receives `request_type` (string) and `args` (map) as arguments
and returns `true` (continue) or `false` (permission denied).
defmodule MyApp.Permissions do
def check_permission(request_type, args) do
case request_type do
"delete_user" -> args["role"] == "admin"
"update_profile" -> args["user_id"] == args["target_user_id"]
_ -> true
end
end
end
gen_api do
service "chat"
permission_callback {MyApp.Permissions, :check_permission, []}
action :delete_user do
# Uses the section-level permission_callback
end
action :admin_action do
# Override with a different callback
permission_callback {MyApp.Permissions, :check_admin, []}
end
end
When `permission_callback` is set, it takes precedence over `check_permission`
and is stored as `{:callback, {Module, :function, []}}` in the FunConfig's
`check_permission` field.
## Active Push Configuration
In addition to the pull-based model (where the gateway pulls config from
service nodes), you can configure the supporter module to **actively push**
its configuration to gateway nodes.
gen_api do
service "chat"
supporter_module MyApp.Chat.GenApiSupporter
version "0.0.1"
push_nodes [:"gateway1@host", :"gateway2@host"]
# Or use an MFA tuple for runtime resolution:
# push_nodes {ClusterHelper, :get_gateway_nodes, []}
end
Then push config during application startup:
def start(_type, _args) do
# ... start supervision tree, then:
MyApp.Chat.GenApiSupporter.push_to_configured_nodes()
# Or push to a specific node:
MyApp.Chat.GenApiSupporter.push_on_startup(:"gateway1@host")
end
The generated supporter module includes these push functions:
- `build_push_config/0` - Builds a `PushConfig` struct from the domain config
- `push_to_gateway/2` - Pushes config to a specific gateway node
- `push_on_startup/2` - Pushes config on application startup
- `verify_on_gateway/2` - Verifies config version on a gateway node
- `resolve_push_nodes/0` - Resolves `push_nodes` at runtime
- `push_to_configured_nodes/1` - Pushes to all configured push_nodes
## Result Encoder
By default, the auto-generated code interface functions return Ash resource
structs as-is. You can configure `result_encoder` to transform the result
before returning it to the caller.
### Encoder Modes
- `:struct` — Return the Ash resource struct as-is (default)
- `:map` — Convert the Ash resource struct to a map using `Map.from_struct/1`
- `{Module, :function, args}` — Custom encoder MFA. The function receives
the result as its first argument, followed by `args`, and must return
the encoded result.
### Configuration
`result_encoder` can be set at the domain, resource section, or action level:
# Domain-level default
gen_api do
service "chat"
result_encoder :map
supporter_module MyApp.Chat.GenApiSupporter
end
# Resource-level default
gen_api do
service "chat"
result_encoder :map
action :create
action :read
end
# Action-level override
gen_api do
service "chat"
result_encoder :struct
action :create do
result_encoder :map # Override section-level default
end
action :read # Inherits section-level :struct
end
# Custom encoder MFA
gen_api do
service "chat"
result_encoder {MyApp.Encoder, :to_api_format, []}
action :create
end
### Encoding Behavior
For `:map` encoding:
- Single structs are converted with `Map.from_struct/1` (removes `__struct__`
and metadata keys like `__meta__`)
- Lists of structs are mapped with `Enum.map(&Map.from_struct/1)`
- The atom `:ok` (from destroy actions) is returned as-is
- Non-struct values (e.g., generic action results) are returned as-is
For custom MFA encoders:
- The function receives the raw result value as its first argument
- For read actions, the value is a list of structs
- For create/update actions, the value is a single struct
- For destroy actions, the value is `:ok`
- The function must return the encoded value
### Important Note
When using `:map` or custom encoding, the bang (!) functions return encoded
values. Since update and destroy actions require a struct as their first
argument, you cannot chain encoded results directly. Use the non-bang
versions or create records directly via Ash if you need to chain operations:
# This works — create returns struct, update accepts struct
{:ok, record} = MyResource.create(%{name: "test"})
{:ok, updated} = MyResource.update(record, %{name: "updated"})
# This does NOT work — create! returns map, update requires struct
record = MyResource.create!(%{name: "test"}) # Returns map with :map encoder
MyResource.update(record, %{name: "updated"}) # Error: record must be a struct
## Type Mapping
Ash types are automatically mapped to PhoenixGenApi argument types:
| Ash Type | PhoenixGenApi Type |
|----------|-------------------|
| `:string`, `:uuid`, `:boolean`, `:date`, etc. | `:string` |
| `:integer`, `:float`, `:decimal` | `:num` |
| `{:array, :string}`, `{:array, :uuid}` | `{:list_string, max_items, max_item_length}` |
| `{:array, :integer}`, `{:array, :float}` | `{:list_num, max_items}` |
See `AshPhoenixGenApi.TypeMapper` for the complete mapping table.
## Resolution Order
Configuration values are resolved in this order (highest priority first):
1. **Action-level explicit config** — e.g., `action :foo do timeout 10_000 end`
2. **Resource section-level defaults** — e.g., `gen_api do timeout 5_000 end`
3. **Domain section-level defaults** — e.g., `gen_api do timeout 5_000 end`
4. **Built-in defaults** — e.g., timeout defaults to `5000`
## Modules
- `AshPhoenixGenApi.Resource` — Resource-level DSL extension
- `AshPhoenixGenApi.Resource.Info` — Resource introspection helpers
- `AshPhoenixGenApi.Resource.ActionConfig` — Action configuration struct
- `AshPhoenixGenApi.Domain` — Domain-level DSL extension
- `AshPhoenixGenApi.Domain.Info` — Domain introspection helpers
- `AshPhoenixGenApi.TypeMapper` — Ash type to PhoenixGenApi type mapping
- `AshPhoenixGenApi.JsonConfig` — JSON function config list generation utilities
- `AshPhoenixGenApi.Codec` — Result encoding for Ash resource structs
- `AshPhoenixGenApi.Transformers.DefineFunConfigs` — Resource transformer
- `AshPhoenixGenApi.Transformers.DefineDomainSupporter` — Domain transformer
- `AshPhoenixGenApi.Verifiers.VerifyActionConfigs` — Resource verifier
- `AshPhoenixGenApi.Verifiers.VerifyDomainConfig` — Domain verifier
"""
@doc """
Lists all modules that are part of the AshPhoenixGenApi extension.
Returns a list of module atoms for the resource extension, domain extension,
and their supporting modules.
## Examples
iex> AshPhoenixGenApi.modules()
[
AshPhoenixGenApi.Resource,
AshPhoenixGenApi.Resource.Info,
AshPhoenixGenApi.Resource.ActionConfig,
AshPhoenixGenApi.Domain,
AshPhoenixGenApi.Domain.Info,
AshPhoenixGenApi.TypeMapper,
...
]
"""
@spec modules() :: [module()]
def modules do
[
AshPhoenixGenApi.Resource,
AshPhoenixGenApi.Resource.Info,
AshPhoenixGenApi.Resource.ActionConfig,
AshPhoenixGenApi.Domain,
AshPhoenixGenApi.Domain.Info,
AshPhoenixGenApi.TypeMapper,
AshPhoenixGenApi.JsonConfig,
AshPhoenixGenApi.Codec,
AshPhoenixGenApi.Transformers.DefineFunConfigs,
AshPhoenixGenApi.Transformers.DefineDomainSupporter,
AshPhoenixGenApi.Verifiers.VerifyActionConfigs,
AshPhoenixGenApi.Verifiers.VerifyDomainConfig
]
end
@doc """
Returns the list of Spark DSL extensions provided by AshPhoenixGenApi.
These are the extensions you add to your Ash resources and domains:
- `AshPhoenixGenApi.Resource` — Add to Ash resources
- `AshPhoenixGenApi.Domain` — Add to Ash domains
## Examples
iex> AshPhoenixGenApi.extensions()
[AshPhoenixGenApi.Resource, AshPhoenixGenApi.Domain]
"""
@spec extensions() :: [module()]
def extensions do
[
AshPhoenixGenApi.Resource,
AshPhoenixGenApi.Domain
]
end
@doc """
Returns the built-in defaults for the gen_api DSL section.
These defaults are used when no explicit value is provided at any level:
- `timeout` — `5000` (5 seconds)
- `response_type` — `:async`
- `request_info` — `true`
- `check_permission` — `false`
- `permission_callback` — `nil` (no custom callback)
- `choose_node_mode` — `:random`
- `nodes` — `:local`
- `version` — `"0.0.1"`
- `retry` — `nil` (no retry)
- `code_interface?` — `true` (auto-generate code interface functions)
- `push_nodes` — `nil` (no push nodes)
- `push_on_startup` — `false`
- `result_encoder` — `:struct` (return Ash resource struct as-is)
"""
@spec defaults() :: map()
def defaults do
%{
timeout: 5_000,
response_type: :async,
request_info: true,
check_permission: false,
permission_callback: nil,
choose_node_mode: :random,
nodes: :local,
version: "0.0.1",
retry: nil,
code_interface?: true,
push_nodes: nil,
push_on_startup: false,
result_encoder: :struct
}
end
@doc """
Returns the version of the AshPhoenixGenApi library.
"""
@spec version() :: String.t()
def version do
unquote(Mix.Project.config()[:version] || "0.1.0")
end
end