How to validate request params in Phoenix

Image not Found

Credit: filter image taken from svgrepo.com

In web developments, server receives lots of request data from client side. And when working with request params from client, my first rule is:

Don’t believe the client

Imagine that you provide API to list all post using the filter from client, and user may add user_id which point to other user, and you don’t remove that unexpected field from request params. If you don’t handle your logic carefully, you may accidentally leak data.

So every request should be cleaned from unexpected params, casted to the proper data type, and validated before passing to business layer.

You can achieve this by:

Using Ecto

If you are building a web server using Phoenix, I guess Ecto is already in your dependencies. Just use it.

Thank to Ecto schemaless, you can build changeset from a dynamic schema:

 1defmodule MyApp.PostController do
 2    ...
 3    defp index_params(params) do
 4        default = %{
 5      status: nil,
 6      q: nil,
 7      is_published: true
 8    }
 9
10    types = %{
11      status: :string,
12      q: :string,
13      is_published: :boolean
14    }
15
16    changeset =
17      {default, types}
18      |> Ecto.Changeset.cast(params, Map.keys(types))
19    
20    if changeset.valid? do
21        {:ok, Ecto.Changeset.apply_changes(changeset)}
22    else
23        {:error, changeset}
24    end
25    end
26    
27    def index(conn, params) do
28        with {:ok, valid_params} <- index_params(params) do
29            # do your logic
30        end
31    end
32    ...
33end

With Ecto you can do validation on your params as you do with your schema changeset.

This way is simple and most of you are familiar with it. But you have to write much code and cannot cast and validate nested params.

Use library Tarams

This library provide a simple way to define schema. Let’s rewrite example above using tarams.

First add this to your dependency list:

{:tarams, "~> 1.0.0"}
 1defmodule MyApp.PostController do
 2    ...
 3    @index_params %{
 4        status: :string,
 5        q: :string
 6        is_published: [type: :boolean, default: true],
 7        page: [type: :integer, number: [min: 1]],
 8        size: [type: :integer, number: [min: 10, max: 100]]
 9    }
10    def index(conn, params) do
11        with {:ok, valid_params} <- Tarams.cast(params, @index_params) do
12            # do your logic
13        end
14    end
15    ...
16end

And it support nested params too

 1defmodule MyApp.PostController do
 2    ...
 3    @create_params %{
 4        title: [type: :string, required: true],
 5        content: [type: :string, required: true],
 6        tags: [type: {:array, :string}],
 7        published_at: :naive_datetime,
 8        meta: %{
 9            tile: :string,
10            description: :string,
11            image: :string
12        }
13    }
14    def create(conn, params) do
15        with {:ok, valid_params} <- Tarams.cast(params, @create_params) do
16            MyApp.Content.create_post(valid_params)
17        end
18    end
19    ...
20end

Conclusion

All request params should be casted and validated at controller. Then you only work with data that you know what it is, and you don’t have to worry about unexpected parameters.

Thanks for reading, hope it can helps.