Menu
CoddyTech

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 icon

Challenge

Easy

The 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.

  1. In routes/tasks.js, add an optional labels field to the Task schema: 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 from Task. A bad list gets the usual 400 invalid_body error. Bad lists include "labels": "office" (text, not a list), "labels": [""], and six labels.
  2. Every saved task has a labels list:
    • When a POST or a PUT sends no labels, save []. PUT replaces the whole task, so a PUT without labels clears them.
    • PATCH changes labels only when it sends them.
  3. In store.js, give the starting tasks labels: ["office"] for tasks 1 and 2, and ["money"] for task 3.
  4. Still in routes/tasks.js, add a filter to the list route: GET /api/v1/tasks?label=office returns only the tasks whose labels include exactly office. It works with the other filters and with pages, like before.
  5. In server.js, add GET /api/v1/labels next to the router mount, and import tasks from store.js there. 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/labels returns 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/labels now shows "count": 3 for office.
  • POST { "title": "Water the plants" }: the new task has "labels": [].
  • GET /api/v1/tasks?label=money returns only task 3. GET /api/v1/tasks?label=nope returns "items": [] and "total": 0.
  • PUT /api/v1/tasks/2 with { "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

Practice on your own: Express playground