Uptime monitor in Elixir & Phoenix: Routing and controllers
Before we delve into the topic of data gathering in Elixir/Phoenix, let us focus on another core aspect of our uptime monitor application: routing and controllers.
Table of contents
- Routing
- What are plugs in the Phoenix Framework?
- What are pipelines in Elixir/Phoenix?
- The scope macro
- Controllers
- Other actions
- Elixir/Phoenix routing and controls: A word of conclusion
Welcome to the fourth part of the article series where we are creating an Uptime Monitor in Elixir. As we said in the previous article on Elixir Phoenix data models, there was a plan to work with data gathering in this part. However, in my opinion, there is one core aspect we should focus on before. This is Elixir Phoenix routing and controllers.
New to the Elixir/Phoenix uptime monitor series? Start with Elixir Phoenix project setup.
Routing
The Router is a big hub of the Phoenix application, where all the requests are dispatched to the right controllers. We had a brief look into the Router while creating authentication and authorization in Elixir/Phoenix application. Let’s take a look at it once again.
The first line in the EMeterWeb.Router module, which is use EMeterWeb, :router, simply makes Phoenix routing functions available in this module. It runs a macro from the >router function in the EMeterWeb module.
The next line - import EMeterWeb.UserAuth, includes authorization functions: redirect_if_user_is_authenticated/2 and require_authenticated_user/2 provided by mix phx.gen.auth in the EMeterWeb.UserAuth module. Those functions are used as plugs further in the module.
defmodule EMeterWeb.Router do
use EMeterWeb, :router
import EMeterWeb.UserAuth
...
Going to the next line, we can spot a pipeline macro. To understand what pipelines are we need to discuss plugs first.
defmodule EMeterWeb.Router do
...
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
pipeline :api do
plug :accepts, ["json"]
end
...
What are plugs in the Phoenix Framework?
Plugs are the heart of the Phoenix Framework. A plug’s task is to operate on connection, check or modify it and pass the unified, modified, and checked connection further into the application. The idea behind plugs is to unify the connection we are working with at all the stages of the request lifecycle.
The whole Phoenix project is built on top of plugs. We interact with them at every stage of the request lifecycle. Also, all the core components, like Endpoint, Router and Controller, are just plugs after all.
Plugs might be implemented in two ways: function plugs and module plugs. In our project we are currently working with function plugs only. I highly encourage you to dig through the Plug’s documentation and read more about the difference between them. Function plugs we are using are just functions receiving and returning a connection.
What are pipelines in Elixir/Phoenix?
Now, as we know what plugs are, let’s go back to the pipeline macro. pipeline defines a series of them, launched one after another. They can be attached to specific scopes in the routes, where they will be launched via pipe_through. It happens often that routes are piped through multiple pipelines.
In our EMeterWeb.Router module we have currently got two pipelines - :browser and :api.
The first one has six plugs:
- :accepts, [“html”] which is responsible for accepting given request format,
- :fetch_session, which as the name says, fetches the session data and makes it available in the connection,
- :fetch_flash, which fetches the session data and makes it available in the connection,
- :protect_from_forgery and :put_secure_browser_headers, which are responsible for securing the application from cross-site request forgery,
- :fetch_current_use, which gets the current user and puts them into connection. This one comes from the mix phx.gen.auth we used in the second article.
The :api pipeline has only one plug at the moment - :accepts, [“json”], which accepts only json-formatted requests.
The scope macro
Finally, we came to the scope macro. scope is the way of grouping given routes under a common path prefix. We want to scope things of common functionality, like admin or API routes. We have quite a lot of scopes already. These were mostly generated via mix phx.gen.auth and are responsible for user authentication and authorization.
defmodule EMeterWeb.Router do
...
scope "/", EMeterWeb do
pipe_through :browser
get "/", PageController, :index
end
...
scope "/", EMeterWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :confirm
end
end
With that kinda longish introduction to routing in Phoenix, let’s create our first routes. We will use yet another macro, which is resources. Its functionality is relatively easy. It creates a basic CRUD for a given resource. Navigate to the scope, which requires the user to be authenticated. Then add a line with the resources macro, which takes three arguments: name of the resource, its controller and options. We won’t cover the editing of our model in this tutorial. This is why there is an except option, which excludes those endpoints from generating in our router.
defmodule EMeterWeb.Router do
...
scope "/", EMeterWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/sites", SitesController, except: [:edit, :update]
...
Let’s run the min phx.routesEMeterWeb.Router command and check the results.
sites_path GET /sites EMeterWeb.SitesController :index
sites_path GET /sites/new EMeterWeb.SitesController :new
sites_path GET /sites/:id EMeterWeb.SitesController :show
sites_path POST /sites EMeterWeb.SitesController :create
sites_path DELETE /sites/:id EMeterWeb.SitesController :delete
As you can see the endpoints for basic model management were created.
Controllers
Controllers are the places our router is pointing to. They are used to group common functionality in the same module. Controllers contain actions, which are just functions receiving connection and request parameters.
We already named our controller in the router. Now let’s create the module file in the apps/e_meter_web/lib/e_meter_web/controllers folder. Name it sites_controller.ex.
defmodule EMeterWeb.SitesController do
use EMeterWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end
The first line of the module says that it should use our EMeterWeb controller function, which defines all needed uses, imports and aliases. It is a default in our application, all the controllers are using it.
Then we define the first action of our controller - index/2. The index/2 purpose in our case is to return all sites of a given user. Let’s also create show/2 and new/2 actions. The first one should show the site with a given id and the second one should render the new form for the site. In the show/2 we are matching id in params. id is a route param related to this endpoint, which is /sites/:id, and it indicates which site should we fetch and show to the user.
defmodule EMeterWeb.SitesController do
...
def show(conn, %{"id" => _id}) do
render(conn, "show.html")
end
def new(conn, _params) do
render(conn, "new.html")
end
end
For now, we will leave the data loading alone and will focus on rendering those pages. To do so, we will need a view module for this controller. Let’s navigate to the apps/e_meter_web/lib/e_meter_web/view folder and create sites_view.ex.
defmodule EMeterWeb.SitesView do
use EMeterWeb, :view
end
As it goes with controllers - views also have a standard pack of uses, imports and assigns, which are defined in EMeterWeb under the view function.
The next thing is to add the templates themselves. To do so, navigate to the apps/e_meter_web/lib/e_meter_web/templates folder. Create a new folder called sites. In this folder, create three empty files: index.html.eex, show.html.eex and new.html.eex. EEx is an Elixir templates system, which Phoenix makes use of.
Now - launch the server with the iex -S mix phx.server command. Sign up & sign in, then call the /sites endpoint of our application. You should see an empty page, which is expected behavior, because our index.html.eex template is currently empty.
Now, when we have our templates rendered, we can start filling them with content.
Firstly - let’s cover creating a new site. What we want to deliver to our template to build a form is a website changeset. It is relatively simple. Just alias the Website module at the top of our controller to keep the calls shorter and then, in new/2 call the changeset with an empty Website structure. Then pass the result to the template as the keyword list. Simple as that.
defmodule EMeterWeb.SitesController do
use EMeterWeb, :controller
alias EMeter.Sites.Website
...
def new(conn, _params) do
changeset = Website.changeset(%Website{})
render(conn, "new.html", changeset: changeset)
end
end
We can inspect the passed changeset in our template now. Update the new.html.eex template and add inspect inside. Then call the /sites/new endpoint.
<% IO.inspect @changeset %>
We should see the whole changeset in the terminal.
#Ecto.Changeset<
action: nil,
changes: %{},
errors: [
user_id: {"can't be blank", [validation: :required]},
url: {"can't be blank", [validation: :required]}
],
data: #EMeter.Sites.Website<>,
valid?: false
>
This is great! We’ve just passed our first data to the template. Now we have to make use of it. We need to create a form for creating the websites in our application. Of course, Phoenix has helpers to make it easier, so we don’t have to write a plain html.
<%= form_for @changeset, Routes.sites_path(@conn, :create), fn f -> %>
<%= text_input f, :url, placeholder: "Website url" %>
<%= error_tag f, :url %>
<%= submit "Sumbit" %>
<% end %>
Knowing that our website model has only one field, url, we can create a form with a single text input and a “submit” button. form_for/4 takes form data, action, options and function arguments. Once we re-render our site, we should see a small form containing our input.
You can also spot the error_tag. This one renders errors from the changeset correlated to a given input.
The question is: why isn’t it rendered with the new form, although there are errors in the changeset? This is correlated to the action field of our changeset. Once it changes from nil to other with some action being performed on that changeset - the form will know that something was made with a given changeset and the errors should be displayed.
We defined action as Routes.sites_path(@conn, :create), yet we didn’t define such action in our controller. To do so, go back to the controller and define create/2 action.
defmodule EMeterWeb.SitesController do
...
def new(conn, _params) do
changeset = Website.changeset(%Website{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"website" => params}) do
IO.inspect conn.assigns
IO.inspect params
redirect(conn, to: Routes.sites_path(conn, :index))
end
end
For now, its purpose is only to inspect assignments and parameters and then - redirect to the index path. Refresh the form, fill it and submit. You should see something like this in your console.
%{
current_user: #EMeter.Accounts.User<
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
confirmed_at: nil,
email: "kkowalczykowski@prograils.com",
hashed_password: "$2b$12$5hp4QP0rSnY7pwf3.Ox6WOKtTqg3PjtaH8mUPBnE8Ec1IWjERXbXm",
id: "2e8dc36a-1c45-4f2f-89e1-f3ca8a100fac",
inserted_at: ~N[2021-08-27 10:46:50],
updated_at: ~N[2021-08-27 10:46:50],
...
>
}
%{"url" => "https://prograils.com/"}
As you can see, we have all the data needed to create a website compatible with our Website model. Let’s create a function for it in the EMeter.Sites context. We will take params, pass them through a Website.changeset/2, and insert the result to the database.
defmodule EMeter.Sites do
alias EMeter.Sites.Website
@repo Postgres.Repo
def create_website(params) do
%Website{}
|> Website.changeset(params)
|> @repo.insert()
end
end
Now we can call this function in our controller. We will also make a reaction flow using a case statement. The database insertion can return two tuples: {:ok, result} in case of success and {:error, reason} in case something went wrong. We will match those results in our controller and react to them properly.
defmodule EMeterWeb.SitesController do
use EMeterWeb, :controller
alias EMeter.Sites
...
def create(conn, %{"website" => params}) do
case Sites.create_website(params) do
{:ok, website} ->
redirect(conn, to: Routes.sites_path(conn, :show, website.id))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end
There is only one thing left. Our model needs a user ID to be created.
This is why we inspected conn.assigns before. There is a current_user assigned by the fetch_current_user plug. We add the ID to params and we should be able to create our first Website.
defmodule EMeterWeb.SitesController do
...
def create(%{assigns: %{current_user: %{id: user_id}}} = conn, %{"website" => params}) do
params
|> Map.put("user_id", user_id)
|> Sites.create_website()
|> case do
{:ok, website} ->
redirect(conn, to: Routes.sites_path(conn, :show, website.id))
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end
Other actions
You should be able to create the logic of other actions in our controller. To keep this article shorter, I won’t discuss all of them, but I will paste my code below with some high-level concepts and explanations, so you have the material to analyze and learn from.
sites_controller.ex - EMeterWeb.SitesController
In the index/2, we just added fetching of websites belonging to a current user. In the show/2, we are trying to fetch a website with a given id. In case of success, we display it, otherwise, we send a 404 and redirect to the index. In delete/2, we are trying to delete a user’s website. If this fails we are raising an error. We also added some statuses and flash messages in various actions.
defmodule EMeterWeb.SitesController do
use EMeterWeb, :controller
alias EMeter.Sites
alias EMeter.Sites.Website
def index(%{assigns: %{current_user: %{id: user_id}}} = conn, _params) do
websites = Sites.get_user_websites(user_id)
render(conn, "index.html", websites: websites)
end
def show(%{assigns: %{current_user: %{id: user_id}}} = conn, %{"id" => website_id}) do
case Sites.get_user_website(website_id, user_id) do
nil ->
conn
|> put_flash(:error, "Website not found")
|> put_status(:not_found)
|> redirect(to: Routes.sites_path(conn, :index))
website ->
render(conn, "show.html", website: website)
end
end
def new(conn, _params) do
changeset = Website.changeset(%Website{})
render(conn, "new.html", changeset: changeset)
end
def create(%{assigns: %{current_user: %{id: user_id}}} = conn, %{"website" => params}) do
params
|> Map.put("user_id", user_id)
|> Sites.create_website()
|> case do
{:ok, website} ->
conn
|> put_flash(:info, "Success, website created")
|> put_status(:created)
|> redirect(to: Routes.sites_path(conn, :show, website.id))
{:error, changeset} ->
conn
|> put_flash(:error, "Error, invalid data")
|> put_status(:unprocessable_entity)
|> render("new.html", changeset: changeset)
end
end
def delete(%{assigns: %{current_user: %{id: user_id}}} = conn, %{"id" => id}) do
Sites.delete_website!(id, user_id)
redirect(conn, to: Routes.sites_path(conn, :index))
end
end
sites.ex - EMeter.Sites
We added three basic functions:
get_user_websites/1, which takes user ID as an argument and returns his websites,
get_user_website/2, which takes website ID and user ID and gets a website matching both of them,
delete_website/2, which takes website ID and user ID and deletes a matching website.
defmodule EMeter.Sites do
import Ecto.Query
alias EMeter.Sites.Website
@repo Postgres.Repo
def create_website(params) do
%Website{}
|> Website.changeset(params)
|> @repo.insert()
end
def get_user_websites(user_id) do
Website
|> where([website], website.user_id == ^user_id)
|> @repo.all()
end
def get_user_website(website_id, user_id) do
Website
|> where([website], website.id == ^website_id)
|> where([website], website.user_id == ^user_id)
|> @repo.one()
end
def delete_website!(id, user_id) do
Website
|> where([website], website.id == ^id)
|> where([website], website.user_id == ^user_id)
|> @repo.delete_all()
|> case do
{1, _} -> :ok
end
end
end
Elixir/Phoenix routing and controls: A word of conclusion
In this article, we have learned about routing and controllers in the Phoenix framework.
Our application is starting to have its core functionality implemented. We are on a good way to start thinking about background jobs and collecting all the data of our Elixir Phoenix app. We are going to focus on it in the next article.
For now, I highly encourage you to dig a bit into Phoenix documentation to learn more about forms, HTML helpers and other really useful stuff that makes developing an application much easier.
Any kind of feedback appreciated!