defmodule NebulaAPI do
@moduledoc """
Documentation for `NebulaAPI`.
"""
defmacro __using__(opts \\ []) do
resolved = __register__(__CALLER__, opts)
quote do
use NebulaAPI.AST
# Runtime accessor for the `use NebulaAPI` options — a function head on a
# literal, so hot paths (APIServer.resolve_timeout/2, on every remote
# call) read it without scanning module attributes. The persisted
# :nebula_api attribute remains as the discovery marker NebulaAPI.Server
# relies on, and as the compile-time source for defapi (self_node).
@doc false
def __nebula_api__(:default_timeout),
do: unquote(Keyword.fetch!(resolved, :default_timeout))
def __nebula_api__(:max_concurrent_calls),
do: unquote(Keyword.fetch!(resolved, :max_concurrent_calls))
end
end
defp __register__(env, opts) do
defaults =
NebulaAPI.Config.default_opts()
|> Keyword.validate!(
self_node: node(),
allow_unknown_self_node: false,
max_concurrent_calls: :infinity,
default_timeout: nil
)
opts =
opts
|> Enum.map(fn {k, v} ->
{k, Code.eval_quoted(v, [], env) |> elem(0)}
end)
|> Keyword.validate!(defaults)
nodes_names =
NebulaAPI.Config.nodes()
|> Keyword.keys()
self_node = Keyword.fetch!(opts, :self_node)
# No node name at compile time (node() is :nonode@nohost) means the name isn't SET — a
# different problem from an UNKNOWN name, so `allow_unknown_self_node` deliberately does
# NOT cover it (it's almost always a forgotten `--name`). Only an explicit nameless build
# is allowed, via `allow_nonode_nohost: true`.
if self_node == :nonode@nohost and not NebulaAPI.Config.config()[:allow_nonode_nohost] do
raise CompileError,
line: env.line,
file: env.file,
description: """
Error using NebulaAPI inside #{inspect(env.module)} — no node name set at compile time.
node() is :nonode@nohost: you compiled without `--name`. NebulaAPI bakes routing per
node, so it must know which node this is. Either:
- compile with `elixir --name node@host -S mix compile`
(or set `config :nebula_api, default_opts: [self_node: :"node@host"]` for dev/test), or
- set `config :nebula_api, allow_nonode_nohost: true` for a deliberate nameless,
generic build that serves nothing.
(allow_unknown_self_node does NOT apply here — the name isn't unknown, it's unset.)
"""
end
allow_unknown_self_node =
opts
|> Keyword.fetch!(:allow_unknown_self_node)
unless allow_unknown_self_node do
unknown_self_node =
not (nodes_names
|> Enum.member?(self_node))
if unknown_self_node do
raise CompileError,
line: env.line,
file: env.file,
description: """
Error using NebulaAPI inside #{inspect(env.module)} !
self_node is an unknown node, please check you're compiling for a known node :
-> self_node = #{inspect(self_node)}
-> node() = #{inspect(node())}
Configured nodes :
#{nodes_names |> Enum.map(&"\t- :\"#{&1}\"") |> Enum.join("\n")}
"""
end
end
max_concurrent_calls = Keyword.fetch!(opts, :max_concurrent_calls)
unless max_concurrent_calls == :infinity or
(is_integer(max_concurrent_calls) and max_concurrent_calls > 0) do
raise CompileError,
line: env.line,
file: env.file,
description: """
Invalid max_concurrent_calls in `use NebulaAPI` inside #{inspect(env.module)}:
#{inspect(max_concurrent_calls)}
Expected a positive integer or :infinity (the default).
`max_concurrent_calls: 1` gives strict serialization.
"""
end
default_timeout = Keyword.fetch!(opts, :default_timeout)
unless is_nil(default_timeout) or (is_integer(default_timeout) and default_timeout > 0) do
raise CompileError,
line: env.line,
file: env.file,
description: """
Invalid default_timeout in `use NebulaAPI` inside #{inspect(env.module)}:
#{inspect(default_timeout)}
Expected a positive integer (milliseconds), e.g. default_timeout: 15_000.
"""
end
# Single compile-time source of truth, persisted per defapi as
# {{fn_name, arity}, configured_nodes}. local/remote on a node are DERIVED from it
# (node ∈ configured ⇒ local) — there are no separate local/remote method lists.
# Exposed at runtime via NebulaAPI.APIServer.{configured_nodes,registered_local_methods}.
Module.register_attribute(env.module, :nebula_configured_nodes,
accumulate: true,
persist: true
)
# persist: true so the marker is readable at runtime via __info__(:attributes),
# which is how NebulaAPI.Server discovers the modules that `use NebulaAPI`.
Module.register_attribute(env.module, :nebula_api, persist: true)
Module.put_attribute(env.module, :nebula_api, opts)
opts
end
end