Chapter 3 — Polish: errors and filters¶
In Chapter 2 your API already creates and lists tasks. 🚀 What's left is the finishing touch: fetch one task by id (and reply 404 when it doesn't exist), mark it as done, and filter the list. That's what makes a real CRUD.
Everything below goes inside the configure of your app.ts, next to the routes you already have.
Add the three routes¶
Paste these three routes inside configure, right after the POST:
// Buscar UMA tarefa por id — 404 se não existir.
app.get("/api/tasks/:id", async (req, res) => {
res.json(await controllerFor().getById(req.params.id));
});
// Marcar como concluída.
app.patch("/api/tasks/:id/done", async (req, res) => {
const repo = new TaskRepository(engine.session());
await repo.update({ id: req.params.id }, { done: true });
res.json(await controllerFor().getById(req.params.id));
});
// Listar só as pendentes: GET /api/tasks?done=false
app.get("/api/tasks/pending", async (_req, res) => {
res.json(await controllerFor().list({ done: false }));
});
Let's go piece by piece.
Fetch one task (with 404 for free)¶
app.get("/api/tasks/:id", async (req, res) => {
res.json(await controllerFor().getById(req.params.id));
});
The :id in the route is a route parameter — Express captures that slice of the URL and hands it to you in req.params.id. So getById(req.params.id) asks the controller for the task with that id.
And if the id doesn't exist? getById throws RecordNotFound, and the SDK translates that into a 404 response automatically — you write no handling. The error body looks like:
The exact message may differ
The detail above is illustrative — the exact text may differ in your version. What matters is the 404 status and the code NOT_FOUND.
Mark it as done¶
app.patch("/api/tasks/:id/done", async (req, res) => {
const repo = new TaskRepository(engine.session());
await repo.update({ id: req.params.id }, { done: true });
res.json(await controllerFor().getById(req.params.id));
});
A PATCH is the right verb for a partial update — you change only the done field, not the whole task.
Here we go straight to the repository: repo.update({ id: req.params.id }, { done: true }). The first argument is the filter (which rows to update: the ones with this id); the second is the fields to change (done: true). Then a getById re-fetches the updated task to return it in the response.
update takes a filter, not an instance
Notice the shape: update(filter, fields). You do not pass a whole task object — you pass a filter (which rows) and the set of fields to change. The method returns how many rows were affected. For the repository's full CRUD — create, fetch, update, delete and paginate — see the Database recipe.
Filter the list¶
app.get("/api/tasks/pending", async (_req, res) => {
res.json(await controllerFor().list({ done: false }));
});
list({ done: false }) uses the convention filter object: pass an object of field: value and you get back only the rows that match. Here, only the tasks where done is false — the pending ones. ✅
Try it¶
First, grab the id of a real task (create one in Chapter 2 and copy the id, or list with GET /api/tasks). Then fetch it:
{
"id": "3f8c2b6e-0a1d-4e7a-9c11-2b3c4d5e6f70",
"isActive": true,
"createdAt": "2026-07-06T12:00:00.000Z",
"updatedAt": "2026-07-06T12:00:00.000Z",
"title": "Comprar pão",
"done": false
}
Now ask for an id that doesn't exist and see the automatic 404:
HTTP/1.1 404 Not Found
content-type: application/json
{"detail":"...","code":"NOT_FOUND","details":{}}
Mark the task as done with PATCH:
{
"id": "3f8c2b6e-0a1d-4e7a-9c11-2b3c4d5e6f70",
"isActive": true,
"createdAt": "2026-07-06T12:00:00.000Z",
"updatedAt": "2026-07-06T12:05:00.000Z",
"title": "Comprar pão",
"done": true
}
Notice done became true. 💡 Finally, list only the pending ones:
Since the only task is already done, the pending list comes back empty:
You can always check in Swagger
All of these routes show up at http://127.0.0.1:8000/docs, ready to test in the browser. 🚀
Where to go next¶
Congratulations — you have a complete Task list API: create, list, fetch, complete and filter, with validation, types, docs and automatic 404. 🎉
From here, each recipe takes you one step further toward a real service:
- Authentication (JWT) — require login and protect routes.
- Database — pagination, migrations and a production database.
- Testing — test all of this with an in-memory database.
- Settings and HTTP hardening — typed settings and a safer HTTP layer.
Recap¶
In this chapter you:
- Added
GET /api/tasks/:idwithgetById, getting an automatic 404 viaRecordNotFound. - Marked tasks as done with
repo.update(filter, { done: true })and re-fetched the result. - Filtered the list with
list({ done: false })using the convention filter object. - Tested every route via
curland learned the next steps.
You went from an empty folder to a complete API, layer by layer. Now pick the next recipe and keep building. ✅