1096 lines
35 KiB
Elixir
1096 lines
35 KiB
Elixir
defmodule NimbleOptions do
|
|
@options_schema [
|
|
*: [
|
|
type: :keyword_list,
|
|
keys: [
|
|
type: [
|
|
type: {:custom, __MODULE__, :validate_type, []},
|
|
default: :any,
|
|
doc: "The type of the option item."
|
|
],
|
|
required: [
|
|
type: :boolean,
|
|
default: false,
|
|
doc: "Defines if the option item is required."
|
|
],
|
|
default: [
|
|
type: :any,
|
|
doc: """
|
|
The default value for the option item if that option is not specified. This value
|
|
is *validated* according to the given `:type`. This means that you cannot
|
|
have, for example, `type: :integer` and use `default: "a string"`.
|
|
"""
|
|
],
|
|
keys: [
|
|
type: :keyword_list,
|
|
doc: """
|
|
Available for types `:keyword_list`, `:non_empty_keyword_list`, and `:map`,
|
|
it defines which set of keys are accepted for the option item. The value of the
|
|
`:keys` option is a schema itself. For example: `keys: [foo: [type: :atom]]`.
|
|
Use `:*` as the key to allow multiple arbitrary keys and specify their schema:
|
|
`keys: [*: [type: :integer]]`.
|
|
""",
|
|
keys: &__MODULE__.options_schema/0
|
|
],
|
|
deprecated: [
|
|
type: :string,
|
|
doc: """
|
|
Defines a message to indicate that the option item is deprecated. \
|
|
The message will be displayed as a warning when passing the item.
|
|
"""
|
|
],
|
|
doc: [
|
|
type: {:or, [:string, {:in, [false]}]},
|
|
type_doc: "`t:String.t/0` or `false`",
|
|
doc: "The documentation for the option item."
|
|
],
|
|
subsection: [
|
|
type: :string,
|
|
doc: "The title of separate subsection of the options' documentation"
|
|
],
|
|
type_doc: [
|
|
type: {:or, [:string, {:in, [false]}]},
|
|
type_doc: "`t:String.t/0` or `false`",
|
|
doc: """
|
|
The type doc to use *in the documentation* for the option item. If `false`,
|
|
no type documentation is added to the item. If it's a string, it can be
|
|
anything. For example, you can use `"a list of PIDs"`, or you can use
|
|
a typespec reference that ExDoc can link to the type definition, such as
|
|
`` "`t:binary/0`" ``. You can use Markdown in this documentation. If the
|
|
`:type_doc` option is not present, NimbleOptions tries to produce a type
|
|
documentation automatically if it can do it unambiguously. For example,
|
|
if `type: :integer`, NimbleOptions will use `t:integer/0` as the
|
|
auto-generated type doc.
|
|
"""
|
|
],
|
|
type_spec: [
|
|
type: :any,
|
|
type_doc: "`t:Macro.t/0`",
|
|
doc: """
|
|
The quoted spec to use *in the typespec* for the option item. You should use this
|
|
when the auto-generated spec is not specific enough. For example, if you are performing
|
|
custom validation on an option (with the `{:custom, ...}` type), then the
|
|
generated type spec for that option will always be `t:term/0`, but you can use
|
|
this option to customize that. The value for this option **must** be a quoted Elixir
|
|
term. For example, if you have an `:exception` option that is validated with a
|
|
`{:custom, ...}` type (based on `is_exception/1`), you can override the type
|
|
spec for that option to be `quote(do: Exception.t())`. *Available since v1.1.0*.
|
|
"""
|
|
]
|
|
]
|
|
]
|
|
]
|
|
|
|
@moduledoc """
|
|
Provides a standard API to handle keyword-list-based options.
|
|
|
|
`NimbleOptions` allows developers to create schemas using a
|
|
pre-defined set of options and types. The main benefits are:
|
|
|
|
* A single unified way to define simple static options
|
|
* Config validation against schemas
|
|
* Automatic doc generation
|
|
|
|
## Schema Options
|
|
|
|
These are the options supported in a *schema*. They are what
|
|
defines the validation for the items in the given schema.
|
|
|
|
#{NimbleOptions.Docs.generate(@options_schema, nest_level: 0)}
|
|
|
|
## Types
|
|
|
|
* `:any` - Any type.
|
|
|
|
* `:keyword_list` - A keyword list.
|
|
|
|
* `:non_empty_keyword_list` - A non-empty keyword list.
|
|
|
|
* `:map` - A map consisting of `:atom` keys. Shorthand for `{:map, :atom, :any}`.
|
|
Keys can be specified using the `keys` option.
|
|
|
|
* `{:map, key_type, value_type}` - A map consisting of `key_type` keys and
|
|
`value_type` values.
|
|
|
|
* `:atom` - An atom.
|
|
|
|
* `:string` - A string.
|
|
|
|
* `:boolean` - A boolean.
|
|
|
|
* `:integer` - An integer.
|
|
|
|
* `:non_neg_integer` - A non-negative integer.
|
|
|
|
* `:pos_integer` - A positive integer.
|
|
|
|
* `:float` - A float.
|
|
|
|
* `:timeout` - A non-negative integer or the atom `:infinity`.
|
|
|
|
* `:pid` - A PID (process identifier).
|
|
|
|
* `:reference` - A reference (see `t:reference/0`).
|
|
|
|
* `nil` - The value `nil` itself. Available since v1.0.0.
|
|
|
|
* `:mfa` - A named function in the format `{module, function, arity}` where
|
|
`arity` is a list of arguments. For example, `{MyModule, :my_fun, [arg1, arg2]}`.
|
|
|
|
* `:mod_arg` - A module along with arguments, such as `{MyModule, arguments}`.
|
|
Usually used for process initialization using `start_link` and similar. The
|
|
second element of the tuple can be any term.
|
|
|
|
* `{:fun, arity}` - Any function with the specified arity.
|
|
|
|
* `{:in, choices}` - A value that is a member of one of the `choices`. `choices`
|
|
should be a list of terms or a `Range`. The value is an element in said
|
|
list of terms, that is, `value in choices` is `true`. This was previously
|
|
called `:one_of` and the `:in` name is available since version 0.3.3 (`:one_of`
|
|
has been removed in v0.4.0).
|
|
|
|
* `{:custom, mod, fun, args}` - A custom type. The related value must be validated
|
|
by `mod.fun(values, ...args)`. The function should return `{:ok, value}` or
|
|
`{:error, message}`.
|
|
|
|
* `{:or, subtypes}` - A value that matches one of the given `subtypes`. The value is
|
|
matched against the subtypes in the order specified in the list of `subtypes`. If
|
|
one of the subtypes matches and **updates** (casts) the given value, the updated
|
|
value is used. For example: `{:or, [:string, :boolean, {:fun, 2}]}`. If one of the
|
|
subtypes is a keyword list or map, you won't be able to pass `:keys` directly. For this reason,
|
|
`:keyword_list`, `:non_empty_keyword_list`, and `:map` are special cased and can
|
|
be used as subtypes with `{:keyword_list, keys}`, `{:non_empty_keyword_list, keys}` or `{:map, keys}`.
|
|
For example, a type such as `{:or, [:boolean, keyword_list: [enabled: [type: :boolean]]]}`
|
|
would match either a boolean or a keyword list with the `:enabled` boolean option in it.
|
|
|
|
* `{:list, subtype}` - A list where all elements match `subtype`. `subtype` can be any
|
|
of the accepted types listed here. Empty lists are allowed. The resulting validated list
|
|
contains the validated (and possibly updated) elements, each as returned after validation
|
|
through `subtype`. For example, if `subtype` is a custom validator function that returns
|
|
an updated value, then that updated value is used in the resulting list. Validation
|
|
fails at the *first* element that is invalid according to `subtype`. If `subtype` is
|
|
a keyword list or map, you won't be able to pass `:keys` directly. For this reason,
|
|
`:keyword_list`, `:non_empty_keyword_list`, and `:map` are special cased and can
|
|
be used as the subtype by using `{:keyword_list, keys}`, `{:non_empty_keyword_list, keys}`
|
|
or `{:keyword_list, keys}`. For example, a type such as
|
|
`{:list, {:keyword_list, enabled: [type: :boolean]}}` would a *list of keyword lists*,
|
|
where each keyword list in the list could have the `:enabled` boolean option in it.
|
|
|
|
* `{:tuple, list_of_subtypes}` - A tuple as described by `tuple_of_subtypes`.
|
|
`list_of_subtypes` must be a list with the same length as the expected tuple.
|
|
Each of the list's elements must be a subtype that should match the given element in that
|
|
same position. For example, to describe 3-element tuples with an atom, a string, and
|
|
a list of integers you would use the type `{:tuple, [:atom, :string, {:list, :integer}]}`.
|
|
*Available since v0.4.1*.
|
|
|
|
* `{:struct, struct_name}` - An instance of the struct type given.
|
|
|
|
## Example
|
|
|
|
iex> schema = [
|
|
...> producer: [
|
|
...> type: :non_empty_keyword_list,
|
|
...> required: true,
|
|
...> keys: [
|
|
...> module: [required: true, type: :mod_arg],
|
|
...> concurrency: [
|
|
...> type: :pos_integer,
|
|
...> ]
|
|
...> ]
|
|
...> ]
|
|
...> ]
|
|
...>
|
|
...> config = [
|
|
...> producer: [
|
|
...> concurrency: 1,
|
|
...> ]
|
|
...> ]
|
|
...>
|
|
...> {:error, %NimbleOptions.ValidationError{} = error} = NimbleOptions.validate(config, schema)
|
|
...> Exception.message(error)
|
|
"required :module option not found, received options: [:concurrency] (in options [:producer])"
|
|
|
|
## Nested Option Items
|
|
|
|
`NimbleOptions` allows option items to be nested so you can recursively validate
|
|
any item down the options tree.
|
|
|
|
### Example
|
|
|
|
iex> schema = [
|
|
...> producer: [
|
|
...> required: true,
|
|
...> type: :non_empty_keyword_list,
|
|
...> keys: [
|
|
...> rate_limiting: [
|
|
...> type: :non_empty_keyword_list,
|
|
...> keys: [
|
|
...> interval: [required: true, type: :pos_integer]
|
|
...> ]
|
|
...> ]
|
|
...> ]
|
|
...> ]
|
|
...> ]
|
|
...>
|
|
...> config = [
|
|
...> producer: [
|
|
...> rate_limiting: [
|
|
...> interval: :oops!
|
|
...> ]
|
|
...> ]
|
|
...> ]
|
|
...>
|
|
...> {:error, %NimbleOptions.ValidationError{} = error} = NimbleOptions.validate(config, schema)
|
|
...> Exception.message(error)
|
|
"invalid value for :interval option: expected positive integer, got: :oops! (in options [:producer, :rate_limiting])"
|
|
|
|
## Validating Schemas
|
|
|
|
Each time `validate/2` is called, the given schema itself will be validated before validating
|
|
the options.
|
|
|
|
In most applications the schema will never change but validating options will be done
|
|
repeatedly.
|
|
|
|
To avoid the extra cost of validating the schema, it is possible to validate the schema once,
|
|
and then use that valid schema directly. This is done by using the `new!/1` function first, and
|
|
then passing the returned schema to `validate/2`.
|
|
|
|
> #### Create the Schema at Compile Time {: .tip}
|
|
>
|
|
> If your option schema doesn't include any runtime-only terms in it (such as anonymous
|
|
> functions), you can call `new!/1` to validate the schema and returned a *compiled* schema
|
|
> **at compile time**. This is an efficient way to avoid doing any unnecessary work at
|
|
> runtime. See the example below for more information.
|
|
|
|
### Example
|
|
|
|
iex> raw_schema = [
|
|
...> hostname: [
|
|
...> required: true,
|
|
...> type: :string
|
|
...> ]
|
|
...> ]
|
|
...>
|
|
...> schema = NimbleOptions.new!(raw_schema)
|
|
...> NimbleOptions.validate([hostname: "elixir-lang.org"], schema)
|
|
{:ok, hostname: "elixir-lang.org"}
|
|
|
|
Calling `new!/1` from a function that receives options will still validate the schema each time
|
|
that function is called. Declaring the schema as a module attribute is supported:
|
|
|
|
@options_schema NimbleOptions.new!([...])
|
|
|
|
This schema will be validated at compile time. Calling `docs/1` on that schema is also
|
|
supported.
|
|
"""
|
|
|
|
alias NimbleOptions.ValidationError
|
|
|
|
defstruct schema: []
|
|
|
|
@basic_types [
|
|
:any,
|
|
:keyword_list,
|
|
:non_empty_keyword_list,
|
|
:map,
|
|
:atom,
|
|
:integer,
|
|
:non_neg_integer,
|
|
:pos_integer,
|
|
:float,
|
|
:mfa,
|
|
:mod_arg,
|
|
:string,
|
|
:boolean,
|
|
:timeout,
|
|
:pid,
|
|
:reference,
|
|
nil
|
|
]
|
|
|
|
@typedoc """
|
|
A schema.
|
|
|
|
See the module documentation for more information.
|
|
"""
|
|
@type schema() :: keyword()
|
|
|
|
@typedoc """
|
|
The `NimbleOptions` struct embedding a validated schema.
|
|
|
|
See the [*Validating Schemas* section](#module-validating-schemas) in
|
|
the module documentation.
|
|
"""
|
|
@type t() :: %__MODULE__{schema: schema()}
|
|
|
|
@doc """
|
|
Validates the given `options` with the given `schema`.
|
|
|
|
See the module documentation for what a `schema` is.
|
|
|
|
If the validation is successful, this function returns `{:ok, validated_options}`
|
|
where `validated_options` is a keyword list. If the validation fails, this
|
|
function returns `{:error, validation_error}` where `validation_error` is a
|
|
`NimbleOptions.ValidationError` struct explaining what's wrong with the options.
|
|
You can use `raise/1` with that struct or `Exception.message/1` to turn it into a string.
|
|
"""
|
|
@spec validate(keyword() | map(), schema() | t()) ::
|
|
{:ok, validated_options :: keyword() | map()} | {:error, ValidationError.t()}
|
|
|
|
def validate(options, %NimbleOptions{schema: schema}) do
|
|
validate_options_with_schema(options, schema)
|
|
end
|
|
|
|
def validate(options, schema) when is_list(options) and is_list(schema) do
|
|
validate(options, new!(schema))
|
|
end
|
|
|
|
@doc """
|
|
Validates the given `options` with the given `schema` and raises if they're not valid.
|
|
|
|
This function behaves exactly like `validate/2`, but returns the options directly
|
|
if they're valid or raises a `NimbleOptions.ValidationError` exception otherwise.
|
|
"""
|
|
@spec validate!(keyword() | map(), schema() | t()) :: validated_options :: keyword() | map()
|
|
def validate!(options, schema) do
|
|
case validate(options, schema) do
|
|
{:ok, options} -> options
|
|
{:error, %ValidationError{} = error} -> raise error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Validates the given `schema` and returns a wrapped schema to be used with `validate/2`.
|
|
|
|
If the given schema is not valid, raises a `NimbleOptions.ValidationError`.
|
|
"""
|
|
@spec new!(schema()) :: t()
|
|
def new!(schema) when is_list(schema) do
|
|
case validate_options_with_schema(schema, options_schema()) do
|
|
{:ok, validated_schema} ->
|
|
%NimbleOptions{schema: validated_schema}
|
|
|
|
{:error, %ValidationError{} = error} ->
|
|
raise ArgumentError,
|
|
"invalid NimbleOptions schema. Reason: #{Exception.message(error)}"
|
|
end
|
|
end
|
|
|
|
@doc ~S"""
|
|
Returns documentation for the given schema.
|
|
|
|
You can use this to inject documentation in your docstrings. For example,
|
|
say you have your schema in a module attribute:
|
|
|
|
@options_schema [...]
|
|
|
|
With this, you can use `docs/1` to inject documentation:
|
|
|
|
@doc "Supported options:\n#{NimbleOptions.docs(@options_schema)}"
|
|
|
|
## Options
|
|
|
|
* `:nest_level` - an integer deciding the "nest level" of the generated
|
|
docs. This is useful when, for example, you use `docs/2` inside the `:doc`
|
|
option of another schema. For example, if you have the following nested schema:
|
|
|
|
nested_schema = [
|
|
allowed_messages: [type: :pos_integer, doc: "Allowed messages."],
|
|
interval: [type: :pos_integer, doc: "Interval."]
|
|
]
|
|
|
|
then you can document it inside another schema with its nesting level increased:
|
|
|
|
schema = [
|
|
producer: [
|
|
type: {:or, [:string, keyword_list: nested_schema]},
|
|
doc:
|
|
"Either a string or a keyword list with the following keys:\n\n" <>
|
|
NimbleOptions.docs(nested_schema, nest_level: 1)
|
|
],
|
|
other_key: [type: :string]
|
|
]
|
|
|
|
"""
|
|
@spec docs(schema() | t(), keyword()) :: String.t()
|
|
def docs(schema, options \\ [])
|
|
|
|
def docs(schema, options) when is_list(schema) and is_list(options) do
|
|
NimbleOptions.Docs.generate(schema, options)
|
|
end
|
|
|
|
def docs(%NimbleOptions{schema: schema}, options) when is_list(options) do
|
|
NimbleOptions.Docs.generate(schema, options)
|
|
end
|
|
|
|
@doc """
|
|
Returns the quoted typespec for any option described by the given schema.
|
|
|
|
The returned quoted code represents the **type union** for all possible
|
|
keys in the schema, alongside their type. Nested keyword lists are
|
|
spec'ed as `t:keyword/0`.
|
|
|
|
## Usage
|
|
|
|
Because of how typespecs are treated by the Elixir compiler, you have
|
|
to use `unquote/1` on the return value of this function to use it
|
|
in a typespec:
|
|
|
|
@type option() :: unquote(NimbleOptions.option_typespec(my_schema))
|
|
|
|
This function returns the type union for a single option: to give you
|
|
flexibility to combine it and use it in your own typespecs. For example,
|
|
if you only validate part of the options through NimbleOptions, you could
|
|
write a spec like this:
|
|
|
|
@type my_option() ::
|
|
{:my_opt1, integer()}
|
|
| {:my_opt2, boolean()}
|
|
| unquote(NimbleOptions.option_typespec(my_schema))
|
|
|
|
If you want to spec a whole schema, you could write something like this:
|
|
|
|
@type options() :: [unquote(NimbleOptions.option_typespec(my_schema))]
|
|
|
|
## Example
|
|
|
|
schema = [
|
|
int: [type: :integer],
|
|
number: [type: {:or, [:integer, :float]}]
|
|
]
|
|
|
|
@type option() :: unquote(NimbleOptions.option_typespec(schema))
|
|
|
|
The code above would essentially compile to:
|
|
|
|
@type option() :: {:int, integer()} | {:number, integer() | float()}
|
|
|
|
"""
|
|
@doc since: "0.5.0"
|
|
@spec option_typespec(schema() | t()) :: Macro.t()
|
|
def option_typespec(schema)
|
|
|
|
def option_typespec(schema) when is_list(schema) do
|
|
NimbleOptions.Docs.schema_to_spec(schema)
|
|
end
|
|
|
|
def option_typespec(%NimbleOptions{schema: schema}) do
|
|
NimbleOptions.Docs.schema_to_spec(schema)
|
|
end
|
|
|
|
@doc false
|
|
def options_schema() do
|
|
@options_schema
|
|
end
|
|
|
|
defp validate_options_with_schema(opts, schema) do
|
|
validate_options_with_schema_and_path(opts, schema, _path = [])
|
|
end
|
|
|
|
defp validate_options_with_schema_and_path(opts, fun, path) when is_function(fun) do
|
|
validate_options_with_schema_and_path(opts, fun.(), path)
|
|
end
|
|
|
|
defp validate_options_with_schema_and_path(opts, schema, path) when is_map(opts) do
|
|
list_opts = Map.to_list(opts)
|
|
|
|
case validate_options_with_schema_and_path(list_opts, schema, path) do
|
|
{:ok, validated_list_opts} -> {:ok, Map.new(validated_list_opts)}
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp validate_options_with_schema_and_path(opts, schema, path) when is_list(opts) do
|
|
schema = expand_star_to_option_keys(schema, opts)
|
|
|
|
with :ok <- validate_unknown_options(opts, schema),
|
|
{:ok, options} <- validate_options(opts, schema) do
|
|
{:ok, options}
|
|
else
|
|
{:error, %ValidationError{} = error} ->
|
|
{:error, %ValidationError{error | keys_path: path ++ error.keys_path}}
|
|
end
|
|
end
|
|
|
|
defp validate_unknown_options(opts, schema) do
|
|
valid_opts = Keyword.keys(schema)
|
|
|
|
case Keyword.keys(opts) -- valid_opts do
|
|
[] ->
|
|
:ok
|
|
|
|
keys ->
|
|
error_tuple(
|
|
keys,
|
|
nil,
|
|
"unknown options #{inspect(keys)}, valid options are: #{inspect(valid_opts)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_options(opts, schema) do
|
|
orig_opts = opts
|
|
|
|
case Enum.reduce_while(schema, {opts, orig_opts}, &reduce_options/2) do
|
|
{:error, %ValidationError{}} = result -> result
|
|
{opts, _orig_opts} -> {:ok, opts}
|
|
end
|
|
end
|
|
|
|
defp reduce_options({key, schema_opts}, {opts, orig_opts}) do
|
|
case validate_option({opts, orig_opts}, key, schema_opts) do
|
|
{:error, %ValidationError{}} = result ->
|
|
{:halt, result}
|
|
|
|
{:ok, value} ->
|
|
opts = Keyword.update(opts, key, value, fn _ -> value end)
|
|
{:cont, {opts, orig_opts}}
|
|
|
|
:no_value ->
|
|
if Keyword.has_key?(schema_opts, :default) do
|
|
opts_with_default = Keyword.put(opts, key, schema_opts[:default])
|
|
reduce_options({key, schema_opts}, {opts_with_default, orig_opts})
|
|
else
|
|
{:cont, {opts, orig_opts}}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp validate_option({opts, orig_opts}, key, schema) do
|
|
with {:ok, value} <- validate_value({opts, orig_opts}, key, schema),
|
|
{:ok, value} <- validate_type(schema[:type], key, value) do
|
|
if nested_schema = schema[:keys] do
|
|
validate_options_with_schema_and_path(value, nested_schema, _path = [key])
|
|
else
|
|
{:ok, value}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp validate_value({opts, orig_opts}, key, schema) do
|
|
cond do
|
|
Keyword.has_key?(opts, key) ->
|
|
if message = Keyword.get(schema, :deprecated) do
|
|
IO.warn("#{render_key(key)} is deprecated. " <> message)
|
|
end
|
|
|
|
{:ok, opts[key]}
|
|
|
|
Keyword.get(schema, :required, false) ->
|
|
error_tuple(
|
|
key,
|
|
nil,
|
|
"required #{render_key(key)} not found, received options: " <>
|
|
inspect(Keyword.keys(orig_opts))
|
|
)
|
|
|
|
true ->
|
|
:no_value
|
|
end
|
|
end
|
|
|
|
defp validate_type(:integer, key, value) when not is_integer(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected integer, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:non_neg_integer, key, value) when not is_integer(value) or value < 0 do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected non negative integer, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:pos_integer, key, value) when not is_integer(value) or value < 1 do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected positive integer, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:float, key, value) when not is_float(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected float, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:atom, key, value) when not is_atom(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected atom, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:timeout, key, value)
|
|
when not (value == :infinity or (is_integer(value) and value >= 0)) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected non-negative integer or :infinity, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:string, key, value) when not is_binary(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected string, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:boolean, key, value) when not is_boolean(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected boolean, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:keyword_list, key, value) do
|
|
if keyword_list?(value) do
|
|
{:ok, value}
|
|
else
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected keyword list, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_type(:non_empty_keyword_list, key, value) do
|
|
if keyword_list?(value) and value != [] do
|
|
{:ok, value}
|
|
else
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected non-empty keyword list, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_type(:map, key, value) do
|
|
validate_type({:map, :atom, :any}, key, value)
|
|
end
|
|
|
|
defp validate_type({:map, key_type, value_type}, key, map) when is_map(map) do
|
|
map
|
|
|> Enum.reduce_while([], fn {key, value}, acc ->
|
|
with {:ok, updated_key} <- validate_type(key_type, {__MODULE__, :key}, key),
|
|
{:ok, updated_value} <- validate_type(value_type, {__MODULE__, :value, key}, value) do
|
|
{:cont, [{updated_key, updated_value} | acc]}
|
|
else
|
|
{:error, %ValidationError{} = error} -> {:halt, error}
|
|
end
|
|
end)
|
|
|> case do
|
|
pairs when is_list(pairs) ->
|
|
{:ok, Map.new(pairs)}
|
|
|
|
%ValidationError{} = error ->
|
|
error_tuple(key, map, "invalid map in #{render_key(key)}: #{error.message}")
|
|
end
|
|
end
|
|
|
|
defp validate_type({:map, _, _}, key, value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected map, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:pid, _key, value) when is_pid(value) do
|
|
{:ok, value}
|
|
end
|
|
|
|
defp validate_type(:pid, key, value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected pid, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:reference, _key, value) when is_reference(value) do
|
|
{:ok, value}
|
|
end
|
|
|
|
defp validate_type(:reference, key, value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected reference, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:mfa, _key, {mod, fun, args} = value)
|
|
when is_atom(mod) and is_atom(fun) and is_list(args) do
|
|
{:ok, value}
|
|
end
|
|
|
|
defp validate_type(:mfa, key, value) when not is_nil(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected tuple {mod, fun, args}, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type(:mod_arg, _key, {mod, _arg} = value) when is_atom(mod) do
|
|
{:ok, value}
|
|
end
|
|
|
|
defp validate_type(:mod_arg, key, value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected tuple {mod, arg}, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type({:fun, arity}, key, value) do
|
|
if is_function(value) do
|
|
case :erlang.fun_info(value, :arity) do
|
|
{:arity, ^arity} ->
|
|
{:ok, value}
|
|
|
|
{:arity, fun_arity} ->
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected function of arity #{arity}, got: function of arity #{inspect(fun_arity)}"
|
|
)
|
|
end
|
|
else
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected function of arity #{arity}, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_type(nil, key, value) do
|
|
if is_nil(value) do
|
|
{:ok, value}
|
|
else
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected nil, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_type({:custom, mod, fun, args}, key, value) do
|
|
case apply(mod, fun, [value | args]) do
|
|
{:ok, value} ->
|
|
{:ok, value}
|
|
|
|
{:error, message} when is_binary(message) ->
|
|
error_tuple(key, value, "invalid value for #{render_key(key)}: " <> message)
|
|
|
|
other ->
|
|
raise "custom validation function #{inspect(mod)}.#{fun}/#{length(args) + 1} " <>
|
|
"must return {:ok, value} or {:error, message}, got: #{inspect(other)}"
|
|
end
|
|
end
|
|
|
|
defp validate_type({:in, choices}, key, value) do
|
|
if value in choices do
|
|
{:ok, value}
|
|
else
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected one of #{inspect(choices)}, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_type({:or, subtypes}, key, value) do
|
|
result =
|
|
Enum.reduce_while(subtypes, _errors = [], fn subtype, errors_acc ->
|
|
{subtype, nested_schema} =
|
|
case subtype do
|
|
{type, keys} when type in [:keyword_list, :non_empty_keyword_list, :map] ->
|
|
{type, keys}
|
|
|
|
other ->
|
|
{other, _nested_schema = nil}
|
|
end
|
|
|
|
case validate_type(subtype, key, value) do
|
|
{:ok, value} when not is_nil(nested_schema) ->
|
|
case validate_options_with_schema_and_path(value, nested_schema, _path = [key]) do
|
|
{:ok, value} -> {:halt, {:ok, value}}
|
|
{:error, %ValidationError{} = error} -> {:cont, [error | errors_acc]}
|
|
end
|
|
|
|
{:ok, value} ->
|
|
{:halt, {:ok, value}}
|
|
|
|
{:error, %ValidationError{} = reason} ->
|
|
{:cont, [reason | errors_acc]}
|
|
end
|
|
end)
|
|
|
|
case result do
|
|
{:ok, value} ->
|
|
{:ok, value}
|
|
|
|
errors when is_list(errors) ->
|
|
message =
|
|
"expected #{render_key(key)} to match at least one given type, but didn't match " <>
|
|
"any. Here are the reasons why it didn't match each of the allowed types:\n\n" <>
|
|
Enum.map_join(errors, "\n", &(" * " <> Exception.message(&1)))
|
|
|
|
error_tuple(key, value, message)
|
|
end
|
|
end
|
|
|
|
defp validate_type({:list, subtype}, key, value) when is_list(value) do
|
|
{subtype, nested_schema} =
|
|
case subtype do
|
|
{type, keys} when type in [:keyword_list, :non_empty_keyword_list, :map] ->
|
|
{type, keys}
|
|
|
|
other ->
|
|
{other, _nested_schema = nil}
|
|
end
|
|
|
|
updated_elements =
|
|
for {elem, index} <- Stream.with_index(value) do
|
|
case validate_type(subtype, {__MODULE__, :list, index}, elem) do
|
|
{:ok, value} when not is_nil(nested_schema) ->
|
|
case validate_options_with_schema_and_path(value, nested_schema, _path = [key]) do
|
|
{:ok, updated_value} -> updated_value
|
|
{:error, %ValidationError{} = error} -> throw({:error, index, error})
|
|
end
|
|
|
|
{:ok, updated_elem} ->
|
|
updated_elem
|
|
|
|
{:error, %ValidationError{} = error} ->
|
|
throw({:error, error})
|
|
end
|
|
end
|
|
|
|
{:ok, updated_elements}
|
|
catch
|
|
{:error, %ValidationError{} = error} ->
|
|
error_tuple(key, value, "invalid list in #{render_key(key)}: #{error.message}")
|
|
|
|
{:error, index, %ValidationError{} = error} ->
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid list element at position #{index} in #{render_key(key)}: #{error.message}"
|
|
)
|
|
end
|
|
|
|
defp validate_type({:list, _subtype}, key, value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected list, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type({:tuple, tuple_def}, key, value)
|
|
when is_tuple(value) and length(tuple_def) == tuple_size(value) do
|
|
tuple_def
|
|
|> Stream.with_index()
|
|
|> Enum.reduce_while([], fn {subtype, index}, acc ->
|
|
elem = elem(value, index)
|
|
|
|
case validate_type(subtype, {__MODULE__, :tuple, index}, elem) do
|
|
{:ok, updated_elem} -> {:cont, [updated_elem | acc]}
|
|
{:error, %ValidationError{} = error} -> {:halt, error}
|
|
end
|
|
end)
|
|
|> case do
|
|
acc when is_list(acc) ->
|
|
{:ok, acc |> Enum.reverse() |> List.to_tuple()}
|
|
|
|
%ValidationError{} = error ->
|
|
error_tuple(key, value, "invalid tuple in #{render_key(key)}: #{error.message}")
|
|
end
|
|
end
|
|
|
|
defp validate_type({:tuple, tuple_def}, key, value) when is_tuple(value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected tuple with #{length(tuple_def)} elements, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type({:tuple, _tuple_def}, key, value) do
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected tuple, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
|
|
defp validate_type({:struct, struct_name}, key, value) do
|
|
if match?(%^struct_name{}, value) do
|
|
{:ok, value}
|
|
else
|
|
error_tuple(
|
|
key,
|
|
value,
|
|
"invalid value for #{render_key(key)}: expected #{inspect(struct_name)}, got: #{inspect(value)}"
|
|
)
|
|
end
|
|
end
|
|
|
|
defp validate_type(_type, _key, value) do
|
|
{:ok, value}
|
|
end
|
|
|
|
defp keyword_list?(value) do
|
|
is_list(value) and Enum.all?(value, &match?({key, _value} when is_atom(key), &1))
|
|
end
|
|
|
|
defp expand_star_to_option_keys(keys, opts) do
|
|
case keys[:*] do
|
|
nil ->
|
|
keys
|
|
|
|
schema_opts ->
|
|
Enum.map(opts, fn {k, _} -> {k, schema_opts} end)
|
|
end
|
|
end
|
|
|
|
defp available_types() do
|
|
types =
|
|
Enum.map(@basic_types, &inspect/1) ++
|
|
[
|
|
"{:fun, arity}",
|
|
"{:in, choices}",
|
|
"{:or, subtypes}",
|
|
"{:custom, mod, fun, args}",
|
|
"{:list, subtype}",
|
|
"{:tuple, list_of_subtypes}",
|
|
"{:map, key_type, value_type}",
|
|
"{:struct, struct_name}"
|
|
]
|
|
|
|
Enum.join(types, ", ")
|
|
end
|
|
|
|
@doc false
|
|
def validate_type(value) when value in @basic_types do
|
|
{:ok, value}
|
|
end
|
|
|
|
def validate_type({:fun, arity} = value) when is_integer(arity) and arity >= 0 do
|
|
{:ok, value}
|
|
end
|
|
|
|
# "choices" here can be any enumerable so there's no easy and fast way to validate it.
|
|
def validate_type({:in, _choices} = value) do
|
|
{:ok, value}
|
|
end
|
|
|
|
def validate_type({:custom, mod, fun, args} = value)
|
|
when is_atom(mod) and is_atom(fun) and is_list(args) do
|
|
{:ok, value}
|
|
end
|
|
|
|
def validate_type({:or, subtypes} = value) when is_list(subtypes) do
|
|
Enum.reduce_while(subtypes, {:ok, value}, fn
|
|
{type, _keys}, acc
|
|
when type in [:keyword_list, :non_empty_keyword_list, :map] ->
|
|
{:cont, acc}
|
|
|
|
subtype, acc ->
|
|
case validate_type(subtype) do
|
|
{:ok, _value} -> {:cont, acc}
|
|
{:error, reason} -> {:halt, {:error, "invalid type given to :or type: #{reason}"}}
|
|
end
|
|
end)
|
|
end
|
|
|
|
# This is to support the special-cased "{:list, {:keyword_list, my_key: [type: ...]}}",
|
|
# like we do in the :or type.
|
|
def validate_type({:list, {type, keys}})
|
|
when type in [:keyword_list, :non_empty_keyword_list, :map] and is_list(keys) do
|
|
{:ok, {:list, {type, keys}}}
|
|
end
|
|
|
|
def validate_type({:list, subtype}) do
|
|
case validate_type(subtype) do
|
|
{:ok, validated_subtype} -> {:ok, {:list, validated_subtype}}
|
|
{:error, reason} -> {:error, "invalid subtype given to :list type: #{reason}"}
|
|
end
|
|
end
|
|
|
|
def validate_type({:tuple, tuple_def}) when is_list(tuple_def) do
|
|
validated_def =
|
|
Enum.map(tuple_def, fn subtype ->
|
|
case validate_type(subtype) do
|
|
{:ok, validated_subtype} -> validated_subtype
|
|
{:error, reason} -> throw({:error, "invalid subtype given to :tuple type: #{reason}"})
|
|
end
|
|
end)
|
|
|
|
{:ok, {:tuple, validated_def}}
|
|
catch
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
|
|
def validate_type({:map, key_type, value_type}) do
|
|
valid_key_type =
|
|
case validate_type(key_type) do
|
|
{:ok, validated_key_type} -> validated_key_type
|
|
{:error, reason} -> throw({:error, "invalid key_type for :map type: #{reason}"})
|
|
end
|
|
|
|
valid_values_type =
|
|
case validate_type(value_type) do
|
|
{:ok, validated_values_type} -> validated_values_type
|
|
{:error, reason} -> throw({:error, "invalid value_type for :map type: #{reason}"})
|
|
end
|
|
|
|
{:ok, {:map, valid_key_type, valid_values_type}}
|
|
catch
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
|
|
def validate_type({:struct, struct_name}) when is_atom(struct_name) do
|
|
{:ok, {:struct, struct_name}}
|
|
end
|
|
|
|
def validate_type({:struct, struct_name}) do
|
|
{:error, "invalid struct_name for :struct, expected atom, got #{inspect(struct_name)}"}
|
|
end
|
|
|
|
def validate_type(value) do
|
|
{:error, "unknown type #{inspect(value)}.\n\nAvailable types: #{available_types()}"}
|
|
end
|
|
|
|
defp error_tuple(key, value, message) do
|
|
{:error, %ValidationError{key: key, message: message, value: value}}
|
|
end
|
|
|
|
defp render_key({__MODULE__, :key}), do: "map key"
|
|
defp render_key({__MODULE__, :value, key}), do: "map key #{inspect(key)}"
|
|
defp render_key({__MODULE__, :tuple, index}), do: "tuple element at position #{index}"
|
|
defp render_key({__MODULE__, :list, index}), do: "list element at position #{index}"
|
|
defp render_key(key), do: inspect(key) <> " option"
|
|
end
|