DEV Community

Sospeter Mong'are
Sospeter Mong'are

Posted on

Building a Simple REST API with Elixir

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

3. Run the migration

# Run the database migration to create the books table
mix ecto.migrate
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Adding a Custom Endpoint (With Comments)

Let's add a custom endpoint to search books by author:

  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode

Running the API

Start the Phoenix server:

mix phx.server
Enter fullscreen mode Exit fullscreen mode

Or in interactive mode:

iex -S mix phx.server
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Get a single book:

curl http://localhost:4000/api/books/1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update a book:

curl -X PUT -H "Content-Type: application/json" -d '{"title":"Updated Title"}' http://localhost:4000/api/books/1
Enter fullscreen mode Exit fullscreen mode

Delete a book:

curl -X DELETE http://localhost:4000/api/books/1
Enter fullscreen mode Exit fullscreen mode

Testing the API (With Explanations)

List all books:

# GET request to fetch all books
curl http://localhost:4000/api/books
Enter fullscreen mode Exit fullscreen mode

Get a single book:

# GET request with ID to fetch a specific book
curl http://localhost:4000/api/books/1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Project setup
  2. Resource generation
  3. Database operations
  4. Request handling
  5. JSON responses
  6. 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:

  1. Functional programming principles
  2. Pattern matching
  3. Immutability
  4. The pipe operator
  5. Phoenix's request lifecycle

Happy coding with Elixir!

Top comments (0)