Skip to content

Implementing the API Routes

With the database repository in place, we'll now wire everything together into a working API[1].

Three files change, one test file is added:

sh
doable/
├── shared/
   ├── src/
   └── task.gleam            # JSON encoder and decoder
   └── test/
       ├── shared_test.gleam     # generated placeholder removed
       └── task_test.gleam       # JSON round-trip tests
└── server/
    └── src/
        ├── web.gleam             # request pipeline helpers
        └── task/
            └── route.gleam       # real handler implementations

Install Dependencies

Both packages need gleam_jsonshared for its types, server for building responses:

sh
cd shared
gleam add gleam_json

cd ../server
gleam add gleam_json

gleam.toml gains one entry in each package:

toml
# shared/gleam.toml

[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0"
toml
# server/gleam.toml

[dependencies]
...
gleam_otp = ">= 1.2.0 and < 2.0.0"
gleam_json = ">= 3.1.0 and < 4.0.0"
wisp = ">= 2.2.1 and < 3.0.0"

JSON Serialization

task.gleam needs four new functions: a decoder and an encoder for both Task and TaskInput:

gleam
// shared/src/task.gleam

import gleam/dynamic/decode.{type Decoder}  
import gleam/json.{type Json}               

pub type Task {
  Task(id: Int, name: String, description: String, completed: Bool)
}

pub fn to_task_input(task: Task) -> TaskInput {
  TaskInput(
    name: task.name,
    description: task.description,
    completed: task.completed,
  )
}

pub fn task_decoder() -> Decoder(Task) {                                    
  use id <- decode.field("id", decode.int)                                  
  use name <- decode.field("name", decode.string)                           
  use description <- decode.field("description", decode.string)             
  use completed <- decode.field("completed", decode.bool)                   
  decode.success(Task(id:, name:, description:, completed:))                
}                                                                           

pub fn task_to_json(task: Task) -> Json {                                   
  json.object([                                                             
    #("id", json.int(task.id)),                                             
    #("name", json.string(task.name)),                                      
    #("description", json.string(task.description)),                        
    #("completed", json.bool(task.completed)),                              
  ])                                                                        
}                                                                           

pub type TaskInput {
  TaskInput(name: String, description: String, completed: Bool)
}

pub fn to_task(input: TaskInput, id: Int) -> Task {
  Task(
    id: id,
    name: input.name,
    description: input.description,
    completed: input.completed,
  )
}

pub fn task_input_decoder() -> Decoder(TaskInput) {                         
  use name <- decode.field("name", decode.string)                           
  use description <- decode.field("description", decode.string)             
  use completed <- decode.optional_field("completed", False, decode.bool)   
  decode.success(TaskInput(name:, description:, completed:))                
}                                                                           

pub fn task_input_to_json(input: TaskInput) -> Json {                       
  json.object([                                                             
    #("name", json.string(input.name)),                                     
    #("description", json.string(input.description)),                       
    #("completed", json.bool(input.completed)),                             
  ])                                                                        
}                                                                           

A few things worth noting:

  • Decoders can be generated — the Gleam LSP offers a code action to generate decoders from type definitions. Place the cursor on the type name and invoke "Generate decoder" to get a starting point, then adjust as needed (e.g. swapping decode.field for decode.optional_field).
  • decode.optional_field — used for completed in TaskInput. If the field is absent from the JSON body, the decoder falls back to False rather than returning an error. This is useful for create requests where a sensible default exists.
  • task_decoder vs task_input_decodertask_decoder expects an id field; task_input_decoder does not. The separation mirrors the split between Task and TaskInput in the domain model.

Shared Module Tests

With the JSON functions in place, it's worth adding unit tests for the shared module. shared/test/shared_test.gleam had boilerplate left over from project setup — that's removed, and a new shared/test/task_test.gleam verifies the conversion and JSON round-trip functions:

gleam
// shared/test/task_test.gleam

import gleam/json
import task.{Task, TaskInput}

const task = Task(
  id: 1,
  name: "Buy groceries",
  description: "Milk, eggs, bread",
  completed: False,
)

const task_input = TaskInput(
  name: "Buy groceries",
  description: "Milk, eggs, bread",
  completed: False,
)

pub fn to_task_test() {
  assert task.to_task(task_input, 1) == task
}

pub fn to_task_input_test() {
  assert task.to_task_input(task) == task_input
}

pub fn task_to_json_test() {
  assert task
    |> task.task_to_json
    |> json.to_string
    |> json.parse(task.task_decoder())
    == Ok(task)
}

pub fn task_input_to_json_test() {
  assert task_input
    |> task.task_input_to_json
    |> json.to_string
    |> json.parse(task.task_input_decoder())
    == Ok(task_input)
}

The JSON tests encode a value to a string then decode it back, confirming that the encoder and decoder are inverses of each other. Run them from the shared/ directory:

sh
cd shared
gleam test

Request Helpers

The route handlers all share three common operations: parsing an ID from a path segment, decoding a JSON body, and translating a database result into an HTTP response. Rather than repeating this logic in every handler, web.gleam provides three helper functions:

gleam
// server/src/web.gleam

import error.{type DatabaseError, RecordNotFound}                       
import gleam/dynamic/decode.{type Decoder}                              
import gleam/int                                                        
import wisp.{type Request, type Response}

pub fn middleware(
  req: Request,
  handle_request: fn(Request) -> Response,
) -> Response {
  use <- wisp.log_request(req)
  use <- wisp.rescue_crashes
  use req <- wisp.handle_head(req)
  handle_request(req)
}

pub fn parse_id(id: String, next: fn(Int) -> Response) -> Response {    
  case int.parse(id) {                                                  
    Ok(value) -> next(value)                                            
    Error(_) -> wisp.not_found()                                        
  }                                                                     
}                                                                       

pub fn decode_body(                                                     
  json: decode.Dynamic,                                                 
  decoder: Decoder(a),                                                  
  next: fn(a) -> Response,                                              
) -> Response {                                                         
  case decode.run(json, decoder) {                                      
    Ok(value) -> next(value)                                            
    Error(_) -> wisp.unprocessable_content()                            
  }                                                                     
}                                                                       

pub fn db_execute(                                                      
  result: Result(a, DatabaseError),                                     
  next: fn(a) -> Response,                                              
) -> Response {                                                         
  case result {                                                         
    Ok(value) -> next(value)                                            
    Error(RecordNotFound) -> wisp.not_found()                           
    Error(_) -> wisp.internal_server_error()                            
  }                                                                     
}                                                                       

Each helper follows the same shape: it takes a next continuation as its last argument. On success it calls next with the unwrapped value; on failure it returns an error response directly. This lets handlers use Wisp's use syntax for a flat, readable pipeline.

  • parse_id — path segment IDs arrive as strings. int.parse converts them; a non-integer (or negative) ID returns 404 rather than crashing.
  • decode_body — runs a decoder against the parsed JSON body. An invalid payload returns 422 Unprocessable Content.
  • db_execute — maps database results to HTTP responses. RecordNotFound becomes 404; any other error becomes 500.

Route Handlers

With the helpers in place, the route handlers become short, readable pipelines:

gleam
// server/src/task/route.gleam

import context.{type Context}
import gleam/json
import task
import task/repository
import web
import wisp.{type Request, type Response}

pub fn list_tasks(ctx: Context) -> Response {
  let db = context.db_conn(ctx)
  use tasks <- web.db_execute(repository.all_tasks(db))

  tasks
  |> json.array(task.task_to_json)
  |> json.to_string
  |> wisp.json_body(wisp.ok(), _)
}

pub fn create_task(req: Request, ctx: Context) -> Response {
  let db = context.db_conn(ctx)
  use json <- wisp.require_json(req)
  use task_input <- web.decode_body(json, task.task_input_decoder())
  use task <- web.db_execute(repository.create_task(db, task_input))

  task
  |> task.task_to_json
  |> json.to_string
  |> wisp.json_body(wisp.created(), _)
}

pub fn show_task(_req: Request, ctx: Context, id: String) -> Response {
  let db = context.db_conn(ctx)
  use id <- web.parse_id(id)
  use task <- web.db_execute(repository.get_task(db, id))

  task
  |> task.task_to_json
  |> json.to_string
  |> wisp.json_body(wisp.ok(), _)
}

pub fn update_task(req: Request, ctx: Context, id: String) -> Response {
  let db = context.db_conn(ctx)
  use id <- web.parse_id(id)
  use json <- wisp.require_json(req)
  use task_input <- web.decode_body(json, task.task_input_decoder())
  let task = task_input |> task.to_task(id)
  use task <- web.db_execute(repository.update_task(db, task))

  task
  |> task.task_to_json
  |> json.to_string
  |> wisp.json_body(wisp.ok(), _)
}

pub fn delete_task(_req: Request, ctx: Context, id: String) -> Response {
  let db = context.db_conn(ctx)
  use id <- web.parse_id(id)
  use _ <- web.db_execute(repository.delete_task(db, id))

  wisp.no_content()
}

A few things worth noting:

  • use for early returns — each use line is a callback that either calls next on success or returns an error response immediately. The result is an imperative-looking pipeline where failures short-circuit without nested case expressions.
  • wisp.require_json — provided by Wisp; parses the request body as JSON and returns a decode.Dynamic value, or responds with 400 Bad Request if the body isn't valid JSON.
  • delete_task discards the valueuse _ <- ignores the Ok(Nil) from repository.delete_task; only the error branch matters here.

Verifying the API

Start the server with the database running:

sh
docker compose up -d
cd server
gleam run

Then exercise each endpoint with curl:

sh
# List tasks — empty initially
curl -i http://localhost:8000/api/tasks
# 200 OK, []

# Create a task
curl -i -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"name":"Buy milk","description":"2% fat"}'
# 201 Created, {"id":1,"name":"Buy milk","description":"2% fat","completed":false}

# Create another task
curl -i -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"name":"Read a book","description":"Something good","completed":true}'
# 201 Created, {"id":2,...}

# List tasks — both tasks now returned
curl -i http://localhost:8000/api/tasks
# 200 OK, [{"id":1,...},{"id":2,...}]

# Show a task
curl -i http://localhost:8000/api/tasks/1
# 200 OK, {"id":1,"name":"Buy milk","description":"2% fat","completed":false}

# Update a task (partial fields)
curl -i -X PATCH http://localhost:8000/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Buy milk","description":"Whole milk","completed":true}'
# 200 OK, {"id":1,"name":"Buy milk","description":"Whole milk","completed":true}

# Delete a task
curl -i -X DELETE http://localhost:8000/api/tasks/1
# 204 No Content

# Show deleted task — 404
curl -i http://localhost:8000/api/tasks/1
# 404 Not Found

# Invalid ID — 404
curl -i http://localhost:8000/api/tasks/abc
# 404 Not Found

# Missing required field — 422
curl -i -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"description":"No name field"}'
# 422 Unprocessable Content

The error cases confirm the helpers are working: a non-integer ID returns 404, a missing required field returns 422, and a deleted record returns 404 on subsequent lookup.

What's Next

The API works end-to-end — but every verification so far has been curl typed by hand. Next, we'll add an integration test suite that exercises every route against a dedicated test database, rolled back between cases so tests stay isolated.


  1. See commit d5e0b85 on GitHub ↩︎