The other day I found myself needing to write a bunch of controllers. Maybe I was feeling lazy, but I really wanted to finish the work quickly. I thought about how I could wrap it up in one afternoon.
Maybe I could write a code generator? or some kind of library?
I decided to go the library route.
In the end, it allowed me to write about 10 controllers in a couple of hours, so I'm sharing it.
Anatomy of a controller
A controller action does 3 things:
-
Input: Passing
params
and data fromconn
to a context function. - Algorithm: the context function.
- Output: Rendering a response based on the context function's result.
Let's use this create
action as an example:
def create(conn, params) do
# call context function
case Catalog.create_product(conn.assigns.current_account, params) do
# pattern match result
{:ok, product} ->
# rendering success
conn
|> put_status(:created)
|> render("show.json", product)
{:error, changeset} ->
# rendering error
conn
|> put_status(:unprocessable_entity)
|> render("error.json", changeset)
end
end
In my case, all my controller actions would follow this structure.
Reusable logic
Each controller action is calling a context function and then pattern matching against the result. The result is always a tagged tuple like {:ok, data}
or {:error, changeset}
. So it seems the response handling could be shared between controllers.
We can extract the shared logic to a module:
defmodule Responder do
# handle success
def respond({:ok, record}, conn) do
conn
|> put_status(:created)
|> render("show.json", record: record)
end
# handle error
def respond({:error, changeset}, conn) do
conn
|> put_status(:unprocessable_entity)
|> render("error.json", changeset: changeset)
end
end
Then our controller code can be simplified:
# import shared response logic
import Responder
def create(conn, params) do
# call context
Catalog.create_product(conn.assigns.current_account, params)
|> respond(conn) # re-use response logic
end
def show(conn, params) do
# call context
Catalog.get_product(conn.assigns.current_account, params["id"])
|> respond(conn) # re-use response logic
end
Way less repetition, it's starting to shape up.
A pattern emerges
All the actions now have a similar look:
def <action-name>(conn, params) do
respond(conn, <context-function>.())
end
So why not distill it further with a macro:
defmacro defaction(name, fun) do
quote do
def unquote(name)(conn, params) do
data = %{conn: conn, params: params, assigns: conn.assigns}
respond(conn, unquote(fun).(data))
end
end
end
Then, replace def action_name(...)
with defaction :action_name, ...
:
defaction :index, &Catalog.list_products(&1.params)
defaction :show, &Catalog.get_product(&1.params)
defaction :create, &Catalog.create_product(&1.params["product"])
defaction :update, &Catalog.update_product(&1.params["id"], &1.params["product"])
defaction :delete, &Catalog.delete_product(&1.params["id"])
Now our controllers are completely declarative. Easier to read and less typing :)
Hex package
Since it worked out well for me, I open sourced it:
- Repo: https://github.com/joshnuss/transponder
- Hex package: https://hex.pm/packages/transponder
To set it up, install the hex package:
# in mix.exs, add to `deps`:
{:transponder, "~> 0.2"}
Then in any controller, import Transponder
, passing the required format (JSON or HTML).
defmodule MyAppWeb.Admin.ProductsController do
use MyAppWeb, :controller
use Transponder, format: Transponder.JSON
defaction :name, &Context.function(...)
end
Conclusion
The idea isn't a new one, it exists in Rails with make_resourceful
and resource_controller
.
The approach works well for controllers that follow the same pattern. It keeps controller code declarative, increases readability, and eliminates the need for testing controllers.
Of course for more intricate applications, hand-tuned response logic may work better. Nothing wrong with that.
P.S. It's still alpha software, but feel free to give it a try and open PRs
Happy hacking,
Top comments (0)