Inter Caetera

10/23/2024

Elixir API from Scratch

One of the first projects I like to do when I start learning a new language or a new technology is to find that language’s HTTP framework and try to write a simple CRUD (Create, Read, Update, Delete) JSON API. In my view, that’s a great starting point, because integrating a database and a HTTP layer is quite simple, but it is not trivial, especially as you start adding concepts like authentication or validation.

This approach, however, has been frustrated slightly in recent years with the wider and wider adoption of automatically generated code in many frameworks. It seems that many creators now prefer to give the user the ability to get to a working application quickly by using command-line tooling to generate various components of the system. This, though, comes at the expense of understanding of how the pieces fit together. Many guides will not offer the write-from-scratch experience anymore, and will just get the user to the quick-start point by using automated code generation.

I found that it was quite tough to find such resources for the premier Elixir HTTP framework—Phoenix. Hence, I decided to sit down and write a simple todo-list API without using the built-in JSON endpoint generator, in order to get a bird’s eye view of how the pieces fit together. I was guided by the code output by the mix phx.gen.json command, and the resulting code is pretty much the same thing as the code generated by the codegen.

Initial setup

We start with the barebones project bootstrap using mix phx.new, followed by the project name, followed by a bunch of flags that indicate what we don’t want in our app.

mix phx.new todos --no-html --no-assets --no-esbuild --no-gettext --no-mailer --no-tailwind --database sqlite3

Most of the files that Phoenix generates in this initial bootstrap are the kind of thing that in many other frameworks would be tucked away in the library code. In the case of Phoenix, we can change the macros that we use to load HTTP-specific DSLs (domain-specific languages), so we get a few bits of generated code that we are responsible for, but we will most likely not edit them at all.

Then we do a few initial tasks: create the database, initialise a Git repository and commit our initial changes.

cd todos
mix ecto.create
git init
git commit -am 'initial commit'

Database migration and schemas

The first step is to create the database integration: the migration and schema. We use the mix ecto.gen.migration followed by the migration name to create the migration file. This is the last time we use generated code in this guide. The migration codegen is quite trivial, it only creates a timestamped file with a module loading the Ecto migration DSL.

$ mix ecto.gen.migration create_todos
* creating priv/repo/migrations/20241020104740_create_todos.exs
defmodule Todos.Repo.Migrations.CreateTodos do
  use Ecto.Migration

  def change do
    # We will define our migration here.
  end
end

The details on how to write Ecto migrations can be found in this guide. Since we’re building a simple todo list, we will add two fields: name and done, as well as the timestamps/1 macro which adds “created at” and “updated at” fields. We add the schema inside change/0.

    create table("todos") do
      add :title, :string
      add :done, :boolean, default: false, null: false

      timestamps(type: :utc_datetime)
    end

When run, this migration will add two columns to the “todos” table: title with type string and done with type boolean that is non-nullable and set to false by default. We also define the timestamps to be stored as utc_datetime.

We run the migrations and start the server.

mix ecto.migrate
iex -S mix phx.server

The next step is to create the Phoenix context and Ecto schema.

Phoenix uses DDD-inspired context modules to organise code. The contexts expose and group related functionality. In this project, we will create a Todolist context that is going to be the main point of interaction with our schema. If we had other entities associated with Todo, we might also add them there, to have a boundary of consistency for our entities. In our case, we will later add the functions that create, read, update and delete our Todos. For now, the context will be empty.

# /lib/todos/todolist.ex
defmodule Todos.Todolist do
  # Context will go here.
end

We also add a Todo schema for our database entity.

# /lib/todos/todolist/todo.ex
defmodule Todos.Todolist.Todo do
  use Ecto.Schema
  
  schema "todos" do
    # Fields go here.
  end
end

Next we add fields to the schema. They will correspond one-to-one with the columns in the database (the syntax here is a little different than in migrations, see this guide for details).

schema "todos" do
    field :title, :string
    field :done, :boolean, default: false

    timestamps(type: :utc_datetime)
end

Using IEx we can now add some Todos to the database and display them.

iex(2)> %Todos.Todolist.Todo{title: "Walk the dog", done: false} |> Todos.Repo.insert!()
%Todos.Todolist.Todo{
  __meta__: #Ecto.Schema.Metadata<:loaded, "todos">,
  id: 1,
  title: "Walk the dog",
  done: false,
  inserted_at: ~U[2024-10-20 11:32:24Z],
  updated_at: ~U[2024-10-20 11:32:24Z]
}

iex(3)> Todos.Todolist.Todo |> Todos.Repo.all()
[
  %Todos.Todolist.Todo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "todos">,
    id: 1,
    title: "Walk the dog",
    done: false,
    inserted_at: ~U[2024-10-20 11:32:24Z],
    updated_at: ~U[2024-10-20 11:32:24Z]
  }
]

Read

While we aren’t going to allow the user to create Todos this way (by directly using the schema), we can already make a HTTP route that will display the added Todo. There are (generally) two ways Ecto lets us select data from the database:

Under the hood schema queries are converted to schemaless queries, which then can be executed by the Repo module. In this case, we will use a schema query to get all the Todos. The Repo.all is a reasonably good entry point for this task. We add a function to our Todolist context:

defmodule Todos.Todolist do
  alias Todos.Repo
  alias Todos.Todolist.Todo

  def list_todos, do: Repo.all(Todo)
end

We can check if it works in IEx:

iex(5)> Todos.Todolist.list_todos()
[
  %Todos.Todolist.Todo{
    __meta__: #Ecto.Schema.Metadata<:loaded, "todos">,
    id: 1,
    title: "Walk the dog",
    done: false,
    inserted_at: ~U[2024-10-20 11:32:24Z],
    updated_at: ~U[2024-10-20 11:32:24Z]
  }
]

The next step is adding a controller. The controller contains the route handlers that process the HTTP requests and return a response. In this case we attempt to directly return JSON using json/2. We create a route handler for the /api/todos route by creating a controller with an index/2 callback and include it in the /api scope inside the router file.

# /lib/todos_web/controllers/todo_controller.ex
defmodule TodosWeb.TodoController do
  use TodosWeb, :controller

  alias Todos.Todolist

  def index(conn, _params) do
    todos = Todolist.list_todos()
    json(conn, todos)
  end
end

Note that the TodoController here is not an alias.

# /lib/todos_web/router.ex
scope "/api", TodosWeb do
  pipe_through :api
  get "/todos", TodoController, :index
end

Unfortunately when we try to call this route, we get an error. This fails, because Jason (the library for encoding and parsing JSON used by Phoenix) doesn’t know how to encode structs. We have to add @derive to our schema.

# /lib/todos/todolist/todo.ex
@derive Jason.Encoder

If we try to call the endpoint again, it’s going to fail, this time because we try to send the Ecto __meta__ field to the client which is not allowed. We have to change the derivation to only include specific fields from our entity.

@derive {Jason.Encoder, only: [:id, :title, :done]}

This should now work, however, this is not the way that Phoenix uses in its generated routes.

Instead of the json/2 function in the controller, Phoenix codegen creates a JSON view that gives more fine-grained control over the serialisation of the JSON and the controller uses render/3 to render it. Another advantage of using the views is that Phoenix can decide which template to render based on the Accept header of the incoming request: if we had an API that served both JSON and HTML, we could use the same route to serve different formats. In my experience, this is quite uncommon.

On the other hand, deriving the Jason.Encoder protocol directly inside the schema and calling json/2 in the controller instead of using views colocates the serialisation logic in the schema file. I suppose that if the serialisation logic is quite involved, it might be better to use views. For simpler handlers, the simpler solution might be enough.

In any case, this time we will try to follow the codegen way of returning JSON data. To do this, we remove the @derive and create the view. The codegen creates a function data which extracts the fields that are supposed to be included in the response, we will follow this convention. The view module for SomethingController should be called SomethingJSON (pay attention to the casing).

# /lib/todos_web/controllers/todo_json.ex
defmodule TodosWeb.TodoJSON do
  alias Todos.Todolist.Todo

  def index(%{todos: todos}) do
    todos |> Enum.map(&data/1)
  end

  defp data(%Todo{} = todo) do
    %{
      id: todo.id,
      title: todo.title,
      done: todo.done,
    } 
  end
end

Note that this is slightly different than the default template created by the codegen. By default, valid JSON responses are wrapped in a { data: ... } object and error responses are wrapped in { error: ... }. I think the reason for this behaviour is that the view can later also add additional metadata to the response, like pagination information. In this case, we will not worry about this and just return the data “neat.”

We also replace the call to json/2 with a call to render/3.

# /lib/todos_web/controllers/todo_controller.ex
  def index(conn, _params) do
    todos = Todolist.list_todos()
    render(conn, :index, todos: todos)
  end

Now opening /api/todos yields:

[{"id":1,"done":false,"title":"Walk the dog"}]

While we are here, we can also add functions that retrieve a Todo by ID. We define the interface to our schema inside the Todolist context.

# /lib/todos/todolist.ex
def get_todo!(id), do: Repo.get!(Todo, id)

Next, we add the route handler inside the controller.

# /lib/todos_web/controllers/todo_controller.ex
def show(conn, %{"id" => id}) do
  todo = Todolist.get_todo!(id)
  render(conn, :show, todo: todo)
end

Next, we add another function inside the view module that will serialise a single Todo. Here we also deviate from the codegen way, since we do not wrap our data in an object.

# /lib/todos_web/controllers/todo_json.ex
def show(%{todo: todo}), do: data(todo)

Finally, we attach the route handler to the /todos/:id route.

# /lib/todos_web/router.ex
get "/todos/:id", TodoController, :show

Now visiting /api/todos/1 yields:

{"id":1,"done":false,"title":"Walk the dog"}

By default the codegen will use the banged (throwing) versions of Repo queries. In case we hit a non-existent route, in development we will get the standard Phoenix error screen. In production, the error_json.ex file will be used to determine the message, by default it will return something like this:

{"errors":{"detail":"Not Found"}}

Alternatively we could use the regular Repo.get/2 and handle the nil result.

Create

To create entities in our database via the API, we should first have a mechanism in place for validating the responses. Ecto recognises that selecting from a database is much different than inserting or updating records. Hence, instead of using queries directly (like we do with fetching data), we will instead use changesets.

Changesets are objects that track changes to a given entity. They can be piped into Repo functions (for example Repo.insert/1) to execute database operations like insert or update.

First, we add a changeset/2 function to our Todo schema.

# /lib/todos/todolist/todo.ex
import Ecto.Changeset

# schema "todos" ...

def changeset(todo, attrs) do
  todo
  |> cast(attrs, [:title, :done])
  |> validate_required([:title])
  |> validate_length(:title, min: 5)
end

Changeset functions typically take the entity that’s being modified, in this case a %Todo{} struct (it can be empty if we want to add a new entity) and attributes that are being changed. We take the todo entity, cast the attributes from the attrs map to create the changeset, and then validate using some provided validators from the Ecto.Changeset module.

It is worth noting that the validation only runs for changed fields. If the attrs map contains the done field, but not the title field, the title validations will not run.

This is what changesets look like in IEx (note the valid? and errors fields):

iex(6)> Todos.Todolist.Todo.changeset(%Todos.Todolist.Todo{}, %{title: "Walk the dog"})
#Ecto.Changeset<
  action: nil,
  changes: %{title: "Walk the dog"},
  errors: [],
  data: #Todos.Todolist.Todo<>,
  valid?: true,
  ...
>
iex(7)> Todos.Todolist.Todo.changeset(%Todos.Todolist.Todo{}, %{title: "Wal"})
#Ecto.Changeset<
  action: nil,
  changes: %{title: "Wal"},
  errors: [
    title: {"should be at least %{count} character(s)",
     [count: 5, validation: :length, kind: :min, type: :string]}
  ],
  data: #Todos.Todolist.Todo<>,
  valid?: false,
  ...
>

A valid changeset can be piped into a Repo function that will return {:ok, entity} if the operation succeeds, or {:error, changeset} if something goes wrong.

Phoenix uses changesets extensively in HTML and LiveView. For entities that have multiple reasons to change, it is common to have multiple changesets. For example, you might have different changesets for different levels of access to the application, or for different business events. In this case however, a single changeset is enough.

It’s common to add a change_entity/2 function to the context that returns an empty changeset and can be used in situations that require validation and change tracking (for example in LiveView forms). We don’t need this now, so we won’t add it, but the automated codegen would create that function for us.

We add a create_todo/1 function that applies attrs to a changeset and pipes it into Repo.insert/1, creating the entity.

# /lib/todos/todolist.ex
def create_todo(attrs \\ %{}) do
  %Todo{}
  |> Todo.changeset(attrs)
  |> Repo.insert()
end

Now we add a route handler to our controller.

# /lib/todos_web/controllers/todo_controller.ex
def create(conn, %{"todo" => todo_params}) do
  {:ok, todo} = Todolist.create_todo(todo_params)
  render(conn, :show, todo: todo)
end

Note that the params here are wrapped in an object with key todo. Autogenerated code will expect you to pass the params wrapped in an object with the entity name, like {"todo": {"title: "Walk the dog"}} rather than just {"title": "Walk the dog"}. I couldn’t find much justification for this, and such a practice is uncommon in REST in general, however Phoenix mixes query, route and body params into one params map so expecting the body to be “namespaced” might be reasonable to avoid collisions.

Next, we attach the route handler to a POST route.

# /lib/todos_web/router.ex
scope "/api", TodosWeb do
  # pipe_through ...
  post "/todos", TodoController, :create
end

Now if we try to create a valid todo using curl:

curl -XPOST -H "Content-type: application/json" -d '{"todo": {"title": "Walk the dog"}}' 'http://localhost:4000/api/todos'

We get a response back and the Todo is created. The problem is, when we try to create an invalid Todo (for instance, missing the title or the title being to short) we receive an unexpected error and the HTTP 500 error code. In the case that validation fails, we would like to return the error.

The source of the error is that this line doesn’t match {:ok, todo} if validation fails:

# /lib/todos_web/controllers/todo_controller.ex
{:ok, todo} = Todolist.create_todo(todo_params)

To fix it, we will attempt to use with. It attempts a pattern match, executing the do block if it succeeds, and returning the failed pattern if it fails.

def create(conn, %{"todo" => todo_params}) do
  with {:ok, todo} <- Todolist.create_todo(todo_params) do
    render(conn, :show, todo: todo)
  end
end

This doesn’t work either, but now we get a different error:

[error] ** (RuntimeError) expected action/2 to return a Plug.Conn, all plugs must receive a connection (conn) and return a connection

We haven’t really talked about Phoenix controllers and route handlers. They are plugs (similar to middleware in other frameworks) that should always return a conn. Conn is basically an object that describes the current request. Coming from JS, they’re kind of like Express’s req and res objects smooshed together. The Plug.Conn module (which we import from todos_web.ex via use TodosWeb, :controller) also has a number of useful functions that manipulate conns by adding statuses, redirecting them, and so on.

We could change the with to a case and try to handle the error in a way that returns a valid conn. However, the error message is lying to us a little. The plugs don’t always have to return conn - they can return something else, as long as we provide a fallback controller which will translate that something else back into a conn.

We create a new file with the fallback controller:

# /lib/todos_web/controllers/fallback_controller.ex
defmodule TodosWeb.FallbackController do
  use TodosWeb, :controller

  def call(conn, error) do
    IO.inspect(error)
    conn
  end
end

We also need to add the action_fallback/1 macro to the TodoController:

# /lib/todos_web/controllers/todo_controller.ex
defmodule TodosWeb.TodoController do
  # use TodosWeb...
  
  action_fallback FallbackController
  
  # def index...
end

Now calling the endpoint with incorrect data reveals, that the failed pattern match is in the form of {:error, changeset}. We can pattern match in the call/2 function of the fallback controller for that kind of error and convert it to a conn that returns an error message.

The automatic codegen would create another view module (changeset_json.ex) for handling changeset errors. This is useful, for example if we wanted to use Gettext during serialisation to get translated error messages, but in this case, instead of making use of a view module, we return the errors directly from the handler using json/2.

# /lib/todos_web/controllers/fallback_controller.ex
defmodule TodosWeb.FallbackController do
  use TodosWeb, :controller

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(Ecto.Changeset.traverse_errors(changeset, &translate_error/1))
  end

  defp translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end
end

Note also the put_status/2 plug here, this is used to change the HTTP status of the response. We could add a specific status code here, but Plug also lets us use atoms that get translated to the status codes. We then use the built-in traverse_errors/2 function that turns the errors into a simple map that can be transformed to JSON.

Making a request to the endpoint with incorrect data now returns something more meaningful:

curl -XPOST -H "Content-type: application/json" -d '{"todo": {"title": "Wa"}}' 'http://localhost:4000/api/todos'

{"title":["should be at least 5 character(s)"]}

We also change the create/2 route handler to add additional information to the successful request:

# /lib/todos_web/controllers/todo_controller.ex
def create(conn, %{"todo" => todo_params}) do
  with {:ok, todo} <- Todolist.create_todo(todo_params) do
    conn
    |> put_status(:created)
    |> put_resp_header("location", ~p"/api/todos/#{todo}")
    |> render(:show, todo: todo)
  end
end

Update and Delete

Now that we have pretty much every bit of plumbing ready and we serve read and create requests, we can add the last two actions to our context:

# /lib/todos/todolist.ex
def update_todo(%Todo{} = todo, attrs) do
  todo
  |> Todo.changeset(attrs)
  |> Repo.update()
end

def delete_todo(%Todo{} = todo), do: Repo.delete(todo)

Next, we add the route handlers to our controller:

# /lib/todos_web/controllers/todo_controller.ex
def update(conn, %{"id" => id, "todo" => todo_params}) do
  todo = Todolist.get_todo!(id)

  with {:ok, %Todolist.Todo{} = todo} <- Todolist.update_todo(todo, todo_params) do
    render(conn, :show, todo: todo)
  end
end

def delete(conn, %{"id" => id}) do
  todo = Todolist.get_todo!(id)

  with {:ok, %Todolist.Todo{}} <- Todolist.delete_todo(todo) do
    send_resp(conn, :no_content, "")
  end
end

Now we should add the routes. We can add them directly under the old ones. However, Phoenix Router gives us a resources/3 macro that we can use to define standard REST routes in this format:

# /lib/todos_web/router.ex
get "/todos", TodoController, :index
get "/todos/:id", TodoController, :show
post "/todos", TodoController, :create
put "/todos/:id", TodoController, :update
delete "/todos/:id", TodoController, :delete

# is equivalent to:
resources "/todos", TodoController, except: [:new, :edit]

Note the except: [:new, :edit] - these actions would be used for a HTML Phoenix app, where /todos/new and /todos/:id/edit routes would direct the user to HTML views with forms for adding and editing a Todo respectively. The forms would call the corresponding create and update routes. Since we are working with a JSON API, they are not necessary.

Testing

The automatic codegen also generates some tests for us. I’m not going to go through every test case, but there are a few basic patterns that we can use to test our business logic and our route handlers.

Business logic

First we’ll make a fixture that creates a todo with given parameters and inserts it into the database, which we’ll use in tests that expect some data to already be in the DB.

# /test/support/fixtures/todolist_fixtures.ex
defmodule Todos.TodolistFixtures do
  @defaults %{done: false, title: "Walk the dog."}

  def todo_fixture(attrs \\ %{}) do
    {:ok, todo} =
        attrs
        |> Enum.into(@defaults)
        |> Todos.Todolist.create_todo()

    todo
  end
end

Next, we’ll create the module for testing the business logic (note the _text.exs suffix of the test files that are supposed to be run by ExUnit).

# /test/todos/todolist_test.exs
defmodule Todos.TodolistTest do
  use Todos.DataCase
  alias Todos.Todolist

  describe "todos" do
    import Todos.TodolistFixtures

    test "list_todos/0 returns all todos" do
      todo = todo_fixture()
      assert Todolist.list_todos() == [todo]
    end
  end
end

Now we can run mix test to see if everything is all right.

mix test
Running ExUnit with seed: 653502, max_cases: 32

...
Finished in 0.03 seconds (0.02s async, 0.01s sync)
3 tests, 0 failures

assert/1 is a very useful macro, we can use it to test not only for equality but also for pattern matching. We could, for example, write a test that verifies that we get an error with a changeset if we try to create an invalid Todo. We can do this by adding a pattern match inside assert, like this:

# /test/todos/todolist_test.exs
test "create_todo/1 with invalid data returns error changeset" do
  invalid_attrs = %{done: nil, title: nil}
  assert {:error, %Ecto.Changeset{}} = Todolist.create_todo(invalid_attrs)
end

Writing the remaining business logic tests is left as an exercise to the reader. As a pro tip: you probably shouldn’t use the todo_fixture/1 in the test for create_todo/1. If you get stuck, you can try to run the codegen and see what tests it spits out.

These tests use Ecto’s SQL Sandbox under the hood (loaded by use Todos.DataCase), so the database is cleared for each test automatically.

Route handlers

To test route handlers we make use of Phoenix.ConnTest which is already included in the TodosWeb.ConnCase module in our app that we can just use. The tests for route handlers are quite similar to the handlers themselves, except “in reverse”: we build the request conn and then dispatch it to the endpoint. At the end we use json_reponse/2 function to retrieve the response from the conn and pattern match it or compare to the expected values.

First we create a module for our route handler tests:

# /test/todos_web/todo_controller_test.exs
defmodule TodosWeb.TodoControllerTest do
  use TodosWeb.ConnCase

  setup %{conn: conn} do
    {:ok, conn: put_req_header(conn, "accept", "application/json")}
  end
  
  describe "index" do
    test "lists all todos", %{conn: conn} do
      conn = get(conn, ~p"/api/todos")
      assert json_response(conn, 200) == []
    end
  end
end

Note that the ~p sigil works in tests: we can use it to get compile-time information about correctness of our routes.

Running mix test shows that it passes:

mix test
Running ExUnit with seed: 899063, max_cases: 32

.....
Finished in 0.05 seconds (0.02s async, 0.03s sync)
5 tests, 0 failures

In case we want to rewrite the test to have some initial setup function that’s going to test against something different than an empty state, we can also include a setup function that’s going to add more data to our test context.

# /test/todos_web/todo_controller_test.exs
describe "index with setup" do
  setup [:setup_todo]

  test "lists all todos (setup)", %{conn: conn, todo: todo} do
    conn = get(conn, ~p"/api/todos")
    expected_todo = %{"id" => todo.id, "title" => todo.title, "done" => todo.done}
    assert [^expected_todo] = json_response(conn, 200)
  end
end

# ...

defp setup_todo(_) do
  todo = todo_fixture()
  %{todo: todo}
end

A final example of a more complicated integration test:

# /test/todos_web/todo_controller_test.exs
describe "create todo" do
  test "responds with a todo when data is valid", %{conn: conn} do
    # given
    attrs = %{title: "Walk the dog.", done: false}

    # when
    conn = post(conn, ~p"/api/todos", todo: attrs)

    # then
    assert %{"id" => id} = json_response(conn, 201)
    conn = get(conn, ~p"/api/todos/#{id}")
    assert %{"id" => ^id, "done" => false, "title" => "Walk the dog."} = json_response(conn, 200)
  end
end

And that’s pretty much it. You can find the code I wrote for this blog post in this GitHub repo.

Divider Divider
Back to top