Labels
Part of the REST APIs section of Coddy's Node.js journey. Lesson 24 of 25.
Tasks get labels like office or money, and the board app wants a menu of every label with its number of tasks. This step adds three things: a schema rule for a list, a filter, and a route that counts labels. Counting into an object, below, is new.
// genres: z.array(z.string()).max(3).optional() in the schema
const counts = {};
for (const book of books) {
for (const genre of book.genres) {
counts[genre] = (counts[genre] ?? 0) + 1;
}
}
// { poetry: 2, drama: 1 }Object.entries(counts) turns that object into [["poetry", 2], ...] pairs, ready to map into a list. A summary like this is its own resource, with its own URL.
optional() allows this field to be absent while still checking its value when present. Turn entry pairs into objects with Object.entries(counts).map(([genre, count]) => ({ genre, count })): brackets unpack each pair; parentheses return an object.
Challenge
EasyThe board app wants to group tasks by labels like office or money, and show a menu of every label with the number of tasks that carry it. Add labels to your API.
- In
routes/tasks.js, add an optionallabelsfield to theTaskschema: a list of at most 5 labels, each a string of 1 to 20 characters after trimming. POST and PATCH pick it up too, since their schemas are built fromTask. A bad list gets the usual 400invalid_bodyerror. Bad lists include"labels": "office"(text, not a list),"labels": [""], and six labels. - Every saved task has a
labelslist:- When a POST or a PUT sends no
labels, save[]. PUT replaces the whole task, so a PUT without labels clears them. - PATCH changes
labelsonly when it sends them.
- When a POST or a PUT sends no
- In
store.js, give the starting tasks labels:["office"]for tasks 1 and 2, and["money"]for task 3. - Still in
routes/tasks.js, add a filter to the list route:GET /api/v1/tasks?label=officereturns only the tasks whoselabelsinclude exactlyoffice. It works with the other filters and with pages, like before. - In
server.js, addGET /api/v1/labelsnext to the router mount, and importtasksfromstore.jsthere. It returns every label in use with the number of tasks that carry it, sorted A to Z by label. With the starting tasks:[ { "label": "money", "count": 1 }, { "label": "office", "count": 2 } ]
Try it:
GET /api/v1/labelsreturns the list above.- POST
{ "title": "Order chairs", "labels": ["office"] }to/api/v1/tasks: you get 201, and the new task has"labels": ["office"].GET /api/v1/labelsnow shows"count": 3foroffice. - POST
{ "title": "Water the plants" }: the new task has"labels": []. GET /api/v1/tasks?label=moneyreturns only task 3.GET /api/v1/tasks?label=nopereturns"items": []and"total": 0.PUT /api/v1/tasks/2with{ "title": "Book the meeting room", "done": false }returns task 2 with"labels": [].
Try it yourself
import { Router } from 'express';
import { z } from 'zod';
import { config } from '../config.js';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// A whole task, as PUT sends it. .strict() refuses fields it does not list.
const Task = z
.object({
title: z.string().trim().min(1).max(100),
done: z.boolean(),
})
.strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];
// True when another task already has this title, in any case.
const titleTaken = (title, exceptId) =>
tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());
router.get('/', (req, res) => {
const { done, q, sort } = req.query;
let list = tasks;
if (done !== undefined) {
if (done !== 'true' && done !== 'false') {
return sendError(res, 400, 'invalid_query', 'done must be true or false');
}
list = list.filter((t) => t.done === (done === 'true'));
}
if (q !== undefined) {
const words = String(q).toLowerCase();
list = list.filter((t) => t.title.toLowerCase().includes(words));
}
if (sort !== undefined) {
const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
if (!SORT_FIELDS.includes(field)) {
return sendError(
res,
400,
'invalid_query',
'sort must be title or createdAt, with - in front to reverse',
);
}
const direction = String(sort).startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
}
const page = req.query.page === undefined ? 1 : Number(req.query.page);
const limit = req.query.limit === undefined ? config.pageSize : Number(req.query.limit);
if (!Number.isInteger(page) || page < 1) {
return sendError(res, 400, 'invalid_query', 'page must be a whole number from 1');
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return sendError(res, 400, 'invalid_query', 'limit must be a whole number from 1 to 50');
}
const start = (page - 1) * limit;
res.json({
items: list.slice(start, start + limit),
page,
total: list.length,
pages: Math.ceil(list.length / limit),
});
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
res.json(task);
});
router.post('/', (req, res) => {
const result = NewTask.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { title, done = false } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/v1/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = TaskChanges.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
Object.assign(task, result.data);
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = Task.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { id, createdAt } = tasks[index];
if (titleTaken(result.data.title, id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
tasks[index] = { id, ...result.data, createdAt };
res.json(tasks[index]);
});
router.delete('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
All lessons in REST APIs
1Resources and Verbs
Resources and URLsCreating with POST4Browsers, Versions and Config
What Is an OriginAllowing Another OriginCORSA Page That Calls the APIPractice on your own: Express playground