Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The JSON API

Six routes. Every one of them reads or writes through the board and returns JSON, and not one of them handles an error.

The Routes, One at a Time

The Shape of the File

Filename: routes/api.zu

import ..models { TaskError }
import ..storage { NotFoundError }

/**
 * Registers every `/api` route on `server`, reading and writing
 * through `board`.
 *
 * @param HttpServer server
 * @param Board board
 */
def register(server, board) {

register(server, board) takes the server and the board rather than importing them. That is what lets the same routes run against a different board — a temporary one in a test, a seeded one in a demo — and it keeps this file free of any decision about where the data lives.

Listing, With an Optional Filter

  server.get('/api/tasks', @(request, response) {
    var column = request.query_param('column', nil)
    var tasks = column == nil ? board.all() : board.in_column(column)

    response.json({
      tasks: tasks.map(@(task) => task.to_view()),
      summary: board.summary(),
    })
  })

One route serving two questions: every task, or every task in one column.

query_param('column', nil) supplies the fallback, and the test is column == nil rather than if column. That distinction matters: a query string of ?column= produces an empty string, which is falsy but present — and an empty column name should be rejected by in_column(), not silently treated as “no filter”.

The response carries summary alongside tasks because a client rendering a board wants both, and making it ask twice would be two requests for one screen.

Fetching One

  server.get('/api/tasks/:id', @(request, response) {
    response.json(board.get(request.param('id')).to_view())
  })

One line, and it can fail. board.get() raises NotFoundError for an unknown id, and this handler does nothing about it — which is the subject of the section below.

Creating

  server.post('/api/tasks', @(request, response) {
    var body = request.json_body() or {}

    var task = board.add(body.get('title', nil), {
      notes: body.get('notes', nil),
      column: body.get('column', nil),
    })

    response.json(task.to_view(), 201)
  })

request.json_body() returns nil for a request with no body, or a body that is not JSON at all. or {} turns that into an empty dictionary, so body.get('title', nil) works either way and a missing title becomes the model’s problem — which is where the message about it already lives.

The 201 is the second argument to response.json(). A created resource is not a 200, and saying so is one character of effort.

task.to_view() rather than task, for the reason below.

Updating and Deleting

  server.patch('/api/tasks/:id', @(request, response) {
    var body = request.json_body() or {}

    response.json(board.update(request.param('id'), body).to_view())
  })

  server.delete('/api/tasks/:id', @(request, response) {
    board.remove(request.param('id'))
    response.json({ deleted: true })
  })

  server.get('/api/summary', @(request, response) {
    response.json(board.summary())
  })
}

The PATCH handler passes the decoded body straight to board.update() with no filtering. That is safe because update() only looks at the three keys it knows — title, notes, column — using contains(), so a body containing {"id": "hacked"} changes nothing. The whitelist lives in the model, once, rather than in every route that accepts a body.

DELETE returns a body rather than a bare 204, because a client that parses every response as JSON should not have to special-case one route.

Handlers Do Not Handle Errors

This is the chapter’s real point, and it is easiest to see by counting: six handlers, zero catch blocks.

board.get() raises NotFoundError for an unknown id. board.add() and board.update() raise TaskError for a bad title or an unknown column. Not one handler catches either, and every one of them is one to five lines as a direct result.

The middleware in the next section catches both and turns them into a 404 and a 422. There is exactly one place in this application that knows which domain error means which status code, and it is not in a route.

Consider the alternative for a moment. Six handlers each wrapping their board call in a catch, each deciding on a status, each formatting an error body — thirty-odd lines of duplication, and a seventh route added next month that gets one of them subtly wrong.

Note also the two imports at the top of the file. TaskError and NotFoundError are imported and never mentioned again in this file; they are there because the module that catches them needs them re-exported through routes. That is the module system doing its job, and Chapter 8 covers why the @ matters there.

to_view() at the Boundary

Every response sends to_view(), never the Task itself.

That single habit means the API’s shape is a decision Task makes in one method, rather than something that leaks out of however the object happens to be laid out. Add a private field to Task tomorrow and the API returns exactly what it returned yesterday.

It is also why the responses carry created_on and is_done, which are not stored anywhere — to_view() computes them, so every client gets a formatted date and a boolean without doing the work itself.

The Routes in Practice

$ curl -s localhost:8000/api/tasks -H 'content-type: application/json' \
    -d '{"title":"write the capstone","notes":"chapter 17"}'
{"id":"01a08e15-e758-741d-b368-d5a988cb90cd","title":"write the capstone",
 "notes":"chapter 17","column":"todo","created_at":1789090195.9,
 "created_on":"Sep 11, 2026","is_done":false}

$ curl -s localhost:8000/api/tasks
{"tasks":[...],"summary":{"todo":1,"doing":0,"done":0}}

$ curl -s -X PATCH localhost:8000/api/tasks/01a08e15-... \
    -H 'content-type: application/json' -d '{"column":"doing"}'
{"id":"01a08e15-...","column":"doing",...}

$ curl -s localhost:8000/api/tasks?column=doing
{"tasks":[...],"summary":{"todo":0,"doing":1,"done":0}}

$ curl -s localhost:8000/api/tasks -H 'content-type: application/json' -d '{"title":""}'
{"error":"a task needs a title","field":"title"}

$ curl -s localhost:8000/api/tasks/does-not-exist
{"error":"no task with id does-not-exist"}

The last two are the interesting ones. A 422 with a field, and a 404 with a message, from handlers that said nothing at all about status codes.