Skip to main content

Принципи дизайну 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 означає, що кожен запит несе повний контекст. Жодних сесій на сервері.

Швидкий приклад

javascript
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-інструменти.

javascript
// Неправильно 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.

javascript
// Неправильно 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 для ресурсу користувача

javascript
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 без тіла, бо описувати вже нічого.

Пагінація і фільтрація

javascript
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

javascript
// 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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?