Найкращі практики проєктування REST API

14 хв. читання

Один з найпопулярніших типів вебсервісів сьогодні — це REST APІ. Він дозволяє клієнтським застосункам (наприклад, браузерним) спілкуватись із сервером.

Щоб правильно створити REST API, потрібно подбати про безпеку, продуктивність і простоту використання. Якщо не дотримуватись загальноприйнятих правил, то наш API навряд чи працюватиме коректно.

У цій статті ми розглянемо принципи проєктування REST API, аби надалі створювати зрозумілий, надійний, швидкий і здатний до розширення спосіб доставляти дані (можливо, конфіденційні) кінцевому користувачу.

У мережевих застосунках (як і в будь-якому ПЗ) виникають помилки. Тож нам потрібен стандарт, згідно з яким REST API оброблятиме помилки і відповідатиме певним HTTP-кодам, які інформують про помилку кінцевих користувачів.

Отримання та передача даних у форматі JSON

REST API повинен приймати та відповідати на запити даними у форматі JSON. Це стандарт передачі даних, майже кожна мережева технологія може використовувати його. У JavaScript, наприклад, є вбудовані методи для серіалізації та десереалізації JSON, отриманого різноманітними HTTP-клієнтами (наприклад, Fetch API). Серверні технології справляються з цим навіть краще.

Існують й інші способи передачі даних. Наприклад, XML. Цей формат не дуже широко підтримується фреймворками (зазвичай, дані серіалізують у JSON). Проблема в тому, що ми не можемо з легкістю користуватися XML-даними на клієнті, адже треба додатково подбати про потрібний формат даних.

Дані з форм добре підходять для передачі, особливо якщо йдеться про надсилання файлів. Однак для текстових та числових даних це надлишковий спосіб. Ми можемо просто передати дані, отримані з форми на клієнті, у форматі JSON. Це найбільш очевидний спосіб.

Аби переконатись, що клієнт може правильно інтерпретувати дані у JSON, необхідно встановити значення Content-Type як application/json у заголовок відповіді. Більшість server-side фреймворків визначають заголовок відповіді автоматично. Деякі HTTP-клієнти все-таки обробляють дані відповідно до Content-Type.

Винятком є обмін файлами між клієнтом та сервером. Тут трохи інший підхід, але цю тему залишимо для наступного матеріалу.

Тож при проєктуванні API вам треба впевнитись, що ендпоінти повертають саме JSON як відповідь. Багато серверних фреймворків роблять це під капотом.

Розглянемо API, яке приймає дані у форматі JSON. У прикладі використовується бекенд-фреймворк для Node.js — Express. Ми також можемо використати посередник body-parser для десереалізації тіла запиту у форматі JSON, а потім викликати метод res.json з об'єктом, який ми хочемо повернути для цього ендпоінта.

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());

app.post('/', (req, res) => {
  res.json(req.body);
});

app.listen(3000, () => console.log('server started'));

bodyParser.json() парсить тіло запиту у JSON-форматі в JavaScript-об'єкт, а потім огортає його об'єктом req.body. Значення заголовку відповіді Content-Type визначаємо як application/json; charset=utf-8. Такий метод підійде до більшості бекенд-фреймворків.

У назвах шляхів використовуйте іменники замість дієслів

Не рекомендується використовувати дієслова у назвах ендпоінтів. А от іменники, які описують сутність, що обробляється ендпоінтом, чудово підійдуть.

Такий підхід кращий тому, що методи HTTP-запиту вже описують дію. Якщо ще й API-ендпоінт складатиметься з дієслова, його назва буде надлишковою. Один розробник назве ендпоінт, що повертає дані get, інший — retrieve, і це призведе до плутанини. Найпростіше мати метод HTTP GET, який однозначно визначає призначення ендпоінта. Саме тому дії, які необхідно виконати над енпоінтом, описують HTTP-методи, найпопулярніші з яких GET, POST, PUT, DELETE.

Метод GET відповідає за отримання ресурсів. POST надсилає дані на сервер. PUT оновлює наявні дані. DELETE, очевидно, видаляє дані. Дії створення (create), зчитування (read), оновлення (update), видалення (delete) разом утворюють абревіатуру CRUD.

З такими принципами іменування ендпоінтів ми можемо, для прикладу, створити роут /articles/ для отримання нових статей. Методу POST (додавання нової статті) відповідатиме роут /articles/, методу PUT (для оновлення статті за її id) відповідатиме роут /articles/:id. Для видалення статті за її ідентифікатором — так само /articles/:id.

Роут /articles/ представляє ресурс REST API. Наприклад, за допомогою Express ми можемо маніпулювати статтями ось так:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());

app.get('/articles', (req, res) => {
  const articles = [];
  // отримання статті
  res.json(articles);
});

app.post('/articles', (req, res) => {
  // створення нової статті
  res.json(req.body);
});

app.put('/articles/:id', (req, res) => {
  const { id } = req.params;
  // оновлення статті
  res.json(req.body);
});

app.delete('/articles/:id', (req, res) => {
  const { id } = req.params;
  // видалення статті
  res.json({ deleted: id });
});

app.listen(3000, () => console.log('server started'));

Вище ми визначили ендпоінти для роботи з об'єктами статей. Як бачимо, назви шляхів не містять жодних дієслів. Натомість іменники чудово підходять для назви ресурсів. Саму ж дію над ресурсами описують HTTP-методи.

Ендпоінти POST, PUT та DELETE приймають JSON як тіло запиту і повертають також JSON.

Для назв колекцій сутностей використовуйте форму множини

Для когось це правило здаватиметься очевидним, однак варто пам'ятати про правильне іменування колекції. Воно повинно бути постійним для вашого API.

Форма множини використовується для узгодження з базою даних. В таблицях БД зазвичай багато записів, тож назва ендпоінта повинна показувати вміст БД.

Наприклад, ендпоінт /articles дає зрозуміти, що статей декілька, а не одна.

Вкладені ресурси

Якщо ендпоінт, до якого ми звертаємось, працює з вкладеними ресурсами, то назва цих вкладених ресурсів повинна йти одразу за назвою батьківського ресурсу.

Варто обережно підходити до визначення вкладених ресурсів, інакше API стане заплутаним.

Наприклад, якщо нам потрібен ендпоінт для отримання коментарів з певної статті, варто додати дочірній шлях /comments до батьківського /articles. Тепер інтуїтивно зрозуміло, що comment — дочірній об'єкт для article у БД.

Невеликий приклад на Express:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

app.use(bodyParser.json());

app.get('/articles/:articleId/comments', (req, res) => {
  const { articleId } = req.params;
  const comments = [];
  // отримання коментарів за articleId
  res.json(comments);
});


app.listen(3000, () => console.log('server started'));

Тут ми визначаємо GET-метод для шляху /articles/:articleId/comments. Він повертає коментарі статті за її id. Ми додаємо шлях comments вже після /articles/:articleId, аби показати, що коментарі — це дочірній ресурс для /articles. В іншому випадку назва шляху не відповідала б її справжньому функціоналу.

Той самий підхід і до ендпоінтів для методів POST, PUT та DELETE. Вони всі використовують однаковий принцип вкладеності стосовно шляхів.

Потурбуйтеся про обробку помилок, повертайте стандартні HTTP-коди

Аби полегшити роботу кінцевих користувачів вашого API, приділіть увагу обробці помилок. Варто повертати стандартні коди HTTP-відповідей, які дають інформацію про помилку і пояснюють, що насправді сталося. Помилки трапляються завжди, але ми не хочемо, аби вони ламали нашу систему. Тож користувачі API самостійно вирішують, як їх обробити.

Найпоширеніші HTTP-коди помилок:

  • 400 Bad Request — на клієнті не пройшла валідація.
  • 401 Unauthorized — користувач не має авторизованого доступу до ресурсу. Зазвичай це трапляється, коли користувач не пройшов автентифікацію.
  • 403 Forbidden — користувач пройшов автентифікацію, однак все ще не має доступу до ресурсу.
  • 404 Not Found — запитуваний ресурс не знайдено.
  • 500 Internal server error — загальна помилка на сервері (не рекомендується повертати цей код явно).
  • 502 Bad Gateway — некоректна відповідь від сервера вищого рівня.
  • 503 Service Unavailable — щось неочікуване трапилось на серверній частині (наприклад, перевантаження системи, деякі частини відмовили тощо).

Перейдемо до практики. Ми хочемо, аби наш сервер повертав помилки, які відповідають проблемі, що трапилась. Наприклад, якщо дані запиту не пройшли валідацію, повертаємо код 400. Реалізація на Express буде такою:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// наявні користувачі
const users = [
  { email: 'abc@foo.com' }
]

app.use(bodyParser.json());

app.post('/users', (req, res) => {
  const { email } = req.body;
  const userExists = users.find(u => u.email === email);
  if (userExists) {
    return res.status(400).json({ error: 'User already exists' })
  }
  res.json(req.body);
});


app.listen(3000, () => console.log('server started'));

Припустимо, що є перелік користувачів з даними про електронну пошту. Якщо ми спробуємо надіслати запит з email, що вже існує в переліку users, отримаємо статус-код 400 з повідомленням User already exists, яке пояснює причину помилки. Тепер кінцевий користувач може виправити власні дії, аби уникнути такої помилки.

Коди помилок повинні супроводжуватись більш детальним поясненням того, що відбулось. Це повідомлення має допомогти розв'язати проблему, однак не перестарайтеся, аби зловмисники не скористались вашою інформацією для доступу до системи.

Тож піклуйтеся про обробку помилок у вашому API, аби система була надійною, а в разі некоректної роботи пояснювала б користувачам, як діяти далі.

Фільтрація, сортування, пагінація

Ваш проєкт зростає і база даних постійно наповнюється новими записами. Настає момент, коли даних так багато, що повертати їх всі було б занадто повільно та невиправдано. Це час для пагінації та фільтрації.

З пагінацією ми можемо повернути лише певну частину ресурсів за раз, аби зменшити навантаження на систему. Загалом такі трюки збільшують продуктивність, адже запитують ресурси сервера за потребою.

Приведемо невеликий приклад, де API може приймати рядок запиту з деякими параметрами, що вказують, як фільтрувати елементи за полями:

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// дані працівників у БД
const employees = [
  { firstName: 'Jane', lastName: 'Smith', age: 20 },
  //...
  { firstName: 'John', lastName: 'Smith', age: 30 },
  { firstName: 'Mary', lastName: 'Green', age: 50 },
]

app.use(bodyParser.json());

app.get('/employees', (req, res) => {
  const { firstName, lastName, age } = req.query;
  let results = [...employees];
  if (firstName) {
    results = results.filter(r => r.firstName === firstName);
  }

  if (lastName) {
    results = results.filter(r => r.lastName === lastName);
  }

  if (age) {
    results = results.filter(r => +r.age === +age);
  }
  res.json(results);
});

app.listen(3000, () => console.log('server started'));

Ми отримуємо параметри запиту з req.query. Потім за допомогою деструктуризації окремих властивостей отримуємо необхідні значення. Нарешті, виконуємо фільтрацію за параметром запиту, використовуючи метод filter. Тепер ми готові повернути response. Коли наступного разу на сервер прийде запит /employees?lastName=Smith&age=30, він поверне результат, відфільтрований за прізвищем та віком:

[
    {
        "firstName": "John",
        "lastName": "Smith",
        "age": 30
    }
]

Так само параметр page допоможе повернути перелік записів, що розташовані у проміжку між (page - 1) * 20 та page * 20.

За тим самим алгоритмом ми можемо налаштувати сортування за параметрами запиту.

Для прикладу, візьмемо URL: http://example.com/articles?sort=+author,-datepublished

Де + означає сортування за зростанням, а - — за зменшенням. Тож ми сортуємо за іменем автора в алфавітному порядку, а за datepublished — за зменшенням.

Безпека — понад усе

Зазвичай спілкування між клієнтом та сервером повинно бути приватним — для захисту інформації, що передається. Тож нам потрібні SSL/TLS.

Сертифікат SSL легко завантажити на сервер, а сам він часто безкоштовний або ж коштує дуже мало. Тому немає жодної причини відмовлятись від безпечного з'єднання у вашому REST API.

Кінцевий користувач не повинен отримувати доступ до інформації, яка не визначена в запиті (наприклад, отримання одним користувачем даних про іншого користувача або навіть адміна).

Аби подбати про принцип мінімальних привілеїв, обережно поставтесь до призначення ролей користувачам — і не менш обережно перевіряйте їх.

Якщо ви вирішите згрупувати користувачів за ролями, тоді вони отримають дозволи на доступ до певних ресурсів і не більше. Якщо мати гранульовану систему дозволів для кожної фічі, то адміну простіше налаштовувати доступ до різних елементів застосунку. Необхідно мати деякі попередньо встановлені ролі, які можуть бути застосовані до групи користувачів, аби не робити це кожен раз вручну.

Кешування даних для оптимізації

Кешування даних допомагає оптимізувати подібні запити, щоб за кожним запитом не довелось звертатись до БД. Кешування потрібне ще й для того, аби користувач отримував результат швидше. Але тут не без недоліків: часто кешовані дані перестають бути актуальними, а це призводить до неочікуваних багів.

Рішень для кешування безліч: це може бути Redis, кешування в пам'яті тощо.

Вже знайомий нам Express забезпечує кешування через посередника apicahche, який працює з мінімальними налаштуваннями. Додати кешування в пам'яті до нашого сервера дуже просто:

const express = require('express');
const bodyParser = require('body-parser');
const apicache = require('apicache');
const app = express();
let cache = apicache.middleware;
app.use(cache('5 minutes'));

// дані працівників у БД
const employees = [
  { firstName: 'Jane', lastName: 'Smith', age: 20 },
  //...
  { firstName: 'John', lastName: 'Smith', age: 30 },
  { firstName: 'Mary', lastName: 'Green', age: 50 },
]

app.use(bodyParser.json());

app.get('/employees', (req, res) => {
  res.json(employees);
});

app.listen(3000, () => console.log('server started'));

Аби отримати посилання на apicache, ми зберігаємо змінну apicache.middleware, а потім налаштовуємо кеш для всього застосунку:

app.use(cache('5 minutes'))

У прикладі ми кешуємо результат протягом 5 хвилин, однак тривалість можна налаштувати.

Версіонування API

Якщо нові зміни вашого API ламають попередні, варто потурбуватись про версіонування, аби не зламати застосунок кінцевих користувачів API. Версії призначаються відповідно до визначеної схеми (наприклад, більшість застосунків використовують формат 2.0.6, де 2 — це major-версія, 0 — minor, а 6 — це патч).

Так можна зберегти старі ендпоінти замість того, щоб змушувати переходити всіх користувачів на нову версію API з кожним новим релізом. Ендпоінти v1 будуть доступними користувачам старіших версій, а з v2 — тим, хто перейшов на нову версію API з новими фічами. Такий підхід особливо важливий, якщо ваш API публічний.

Різні версії API найчастіше вказуються на початку шляху як /v1/, /v2/ тощо.

Спробуймо в Express:

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.json());

app.get('/v1/employees', (req, res) => {
  const employees = [];
  // код отримання працівників
  res.json(employees);
});

app.get('/v2/employees', (req, res) => {
  const employees = [];
  // новий код отримання працівників
  res.json(employees);
});

app.listen(3000, () => console.log('server started'));

Аби визначити версію API, ми просто додаємо її номер на початок URL ендпоінта.

Підсумуємо

Головне правило — при проєктуванні REST API важливо керуватись вебстандартами та загальними угодами. JSON, SSL/TLS і HTTP-коди статусу — стандарти побудови сучасного вебу.

Звертайте увагу на продуктивність. Не варто повертати всі дані за раз (пагінація, сортування, фільтрація вам допоможуть). Кешування даних допоможе уникнути зайвих запитів до БД.

Назви ендпоінтів повинні бути узгодженими, варто використовувати іменники, оскільки HTTP-методи вже означають дію. Шляхи вкладених ресурсів варто розміщувати одразу за шляхами батьківських ресурсів. Назви ендпоінтів повинні бути достатньо інформативними, аби можна було зрозуміти ієрархію об'єктів, не звертаючись до документації.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 4.7K
Приєднався: 10 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація