defmodule PhoenixParams.Util do def validate(params, _) when not is_map(params), do: {:error, "invalid"} def validate(params, meta), do: validate(params, {nil, nil, nil}, meta) def validate(params, {pparams, bparams, qparams}, meta) do params |> extract({pparams, bparams, qparams}, meta) |> run_coercions(meta) |> run_validations(meta) |> conclude() |> maybe_run_global_validations(meta) |> conclude() end def validate_array(list, _) when not is_list(list), do: {:error, "invalid"} def validate_array(list, meta) do {errors, validated} = list |> Enum.with_index |> Enum.reduce({[], []}, fn {params, i}, {bad, good} -> case validate(params, meta) do {:error, errors} -> Enum.reduce(errors, bad, fn {k, v}, bad -> {[{"[#{i}].#{k}", v} | bad], good} end) {:ok, res} -> {bad, [res | good]} end end) Enum.any?(errors) && {:error, errors} || {:ok, Enum.reverse(validated)} end def extract(params, {pparams, bparams, qparams}, meta) do for name <- meta.param_names, into: %{} do pdef = meta.paramdefs[name] input_params = case pdef.source do :auto -> params :path -> pparams :body -> bparams :query -> qparams end value = fetch_param(meta.key_type, input_params, name) value = cond do not is_nil(value) -> value not is_function(pdef.default) -> pdef.default Function.info(pdef.default)[:arity] == 0 -> pdef.default.() Function.info(pdef.default)[:arity] == 1 -> pdef.default.(params) true -> raise ":default expected a function of arity 0 or 1, got: #{inspect(pdef.default)}" end {name, value} end end def run_coercions(params, meta) do Enum.reduce(params, params, fn {name, value}, coerced -> pdef = meta.paramdefs[name] case value do nil -> if pdef.required, do: %{coerced | name => {:error, "required"}}, else: coerced _ -> case pdef.coercer.(value) do {:ok, val} -> %{coerced | name => val} val -> %{coerced | name => val} end end end) end def run_validations(coerced_params, meta) do Enum.reduce(coerced_params, coerced_params, fn {name, value}, validated -> pdef = meta.paramdefs[name] cond do is_nil(pdef.validator) -> # no validator defined => don't validate validated is_nil(value) -> # param was optional and value is nil => don't validate validated is_tuple(value) -> # coercion failed => don't validate validated is_tuple(pdef.validator) -> {vname, vopts} = pdef.validator case run_builtin_validation(vname, vopts, value) do {:error, reason} -> %{validated | name => {:error, reason}} _ -> validated end is_function(pdef.validator) -> case pdef.validator.(value) do {:error, reason} -> %{validated | name => {:error, reason}} _ -> validated end end end) end def conclude(validated_params) do errors = Enum.filter(validated_params, fn param -> case param do # global validation failed {nil, _} -> true # param validation or coercion failed {_, {:error, _}} -> true _ -> false end end) (Enum.any?(errors) && {:error, errors}) || {:ok, validated_params} end def maybe_run_global_validations(validated_params, meta) do case validated_params do {:error, params} -> # Don't run global validations if individual params failed params {:ok, params} -> errors = Enum.reduce_while(meta.global_validators, [], fn {validator, should_halt}, errors -> case validator.(params) do {:error, reason} -> errors = errors ++ [reason] (should_halt && {:halt, errors}) || {:cont, errors} _ -> {:cont, errors} end end) if Enum.any?(errors), do: Map.put(params, nil, errors), else: params end end def call(conn, meta) do case validate(conn.params, {conn.path_params, conn.body_params, conn.query_params}, meta) do {:error, errors} -> errors = Enum.reduce(errors, [], &validation_error(&1, &2)) errors = (length(errors) > 1 && errors) || List.first(errors) conn |> Plug.Conn.put_status(400) |> Plug.Conn.halt() |> Phoenix.Controller.put_view(meta.error_view) |> Phoenix.Controller.render("400.json", validation_failed: errors) {:ok, params} -> # NOTE: It's generally better to leave the original conn.params # untouched. However, the phoenix framework passes this # explicitly as the second param to any controller action, # which will discourage anyone from manually having to fetch # the coerced params stored in conn.private, so people # will eventually forget about them and just start using the # raw params. # Plug.Conn.put_private(conn, :sumup_params, coerced_params) Map.put(conn, :params, params) end end def coercion_error?(_, {:error, _}), do: true def coercion_error?(_), do: false def fetch_param(:atom, raw_params, name) when is_atom(name), do: raw_params[name] def fetch_param(:atom, raw_params, name) when is_bitstring(name), do: raw_params[String.to_atom(name)] def fetch_param(:string, raw_params, name), do: raw_params[to_string(name)] # # Default coercers # def coerce_integer(v) when is_nil(v), do: v def coerce_integer(v) when is_integer(v), do: v def coerce_integer(v) when not is_bitstring(v), do: {:error, "not an integer"} def coerce_integer(v) do case Integer.parse(v) do {i, ""} -> i _ -> {:error, "not an integer"} end end def coerce_float(v) when is_nil(v), do: v def coerce_float(v) when is_float(v), do: v def coerce_float(v) when not is_bitstring(v), do: {:error, "not a float"} def coerce_float(v) do case Float.parse(v) do {i, ""} -> i _ -> {:error, "not a float"} end end def coerce_decimal(v) when is_nil(v), do: v def coerce_decimal(v) when is_integer(v), do: Decimal.new(v) def coerce_decimal(v) when is_float(v), do: Decimal.from_float(v) def coerce_decimal(v) when not is_bitstring(v), do: {:error, "not a float"} def coerce_decimal(v) do case Decimal.parse(v) do {:ok, i} -> i _ -> {:error, "not a decimal"} end end def coerce_string(v) when is_nil(v), do: v def coerce_string(v) when not is_bitstring(v), do: {:error, "not a string"} def coerce_string(v), do: v def coerce_date(v) when is_nil(v), do: v def coerce_date(v) when not is_bitstring(v), do: {:error, "invalid date"} def coerce_date(v) do case Date.from_iso8601(v) do {:ok, d} -> d {:error, _} -> {:error, "invalid date"} end end def coerce_datetime(v) when is_nil(v), do: v def coerce_datetime(v) when not is_bitstring(v), do: {:error, "invalid datetime"} def coerce_datetime(v) do case DateTime.from_iso8601(v) do {:ok, dt, _} -> dt {:error, _} -> {:error, "invalid datetime"} end end def coerce_atom(v) when is_bitstring(v), do: String.to_atom(v) def coerce_atom(_), do: {:error, "string expected"} def coerce_boolean(v) when is_nil(v), do: v def coerce_boolean(v) when is_boolean(v), do: v def coerce_boolean(v) when v in ["true", "false"], do: String.to_existing_atom(v) def coerce_boolean(_), do: {:error, "not a boolean"} # # This validator is to be invoked manually in custom validators. # E.g. # def my_validator(list) when is_list(list), do: validate_each(list, &my_validator/1) # def my_validator(value) do # value == 5 || {:error, "is not 5"} # end # def validate_each(list, validator) do {i, res} = Enum.reduce_while(list, {0, nil}, fn x, {i, nil} -> case validator.(x) do {:error, reason} -> {:halt, {i, {:error, reason}}} _ -> {:cont, {i + 1, nil}} end end) case res do {:error, reason} -> {:error, "element at index #{i}: #{reason}"} _ -> true end end # # Builtin validations # def run_builtin_validation(:numericality, opts, %Decimal{} = value) do with true <- !Map.has_key?(opts, :gt) || Decimal.cmp(value, opts.gt) === :gt || "must be > #{opts.gt}", true <- !Map.has_key?(opts, :gte) || Decimal.cmp(value, opts.gte) !== :lt || "must be >= #{opts.gte}", true <- !Map.has_key?(opts, :lt) || Decimal.cmp(value, opts.lt) === :lt || "must be < #{opts.lt}", true <- !Map.has_key?(opts, :lte) || Decimal.cmp(value, opts.lte) !== :gt || "must be <= #{opts.lte}", true <- !Map.has_key?(opts, :eq) || Decimal.cmp(value, opts.eq) !== :eq || "must be == #{opts.eq}" do true else message -> {:error, message} end end def run_builtin_validation(:numericality, opts, value) do with true <- !Map.has_key?(opts, :gt) || value > opts.gt || "must be > #{opts.gt}", true <- !Map.has_key?(opts, :gte) || value >= opts.gte || "must be >= #{opts.gte}", true <- !Map.has_key?(opts, :lt) || value < opts.lt || "must be < #{opts.lt}", true <- !Map.has_key?(opts, :lte) || value <= opts.lte || "must be <= #{opts.lte}", true <- !Map.has_key?(opts, :eq) || value == opts.eq || "must be == #{opts.eq}" do true else message -> {:error, message} end end def run_builtin_validation(:in, values, value) do Enum.member?(values, value) || {:error, "allowed values: #{inspect(values)}"} end def run_builtin_validation(:length, opts, value) when is_bitstring(value) do with true <- !Map.has_key?(opts, :gt) || String.length(value) > opts.gt || "must be more than #{opts.gt} chars", true <- !Map.has_key?(opts, :gte) || String.length(value) >= opts.gte || "must be at least #{opts.gte} chars", true <- !Map.has_key?(opts, :lt) || String.length(value) < opts.lt || "must be less than #{opts.lt} chars", true <- !Map.has_key?(opts, :lte) || String.length(value) <= opts.lte || "must at most #{opts.lte} chars", true <- !Map.has_key?(opts, :eq) || String.length(value) == opts.eq || "must be exactly #{opts.eq} chars" do true else message -> {:error, message} end end def run_builtin_validation(:size, opts, value) when is_list(value) do with true <- !Map.has_key?(opts, :gt) || length(value) > opts.gt || "must contain more than #{opts.gt} elements", true <- !Map.has_key?(opts, :gte) || length(value) >= opts.gte || "must contain at least #{opts.gte} elements", true <- !Map.has_key?(opts, :lt) || length(value) < opts.lt || "must contain less than #{opts.lt} elements", true <- !Map.has_key?(opts, :lte) || length(value) <= opts.lte || "must contain at most #{opts.lte} elements", true <- !Map.has_key?(opts, :eq) || length(value) == opts.eq || "must contain exactly #{opts.eq} elements" do true else message -> {:error, message} end end def run_builtin_validation(:regex, pattern, value) do Regex.match?(pattern, value) || {:error, "invalid format"} end # # Error formatter # # Global validation errors are stored under a nil key and are a list # of messages defp validation_error({nil, list}, errors) when is_list(list) do Enum.reduce(list, errors, &validation_error({nil, &1}, &2)) end # Nested validation errors are stored under a param key and are a # (keyword) list of {name, {:error, msg}} (or {nil, list} like above) defp validation_error({name, {:error, list}}, errors) when is_list(list) do Enum.reduce(list, errors, fn {k, v}, acc -> nested_name = (k && "#{name}.#{k}") || name validation_error({nested_name, v}, acc) end) end # Regular validation errors are stored under a param key and are # a tuple {:error, msg} defp validation_error({name, {:error, message}}, errors) do validation_error({name, message}, errors) end defp validation_error({name, message}, errors) do [validation_error(name, message) | errors] end defp validation_error(nil, message) do %{error_code: "INVALID", message: "Validation error: #{message}"} end defp validation_error(name, message) when is_list(message) do validation_error(name, Enum.join(message, "; ")) end defp validation_error(name, message) do code = (message == "required" && "MISSING") || "INVALID" %{error_code: code, param: name, message: "Validation error: #{message}"} end end