בניית REST API מלא

עד עכשיו ראינו חלקים: Routes, Middleware, בקשות, שגיאות. בשיעור הזה נחבר אותם ל-API שלם לניהול משימות (Todo), במבנה שמתאים גם לפרויקט אמיתי. בשיעור הבא נחליף את הנתונים שבזיכרון במסד נתונים.

מה זה REST

REST הוא סגנון לבניית API: כל "משאב" מקבל כתובת, והפעולה נקבעת לפי שיטת ה-HTTP:

פעולהשיטה ונתיבתשובה בהצלחה
רשימת משימותGET /api/todos200 + מערך
משימה אחתGET /api/todos/:id200 + אובייקט
יצירהPOST /api/todos201 + המשימה החדשה
עדכון חלקיPATCH /api/todos/:id200 + המשימה המעודכנת
מחיקהDELETE /api/todos/:id204, בלי גוף

שמות נתיבים הם שמות עצם ברבים (/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.

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. מה השם הנכון לנתיב של רשימת משימות ב-REST?

    1. /api/todos
    2. /getTodos
    3. /todosList
    4. /api/todo/list/get
    הצגת התשובה

    תשובה א. שם עצם ברבים, והפועל הוא שיטת ה-HTTP.

  2. איזה קוד מחזירים אחרי DELETE מוצלח בלי גוף?

    1. 404
    2. 200
    3. 201
    4. 204
    הצגת התשובה

    תשובה ד. 204 No Content.

  3. למה להפריד בין routes ל-service?

    1. Express דורש
    2. סגנון בלבד
    3. כדי להחליף את שכבת הנתונים (זיכרון, מסד) בלי לגעת בנתיבים, ולבדוק כל שכבה לבד
    4. זה מהיר יותר
    הצגת התשובה

    תשובה ג. בשיעור הבא בדיוק כך מחליפים את האחסון למסד נתונים.