Web Apps using the Elixer Phoenix Framework

Updated: 08 May 2024

Notes based on Phoenix Framework REST API Crash Course

Prerequisites

Probably take a look at the Elixir Notes first

In order to get started the following are required:

It’s also handy to have the following installed if using VSCode:

Create the Application

Mix is a build tool for Elixir - To create the application we will use the mix CLI along with the Phoenix template

Terminal window
1
mix phx.new elixirphoenix --database sqlite3

In the above setup we’re going to use SQLite as the database. You can find the configuration for this in config/dev.exs

The databse is fairly abstracted from our application so the overall implementation shouldn’t differ too much other than in some configuration

Thereafter, you can start your application using:

Terminal window
1
mix phx.server

In future, when updating dependencies the following command can be used to install dependencies:

Terminal window
1
mix deps.get

Application Structure

The application code is laid out as follows:

1
├── _build // compilation artifacts
2
├── assets // client-side assets (JS/CSS)
3
├── config // configuration for application. config.exs is the main file that imports environments. runtime.exs can be used for loading dynamic config
4
├── deps // dependecy code
5
├── lib // application code
6
│   ├── elixirphoenix // Model - business logic
7
│   ├── elixirphoenix.ex
8
│   ├── elixirphoenix_web // View and Controller - exposes business logic to API
9
│   └── elixirphoenix_web.ex
10
├── priv // resources necessary in production but not exactly source code - e.g. migrations
11
└── test
12
mix.exs // basic elixir project configuration and dependencies
13
mix.lock // dependency lockfile

Some of the important files we have are:

  • mix.ex - dependencies and project meta
  • config/dev.ex - development config - particularly database config
  • lib/elixirphoenix_web/router.ex - application routes

Routing

We can take a look at the router.ex file to view our initial app structure:

lib/elixirphoenix_web/router.ex
1
defmodule ElixirphoenixWeb.Router do
2
use ElixirphoenixWeb, :router
3
4
pipeline :browser do
5
plug :accepts, ["html"]
6
plug :fetch_session
7
plug :fetch_live_flash
8
plug :put_root_layout, html: {ElixirphoenixWeb.Layouts, :root}
9
plug :protect_from_forgery
10
plug :put_secure_browser_headers
11
end
12
13
pipeline :api do
14
plug :accepts, ["json"]
15
end
16
17
scope "/", ElixirphoenixWeb do
18
pipe_through :browser
19
20
get "/", PageController, :home
21
end
22
23
# Other scopes may use custom stacks.
24
# scope "/api", ElixirphoenixWeb do
25
# pipe_through :api
26
# end
27
28
# Enable LiveDashboard and Swoosh mailbox preview in development
29
if Application.compile_env(:elixirphoenix, :dev_routes) do
30
# If you want to use the LiveDashboard in production, you should put
31
# it behind authentication and allow only admins to access it.
32
# If your application does not have an admins-only section yet,
33
# you can use Plug.BasicAuth to set up some basic authentication
34
# as long as you are also using SSL (which you should anyway).
35
import Phoenix.LiveDashboard.Router
36
37
scope "/dev" do
38
pipe_through :browser
39
40
live_dashboard "/dashboard", metrics: ElixirphoenixWeb.Telemetry
41
forward "/mailbox", Plug.Swoosh.MailboxPreview
42
end
43
end
44
end

In the above we can se that we are using the PageController, the PageController is defined also as:

lib/elixirphoenix_web/controllers/page_controller.ex
1
defmodule ElixirphoenixWeb.PageController do
2
use ElixirphoenixWeb, :controller
3
4
def home(conn, _params) do
5
# The home page is often custom made,
6
# so skip the default app layout.
7
render(conn, :home, layout: false)
8
end
9
end

The above is rendering the :home page which we can find by the lib/elixirphoenix_web/controllers/page_html/home.html.heex which is just a template file. We can replace the contents with a simple hello world

lib/elixirphoenix_web/controllers/page_html/home.html.heex
1
<h1>Hello World</h1>

Creating a Route

In general, we follow the following process when adding routes to our application:

  1. We go to the router file and define a reference to our routes
  2. We go to the controller file and define the routes which may render a template
  3. We go to the template file which renders our content

Let’s create a route in the PageController called users. Do this we need to asadd a reference to it in the router.ex file:

lib/elixirphoenix_web/router.ex
1
scope "/", ElixirphoenixWeb do
2
pipe_through :browser
3
4
get "/", PageController, :home
5
get "/users", PageController, :users
6
end

Next, we can add a function in the PageController called users as we defined by :users above:

lib/elixirphoenix_web/controllers/page_controller.ex
1
def users(conn, _params) do
2
IO.puts("/users endpoint called")
3
users = [
4
%{id: 1,name: "Alice", email: "alice@email.com"},
5
%{id: 2,name: "Bob", email: "bob@email.com"},
6
]
7
8
render(conn, :users, users: users, layout: false)
9
end

And we can create a users.html.heex file:

lib/elixirphoenix_web/controllers/page_html/users.html.heex
1
<h1>Users</h1>
2
3
<ul>
4
<%= for user <- @users do %>
5
<li><%= user.name %> - <%= user.email %></li>
6
<% end %>
7
</ul>

In the heex file above, the elixir code that is embedded in the template is denoted by the <%= ... %>.

HEEx stands for HTML + Embedded Elixir

Working with Data

Phoenix

Defining a JSON resource

We can use phoenix and mix to generate and inialize a resource using the Mix CLI

We’re going to generate a simple entity called a Post for our application

Terminal window
1
mix phx.gen.json Posts Post posts title:string body:string

The above commands are in the structure of Context Entity dbtable ...fields

The Context is an elixir module that will be used to contain all the related functionality for a given entity. For example we may have a User in multiple different Contexts such as Accounts.user and Payments.user

The above commands will generate the relvant modules and JSON code for interacting with application entities:

  • Controllers for each resource
  • Entity schemas for each resource
  • Migrations for each resource

Overall, when working with phoenix, we use the Model-View-Controller architecture (MVC), when building an API, we can think of the JSON structure as the view for the sake of our API

Hooking up the Generated Code

After running the above command we will have some instructions telling us to add some content to the router.ex file. We’re going to first create a new scope and add it into that scope:

lib/elixirphoenix_web/router.ex
1
scope "/api", ElixirphoenixWeb do
2
pipe_through :api
3
4
resources "/posts", PostController, except: [:new, :edit]
5
end

Next, we need to run the following command as instructed:

Terminal window
1
mix ecto.migrate

Which will run the database migration that sets up the posts which was generated during the above command:

priv/repo/migrations/20240508103250_create_posts.exs
1
defmodule Elixirphoenix.Repo.Migrations.CreatePosts do
2
use Ecto.Migration
3
4
def change do
5
create table(:posts) do
6
add :title, :string
7
add :body, :string
8
9
timestamps()
10
end
11
end
12
end

This sets up the database to work with the Post entity that was generated which can be seen below:

lib/elixirphoenix/posts/post.ex
1
defmodule Elixirphoenix.Posts.Post do
2
use Ecto.Schema
3
import Ecto.Changeset
4
5
schema "posts" do
6
field :title, :string
7
field :body, :string
8
9
timestamps()
10
end
11
12
@doc false
13
def changeset(post, attrs) do
14
post
15
|> cast(attrs, [:title, :body])
16
|> validate_required([:title, :body])
17
end
18
end

Working with Resourecs

Which we then access from the controller that we exposed earlier in our router:

lib/elixirphoenix_web/controllers/post_controller.ex
1
defmodule ElixirphoenixWeb.PostController do
2
use ElixirphoenixWeb, :controller
3
4
alias Elixirphoenix.Posts
5
alias Elixirphoenix.Posts.Post
6
7
action_fallback ElixirphoenixWeb.FallbackController
8
9
def index(conn, _params) do
10
posts = Posts.list_posts()
11
render(conn, :index, posts: posts)
12
end
13
14
def create(conn, %{"post" => post_params}) do
15
with {:ok, %Post{} = post} <- Posts.create_post(post_params) do
16
conn
17
|> put_status(:created)
18
|> put_resp_header("location", ~p"/api/posts/#{post}")
19
|> render(:show, post: post)
20
end
21
end
22
23
def show(conn, %{"id" => id}) do
24
post = Posts.get_post!(id)
25
render(conn, :show, post: post)
26
end
27
28
def update(conn, %{"id" => id, "post" => post_params}) do
29
post = Posts.get_post!(id)
30
31
with {:ok, %Post{} = post} <- Posts.update_post(post, post_params) do
32
render(conn, :show, post: post)
33
end
34
end
35
36
def delete(conn, %{"id" => id}) do
37
post = Posts.get_post!(id)
38
39
with {:ok, %Post{}} <- Posts.delete_post(post) do
40
send_resp(conn, :no_content, "")
41
end
42
end
43
end

If we run our app now using mix phx.server we can go to http://localhost:4000/api/posts where we will see the date returned by our controller:

GET http://localhost:4000/api/posts
1
{
2
"data": []
3
}

We can also do the same request using Nushell or any other HTTP Client you want

This is empty since we have nothing in our database as yet. We can create a POST request with the data for a user to the same endpoint which will create a new Post using the following:

POST http://localhost:4000/api/posts
1
// REQUEST
2
{
3
"post": {
4
"title": "My Post Title",
5
"body": "Some content for my post"
6
}
7
}
8
9
// RESPONSE
10
{
11
"data": {
12
"id": 1,
13
"title": "My Post Title",
14
"body": "Some content for my post"
15
}
16
}

The above is the format of our API though we can change this if we wanted. The method handling the above request can be seen below:

lib/elixirphoenix_web/controllers/post_controller.ex
1
def create(conn, %{"post" => post_params}) do
2
# pattern match the result of post creation with an ok status
3
with {:ok, %Post{} = post} <- Posts.create_post(post_params) do
4
conn
5
|> put_status(:created)
6
|> put_resp_header("location", ~p"/api/posts/#{post}")
7
|> render(:show, post: post)
8
end
9
end

If we post invalid data we will return an error depending on the data we send as will be defined from the

We can also see in the above that we return data using the render(:show, post: post) call, this renders the response using the following template:

lib/elixirphoenix_web/controllers/post_json.ex
1
defmodule ElixirphoenixWeb.PostJSON do
2
alias Elixirphoenix.Posts.Post
3
4
@doc """
5
Renders a list of posts.
6
"""
7
def index(%{posts: posts}) do
8
%{data: for(post <- posts, do: data(post))}
9
end
10
11
@doc """
12
Renders a single post.
13
"""
14
def show(%{post: post}) do
15
%{data: data(post)}
16
end
17
18
defp data(%Post{} = post) do
19
%{
20
id: post.id,
21
title: post.title,
22
body: post.body
23
}
24
end
25
end

Viewing Routes

We can get a view of the routes that our application has available using the following command:

Terminal window
1
> mix phx.routes
2
3
GET / ElixirphoenixWeb.PageController :home
4
GET /users ElixirphoenixWeb.PageController :users
5
GET /api/posts ElixirphoenixWeb.PostController :index
6
GET /api/posts/:id ElixirphoenixWeb.PostController :show
7
POST /api/posts ElixirphoenixWeb.PostController :create
8
PATCH /api/posts/:id ElixirphoenixWeb.PostController :update
9
PUT /api/posts/:id ElixirphoenixWeb.PostController :update
10
DELETE /api/posts/:id ElixirphoenixWeb.PostController :delete
11
GET /dev/dashboard/css-:md5 Phoenix.LiveDashboard.Assets :css
12
GET /dev/dashboard/js-:md5 Phoenix.LiveDashboard.Assets :js
13
GET /dev/dashboard Phoenix.LiveDashboard.PageLive :home
14
GET /dev/dashboard/:page Phoenix.LiveDashboard.PageLive :page
15
GET /dev/dashboard/:node/:page Phoenix.LiveDashboard.PageLive :page
16
* /dev/mailbox Plug.Swoosh.MailboxPreview []
17
WS /live/websocket Phoenix.LiveView.Socket
18
GET /live/longpoll Phoenix.LiveView.Socket
19
POST /live/longpoll Phoenix.LiveView.Socket

The above shows us the routes that exist in our app. Phoenix is largely convention based and so our controllers will have a fairly standard structure

We can also see that the POST and PATCH for our resource point to the :update method. This is because by default the POST and PATCH both work like a PATCH. If you want replace data you can create a separate method that would work as a normal POST method

Resource Relationships

We can create another resource for Users that can have posts associated with them, we can generate this using the following:

Terminal window
1
mix phx.gen.json Accounts User users name:string email:string:unique

Next, we can follow the instructions to add the resource to our router:

lib/elixirphoenix_web/router.ex
1
scope "/api", ElixirphoenixWeb do
2
pipe_through :api
3
4
resources "/posts", PostController, except: [:new, :edit]
5
resources "/users", UserController, except: [:new, :edit]
6
end

We can also remove the users that we had before:

lib/elixirphoenix_web/router.ex
1
scope "/", ElixirphoenixWeb do
2
pipe_through :browser
3
4
get "/", PageController, :home
5
# get "/users", PageController, :users
6
end

And run the migration:

Terminal window
1
mix ecto.migrate

Now, we want to associate a user with a post. We’re going to do this to add a user to a post:

To do this, we need to create new migration

Terminal window
1
mix ecto.gen.migration add_user_to_post

This will generate the following empty migration:

priv/repo/migrations/20240508120947_add_user_to_post.exs
1
defmodule Elixirphoenix.Repo.Migrations.AddUserToPost do
2
use Ecto.Migration
3
4
def change do
5
6
end
7
end

In this file we will need to specify the change we want to make to our database table:

priv/repo/migrations/20240508120947_add_user_to_post.exs
1
defmodule Elixirphoenix.Repo.Migrations.AddUserToPost do
2
use Ecto.Migration
3
4
def change do
5
alter table(:posts) do
6
add :user_id, references(:users, on_delete: :nothing)
7
end
8
end
9
end

Next we can apply the migration with mix ecto.migrate

We need to also define that we have a user_id in our schemas. We need to do this in both our resource types:

lib/elixirphoenix/accounts/user.ex
1
defmodule Elixirphoenix.Accounts.User do
2
use Ecto.Schema
3
import Ecto.Changeset
4
5
schema "users" do
6
field :name, :string
7
field :email, :string
8
has_many :posts, Elixirphoenix.Posts.Post
9
10
timestamps()
11
end
12
13
@doc false
14
def changeset(user, attrs) do
15
user
16
|> cast(attrs, [:name, :email])
17
|> validate_required([:name, :email])
18
|> unique_constraint(:email)
19
end
20
end

For the Post, we need to define the relationship as well as the validation information:

lib/elixirphoenix/posts/post.ex
1
defmodule Elixirphoenix.Posts.Post do
2
use Ecto.Schema
3
import Ecto.Changeset
4
5
schema "posts" do
6
field :title, :string
7
field :body, :string
8
belongs_to :user, Elixirphoenix.Accounts.User
9
10
timestamps()
11
end
12
13
@doc false
14
def changeset(post, attrs) do
15
post
16
|> cast(attrs, [:title, :body, :user_id])
17
|> validate_required([:title, :body, :user_id])
18
end
19
end

We can create a user by sending the following:

POST http://localhost:4000/api/users
1
{
2
"user": {
3
"name": "Bob",
4
"email": "bob@email.com"
5
}
6
}

And then creating a Post with the associated user ID that we get back:

POST http://localhost:4000/api/posts
1
{
2
"post": {
3
"title": "My Post Title",
4
"body": "Some content for my post",
5
"user_id": 1
6
}
7
}

If we want the user_id to be returned with the Post data we can modify the post_json.ex file:

lib/elixirphoenix_web/controllers/post_json.ex
1
defp data(%Post{} = post) do
2
%{
3
id: post.id,
4
title: post.title,
5
body: post.body,
6
user_id: post.user_id
7
}
8
end

Getting Nested Data

If we want to get the posts when getting a user, we can make it such that we include the data. This is done from the context where we can add Repo.preload(:post) into the get_user as well as our list_users functions:

lib/elixirphoenix/accounts.ex
1
def list_users do
2
Repo.all(User) |> Repo.preload(:posts)
3
end
4
5
def get_user!(id), do: Repo.get!(User, id) |> Repo.preload(:posts)

Then we need to update our view to include posts. First, we can change the data function from our post_json.ex file to not be private:

lib/elixirphoenix_web/controllers/post_json.ex
1
def data(%Post{} = post) do

And we can then use that from our user_json file:

lib/elixirphoenix_web/controllers/user_json.ex
1
defmodule ElixirphoenixWeb.UserJSON do
2
alias ElixirphoenixWeb.PostJSON
3
alias Elixirphoenix.Accounts.User
4
5
@doc """
6
Renders a list of users.
7
"""
8
def index(%{users: users}) do
9
%{data: for(user <- users, do: data(user))}
10
end
11
12
@doc """
13
Renders a single user.
14
"""
15
def show(%{user: user}) do
16
%{data: data(user)}
17
end
18
19
defp data(%User{} = user) do
20
%{
21
id: user.id,
22
name: user.name,
23
email: user.email,
24
posts: for(post <- user.posts, do: PostJSON.data(post))
25
}
26
end
27
end

This will load the posts property when we query a user, so now if we do:

GET http://localhost:4000/api/users/1
1
{
2
"data": {
3
"id": 1,
4
"name": "Bob",
5
"email": "bob@email.com",
6
"posts": [
7
{
8
"id": 2,
9
"title": "My Post Title",
10
"body": "Some content for my post",
11
"user_id": 1
12
}
13
]
14
}
15
}

The above implementation however will not catch all cases where we can load the data since we try to convert this view to JSON in cases such as creation or updating where we may not want to preload the data. In these cases we can also use pattern matching to check if the data has not been loaded:

lib/elixirphoenix_web/controllers/user_json.ex
1
defp data(%User{} = user) do
2
result = %{
3
id: user.id,
4
name: user.name,
5
email: user.email,
6
}
7
8
case user.posts do
9
# if the posts are not loaded then we return the result without them
10
%Ecto.Association.NotLoaded{} -> result
11
12
# we add a `posts` field to the map if we do have the posts
13
posts -> Map.put(result, :posts, Enum.map(posts, &PostJSON.data/1))
14
end
15
end