Routes and errors

Update and delete resources

Implement predictable update and deletion behavior without treating every action as an unrelated POST endpoint.

HTTP already has verbs for changing and removing things. PUT /books/:id replaces a book. DELETE /books/:id removes it. I see a lot of APIs that invent POST /books/:id/update and POST /books/:id/remove instead. Don’t. The standard methods carry meaning that every client, proxy and cache already understands.

Let’s start with delete, because it’s shorter:

app.delete('/books/:id', c => {
  const index = books.findIndex(book => book.id === c.req.param('id'))
  if (index === -1) return c.json({ title: 'Book not found', status: 404 }, 404)
  books.splice(index, 1)
  return c.body(null, 204)
})

A successful delete returns 204 No Content. There is nothing to send back, so the body is empty. Notice I use c.body(null, 204) and not c.json(). A 204 must not carry a body or a content-type header. Some HTTP clients throw when a 204 arrives with JSON attached.

Run it twice against the same book:

curl -i -X DELETE http://localhost:3000/books/1
curl -i -X DELETE http://localhost:3000/books/1

The first call returns 204. The second returns the 404 with our problem body. That’s the correct behavior, and it’s worth seeing with your own eyes: the second request finds nothing, so it says so.

PUT replaces everything the client can edit

PUT means “here is the complete new version of this book”. The handler looks up the book, and if it exists, swaps the writable fields for the ones in the body:

app.put('/books/:id', async c => {
  const id = c.req.param('id')
  const index = books.findIndex(book => book.id === id)
  if (index === -1) return c.json({ title: 'Book not found', status: 404 }, 404)
  const input = await c.req.json()
  books[index] = { id, title: input.title, author: input.author }
  return c.json({ book: books[index] })
})

Two design decisions are baked in here. First, PUT cannot create a book. Some APIs let PUT /books/42 create book 42 if it’s missing. For this project a missing ID is a 404, and we wrote that in the contract table. Second, id is copied from the URL, never from the body, so the server-owned field stays put.

Because PUT is a full replacement, a body missing author should fail validation, not quietly keep the old author. If you find yourself wanting “change only the title”, that’s a different operation. Add PATCH with its own contract instead of making PUT mean two things.

Idempotent by design

Both methods are idempotent: sending the same request twice leaves the server in the same state as sending it once. Send the same PUT body three times and the book converges on the same values. Send DELETE twice and the book is gone either way, even if the second response differs.

This is what makes them safe to retry, unlike POST. When the database arrives in the next module, keep lookup, authorization check and write together in one operation, so nothing can slip in between the check and the change.

Add both routes now, then repeat each request a few times and watch the responses stay predictable.

Lesson completed