Current section

Files

Jump to
x3m_system lib router.ex
Raw

lib/router.ex

defmodule X3m.System.Router do
@moduledoc """
Registers system wide services.
Each `service/2` macro registers system-wide service and function with
documentation in module that `uses` this module.
`servicep/2` is considered as private service and is not introduced to other nodes
in the cluster.
Service functions invoke function of the same name of specified module.
If result of that invocation is `{:reply, %X3m.System.Message{}}`,
it sends message to `message.reply_to` pid.
If result of invocation is `:noreply`, nothing is sent to that pid.
In any case function returns `:ok`.
## Examples
### Defining router
defmodule MyRouter do
use X3m.System.Router
@servicedoc false
service :create_user, MessageHandler
@servicedoc \"""
overridden!
\"""
service :get_user, MessageHandler
service :edit_user, MessageHandler
servicep :private_service, MessageHandler
end
When defining a router you can pass `:ensure_local_logging?` argument in
options which is expected to be `boolean()`.
This argument is optional and if not passed is treated as `true` thus
ensuring logs of the called node are not shown on the calling node.
When this argument is passed as `false`, log messages sent to stdout by the called node
will be shown in the caller node stdout, thus keeping REPL behaviour is maintained.
## Examples
### Defining a router ensuring remote callers don't receive logger stdout
This is the default behaviour, so
defmodule MyRouter do
use X3m.System.Router
...
end
is identical to
defmodule MyRouter do
use X3m.System.Router, ensure_local_logging?: true
...
end
### Defining a router that keeps REPL logging behaviour
Pass `ensure_local_logging?: false` so log messages emitted by the called node are
shown in the *caller* node's stdout (useful when driving services from an `iex`
session):
defmodule MyRouter do
use X3m.System.Router, ensure_local_logging?: false
...
end
### Getting registered services (public, private, or by default all)
iex> MyRouter.registered_services()
[create_user: 1, get_user: 1, edit_user: 1, private_service: 1]
iex> MyRouter.registered_services(:public)
[create_user: 1, get_user: 1, edit_user: 1]
### Invoking service as a function
iex> :create_user |>
...> X3m.System.Message.new() |>
...> MyRouter.create_user()
:ok
"""
alias X3m.System.Message
defmacro service(service_name, message_handler, f) do
quote do
case(@servicedoc) do
nil ->
@doc """
Accepts `#{unquote(service_name)}` service call, routing it's `message` to
`#{unquote(message_handler)}.#{unquote(f)}/1`.
If result of that invocation is `{:reply, %X3m.System.Message{}}`,
it sends message to `message.reply_to` pid.
If result of invocation is `:noreply`, nothing is sent to that pid.
In any case function returns `:ok`.
## Example:
iex> #{inspect(unquote(service_name))} |>
...> X3m.System.Message.new() |>
...> #{__MODULE__}.#{unquote(service_name)}()
:ok
"""
other ->
@doc other
end
@x3m_service [{unquote(service_name), 1}]
@spec unquote(service_name)(Message.t()) :: :ok
def unquote(service_name)(%Message{service_name: unquote(service_name)} = message) do
message.logger_metadata
|> set_local_logging()
|> Logger.metadata()
X3m.System.Instrumenter.execute(:service_request_received, %{}, %{
service: unquote(service_name),
visibility: :public,
origin_node: message.origin_node
})
__MODULE__
|> apply(:authorize, [message])
|> case do
:ok ->
message
|> choose_node()
|> _invoke(unquote(message_handler), unquote(f), message)
other ->
send(message.reply_to, Message.error(message, other))
:ok
end
end
@servicedoc nil
end
end
defmacro service(service_name, message_handler) do
quote do
service(unquote(service_name), unquote(message_handler), unquote(service_name))
end
end
defmacro servicep(service_name, message_handler, f) do
quote do
case(@servicedoc) do
nil ->
@doc """
This service is not shared with other nodes!
Accepts `#{unquote(service_name)}` service call, routing it's `message` to
`#{unquote(message_handler)}.#{unquote(f)}/1`.
If result of that invocation is `{:reply, %X3m.System.Message{}}`,
it sends message to `message.reply_to` pid.
If result of invocation is `:noreply`, nothing is sent to that pid.
In any case function returns `:ok`.
## Example:
iex> #{inspect(unquote(service_name))} |>
...> X3m.System.Message.new() |>
...> #{__MODULE__}.#{unquote(service_name)}()
:ok
"""
other ->
@doc other
end
@x3m_servicep [{unquote(service_name), 1}]
@spec unquote(service_name)(Message.t()) :: :ok
def unquote(service_name)(%Message{service_name: unquote(service_name)} = message) do
Logger.metadata(message.logger_metadata)
X3m.System.Instrumenter.execute(:service_request_received, %{}, %{
service: unquote(service_name),
visibility: :private,
origin_node: message.origin_node
})
__MODULE__
|> apply(:authorize, [message])
|> case do
:ok ->
message
|> choose_node()
|> _invoke(unquote(message_handler), unquote(f), message)
other ->
send(message.reply_to, Message.error(message, other))
:ok
end
end
@servicedoc nil
end
end
defmacro servicep(service_name, message_handler) do
quote do
servicep(unquote(service_name), unquote(message_handler), unquote(service_name))
end
end
defmacro __using__(opts) do
ensure_local_logging? = Keyword.get(opts, :ensure_local_logging?, true)
unless is_boolean(ensure_local_logging?),
do:
raise(
ArgumentError,
"""
`:ensure_local_logging?` is expected to be boolean!
Check that you are passing either atom `true` or `false`.
"""
)
logging_function =
quote do
if unquote(ensure_local_logging?) do
@spec set_local_logging(keyword()) :: keyword()
def set_local_logging(logger_metadata),
do: Keyword.put(logger_metadata, :gl, Process.whereis(:user))
else
@spec set_local_logging(keyword()) :: keyword()
def set_local_logging(logger_metadata),
do: logger_metadata
end
end
module =
quote do
alias X3m.System.Router
require Router
import Router
Module.register_attribute(
__MODULE__,
:x3m_service,
accumulate: true,
persist: true
)
Module.register_attribute(
__MODULE__,
:x3m_servicep,
accumulate: true,
persist: true
)
@servicedoc nil
@doc !"""
Returns list of public, private or all service functions with their arrity.
"""
@spec registered_services(:public | :private | :all) :: [{:atom, non_neg_integer}]
def registered_services(visibility \\ :all)
def registered_services(:public) do
__MODULE__.__info__(:attributes)
|> Keyword.get_values(:x3m_service)
|> List.flatten()
end
def registered_services(:private) do
__MODULE__.__info__(:attributes)
|> Keyword.get_values(:x3m_servicep)
|> List.flatten()
end
def registered_services(:all),
do: registered_services(:private) ++ registered_services(:public)
@doc !"""
Sends internal event for each service to be registered in runtime.
"""
@spec register_services :: :ok
def register_services do
public_services =
registered_services(:public)
|> Enum.map(fn {service, _arrity} -> {service, __MODULE__} end)
|> Enum.into(%{})
private_services =
registered_services(:private)
|> Enum.map(fn {service, _arrity} -> {service, __MODULE__} end)
|> Enum.into(%{})
X3m.System.Instrumenter.execute(:register_local_services, %{}, %{
public: public_services,
private: private_services
})
:ok
end
def authorized?(%Message{} = message),
do: authorize(message) == :ok
@doc false
@spec _invoke(:local | node(), atom, atom, Message.t()) :: :ok
def _invoke(node, message_handler, f, message)
def _invoke(:local, message_handler, f, message) do
message.logger_metadata
|> set_local_logging()
|> Logger.metadata()
mono_start = System.monotonic_time()
X3m.System.Instrumenter.execute(
:executing_service,
%{start: DateTime.utc_now(), mono_start: mono_start},
%{
node: Node.self(),
service: message.service_name
}
)
case apply(message_handler, f, [message]) do
{:reply, %Message{} = message} ->
message =
case message do
%Message{dry_run: :verbose} = msg ->
%Message{msg | request: nil}
%Message{} = msg ->
%Message{msg | request: nil, events: []}
end
send(message.reply_to, message)
X3m.System.Instrumenter.execute(
:execution_finished,
%{
time: DateTime.utc_now(),
duration: X3m.System.Instrumenter.duration(mono_start)
},
%{message: message}
)
:ok
:noreply ->
:ok
end
end
def _invoke(node, message_handler, f, message) do
true =
:rpc.cast(node, __MODULE__, :_invoke, [
:local,
message_handler,
f,
message
])
:ok
end
@doc !"""
Choose node on which MFA will be applied.
This is optional callback. By default it will return `:local`,
meaning that `sys_msg` will be handled by local module.
It can be overridden like:
```
def choose_node(%X3m.System.Message{}) do
[:jobs_1@my_comp_name, :local] |> Enum.random()
end
```
"""
@spec choose_node(Message.t()) :: :local | node()
def choose_node(_sys_msg),
do: :local
defoverridable choose_node: 1
@before_compile X3m.System.Router
end
[module, logging_function]
end
defmacro __before_compile__(env) do
# Only inject the deny-by-default `authorize/1` clause when the client hasn't already
# defined its own catch-all. Otherwise our clause would be redundant and the compiler
# would warn (which fails builds run with `--warnings-as-errors`).
if _catch_all_authorize?(env.module) do
nil
else
quote do
# Authorizes given `message`. Should return `:ok` if request is authorized,
# otherwise, response will be set as `Message.response` and will be returned to the caller
# immediately
#
# By default it returns `:forbidden` but it can/should be overridden
# at least for cases where service call should be processed.
#
# ```
# def authorize(%X3m.System.Message{service_name: :example_service, assigns: %{identity: %{admin?: true}}}),
# do: :ok
# ```
@spec authorize(Message.t()) :: :ok | :forbidden
def authorize(_sys_msg),
do: :forbidden
end
end
end
# coveralls-ignore-start
# These run only at compile time (during `__before_compile__` expansion), so the runtime
# coverage tool never exercises them; their behavior is covered by routers compiling with
# and without a catch-all `authorize/1`.
# Returns `true` if `module` already defines an unconditional catch-all `authorize/1`
# clause (a bare variable or `_` with no guard).
defp _catch_all_authorize?(module) do
if Module.defines?(module, {:authorize, 1}) do
{_version, _kind, _meta, clauses} = Module.get_definition(module, {:authorize, 1})
Enum.any?(clauses, &_catch_all_clause?/1)
else
false
end
end
defp _catch_all_clause?({_meta, [{name, _var_meta, context}], [] = _guards, _body})
when is_atom(name) and is_atom(context),
do: true
defp _catch_all_clause?(_clause),
do: false
# coveralls-ignore-stop
end