Yesterday I posted about how Phoenix 1.3 was pure love for API development. In that post, I mentioned that one of my favorites features in this new release is the action_fallback
plug.
Today, I want to talk a little bit more about a killer combo I’ve uncovered: action_fallback
+ contexts.
I’m still amazed at how Phoenix 1.3 enables my controllers to be so tiny.
# Background
In my application, I have a `User` module that’s managed through an `Accounts` context:
def update(conn, %{"id" => id, "user" => user_params}) do
user = Accounts.get_user(id)
with {:ok, %User{} = user} <- Accounts.update_user(user, user_params),
do: render(conn, "show.json", user: user)
end
The default MyApp.Web.FallbackController
that’s generated when you first create your app includes, amongst others, a call/2
definition that accepts an %Ecto.Changeset{}
instance:
defmodule MyApp.Web.FallbackController do
use MyApp.Web, :controller
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> render(MyApp.Web.ChangesetView, "error.json", changeset: changeset)
end
#
end
This function will be called if, for instance, the update/2
method on UserController
failed to pattern match the with
statement. Repo.update/1
returns {:error, changeset}
in case it fails. Since I didn’t define an else
path, {:error, changeset}
would be then forwarded to MyApp.Web.FallbackController
.
# Custom Errors
I like to define custom errors for the domains for my app. The default behavior on `MyApp.Web.ChangesetView` will output something like this:
{
errors: [
"username has already been taken"
]
}
I modified MyApp.Web.ChangesetView
to expose the error the way I wanted:
defmodule MyApp.Web.ChangesetView do
use MyApp.Web, :view
alias MyApp.ResponseWrapper
def translate_errors(changeset) do
case hd(changeset.errors) do
{label, {"has already been taken", _}} ->
"#{label}_taken"
{:password, {"should be at most %{count} character(s)", _}} ->
"password_too_long"
{:password, {"should be at least %{count} character(s)", _}} ->
"password_too_short"
_ ->
:something_went_wrong
end
end
def render("error.json", %{changeset: changeset}) do
ResponseWrapper.error translate_errors(changeset)
end
end
In the translate_errors/1
function, I take the first error off the changeset, and then pattern match its content. I can add more patterns to match later depending on my needs. Now, MyApp.Web.ChangesetView
outputs errors like this:
{
status: "error",
error: "username_taken"
}
{
status: "error",
error: "password_too_short"
}
# Benefits
What’s great about that, is that now I can basically reuse that same code for any other module that needs to be inserted in my database.
This lets me write code like this:
defmodule MyApp.Web.DeviceController do
use MyApp.Web, :controller
alias MyApp.{Authentication, Notifications}
plug :scrub_params, "device" when action in [:create]
plug Authentication
action_fallback MyApp.Web.FallbackController
def create(conn, %{"device" => device_params}) do
with {:ok, user} <- Authentication.get_current_user(conn),
{:ok, _dev} <- Notifications.add_device(device_params, user),
do: :success
end
end
My create/2
function in DeviceController
is just 3 lines long. In those 3 lines, I:
- Get the current user.
- Insert a new device (with changeset validations) to the database.
- Passes
:success
to my theaction_fallback
plug.
The “happy path” response from my fallback looks like this:
{
status: "success",
data: {
status: "success"
}
}
If any of the guards in the with
statement fails, guess what: my fallback controller’s got my back 😬.
For instance, say the Notifications.add_device/2
function fails to insert the new device into the database because it didn’t meet one of the changeset validations, such as the unique_constraint
for device_token
.
Notifications.add_device
would then return {:error, changeset}
, which would be forwarded to my fallback controller, and the JSON error would look like this:
{
status: "error",
error: "device_token_taken"
}
And I got that for free. 👆
Yay contexts! Yay action_fallback
! 🎉