To-Do List Web API
- A three-tier web service separates HTTP handling, business logic, and state management into independent layers.
- The state tier is an OTP actor handling typed messages and is the single source of truth.
- The logic tier is pure functions over
List(Todo), which is easy to test without a running server. - The HTTP tier routes requests, calls the actor, and encodes responses as JSON.
gleam/jsonencodes structured data;gleam/dynamicdecodes untyped JSON bodies.- Use a database to ensure that data survive restarts.
- Running migrations at startup keeps the schema versioned and reproducible.
- Never interpolate user input into SQL strings.
The Three-Tier Structure
- Web applications benefit from clear separation between layers:
- State tier: holds mutable data; the only place writes happen
- Logic tier: pure functions that transform data
- HTTP tier: parses requests, calls logic, formats responses
- In Gleam on the BEAM, the state tier is an OTP actor
- The logic tier is ordinary functions with no imports from
gleam_otporwisp - The HTTP tier uses Wisp for routing and Mist as the underlying HTTP server
Types
pub type Todo {
Todo(id: Int, title: String, done: Bool)
}
pub type Msg {
GetAll
Add(String)
MarkDone(Int)
Remove(Int)
}
Todois the domain record: an integer ID, a title string, and a completion flagMsgis the actor's message type: typed commands sent to the state tierGetAllcarries no data; the caller receives a reply on aSubjectAdd(String)carries the new titleMarkDone(Int)andRemove(Int)carry the target ID
Logic Tier
- Use pure functions for the logic layer:
pub fn add(
todos: List(Todo),
title: String,
) -> #(Todo, List(Todo)) {
let id = list.length(todos)
let item = Todo(id, title, False)
#(item, [item, ..todos])
}
pub fn get_all(todos: List(Todo)) -> List(Todo) {
todos
}
pub fn mark_done(
todos: List(Todo),
id: Int,
) -> List(Todo) {
list.map(todos, fn(t) {
case t.id == id {
True -> Todo(t.id, t.title, True)
False -> t
}
})
}
pub fn remove(todos: List(Todo), id: Int) -> List(Todo) {
list.filter(todos, fn(t) { t.id != id })
}
addreturns both the new item and the new list- The caller can return the item to the client in the HTTP response
mark_donemaps over the list and rebuilds the matching record withdone: Trueremovefilters out the matching record- None of these functions touch IO or the actor; they can be tested with plain gleeunit
JSON Encoding
fn encode_todos(todos: List(Todo)) -> String {
json.array(todos, fn(t) {
json.object([
#("id", json.int(t.id)),
#("title", json.string(t.title)),
#("done", json.bool(t.done)),
])
})
|> json.to_string
}
json.array(list)encodes a list of JSON values as a JSON arrayjson.object([#(key, value), ...])encodes a record as a JSON objectjson.int,json.string,json.boolwrap Gleam valuesjson.to_stringserializes the entire structure to aString
The resulting JSON looks like:
[
{"id":0,"title":"learn Gleam","done":true},
{"id":1,"title":"build API","done":false}
]
HTTP Routes
- In a full Wisp application the router dispatches on method and path:
fn router(req: Request, todos_subject: Subject(Msg)) -> Response {
case wisp.path_segments(req) {
["todos"] -> {
case req.method {
Get -> handle_get_all(todos_subject)
Post -> handle_post(req, todos_subject)
_ -> wisp.method_not_allowed([Get, Post])
}
}
["todos", id_str] -> {
case req.method {
Delete -> handle_delete(id_str, todos_subject)
Patch -> handle_patch(id_str, req, todos_subject)
_ -> wisp.method_not_allowed([Delete, Patch])
}
}
_ -> wisp.not_found()
}
}
wisp.path_segmentssplits the URL path into a list of strings- Pattern matching on the list handles both fixed paths (
"todos") and paths with parameters ("todos", id_str) wisp.method_not_allowedreturns a 405 response with theAllowheader set
JSON Decoding
- Parsing the
POSTbody requires decoding an untyped JSON value:
fn decode_add_body(data: dynamic.Dynamic) -> Result(String, List(dynamic.DecodeError)) {
dynamic.field("title", dynamic.string)(data)
}
dynamic.field("title", dynamic.string)extracts the"title"field and asserts it is a string- The return type
Result(String, List(DecodeError))forces the caller to handle missing or wrongly-typed fields - Composing field decoders with
dynamic.decode2ordynamic.decode3handles records with multiple fields
Adding a Database
- The actor's
List(Todo)disappears when the process restarts - Replacing it with SQLite or some other database makes data survive restarts and supports indexed queries that would be expensive over an in-memory list
- The HTTP routes and JSON encoding do not change; only the storage layer changes
- Open the database at startup and pass the connection to the actor:
fn insert_todo(conn: Connection, title: String) -> Result(Todo, String) {
let sql = "INSERT INTO todos (title, done) VALUES (?, ?)"
let params = [sqlight.text(title), sqlight.int(0)]
case sqlight.query(sql, conn, params, row_decoder()) {
Ok([todo, ..]) -> Ok(todo)
Ok([]) -> Error("insert returned no rows")
Error(e) -> Error(sqlight.error_to_string(e))
}
}
sqlight.text(title)andsqlight.int(0)wrap parameters as SQL values?placeholders are replaced in order- Never interpolate user input directly into SQL strings
- Doing so enables SQL injection attacks
Row Decoder
- Each result row arrives as
Dynamic - A decoder extracts columns by index:
fn row_decoder() -> dynamic.Decoder(Todo) {
dynamic.decode3(
fn(id, title, done) { Todo(id, title, done == 1) },
dynamic.element(0, dynamic.int),
dynamic.element(1, dynamic.string),
dynamic.element(2, dynamic.int),
)
}
dynamic.element(0, dynamic.int)reads column 0 as an integerdone == 1converts SQLite's integer0/1toBooldynamic.decode3assembles the three columns using the constructor
Migrations
- A migration is a SQL script that changes the schema without losing data
- Running all migrations at startup ensures the database is always in the expected state:
fn run_migrations(conn: Connection) -> Result(Nil, String) {
let migrations = [
"CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0
)",
]
list.try_each(migrations, fn(sql) {
sqlight.exec(sql, conn)
|> result.map_error(sqlight.error_to_string)
})
}
CREATE TABLE IF NOT EXISTSmakes the migration idempotent- So it is safe to run against an existing database
- New schema changes are always appended as new entries
- Existing migrations are never modified
- Opening
:memory:in tests gives a fresh database per test with no cleanup required
Testing
pub fn add_creates_item_test() {
let #(item, todos) = add([], "learn Gleam")
item.title
|> should.equal("learn Gleam")
item.done
|> should.be_false()
list.length(todos)
|> should.equal(1)
}
pub fn mark_done_updates_flag_test() {
let #(_, todos) = add([], "task")
let updated = mark_done(todos, 0)
list.first(updated)
|> should.equal(Ok(Todo(0, "task", True)))
}
pub fn remove_deletes_item_test() {
let #(_, todos) = add([], "task")
remove(todos, 0)
|> list.length
|> should.equal(0)
}
- All tests exercise the logic tier directly
- no HTTP or actor setup needed
add_creates_item_testchecks that the returned item has the right title and thatdonestarts asFalsemark_done_updates_flag_testconfirms that only the targeted item changesremove_deletes_item_testconfirms the list shrinks by one
Check Understanding
What is SQL injection and why do parameterised queries prevent it?
SQL injection occurs when user-supplied input is concatenated directly into a SQL string.
A title like "'; DROP TABLE todos; --" would execute a DROP TABLE statement if interpolated naively.
Parameterised queries send the query and the parameters separately.
The database driver handles escaping,
so user input is never interpreted as SQL syntax.
Always use ? placeholders:
never build SQL strings by interpolating user input.
Why use an OTP actor for state rather than a global variable?
Gleam has no global mutable variables, so all state must live somewhere explicit. An OTP actor is a process that owns its state: only the actor reads or writes it, so there is no need for locks. Other processes send messages and receive replies; the actor serializes concurrent requests automatically. If the actor crashes, the supervisor restarts it, so no other process is affected. This "share nothing" model is why BEAM applications can run for years.
The logic tier has no imports from wisp or gleam_otp.
Why does that matter for testing?
Because the logic functions are pure:
they take data in and return result with no side effects and no external dependencies.
Tests can call add, mark_done, and remove directly with plain List(Todo) values,
without starting a server, an actor, or a network socket.
Keeping business logic in a layer that has no framework dependencies is
what makes unit testing fast and reliable.
Exercises
Mark done route (15 minutes)
Add PATCH /todos/:id that marks an item as done.
Update Msg with MarkDone(Int), add a logic function, and add a Wisp handler.
Write a test that posts an item and then patches it.
Input validation (10 minutes)
Reject a POST with an empty title ("") and return 400 Bad Request.
Add this check in the HTTP tier (after decoding), not in the logic tier.
Write a test confirming the 400 response.
Priority field (20 minutes)
Add type Priority { High Medium Low } and a priority field to Todo.
Update encoding (use json.string(priority_to_string(p)))
and decoding (parse the string back to a variant),
and add tests.
Filter by status (10 minutes)
Add GET /todos?done=true that returns only completed items.
Use wisp.get_query to read the query parameter and filter the list in the logic tier.
Write tests for both ?done=true and ?done=false.
SQLite persistence (20 minutes)
Replace the actor's List(Todo) with a sqlight.Connection.
Update Add, MarkDone, and Remove to run parameterised queries.
Use sqlight.open(":memory:") in tests so each test starts with a fresh database.
Confirm that the same HTTP-tier tests still pass without modification.
Transactional batch insert (20 minutes)
Write insert_many(conn: Connection, titles: List(String)) -> Result(List(Todo), String).
Wrap all inserts in a transaction: BEGIN, insert each, COMMIT.
If any insert fails, ROLLBACK and return an Error.
Write a test that confirms a failed batch leaves no partial data.