Пошук уроків, статей та іншого контенту
Навчитеся версіонувати API, підтримувати сумісність клієнтів і безпечно розвивати контракти ресурсів.
API є контрактом між сервером і клієнтами. Контракт описує:
доступні маршрути;
формат запитів;
формат відповідей;
типи та обов’язковість полів;
HTTP-коди;
правила обробки помилок;
семантику операцій.
Зміна контракту може вплинути не лише на ваш frontend, а й на мобільні застосунки, зовнішніх партнерів, інтеграції та скрипти клієнтів.
Версіювання потрібне тоді, коли нова реалізація більше не може гарантувати сумісність зі старими клієнтами.
Наприклад, зміна назви поля:
{
"name": "Олена Коваль"
}на:
{
"firstName": "Олена",
"lastName": "Коваль"
}є несумісною, якщо старий клієнт очікує поле name.
Водночас додавання нового необов’язкового поля зазвичай є сумісною зміною:
{
"id": "user-1",
"name": "Олена Коваль",
"avatarUrl": "/avatars/user-1.png"
}Старий клієнт може проігнорувати avatarUrl.
Під час розвитку API важливо розрізняти два напрямки сумісності.
Нова версія сервера продовжує коректно обслуговувати старих клієнтів.
Наприклад, сервер не видаляє поле name, поки існують клієнти, які його використовують.
Новий клієнт може працювати зі старою версією сервера.
Це складніше гарантувати, оскільки новий клієнт може очікувати функції, яких старий сервер ще не підтримує.
На практиці API найчастіше проєктують так, щоб:
старі клієнти продовжували працювати з новим сервером;
нові поля були необов’язковими;
нові операції додавалися без зміни старих;
несумісні зміни потрапляли до нової версії API.
До потенційно breaking changes належать:
видалення маршруту;
зміна HTTP-методу;
зміна структури URL;
перейменування поля;
видалення поля з відповіді;
зміна типу поля, наприклад number на string;
зміна поля з необов’язкового на обов’язкове;
зміна формату дати;
зміна значень enum;
зміна семантики HTTP-коду;
зміна правил сортування або фільтрації;
зміна формату помилок;
зміна поведінки за замовчуванням;
звуження діапазону допустимих значень.
Наприклад, заміна:
{
"isActive": true
}на:
{
"status": "active"
}не є простим перейменуванням. Змінюється структура і, можливо, набір доступних станів.
Зазвичай без створення нової версії можна:
додати новий маршрут;
додати необов’язковий параметр запиту;
додати необов’язкове поле до відповіді;
додати нове значення до окремого розширюваного набору станів;
додати новий HTTP-метод, не змінюючи старих;
покращити повідомлення в логах;
оптимізувати внутрішню реалізацію без зміни контракту.
Однак навіть додавання поля може бути несумісним для клієнта, який помилково відхиляє невідомі поля. Тому сумісність потрібно перевіряти реальними клієнтами або контрактними тестами, а не визначати лише теоретично.
Найпоширеніший варіант:
/api/v1/users
/api/v2/usersПереваги:
версію легко побачити в запиті;
просто тестувати через браузер, curl або API-клієнт;
зручно налаштовувати маршрутизацію;
кеші та проксі можуть розрізняти адреси.
Недолік — версія стає частиною публічної адреси ресурсу. Потрібно підтримувати окремі маршрути або шлюз для різних версій.
Версію можна передавати спеціальним заголовком:
GET /api/users
Accept: application/vnd.example.users.v2+jsonПереваги:
URL описує ресурс, а не формат контракту;
можна змінювати представлення одного ресурсу.
Недоліки:
запит складніше тестувати вручну;
клієнти та проксі мають коректно працювати з узгодженням вмісту;
потрібно враховувати заголовок Vary: Accept для кешування;
версію важче побачити в логах і трасуванні.
Приклад:
/api/users?version=2Такий підхід простий, але часто призводить до неоднозначності:
що відбувається, якщо параметр не передано;
чи впливає версія на кеш;
чи є version частиною бізнес-фільтрації;
як поводиться документація та маршрутизація.
Для публічного API зазвичай краще мати одну чітку та послідовну стратегію.
У прикладі використано версію в URL. Бізнес-дані є спільними для версій, а формат відповіді перетворюється окремими адаптерами.
Завдяки цьому не потрібно дублювати всю бізнес-логіку для кожної версії.
const http = require("node:http");
const { URL } = require("node:url");
const users = [
{
id: "user-1",
firstName: "Олена",
lastName: "Коваль",
email: "olena@example.com",
createdAt: "2026-01-15T10:00:00.000Z"
},
{
id: "user-2",
firstName: "Андрій",
lastName: "Мельник",
email: "andrii@example.com",
createdAt: "2026-02-20T12:30:00.000Z"
}
];
function findUserById(id) {
return users.find((user) => user.id === id);
}
function toV1User(user) {
return {
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email
};
}
function toV2User(user) {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
createdAt: user.createdAt
};
}
function sendJson(response, statusCode, body, headers = {}) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
...headers
});
response.end(JSON.stringify(body));
}
function sendNotFound(response) {
sendJson(response, 404, {
error: {
code: "NOT_FOUND",
message: "Ресурс не знайдено"
}
});
}
function handleUsers(request, response, version, userId) {
const toRepresentation = version === "v1" ? toV1User : toV2User;
if (request.method !== "GET") {
sendJson(
response,
405,
{
error: {
code: "METHOD_NOT_ALLOWED",
message: "Метод не підтримується"
}
},
{
Allow: "GET"
}
);
return;
}
if (userId) {
const user = findUserById(userId);
if (!user) {
sendNotFound(response);
return;
}
const headers =
version === "v1"
? {
// Власні заголовки дозволяють клієнтам побачити план міграції.
"X-API-Deprecated": "true",
"X-API-Sunset": "2027-01-01"
}
: {};
sendJson(response, 200, toRepresentation(user), headers);
return;
}
sendJson(response, 200, {
data: users.map(toRepresentation),
meta: {
count: users.length
}
});
}
const server = http.createServer((request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const segments = url.pathname.split("/").filter(Boolean);
// Очікувані адреси: /api/v1/users та /api/v2/users.
if (
segments.length >= 3 &&
segments[0] === "api" &&
["v1", "v2"].includes(segments[1]) &&
segments[2] === "users" &&
segments.length <= 4
) {
handleUsers(request, response, segments[1], segments[3]);
return;
}
sendNotFound(response);
});
server.listen(3000, () => {
console.log("API запущено на http://localhost:3000");
});Запуск:
node server.jsПриклади запитів:
curl http://localhost:3000/api/v1/users/user-1
curl http://localhost:3000/api/v2/users/user-1Версія v1 повертає старе представлення:
{
"id": "user-1",
"name": "Олена Коваль",
"email": "olena@example.com"
}Версія v2 повертає новий контракт:
{
"id": "user-1",
"firstName": "Олена",
"lastName": "Коваль",
"email": "olena@example.com",
"createdAt": "2026-01-15T10:00:00.000Z"
}Обидві версії використовують одну функцію пошуку користувача. Відмінність між ними знаходиться на рівні представлення.
Не варто зберігати різні версії одного ресурсу як різні моделі бази даних без необхідності.
Краще розділяти:
доменну модель — внутрішнє представлення даних;
контракт API — публічне представлення для конкретної версії;
адаптер або serializer — перетворення доменної моделі на відповідь.
Схематично це виглядає так:
запит клієнта
↓
маршрутизація версії
↓
спільна бізнес-логіка
↓
доменна модель
↓
адаптер v1 або v2
↓
HTTP-відповідьТаке розділення зменшує ризик, що виправлення у v2 випадково змінить поведінку v1.
Водночас адаптер не повинен приховувати справжню несумісність. Якщо версія має інші правила доступу, валідації або побічні ефекти, відмінності потрібно реалізовувати явно та тестувати окремо.
Версія API не повинна існувати без плану підтримки.
Для кожної версії варто визначити:
дату запуску;
статус: активна, застаріла або запланована до вимкнення;
мінімальну підтримувану функціональність;
дату завершення підтримки;
процес міграції;
відповідального за оновлення клієнтів.
Типовий життєвий цикл:
Запуск — нова версія доступна клієнтам.
Стабілізація — виправляються помилки без зміни контракту.
Deprecation — версія ще працює, але нові інтеграції вже не повинні її використовувати.
Sunset — після оголошеної дати версія вимикається.
Попередження про застарілу версію можна передавати заголовками або через документацію. Важливо, щоб клієнт отримував:
яка версія застаріла;
коли вона буде вимкнена;
на яку версію потрібно перейти;
які зміни потрібно виконати.
Не слід вимикати стару версію одразу після появи нової. Клієнтам потрібен перехідний період, а команді — спосіб перевірити, що активних споживачів більше немає.
Нові поля бажано додавати так, щоб старий клієнт міг їх проігнорувати:
{
"id": "order-42",
"status": "paid",
"currency": "UAH"
}Якщо клієнт повинен використовувати нове поле для правильної роботи, воно вже не є просто необов’язковим доповненням.
Безпечніший підхід — тимчасово повертати обидва поля:
{
"name": "Олена Коваль",
"fullName": "Олена Коваль"
}Проте це не вирішує проблему назавжди. Потрібно:
позначити старе поле застарілим;
повідомити клієнтів;
зібрати інформацію про використання;
видалити старе поле лише в новій версії або після погодженого завершення підтримки.
Додавання нового значення до enum може зламати клієнт, який обробляє всі невідомі значення як помилку.
Замість припущення:
if (status === "paid") {
// ...
} else if (status === "pending") {
// ...
}клієнт має мати безпечний fallback:
switch (status) {
case "paid":
handlePaid();
break;
case "pending":
handlePending();
break;
default:
handleUnknownStatus();
}Якщо таку поведінку неможливо гарантувати, додавання нового значення може вимагати нової версії.
Формат потрібно зафіксувати явно.
Наприклад, важливо визначити:
чи дата передається в UTC;
чи використовується ISO 8601;
чи число є цілим або дробовим;
як передаються грошові суми;
чи може поле мати null.
Зміна null на відсутнє поле або навпаки може бути важливою для клієнтів, які розрізняють ці стани.
Нова версія API може змінити представлення даних, але це не означає, що потрібно одразу змінювати всі записи в базі.
Наприклад, внутрішня модель може зберігати:
firstName
lastNameА адаптер v1 формує з них поле name.
Це дозволяє:
не дублювати дані;
не виконувати ризиковану масову міграцію без потреби;
підтримувати старий контракт окремо від нового.
Проте якщо нова версія змінює саму бізнес-семантику, проста трансформація відповіді буде недостатньою. Тоді потрібно окремо спланувати міграцію даних, правил і клієнтів.
Для кожної версії слід перевіряти не лише статус 200, а весь публічний контракт:
структуру JSON;
типи полів;
обов’язкові поля;
HTTP-коди;
формат помилок;
заголовки;
правила пагінації;
поведінку для невалідних даних.
Особливо важливо перевіряти, що зміни у спільній бізнес-логіці не ламають стару версію.
Наприклад, для v1 потрібно перевірити, що поле name досі існує, навіть якщо внутрішня модель уже використовує firstName і lastName.
Корисно мати окремі набори тестів:
tests/api/v1/users.test.js
tests/api/v2/users.test.jsТести різних версій можуть використовувати спільні фабрики тестових даних, але очікування відповідей мають бути окремими.
Пагінація часто недооцінюється під час версіювання. Несумісною може бути не лише зміна формату елемента, а й зміна навігації.
Наприклад, ці формати мають різну семантику:
{
"data": [],
"page": 2,
"pageSize": 20,
"total": 100
}і:
{
"data": [],
"nextCursor": "eyJpZCI6MjB9"
}Перехід зі сторінкової пагінації на cursor-based pagination може вимагати нової версії, якщо клієнти залежать від page, pageSize або total.
Формат помилки потрібно версіонувати так само уважно, як і успішну відповідь.
Стабільніша структура:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Запит містить некоректні дані",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT"
}
]
}
}Клієнтам краще орієнтуватися на машинний code, а не на текст message. Текст можна змінювати для зрозумілості або локалізації.
Не варто без потреби змінювати:
400 на 422;
404 на 200 з порожнім об’єктом;
структуру поля error;
тип details з масиву на об’єкт.
Такі зміни можуть зламати обробку помилок у клієнтах.
Нова версія виправдана, якщо:
старий формат неможливо підтримувати без неоднозначності;
змінюється структура основного ресурсу;
змінюється семантика операції;
змінюються правила авторизації;
змінюється формат помилок;
змінюється спосіб пагінації;
старі обмеження заважають розвитку API;
кілька несумісних змін потрібно випустити узгоджено.
Не потрібно створювати нову версію для кожного нового поля або маршруту. Надмірне версіювання збільшує кількість кодових шляхів, тестів і документації.
Перед випуском v2:
Опишіть усі відмінності від v1.
Позначте кожну зміну як сумісну або несумісну.
Визначте, які клієнти використовують v1.
Реалізуйте спільну бізнес-логіку та окремі адаптери представлення.
Додайте контрактні тести для обох версій.
Випустіть v2 без негайного вимкнення v1.
Оголосіть план переходу.
Відстежуйте трафік старої версії.
Повідомте про дату завершення підтримки.
Вимкніть v1 лише після перевірки міграції клієнтів.
Якщо документація називає API «версією 2», але сервер не розрізняє контракти, клієнт не отримує жодної гарантії сумісності.
Версія повинна бути частиною маршрутизації або механізму узгодження формату.
Якщо той самий маршрут випадково повертає різні поля залежно від клієнта, середовища або порядку розгортання, контракт стає непередбачуваним.
Одна версія повинна мати стабільні правила.
Навіть якщо поле «вже не використовується» ваш frontend, воно може бути потрібне мобільній версії або зовнішньому клієнту.
Перед видаленням перевіряйте реальне використання поля.
Копіювання обробників для v1 і v2 часто призводить до розбіжностей: помилка виправляється в одній версії, але залишається в іншій.
Спільними мають бути доменні операції, а відмінності — ізольовані в адаптерах і правилах конкретного контракту.
Запуск нової версії сам по собі не переводить клієнтів на неї. Без дедлайну стара версія може залишитися в системі назавжди.
Сумісність включає помилки, порожні результати, невірні параметри, відсутні ресурси та граничні випадки.
Поле amount не повинно в одній версії означати суму в гривнях, а в іншій — суму в копійках. Якщо семантика змінюється, змініть назву або створіть нову версію.
Версіювання захищає клієнтів від несумісних змін API.
Спочатку потрібно визначити, які зміни є breaking changes.
Найпростіший для розуміння варіант — версія в URL, наприклад /api/v1 і /api/v2.
Бізнес-логіку бажано спільно використовувати між версіями, а формати відповідей реалізовувати через окремі адаптери.
Додавання полів, enum-значень і зміна формату помилок потребують окремої перевірки сумісності.
Стара версія повинна мати визначений життєвий цикл і план завершення підтримки.
Контрактні тести мають перевіряти кожну версію окремо.
Нова версія потрібна для реальної зміни контракту, а не для кожного невеликого доповнення.