To-Do List Web API

The Three-Tier Structure

Types

i
pub type Todo {
  Todo(id: Int, title: String, done: Bool)
}

pub type Msg {
  GetAll
  Add(String)
  MarkDone(Int)
  Remove(Int)
}

Logic Tier

i
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 })
}

JSON Encoding

i
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
}

The resulting JSON looks like:

[
  {"id":0,"title":"learn Gleam","done":true},
  {"id":1,"title":"build API","done":false}
]

HTTP Routes

i
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()
  }
}

JSON Decoding

i
fn decode_add_body(data: dynamic.Dynamic) -> Result(String, List(dynamic.DecodeError)) {
  dynamic.field("title", dynamic.string)(data)
}

Adding a Database

i
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))
  }
}

Row Decoder

i
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),
  )
}

Migrations

i
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)
  })
}

Testing

i
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)
}

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.