This is a part of the Tutorial to build a blog platform with Elixir Phoenix and Next.js that help you to develop a web application from scratch using modern technologies such as Elixir Phoenix, Next.js, and more.
In the previous part, we set up the Backend project and successfully implemented the get list post API. Now, we will enhance our Back end project by implementing more APIs and following some best practices such as error handling and CORS configuration. This part will introduce some important concepts of API development in Phoenix. But first, we have to understand Changeset.
Ecto Changeset
Changesets
let Ecto
manage the update process by dividing it into three different stages: casting and filtering user input, validating the input, then sending the input to the database and getting the result.
In short, Changesets
help us with the entire life cycle of making a change, starting with raw data, and ending with the operation succeeding or failing at the database level.
Lets change lib/lani_blog/blog/post.ex
defmodule LaniBlog.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :description, :string
field :content, :string
timestamps()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :description, :content])
|> validate_required([:title, :content])
end
end
We import Ecto.Changeset
functions to make working with changesets easier. We call the cast
function with a list of fields that we want to allow as user input: title
, description
and content
. Then, we use validate_required
to ensure that all the required fields are present.
Unlike other libraries that put the validation at the schema definition, Ecto has the changeset concept that lets us apply different validation or casting logic in different situations. This gives us more flexibility.
Next, we need to update our lib/lani_blog/blog.ex
defmodule LaniBlog.Blog do
alias LaniBlog.Repo
alias LaniBlog.Blog.Post
def list_posts do
Repo.all(Post, order_by: [desc: :inserted_at])
end
def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
end
In the create_post
function, we use our changeset
to handle casting and validation. If there is any error, Ecto will return it to us.
Then, we can add create
action to our lib/lani_blog_web/controllers/post_controller
file.
def create(conn, %{"data" => post_params}) do
with {:ok, post} <- Blog.create_post(post_params) do
render(conn, "show.json", post: post)
end
end
We have to add another render function to lib/lani_blog_web/views/post_view.ex
. This will take care of rendering a single post.
def render("show.json", %{post: post}) do
%{data: render_one(post, __MODULE__, "post.json")}
end
At last, we change lani_blog/lib/lani_blog_web/router.ex
scope "/api", LaniBlogWeb do
pipe_through :api
get "/posts", PostController, :index
post "/posts", PostController, :create
end
We will use curl
command again to test our create api. We can use the POST
method to create a new post with some data. We need to specify the content-type
header as application/json
and pass the data as a json object. Here is the command:
$ curl --location --request POST 'http://localhost:4000/api/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": {
"title": "Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Bibendum at varius vel pharetra. Scelerisque viverra mauris in aliquam sem fringilla.",
"content": "Ipsum dolor sit amet **consectetur** adipiscing. Leo integer malesuada nunc vel risus. Id leo in vitae turpis massa sed elementum tempus egestas.\n\n- Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus\n- Sed felis eget velit aliquet sagittis id consectetur. Interdum varius sit amet mattis\n- Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor.\n\n Integer enim neque volutpat ac tincidunt vitae. Ac turpis egestas maecenas pharetra convallis posuere. Urna neque viverra justo nec ultrices dui."
}
}'
We see the 500 error now. We look at the server logs and find the error:
** (MyXQL.Error) (1406) Data too long for column 'content' at row 1
The problem is the string field can only have 255 characters, but we make a post with longer content field. To solve it, we make a database migration:
$ mix ecto.gen.migration update_post_content_size
* creating priv/repo/migrations/20231018085122_update_post_content_size.exs
We get a new migration file and we change it:
defmodule LaniBlog.Repo.Migrations.UpdatePostContentSize do
use Ecto.Migration
def change do
alter table(:posts) do
modify :content, :string, size: 5000
end
end
end
We update the database with the change that sets the content field’s maximum length to 5000
:
$ mix ecto.migrate
== Running 20231018085122 LaniBlog.Repo.Migrations.UpdatePostContentSize.change/0 forward
== Migrated 20231018085122 in 0.0s
Now we test again:
curl --location --request POST 'http://localhost:4000/api/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": {
"title": "Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Bibendum at varius vel pharetra. Scelerisque viverra mauris in aliquam sem fringilla.",
"content": "Ipsum dolor sit amet **consectetur** adipiscing. Leo integer malesuada nunc vel risus. Id leo in vitae turpis massa sed elementum tempus egestas.\n\n- Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus\n- Sed felis eget velit aliquet sagittis id consectetur. Interdum varius sit amet mattis\n- Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor.\n\n Integer enim neque volutpat ac tincidunt vitae. Ac turpis egestas maecenas pharetra convallis posuere. Urna neque viverra justo nec ultrices dui."
}
}'
This is what we get:
{
"data": {
"content": "Ipsum dolor sit amet **consectetur** adipiscing. Leo integer malesuada nunc vel risus. Id leo in vitae turpis massa sed elementum tempus egestas.\n\n- Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus\n- Sed felis eget velit aliquet sagittis id consectetur. Interdum varius sit amet mattis\n- Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor.\n\n Integer enim neque volutpat ac tincidunt vitae. Ac turpis egestas maecenas pharetra convallis posuere. Urna neque viverra justo nec ultrices dui.",
"created_at": "2023-10-19T05:50:49",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Bibendum at varius vel pharetra. Scelerisque viverra mauris in aliquam sem fringilla.",
"id": 1,
"title": "Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod"
}
}
Great! We have successfully created a new post. However, to verify that the post has been actually created, we need to create a get post detail api.
Get Post Detail API
We need to add the get_post
function to the context lib/lani_blog/blog.ex
. This function will try to find a post by its id and return either an error or the post.
def get_post(id) do
case Repo.get(Post, id) do
nil -> {:error, :not_found}
post -> {:ok, post}
end
end
We also need to add the show
action to the lib/lani_blog_web/controllers/post_controller.ex
. This action will use the get_post
function and use the view to render the post as json.
def show(conn, %{"id" => id}) do
with {:ok, post} <- Blog.get_post(id) do
render(conn, "show.json", post: post)
end
end
We need to update the lib/lani_blog_web/router.ex
file to add the new route for the show action. Our api scope in the router will look like this:
scope "/api", LaniBlogWeb do
pipe_through :api
get "/posts", PostController, :index
get "/posts/:id", PostController, :show
post "/posts", PostController, :create
end
However, we can simplify this by using the resources
macro that Phoenix provides. This macro will generate routes for common actions related to a resource. We can specify which actions we want with the only
option
scope "/api", LaniBlogWeb do
pipe_through :api
resources "/posts", PostController, only: [:index, :show, :create]
end
The resources
macro is a convenient way to generate a set of common routes that correspond to create, read, update, and delete operations (also known as CRUD) for a resource using simple HTTP verbs. Instead of writing each route manually, we can use the resources
macro to define them in one line. For example, resources “/posts”, PostController
would create these routes:
get "/posts", PostController, :index
get "/posts/:id/edit", PostController, :edit
get "/posts/new", PostController, :new
get "/posts/:id", PostController, :show
post "/posts", PostController, :create
patch "/posts/:id", PostController, :update
put "/posts/:id", PostController, :update
delete "/posts/:id", PostController, :delete
If we don’t need all these routes, we can use the :only
and :except
options to specify which actions we want or don’t want. In our case, we use :only
to list the routes we want to generate.
In order to test our get post detail api, we can use the GET
method again to get a specific post by its id. We need to pass the id
as a parameter in the url. Here is the command:
$ curl --location --request GET 'http://localhost:4000/api/posts/1'
This is the response we get:
{
"data": {
"content": "Ipsum dolor sit amet **consectetur** adipiscing. Leo integer malesuada nunc vel risus. Id leo in vitae turpis massa sed elementum tempus egestas.\n\n- Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus\n- Sed felis eget velit aliquet sagittis id consectetur. Interdum varius sit amet mattis\n- Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor.\n\n Integer enim neque volutpat ac tincidunt vitae. Ac turpis egestas maecenas pharetra convallis posuere. Urna neque viverra justo nec ultrices dui.",
"created_at": "2023-10-19T05:50:49",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Bibendum at varius vel pharetra. Scelerisque viverra mauris in aliquam sem fringilla.",
"id": 1,
"title": "Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod"
}
}
Excellent! We have achieved a wonderful milestone of implementing all these apis. However, there are still some edge cases and error scenarios that we need to handle. Let’s see what happens when we try some of them:
- Get the post that does not exist:
We can use the GET
method to try to get a post that does not exist. We need to pass an invalid id
as a parameter in the url. Here is the command:
$ curl --location --request GET 'http://localhost:4000/api/posts/5'
We should get a response with the status 404
— Not Found and an error message:
{
"errors": {
"detail": "Not Found"
}
}
This is what we expect to happen when we try to get a post that does not exist. However, because we have not handled this case properly in our code, we get a different response with the status 500
— Internal Server Error.
2. Create post that miss the content
field:
We can use the POST method to try to create a new post with some missing data. Here is the command:
$ curl --location --request POST 'http://localhost:4000/api/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": {
"title": "first title",
"description": "test description"
}
}'
We should get a response with the status 400 — Bad Request and an error message with some details about what went wrong:
{
"errors": {
"content": [
"can't be blank"
]
}
}
This is what we expect to happen when we try to create a new post with some missing data. However, again, because we have not handled this case properly in our code, we get a different response with the status 500
— Internal Server Error.
This is not a good user experience and we need to handle these cases.
Error Handling
Let’s take a look at our show
action from lib/lani_blog_web/controllers/post_controller.ex
def show(conn, %{"id" => id}) do
with {:ok, post} <- Blog.get_post(id) do
render(conn, "show.json", post: post)
end
end
We only handle the success case here, but what if the post is not found? We need to handle the error case as well:
def show(conn, %{"id" => id}) do
with {:ok, post} <- Blog.get_post(id) do
render(conn, "show.json", post: post)
else
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> put_view(LaniBlogWeb.ErrorView)
|> render(:"404")
end
end
If the Blog context returns {:error, :not_found}
, we will respond with the :not_found
status (404), and use the LaniBlogWeb.ErrorView
to render the response body by using the put_view
function. Phoenix generated LaniBlogWeb.ErrorView
when we created the app to help us render errors in a generic way. We can find it at lib/lani_blog_web/views/error_view.ex
.
We can test it with this command:
$ curl --location --request GET 'http://localhost:4000/api/posts/5'
{
"errors": {
"detail": "Not Found"
}
}
It works as expected now. However, think about how much duplication you would have if you had to write the same logic for every controller and action handled by your API.
Phoenix has an effective tool to help us in this case: action_fallback
.
Action Fallback
Let’s create a new controller lib/lani_blog_web/controllers/fallback_controller.ex
. This controller will handle the error cases for our apis
defmodule LaniBlogWeb.FallbackController do
use Phoenix.Controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(LaniBlogWeb.ErrorView)
|> render(:"404")
end
end
Then, let’s update the lib/lani_blog_web/controllers/post_controller.ex
to use the fallback controller.
defmodule LaniBlogWeb.PostController do
use LaniBlogWeb, :controller
alias LaniBlog.Blog
action_fallback LaniBlogWeb.FallbackController
def index(conn, _params) do
posts = Blog.list_posts()
render(conn, "index.json", posts: posts)
end
def create(conn, %{"data" => post_params}) do
with {:ok, post} <- Blog.create_post(post_params) do
render(conn, "show.json", post: post)
end
end
def show(conn, %{"id" => id}) do
with {:ok, post} <- Blog.get_post(id) do
render(conn, "show.json", post: post)
end
end
end
We use the action_fallback
macro to specify our new controller as the fallback controller and simply remove the else
clause from our with statement in the show
action. This way we can handle the :not_found
error case in any controllers. Test the api again, it works as expected.
$ curl --location --request GET 'http://localhost:4000/api/posts/5'
Status code: 404
{
"errors": {
"detail": "Not Found"
}
}
Next, we handle the changeset error case in the lib/lani_blog_web/controllers/fallback_controller.ex
file.
We add a new function to match the {:error, %Ecto.Changeset{}}
tuple and respond with the :bad_request
status (400) and the changeset errors
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:bad_request)
|> put_view(LaniBlogWeb.ChangesetView)
|> render("error.json", changeset: changeset)
end
Let’s create a new view for this function lib/lani_blog_web/views/changeset_view.ex
. This view will render the changeset errors as json.
defmodule LaniBlogWeb.ChangesetView do
use LaniBlogWeb, :view
def render("error.json", %{changeset: changeset}) do
%{
errors:
Ecto.Changeset.traverse_errors(changeset, &LaniBlogWeb.ErrorHelpers.translate_error/1)
}
end
end
We use the LaniBlogWeb.ErrorHelpers.translate_error
function to translate Ecto Changeset errors into human-readable messages. Phoenix generated this function for us in the error_helper.ex
file. You can find it at: lib/lani_blog_web/views/error_helpers.ex
Now we test the case where we try to create a post with some missing data:
$ curl --location --request POST 'http://localhost:4000/api/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": {
"title": "first title",
"description": "test description"
}
}'
Status code: 400
{
"errors": {
"content": [
"can't be blank"
]
}
}
It works as we want it to now.
Configure CORS
The last step is to make sure our Frontend app can access our api we need to add the CORS configuration:
Add this plug to your mix.exs
dependencies lani_blog/mix.exs
def deps do
# ...
{:cors_plug, "~> 3.0"},
#...
end
Run the following command to get the latest dependencies:
$ mix deps.get
Then update lib/lani_blog_web/endpoint.ex
defmodule LaniBlogWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :lani_blog
# ...
plug CORSPlug, origin: ["http://localhost:3000"]
plug LaniBlogWeb.Router
end
We have successfully finished the first version of the Blog API. You have done a splendid job. This is a good time to review what we have done for this part of the tutorial.
Wrapping Up Part 1 & 2
We’ve made a lot of progress. Let’s recap what you’ve done:
- You’ve created your first project with Phoenix and Elixir.
- You’ve learned about Phoenix components: Controller, Context, View, Router and how they work together.
- You’ve used Ecto to define resource schema and changeset.
- You’ve used Ecto Repository in the Context to interact with database.
- You’ve implemented generic error handler with
action_fallback
.
In the next part, we are going to build the frontend app to use these APIs. But before we do that, let’s take a short break and enjoy our accomplishment. Are you curious to see what we can build next?