Building a JSON Rest API for managing an Online Bookstore using Elixir, Phoenix and PostgreSQL
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
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
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)
enddef get_book!(id), do: Repo.get!(Book, id)def create_book(attrs \\ %{}) do
%Book{}
|> Book.changeset(attrs)
|> Repo.insert()
enddef update_book(%Book{} = book, attrs) do
book
|> Book.changeset(attrs)
|> Repo.update()
enddef delete_book(%Book{} = book) do
Repo.delete(book)
enddef 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 :
We can verify if everything is OK for get some Get Query :
Tada, our server is running and correctly answers : we don’t have data until now !
Let’s try to get an 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 !
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
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++."
}
}
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)
enddef 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:
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:
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)
enddef 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:
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
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 :
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"
}
}
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 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:
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:
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/
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