Packages

Define delegating facade functions and behaviours from a single @spec-shaped declaration.

Current section

Files

Jump to
facade lib facade.ex
Raw

lib/facade.ex

defmodule Facade do
@moduledoc """
`defapi/1` defines a facade function **and** a matching callback from a single
`@spec`-shaped AST. The generated function delegates to the module passed as
its first argument, so every facade takes one more argument than the callback
it advertises: the implementing module.
This is useful when you want:
- a behaviour contract (`@callback`) for implementations
- a single, consistent call shape that supports dependency injection
(pass the implementation module explicitly)
Quick example:
defmodule A do
import Facade
@doc "Sample documentation."
defapi foo(a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer()
end
defmodule Impl do
@behaviour A
@impl A
def foo(a, l) when is_integer(a) and is_list(l), do: {a, l}
end
# Usage:
A.foo(Impl, 10, [1, 2, 3])
The spec you pass to `defapi/1` follows the same rules as `@spec`:
- zero-arity can be written as `foo :: atom()` or `foo() :: atom()`
- guards are supported via `when`
- attributes like `@doc`, `@doc false`, and `@deprecated` on the call site
are copied onto the generated function
"""
@type missing_callbacks :: [{atom(), non_neg_integer()}]
@type validate_result :: :ok | {:error, missing_callbacks()}
defmodule MissingCallbacksError do
@moduledoc """
Raised by `validate!/2` (and `validate!/1` when `use Facade` is used in a behaviour module)
when a module is missing required callbacks.
"""
defexception [:behaviour, :module, :missing_callbacks]
@type t :: %__MODULE__{
behaviour: module(),
module: module(),
missing_callbacks: Facade.missing_callbacks()
}
@impl Exception
def message(%__MODULE__{behaviour: behaviour, module: module, missing_callbacks: missing}) do
formatted =
missing
|> Enum.map(fn {name, arity} -> "#{name}/#{arity}" end)
|> Enum.join(", ")
"module #{inspect(module)} does not implement behaviour #{inspect(behaviour)}; missing callbacks: #{formatted}"
end
end
@doc """
When used from a behaviour module, imports `defapi/1` and defines:
- `validate/1` — validates an implementation module at runtime
- `validate!/1` — like `validate/1`, but raises `#{inspect(MissingCallbacksError)}`
"""
defmacro __using__(_opts) do
quote do
import Facade, only: [defapi: 1]
@doc "Validates that `impl_module` implements the required callbacks of this behaviour."
@spec validate(module()) :: Facade.validate_result()
def validate(impl_module), do: Facade.validate(__MODULE__, impl_module)
@doc "Like `validate/1`, but raises if required callbacks are missing."
@spec validate!(module()) :: :ok
def validate!(impl_module), do: Facade.validate!(__MODULE__, impl_module)
end
end
@doc """
Validates that `impl_module` implements required callbacks of `behaviour_module`.
Optional callbacks (declared via `@optional_callbacks`) are ignored.
"""
@spec validate(module(), module()) :: validate_result()
def validate(behaviour_module, impl_module) do
required = required_callbacks!(behaviour_module)
missing =
case Code.ensure_loaded(impl_module) do
{:module, _} ->
Enum.reject(required, fn {name, arity} ->
function_exported?(impl_module, name, arity)
end)
{:error, _reason} ->
required
end
if missing == [], do: :ok, else: {:error, missing}
end
@doc """
Like `validate/2`, but raises `#{inspect(MissingCallbacksError)}` on failure.
"""
@spec validate!(module(), module()) :: :ok | no_return()
def validate!(behaviour_module, impl_module) do
case validate(behaviour_module, impl_module) do
:ok ->
:ok
{:error, missing_callbacks} ->
raise MissingCallbacksError,
behaviour: behaviour_module,
module: impl_module,
missing_callbacks: missing_callbacks
end
end
@doc """
Defines a facade function and a callback with the given specification.
The generated function will take an extra first argument: the module
implementing the callback, and inherits caller attributes such as `@doc`,
`@doc false`, and `@deprecated`.
## Examples
defapi foo(a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer()
Generates:
@spec foo(mod :: module(), a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer()
def foo(mod, a, l) do
mod.foo(a, l)
end
@callback foo(a :: integer(), l :: list(x)) :: {integer(), list()} when x: integer()
Zero-arity forms also work:
defapi ping :: :pong
"""
defmacro defapi(spec_ast) do
{name, args_sig_ast, ret_ast, guards_ast} = split_cb!(spec_ast)
{vars, vars_spec} = build_args(args_sig_ast)
mod_spec = {:"::", [], [{:mod, [], nil}, {:module, [], []}]}
spec = {:"::", [], [{name, [], [mod_spec | vars_spec]}, ret_ast]}
spec =
case guards_ast do
nil -> spec
ast -> {:when, [], [spec, ast]}
end
quote do
@spec unquote(spec)
def unquote(name)(mod, unquote_splicing(vars)) do
mod.unquote(name)(unquote_splicing(vars))
end
@callback unquote(spec_ast)
end
end
defp split_cb!(ast) do
{core, guards} =
case ast do
{:when, _, [inner, guards]} -> {inner, guards}
other -> {other, nil}
end
case core do
{:"::", _, [head, ret]} ->
case head do
{name, _, args} when is_atom(name) ->
{name, args, ret, guards}
_ ->
raise ArgumentError,
"defapi expects `name(args...) :: return` (e.g. `foo(a :: integer()) :: atom()`), got: #{Macro.to_string(ast)}"
end
_ ->
raise ArgumentError,
"defapi expects `name(args...) :: return` (e.g. `foo(a :: integer()) :: atom()`), got: #{Macro.to_string(ast)}"
end
end
defp required_callbacks!(behaviour_module) do
unless function_exported?(behaviour_module, :behaviour_info, 1) do
raise ArgumentError,
"expected #{inspect(behaviour_module)} to be a behaviour module (must define @callback and export behaviour_info/1)"
end
callbacks = behaviour_module.behaviour_info(:callbacks)
optional = behaviour_module.behaviour_info(:optional_callbacks)
callbacks -- optional
end
defp build_args(arg_asts) do
pairs =
arg_asts
|> List.wrap()
|> Enum.with_index(1)
|> Enum.map(fn {arg_ast, idx} -> normalize_arg(arg_ast, idx) end)
arg_vars = Enum.map(pairs, fn {var, _typed} -> var end)
arg_specs = Enum.map(pairs, fn {_var, typed} -> typed end)
{arg_vars, arg_specs}
end
defp normalize_arg({:"::", _, [var_ast, _]} = typed, _idx) do
var =
case var_ast do
{name, meta, ctx} when is_atom(name) and is_list(meta) ->
{name, meta, ctx}
_ ->
# typed but not a variable on LHS, treat as unnamed
Macro.var(:arg, nil)
end
{var, typed}
end
defp normalize_arg({name, meta, ctx} = var_ast, _idx)
when is_atom(name) and is_list(meta) and (is_atom(ctx) or is_nil(ctx)) do
{var_ast, var_ast}
end
defp normalize_arg(other, idx) do
# unnamed type like integer() or some AST: generate argN variable
var = Macro.var(:"arg#{idx}", nil)
{var, other}
end
end