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
1mix 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:
1mix phx.server
In future, when updating dependencies the following command can be used to install dependencies:
1mix deps.get
Application Structure
The application code is laid out as follows:
1├── _build // compilation artifacts2├── 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 config4├── deps // dependecy code5├── lib // application code6│ ├── elixirphoenix // Model - business logic7│ ├── elixirphoenix.ex8│ ├── elixirphoenix_web // View and Controller - exposes business logic to API9│ └── elixirphoenix_web.ex10├── priv // resources necessary in production but not exactly source code - e.g. migrations11└── test12mix.exs // basic elixir project configuration and dependencies13mix.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:
1defmodule ElixirphoenixWeb.Router do2 use ElixirphoenixWeb, :router3
4 pipeline :browser do5 plug :accepts, ["html"]6 plug :fetch_session7 plug :fetch_live_flash8 plug :put_root_layout, html: {ElixirphoenixWeb.Layouts, :root}9 plug :protect_from_forgery10 plug :put_secure_browser_headers11 end12
13 pipeline :api do14 plug :accepts, ["json"]15 end16
17 scope "/", ElixirphoenixWeb do18 pipe_through :browser19
20 get "/", PageController, :home21 end22
23 # Other scopes may use custom stacks.24 # scope "/api", ElixirphoenixWeb do25 # pipe_through :api26 # end27
28 # Enable LiveDashboard and Swoosh mailbox preview in development29 if Application.compile_env(:elixirphoenix, :dev_routes) do30 # If you want to use the LiveDashboard in production, you should put31 # 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 authentication34 # as long as you are also using SSL (which you should anyway).35 import Phoenix.LiveDashboard.Router36
37 scope "/dev" do38 pipe_through :browser39
40 live_dashboard "/dashboard", metrics: ElixirphoenixWeb.Telemetry41 forward "/mailbox", Plug.Swoosh.MailboxPreview42 end43 end44end
In the above we can se that we are using the PageController
, the PageController
is defined also as:
1defmodule ElixirphoenixWeb.PageController do2 use ElixirphoenixWeb, :controller3
4 def home(conn, _params) do5 # The home page is often custom made,6 # so skip the default app layout.7 render(conn, :home, layout: false)8 end9end
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
1<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:
1scope "/", ElixirphoenixWeb do2 pipe_through :browser3
4 get "/", PageController, :home5 get "/users", PageController, :users6end
Next, we can add a function in the PageController
called users
as we defined by :users
above:
1def users(conn, _params) do2 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)9end
And we can create a users.html.heex
file:
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
1mix 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:
1scope "/api", ElixirphoenixWeb do2 pipe_through :api3
4 resources "/posts", PostController, except: [:new, :edit]5end
Next, we need to run the following command as instructed:
1mix ecto.migrate
Which will run the database migration that sets up the posts which was generated during the above command:
1defmodule Elixirphoenix.Repo.Migrations.CreatePosts do2 use Ecto.Migration3
4 def change do5 create table(:posts) do6 add :title, :string7 add :body, :string8
9 timestamps()10 end11 end12end
This sets up the database to work with the Post
entity that was generated which can be seen below:
1defmodule Elixirphoenix.Posts.Post do2 use Ecto.Schema3 import Ecto.Changeset4
5 schema "posts" do6 field :title, :string7 field :body, :string8
9 timestamps()10 end11
12 @doc false13 def changeset(post, attrs) do14 post15 |> cast(attrs, [:title, :body])16 |> validate_required([:title, :body])17 end18end
Working with Resourecs
Which we then access from the controller that we exposed earlier in our router:
1defmodule ElixirphoenixWeb.PostController do2 use ElixirphoenixWeb, :controller3
4 alias Elixirphoenix.Posts5 alias Elixirphoenix.Posts.Post6
7 action_fallback ElixirphoenixWeb.FallbackController8
9 def index(conn, _params) do10 posts = Posts.list_posts()11 render(conn, :index, posts: posts)12 end13
14 def create(conn, %{"post" => post_params}) do15 with {:ok, %Post{} = post} <- Posts.create_post(post_params) do16 conn17 |> put_status(:created)18 |> put_resp_header("location", ~p"/api/posts/#{post}")19 |> render(:show, post: post)20 end21 end22
23 def show(conn, %{"id" => id}) do24 post = Posts.get_post!(id)25 render(conn, :show, post: post)26 end27
28 def update(conn, %{"id" => id, "post" => post_params}) do29 post = Posts.get_post!(id)30
31 with {:ok, %Post{} = post} <- Posts.update_post(post, post_params) do32 render(conn, :show, post: post)33 end34 end35
36 def delete(conn, %{"id" => id}) do37 post = Posts.get_post!(id)38
39 with {:ok, %Post{}} <- Posts.delete_post(post) do40 send_resp(conn, :no_content, "")41 end42 end43end
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:
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:
1// REQUEST2{3 "post": {4 "title": "My Post Title",5 "body": "Some content for my post"6 }7}8
9// RESPONSE10{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:
1def create(conn, %{"post" => post_params}) do2 # pattern match the result of post creation with an ok status3 with {:ok, %Post{} = post} <- Posts.create_post(post_params) do4 conn5 |> put_status(:created)6 |> put_resp_header("location", ~p"/api/posts/#{post}")7 |> render(:show, post: post)8 end9end
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:
1defmodule ElixirphoenixWeb.PostJSON do2 alias Elixirphoenix.Posts.Post3
4 @doc """5 Renders a list of posts.6 """7 def index(%{posts: posts}) do8 %{data: for(post <- posts, do: data(post))}9 end10
11 @doc """12 Renders a single post.13 """14 def show(%{post: post}) do15 %{data: data(post)}16 end17
18 defp data(%Post{} = post) do19 %{20 id: post.id,21 title: post.title,22 body: post.body23 }24 end25end
Viewing Routes
We can get a view of the routes that our application has available using the following command:
1> mix phx.routes2
3GET / ElixirphoenixWeb.PageController :home4GET /users ElixirphoenixWeb.PageController :users5GET /api/posts ElixirphoenixWeb.PostController :index6GET /api/posts/:id ElixirphoenixWeb.PostController :show7POST /api/posts ElixirphoenixWeb.PostController :create8PATCH /api/posts/:id ElixirphoenixWeb.PostController :update9PUT /api/posts/:id ElixirphoenixWeb.PostController :update10DELETE /api/posts/:id ElixirphoenixWeb.PostController :delete11GET /dev/dashboard/css-:md5 Phoenix.LiveDashboard.Assets :css12GET /dev/dashboard/js-:md5 Phoenix.LiveDashboard.Assets :js13GET /dev/dashboard Phoenix.LiveDashboard.PageLive :home14GET /dev/dashboard/:page Phoenix.LiveDashboard.PageLive :page15GET /dev/dashboard/:node/:page Phoenix.LiveDashboard.PageLive :page16* /dev/mailbox Plug.Swoosh.MailboxPreview []17WS /live/websocket Phoenix.LiveView.Socket18GET /live/longpoll Phoenix.LiveView.Socket19POST /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:
1mix phx.gen.json Accounts User users name:string email:string:unique
Next, we can follow the instructions to add the resource to our router:
1scope "/api", ElixirphoenixWeb do2 pipe_through :api3
4 resources "/posts", PostController, except: [:new, :edit]5 resources "/users", UserController, except: [:new, :edit]6end
We can also remove the users
that we had before:
1scope "/", ElixirphoenixWeb do2 pipe_through :browser3
4 get "/", PageController, :home5 # get "/users", PageController, :users6end
And run the migration:
1mix 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
1mix ecto.gen.migration add_user_to_post
This will generate the following empty migration:
1defmodule Elixirphoenix.Repo.Migrations.AddUserToPost do2 use Ecto.Migration3
4 def change do5
6 end7end
In this file we will need to specify the change we want to make to our database table:
1defmodule Elixirphoenix.Repo.Migrations.AddUserToPost do2 use Ecto.Migration3
4 def change do5 alter table(:posts) do6 add :user_id, references(:users, on_delete: :nothing)7 end8 end9end
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:
1defmodule Elixirphoenix.Accounts.User do2 use Ecto.Schema3 import Ecto.Changeset4
5 schema "users" do6 field :name, :string7 field :email, :string8 has_many :posts, Elixirphoenix.Posts.Post9
10 timestamps()11 end12
13 @doc false14 def changeset(user, attrs) do15 user16 |> cast(attrs, [:name, :email])17 |> validate_required([:name, :email])18 |> unique_constraint(:email)19 end20end
For the Post, we need to define the relationship as well as the validation information:
1defmodule Elixirphoenix.Posts.Post do2 use Ecto.Schema3 import Ecto.Changeset4
5 schema "posts" do6 field :title, :string7 field :body, :string8 belongs_to :user, Elixirphoenix.Accounts.User9
10 timestamps()11 end12
13 @doc false14 def changeset(post, attrs) do15 post16 |> cast(attrs, [:title, :body, :user_id])17 |> validate_required([:title, :body, :user_id])18 end19end
We can create a user by sending the following:
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:
1{2 "post": {3 "title": "My Post Title",4 "body": "Some content for my post",5 "user_id": 16 }7}
If we want the user_id
to be returned with the Post data we can modify the post_json.ex
file:
1defp data(%Post{} = post) do2 %{3 id: post.id,4 title: post.title,5 body: post.body,6 user_id: post.user_id7 }8end
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:
1def list_users do2 Repo.all(User) |> Repo.preload(:posts)3end4
5def 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:
1def data(%Post{} = post) do
And we can then use that from our user_json
file:
1defmodule ElixirphoenixWeb.UserJSON do2 alias ElixirphoenixWeb.PostJSON3 alias Elixirphoenix.Accounts.User4
5 @doc """6 Renders a list of users.7 """8 def index(%{users: users}) do9 %{data: for(user <- users, do: data(user))}10 end11
12 @doc """13 Renders a single user.14 """15 def show(%{user: user}) do16 %{data: data(user)}17 end18
19 defp data(%User{} = user) do20 %{21 id: user.id,22 name: user.name,23 email: user.email,24 posts: for(post <- user.posts, do: PostJSON.data(post))25 }26 end27end
This will load the posts property when we query a user, so now if we do:
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": 112 }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:
1defp data(%User{} = user) do2 result = %{3 id: user.id,4 name: user.name,5 email: user.email,6 }7
8 case user.posts do9 # if the posts are not loaded then we return the result without them10 %Ecto.Association.NotLoaded{} -> result11
12 # we add a `posts` field to the map if we do have the posts13 posts -> Map.put(result, :posts, Enum.map(posts, &PostJSON.data/1))14 end15end