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 π¬