When a Changeset is a Plug

Todd Resudek
Dec 16, 2018 · 2 min read
Photo Credit: Marco Verch

I recently ran into this error on a project I have been working on:

Ecto.Query.CastError: lib/project/queries/project_query.ex:238: value `"xyz"` cannot be dumped to type :binary_id in query

It seems a user incorrectly entered a URL, replacing what should be a UUID with a regular binary. It could be a path that has changed, or a bad inbound link, or just a typo.

As a result of this error not being correctly intercepted, the user is bound to get a 500 error back. Not only is that frustrating, but it is also incorrect. The user should get some indication that the URL is malformed and it will never work.

Typically in Phoenix I will use a changeset to validate incoming requests. In this case the path names that parameter :project_id . The controller would cast that parameter and use a validator function to insure it is a correctly formed UUID. If it isn’t, it will return a changeset error.

The problem is this project has 40 routes that expect UUIDs, spread across 15 controllers. Adding changeset validations to all of those controllers and actions will take a lot of time and effort.

I decided to take another approach. Since the parameters I want to validate are all named :id or :project_id I thought I could validate them with a plug. The plug is used in the pipeline for every request and looks like this:

defmodule ProjectWeb.IDValidatorPlug do
@moduledoc """
Takes the params from `conn` and validates any IDs are in the
correct format.
"""

@params_to_validate ~w[id project_id]

def init(opts), do: opts

@spec call(Plug.Conn.t(), list()) :: no_return()
def call(conn, opts \\ [])

def call(%{params: _params} = conn, _opts) do
has_errors? =
conn
|> Plug.Conn.fetch_query_params()
|> Map.get(:params)
|> Enum.filter(fn {k, _v} -> k in @params_to_validate end)
|> Enum.map(fn {_k, v} -> Ecto.UUID.cast(v) end)
|> Enum.member?(:error)

case has_errors? do
true -> halt_with(422, "The ID provided was not in the correct format", conn)
_ -> conn
end
end

def call(conn, _opts), do: conn
defp halt_with(code, message, conn) do
conn
|> put_status(code)
|> Phoenix.Controller.put_view(ProjectWeb.ErrorView)
|> Phoenix.Controller.render("#{code}.json", %{
message: "#{message}",
status: "#{code}"
})
|> halt()
end
end

As you can see, the plug analyzes the conn for params named either :id or :project_id . When it finds one, it attempts to cast it’s value to a UUID. If that returns an error, the conn is halted and a view with a 422 status code is returned. The error message is explanatory of the actual issue.

You see, sometimes what you think is a changeset actually turns out to be a plug.

Todd Resudek

Written by

Software Engineer

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade