defmodule NimbleOptionsEx do @moduledoc """ Set of custom functions to enhance the UX with `NimbleOptions`. """ @doc """ Validates a behaviour specified as a module or as a set of functions. _Example:_ iex> schema = [ ...> container: [ ...> required: true, ...> type: {:custom, NimbleOptionsEx, :behaviour, [Supervisor]}, ...> doc: "The implementation of `Supervisor` to be used as a supervisor" ...> ] ...> ] iex> NimbleOptions.validate([container: DynamicSupervisor], schema) {:ok, [container: DynamicSupervisor]} iex> NimbleOptions.validate([container: DateTime], schema) {:error, %NimbleOptions.ValidationError{ message: "invalid value for :container option: module ‹DateTime› does not implement requested callbacks ‹[init: 1]›", key: :container, value: DateTime, keys_path: []}} If no arguments are given, the checker would make sure the module passed is indeed a module, available at the moment of invocation. --- Please note, that `Access` behaviour is somewhat special, because it’s supported for _terms_, such as maps `%{foo: :bar}` (not `Map`,) keywords `[foo: :bar]` (not `Keyword`,) and structs `%Strct{foo: :bar}` (not `Strct` module itself.) Use `access?/1` validator to check whether `Access` is supported _by a term itself_. """ @spec behaviour(module() | any(), module() | {module(), :strict} | []) :: {:ok, validated_option :: module()} | {:error, String.t()} def behaviour(value, funs_or_behaviour \\ []) def behaviour(value, behaviour) when not is_atom(value) do {:error, "expected to be a module, implementing a behaviour, " <> "specified by ‹" <> inspect(behaviour) <> "›, got: ‹" <> inspect(value) <> "›"} end def behaviour(value, behaviour) when is_atom(behaviour) do with {:module, ^behaviour} <- Code.ensure_compiled(behaviour), true <- function_exported?(behaviour, :behaviour_info, 1), funs when is_list(funs) <- behaviour.behaviour_info(:callbacks) -- behaviour.behaviour_info(:optional_callbacks) do behaviour(value, funs) else _ -> {:error, "schema is invalid, ‹" <> inspect(behaviour) <> "› is not implementing the expected behaviour"} end end def behaviour(value, {behaviour, :strict}) when is_atom(behaviour) do with {:module, ^behaviour} <- Code.ensure_compiled(behaviour), true <- function_exported?(behaviour, :behaviour_info, 1), funs when is_list(funs) <- behaviour.behaviour_info(:callbacks) do behaviour(value, funs) else _ -> {:error, "schema is invalid, ‹" <> inspect(behaviour) <> "› is not strictly implementing the expected behaviour"} end end def behaviour(value, {:strict, behaviour}) when is_atom(behaviour), do: behaviour(value, {behaviour, :strict}) def behaviour(value, strict: behaviour) when is_atom(behaviour), do: behaviour(value, {behaviour, :strict}) def behaviour(value, funs) when is_list(funs) do case Code.ensure_compiled(value) do {:module, ^value} -> funs |> Enum.reject(fn {fun, arity} -> function_exported?(value, fun, arity) end) |> case do [] -> {:ok, value} not_exported when is_list(not_exported) -> {:error, "module ‹" <> inspect(value) <> "› does not implement requested callbacks ‹" <> inspect(not_exported) <> "›"} end {:error, error} -> {:error, "cannot find the requested module ‹" <> inspect(value) <> "› (#{error})"} end end @doc """ Validates that the term passed implements `Access` behaviour. _Example:_ iex> schema = [ ...> container: [ ...> required: true, ...> type: {:custom, NimbleOptionsEx, :access?, []}, ...> doc: "The implementation of `Access` to be used as a storage" ...> ] ...> ] iex> NimbleOptions.validate([container: %{}], schema) {:ok, [container: %{}]} iex> NimbleOptions.validate([container: [foo: :bar]], schema) {:ok, [container: [foo: :bar]]} iex> NimbleOptions.validate([container: [1, 2, 3]], schema) {:error, %NimbleOptions.ValidationError{ message: "invalid value for :container option: expected a keyword list, got a list ‹[1, 2, 3]›", key: :container, value: [1, 2, 3], keys_path: []}} """ @spec access?(term()) :: {:ok, validated_option :: module()} | {:error, String.t()} def access?(nil), do: {:ok, nil} def access?(value) when is_list(value) do if Keyword.keyword?(value), do: {:ok, value}, else: {:error, "expected a keyword list, got a list ‹" <> inspect(value) <> "›"} end def access?(%type{} = value) do with {:ok, ^type} <- behaviour(type, Access), do: {:ok, value} end def access?(%{} = value), do: {:ok, value} @doc """ Validates that the term passed is a `prop`, a member of a `prop_list`. _Example:_ iex> schema = [ ...> prop: [ ...> required: true, ...> type: {:custom, NimbleOptionsEx, :property, [:string]}, ...> doc: "The property of type `{atom(), binary()}`" ...> ] ...> ] iex> NimbleOptions.validate([prop: {:foo, "bar"}], schema) {:ok, [prop: {:foo, "bar"}]} iex> NimbleOptions.validate([prop: {:foo, 42}], schema) {:error, %NimbleOptions.ValidationError{ message: "invalid value for :prop option: invalid value for :foo option: expected string, got: 42", key: :prop, keys_path: [], value: {:foo, 42}}} """ @spec property(term(), atom() | tuple() | keyword()) :: {:ok, validated_option :: module()} | {:error, String.t()} def property({atom, type}, [{_, _} | _] = expected_type) when is_atom(atom) do case Keyword.fetch(expected_type, atom) do {:ok, expected_type} -> property({atom, type}, expected_type) :error -> {:error, "expected a tuple, with a key from ‹#{inspect(Keyword.keys(expected_type))}" <> "›, got: ‹" <> inspect({atom, type}) <> "›"} end end def property({atom, type}, expected_type) when is_atom(atom) do case NimbleOptions.validate([{atom, type}], [{atom, type: expected_type}]) do {:ok, [{^atom, _} = result]} -> {:ok, result} {:error, %NimbleOptions.ValidationError{message: message}} -> {:error, message} other -> {:error, "Failed to validate property ‹" <> inspect(other) <> "›"} end rescue ArgumentError -> property({atom, type}, [expected_type]) end def property(other, expected_type) do {:error, "expected a tuple, with a second element being ‹" <> inspect(expected_type) <> "›, got: ‹" <> inspect(other) <> "›"} end end