12. REST API дизайн

REST (Representational State Transfer) — архитектурный стиль для создания API. Правильный REST API — читаемый, предсказуемый и удобный для клиентов.
REST принципы
Заголовок раздела «REST принципы»1. Ресурсы — URL представляет ресурс, не действие ✅ /users/42 ❌ /getUser?id=42 ❌ /user/get/42
2. HTTP методы — смысл определяет метод GET — получить POST — создать PUT — заменить полностью PATCH — обновить частично DELETE — удалить
3. Stateless — каждый запрос независим, сервер не хранит состояние клиента
4. Единообразие — одинаковые конвенции для всего APIИменование URL
Заголовок раздела «Именование URL»# Коллекции — множественное числоGET /users — список пользователейGET /users/42 — конкретный пользовательPOST /users — создать пользователяPUT /users/42 — заменить пользователяPATCH /users/42 — обновить поля пользователяDELETE /users/42 — удалить пользователя
# Вложенные ресурсыGET /users/42/posts — посты юзера 42GET /users/42/posts/7 — пост 7 юзера 42POST /users/42/posts — создать пост для юзера 42DELETE /users/42/posts/7 — удалить пост 7 юзера 42
# Действия (когда CRUD не подходит)POST /auth/login — войтиPOST /auth/logout — выйтиPOST /auth/refresh — обновить токенPOST /orders/42/cancel — отменить заказPOST /emails/send — отправить emailGET /reports/generate — сгенерировать отчёт
# Версионирование/api/v1/users/api/v2/usersHTTP статус коды
Заголовок раздела «HTTP статус коды»// 2xx — Успех200 OK // GET, PATCH, PUT — успешно201 Created // POST — создано204 No Content // DELETE — удалено, нет тела ответа
// 3xx — Перенаправления301 Moved Permanently // постоянный редирект302 Found // временный редирект304 Not Modified // кэш актуален (с ETag/If-None-Match)
// 4xx — Ошибки клиента400 Bad Request // некорректный запрос / ошибка валидации401 Unauthorized // не авторизован (нет токена)403 Forbidden // нет прав (токен есть, но доступа нет)404 Not Found // ресурс не найден405 Method Not Allowed // метод не поддерживается409 Conflict // конфликт (например, email уже занят)410 Gone // ресурс удалён навсегда422 Unprocessable Entity// семантически некорректный запрос429 Too Many Requests // превышен rate limit
// 5xx — Ошибки сервера500 Internal Server Error // что-то сломалось на сервере502 Bad Gateway // upstream сервер ответил ошибкой503 Service Unavailable // сервер недоступен (обслуживание)Формат ответов
Заголовок раздела «Формат ответов»// Успешный ответ с данными{ "data": { ... }, "meta": { "requestId": "uuid", "timestamp": "2024-01-15T12:00:00Z" }}
// Список с пагинацией{ "data": [ ... ], "pagination": { "page": 1, "limit": 10, "total": 150, "pages": 15 }}
// Ошибка{ "error": { "code": "VALIDATION_ERROR", "message": "Некорректные данные", "details": { "email": ["Неверный формат email"], "name": ["Минимум 2 символа"] } }}Полный CRUD пример
Заголовок раздела «Полный CRUD пример»const { Router } = require('express');const { z } = require('zod');const { authenticate } = require('../middleware/auth');const { validate } = require('../middleware/validate');const asyncHandler = require('../utils/asyncHandler');const ProductService = require('../services/product');
const router = Router();
const createSchema = z.object({ name: z.string().min(2).max(100), price: z.number().positive(), category: z.string(), stock: z.number().int().nonnegative().default(0),});
const updateSchema = createSchema.partial(); // все поля опциональны
const querySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(10), category: z.string().optional(), minPrice: z.coerce.number().optional(), maxPrice: z.coerce.number().optional(), search: z.string().optional(),});
// GET /productsrouter.get('/', validate(querySchema, 'query'), asyncHandler(async (req, res) => { const result = await ProductService.getAll(req.query); res.json(result); }));
// GET /products/:idrouter.get('/:id', asyncHandler(async (req, res) => { const id = parseInt(req.params.id); if (isNaN(id)) { return res.status(400).json({ error: { message: 'Некорректный ID' } }); }
const product = await ProductService.getById(id); if (!product) { return res.status(404).json({ error: { message: 'Товар не найден' } }); }
res.json({ data: product }); }));
// POST /productsrouter.post('/', authenticate, validate(createSchema), asyncHandler(async (req, res) => { const product = await ProductService.create(req.body); res.status(201).json({ data: product }); }));
// PATCH /products/:idrouter.patch('/:id', authenticate, validate(updateSchema), asyncHandler(async (req, res) => { const id = parseInt(req.params.id); const product = await ProductService.update(id, req.body); if (!product) { return res.status(404).json({ error: { message: 'Товар не найден' } }); } res.json({ data: product }); }));
// DELETE /products/:idrouter.delete('/:id', authenticate, asyncHandler(async (req, res) => { const id = parseInt(req.params.id); await ProductService.delete(id); res.sendStatus(204); }));
module.exports = router;Пагинация, сортировка, фильтрация
Заголовок раздела «Пагинация, сортировка, фильтрация»async function getAll({ page, limit, category, minPrice, maxPrice, search }) { const offset = (page - 1) * limit;
// Строим фильтры const where = {}; if (category) where.category = category; if (minPrice || maxPrice) { where.price = {}; if (minPrice) where.price.gte = minPrice; if (maxPrice) where.price.lte = maxPrice; } if (search) { where.name = { contains: search, mode: 'insensitive' }; }
const [products, total] = await Promise.all([ db.product.findMany({ where, skip: offset, take: limit, orderBy: { createdAt: 'desc' }, }), db.product.count({ where }), ]);
return { data: products, pagination: { page, limit, total, pages: Math.ceil(total / limit), hasNext: page * limit < total, hasPrev: page > 1, }, };}Версионирование API
Заголовок раздела «Версионирование API»// Способ 1: В URLapp.use('/api/v1', v1Router);app.use('/api/v2', v2Router);
// Способ 2: Через заголовокapp.use((req, res, next) => { const version = req.headers['api-version'] || '1'; req.apiVersion = parseInt(version); next();});
// Способ 3: Accept header// Accept: application/vnd.myapi.v2+jsonПрактика
Заголовок раздела «Практика»- Создай полный REST API для блога (posts, comments) с правильными статус-кодами
- Реализуй фильтрацию, сортировку и пагинацию для GET /posts
- Добавь единый формат ответов (
{ data: ... }и{ error: { message, details } }) - Реализуй версионирование:
/api/v1/postsи/api/v2/postsс разными форматами - Протестируй API через Postman или HTTPie