Routes and errors

Read path and query parameters

Use path parameters to identify one resource and query parameters to filter or shape a collection.

Path parameters and query parameters answer two different questions. /books/42 says “this one book”. /books?author=le-guin says “the books that match this filter”. The first identifies, the second narrows.

That difference drives the status codes. A path that points at a book that doesn’t exist is a 404. A filter that matches nothing is a 200 with an empty list. Keep that in mind while we add both.

The detail route

In Hono a path segment starting with : is a parameter. You read it with c.req.param():

app.get('/books/:id', c => {
  const id = c.req.param('id')
  const book = books.find(item => item.id === id)
  if (!book) return c.json({ type: 'about:blank', title: 'Book not found', status: 404 }, 404)
  return c.json({ book })
})

Request /books/1 and you get {"book":{"id":"1",...}} with a 200. Request /books/99 and you get the 404 with the error body. That body follows the problem details format from RFC 9457, and we make it the standard for every error in a later lesson.

The filter

Query values come from c.req.query(). Let’s add an optional author filter to the collection route:

app.get('/books', c => {
  const author = c.req.query('author')
  const result = author ? books.filter(book => book.author === author) : books
  return c.json({ books: result })
})

Try curl 'http://localhost:3000/books?author=Frank%20Herbert' and Dune comes back. Try ?author=Le%20Guin and you get {"books":[]} with a 200. No error, because the request was valid. It just matched nothing.

Both are strings, both are untrusted

c.req.param() and c.req.query() always return strings, or undefined when the value is missing. ?limit=20 gives you '20', not 20. And anyone can type anything into a URL, so treat both as input from a stranger.

That leads to three separate outcomes you should keep distinct. If your IDs have a required shape, say a UUID, a value like abc is malformed and deserves a 400 before you even look it up. A well-formed ID that finds nothing is absent, and that’s the 404. A filter that finds nothing is empty, and that’s a 200.

Decide the normalization

Small questions hide in a filter, and my advice is to answer them on purpose. Is the author match exact or case-insensitive? Does surrounding whitespace matter? What happens with ?author= or with ?author=a&author=b? Whatever you pick, write it in the contract file from the first lesson.

One rule has no exceptions: never copy a query value into SQL or into a response header before validating it. We get to parameterized queries in the next module, and this is exactly why.

Test the edges before moving on: an encoded value like Le%20Guin, an empty author, a malformed ID, and a valid ID that doesn’t exist. Each one should produce a different, predictable answer.

Lesson completed