Menu
CoddyTech

Etiketler

Coddy'nin Node.js Journey'sinin REST API'leri bölümünün bir parçası. Ders 24 / 25.

Görevler office veya money gibi etiketler alır ve pano uygulaması, her etiketin görev sayısını gösteren bir menü ister. Bu adım üç şey ekler: liste için bir şema kuralı, bir filtre ve etiketleri sayan bir rota. Aşağıda gösterilen şekilde bir nesneye sayım yapmak yeni.

// şemada genres: z.array(z.string()).max(3).optional()
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), bu nesneyi bir listeye dönüştürmeye hazır [["poetry", 2], ...] çiftlerine dönüştürür. Bunun gibi bir özet, kendine ait URL'si olan bağımsız bir kaynaktır.

optional(), bu alanın bulunmamasına izin verirken mevcut olduğunda değerini yine de denetler. Object.entries(counts).map(([genre, count]) => ({ genre, count })) ile giriş çiftlerini nesnelere dönüştür: köşeli ayraçlar her çifti açar; parantezler bir nesne döndürür.

challenge icon

Görev

Kolay

Pano uygulaması, görevleri office veya money gibi etiketlere göre gruplandırmak ve her etiketi, onu taşıyan görev sayısıyla birlikte gösteren bir menü sunmak istiyor. API’nize etiketleri ekleyin.

  1. routes/tasks.js dosyasında Task şemasına isteğe bağlı bir labels alanı ekleyin: kırpma işleminden sonra her biri 1 ila 20 karakter olan, en fazla 5 etiketten oluşan bir liste. Şemaları Task üzerinden oluşturulduğu için POST ve PATCH de bu alanı alır. Geçersiz bir liste, alışılmış 400 invalid_body hatasına yol açar. Geçersiz listelere "labels": "office" (liste değil, metin), "labels": [""] ve altı etiket dahildir.
  2. Kaydedilen her görevin bir labels listesi olur:
    • Bir POST veya PUT isteğinde labels gönderilmezse [] kaydedin. PUT görevin tamamını değiştirdiği için, labels olmadan yapılan bir PUT etiketleri temizler.
    • PATCH, labels alanını yalnızca gönderildiğinde değiştirir.
  3. store.js dosyasında başlangıç görevlerine etiketler ekleyin: 1. ve 2. görevler için ["office"], 3. görev için ["money"].
  4. Yine routes/tasks.js dosyasında liste rotasına bir filtre ekleyin: GET /api/v1/tasks?label=office yalnızca labels listesinde tam olarak office bulunan görevleri döndürür. Önceden olduğu gibi diğer filtrelerle ve sayfalarla birlikte çalışır.
  5. server.js dosyasında, yönlendiricinin bağlandığı yere GET /api/v1/labels ekleyin ve burada store.js dosyasından tasks öğesini içe aktarın. Kullanımda olan her etiketi, onu taşıyan görev sayısıyla birlikte döndürür; etiketleri A'dan Z'ye sıralar. Başlangıç görevleriyle sonuç şöyledir:
    [
      { "label": "money", "count": 1 },
      { "label": "office", "count": 2 }
    ]

Deneyin:

  • GET /api/v1/labels yukarıdaki listeyi döndürür.
  • /api/v1/tasks adresine POST { "title": "Order chairs", "labels": ["office"] } gönderin: 201 alırsınız ve yeni görevde "labels": ["office"] bulunur. GET /api/v1/labels artık office için "count": 3 gösterir.
  • POST { "title": "Water the plants" } gönderin: yeni görevde "labels": [] bulunur.
  • GET /api/v1/tasks?label=money yalnızca 3. görevi döndürür. GET /api/v1/tasks?label=nope, "items": [] ve "total": 0 döndürür.
  • PUT /api/v1/tasks/2 adresine { "title": "Book the meeting room", "done": false } gönderin; 2. görev "labels": [] ile döndürülür.

Kendin dene

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();

// PUT tarafından gönderildiği şekliyle eksiksiz bir görev. .strict(), listelemediği alanları reddeder.
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'];

// Büyük-küçük harf fark etmeksizin başka bir görev zaten bu başlığa sahipse true.
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;

REST API'leri bölümündeki tüm dersler

Kendi başına pratik yap: Express playground