How To Make A Todo App With Erlang
2024-12-08
Todo apps, despite being cliche, are great learning projects. They can exemplify a basic CRUD web app architecture with a minimal amount of code. For years, I’ve felt Erlang was a little heavy handed in making small web projects compared to other ecosystems. Now, through some effort of my own, Erlang has the tools to make a competitive developer experience for making web applications.
This post will assume a basic understanding of Erlang and Web applications in general. By the end, we will have built a web JSON API backend to a todo application. Since frontend doesn’t have much to do with Erlang, and security is a bit too complex for the purpose of this post I am explicitly leaving all of that out.
If you just want to read the code or reference it while following along, here is a repo with the full example.
Initialize the Project
If you want to follow along but haven’t done any Erlang coding you’ll need to make sure to install the latest version of Erlang and rebar3 rebar3 is the default package manager for Erlang.
We begin by initializing our project. Use rebar3 to create a new project with the release
scaffolding.
rebar3 new release todo
Add dependencies to rebar.config:
{deps, [nine, nine_cowboy, strata, strata_sqlite3, esqlite, keep]}.
Make sure to add these to apps/todo/src/todo.app.src
, like so:
{application, todo,
[{description, "An OTP application"},
{vsn, "0.1.0"},
{registered, []},
{mod, {todo_app, []}},
{applications,
[kernel,
stdlib,
nine,
nine_cowboy,
strata,
strata_sqlite3,
esqlite,
keep
]},
{env,[]},
{modules, []},
{licenses, ["Apache-2.0"]},
{links, []}
]}.
Run rebar3 deps
and the packages will be added to your project. Since esqlite is a sqlite3 wrapper you might need make and other build tools to compile the native library.
Next make a folder at apps/todo/src/migrations
. We will be using this folder for our schema migrations we will need to make later.
Setup the Database
sqlite is nice because it is embedded and easy to setup. One oddity since it doesn’t have a client library is that the connection needs to be passed around to be used for queries. For erlang this is a little tricky since you can’t just make a statically mutable variable. Except you can. With the power of ETS, we can store the connection in an inmemory database and retrieve it for any process to get access to it. Instead of using ETS directly, I’ve opted to use keep. keep is a key value abstraction on top of ETS that just makes it very intuitive to do what we need. I like to think of keep as the equivalent of localStorage for for Erlang.
%% Initalize keep
keep:init(),
%% Open the sqlite3 database file and create a connection to it.
Conn = esqlite3:open("todo.db")
%% Store the connection in keep
keep:put(db, Conn),
Setup Schema Migrations
Now that we have a database connection we can manage our table schemas using strata
. strata
is a schema management interface that can have different
database types plug into it. In our case we are using the strata_sqlite3
backend.
For our example project we will only have one schema migration that we will need to make. First, let’s install the rebar3_strata
plugin.
In rebar.config
, add:
{project_plugins, [rebar3_strata]}.
{provider_hooks, [{pre, [{compile, {strata, compile}}]}]}.
{strata, [{path, "apps/todo/src/migrations"}]}.
These lines respectively install the rebar3_strata plugin, tell rebar3 to run the plugin at compile time, and point the plugin to our migrations directory we made earlier.
Run rebar3 deps
to install the new dependency.
Now you can run rebar3 strata new create_todos
. This will create an erlang module in the migrations directory that will look something like this:
-module(create_todos_1733705799680897).
-behaviour(strata_migration).
-export([up/0, down/0]).
up() -> todo.
down() -> todo.
Notice the todo atoms being returned from up/0
and down/0
. We need to fill those in with the query we want to run to create and delete our todos
table.
So let’s change those functions to look like this:
up() ->
[<<"CREATE TABLE IF NOT EXITS todos (">>,
<<" id INTEGER PRIMARY KEY AUTOINCREMENT, ">>,
<<" body TEXT NOT NULL">>,
<<")">>
].
down() ->
<<"DROP TABLE IF EXISTS todos">>.
Now run rebar3 compile
. This will compile your project and trigger the strata plugin to generate another Erlang module in apps/todo/src/migrations
called
strata_migrations.erl
. In this module you will see something like this:
-module(strata_migrations).
-export([init/0]).
init() ->
#{
create_todos_1733705799680897 => 1733705799680897
}.
You will never need to edit this file, nor should you, since it is managed by the strata plugin.
Finally we need to use the strata
libraries to actually run the schema migrations. Now there are several ways of doing this but I have opted
to run the schema migrations at app startup. So in apps/todo/src/todo_app.erl
, we will add to our initialization something like this:
Migrations = strata_migrations:init(),
Config = strata:config(Migrations, strata_sqlite3, Conn),
ok = strata:run(Config),
We retrieve the migrations from strata_migrations:init/0
. Then we setup the migrations config for strata
. We need to provide the strata backend, and in this case,
the connection to the actual sqlite database. Finally we run the schema migrations with strata:run/1
.
Setup the Routes
Once we have the database and our schemas setup, we can move on to building the actual web part of our web application.
Let’s make a module called todo_web.erl
:
-module(todo_web).
-export([routes/0,
get_todos/1,
new_todo/1,
delete_todo/1,
update_todo/1]).
routes() ->
[#{path => <<"/api">>,
handle => [#{path => <<"/todos">>,
method => <<"GET">>,
handle => {todo_web, get_todos}},
#{path => <<"/todo">>,
pre => {nine_cowboy_mid, json_request},
handle => [#{method => <<"POST">>,
handle => {todo_web, new_todo}},
#{method => <<"DELETE">>,
handle => {todo_web, delete_todo}},
#{method => <<"PUT">>,
handle => {todo_web, update_todo}}]}]},
#{path => <<"*">>,
handle => {nine_cowboy_util, not_found}}].
get_todos(Context) -> Context.
new_todo(Context) -> Context.
delete_todo(Context) -> Context.
update_todo(Context) -> Context.
Once again we are going to modify our start method in todo
to compile the routes into a router module and initialize the cowboy webserver.
Add these lines:
nine:compile(#{
routes => todo_web:routes(),
generator => nine_cowboy,
router => todo_router
}),
{ok, _} = cowboy:start_clear(todo_http_listener,
[{port, 8080}],
#{middlewares => [todo_router]}
),
nine
will compile the routes into a router module using the web server backend provided. In this case, the backend is nine_cowboy
, so we can use
the cowboy
webserver. The compiled router module will be called todo_router
, and we tell cowboy
we want to use it as the sole middleware.
Run rebar3 shell
to make sure everything is working as expected. The app currently does nothing, but at least it runs.
Let me go back to revisit the routes method we made in todo_web
.
Routes
Notice that the routes module returns a list. The order of elements corresponds to the order that the route will be matched in on a given request.
Each element of the list is a map that must at least contain the handle
key. A few other optional keys are path
and method
. So a simple handler might look like this:
#{path => <<"/todos">>,
method => <<"GET">>,
handler => {todo_web, get_todos}}
The above will execute the method todo_web:get_todos/1
when a request comes in with the path /todos
and the HTTP method GET
.
In the routes config above we have nested handlers. This is a feature of nine to support composition of routes, handlers, and middleware.
Our route config puts all the API calls under the “/api” prefix. So retrieving a list of todos from the API would access a path like this:
"/api/todos"
The corresponding route config for this is:
[#{path => <<"/api">>,
handler => [#{path => <<"/todos">>,
...
We have also split up the calls for retrieving a list of todos from API calls acting on a single todo. To do basic operations on a single todo, we address requests with the path:
"/api/todo"
This is done to be semantically clear what someone is acting on, as well as the fact we want to add
the json_request
middleware to the requests operating on a single todo, however we don’t want to apply it to the
GET
request because we are not expecting to receive JSON bodies. When we add the middleware, when a request comes into
that path without the content type set to application/json
it will reject the request and return an error. In the config,
adding the middleware looked like this:
#{path => <<"/todo">>,
pre => {nine_cowboy_mid, json_request}
...
}
The pre
key applies middleware to occur before the handler is called. We could use the post
key to apply middleware after the handler.
This example only shows one middleware being applied but if we wanted more we could use a list:
#{path => <<"/todo">>,
pre => [{example_mid, logger}, {nine_cowboy_mid, json_request}]
...
}
In this case, the logger middleware would execute, then the json_request middleware, and then finally the actual request handler.
Implement the Web Handlers
Right now any request is returning an empty response. Let’s fix that by filling in the handler methods we defined earlier.
First lets make some handy functions to make our requests a little more DRY.
json_response(#{req := Req} = Context, Data) ->
Context#{resp => nine_cowboy_util:json_Response(200, Data, Req)}.
json_success(#{req := Req} = Context) ->
json_response(Context, #{success => true}).
query_db(Sql) ->
query_db(Sql, []).
query_db(Sql, Args) ->
esqlite3:q(keep:get(db), Sql, Args).
query_response(Context, Sql) ->
json_response(Context, query_db(Sql)).
query_success(Context, Sql, Args) ->
query_db(Sql, Args),
json_success(Context).
Get Todos Handler
Now we can make our handlers with the functions we just defined. Getting the list of all todos looks like this:
get_todos(Context) ->
query_response(Context, <<"SELECT * FROM todos">>).
Post New Todo Handler
If you remembered we discussed the json_request
middleware being applied to any request for a single todo (path being “/api/todo”). That middleware
has the effect of adding the parsed json body to the Context map under the key json
. We can make on that parsed body like so:
new_todo(#{json := #{<<"body">> := Body}} = Context) ->
query_success(Context, <<"INSERT INTO todos (body) VALUES (?)">>, [Body]).
If a JSON body comes in that doesn’t match, it will fail to execute the Erlang function. This is a simple way of doing it, but maybe not the preferred way for a robust application. So keep that in mind.
Update Todo Handler
When we update the todo we use a similar idea, except we added the id
key.
update_todo(#{json := #{<<"id">> := Id,
<<"body">> := Body}} = Context) ->
query_success(Context, <<"UPDATE SET body = ? WHERE id = ?">>, [Body, Id]).
Delete Todo Handler
Deletes we do the same thing:
delete_todo(#{json := #{<<"id">> := Id}} = Context) ->
query_success(Context, <<"DELETE FROM todos WHERE id = ?">>, [Id]).
Conclusion
If you followed along this far, remember to check out the working code at this repo.
Hopefully all of this seemed straight forward and easy. I wanted to create a better experience than I had when I first started making web apps in Erlang. With the help of nine and strata, Erlang devs can have their cake and eat it too. All the benefits of Erlang without sacrificing developer experience. These are just the basic components, and I hope more people are inspired to build out the rest of the ecosystem.