Packages
phoenix_gen_api
2.21.0
2.22.0
2.21.1
2.21.0
2.20.1
2.20.0
2.19.0
2.18.1
2.18.0
2.17.1
2.17.0
2.16.0
2.15.0
2.14.0
2.13.0
2.12.0
2.11.0
2.10.0
2.9.1
2.9.0
2.8.0
2.7.0
2.6.1
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.1
2.1.0
2.0.1
2.0.0
1.2.0
1.1.3
1.1.2
1.1.1
1.1.0
1.0.1
1.0.0
0.2.1
0.2.0
0.1.1
0.1.0
0.0.16
0.0.15
0.0.14
0.0.13
0.0.12
0.0.11
0.0.10
0.0.9
0.0.8
0.0.7
0.0.6
0.0.5
0.0.4
0.0.3
0.0.2
0.0.1
A library for fast develop APIs in Elixir cluster, using Phoenix Channels for transport data, auto pull api configs from service nodes.
Current section
Files
Jump to
Current section
Files
lib/phoenix_gen_api/config_cache/config_receiver.ex
defmodule PhoenixGenApi.ConfigReceiver do
@moduledoc """
A server-side GenServer that receives pushed configs from remote nodes.
Remote nodes can push their service configurations to the server/gateway node
using this module. The receiver validates the pushed data, stores it in
`ConfigDb`, and optionally registers the service with `ConfigPuller` for
auto-pull.
## Push Mechanism
When a remote node pushes a `PushConfig`, the receiver:
1. Validates the `PushConfig` struct
2. Compares the `config_version` with the locally stored version
3. If the version matches (and `:force` is not set), the push is skipped
4. If new or forced:
- Validates all `FunConfig` items
- Enforces the service name on each `FunConfig`
- Ensures each `FunConfig` has a version (defaults to `nil`)
- The value `"0.0.0"` is reserved as a sentinel and cannot be explicitly registered
- Stores them in `ConfigDb` via `ConfigDb.batch_add/1`
- Stores the `PushConfig` in state
- If the `PushConfig` has `module`/`function` for auto-pull, registers
it with `ConfigPuller` via `ConfigPuller.add/1`
## Atomicity
The push operation is atomic — either all configs are stored or none. If
validation fails for the `PushConfig` or any `FunConfig` item, the entire
push is rejected.
## Important Notes
- This module runs on the **server/gateway** node (not `client_mode`)
- It should NOT be started when `client_mode: true`
"""
use GenServer, restart: :permanent
alias PhoenixGenApi.Structs.{PushConfig, FunConfig, ServiceConfig}
alias PhoenixGenApi.{ConfigDb, ConfigPuller, Security}
require Logger
### Public API
@doc """
Starts the `ConfigReceiver` GenServer.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Receives a `PushConfig` struct (or map that can be decoded into one).
Validates the `PushConfig`, checks the version, and stores the configs
if the version is new. If the `config_version` matches what is already
stored, the push is skipped.
## Returns
- `{:ok, :accepted}` - New configs were stored successfully
- `{:ok, :skipped, reason}` - Push was skipped (e.g., version matches)
- `{:error, reason}` - Validation failure
"""
@spec push(PushConfig.t() | map()) ::
{:ok, :accepted} | {:ok, :skipped, term()} | {:error, term()}
def push(config) do
push(config, [])
end
@doc """
Same as `push/1` but with options.
## Options
- `:force` - Force push even if version matches (default: `false`)
"""
@spec push(PushConfig.t() | map(), keyword()) ::
{:ok, :accepted} | {:ok, :skipped, term()} | {:error, term()}
def push(config, opts) do
force = Keyword.get(opts, :force, false)
GenServer.call(__MODULE__, {:push, config, force})
end
@doc """
Verifies that the server has the given service and `config_version`.
## Parameters
- `service` - The service name (string or atom)
- `config_version` - The config version string to verify
## Returns
- `{:ok, :matched}` - Version matches what is stored
- `{:ok, :mismatch, stored_version}` - Version differs from what is stored
- `{:error, :not_found}` - Service is not known
"""
@spec verify(String.t() | atom(), String.t()) ::
{:ok, :matched} | {:ok, :mismatch, String.t()} | {:error, :not_found}
def verify(service, config_version) do
GenServer.call(__MODULE__, {:verify, service, config_version})
end
@doc """
Returns the `PushConfig` for a given service name, or `nil`.
"""
@spec get_pushed_config(String.t() | atom()) :: PushConfig.t() | nil
def get_pushed_config(service) do
GenServer.call(__MODULE__, {:get_pushed_config, service})
end
@doc """
Returns a map of all pushed services and their config versions.
"""
@spec get_all_pushed_services() :: %{(String.t() | atom()) => String.t()}
def get_all_pushed_services() do
GenServer.call(__MODULE__, :get_all_pushed_services)
end
@doc """
Returns a status snapshot for the config receiver.
"""
@spec status() :: map()
def status() do
GenServer.call(__MODULE__, :status)
end
@doc """
Removes a pushed service from the receiver's state.
Also removes the service from `ConfigPuller` if it was registered for
auto-pull.
"""
@spec delete_pushed_service(String.t() | atom()) :: :ok
def delete_pushed_service(service) do
GenServer.call(__MODULE__, {:delete_pushed_service, service})
end
### Callbacks
@impl true
def init(_opts) do
Logger.info("[ConfigReceiver] init")
state = %{
pushed_configs: %{},
service_versions: %{}
}
{:ok, state}
end
@impl true
def handle_call({:push, config, force?}, _from, state) do
result = do_push(config, force?, state)
case result do
{:ok, :accepted, new_state} ->
{:reply, {:ok, :accepted}, new_state}
{:ok, :skipped, reason, state} ->
{:reply, {:ok, :skipped, reason}, state}
{:error, reason, state} ->
{:reply, {:error, reason}, state}
end
end
def handle_call({:verify, service, config_version}, _from, state) do
case Map.get(state.service_versions, service) do
nil ->
{:reply, {:error, :not_found}, state}
^config_version ->
{:reply, {:ok, :matched}, state}
stored_version ->
{:reply, {:ok, :mismatch, stored_version}, state}
end
end
def handle_call({:get_pushed_config, service}, _from, state) do
{:reply, Map.get(state.pushed_configs, service), state}
end
def handle_call(:get_all_pushed_services, _from, state) do
{:reply, state.service_versions, state}
end
def handle_call(:status, _from, state) do
{:reply,
%{
status: :ok,
pushed_services: state.service_versions,
pushed_configs: state.pushed_configs
}, state}
end
def handle_call({:delete_pushed_service, service}, _from, state) do
case Map.get(state.pushed_configs, service) do
nil ->
{:reply, :ok, state}
pushed_config ->
# Remove from ConfigPuller if it was registered for auto-pull
maybe_unregister_from_puller(pushed_config)
new_state = %{
state
| pushed_configs: Map.delete(state.pushed_configs, service),
service_versions: Map.delete(state.service_versions, service)
}
Logger.info("[ConfigReceiver] deleted pushed service: service=#{inspect(service)}")
{:reply, :ok, new_state}
end
end
### Private - Push Logic
defp do_push(raw_config, force?, state) do
with {:ok, config} <- decode_push_config(raw_config),
:ok <- validate_push_config(config),
:ok <- validate_push_token(config),
:ok <- maybe_skip_if_same_version(config, force?, state),
{:ok, prepared_configs} <- prepare_fun_configs(config) do
store_and_register(config, prepared_configs, state)
else
{:skip, reason} ->
{:ok, :skipped, reason, state}
{:error, reason} ->
{:error, reason, state}
end
end
defp decode_push_config(config = %PushConfig{}), do: {:ok, config}
defp decode_push_config(data) when is_map(data) do
try do
config = PushConfig.from_map(data)
{:ok, config}
rescue
e ->
Logger.error("[ConfigReceiver] failed to decode PushConfig: error=#{inspect(e)}")
{:error, :invalid_push_config_data}
end
end
defp decode_push_config(other) do
Logger.error("[ConfigReceiver] push received invalid data type: data=#{inspect(other)}")
{:error, :invalid_push_config_data}
end
defp validate_push_config(config = %PushConfig{}) do
case PushConfig.validate_with_details(config) do
{:ok, _} ->
:ok
{:error, errors} ->
Logger.error("[ConfigReceiver] PushConfig validation failed: errors=#{inspect(errors)}")
{:error, {:validation_failed, errors}}
end
end
defp validate_push_token(%PushConfig{push_token: token, service: service}) do
if Security.valid_push_token?(token) do
:ok
else
Logger.warning(
"[ConfigReceiver] push token rejected: service=#{inspect(service)} token=#{inspect(token)}"
)
{:error, :invalid_push_token}
end
end
defp maybe_skip_if_same_version(
%PushConfig{service: service, config_version: version},
true,
_state
) do
Logger.debug(
"[ConfigReceiver] force push: service=#{inspect(service)} version=#{inspect(version)}"
)
:ok
end
defp maybe_skip_if_same_version(
%PushConfig{service: service, config_version: version},
false,
state
) do
case Map.get(state.service_versions, service) do
^version ->
Logger.warning(
"[ConfigReceiver] skipping push: service=#{inspect(service)} version=#{inspect(version)} already stored"
)
{:skip, :version_matches}
_ ->
:ok
end
end
defp prepare_fun_configs(%PushConfig{service: service, fun_configs: fun_configs}) do
{prepared, errors} =
Enum.flat_map_reduce(fun_configs, [], fn
%FunConfig{} = config, acc_errors ->
config =
config
|> PhoenixGenApi.Helpers.Shared.enforce_service_name(service)
|> PhoenixGenApi.Helpers.Shared.ensure_version()
if FunConfig.valid?(config) do
{[config], acc_errors}
else
reasons =
case FunConfig.validate_with_details(config) do
{:error, errors} -> errors
_ -> ["unknown validation error"]
end
error_msg =
"invalid FunConfig: request_type=#{inspect(config.request_type)}, service=#{inspect(service)}, version=#{inspect(config.version)}, nodes: #{inspect(config.nodes)}, mfa: #{inspect(config.mfa)}, response_type: #{inspect(config.response_type)}, check_permission: #{inspect(config.check_permission)}"
Logger.error("[ConfigReceiver] #{error_msg}")
PhoenixGenApi.ConfigFailed.record(config, reasons, :push, nil)
{[], [error_msg | acc_errors]}
end
other, acc_errors ->
error_msg = "unexpected item in fun_configs: item=#{inspect(other)}"
Logger.error("[ConfigReceiver] #{error_msg}")
{[], [error_msg | acc_errors]}
end)
if prepared == [] do
Logger.error("[ConfigReceiver] no valid FunConfig items: service=#{inspect(service)}")
{:error, {:all_invalid, Enum.reverse(errors)}}
else
if errors != [] do
Logger.warning(
"[ConfigReceiver] partial push: #{length(prepared)} valid, #{length(errors)} invalid, service=#{inspect(service)}, errors: #{inspect(Enum.reverse(errors))}"
)
end
{:ok, prepared}
end
end
defp store_and_register(config = %PushConfig{}, prepared_configs, state) do
service = config.service
version = config.config_version
case ConfigDb.batch_add(prepared_configs) do
{:ok, count} ->
:telemetry.execute(
[:phoenix_gen_api, :config, :push],
%{count: count},
%{service: service, version: version}
)
Logger.info(
"[ConfigReceiver] stored configs: service=#{inspect(service)} version=#{inspect(version)} count=#{count}"
)
# Register with ConfigPuller if module/function are present
maybe_register_with_puller(config)
new_state = %{
state
| pushed_configs: Map.put(state.pushed_configs, service, config),
service_versions: Map.put(state.service_versions, service, version)
}
{:ok, :accepted, new_state}
{:error, :all_invalid} ->
Logger.error(
"[ConfigReceiver] ConfigDb.batch_add failed: service=#{inspect(service)} reason=all_invalid"
)
{:error, :batch_add_failed, state}
end
end
### Private - ConfigPuller Integration
defp maybe_register_with_puller(config = %PushConfig{}) do
case PushConfig.to_service_config(config) do
nil ->
Logger.debug(
"[ConfigReceiver] no auto-pull registration: service=#{inspect(config.service)} reason=module_function_not_provided"
)
:ok
%ServiceConfig{} = service_config ->
Logger.info(
"[ConfigReceiver] registering service with ConfigPuller: service=#{inspect(config.service)}"
)
ConfigPuller.add([service_config])
end
end
defp maybe_unregister_from_puller(config = %PushConfig{}) do
case PushConfig.to_service_config(config) do
nil ->
:ok
%ServiceConfig{} = service_config ->
Logger.info(
"[ConfigReceiver] unregistering service from ConfigPuller: service=#{inspect(config.service)}"
)
ConfigPuller.delete([service_config])
end
end
end