defmodule PhoenixApiToolkit.GenericRequestValidator do
@moduledoc """
Request validator for generic (REST) requests.
Meant to supplement database-level Ecto changesets.
For example when creating a new entity,
your database contexts / changesets will do their own validations and it would
be useless to do so an extra time.
Suppose you have the following "users" resource:
- GET /api/users
- GET /api/users/{id}
You could have the following resource request validator and controller. The users context
is not present here, but if it uses `PhoenixApiToolkit.Ecto.DynamicFilters` the processed
query parameters can be passed straight on and will result in a single filtered query.
```
defmodule MyUsersRequestValidator do
import Ecto.Changeset
import PhoenixApiToolkit.Ecto.Validators
import PhoenixApiToolkit.GenericRequestValidator
@schema resource_schema(%{username: :string}, %{date_of_birth: :date})
@entity_fields @schema |> get_entity_fields()
def index_query(attrs) do
@schema
|> query_order_by(attrs)
|> query_pagination(attrs)
|> cast(attrs, @entity_fields)
end
end
defmodule MyUsersController do
use MyAppWeb, :controller
import Plug.Conn
alias MyUsersRequestValidator, as: ReqVal
alias PhoenixApiToolkit.GenericRequestValidator, as: GenReqVal
def index(conn, _params) do
with %{valid?: true, changes: query_params} <- ReqVal.index_query(conn.query_params),
users <- MyUsersContext.list(query_params) do
conn |> send_resp(200, Jason.encode!(users))
else
_ -> conn |> send_resp(400, "idiot, your request is bad")
end
end
def show(conn, _params) do
with {valid?: true, changes: %{id: id}} <- GenReqVal.path_param(conn.path_params),
user when not is_nil(user) <- MyUsersContext.get(id) do
conn |> send_resp(200, Jason.encode!(user))
else
_ -> conn |> send_resp(400, "idiot, your request is bad")
end
end
end
```
"""
import Ecto.Changeset
import PhoenixApiToolkit.Ecto.Validators
@typedoc "A simple Ecto schema, embedded only, not coupled to a module or database entity"
@type schema :: {%{}, %{required(atom) => atom}}
@doc """
Creates a generic schema for a REST resource.
In general, REST resources will support an integer `id` as a path parameter,
and the index endpoint will support `order_by`, `limit` and `offset`.
Additionally, some endpoints will support a `lock_version` for
optimistic locking using `Ecto.Changeset.optimistic_lock/3`.
Additional fields can be passed along to the `extra_fields` parameter. Fields that
can (usefully) be compared with smaller than / greater than comparisons can be passed
in `comparables`. The value you pass AND a "_lt" (smaller than) and "_gte"
(greater than or equal to) variant will be added to the schema.
## Examples
# the result can be fed to cast/3
iex> resource_schema() |> Ecto.Changeset.cast(%{}, [])
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: %{}, valid?: true>
# an extended schema can be created by providing a map of fields
iex> resource_schema(%{first_name: :string})
{%{}, %{first_name: :string, id: :integer, limit: :integer, lock_version: :integer, offset: :integer, order_by: :string}}
# fields passed to the "comparables" parameter are added literally AND with _lt and _gte variants
iex> resource_schema(%{}, %{date_of_birth: :date}) |> elem(1) |> Map.has_key?(:date_of_birth_lt)
true
"""
@spec resource_schema(map) :: schema
def resource_schema(extra_fields \\ %{}, comparables \\ %{}) do
{
%{},
%{
id: :integer,
order_by: :string,
limit: :integer,
offset: :integer,
lock_version: :integer
}
|> Map.merge(extra_fields)
|> Map.merge(comparables)
|> Map.merge(comparables |> create_comparables("lt"))
|> Map.merge(comparables |> create_comparables("gte"))
}
end
defp create_comparables(comparables, postfix) do
comparables
|> Stream.map(fn {field, type} -> {"#{field}_#{postfix}", type} end)
|> Enum.map(fn {field, type} -> {String.to_atom(field), type} end)
|> Enum.into(%{})
end
@doc """
Get all the "non-meta" fields from a schema, that is, fields `[:limit, :offset, :order_by, :lock_version]`
are filtered out.
## Examples
iex> resource_schema(%{first_name: :string}) |> get_entity_fields()
[:first_name, :id]
"""
@spec get_entity_fields(schema) :: [atom]
def get_entity_fields(schema) do
schema
|> elem(1)
|> Map.drop([:limit, :offset, :order_by, :lock_version])
|> Map.keys()
end
@doc """
Validates the path parameter of a generic GET request of a RESTful resource.
## Examples
# "id" is a required parameter
iex> path_param(%{}) |> Map.get(:valid?)
false
# "id" must be an integer
iex> path_param(%{"id" => "boom"}) |> Map.get(:valid?)
false
# "id" must be greater than 0
iex> path_param(%{"id" => 0}) |> Map.get(:valid?)
false
iex> path_param(%{"id" => 1}) |> Map.get(:valid?)
true
"""
@spec path_param(map()) :: Ecto.Changeset.t()
def path_param(attrs) do
{%{}, %{id: :integer}}
|> cast(attrs, [:id])
|> validate_required([:id])
|> validate_number(:id, greater_than: 0)
end
@doc """
Validates the `order_by` query parameter of an index endpoint.
## Examples
iex> resource_schema() |> query_order_by(%{"order_by" => "asc:last_name"}, ~w(last_name) |> MapSet.new())
#Ecto.Changeset<action: nil, changes: %{order_by: [asc: :last_name]}, errors: [], data: %{}, valid?: true>
See `PhoenixApiToolkit.Ecto.Validators.validate_order_by/2` for more examples.
"""
@spec query_order_by(map() | Ecto.Changeset.t() | schema, map(), Enum.t()) :: Ecto.Changeset.t()
def query_order_by(changeset, attrs, orderables) do
changeset
|> cast(attrs, [:order_by])
|> validate_order_by(orderables)
end
@doc """
Validates the `limit` and `offset` query parameters of an index endpoint. If `max_limit == nil`, no maximum limit is enforced.
## Examples
# the requested limit and offset should be in the range 0 - max_limit
iex> resource_schema() |> query_pagination(%{"limit" => 10}, 100)
#Ecto.Changeset<action: nil, changes: %{limit: 10}, errors: [], data: %{}, valid?: true>
iex> cs = resource_schema() |> query_pagination(%{"limit" => 150}, 100)
iex> cs.valid?
false
# a default limit can be set so that a default number of results is returned
iex> resource_schema() |> query_pagination(%{}, 100, 50)
#Ecto.Changeset<action: nil, changes: %{limit: 50}, errors: [], data: %{}, valid?: true>
# no max limit is enforced if `max_limit == nil`
iex> resource_schema() |> query_pagination(%{"limit" => 1_000_000}, nil)
#Ecto.Changeset<action: nil, changes: %{limit: 1000000}, errors: [], data: %{}, valid?: true>
# default limit can be disabled
iex> resource_schema() |> query_pagination(%{}, nil, nil)
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: %{}, valid?: true>
"""
@spec query_pagination(
map() | Ecto.Changeset.t() | schema,
map(),
integer() | nil,
integer() | nil
) :: Ecto.Changeset.t()
def query_pagination(changeset, attrs, max_limit \\ 100, default_limit \\ 50) do
changeset
|> cast(attrs, [:limit, :offset])
|> validate_default_limit(default_limit)
|> validate_number(:limit, greater_than_or_equal_to: 0)
|> validate_number(:offset, greater_than_or_equal_to: 0)
|> validate_max_limit(max_limit)
end
###########
# Private #
###########
defp validate_max_limit(cs, nil), do: cs
defp validate_max_limit(cs, max_limit) do
cs
|> validate_required(:limit)
|> validate_number(:limit, less_than_or_equal_to: max_limit)
end
defp validate_default_limit(cs, nil), do: cs
defp validate_default_limit(cs, default_limit),
do: default_change(cs, :limit, default_limit)
end