Building a JSON Rest API for managing an Online Bookstore using Elixir, Phoenix and PostgreSQL

Héla Ben Khalfallah
14 min readSep 8, 2020

What we will do ?

Project

We will try to make an Online Bookstore.

Possible use cases

  • get all books
  • get a book details
  • get all categories
  • get all authors
  • get books by category
  • get books by author
  • search a book by its name or author
  • add a review : comment and rating
  • update a review : comment and rating
  • user can order one or many books

Constraints

  • a book can have only one author
  • a book can be associated only one category
  • a book can have zero to many reviews
  • an order can contains the same book only once time but with quantity from one to max
  • user can make a purchase that contains from one to many books orders

Entity Relationship Diagram

Entity Relationship Diagram

We can consider the author as a special type of user (user types can be: author, client, administrator). But I chose to keep our data model clean and simple and to think of the two entities as different.

Prerequisite

  • Install elixir
  • Install phoenix
  • Install PostgreSQL

Elixir installation

Verify that Elixir is correctly installed by running :

% elixir --version
Erlang/OTP 23 [erts-11.0.3] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [hipe] [dtrace]

Elixir 1.10.4 (compiled with Erlang/OTP 23)

Phoenix installation

Verify that Phoenix is correctly installed by running :

% mix phx
Phoenix v1.5.4
Productive. Reliable. Fast.
A productive web framework that does not compromise speed or maintainability.Available tasks:
mix phx.digest # Digests and compresses static files
mix phx.digest.clean # Removes old versions of static assets.
mix phx.gen.cert # Generates a self-signed certificate for HTTPS testing
mix phx.gen.channel # Generates a Phoenix channel

PostgreSQL installation

Init the project

Let’s start by creating a github repository for our project : online_book_store.

Init a phoenix project

We need to create a phoenix project without the frontend part (only API) :

mix phx.new online_book_store --no-html --no-webpack

Create database

mix ecto.create

Running this command in our local workspace will automatically create a database named : online_book_store_dev

Created DataBase

Start the Phoenix Server

mix phx.server

By default, our base url will be : http://localhost:4000

% mix phx.server
[info] Running OnlineBookStoreWeb.Endpoint with cowboy 2.8.0 at 0.0.0.0:4000 (http)
[info] Access OnlineBookStoreWeb.Endpoint at http://localhost:4000

Tada ! We have an Elixir API Server using few commands !

Ecto as a database ORM

Phoenix use Ecto as a database ORM.

Phoenix also create a default repo.ex file :

defmodule OnlineBookStore.Repo do
use Ecto.Repo,
otp_app: :online_book_store,
adapter: Ecto.Adapters.Postgres
end

In this module we will put all our data managers (database CRUD).

This module is started at the application launch as a single independent process and supervised by another process (in case of crash, Repo process will been automatically started by the supervisor).

application.ex :

defmodule OnlineBookStore.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false

use Application

def start(_type, _args) do
children = [
# Start the Ecto repository
OnlineBookStore.Repo,
# Start the Telemetry supervisor
OnlineBookStoreWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: OnlineBookStore.PubSub},
# Start the Endpoint (http/https)
OnlineBookStoreWeb.Endpoint
# Start a worker by calling: OnlineBookStore.Worker.start_link(arg)
# {OnlineBookStore.Worker, arg}
]

# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: OnlineBookStore.Supervisor]
Supervisor.start_link(children, opts)

end

# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
def config_change(changed, _new, removed) do
OnlineBookStoreWeb.Endpoint.config_change(changed, removed)
:ok
end
end

mix.ex :

# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {OnlineBookStore.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end

mod: specify the callback module to invoke when application has started (the entry point), for our case it’s ‘OnlineBookStore.Application’. OTP will invoke the start function defined in the called application.

config.exs :

config :online_book_store,
ecto_repos: [OnlineBookStore.Repo]

Here we register our Repo.

For example if you want to have multiple Repo, you can do this :

config :my_app,
ecto_repos: [
MyApp.NewsRepo,
MyApp.CommentRepo,
MyApp.UserRepo
]

env configurations :

For each env we have a separate configuration file : dev.exs (dev), test.exs (test) and prod.exs and prod.secret.exs(prod)

dev.exs :

# Configure your database
config :online_book_store, OnlineBookStore.Repo,
username: "postgres",
password: "postgres",
database: "online_book_store_dev",
hostname: "localhost",
show_sensitive_data_on_connection_error: true,
pool_size: 10

Because we test project on our local machine, the database hostname will be ‘localhost’.

prod.secret.exs :

database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""

config :online_book_store, OnlineBookStore.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

For prod env, we need to specify the database_url because it’s not hosted in the same place as our server.

For example, if you use Google Cloud, the code will been hosted in the AppEngine but the database in CloudSQL.

Now, before adding the different CRUD functions, let’s create the different schemas (data models).

Creating schemas

It’s easy to create schema using Phoenix : we need to run a single command !

Note : Phoenix will create a default id attribute (integer) for each created schema, so we don’t need to specify it.

Command Syntax

We can use mix.gen.schema to create only schema :

And we can use mix.gen.json to generate schema & controller for a JSON resource :

Yes, only one command to do everything !

Different primitives types

Book Schema

mix phx.gen.json Schema Book book author_id:integer category_id:integer isbn:string title:string description:string published_on:date original_price:float image_url:string

Then run :

mix ecto.migrate

This will create a book.ex file under lib/online_book_store/schema :

defmodule OnlineBookStore.Schema.Book do
use Ecto.Schema
import Ecto.Changeset

schema "book" do
field :author_id, :integer
field :category_id, :integer
field :description, :string
field :image_url, :string
field :isbn, :string
field :original_price, :float
field :published_on, :date
field :title, :string

timestamps()
end

@doc false
def changeset(book, attrs) do
book
|> cast(attrs, [:author_id, :category_id, :isbn, :title, :description, :published_on, :original_price, :image_url])
|> validate_required([:author_id, :category_id, :isbn, :title, :description, :published_on, :original_price, :image_url])
end
end

timestamps automatically generates :inserted_at and :updated_at timestamp fields.

And it will also create :

lib/online_book_store/schema.ex which contains the basic Database CRUD for Book Entity :

def list_book do
Repo.all(Book)
end
def get_book!(id), do: Repo.get!(Book, id)def create_book(attrs \\ %{}) do
%Book{}
|> Book.changeset(attrs)
|> Repo.insert()
end
def update_book(%Book{} = book, attrs) do
book
|> Book.changeset(attrs)
|> Repo.update()
end
def delete_book(%Book{} = book) do
Repo.delete(book)
end
def change_book(%Book{} = book, attrs \\ %{}) do
Book.changeset(book, attrs)
end

/lib/online_book_store_web/controllers/book_controller.ex :

def index(conn, _params) do
book = Schema.list_book()
render(conn, "index.json", book: book)
end

def create(conn, %{"book" => book_params}) do
with {:ok, %Book{} = book} <- Schema.create_book(book_params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.book_path(conn, :show, book))
|> render("show.json", book: book)
end
end

def show(conn, %{"id" => id}) do
book = Schema.get_book!(id)
render(conn, "show.json", book: book)
end

def update(conn, %{"id" => id, "book" => book_params}) do
book = Schema.get_book!(id)
with {:ok, %Book{} = book} <- Schema.update_book(book, book_params) do
render(conn, "show.json", book: book)
end
end

def delete(conn, %{"id" => id}) do
book = Schema.get_book!(id)
with {:ok, %Book{}} <- Schema.delete_book(book) do
send_resp(conn, :no_content, "")
end
end

The Controller will been called by the router according to called resource :

defmodule OnlineBookStoreWeb.Router do
use OnlineBookStoreWeb, :router

pipeline :api do
plug :accepts, ["json"]
end

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/books", BookController, :index
end

# Enables LiveDashboard only for development
#
# 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).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router

scope "/" do
pipe_through [:fetch_session, :protect_from_forgery]
live_dashboard "/dashboard", metrics: OnlineBookStoreWeb.Telemetry
end
end
end

Author Schema

mix phx.gen.json AuthorSchema Author author first_name:string last_name:string biography:stringmix ecto.migrate

I choose a different context for Author to avoid having a big file that contains all database CRUD for all Entities.

This will generate an Author schema: lib/online_book_store/author_schema/author.ex :

defmodule OnlineBookStore.AuthorSchema.Author do
use Ecto.Schema
import Ecto.Changeset

schema "author" do
field :biography, :string
field :first_name, :string
field :last_name, :string

timestamps()
end

@doc false
def changeset(author, attrs) do
author
|> cast(attrs, [:first_name, :last_name, :biography])
|> validate_required([:first_name, :last_name, :biography])
end
end

It will also create lib/online_book_store/author_schema.ex file which contains the basic Database CRUD for Author Entity (like Book).

And of course its Controller to be called inside router.ex :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/books", BookController, :index
get "/books/:id", BookController, :show
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
end

The current project tree should look like this :

Project current tree

We can verify if everything is OK for get some Get Query :

Get Book List
GET Author List

Tada, our server is running and correctly answers : we don’t have data until now !

Let’s try to get an Author by Id :

Get Author by id

This is because we don’t have an author yet and we use get! that will throw an error if no author was found :

def get_author!(id), do: Repo.get!(Author, id)

We can change the code in author_schema like this :

def get_author(id), do: Repo.get(Author, id)

And in author_controller.ex :

def show(conn, %{"id" => id}) do
IO.inspect("Get book for id: #{id}")
case AuthorSchema.get_author(id) do
nil -> {:error, :not_found}
author -> render(conn, "show.json", author: author)
end
end

So in case we don’t have a record with the id, our server will return a not found error rather than crashing !

Not found error
Postman — Not found error with 404 status

Category Schema

You don’t need to restart the server each time you create/update a schema, all changes will been hot reloaded !

mix phx.gen.json CategorySchema Category category name:string
mix ecto.migrate

Review Schema

mix phx.gen.json ReviewSchema Review review user_id:integer book_id:integer rating:integer comment:stringmix ecto.migrate

User Schema

mix phx.gen.json UserSchema User user first_name:string last_name:string address:string phone:stringmix ecto.migrate

Purchase Schema

mix phx.gen.json PurchaseSchema Purchase purchase user_id:integer delivery_date:date delivery_address:stringmix ecto.migrate

Order Schema

mix phx.gen.json OrderSchema Order order book_id:integer purchase_id:integer order_quantity:integermix ecto.migrate

Current project

Current project tree

At this step, all schemas and controllers are created, we will focus now on adding missing endpoints in router and there related parts in controllers.

Adding endpoints and missing Database CRUDs

What are the needed API endpoints ?

  • create a category
  • get all categories
  • create an author
  • get all authors
  • create a book
  • get all books
  • get a book details
  • get books by category
  • get books by author
  • search a book by its name or author
  • create a review : comment and rating
  • create a purchase
  • create an order for a purchase

We need to create Author and Category before Book because a Book must have an Author and a Category.

create authors

author_controller.ex :

def create(conn, %{"author" => author_params}) do
with {:ok, %Author{} = author} <- AuthorSchema.create_author(author_params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.author_path(conn, :show, author))
|> render("show.json", author: author)
end
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
post "/authors/add", AuthorController, :create
end

Let’s test on Postman:

Expected body format :

{
"author": {
"first_name": "Juric",
"last_name": "Saša",
"biography": "Saša Jurić is a software developer with many years of professional experience in programming of web and desktop applications using various languages, such as Elixir, Erlang, Ruby, JavaScript, C# and C++."
}
}
Create an author

Tada ! It works !

get all authors & get author by id

author_controller.ex :

def index(conn, _params) do # all authors
author = AuthorSchema.list_author()
render(conn, "index.json", author: author)
end
def show(conn, %{"id" => id}) do # get author by id
IO.inspect("Get book for id: #{id}")
case AuthorSchema.get_author(id) do
nil -> {:error, :not_found}
author -> render(conn, "show.json", author: author)
end
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show

post "/authors/add", AuthorController, :create
end

Let’s test on Postman:

Get all authors
Get author by id

Tada ! It works !

create categories

category_controller.ex :

def create(conn, %{"category" => category_params}) do
with {:ok, %Category{} = category} <- CategorySchema.create_category(category_params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.category_path(conn, :show, category))
|> render("show.json", category: category)
end
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
post "/authors/add", AuthorController, :create

get "/categories", CategoryController, :index
get "/categories/:id", CategoryController, :show
post "/categories/add", CategoryController, :create
end

Let’s test on Postman:

Add a category — Software
Add a category — Science

get all categories & get category by id

category_controller.ex :

def index(conn, _params) do # all categories
category = CategorySchema.list_category()
render(conn, "index.json", category: category)
end
def show(conn, %{"id" => id}) do # category by id
category = CategorySchema.get_category!(id)
render(conn, "show.json", category: category)
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
post "/authors/add", AuthorController, :create

get "/categories", CategoryController, :index
get "/categories/:id", CategoryController, :show

post "/categories/add", CategoryController, :create
end

Let’s test on Postman:

Get all categories

delete category by id

category_controller.ex

def delete(conn, %{"id" => id}) do
category = CategorySchema.get_category!(id)

with {:ok, %Category{}} <- CategorySchema.delete_category(category) do
send_resp(conn, :no_content, "")
end
end

router.ex

post "/categories/delete", CategoryController, :delete
Delete category by id
Get all categories — categories with id 1..8 are deleted

create books

book_controller.ex :

def create(conn, %{"book" => book_params}) do
with {:ok, %Book{} = book} <- Schema.create_book(book_params) do
conn
|> put_status(:created)
|> put_resp_header("location", Routes.book_path(conn, :show, book))
|> render("show.json", book: book)
end
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
post "/authors/add", AuthorController, :create

get "/categories", CategoryController, :index
get "/categories/:id", CategoryController, :show
post "/categories/add", CategoryController, :create
post "/categories/delete", CategoryController, :delete

get "/books", BookController, :index
get "/books/:id", BookController, :show
post "/books/add", BookController, :create
end

Let’s test on Postman:

Invalid input :

Create Book — Invalid input

Automatic error handling !

Valid input :

{
"book": {
"author_id": 1,
"category_id": 10,
"isbn": "XX123456",
"title": "Elixir in Action, Second Edition",
"description": "Elixir in Action, Second Edition teaches you how to use the Elixir programming language to solve the practical problems of scalability, fault tolerance, and high availability.",
"image_url": "https://images-na.ssl-images-amazon.com/images/I/51IAkbmOt1L._SX258_BO1,204,203,200_.jpg",
"original_price": 33.72,
"published_on": "2019-10-10"
}
}
Create Book — Valid Insert

get all books

book_controller.ex :

def index(conn, _params) do
book = Schema.list_book()
render(conn, "index.json", book: book)
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
post "/authors/add", AuthorController, :create

get "/categories", CategoryController, :index
get "/categories/:id", CategoryController, :show
post "/categories/add", CategoryController, :create
post "/categories/delete", CategoryController, :delete

get "/books", BookController, :index
get "/books/:id", BookController, :show
post "/books/add", BookController, :create
end

Let’s test on Postman:

Get all books

get book by id (book details)

book_controller.ex :

def show(conn, %{"id" => id}) do
book = Schema.get_book!(id)
render(conn, "show.json", book: book)
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
post "/authors/add", AuthorController, :create

get "/categories", CategoryController, :index
get "/categories/:id", CategoryController, :show
post "/categories/add", CategoryController, :create
post "/categories/delete", CategoryController, :delete

get "/books", BookController, :index
get "/books/:id", BookController, :show
post "/books/add", BookController, :create
end

Let’s test on Postman:

Get book by id

update a book

book_controller.ex :

def update(conn, %{"id" => id, "book" => book_params}) do
book = Schema.get_book!(id)

with {:ok, %Book{} = book} <- Schema.update_book(book, book_params) do
render(conn, "show.json", book: book)
end
end

Note : this function is auto-generated by Phoenix when we had create schema.

Let’s add now the related endpoint (route.ex) :

scope "/api", OnlineBookStoreWeb do
pipe_through :api
get "/authors", AuthorController, :index
get "/authors/:id", AuthorController, :show
post "/authors/add", AuthorController, :create

get "/categories", CategoryController, :index
get "/categories/:id", CategoryController, :show
post "/categories/add", CategoryController, :create
post "/categories/delete", CategoryController, :delete

get "/books", BookController, :index
get "/books/:id", BookController, :show
post "/books/add", BookController, :create
post "/books/update", BookController, :update
end

Let’s test on Postman:

Update Book Query
Get all books — updated book

Now, you know how to create a Rest JSON API using Phoenix, it’s so easy !

You can do the same for Review, Purchase and Order!

More informations

Dashboard

LiveDashboard provides real-time performance monitoring and debugging tools for Phoenix :

http://localhost:4000/dashboard/

Dashboard
Query Metrics
VM Monitoring

More informations

Conclusion

In this story, we had see together how to build, from scratch, a JSON Rest API for managing an Online Bookstore using Elixir, Phoenix and PostgreSQL.

We had see how much Phoenix make building a Rest API easy and simple !

And an additional awesome tool : LiveDashboard to real time monitoring our application !

Project Source

Thank you for reading my story.

You can find me at :

Twitter : https://twitter.com/b_k_hela

Github : https://github.com/helabenkhalfallah

--

--

Héla Ben Khalfallah

I love coding whatever the language and trying new programming tendencies. I have a special love to JS (ES6+), functional programming, clean code & tech-books.