Current section
Files
Jump to
Current section
Files
lib/plug.ex
defmodule Request.Validator.Plug do
alias Plug.Conn
alias Ecto.Changeset
alias Request.Validator
alias Request.Validator.{DefaultRules, Rules, Rules.Array, Rules.Map_}
import Plug.Conn
@doc ~S"""
Init the Request.Validator.Plug with an optional error callback
and handlers with their corresponding request validator module.
```elixir
plug Request.Validator.Plug,
register: App.Requests.RegisterRequest,
on_error: fn conn, errors -> json_resp(conn, "Handle your errors: #{inspect errors}") end
```
"""
def init(opts) when is_map(opts), do: init(Keyword.new(opts))
def init(opts) do
opts
|> Keyword.put_new(:on_error, &Validator.Plug.on_error/2)
end
@doc ~S"""
The default callback to be invoked when there is a param that fails validation.
"""
def on_error(conn, errors) do
json_resp(conn, 422, %{message: "Unprocessable entity", errors: errors}) |> halt()
end
defp unauthorized(conn) do
json_resp(conn, 403, %{message: "Forbidden"}) |> halt
end
@doc ~S"""
Performs validations on `conn.params`
If all validations are successful returns the connection struct
Otherwise returns an error map in the following structure: `%{param: ["some error", ...]}`
Will call the given `on_error` callback in case some validation failed
"""
def call(conn, opts) do
with action <- Map.get(conn.private, :phoenix_action),
request_validator <- get_validator(opts, action) do
case request_validator do
nil -> conn
_ -> validate(Conn.fetch_query_params(conn), request_validator, opts[:on_error])
end
end
end
defp get_validator(opt, key) when is_map(opt), do: Map.get(opt, key)
defp get_validator(opt, key) when is_list(opt), do: Keyword.get(opt, key)
defp validate(conn, module, on_error) do
module = load_module(module)
rules =
cond do
function_exported?(module, :rules, 1) ->
module.rules(conn)
function_exported?(module, :rules, 0) ->
module.rules()
end
errors = collect_errors(conn.params, rules)
cond do
not module.authorize(conn) -> unauthorized(conn)
Enum.empty?(errors) -> conn
true -> on_error.(conn, errors)
end
end
defp collect_errors(_, %Ecto.Changeset{} = changeset) do
Changeset.traverse_errors(changeset, fn {key, errors} ->
Enum.reduce(errors, key, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
defp collect_errors(params, validations) do
Enum.reduce(validations, %{}, errors_collector(params))
end
defp errors_collector(params) do
fn
{field, %Rules.Bail{rules: rules}}, acc ->
value = Map.get(params, to_string(field))
result =
Enum.find_value(rules, nil, fn callback ->
case run_rule(callback, value, field, params) do
:ok ->
nil
a ->
a
end
end)
case is_binary(result) do
true -> Map.put(acc, field, [result])
_ -> acc
end
{field, %Array{attrs: rules}}, acc ->
value = Map.get(params, to_string(field))
with true <- is_list(value),
result <- Enum.map(value, &collect_errors(&1, rules)) do
# result <- Enum.reject(result, &Enum.empty?/1) do
result =
result
|> Enum.map(fn val ->
index = Enum.find_index(result, &(val == &1))
if Enum.empty?(val) do
nil
else
{index, val}
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.reduce(%{}, fn {index, errors}, acc ->
errors =
errors
|> Enum.map(fn {key, val} -> {"#{field}.#{index}.#{key}", val} end)
|> Enum.into(%{})
Map.merge(acc, errors)
end)
Map.merge(acc, result)
else
_ ->
Map.put(acc, field, ["This field is expected to be an array."])
end
{field, %Map_{attrs: rules, nullable: nullable}}, acc ->
value = Map.get(params, to_string(field))
with %{} <- value,
result <- collect_errors(value, rules),
{true, _} <- {Enum.empty?(result), result} do
acc
else
{false, result} ->
result =
result
|> Enum.map(fn {key, val} -> {"#{field}.#{key}", val} end)
|> Enum.into(%{})
Map.merge(acc, result)
val ->
cond do
nullable && is_nil(val) ->
acc
true ->
Map.put(acc, field, ["This field is expected to be a map."])
end
end
{field, vf}, acc ->
value = Map.get(params, to_string(field))
case run_rules(vf, value, field, params) do
{:error, errors} -> Map.put(acc, field, errors)
_ -> acc
end
end
end
defp run_rule(callback, value, field, fields) do
opts = [field: field, fields: fields]
module = rules_module()
{callback, args} =
case callback do
cb when is_atom(cb) ->
{cb, [value, opts]}
{cb, params} when is_atom(cb) ->
{cb, [value, params, opts]}
end
case apply(module, :run_rule, [callback] ++ args) do
:ok -> true
{:error, msg} -> msg
end
end
defp run_rules(rules, value, field, fields) do
results =
Enum.map(rules, fn callback ->
run_rule(callback, value, field, fields)
end)
|> Enum.filter(&is_binary/1)
if Enum.empty?(results), do: nil, else: {:error, results}
end
defp json_resp(conn, status, body) do
conn
|> put_resp_header("content-type", "application/json")
|> send_resp(status, json_library().encode_to_iodata!(body))
end
defp json_library do
Application.get_env(:request_validator, :json_library, Jason)
end
defp load_module(module) do
case Code.ensure_loaded(module) do
{:module, mod} -> mod
{:error, reason} -> raise ArgumentError, "Could not load #{module}, reason: #{reason}"
end
end
defp rules_module, do: Application.get_env(:request_validator, :rules_module, DefaultRules)
end