Принципи дизайну REST API та найкращі практики?
REST API - це набір угод для побудови вебсервісів, де дані представлені як адресовані ресурси з передбачуваними URL, а HTTP-методи визначають, що саме з ними робити.
Теорія
TL;DR
- Уяви бібліотечний каталог: кожна книга (ресурс) має фіксоване місце на полиці (URL), а дії "взяти" чи "повернути" виконуються стандартними методами (GET/POST) без жодних особливих інструкцій.
- Головне правило: іменники в шляхах (
/users), дієслова через HTTP (GETотримує список,POSTстворює,DELETEвидаляє). GET /users/123читає юзера.DELETE /users/123видаляє його. URL не змінюється, тільки метод.- REST для публічних API, де важливі кешування і передбачуваність. GraphQL - коли клієнт потребує гнучкого вибору полів.
- Stateless означає, що кожен запит несе повний контекст. Жодних сесій на сервері.
Швидкий приклад
const express = require('express');
const app = express();
app.use(express.json());
let users = [{ id: 1, name: 'Alice' }];
app.get('/users', (req, res) => res.json(users)); // 200 [{id:1,name:'Alice'}]
app.post('/users', (req, res) => { // 201 {id:2,name:'Bob'}
const user = { id: users.length + 1, ...req.body };
users.push(user);
res.status(201).set('Location', `/users/${user.id}`).json(user);
});
app.get('/users/:id', (req, res) => { // 200 або 404
const user = users.find(u => u.id === +req.params.id);
user ? res.json(user) : res.status(404).json({ error: 'Not found' });
});
app.delete('/users/:id', (req, res) => { // 204 No Content
users = users.filter(u => u.id !== +req.params.id);
res.status(204).send();
});
app.listen(3000);POST повертає 201 Created із заголовком Location, який вказує на новий ресурс. DELETE повертає 204 без тіла відповіді. Це не опціональні конвенції - це те, що очікують HTTP-клієнти і CDN.
Головна різниця від RPC-стилю
RPC-стиль API використовує дієслівні шляхи: /getUserById, /createUser, /deleteUser. REST розглядає URL як іменник, а HTTP-метод як дієслово. /users/123 - це ресурс. GET, PUT і DELETE - дії над ним. Таке розділення дозволяє CDN та reverse proxy приймати рішення про кешування на основі одного лише методу, без аналізу семантики URL.
Коли використовувати REST
- Публічний API - REST. Передбачуваний, кешується, працює з будь-яким HTTP-клієнтом.
- Внутрішній мікросервіс - REST або gRPC. REST, якщо стандартних HTTP-інструментів достатньо.
- Клієнт потребує гнучкого вибору полів - GraphQL. Уникає передачі 40 полів, коли потрібно лише 3.
- Оновлення в реальному часі - WebSockets або SSE. REST не підтримує push за своєю природою.
- Простий CRUD - REST. Мінімальний поріг входу, запрацює одразу.
Як HTTP обробляє REST-запити
Сервер (Node.js, Nginx або що завгодно) парсить вхідний запит, зіставляє шлях URL з обробником маршруту і виконує логіку на основі req.method. Жодних сесій на сервері. Кожен запит несе повний контекст: токен авторизації в заголовку Authorization, фільтри в query-параметрах, дані в JSON-тілі.
GET-відповіді кешуються CDN за допомогою заголовків ETag і Last-Modified. Коли клієнт надсилає If-None-Match зі збереженим ETag, сервер повертає 304 Not Modified без тіла, якщо нічого не змінилось. Безкоштовна оптимізація продуктивності без жодного додаткового коду.
Типові помилки
Дієслівні шляхи. /getUsers або /deleteUser/123 ламає кешування і плутає HTTP-інструменти.
// Неправильно
app.get('/getAllUsers', handler);
app.get('/deleteUser/:id', handler);
// Правильно
app.get('/users', handler);
app.delete('/users/:id', handler);Однина замість множини. /user замість /users створює неоднозначність. POST /user - це створення нового юзера чи звернення до поточного? Завжди використовуй множину.
Неправильні статус-коди при створенні. Повернути 200 замість 201 - помилка. Клієнти й автоматизовані інструменти очікують 201 Created із заголовком Location.
// Неправильно
res.status(200).json(newUser);
// Правильно
res.status(201).set('Location', `/users/${newUser.id}`).json(newUser);Відсутність pagination у списках. GET /users без обмеження повертає всі записи і впаде на будь-якій реальній базі даних. За замовчуванням використовуй ?page=1&limit=20.
Сесії на сервері. Зберігання стану сесії порушує stateless-принцип і ускладнює горизонтальне масштабування. Використовуй JWT в заголовку Authorization: Bearer <token>.
Плутанина між PUT і PATCH. PUT повністю замінює ресурс. Якщо надіслати тільки { name: 'Bob' } в PUT /users/1 і обробник виконає повну заміну, email юзера зникне. PATCH - для часткових оновлень (часткове оновлення, partial update). Більшість команд використовують PATCH для редагування і залишають PUT для випадків, де повна заміна є навмисною.
Де зустрічається в реальних проектах
- Stripe API -
/v1/customers,GETзі cursor-пагінацією,POSTіз idempotency key в заголовку. - GitHub API -
/repos/{owner}/{repo}/issues, посилання (HATEOAS)_linksдля навігації між сторінками. - Twitter API v2 -
/2/tweets?expansions=author_id, query-параметри для вибору полів. - Express + Prisma - множина іменників, HTTP-методи,
prisma.user.findMany()прямо відповідаєGET /users. - Версіонування в продакшені - URL-підхід (
/api/v1/users) простіший для налаштування proxy. Header-підхід (Accept: application/vnd.myapi.v2+json) чистіший, але складніший для тестування в браузері.
Питання на співбесіді
Q: Чому множина іменників, а не однина?
A: Множина природно відображає колекції. POST /users створює юзера і повертає /users/123. З /user незрозуміло: ти створюєш нового юзера чи звертаєшся до поточного?
Q: Які HTTP статус-коди відповідають операціям CRUD?
A: GET повертає 200 (знайдено) або 404 (не знайдено). POST повертає 201 Created із Location. PUT і PATCH повертають 200 (з тілом) або 204 (без тіла). DELETE повертає 204 No Content.
Q: У чому різниця між PUT і PATCH?
A: PUT повністю замінює ресурс, PATCH застосовує часткові зміни. GitHub використовує PATCH /repos/:id, щоб оновити тільки опис репозиторію без перезапису інших полів.
Q: Як організувати версіонування API?
A: Два основних підходи: префікс в URL (/api/v1/users) і заголовок (Accept: application/vnd.myapi.v2+json). URL-версіонування простіше для маршрутизації та дебагу. Header-версіонування чистіше, але потребує налаштування на клієнті.
Q: Як працює кешування в REST?
A: Сервер встановлює ETag (хеш відповіді) на GET-відповідях. Клієнт зберігає його і надсилає If-None-Match: <etag> в наступному запиті. Якщо ресурс не змінився, сервер повертає 304 Not Modified без тіла. CDN роблять це автоматично для публічних ендпоінтів.
Q: (Senior) Спроектуй REST API для блогу з постами, коментарями, пошуком та авторизацією. Які ендпоінти, заголовки та статус-коди ти б використав?
A: Починай із GET /posts?page=1&search=foo, який повертає { data: [], _links: { next: { href: '...' } } }. Авторизація через Authorization: Bearer <jwt>. Створення через POST /posts з поверненням 201 та Location. Коментарі на GET /posts/:id/comments. Ліміти запитів через заголовки X-RateLimit-Limit і X-RateLimit-Remaining. HATEOAS-посилання в кожній відповіді, щоб клієнти могли навігувати без жорсткого кодування URL.
Приклади
Базовий CRUD для ресурсу користувача
const express = require('express');
const app = express();
app.use(express.json());
let users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' }
];
// GET /users - список всіх
app.get('/users', (req, res) => res.json(users));
// GET /users/:id - отримати одного
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === +req.params.id);
user ? res.json(user) : res.status(404).json({ error: 'User not found' });
});
// POST /users - створити
app.post('/users', (req, res) => {
const user = { id: users.length + 1, ...req.body };
users.push(user);
res.status(201).set('Location', `/users/${user.id}`).json(user);
});
// PATCH /users/:id - часткове оновлення
app.patch('/users/:id', (req, res) => {
const user = users.find(u => u.id === +req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
Object.assign(user, req.body); // злиття змін, не заміна
res.json(user);
});
// DELETE /users/:id
app.delete('/users/:id', (req, res) => {
users = users.filter(u => u.id !== +req.params.id);
res.status(204).send();
});
app.listen(3000);Кожен метод відповідає одній дії. URL (/users/:id) залишається незмінним, змінюється тільки HTTP-метод. PATCH використовує Object.assign для злиття змін, а не перезапису. DELETE повертає 204 без тіла, бо описувати вже нічого.
Пагінація і фільтрація
app.get('/users', (req, res) => {
const { page = 1, limit = 10, name } = req.query;
let filtered = users;
if (name) filtered = filtered.filter(u => u.name.includes(name));
const start = (page - 1) * limit;
const data = filtered.slice(start, +start + +limit);
res.json({
data,
pagination: {
page: +page,
limit: +limit,
total: filtered.length,
pages: Math.ceil(filtered.length / limit)
}
});
});
// curl "localhost:3000/users?page=1&limit=2&name=Alice"
// -> { data: [{id:1,name:'Alice',...}], pagination: {page:1,limit:2,total:1,pages:1} }Ніколи не повертай необмежений список. Будь-який ендпоінт, що звертається до таблиці бази даних без LIMIT, впаде під навантаженням. Цей патерн відповідає підходу Stripe і GitHub: масив data, метадані пагінації, опціональна фільтрація через query-параметри. Я бачив цей баг у продакшені: GET /orders, який чудово працював в розробці і падав по таймауту в перший понеділок після деплою.
HATEOAS-посилання і часткове оновлення через JSON Patch
// PATCH з JSON Patch (RFC 6902) - масив операцій
app.patch('/users/:id', (req, res) => {
const user = users.find(u => u.id === +req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
// req.body: [{ op: 'replace', path: '/name', value: 'Alicia' }]
req.body.forEach(op => {
if (op.op === 'replace') user[op.path.slice(1)] = op.value;
});
res.json({
...user,
_links: {
self: { href: `/users/${user.id}` },
posts: { href: `/users/${user.id}/posts` }
}
});
});
// curl -X PATCH localhost:3000/users/1 \
// -H "Content-Type: application/json" \
// -d '[{"op":"replace","path":"/name","value":"Alicia"}]'
// -> { id: 1, name: 'Alicia', _links: { self: {href:'/users/1'}, posts: {href:'/users/1/posts'} } }JSON Patch (RFC 6902) надсилає масив операцій замість часткового об'єкта. Намір явний: ти не кажеш "ось як зараз виглядає об'єкт", а кажеш "застосуй цю конкретну зміну". Поле _links за підходом GitHub: клієнти знаходять пов'язані ресурси без жорсткого кодування URL.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.