Introduction
This guide will walk you through creating a basic REST API using Elixir and Phoenix Framework with thorough comments explaining each piece of code.
Setup and Installation
1. Install Elixir and Erlang
If you haven't already installed Elixir and Erlang, follow the instructions for your operating system from the official Elixir website.
2. Install Phoenix Framework
Once Elixir is installed, install the Phoenix Framework:
# Install hex package manager
mix local.hex
# Install the Phoenix project generator
mix archive.install hex phx_new
3. Create a new Phoenix API project
# Create a new Phoenix project called book_api
# The flags disable HTML, assets, dashboard, LiveView, and mailer
# to create a minimal API-only project
mix phx.new book_api --no-html --no-assets --no-dashboard --no-live --no-mailer
# Navigate into the project directory
cd book_api
4. Configure the database
Open config/dev.exs
and set up your database credentials if needed.
5. Create the database
# Create the database defined in your config
mix ecto.create
Building the API
1. Generate a Book resource
# Generate JSON resource with:
# - Library context (business logic grouping)
# - Book schema (database model)
# - books table name
# - and fields with their types
mix phx.gen.json Library Book books title:string author:string isbn:string published_date:date
2. Add the generated routes
Open lib/book_api_web/router.ex
and add the generated routes:
defmodule BookApiWeb.Router do
use BookApiWeb, :router
# Define the API pipeline that handles JSON requests
pipeline :api do
plug :accepts, ["json"] # This tells Phoenix to accept JSON requests
end
# Define the routes under /api path
scope "/api", BookApiWeb do
pipe_through :api # Use the API pipeline for these routes
# Create all RESTful routes for books
# except :new and :edit which are for HTML forms
# This creates:
# GET /api/books - index
# POST /api/books - create
# GET /api/books/:id - show
# PATCH/PUT /api/books/:id - update
# DELETE /api/books/:id - delete
resources "/books", BookController, except: [:new, :edit]
end
end
3. Run the migration
# Run the database migration to create the books table
mix ecto.migrate
Understanding the Code (With Detailed Comments)
Schema - lib/book_api/library/book.ex
defmodule BookApi.Library.Book do
# Use Ecto.Schema to define database models
use Ecto.Schema
# Import validation functions
import Ecto.Changeset
# Define the database schema for the "books" table
schema "books" do
# Define fields with their types
field :author, :string # Author's name as string
field :isbn, :string # ISBN as string
field :published_date, :date # Publication date as date
field :title, :string # Book title as string
# Add timestamp fields (inserted_at and updated_at)
timestamps()
end
# Define a changeset function for validating and preparing data
# A changeset is Ecto's way of validating and transforming data
@doc false # Don't include this function in documentation
def changeset(book, attrs) do
book
# Specify which attributes can be set/updated (whitelisting)
|> cast(attrs, [:title, :author, :isbn, :published_date])
# Specify which fields are required
|> validate_required([:title, :author, :isbn])
# You could add more validations here, like:
# |> validate_length(:title, min: 2, max: 100)
# |> validate_format(:isbn, ~r/^\d{13}$/)
end
end
Context - lib/book_api/library.ex
This module contains the business logic:
defmodule BookApi.Library do
# Import database query functions
import Ecto.Query, warn: false
# Create aliases to make the code more readable
alias BookApi.Repo # Database repository
alias BookApi.Library.Book # Book schema
# Retrieve all books from the database
def list_books do
Repo.all(Book) # Fetch all book records
end
# Get a single book by ID
# The ! indicates it will raise an error if book not found
def get_book!(id), do: Repo.get!(Book, id)
# Create a new book
# Default empty map handles cases where no attrs are passed
def create_book(attrs \\ %{}) do
%Book{} # Create a new empty Book struct
|> Book.changeset(attrs) # Apply changes and validate
|> Repo.insert() # Insert into database
# Returns {:ok, book} on success or {:error, changeset} on failure
end
# Update an existing book
# Pattern matching ensures we get a Book struct
def update_book(%Book{} = book, attrs) do
book
|> Book.changeset(attrs) # Apply changes and validate
|> Repo.update() # Update in database
# Returns {:ok, book} on success or {:error, changeset} on failure
end
# Delete a book
# Pattern matching ensures we get a Book struct
def delete_book(%Book{} = book) do
Repo.delete(book) # Delete from database
# Returns {:ok, book} on success or {:error, changeset} on failure
end
# Create a changeset for a book
# Mainly used for forms (not needed for APIs)
def change_book(%Book{} = book, attrs \\ %{}) do
Book.changeset(book, attrs)
end
end
Controller - lib/book_api_web/controllers/book_controller.ex
Handles HTTP requests:
defmodule BookApiWeb.BookController do
# Include controller functionality
use BookApiWeb, :controller
# Create aliases for cleaner code
alias BookApi.Library # Business logic context
alias BookApi.Library.Book # Book schema
# Use the FallbackController for error handling
# This handles common error cases like invalid data or not found
action_fallback BookApiWeb.FallbackController
# GET /api/books
def index(conn, _params) do
books = Library.list_books() # Get all books
render(conn, :index, books: books) # Render JSON response
# :index refers to the index/1 function in BookJSON module
end
# POST /api/books
def create(conn, book_params) do
# Pattern match to extract book params from request body
# The 'with' expression handles the success/failure flow elegantly
# Only proceeds to the next line if the left side matches {:ok, book}
with {:ok, %Book{} = book} <- Library.create_book(book_params) do
conn
|> put_status(:created) # Set HTTP status 201
|> put_resp_header("location", ~p"/api/books/#{book}") # Add location header
|> render(:show, book: book) # Render JSON response
# :show refers to the show/1 function in BookJSON module
end
# If error occurs, it falls back to FallbackController
end
# GET /api/books/:id
def show(conn, %{"id" => id}) do
# Pattern match to extract id from URL params
book = Library.get_book!(id) # Get book by ID (raises if not found)
render(conn, :show, book: book) # Render JSON response
end
# PUT/PATCH /api/books/:id
def update(conn, %{"id" => id, "book"} =params) do
# Pattern match to extract id and book params
book_params = Map.drop(params, ["id"])
book = Library.get_book!(id) # Get existing book
with {:ok, %Book{} = book} <- Library.update_book(book, book_params) do
render(conn, :show, book: book) # Render JSON response
end
# If error occurs, it falls back to FallbackController
end
# DELETE /api/books/:id
def delete(conn, %{"id" => id}) do
book = Library.get_book!(id) # Get book by ID
with {:ok, %Book{}} <- Library.delete_book(book) do
send_resp(conn, :no_content, "") # Return 204 No Content
# No body needed for successful delete
end
end
end
JSON Views - lib/book_api_web/controllers/book_json.ex
Formats the data for JSON responses:
defmodule BookApiWeb.BookJSON do
# Book alias not strictly needed here but good practice
alias BookApi.Library.Book
@doc """
Renders a list of books.
"""
# Takes a map with :books key and formats it for JSON response
def index(%{books: books}) do
# Create a map with :data key containing all books
# 'for' is Elixir's list comprehension syntax
%{data: for(book <- books, do: data(book))}
end
@doc """
Renders a single book.
"""
# Takes a map with :book key and formats it for JSON response
def show(%{book: book}) do
# Create a map with :data key containing one book
%{data: data(book)}
end
# Private helper function to format a book for JSON
# Pattern matching ensures we get a Book struct
defp data(%Book{} = book) do
# Return a map of book attributes to include in the response
%{
id: book.id, # Database ID
title: book.title, # Book title
author: book.author, # Author name
isbn: book.isbn, # ISBN
published_date: book.published_date # Publication date
# Notice we don't include timestamps
}
end
end
Adding a Custom Endpoint (With Comments)
Let's add a custom endpoint to search books by author:
- First, add a function in the Library context:
# In lib/book_api/library.ex
def search_by_author(author) do
# Create a query that searches for partial author name matches
# ilike is case-insensitive LIKE from SQL
# ^"%" interpolates the variable and adds wildcards
from(b in Book, where: ilike(b.author, ^"%#{author}%"))
|> Repo.all() # Execute the query and return all matching books
end
- Next, add a new action to the controller:
# In lib/book_api_web/controllers/book_controller.ex
def search_by_author(conn, %{"author" => author}) do
# Pattern match to extract author from URL params
books = Library.search_by_author(author) # Call the context function
render(conn, :index, books: books) # Reuse the index view
# Note that we reuse the existing :index view since
# we're still returning a list of books
end
- Finally, add the route:
# In lib/book_api_web/router.ex
scope "/api", BookApiWeb do
pipe_through :api
resources "/books", BookController, except: [:new, :edit]
# Add custom route for author search
# The :author at the end is a URL parameter
get "/books/search/author/:author", BookController, :search_by_author
end
Running the API
Start the Phoenix server:
mix phx.server
Or in interactive mode:
iex -S mix phx.server
Your API will be available at http://localhost:4000/api/books
.
Testing the API
You can test your API using tools like curl, Postman, or any HTTP client:
List all books:
curl http://localhost:4000/api/books
Get a single book:
curl http://localhost:4000/api/books/1
Create a book:
curl -X POST -H "Content-Type: application/json" -d '{"title":"Elixir Programming","author":"Jane Doe","isbn":"1234567890","published_date":"2023-01-01"}' http://localhost:4000/api/books
Update a book:
curl -X PUT -H "Content-Type: application/json" -d '{"title":"Updated Title"}' http://localhost:4000/api/books/1
Delete a book:
curl -X DELETE http://localhost:4000/api/books/1
Testing the API (With Explanations)
List all books:
# GET request to fetch all books
curl http://localhost:4000/api/books
Get a single book:
# GET request with ID to fetch a specific book
curl http://localhost:4000/api/books/1
Create a book:
# POST request with JSON data in the book parameter
# -X POST: Use POST method
# -H "Content-Type: application/json": Set header for JSON
# -d '{"book":{...}}': Send JSON data in request body
curl -X POST -H "Content-Type: application/json" -d '{"book":{"title":"Elixir Programming","author":"Jane Doe","isbn":"1234567890","published_date":"2023-01-01"}}' http://localhost:4000/api/books
Update a book:
# PUT request with ID and JSON data to update
# -X PUT: Use PUT method
# Only updating the title field
curl -X PUT -H "Content-Type: application/json" -d '{"book":{"title":"Updated Title"}}' http://localhost:4000/api/books/1
Delete a book:
# DELETE request with ID to remove a book
# -X DELETE: Use DELETE method
curl -X DELETE http://localhost:4000/api/books/1
Enhanced Validation (With Comments)
You can enhance the changeset
function in the Book schema:
def changeset(book, attrs) do
book
|> cast(attrs, [:title, :author, :isbn, :published_date])
|> validate_required([:title, :author, :isbn])
# Ensure title length is between 2 and 100 characters
|> validate_length(:title, min: 2, max: 100)
# Ensure ISBN is exactly 13 characters
|> validate_length(:isbn, is: 13)
# Ensure ISBN is unique in the database
# This prevents duplicate books
|> unique_constraint(:isbn)
end
Elixir-Specific Concepts for Beginners
Pattern Matching
Pattern matching is a fundamental concept in Elixir:
# In function heads
def update(conn, %{"id" => id, "book" => book_params}) do
# This only runs when the second parameter has both "id" and "book" keys
# And assigns their values to the variables id and book_params
end
# In with expressions
with {:ok, %Book{} = book} <- Library.create_book(book_params) do
# This only runs when create_book returns {:ok, book}
# And the book is a Book struct
# Otherwise it falls back to the error handling
end
Pipe Operator (|>)
The pipe operator passes the result of the left expression as the first argument to the function on the right:
# Without pipe
def create_book(attrs \\ %{}) do
changeset = Book.changeset(%Book{}, attrs)
result = Repo.insert(changeset)
result
end
# With pipe (more readable)
def create_book(attrs \\ %{}) do
%Book{}
|> Book.changeset(attrs) # Same as Book.changeset(%Book{}, attrs)
|> Repo.insert() # Same as Repo.insert(changeset)
end
Immutability
In Elixir, data is immutable - variables don't change:
book = %Book{title: "Original Title"}
updated_book = %{book | title: "New Title"}
# book still has the original title
# updated_book has the new title
# They are separate values in memory
This makes your code more predictable and easier to reason about.
Conclusion
You've successfully built a basic REST API with Elixir and Phoenix with detailed comments explaining each part of the code! This covers:
- Project setup
- Resource generation
- Database operations
- Request handling
- JSON responses
- Custom endpoints and validations
From here, you can expand your API by adding:
- Authentication
- More complex relationships between resources
- Pagination
- Filtering and sorting
- Documentation
As you continue learning, focus on understanding:
- Functional programming principles
- Pattern matching
- Immutability
- The pipe operator
- Phoenix's request lifecycle
Happy coding with Elixir!
Top comments (0)