Web Apps using the Elixer Phoenix Framework
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
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:
mix phx.server
In future, when updating dependencies the following command can be used to install dependencies:
mix deps.get
Application Structure
The application code is laid out as follows:
├── _build // compilation artifacts
├── assets // client-side assets (JS/CSS)
├── config // configuration for application. config.exs is the main file that imports environments. runtime.exs can be used for loading dynamic config
├── deps // dependecy code
├── lib // application code
│ ├── elixirphoenix // Model - business logic
│ ├── elixirphoenix.ex
│ ├── elixirphoenix_web // View and Controller - exposes business logic to API
│ └── elixirphoenix_web.ex
├── priv // resources necessary in production but not exactly source code - e.g. migrations
└── test
mix.exs // basic elixir project configuration and dependencies
mix.lock // dependency lockfile
Some of the important files we have are:
mix.ex- dependencies and project metaconfig/dev.ex- development config - particularly database configlib/elixirphoenix_web/router.ex- application routes
Routing
We can take a look at the router.ex file to view our initial app structure:
defmodule ElixirphoenixWeb.Router do
use ElixirphoenixWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {ElixirphoenixWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", ElixirphoenixWeb do
pipe_through :browser
get "/", PageController, :home
end
# Other scopes may use custom stacks.
# scope "/api", ElixirphoenixWeb do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:elixirphoenix, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard", metrics: ElixirphoenixWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end
In the above we can se that we are using the PageController, the PageController is defined also as:
defmodule ElixirphoenixWeb.PageController do
use ElixirphoenixWeb, :controller
def home(conn, _params) do
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
end
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
<h1>Hello World</h1>
Creating a Route
In general, we follow the following process when adding routes to our application:
- We go to the router file and define a reference to our routes
- We go to the controller file and define the routes which may render a template
- 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:
scope "/", ElixirphoenixWeb do
pipe_through :browser
get "/", PageController, :home
get "/users", PageController, :users
end
Next, we can add a function in the PageController called users as we defined by :users above:
def users(conn, _params) do
IO.puts("/users endpoint called")
users = [
%{id: 1,name: "Alice", email: "alice@email.com"},
%{id: 2,name: "Bob", email: "bob@email.com"},
]
render(conn, :users, users: users, layout: false)
end
And we can create a users.html.heex file:
<h1>Users</h1>
<ul>
<%= for user <- @users do %>
<li><%= user.name %> - <%= user.email %></li>
<% end %>
</ul>
In the heex file above, the elixir code that is embedded in the template is denoted by the <%= ... %>.
HEExstands 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
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:
scope "/api", ElixirphoenixWeb do
pipe_through :api
resources "/posts", PostController, except: [:new, :edit]
end
Next, we need to run the following command as instructed:
mix ecto.migrate
Which will run the database migration that sets up the posts which was generated during the above command:
defmodule Elixirphoenix.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string
add :body, :string
timestamps()
end
end
end
This sets up the database to work with the Post entity that was generated which can be seen below:
defmodule Elixirphoenix.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title, :body])
end
end
Working with Resourecs
Which we then access from the controller that we exposed earlier in our router:
defmodule ElixirphoenixWeb.PostController do
use ElixirphoenixWeb, :controller
alias Elixirphoenix.Posts
alias Elixirphoenix.Posts.Post
action_fallback ElixirphoenixWeb.FallbackController
def index(conn, _params) do
posts = Posts.list_posts()
render(conn, :index, posts: posts)
end
def create(conn, %{"post" => post_params}) do
with {:ok, %Post{} = post} <- Posts.create_post(post_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/posts/#{post}")
|> render(:show, post: post)
end
end
def show(conn, %{"id" => id}) do
post = Posts.get_post!(id)
render(conn, :show, post: post)
end
def update(conn, %{"id" => id, "post" => post_params}) do
post = Posts.get_post!(id)
with {:ok, %Post{} = post} <- Posts.update_post(post, post_params) do
render(conn, :show, post: post)
end
end
def delete(conn, %{"id" => id}) do
post = Posts.get_post!(id)
with {:ok, %Post{}} <- Posts.delete_post(post) do
send_resp(conn, :no_content, "")
end
end
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:
{
"data": []
}
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:
// REQUEST
{
"post": {
"title": "My Post Title",
"body": "Some content for my post"
}
}
// RESPONSE
{
"data": {
"id": 1,
"title": "My Post Title",
"body": "Some content for my post"
}
}
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:
def create(conn, %{"post" => post_params}) do
# pattern match the result of post creation with an ok status
with {:ok, %Post{} = post} <- Posts.create_post(post_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/posts/#{post}")
|> render(:show, post: post)
end
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:
defmodule ElixirphoenixWeb.PostJSON do
alias Elixirphoenix.Posts.Post
@doc """
Renders a list of posts.
"""
def index(%{posts: posts}) do
%{data: for(post <- posts, do: data(post))}
end
@doc """
Renders a single post.
"""
def show(%{post: post}) do
%{data: data(post)}
end
defp data(%Post{} = post) do
%{
id: post.id,
title: post.title,
body: post.body
}
end
end
Viewing Routes
We can get a view of the routes that our application has available using the following command:
> mix phx.routes
GET / ElixirphoenixWeb.PageController :home
GET /users ElixirphoenixWeb.PageController :users
GET /api/posts ElixirphoenixWeb.PostController :index
GET /api/posts/:id ElixirphoenixWeb.PostController :show
POST /api/posts ElixirphoenixWeb.PostController :create
PATCH /api/posts/:id ElixirphoenixWeb.PostController :update
PUT /api/posts/:id ElixirphoenixWeb.PostController :update
DELETE /api/posts/:id ElixirphoenixWeb.PostController :delete
GET /dev/dashboard/css-:md5 Phoenix.LiveDashboard.Assets :css
GET /dev/dashboard/js-:md5 Phoenix.LiveDashboard.Assets :js
GET /dev/dashboard Phoenix.LiveDashboard.PageLive :home
GET /dev/dashboard/:page Phoenix.LiveDashboard.PageLive :page
GET /dev/dashboard/:node/:page Phoenix.LiveDashboard.PageLive :page
* /dev/mailbox Plug.Swoosh.MailboxPreview []
WS /live/websocket Phoenix.LiveView.Socket
GET /live/longpoll Phoenix.LiveView.Socket
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:
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:
scope "/api", ElixirphoenixWeb do
pipe_through :api
resources "/posts", PostController, except: [:new, :edit]
resources "/users", UserController, except: [:new, :edit]
end
We can also remove the users that we had before:
scope "/", ElixirphoenixWeb do
pipe_through :browser
get "/", PageController, :home
# get "/users", PageController, :users
end
And run the migration:
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
mix ecto.gen.migration add_user_to_post
This will generate the following empty migration:
defmodule Elixirphoenix.Repo.Migrations.AddUserToPost do
use Ecto.Migration
def change do
end
end
In this file we will need to specify the change we want to make to our database table:
defmodule Elixirphoenix.Repo.Migrations.AddUserToPost do
use Ecto.Migration
def change do
alter table(:posts) do
add :user_id, references(:users, on_delete: :nothing)
end
end
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:
defmodule Elixirphoenix.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :name, :string
field :email, :string
has_many :posts, Elixirphoenix.Posts.Post
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email])
|> validate_required([:name, :email])
|> unique_constraint(:email)
end
end
For the Post, we need to define the relationship as well as the validation information:
defmodule Elixirphoenix.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
belongs_to :user, Elixirphoenix.Accounts.User
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :user_id])
|> validate_required([:title, :body, :user_id])
end
end
We can create a user by sending the following:
{
"user": {
"name": "Bob",
"email": "bob@email.com"
}
}
And then creating a Post with the associated user ID that we get back:
{
"post": {
"title": "My Post Title",
"body": "Some content for my post",
"user_id": 1
}
}
If we want the user_id to be returned with the Post data we can modify the post_json.ex file:
defp data(%Post{} = post) do
%{
id: post.id,
title: post.title,
body: post.body,
user_id: post.user_id
}
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:
def list_users do
Repo.all(User) |> Repo.preload(:posts)
end
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:
def data(%Post{} = post) do
And we can then use that from our user_json file:
defmodule ElixirphoenixWeb.UserJSON do
alias ElixirphoenixWeb.PostJSON
alias Elixirphoenix.Accounts.User
@doc """
Renders a list of users.
"""
def index(%{users: users}) do
%{data: for(user <- users, do: data(user))}
end
@doc """
Renders a single user.
"""
def show(%{user: user}) do
%{data: data(user)}
end
defp data(%User{} = user) do
%{
id: user.id,
name: user.name,
email: user.email,
posts: for(post <- user.posts, do: PostJSON.data(post))
}
end
end
This will load the posts property when we query a user, so now if we do:
{
"data": {
"id": 1,
"name": "Bob",
"email": "bob@email.com",
"posts": [
{
"id": 2,
"title": "My Post Title",
"body": "Some content for my post",
"user_id": 1
}
]
}
}
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:
defp data(%User{} = user) do
result = %{
id: user.id,
name: user.name,
email: user.email,
}
case user.posts do
# if the posts are not loaded then we return the result without them
%Ecto.Association.NotLoaded{} -> result
# we add a `posts` field to the map if we do have the posts
posts -> Map.put(result, :posts, Enum.map(posts, &PostJSON.data/1))
end
end