Params Modules for Phoenix

We introduce Params modules as a pattern to validate and massage a controller action's params into data that can be passed to contexts.

API parameters are so easy to work with when they can all be handled by a schema's changeset. This is often the case when dealing with simple RESTful endpoints which directly map an API resource to a schema.

POST /addresses
{
  "line1": "225 E 60th St",
  "city": "New York",
  "state": "NY",
  "zip_code": "10022",
  "country": "USA"
}

defmodule MyAppWeb.AddressController do
  use MyAppWeb, :controller

  def create(conn, params) do
    with {:ok, address} <- MyApp.Addresses.create(params) do
      send_resp(conn, :created, "")
    end
  end
end

In many other cases, an endpoint receives params representing pieces of multiple different schemas; or no schema at all.

Logging in

To log in a user, /login receives a user's email address, password, and answers to their security questions.

POST /login
{
  "email": "mateusz@example.com"
  "password": "abc123",
  "security_answers": [
    {
      "security_question_id": "12",
      "text": "MniszkΓ³w"
    },
    {
      "security_question_id": "23",
      "text": "basketball"
    }
  ]
}

defmodule MyAppWeb.LoginController do
  use MyAppWeb, :controller

  alias MyApp.{Login, StrongAuth}

  def create(conn, params) do
    with {:ok, user} <- Login.authenticate(params["email"], params["password"]),
         :ok <- StrongAuth.authenticate(user, params["security_answers"]) do
      send_resp(conn, :created, "")
    end
  end
end

Unlike our address example, these parameters don't map directly to some underlying changeset function. The email and password are passed to Login.authenticate/2 to find a user, and then that user and the security questions are passed to StrongAuth.authenticate/2.

So, we want to validate, in the web layer, that we've received the data needed by our functions before calling them.

One way to do this is to pattern match the params in the action:

def create(conn, %{"email" => email, "password" => password, "security_answers" => security_answers}) do
  # ...
end

This approach currently doesn't validate the structure of the nested security_answers. Doing so, and more complex cases can quickly clutter the controller. Additionally, if any part of the pattern match fails, a blanket error response will be returned instead of indicating which part of the request body was malformed.

Params modules

We can use the Params module pattern to validate the structure of the incoming params.

defmodule MyAppWeb.LoginController do
  use MyAppWeb, :controller

  alias MyApp.{Login, StrongAuth}

  def create(conn, params) do
    with {:ok, params} <- MyAppWeb.LoginParams.prepare(params),
         {:ok, user} <- Login.authenticate(params.email, params.password),
         :ok <- StrongAuth.authenticate(user, params.security_answers) do
      send_resp(conn, :created, "")
    end
  end
end

Now, our controller stays slim, and we've made sure we have the data needed by the contexts before continuing.

Here's what LoginParams looks like:

# my_app_web/params/login_params.ex
defmodule MyAppWeb.LoginParams do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false

  embedded_schema do
    field(:email, :string)
    field(:password, :string)

    embeds_many(:security_answers, SecurityAnswerParams)
  end

  def prepare(params) do
    changeset(params) |> apply_action(:insert)
  end

  def changeset(login_params \\ %__MODULE__{}, params) do
    login_params
    |> cast(params, [:email, :password])
    |> cast_embed(:security_answers, required: true)
    |> validate_required([:email, :password])
    |> validate_length(:security_answers, is: 2)
  end
end

As you can see, params modules are simple Ecto.Schema which use embedded_schema. Using the usual Ecto.Changeset functions, they validate the structure of the data and supports nesting.

Params modules expose a prepare/1 function which will return an :ok tuple with validated params, or an :error tuple with an invalid changeset for the controller to handle.

Using the invalid changeset, the controller can return a very specific error message to indicate which part of the params were malformed.

Massaging params

We can also use params modules to apply simple transformations β€” such as trimming whitespace around an email β€” or complex transformations.

Say our login API accepts a list of %{text:, security_question_id:} for security answers, while StrongAuth.authenticate/2 requires a list of %{answer:, security_question_id:}. We could modify LoginParams to transform the data if it's valid:

defmodule MyAppWeb.LoginParams do
  # ...

  def prepare(params) do
    changeset(params)
    |> apply_action(:insert)
    |> case do
      {:ok, params} -> {:ok, transform(params)}
      {:error, changeset} -> {:error, changeset}
    end
  end

  def transform(params) do
    security_answers =
      Enum.map(params.security_answers, fn security_answer ->
        %{
          security_question_id: security_answer.security_question_id,
          answer: security_answer.text
        }
      end)

    Map.put(params, :security_answers, security_answers)
  end
end

This flexibility allow the format of the API to be entirely decoupled from the format of the rest of the business logic.

Params modules, the Phoenix way

Way 1: We can cleanup some of the boilerplate logic that comes with params modules the same way we do for phoenix controllers, channels, views, and routers. First we add a block to lib/my_app_web.ex:

defmodule MyAppWeb do
  # ...

  def params do
    quote do
      use Ecto.Schema
      import Ecto.Changeset

      @primary_key false
    end
  end

  # ...
end

And now we can use it in each of our params modules:

defmodule MyAppWeb.LoginParams do
  use MyAppWeb, :params

  # ...
end

Way 2: Params modules follow the same naming convention as Phoenix controllers and views:

# my_app_web/controllers/login_controller.ex
defmodule MyAppWeb.LoginController do

# my_app_web/params/login_params.ex
defmodule MyAppWeb.LoginParams do

A tool in the tool belt

Params modules are a pattern we can reach for when dealing with params structures that aren't (and shouldn't be) handled by a phoenix context. Using them in every controller action would add unnecessary complexity.

With that said, here are some reasons you might want to try out params modules in your project:

  • Params modules keep param validation and massaging out of your controller.

  • Params modules keep web-layer concerns out of your contexts.

  • Params modules use familiar Ecto schemas and Phoenix conventions. There's no magic!

  • Params modules convert string keys to atoms so using string keys stops at the web layer.

  • Params modules are a convention so using them doesn't add any dependencies to your project.

Shout-outs

A massive inspiration for this work came from vic/params so be sure to check it out. Params modules take a step back from what the params package provides in favour of: being a pattern instead of a dependency, explicitly using familiar Ecto schemas instead of a new DSL, and the flexibility to transform instead of only validating the params structure.

If all you want is a way to whitelist params like Rails does with Strong Parameters, I recommend checking out Lucas de Queiroz Alves' Phoenix |> Strong Params.


Discuss on Twitter πŸ’¬