בניית REST API מלא
עד עכשיו ראינו חלקים: Routes, Middleware, בקשות, שגיאות. בשיעור הזה נחבר אותם ל-API שלם לניהול משימות (Todo), במבנה שמתאים גם לפרויקט אמיתי. בשיעור הבא נחליף את הנתונים שבזיכרון במסד נתונים.
מה זה REST
REST הוא סגנון לבניית API: כל "משאב" מקבל כתובת, והפעולה נקבעת לפי שיטת ה-HTTP:
| פעולה | שיטה ונתיב | תשובה בהצלחה |
|---|---|---|
| רשימת משימות | GET /api/todos | 200 + מערך |
| משימה אחת | GET /api/todos/:id | 200 + אובייקט |
| יצירה | POST /api/todos | 201 + המשימה החדשה |
| עדכון חלקי | PATCH /api/todos/:id | 200 + המשימה המעודכנת |
| מחיקה | DELETE /api/todos/:id | 204, בלי גוף |
שמות נתיבים הם שמות עצם ברבים (/todos), לא פעלים (/getTodos). הפועל הוא שיטת ה-HTTP.
מבנה תיקיות
todo-api/
├── package.json ("type": "module")
├── .env
└── src/
├── server.js הפעלת השרת
├── app.js הגדרת Express, Middleware ו-Routes
├── errors.js מחלקות שגיאה
└── todos/
├── todos.routes.js הנתיבים
└── todos.service.js הלוגיקה והנתונים
ההפרדה בין routes (מה מגיע ב-HTTP) ל-service (מה עושים עם הנתונים) היא מה שיאפשר בשיעור הבא להחליף את האחסון בלי לגעת בנתיבים.
שכבת הנתונים (service)
// src/todos/todos.service.js
import { randomUUID } from 'node:crypto';
const todos = new Map();
export function listTodos({ done } = {}) {
const all = [...todos.values()];
return done === undefined ? all : all.filter((todo) => todo.done === done);
}
export function getTodo(id) {
return todos.get(id) ?? null;
}
export function createTodo(title) {
const todo = { id: randomUUID(), title, done: false, createdAt: new Date().toISOString() };
todos.set(todo.id, todo);
return todo;
}
export function updateTodo(id, changes) {
const todo = todos.get(id);
if (!todo) return null;
Object.assign(todo, changes);
return todo;
}
export function deleteTodo(id) {
return todos.delete(id);
}
הנתיבים (routes)
// src/todos/todos.routes.js
import { Router } from 'express';
import * as service from './todos.service.js';
import { AppError, NotFoundError } from '../errors.js';
export const todosRouter = Router();
function readTitle(body) {
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) throw new AppError('חובה לשלוח title', 400);
if (title.length > 200) throw new AppError('title ארוך מדי', 400);
return title;
}
// GET /api/todos?done=true
todosRouter.get('/', (req, res) => {
const done = req.query.done === undefined ? undefined : req.query.done === 'true';
res.json(service.listTodos({ done }));
});
todosRouter.get('/:id', (req, res) => {
const todo = service.getTodo(req.params.id);
if (!todo) throw new NotFoundError('המשימה');
res.json(todo);
});
todosRouter.post('/', (req, res) => {
const todo = service.createTodo(readTitle(req.body));
res.status(201).location(`/api/todos/${todo.id}`).json(todo);
});
todosRouter.patch('/:id', (req, res) => {
const changes = {};
if (req.body.title !== undefined) changes.title = readTitle(req.body);
if (req.body.done !== undefined) changes.done = Boolean(req.body.done);
const todo = service.updateTodo(req.params.id, changes);
if (!todo) throw new NotFoundError('המשימה');
res.json(todo);
});
todosRouter.delete('/:id', (req, res) => {
if (!service.deleteTodo(req.params.id)) throw new NotFoundError('המשימה');
res.sendStatus(204);
});
חיבור הכול
// src/app.js
import express from 'express';
import { todosRouter } from './todos/todos.routes.js';
export const app = express();
app.use(express.json());
app.use('/api/todos', todosRouter);
app.use((req, res) => res.status(404).json({ error: 'הנתיב לא קיים' }));
app.use((error, req, res, next) => {
const status = error.status ?? 500;
if (status >= 500) console.error(error);
res.status(status).json({ error: status >= 500 ? 'שגיאה בשרת' : error.message });
});
// src/server.js
import { app } from './app.js';
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`ה-API רץ בכתובת http://localhost:${port}`));
ההפרדה בין app.js ל-server.js מאפשרת לבדוק את האפליקציה בלי להפעיל שרת אמיתי.
לנסות את ה-API
curl -X POST http://localhost:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "ללמוד Express"}'
curl http://localhost:3000/api/todos
curl -X PATCH http://localhost:3000/api/todos/<id> \
-H "Content-Type: application/json" \
-d '{"done": true}'
נוח יותר מהטרמינל: התוסף REST Client ל-VS Code, או תוכנות כמו Postman ו-Bruno.
בדיקה אוטומטית עם node:test
// src/app.test.js
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { app } from './app.js';
const server = app.listen(0); // פורט פנוי אקראי
const base = `http://localhost:${server.address().port}`;
after(() => server.close());
test('יצירת משימה מחזירה 201', async () => {
const response = await fetch(`${base}/api/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'בדיקה' }),
});
assert.equal(response.status, 201);
const todo = await response.json();
assert.equal(todo.title, 'בדיקה');
});
test('משימה בלי title מחזירה 400', async () => {
const response = await fetch(`${base}/api/todos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
assert.equal(response.status, 400);
});
node --test
מוסכמות ששוות הרבה
- תמיד JSON, גם בשגיאה:
{ "error": "..." }. הלקוח לא צריך לנחש. - קוד סטטוס נכון לכל תשובה, לא
200עם{"success": false}. - קידומת גרסה כשה-API ציבורי:
/api/v1/todos, כדי לשנות בעתיד בלי לשבור לקוחות קיימים. - רשימה ארוכה מחזירים בעמודים:
?page=2&limit=20.
בדקו את עצמכם
נסו לענות לבד לפני שאתם פותחים את התשובה.
-
מה השם הנכון לנתיב של רשימת משימות ב-REST?
הצגת התשובה
תשובה א. שם עצם ברבים, והפועל הוא שיטת ה-HTTP.
-
איזה קוד מחזירים אחרי DELETE מוצלח בלי גוף?
הצגת התשובה
תשובה ד. 204 No Content.
-
למה להפריד בין routes ל-service?
הצגת התשובה
תשובה ג. בשיעור הבא בדיוק כך מחליפים את האחסון למסד נתונים.