> ⚠️ **EXPERIMENTAL / UNDER ACTIVE DEVELOPMENT** ⚠️
# AshPhoenixGenApi
An Ash Framework extension for generating [PhoenixGenApi](https://github.com/ohhi-vn/phoenix_gen_api) function configurations from Ash resources and domains.
`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.
## Features
- **DSL-driven API configuration** — Define PhoenixGenApi endpoints alongside your Ash resource definitions
- **Automatic type mapping** — Ash types are automatically converted to PhoenixGenApi argument types
- **Auto-derived arguments** — Action arguments and accepted attributes are automatically extracted from Ash actions
- **Domain-level aggregation** — Auto-generates a "supporter" module that aggregates FunConfigs from all resources
- **Compile-time verification** — Validates action existence, request type uniqueness, and argument consistency
- **Resolution hierarchy** — Configuration values cascade from action → resource → domain → built-in defaults
- **PhoenixGenApi client interface** — Generated supporter modules implement `get_config/1`, `get_config_version/1`, `fun_configs/0`, etc.
## Installation
Add `ash_phoenix_gen_api` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:ash_phoenix_gen_api, "~> 0.1.0"},
{:ash, "~> 3.5"},
{:phoenix_gen_api, "~> 2.1"}
]
end
```
Then fetch dependencies:
```bash
mix deps.get
```
## Quick Start
### 1. Add the Resource extension
Add `AshPhoenixGenApi.Resource` to your Ash resources:
```elixir
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
end
gen_api do
service "chat"
nodes {ClusterHelper, :get_nodes, [:chat]}
choose_node_mode :random
timeout 5_000
response_type :async
request_info true
version "0.0.1"
action :create do
request_type "send_direct_message"
timeout 10_000
check_permission {:arg, "from_user_id"}
end
action :read do
request_type "get_conversation"
timeout 5_000
end
action :update_content do
request_type "update_content"
response_type :sync
end
end
end
```
### 2. Add the Domain extension
Add `AshPhoenixGenApi.Domain` to your Ash domain:
```elixir
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
```
### 3. Use the generated supporter module
After compilation, `MyApp.Chat.GenApiSupporter` is auto-generated:
```elixir
# 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", ...]
```
### 4. Configure the gateway node
On the Phoenix gateway node, configure `phoenix_gen_api` in `config.exs`:
```elixir
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]
}
]
```
## DSL Reference
### Resource DSL (`gen_api`)
The `gen_api` section is added to Ash resources when using `AshPhoenixGenApi.Resource`.
#### Section Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `service` | `:atom \| :string` | **required** | Service name for routing |
| `nodes` | `:local \| {:list, :atom} \| {:tuple, [:atom, :atom, {:list, :any}]}` | `:local` | Default target nodes |
| `choose_node_mode` | `:random \| :hash \| :round_robin \| {:hash, string}` | `:random` | Default node selection strategy |
| `timeout` | `pos_integer \| :infinity` | `5000` | Default timeout in milliseconds |
| `response_type` | `:sync \| :async \| :stream \| :none` | `:async` | Default response mode |
| `request_info` | `:boolean` | `true` | Default for passing request info |
| `check_permission` | `false \| :any_authenticated \| {:arg, string} \| {:role, [string]}` | `false` | Default permission check mode |
| `version` | `:string` | `"0.0.1"` | Default version string |
| `retry` | `pos_integer \| {:same_node, pos_integer} \| {:all_nodes, pos_integer}` | `nil` | Default retry configuration |
#### Action Entity Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `name` | `:atom` | **required** | Ash action name to expose |
| `request_type` | `:string` | action name as string | PhoenixGenApi request type string |
| `timeout` | `pos_integer \| :infinity` | section default | Timeout in milliseconds |
| `response_type` | `:sync \| :async \| :stream \| :none` | section default | Response mode |
| `request_info` | `:boolean` | section default | Whether to pass request info |
| `check_permission` | see above | section default | Permission check mode |
| `choose_node_mode` | see above | section default | Node selection strategy |
| `nodes` | see above | section default | Target nodes |
| `retry` | see above | section default | Retry configuration |
| `version` | `:string` | section default | API version string |
| `mfa` | `{module, atom, list}` | auto-generated | Explicit MFA tuple |
| `arg_types` | `map \| nil` | auto-derived | Explicit argument types |
| `arg_orders` | `[string] \| nil` | auto-derived | Explicit argument order |
| `disabled` | `:boolean` | `false` | Disable this endpoint |
### Domain DSL (`gen_api`)
The `gen_api` section is added to Ash domains when using `AshPhoenixGenApi.Domain`.
#### Section Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `service` | `:atom \| :string` | — | Service name (used as default for resources) |
| `nodes` | see above | `:local` | Default target nodes |
| `choose_node_mode` | see above | `:random` | Default node selection strategy |
| `timeout` | see above | `5000` | Default timeout |
| `response_type` | see above | `:async` | Default response mode |
| `request_info` | `:boolean` | `true` | Default for passing request info |
| `check_permission` | see above | `false` | Default permission check mode |
| `version` | `:string` | `"0.0.1"` | Default version string |
| `retry` | see above | `nil` | Default retry configuration |
| `supporter_module` | `:atom` | **required** | Module name for auto-generated supporter |
| `define_supporter?` | `:boolean` | `true` | Whether to auto-generate the supporter module |
## Type Mapping
Ash types are automatically mapped to PhoenixGenApi argument types:
| Ash Type | PhoenixGenApi Type |
|----------|-------------------|
| `:string`, `Ash.Type.String` | `:string` |
| `:ci_string`, `Ash.Type.CiString` | `:string` |
| `:integer`, `Ash.Type.Integer` | `:num` |
| `:float`, `Ash.Type.Float` | `:num` |
| `:decimal`, `Ash.Type.Decimal` | `:num` |
| `:uuid`, `Ash.Type.UUID` | `:string` |
| `:uuid_v7`, `Ash.Type.UUIDv7` | `:string` |
| `:boolean`, `Ash.Type.Boolean` | `:string` |
| `:date`, `Ash.Type.Date` | `:string` |
| `:time`, `Ash.Type.Time` | `:string` |
| `:datetime`, `Ash.Type.DateTime` | `:string` |
| `:utc_datetime`, `Ash.Type.UtcDateTime` | `:string` |
| `:naive_datetime`, `Ash.Type.NaiveDateTime` | `:string` |
| `:atom`, `Ash.Type.Atom` | `:string` |
| `:map`, `Ash.Type.Map` | `:string` |
| `:json`, `Ash.Type.Json` | `:string` |
| `:binary`, `Ash.Type.Binary` | `:string` |
| `:term`, `Ash.Type.Term` | `:string` |
| `{:array, :string}` | `{:list_string, 1000, 50}` |
| `{:array, :integer}` | `{:list_num, 1000}` |
| `{:array, :uuid}` | `{:list_string, 1000, 50}` |
| `{:array, :float}` | `{:list_num, 1000}` |
See `AshPhoenixGenApi.TypeMapper` for the complete mapping and customization options.
## 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`
For `arg_types` and `arg_orders`:
1. **Explicit `arg_types`/`arg_orders`** on the action entity
2. **Auto-derived** from the Ash action's accepted attributes and arguments
For `mfa`:
1. **Explicit `mfa`** on the action entity
2. **Auto-generated** as `{ResourceModule, :action_name, []}`
## Auto-Derived Arguments
When `arg_types` and `arg_orders` are not explicitly set, the extension automatically derives them from the Ash action:
- **For `:create` and `:update` actions**: Includes accepted attributes and action arguments
- **For `:read`, `:destroy`, and `:action` types**: Includes only action arguments
Example — given this Ash action:
```elixir
actions do
create :create do
accept [:from_user_id, :to_user_id, :content, :reply_to_id, :file_id]
end
end
```
The auto-derived `arg_types` and `arg_orders` would be:
```elixir
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"]
```
## Generated Supporter Module
The domain extension auto-generates a supporter module that implements the PhoenixGenApi client config interface. This module:
1. **Aggregates FunConfigs** from all resources in the domain that have `AshPhoenixGenApi.Resource`
2. **Implements `get_config/1`** — Returns `{:ok, fun_configs()}` for PhoenixGenApi pull
3. **Implements `get_config_version/1`** — Returns `{:ok, version}` for version checking
4. **Implements `fun_configs/0`** — Returns the aggregated list of `FunConfig` structs
5. **Implements `list_request_types/0`** — Returns all available request type strings
6. **Implements `get_fun_config/1`** — Returns a specific `FunConfig` by request_type
The generated module matches the interface described in the PhoenixGenApi documentation for remote config pulling.
## Introspection
### Resource Introspection
```elixir
# Check if a resource has gen_api configured
AshPhoenixGenApi.Resource.Info.has_gen_api?(MyApp.Chat.DirectMessage)
#=> true
# Get the service name
AshPhoenixGenApi.Resource.Info.gen_api_service(MyApp.Chat.DirectMessage)
#=> "chat"
# Get all action configs
AshPhoenixGenApi.Resource.Info.gen_api_actions(MyApp.Chat.DirectMessage)
#=> [%ActionConfig{name: :create, ...}, ...]
# Get a specific action config
AshPhoenixGenApi.Resource.Info.action(MyApp.Chat.DirectMessage, :create)
#=> %ActionConfig{name: :create, request_type: "send_direct_message", ...}
# Get only enabled actions
AshPhoenixGenApi.Resource.Info.enabled_actions(MyApp.Chat.DirectMessage)
#=> [%ActionConfig{disabled: false, ...}, ...]
# Get the generated FunConfig structs
AshPhoenixGenApi.Resource.Info.fun_configs(MyApp.Chat.DirectMessage)
#=> [%PhoenixGenApi.Structs.FunConfig{...}, ...]
# Get a specific FunConfig by request_type
AshPhoenixGenApi.Resource.Info.fun_config(MyApp.Chat.DirectMessage, "send_direct_message")
#=> %PhoenixGenApi.Structs.FunConfig{request_type: "send_direct_message", ...}
# Get all request types
AshPhoenixGenApi.Resource.Info.request_types(MyApp.Chat.DirectMessage)
#=> ["send_direct_message", "get_conversation", ...]
# Get effective values with fallback resolution
AshPhoenixGenApi.Resource.Info.effective_timeout(MyApp.Chat.DirectMessage, :create)
#=> 10_000
AshPhoenixGenApi.Resource.Info.effective_mfa(MyApp.Chat.DirectMessage, :create)
#=> {MyApp.Chat.DirectMessage, :create, []}
```
### Domain Introspection
```elixir
# Check if a domain has gen_api configured
AshPhoenixGenApi.Domain.Info.has_gen_api?(MyApp.Chat)
#=> true
# Get the supporter module name
AshPhoenixGenApi.Domain.Info.supporter_module(MyApp.Chat)
#=> MyApp.Chat.GenApiSupporter
# Get all resources with gen_api configured
AshPhoenixGenApi.Domain.Info.resources_with_gen_api(MyApp.Chat)
#=> [MyApp.Chat.DirectMessage, MyApp.Chat.GroupMessage]
# Get aggregated FunConfigs from all resources
AshPhoenixGenApi.Domain.Info.fun_configs(MyApp.Chat)
#=> [%PhoenixGenApi.Structs.FunConfig{...}, ...]
# Get all request types across all resources
AshPhoenixGenApi.Domain.Info.all_request_types(MyApp.Chat)
#=> ["send_direct_message", "get_conversation", ...]
# Get a configuration summary
AshPhoenixGenApi.Domain.Info.summary(MyApp.Chat)
#=> %{
#=> service: "chat",
#=> version: "0.0.1",
#=> supporter_module: MyApp.Chat.GenApiSupporter,
#=> total_fun_configs: 5,
#=> resources: [
#=> %{resource: MyApp.Chat.DirectMessage, request_types: ["send_direct_message", ...]},
#=> %{resource: MyApp.Chat.GroupMessage, request_types: ["send_group_message", ...]}
#=> ]
#=> }
```
## Compile-Time Verification
The extension performs compile-time verification to catch configuration errors early:
### Resource Verification
- **Action existence** — Every `action` entity must reference an existing Ash action
- **Request type uniqueness** — No two actions in the same resource may share a `request_type`
- **Arg consistency** — When both `arg_types` and `arg_orders` are provided, their keys must match
- **Permission arg existence** — When `check_permission` is `{:arg, "name"}`, the argument must exist
- **MFA validity** — When an explicit `mfa` is provided, it must be a valid `{module, function, args}` tuple
### Domain Verification
- **Supporter module name** — Must be a valid Elixir module name
- **Service configuration** — Resources with gen_api must have a service configured (either on the resource or the domain)
- **Request type uniqueness across resources** — No two resources in the domain may expose the same `request_type`
## Example: Chat Service
Here's a complete example matching the ChatService pattern from PhoenixGenApi:
```elixir
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, public?: true
attribute :to_user_id, :uuid, public?: true
attribute :content, :string, public?: true, allow_nil?: true
attribute :reply_to_id, :uuid, public?: true, allow_nil?: true
attribute :file_id, :uuid, public?: true, allow_nil?: true
attribute :order, :integer, public?: true
attribute :read, :boolean, public?: true, allow_nil?: true, default: false
attribute :deleted, :boolean, public?: true, allow_nil?: true, default: false
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 :mark_read do
accept [:read]
end
destroy :destroy
end
gen_api do
service "chat"
nodes {ClusterHelper, :get_nodes, [:chat]}
choose_node_mode :random
timeout 5_000
response_type :async
request_info true
version "0.0.1"
action :create do
request_type "send_direct_message"
timeout 10_000
check_permission {:arg, "from_user_id"}
end
action :read do
request_type "get_conversation"
end
action :mark_read do
request_type "mark_direct_messages_as_read"
end
end
end
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
end
end
```
This generates the same FunConfig structures that were previously hand-written in `ChatService.Interface.GenApi.Supporter`, but now derived automatically from your Ash resource definitions.
## Modules
| Module | Description |
|--------|-------------|
| `AshPhoenixGenApi` | Top-level module with documentation and helpers |
| `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.Transformers.DefineFunConfigs` | Resource transformer |
| `AshPhoenixGenApi.Transformers.DefineDomainSupporter` | Domain transformer |
| `AshPhoenixGenApi.Verifiers.VerifyActionConfigs` | Resource verifier |
| `AshPhoenixGenApi.Verifiers.VerifyDomainConfig` | Domain verifier |
## License
MPL 2.0