Flop.validate/2 and Flop.validate_and_run/3 return
{:error, %Flop.Meta{}} for invalid parameters. The errors are in
meta.errors, and the parameters that produced them are in meta.params.
The shape
meta.errors is a keyword list in the format of
Ecto.Changeset.traverse_errors(changeset, & &1): a key per parameter, and a
list of {message, opts} tuples per key.
params = %{limit: 5_000, order_by: [:mood]}
{:error, meta} = Flop.validate(params, for: MyApp.Pet)meta.errors == [
limit: [
{"must be less than or equal to %{number}",
[validation: :number, kind: :less_than_or_equal_to, number: 100]}
],
order_by: [
{"has an invalid entry", [validation: :subset, enum: [:name, :age]]}
]
]The message holds %{key} placeholders and the options hold the values to
interpolate. The options also name what would have been accepted: an order_by
error lists the sortable fields, an unknown filter field lists the filterable
fields, and an operator error lists the operators the field allows.
params = %{filters: [%{field: :age, op: :ilike, value: 1}]}
{:error, meta} = Flop.validate(params, for: MyApp.Pet)
meta.errors[:filters] == [
[
op: [
{"is invalid",
[
allowed_operators: [
:==, :!=, :empty, :not_empty, :<=, :<, :>=, :>, :in, :not_in
]
]}
]
]
]meta.params holds the parameters as they were passed, normalized to string
keys, and is only set when validation failed.
meta.params == %{
"filters" => [%{"field" => :age, "op" => :ilike, "value" => 1}]
}Filter errors line up with filter parameters
Filters are a list, so their errors are a list of lists. The two lists always have the same length and the same order, and a filter without errors holds an empty list.
params = %{
filters: [
%{field: :name, op: :==, value: "Ada"},
%{field: :age, op: :==, value: "old"},
%{field: :name, op: :==, value: "Bo"}
]
}
{:error, meta} = Flop.validate(params, for: MyApp.Pet)
meta.errors[:filters] == [[], [value: [{"is invalid", []}]], []]We can zip the two lists to say which filter went wrong.
defmodule MyApp.FlopErrors do
def filter_errors(%Flop.Meta{} = meta) do
filters = Map.get(meta.params, "filters", [])
errors = Keyword.get(meta.errors, :filters, [])
filters
|> Enum.zip(errors)
|> Enum.flat_map(fn {filter, filter_errors} ->
Enum.flat_map(filter_errors, fn {key, messages} ->
Enum.map(messages, fn message ->
%{
field: filter["field"],
op: filter["op"],
value: filter["value"],
key: key,
message: translate(message)
}
end)
end)
end)
end
defp translate({message, opts}) do
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", stringify(value))
end)
end
defp stringify(value) when is_list(value),
do: Enum.map_join(value, ", ", &to_string/1)
defp stringify(value), do: to_string(value)
endtranslate/1 does the interpolation that every Ecto error message needs. Route
it through Gettext instead if the messages are user-facing in more than one
language.
stringify/1 is needed because some option values are lists. enum and
allowed_operators hold lists of atoms, and to_string/1 raises on those.
Dropping bad parameters instead
An API that would rather serve a page than an error can set
replace_invalid_params: true. Flop then discards what it cannot use and
returns {:ok, flop}.
params = %{limit: 5_000, filters: [%{field: :age, op: :==, value: "old"}]}
opts = [for: MyApp.Pet, replace_invalid_params: true]
{:ok, flop} = Flop.validate(params, opts)
{flop.limit, flop.filters} == {50, []}The limit falls back to the default rather than to the maximum, and the invalid filter is gone. Nothing reports what was dropped.
Raising instead
Flop.validate!/2 and Flop.validate_and_run!/3 raise
Flop.InvalidParamsError, which holds the same errors and params.
Flop.validate!(%{limit: 5_000}, for: MyApp.Pet)
** (Flop.InvalidParamsError) invalid Flop parametersFlop does not depend on Plug, so nothing maps that exception to a status code.
In a Phoenix application, you can implement the Plug.Exception protocol to
turn a Flop.InvalidParamsError exception into a 400 response instead of a 500.
defimpl Plug.Exception, for: Flop.InvalidParamsError do
def status(_), do: 400
def actions(_), do: []
end