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