Appearance
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 implementationsInstall Dependencies
Both packages need gleam_json — shared for its types, server for building responses:
sh
cd shared
gleam add gleam_json
cd ../server
gleam add gleam_jsongleam.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.fieldfordecode.optional_field). decode.optional_field— used forcompletedinTaskInput. If the field is absent from the JSON body, the decoder falls back toFalserather than returning an error. This is useful for create requests where a sensible default exists.task_decodervstask_input_decoder—task_decoderexpects anidfield;task_input_decoderdoes not. The separation mirrors the split betweenTaskandTaskInputin 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 testRequest 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.parseconverts them; a non-integer (or negative) ID returns404rather than crashing.decode_body— runs a decoder against the parsed JSON body. An invalid payload returns422 Unprocessable Content.db_execute— maps database results to HTTP responses.RecordNotFoundbecomes404; any other error becomes500.
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:
usefor early returns — eachuseline is a callback that either callsnexton success or returns an error response immediately. The result is an imperative-looking pipeline where failures short-circuit without nestedcaseexpressions.wisp.require_json— provided by Wisp; parses the request body as JSON and returns adecode.Dynamicvalue, or responds with400 Bad Requestif the body isn't valid JSON.delete_taskdiscards the value —use _ <-ignores theOk(Nil)fromrepository.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 runThen 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 ContentThe 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.