Label
Termasuk bagian REST API dalam perjalanan Node.js di Coddy. Pelajaran 24 dari 25.
Tugas mendapatkan label seperti office atau money, dan aplikasi papan ingin menampilkan menu semua label beserta jumlah tugasnya. Langkah ini menambahkan tiga hal: aturan skema untuk list, filter, dan rute yang menghitung label. Menghitung ke dalam object, seperti di bawah ini, adalah hal baru.
// genres: z.array(z.string()).max(3).optional() dalam skema
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) mengubah object itu menjadi pasangan [["poetry", 2], ...], siap dipetakan menjadi list. Ringkasan seperti ini adalah resource tersendiri, dengan URL-nya sendiri.
optional() memungkinkan field ini tidak ada, tetapi tetap memeriksa nilainya jika tersedia. Ubah pasangan entri menjadi object dengan Object.entries(counts).map(([genre, count]) => ({ genre, count })): tanda kurung siku membongkar setiap pasangan; tanda kurung biasa mengembalikan object.
Tantangan
MudahAplikasi papan ingin mengelompokkan tugas berdasarkan label seperti office atau money, dan menampilkan menu berisi semua label beserta jumlah tugas yang menggunakannya. Tambahkan label ke API-mu.
- Di
routes/tasks.js, tambahkan fieldlabelsopsional ke skemaTask: sebuah list yang berisi paling banyak 5 label, masing-masing berupa string sepanjang 1 hingga 20 karakter setelah dipangkas. POST dan PATCH juga mengambilnya karena skemanya dibuat dariTask. List yang tidak valid menghasilkan error 400invalid_bodyseperti biasa. Contoh list yang tidak valid mencakup"labels": "office"(teks, bukan list),"labels": [""], dan enam label. - Setiap tugas yang disimpan memiliki list
labels:- Jika POST atau PUT tidak mengirim
labels, simpan[]. PUT mengganti seluruh tugas, jadi PUT tanpa label akan menghapus labelnya. - PATCH hanya mengubah
labelsjika mengirimkannya.
- Jika POST atau PUT tidak mengirim
- Di
store.js, beri label pada tugas awal:["office"]untuk tugas 1 dan 2, serta["money"]untuk tugas 3. - Masih di
routes/tasks.js, tambahkan filter ke route list:GET /api/v1/tasks?label=officehanya mengembalikan tugas yanglabels-nya memuat tepatoffice. Filter ini berfungsi bersama filter lain dan halaman, seperti sebelumnya. - Di
server.js, tambahkanGET /api/v1/labelsdi samping pemasangan router, lalu importasksdaristore.jsdi sana. Route ini mengembalikan setiap label yang digunakan beserta jumlah tugas yang menggunakannya, diurutkan berdasarkan label dari A ke Z. Dengan tugas awal:[ { "label": "money", "count": 1 }, { "label": "office", "count": 2 } ]
Coba:
GET /api/v1/labelsmengembalikan list di atas.- Kirim POST
{ "title": "Order chairs", "labels": ["office"] }ke/api/v1/tasks: kamu mendapat 201, dan tugas baru memiliki"labels": ["office"].GET /api/v1/labelskini menampilkan"count": 3untukoffice. - Kirim POST
{ "title": "Water the plants" }: tugas baru memiliki"labels": []. GET /api/v1/tasks?label=moneyhanya mengembalikan tugas 3.GET /api/v1/tasks?label=nopemengembalikan"items": []dan"total": 0.PUT /api/v1/tasks/2dengan{ "title": "Book the meeting room", "done": false }mengembalikan tugas 2 dengan"labels": [].
Coba sendiri
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();
// Task lengkap seperti yang dikirim oleh PUT. .strict() menolak field yang tidak tercantum.
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 jika task lain sudah memiliki title ini, tanpa membedakan huruf besar dan kecil.
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;
Semua pelajaran di REST API
1Sumber Daya dan Kata Kerja
Sumber Daya dan URLMembuat dengan POST4Peramban, Versi, dan Konfigurasi
Apa Itu OriginMengizinkan Origin LainCORSHalaman yang Memanggil APILatihan sendiri: Playground Express