Part 2 — Developing APIs for Post Creation, Post Detail and Error Handling

Loi Le
11 min readOct 19, 2023

--

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.

Index | < Pre | Next >

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:

  1. 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?

Index | < Pre | Next >

--

--