This guide will walk you through setting up AshPhoenixGenApi to generate PhoenixGenApi function configurations from your Ash resources and domains.
Prerequisites
- Elixir ~> 1.18
- Ash ~> 3.5
- PhoenixGenApi ~> 2.1
Installation
Add ash_phoenix_gen_api to your list of dependencies in mix.exs:
def deps do
[
{:ash_phoenix_gen_api, "~> 0.1.0"},
{:ash, "~> 3.5"},
{:phoenix_gen_api, "~> 2.1"}
]
endThen fetch dependencies:
mix deps.get
Step 1: Add the Resource Extension
Add AshPhoenixGenApi.Resource to your Ash resources that you want to expose as PhoenixGenApi endpoints:
defmodule MyApp.Chat.DirectMessage do
use Ash.Resource,
domain: MyApp.Chat,
extensions: [AshPhoenixGenApi.Resource]
attributes do
uuid_primary_key :id
attribute :from_user_id, :uuid do
public? true
end
attribute :to_user_id, :uuid do
public? true
end
attribute :content, :string do
public? true
allow_nil? true
end
attribute :reply_to_id, :uuid do
public? true
allow_nil? true
end
attribute :file_id, :uuid do
public? true
allow_nil? true
end
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
update :update_content do
accept [:content]
end
destroy :destroy
endNow add the gen_api section to configure your PhoenixGenApi endpoints:
gen_api do
# Required: the service name used for routing
service "chat"
# Target nodes — can be a list of atoms, an MFA tuple, or :local
nodes {ClusterHelper, :get_nodes, [:chat]}
# Default node selection strategy
choose_node_mode :random
# Default timeout in milliseconds
timeout 5_000
# Default response mode
response_type :async
# Whether to pass request info (user_id, device_id, request_id)
request_info true
# API version string
version "0.0.1"
# Expose the :create action as "send_direct_message"
action :create do
request_type "send_direct_message"
timeout 10_000
check_permission {:arg, "from_user_id"}
end
# Expose the :read action as "get_conversation"
action :read do
request_type "get_conversation"
timeout 5_000
end
# Expose the :update_content action as "update_content"
action :update_content do
request_type "update_content"
response_type :sync
end
end
endStep 2: Add the Domain Extension
Add AshPhoenixGenApi.Domain to your Ash domain to aggregate FunConfigs from all resources:
defmodule MyApp.Chat do
use Ash.Domain,
extensions: [AshPhoenixGenApi.Domain]
gen_api do
# Domain-level defaults (used as fallback for resources)
service "chat"
nodes {ClusterHelper, :get_nodes, [:chat]}
choose_node_mode :random
version "0.0.1"
# Required: the module name for the auto-generated supporter
supporter_module MyApp.Chat.GenApiSupporter
end
resources do
resource MyApp.Chat.DirectMessage
resource MyApp.Chat.GroupMessage
end
endStep 3: Use the Generated Supporter Module
After compilation, MyApp.Chat.GenApiSupporter is automatically generated. It implements the PhoenixGenApi client config interface:
# Get all FunConfigs (for PhoenixGenApi pull)
MyApp.Chat.GenApiSupporter.fun_configs()
#=> [%PhoenixGenApi.Structs.FunConfig{request_type: "send_direct_message", ...}, ...]
# Get config for remote pull (matches the PhoenixGenApi client interface)
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", "update_content", ...]Step 4: 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]
}
]Understanding Auto-Derived Arguments
When you don't specify arg_types and arg_orders on an action, the extension automatically derives them from the Ash action's accepted attributes and arguments.
Basic Example
Given this action:
actions do
create :create do
accept [:from_user_id, :to_user_id, :content, :reply_to_id, :file_id]
end
endThe auto-derived arg_types and arg_orders would be:
arg_types: %{
"from_user_id" => :string, # UUID → :string
"to_user_id" => :string, # UUID → :string
"content" => :string, # String → :string
"reply_to_id" => :string, # UUID → :string
"file_id" => :string # UUID → :string
},
arg_orders: ["from_user_id", "to_user_id", "content", "reply_to_id", "file_id"]Nil Attribute Example
When attributes have allow_nil? true, the extended format is used:
attributes do
attribute :content, :string do
allow_nil? true
end
attribute :reply_to_id, :uuid do
allow_nil? true
end
end
actions do
create :create do
accept [:content, :reply_to_id]
end
endThe auto-derived arg_types would be:
arg_types: %{
"content" => [type: :string, allow_nil?: true],
"reply_to_id" => [type: :uuid, allow_nil?: true]
},
arg_orders: ["content", "reply_to_id"]Type Mapping Reference
| Ash Type | PhoenixGenApi Type |
|---|---|
:string | :string |
:uuid | :uuid |
:integer | :num |
:float | :num |
:decimal | :num |
:boolean | :boolean |
:date | :string |
:datetime | :datetime |
:atom | :string |
:map | :map |
{:array, :string} | {:list_string, 1000, 50} |
{:array, :integer} | {:list_num, 1000} |
Nil Attribute Support
When an Ash attribute or argument has allow_nil? true, the generated arg_types uses an extended format:
# For attributes with allow_nil? false (default)
"content" => :string
# For attributes with allow_nil? true
"content" => [type: :string, allow_nil?: true]
# With constraints and allow_nil? true
"description" => [type: {:string, 255}, max_bytes: 255, allow_nil?: true]
"tags" => [type: :list_string, max_items: 1000, max_item_bytes: 50, allow_nil?: true]The extended format includes:
:type- The PhoenixGenApi type (atom or tuple):allow_nil?- Alwaystruewhen present- Type-specific constraints (e.g.,
:max_bytes,:max_items) :default_value- Present when the Ash attribute has a default value
This allows PhoenixGenApi clients to properly handle optional fields.
Overriding Auto-Derived Arguments
You can override the auto-derived arguments by explicitly specifying arg_types and arg_orders:
gen_api do
service "chat"
action :create do
request_type "send_direct_message"
# Override auto-derived args with custom types
arg_types %{
"from_user_id" => :string,
"to_user_id" => :string,
"content" => :string,
"reply_to_id" => :string,
"file_id" => :string,
"tags" => {:list_string, 100, 20} # Custom list type
}
arg_orders ["from_user_id", "to_user_id", "content", "reply_to_id", "file_id", "tags"]
end
endIf you only provide arg_types, arg_orders will be derived from its keys:
action :create do
request_type "send_direct_message"
arg_types %{
"from_user_id" => :string,
"content" => :string
}
# arg_orders will be ["from_user_id", "content"]
endResolution Order
Configuration values are resolved in this order (highest priority first):
- Action-level explicit config — e.g.,
action :foo do timeout 10_000 end - Resource section-level defaults — e.g.,
gen_api do timeout 5_000 end - Domain section-level defaults — e.g.,
gen_api do timeout 5_000 end - Built-in defaults — e.g., timeout defaults to
5000
Custom MFA
The MFA (Module, Function, Arguments) tuple tells the gateway node which function to call when a request arrives. Understanding how it's called is key to configuring it correctly.
How the MFA is Called
At runtime, the PhoenixGenApi executor calls your function like this:
{mod, fun, predefined_args} = fun_config.mfa
final_args = predefined_args ++ converted_args ++ info_args
apply(mod, fun, final_args)Where:
predefined_args— the third element of your MFA tuple (e.g.,[]). These are prepended to every call, useful for passing static context.converted_args— the request arguments, derived fromarg_typesandarg_orders:- When
arg_ordersis:map(the default), this is a single-element list containing a map with string keys:[%{"from_user_id" => "...", "content" => "..."}] - When
arg_ordersis an explicit list, this is a list of positional values:["user_123", "hello"] - When there are no arguments, this is
[]
- When
info_args— ifrequest_infoistrue, a single-element list with the request info map:[%{user_id: "...", device_id: "...", request_id: "..."}]. Otherwise[].
Default MFA
By default, the extension generates {ResourceModule, :action_name, []}. This works because the extension auto-generates code interface functions on the resource module (when code_interface? is true, which is the default). For example, with arg_orders: :map and request_info: true, the generated function is called as:
MyApp.Chat.DirectMessage.create(%{"from_user_id" => "...", "content" => "..."}, %{user_id: "...", device_id: "...", request_id: "..."})Overriding with a Custom MFA
You can override the default with an explicit mfa to route requests to your own function:
gen_api do
service "chat"
action :create do
request_type "send_direct_message"
mfa {MyApp.Interface.Api, :send_direct_message, []}
end
endThis generates a FunConfig with mfa: {MyApp.Interface.Api, :send_direct_message, []}. Your function must accept the same calling convention. With the default arg_orders: :map and request_info: true:
defmodule MyApp.Interface.Api do
# Called as: send_direct_message(args_map, request_info)
def send_direct_message(args, request_info) do
# args is a map with string keys, e.g., %{"from_user_id" => "...", "content" => "..."}
# request_info is a map, e.g., %{user_id: "...", device_id: "...", request_id: "..."}
# ...
end
endIf request_info is false, the request_info argument is omitted:
def send_direct_message(args) do
# Only receives the args map
endIf you set arg_orders to an explicit list (e.g., ["from_user_id", "content"]), arguments are passed positionally instead of as a map:
def send_direct_message(from_user_id, content, request_info) do
# Positional args in the order specified by arg_orders, plus request_info
endYou can also use the third element of the MFA tuple to pass static predefined arguments:
mfa {MyApp.Interface.Api, :send_direct_message, [:chat_service]}This prepends :chat_service to every call:
def send_direct_message(service, args, request_info) do
# service is always :chat_service
# ...
endStandalone MFA Endpoints
In addition to action entities (which map Ash resource actions to FunConfigs), you can define standalone MFA endpoints using the mfa entity. These call an arbitrary function directly — with no Ash action involved.
This is useful for exposing custom functions that don't map to standard Ash CRUD actions, such as utility endpoints, batch operations, or service-to-service calls.
Basic Usage
gen_api do
service "chat"
action :create do
request_type "send_direct_message"
end
mfa :ping do
request_type "ping"
mfa {MyApp.Chat.Api, :ping, []}
arg_types %{}
end
endRequired Fields
Unlike action entities, mfa entities require explicit configuration since there is no Ash action to auto-derive from:
request_type— Required. The PhoenixGenApi request type string.mfa— Required. The MFA tuple to call, e.g.,{Module, :function, []}.arg_types— Required. The argument types map. Use%{}for endpoints with no arguments.
With Arguments
mfa :search do
request_type "search"
mfa {MyApp.SearchHandler, :search, []}
arg_types %{"query" => :string, "limit" => :num}
# arg_orders defaults to :map — args are passed as a map with string keys
endWhen arg_orders is :map (the default), your function receives a map:
def search(args, request_info) do
# args is %{"query" => "...", "limit" => 10}
# request_info is %{user_id: ..., device_id: ..., request_id: ...}
endFor positional arguments, set arg_orders to a list:
mfa :search do
request_type "search"
mfa {MyApp.SearchHandler, :search, []}
arg_types %{"query" => :string, "limit" => :num}
arg_orders ["query", "limit"]
endYour function then receives positional args:
def search(query, limit, request_info) do
# query is the string value, limit is the number
endWith Predefined Arguments
Use the third element of the MFA tuple to pass static context:
mfa :batch_process do
request_type "batch_process"
mfa {MyApp.BatchProcessor, :run, [:chat_service]}
arg_types %{"items" => {:list_string, 1000, 50}}
response_type :async
endThis prepends :chat_service to every call:
def run(service, args, request_info) do
# service is always :chat_service
# ...
endNo Code Interface
Unlike action entities, mfa entities do not generate code interface functions on the resource module. This is because there is no Ash action to wrap — the MFA function is called directly by the PhoenixGenApi gateway.
Inheriting Section Defaults
Like action entities, mfa entities inherit defaults from the gen_api section:
gen_api do
service "chat"
timeout 5_000
response_type :async
request_info true
mfa :ping do
request_type "ping"
mfa {MyApp.Chat.Api, :ping, []}
arg_types %{}
timeout 1_000 # Override section default
# response_type, request_info, etc. inherited from section
end
endDisabling an Action
You can temporarily disable an endpoint without removing its configuration:
gen_api do
service "chat"
action :create do
request_type "send_direct_message"
end
action :deprecated_action do
disabled true
end
endDisabled actions are excluded from the generated FunConfig list.
Compile-Time Verification
The extension performs compile-time verification to catch configuration errors early:
- Action existence — Every
actionentity must reference an existing Ash action on the resource - MFA required fields — Every
mfaentity must haverequest_type,mfa, andarg_typesset - MFA tuple validity — The
mfafield must be a valid{module, function, args_list}tuple - Request type uniqueness — No two endpoints (actions or mfas) in the same resource may share a
request_type - Arg consistency — When both
arg_typesandarg_ordersare provided, their keys must match - Permission arg existence — When
check_permissionis{:arg, "name"}, the argument must exist inarg_types(formfaentities) or the Ash action (foractionentities) - Cross-resource request type uniqueness — No two resources in the domain may expose the same
request_type
If any verification fails, you'll get a descriptive error message at compile time.
Introspection
You can introspect your configuration at runtime:
# Resource introspection
AshPhoenixGenApi.Resource.Info.has_gen_api?(MyApp.Chat.DirectMessage)
#=> true
AshPhoenixGenApi.Resource.Info.gen_api_service(MyApp.Chat.DirectMessage)
#=> "chat"
AshPhoenixGenApi.Resource.Info.fun_configs(MyApp.Chat.DirectMessage)
#=> [%PhoenixGenApi.Structs.FunConfig{...}, ...]
AshPhoenixGenApi.Resource.Info.request_types(MyApp.Chat.DirectMessage)
#=> ["send_direct_message", "get_conversation", "update_content", "ping"]
# MFA-specific introspection
AshPhoenixGenApi.Resource.Info.mfas(MyApp.Chat.DirectMessage)
#=> [%AshPhoenixGenApi.Resource.MfaConfig{name: :ping, ...}, ...]
AshPhoenixGenApi.Resource.Info.mfa(MyApp.Chat.DirectMessage, :ping)
#=> %AshPhoenixGenApi.Resource.MfaConfig{name: :ping, request_type: "ping", ...}
AshPhoenixGenApi.Resource.Info.enabled_mfas(MyApp.Chat.DirectMessage)
#=> [%AshPhoenixGenApi.Resource.MfaConfig{name: :ping, disabled: false, ...}, ...]
# Domain introspection
AshPhoenixGenApi.Domain.Info.supporter_module(MyApp.Chat)
#=> MyApp.Chat.GenApiSupporter
AshPhoenixGenApi.Domain.Info.fun_configs(MyApp.Chat)
#=> [%PhoenixGenApi.Structs.FunConfig{...}, ...]
AshPhoenixGenApi.Domain.Info.summary(MyApp.Chat)
#=> %{
#=> service: "chat",
#=> version: "0.0.1",
#=> supporter_module: MyApp.Chat.GenApiSupporter,
#=> total_fun_configs: 3,
#=> resources: [
#=> %{resource: MyApp.Chat.DirectMessage, request_types: ["send_direct_message", ...]}
#=> ]
#=> }What's Next?
- Read the DSL Reference for the complete list of configuration options
- Read the TypeMapper documentation for details on type mapping
- Check the PhoenixGenApi documentation for more on FunConfig and the gateway architecture