DocsHex.pm

PhoenixGenApi

The library helps quickly develop APIs for client, the library is based on Phoenix Channel. Developers can add or update APIs in runtime from other nodes in the cluster without restarting or reconfiguring the Phoenix app. In this case, the Phoenix app will take on the role of an API gateway.

The library can use with EasyRpc and ClusterHelper for fast and easy to develop a dynamic Elixir cluster.

Concept

After received an event from client(in handle_in callback of Phoenix Channel), the event will be passed to PhoenixGenApi to find target API & target node to execute then get result for response to client.

For service nodes (target node), the libray support some basic strategy for selecting node (:choose_node_mode) like: :random, :hash, :round_robin.

Supported :sync, :async, :stream for request/response to client.

Supported basic check type & permission.

Features

Installation

Note: Require Elixir ~> 1.18 and OTP ~> 27

The package can be installed by adding phoenix_gen_api to your list of dependencies in mix.exs:

def deps do
  [
    {:phoenix_gen_api, "~> 2.1"}
  ]
end

Note: You can use :libcluster to build a Elixir cluster.

Usage

Remote Node (optional)

Add config to your config.exs file to mark this is remote node.

config :phoenix_gen_api, :client_mode, true

Declare a module for support PhoenixGenApi can pull config.

Example:

defmodule MyApp.GenApi.Supporter do

  alias PhoenixGenApi.Structs.FunConfig

  @doc """
  Support for remote pull general api config.
  """
  def get_config(_arg) do
    {:ok, my_fun_configs()}
  end

  @doc """
  Return list of %FunConfig{}.
  """
  def my_fun_configs() do
    [
      %FunConfig{
        request_type: "get_data",
        service: "my_service",
        nodes: [Node.self()],
        choose_node_mode: :random,
        timeout: 5_000,
        mfa: {MyApp.Interface.Api, :get_data, []},
        arg_types: %{"id" => :string},
        response_type: :async,
        version: "1.0.0"
      }
    ]
  end
end

Note: You can add directly in runtime in gateway node without using client mode.

Active Push (Remote Node → Gateway)

In addition to the pull mechanism, remote nodes can actively push their service configuration and function configs to the gateway node. This is useful when you want to register a service immediately on startup rather than waiting for the gateway to pull.

How It Works

  1. Remote node creates a PushConfig with service info, function configs, and a config version
  2. Remote node calls ConfigPusher.push_on_startup/3 (or push/2) to push to the gateway
  3. Gateway node receives the push via ConfigReceiver, validates, stores in ConfigDb, and optionally registers for auto-pull
  4. If the PushConfig includes module/function for auto-pull, the gateway will also periodically refresh the config

The push is idempotent — if the config_version matches what the gateway already has, the push is skipped. Use force: true to override.

Remote Node Setup

# In your remote node's application start or GenServer init:
alias PhoenixGenApi.Structs.FunConfig
alias PhoenixGenApi.ConfigPusher

fun_configs = [
  %FunConfig{
    request_type: "get_data",
    service: :my_service,
    nodes: [Node.self()],
    choose_node_mode: :random,
    timeout: 5_000,
    mfa: {MyApp.Interface.Api, :get_data, []},
    arg_types: %{"id" => :string},
    response_type: :sync,
    version: "1.0.0"
  }
]

# Option 1: Build PushConfig manually
push_config = %PhoenixGenApi.Structs.PushConfig{
  service: :my_service,
  nodes: [Node.self()],
  config_version: "1.0.0",
  fun_configs: fun_configs,
  # Optional: enable auto-pull after initial push
  module: MyApp.GenApi.Supporter,
  function: :get_config,
  args: []
}

# Option 2: Build PushConfig using helper
push_config = ConfigPusher.from_service_config(
  :my_service,
  [Node.self()],
  fun_configs,
  config_version: "1.0.0",
  module: MyApp.GenApi.Supporter,
  function: :get_config
)

# Push once on startup
ConfigPusher.push_on_startup(:gateway@host, push_config)

# Or verify first, then push only if needed
case ConfigPusher.verify(:gateway@host, :my_service, "1.0.0") do
  {:ok, :matched} -> :already_registered
  {:ok, :mismatch, _} -> ConfigPusher.push(:gateway@host, push_config)
  {:error, :not_found} -> ConfigPusher.push(:gateway@host, push_config)
end

Force Push

# Force push even if version matches (e.g., after hot code upgrade)
ConfigPusher.push(:gateway@host, push_config, force: true)

Gateway Node API

On the gateway node, you can also use the server-side API directly:

# Receive a push (typically called via RPC from remote node)
{:ok, :accepted} = PhoenixGenApi.push_config(push_config)
{:ok, :skipped, :version_matches} = PhoenixGenApi.push_config(push_config)
{:ok, :accepted} = PhoenixGenApi.push_config(push_config, force: true)

# Verify a service's config version
{:ok, :matched} = PhoenixGenApi.verify_config("my_service", "1.0.0")
{:ok, :mismatch, "0.9.0"} = PhoenixGenApi.verify_config("my_service", "1.0.0")
{:error, :not_found} = PhoenixGenApi.verify_config("unknown_service", "1.0.0")

# Check pushed services status (IEx helper)
PhoenixGenApi.pushed_services_status()

Phoenix Node (Gateway node)

Add config for Phoenix can pull config from remote nodes(above) like:

# Config for general api.
config :phoenix_gen_api, :gen_api,
  service_configs: [
    # service config for pulling general api config.
    %{
      # service type
      service: "my_service",
      # nodes of service in cluster, need to connecto to get config
      # list of nodes or using MFA like: {ClusterHelper, get_nodes, [:my_api]}
      nodes: [:"remote_service@test.local"], 
      # module to get config
      module: MyApp.GenApi.Supporter,
      # function to get config
      function: :get_config,
      # args to get config, using for identity or check security.
      args: [:gateway_1],
    }
  ]

# Config for rate limiter.
config :phoenix_gen_api, :rate_limiter,
  enabled: true,
  global_limits: [
    %{key: :user_id, max_requests: 2000, window_ms: 60_000},
    %{key: :device_id, max_requests: 10000, window_ms: 60_000}
  ],
  api_limits: [
    %{
      service: "data_service",
      request_type: "export_data",
      key: :user_id,
      max_requests: 10,
      window_ms: 60_000
    }
  ]

In Phoenix Channel you can add add this line for apply PhoenixGenApi:

use  PhoenixGenApi, event: "phoenix_gen_api"

Follow functions will be expanded and reserved for PhoenixGenApi.

handle_in("phoenix_gen_api", payload, socket) 
handle_info({:push, result}, socket)
handle_info({:async_call, result = %Response{}}, socket)
handle_info({:stream_response, result}, socket)

Default PhoenixGenApi will overwrite user_id in Request. Disable this by add option: override_user_id: false to use PhoenixGenApi

In this case, if need you can authenticate by using Phoenix framework.

Now you can start your cluster and test!

After start Phoenix app, PhoenixGenApi will auto pull config from remote node to serve client.

For test in Elixir you can use phoenix_client to create a connection to Phoenix Channel.

You can push a event with content like:

{
  "user_id": "user_1",
  "device_id": "device_1",
  "service": "my_service",
  "request_type": "get_data",
  "request_id": "test_request_1",
  "version": "1.0.0",
  "args": {
    "id": "test_data_id"
  }
}

Result like:

If is async/stream call you will receive a message like this:

{
  "async": true,
  "error": "",
  "has_more": false,
  "request_id": "test_request_1",
  "result": null,
  "success": true
}

After that is a another message with result:

{
  "async": false,
  "error": "",
  "has_more": false,
  "request_id": "test_request_1",
  "result": [
    {
      "id": "14e99227-512a-47b6-b6b1-2d4bc29ca13e",
      "name": "Hello World!"
    }
  ]
}

For better security you can overwrite user_id in server, using basic check permission or passing request info (user_id, device_id, request_id).

Function Versioning

PhoenixGenApi supports multiple versions of the same API, allowing you to manage API evolution and deprecation gracefully.

Version Configuration

Each FunConfig can have a version field (defaults to "0.0.0" if not specified):

%FunConfig{
  request_type: "get_user",
  service: "user_service",
  version: "1.0.0",
  nodes: :local,
  mfa: {MyApp.Users, :get_user_v1, []},
  arg_types: %{"id" => :string},
  response_type: :sync
}

%FunConfig{
  request_type: "get_user",
  service: "user_service",
  version: "2.0.0",
  nodes: :local,
  mfa: {MyApp.Users, :get_user_v2, []},
  arg_types: %{"id" => :string, "fields" => :list_string},
  response_type: :sync
}

Request Version

Clients can specify which version they want to use by including the version field in their request:

{
  "user_id": "user_1",
  "service": "user_service",
  "request_type": "get_user",
  "request_id": "req_1",
  "version": "2.0.0",
  "args": {
    "id": "123",
    "fields": ["name", "email"]
  }
}

If no version is specified, the system defaults to "0.0.0".

Managing Versions

You can manage API versions programmatically:

alias PhoenixGenApi.ConfigDb

# Get a specific version
{:ok, config} = ConfigDb.get("user_service", "get_user", "1.0.0")

# Get the latest enabled version
{:ok, latest_config} = ConfigDb.get_latest("user_service", "get_user")

# Disable a version (e.g., for deprecation)
:ok = ConfigDb.disable("user_service", "get_user", "1.0.0")

# Re-enable a disabled version
:ok = ConfigDb.enable("user_service", "get_user", "1.0.0")

# Delete a specific version
:ok = ConfigDb.delete("user_service", "get_user", "1.0.0")

# List all functions with their versions
%{
  "user_service" => %{
    "get_user" => ["1.0.0", "2.0.0"],
    "create_user" => ["1.0.0"]
  }
} = ConfigDb.get_all_functions()

Version Behavior

Rate Limiter

PhoenixGenApi includes a high-performance sliding window rate limiter using ETS for tracking.

Configuration

Configure rate limits in config.exs:

config :phoenix_gen_api, :rate_limiter,
  enabled: true,
  global_limits: [
    # 2000 requests per minute per user
    %{key: :user_id, max_requests: 2000, window_ms: 60_000},
    # 10000 requests per minute per device
    %{key: :device_id, max_requests: 10000, window_ms: 60_000}
  ],
  api_limits: [
    # Expensive operation: 10 requests per minute
    %{
      service: "data_service",
      request_type: "export_data",
      key: :user_id,
      max_requests: 10,
      window_ms: 60_000
    },
    # Public endpoint: 100 requests per minute
    %{
      service: "public_service",
      request_type: "search",
      key: :ip_address,
      max_requests: 100,
      window_ms: 60_000
    }
  ]

Usage

Rate limiting is automatically checked when you call Executor.execute!/1 or Executor.execute_params!/1.

You can also check rate limits manually:

alias PhoenixGenApi.RateLimiter

# Check rate limit for a request
case RateLimiter.check_rate_limit(request) do
  :ok ->
    # Proceed with execution
    Executor.execute!(request)

  {:error, :rate_limited, details} ->
    # Return rate limit error
    %{
      error: "Rate limit exceeded",
      retry_after: details.retry_after_ms,
      current_requests: details.current_requests,
      max_requests: details.max_requests
    }
end

# Check global rate limit directly
RateLimiter.check_rate_limit("user_123", :global, :user_id)

# Check API-specific rate limit
RateLimiter.check_rate_limit("user_123", {"my_service", "my_api"}, :user_id)

Dynamic Configuration

Update rate limits at runtime:

# Add a new global limit
RateLimiter.add_global_limit(%{
  key: :ip_address,
  max_requests: 5000,
  window_ms: 60_000
})

# Update configuration
RateLimiter.update_config(%{
  enabled: true,
  global_limits: [...],
  api_limits: [...]
})

Rate Limit Keys

Supported key types:

Telemetry

The rate limiter emits telemetry events for monitoring:

:telemetry.attach(
  "rate-limiter-monitor",
  [:phoenix_gen_api, :rate_limiter, :exceeded],
  fn event, measurements, metadata, config ->
    Logger.warning("Rate limit exceeded: #{inspect(metadata)}")
  end,
  %{}
)

Permission System

PhoenixGenApi provides a flexible permission system with multiple modes for authentication and authorization.

Permission Modes

Configure permissions in FunConfig.check_permission:

1. Disabled (false)

No permission check. Useful for public endpoints.

%FunConfig{
  request_type: "get_public_data",
  check_permission: false
}

2. Any Authenticated (:any_authenticated)

Requires a valid user_id. Any authenticated user can access.

%FunConfig{
  request_type: "get_profile",
  check_permission: :any_authenticated
}

# Passes - user is authenticated
request = %Request{user_id: "user_123"}

# Fails - no user_id
request = %Request{user_id: nil}

3. Argument-Based ({:arg, arg_name})

User can only access their own data. The specified argument must match user_id.

%FunConfig{
  request_type: "get_user_profile",
  check_permission: {:arg, "user_id"}
}

# Passes - user accessing their own data
request = %Request{
  user_id: "user_123",
  args: %{"user_id" => "user_123"}
}

# Fails - user trying to access another user's data
request = %Request{
  user_id: "user_123",
  args: %{"user_id" => "user_999"}
}

4. Role-Based ({:role, allowed_roles})

User must have one of the specified roles.

%FunConfig{
  request_type: "delete_user",
  check_permission: {:role, ["admin", "moderator"]}
}

# Passes - user has admin role
request = %Request{
  user_id: "user_123",
  user_roles: ["admin"]
}

# Passes - user has moderator role
request = %Request{
  user_id: "user_456",
  user_roles: ["moderator", "user"]
}

# Fails - user doesn't have required role
request = %Request{
  user_id: "user_789",
  user_roles: ["user"]
}

Request Structure for Permissions

%Request{
  user_id: "user_123",           # Required for permission checks
  user_roles: ["admin", "user"], # Required for role-based checks
  request_type: "get_profile",
  service: "user_service",
  request_id: "req_1",
  args: %{"user_id" => "user_123"}
}

Security Best Practices

Retry

PhoenixGenApi supports configurable retry behavior when request execution fails. The retry field in FunConfig controls how failed requests are retried.

Retry Configuration

The retry field accepts the following values:

Value Meaning
nil No retry (default, backward compatible)
3 (number) Equivalent to {:all_nodes, 3} — retry across all available nodes
{:same_node, 2} Retry on the same node(s) originally selected by choose_node_mode
{:all_nodes, 3} Retry across all available nodes in the cluster

How It Works

When a request execution fails (returns {:error, _} or {:error, _, _}), the executor retries according to the retry configuration:

Validation

The retry field is validated as follows:

Normalization

FunConfig.normalize_retry/1 converts the raw config to a standard format:

Input Normalized
nilnil
3{:all_nodes, 3}
{:same_node, 2}{:same_node, 2}
{:all_nodes, 5}{:all_nodes, 5}
2.7 (float) {:all_nodes, 2} (truncated)

Telemetry

Retry attempts emit telemetry events at [:phoenix_gen_api, :executor, :retry] with:

:telemetry.attach(
  "retry-monitor",
  [:phoenix_gen_api, :executor, :retry],
  fn _event, measurements, metadata, _config ->
    Logger.info("Retry attempt #{measurements.attempt}, mode: #{metadata.mode}, type: #{metadata.type}")
  end,
  %{}
)

Example FunConfig with Retry

%FunConfig{
  request_type: "get_data",
  service: "my_service",
  nodes: [:"node1@host", :"node2@host", :"node3@host"],
  choose_node_mode: :random,
  timeout: 5_000,
  mfa: {MyApp.Interface.Api, :get_data, []},
  arg_types: %{"id" => :string},
  response_type: :sync,
  retry: {:all_nodes, 3}  # Retry up to 3 times across all available nodes
}

Full Example

We will add a full example in the future.

Planned Features

Support AI agents & MCP for dev & improvement

Run this command for update guide & rules from deps to repo for supporting ai agents.

mix usage_rules.sync AGENTS.md --all \
  --link-to-folder deps \
  --inline usage_rules:all

Run this command for enable MCP server

mix tidewave

Config MCP for agent http://localhost:4114/tidewave/mcp, changes port in mix.exs file if needed. Go to Tidewave for more informations.