defmodule Ashton do @moduledoc """ Documentation for Ashton. """ @type schema() :: Ashton.Schema.t() @type error :: {atom, String.t()} @type validation_result :: {:ok, Keyword.t()} | {:error, [error]} @doc "See `Ashton.Schema.new/1`" defdelegate schema(opts), to: Ashton.Schema, as: :new defdelegate schema(), to: Ashton.Schema, as: :new @doc "See `Ashton.Schema.merge/2`" defdelegate merge(left, right), to: Ashton.Schema defdelegate merge(left, right, opts), to: Ashton.Schema @doc "See `Ashton.Doc.document/2`" defdelegate document(schema, opts), to: Ashton.Doc defdelegate document(schema), to: Ashton.Doc @doc """ Validates opts according to a schema or the constructor for a schema. Raises on invalid opts. iex> Ashton.validate!([reticulate_splines?: true], opts: [:reticulate_splines?]) [reticulate_splines?: true] iex> Ashton.validate!([reticulate_splines?: true], opts: [:load_textures?], extra_keys?: true) [reticulate_splines?: true] iex> schema = Ashton.schema(opts: [:reticulate_splines?], required: [:reticulate_splines?], extra_keys?: true) ...> Ashton.validate!([reticulate_splines?: true, hack_interwebs?: true], schema) [reticulate_splines?: true, hack_interwebs?: true] iex> Ashton.validate!([], opts: [:reticulate_splines?], required: [:reticulate_splines?]) ** (ArgumentError) Opt Validation Error: reticulate_splines? - is required """ @spec validate!(opts :: Keyword.t(), schema :: schema()) :: Keyword.t() | no_return def validate!(opts, schema = %Ashton.Schema{}) do case validate(opts, schema) do {:ok, opts} -> opts {:error, errors} -> message = message(errors) raise ArgumentError, message end end def validate!(opts, schema_config) do validate!(opts, schema(schema_config)) end @spec validate(opts :: Keyword.t(), schema :: Ashton.Schema.t()) :: {:ok, Keyword.t()} | {:error, [error]} def validate(opts, schema) when is_map(opts) do validate(Enum.into(opts, []), schema) end def validate(opts, schema) when is_list(opts) do with_defaults = Enum.reduce(schema.defaults, opts, fn {default, value}, opts -> Keyword.put_new(opts, default, value) end) {:ok, with_defaults} |> validate_required(with_defaults, schema) |> validate_types(with_defaults, schema) |> validate_extra_keys(with_defaults, schema) |> validate_custom(schema) end def validate(_opts, _schema) do {:error, [{:opts, "opts must be a keyword list or a map."}]} end defp validate_custom( validation_result = {:ok, opts}, schema = %{custom: [{field, custom} | rest]} ) do result = case custom.(opts[field], field, opts, schema) do true -> validation_result false -> add_errors(validation_result, {field, "failed a custom validation"}) :ok -> validation_result {:ok, updated_opts} -> {:ok, updated_opts} {:error, error_or_errors} -> add_errors(validation_result, error_or_errors) [] -> validation_result errors when is_list(errors) -> add_errors(validation_result, errors) end validate_custom(result, %{schema | custom: rest}) end defp validate_custom(validation_result, _schema), do: validation_result defp validate_types(validation_result, opts, %{types: types}) do Enum.reduce(types, validation_result, fn {field, type}, result -> cond do !Keyword.has_key?(opts, field) -> result match?(%Ashton.Schema{}, type) || match?({nested_type, %Ashton.Schema{}} when nested_type in [:keyword, :list], type) -> validate_nested_schema(result, type, opts, field) Ashton.Type.matches_type?(type, opts[field]) -> result true -> message = "must be of type " <> sanitize_type(type) add_errors(result, {field, message}) end end) end defp validate_nested_schema(result, type, opts, field) do case do_nested_schema_validation(type, opts[field]) do {:ok, value} -> update_result_key(result, field, value) {:error, message} -> add_errors(result, {field, message}) end end defp do_nested_schema_validation({:list, _schema}, value) when not is_list(value) do {:error, "expected a list of keywords conforming to a subschema"} end defp do_nested_schema_validation({:list, schema}, value) do nested_opts_result = value |> Enum.with_index() |> Enum.reduce({[], []}, fn {keyword, index}, {opts_acc, errors_acc} -> case validate(keyword, schema) do {:ok, new_opts} -> {[new_opts | opts_acc], errors_acc} {:error, errors} -> message = nested_error_message(index, errors) {opts_acc, [message | errors_acc]} end end) case nested_opts_result do {opts_acc, []} -> {:ok, Enum.reverse(opts_acc)} {_, errors} -> message = Enum.join(errors, ", ") {:error, message} end end defp do_nested_schema_validation({:keyword, schema}, value) do nested_opts_result = value |> Enum.reduce({[], []}, fn {key, keyword}, {opts_acc, errors_acc} -> case validate(keyword, schema) do {:ok, new_opts} -> {[{key, new_opts} | opts_acc], errors_acc} {:error, errors} -> message = nested_error_message(key, errors) {opts_acc, [message | errors_acc]} end end) case nested_opts_result do {opts_acc, []} -> {:ok, Enum.reverse(opts_acc)} {_, errors} -> message = Enum.join(errors, ", ") {:error, message} end end defp do_nested_schema_validation(schema = %Ashton.Schema{}, value) do case validate(value, schema) do {:ok, new_opts} -> {:ok, new_opts} {:error, errors} -> message = Enum.map_join(errors, ", ", fn {nested_field, message} -> "nested field #{nested_field} #{message}" end) {:error, message} end end defp nested_error_message(nesting, errors) do Enum.map_join(errors, ", ", fn {nested_field, message} -> "nested field [#{nesting}][#{nested_field}] #{message}" end) end defp sanitize_type(%struct{}), do: sanitize_type({:struct, struct}) defp sanitize_type(term), do: inspect(term) defp validate_required(validation_result, opts, %{required: required}) do Enum.reduce(required, validation_result, fn key, result -> if is_nil(opts[key]) do add_errors(result, {key, "is required"}) else result end end) end defp validate_extra_keys(validation_result, _opts, %{extra_keys?: true}), do: validation_result defp validate_extra_keys(validation_result, opts, %{opts: keys}) do extra_keys = opts |> Keyword.keys() |> Kernel.--(keys) Enum.reduce( extra_keys, validation_result, &add_errors(&2, {&1, "is not allowed (no extra keys)"}) ) end defp update_result_key({:ok, opts}, field, value) do {:ok, Keyword.put(opts, field, value)} end defp update_result_key(other, _, _) do other end defp add_errors({:ok, _opts}, error_or_errors) do add_errors({:error, []}, error_or_errors) end defp add_errors({:error, existing_errors}, error_or_errors) do errors = List.wrap(error_or_errors) {:error, errors ++ existing_errors} end defp message(errors) do short_messages = errors |> Enum.map(&short_message/1) |> Enum.join(", ") "Opt Validation Error: " <> "#{short_messages}" end defp short_message({path, message}) when is_list(path) do path = path |> Enum.with_index() |> Enum.map(fn {elem, i} -> if i == 0 do elem else "[:#{elem}]" end end) |> Enum.join() "#{path} - #{message}" end defp short_message({field, message}) do "#{field} - #{message}" end end