Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як реалізувати версіонування API в Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Версіонування API в Express.js** - це запуск кількох версій API на одному сервері, щоб старі клієнти продовжували працювати при несумісних змінах. ```js const v1Router = require('./routes/v1'); const v2Router = require('./routes/v2'); app.use('/api/v1', v1Router); // старі клієнти app.use('/api/v2', v2Router); // нові клієнти ``` **Ключове:** URL-версіонування (versioning) - найпоширеніший вибір для публічних API. Бізнес-логіку тримай у спільному шарі `services/`, версіонуй тільки маршрути і контролери.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Версіонування API** - це запуск кількох версій API на одному Express-сервері, щоб старі клієнти продовжували працювати, коли ти вносиш несумісні зміни. ## Теорія ### TL;DR - Аналогія: ключ від кімнати 101 і ключ від кімнати 102, один і той самий ресепшн обробляє обидва - Три основні підходи: URL-шлях (`/api/v1`), заголовки запиту (`Accept-Version`), query-параметри (`?version=2`) - URL-шлях для публічних API; заголовки для мобільних або внутрішніх клієнтів; query-параметри тільки для прототипів - Завжди додавай заголовки деплікації (`Sunset`, `Deprecation`) при виведенні версії з обігу - Бізнес-логіку тримай у `services/`, версіонуй тільки шар API ### Швидкий приклад ```js const express = require('express'); const app = express(); // v1: плоский об'єкт користувача (legacy-клієнти очікують саме це) app.use('/api/v1/users', (req, res) => { res.json([{ name: 'John Doe' }]); }); // v2: розділені поля імені (несумісна зміна) app.use('/api/v2/users', (req, res) => { res.json([{ firstName: 'John', lastName: 'Doe' }]); }); app.listen(3000); ``` ``` GET /api/v1/users -> [{ "name": "John Doe" }] GET /api/v2/users -> [{ "firstName": "John", "lastName": "Doe" }] ``` Обидві версії працюють паралельно. Старі клієнти звертаються до `/v1` і отримують те, на що розраховують. Нові клієнти звертаються до `/v2` і отримують оновлену структуру. ### URL-шлях проти заголовків Версіонування за URL-шляхом прив'язує версію до маршруту. Можна протестувати в браузері, поділитися посиланням у Slack, проксувати через CDN без додаткових налаштувань. Версіонування за заголовками тримає URL чистими: версія читається з `Accept-Version` або `Api-Version` у заголовках запиту. Зручно для мобільних додатків, які контролюють кожен запит, але незручно тестувати вручну і потребує `Vary: Accept-Version` для правильного кешування. ### Коли що використовувати - Публічний REST API з багатьма клієнтами: URL-шлях (`/api/v1`, `/api/v2`) - Мобільний або внутрішній сервіс: версіонування за заголовками - Прототип або legacy-клієнт, який не можна оновити: query-параметр (`?version=2`) - Корпоративний API gateway з semver-діапазонами: кастомний middleware з бібліотекою `semver` ### Порівняння стратегій | Стратегія | Приклад | Кешується | Коли використовувати | |---|---|---|---| | URL-шлях | `/api/v1/users` | Так | Публічні API (Stripe, Twilio) | | Заголовок | `Accept-Version: 2.0.0` | Тільки з `Vary` | Мобільні, внутрішні API (GitHub) | | Query-параметр | `/api/users?version=2` | Ні | Прототипи, legacy-клієнти | | Middleware semver | Кастомна маршрутизація | Налаштовується | Enterprise API gateway | ### Структура проекту: спільна логіка, версіоновані маршрути Найпоширеніша помилка - дублювати контролери повністю для кожної версії. Розділяй шари: ``` src/ ├── routes/ │ ├── v1/ │ │ ├── index.js │ │ └── users.routes.js │ └── v2/ │ ├── index.js │ └── users.routes.js <- тільки змінені ендпоінти ├── controllers/ │ ├── v1/ │ └── v2/ └── services/ <- спільна бізнес-логіка, без версіонування ``` Якщо модуль не змінився між версіями, просто реекспортуй v1: ```js // routes/v2/products.routes.js // Без змін з v1 - використовуємо той самий router module.exports = require('../v1/products.routes'); ``` Версіонуєш інтерфейс, а не запити до бази чи доменну логіку. ### Версіонування за заголовками з semver Коли клієнти передають рядки версій типу `1.2.0` або `^2.0.0`, порівняння рядків не спрацьовує. Використовуй бібліотеку `semver`: ```js const express = require('express'); const semver = require('semver'); const app = express(); app.use('/api/users', (req, res, next) => { const version = req.headers['accept-version'] || '1.0.0'; if (semver.satisfies('2.0.0', version)) return v2Handler(req, res, next); if (semver.satisfies('1.0.0', version)) { console.warn('v1 deprecated, будь ласка оновіть до v2'); return v1Handler(req, res, next); } res.status(400).json({ error: 'Invalid version' }); }); const v1Handler = (req, res) => res.json({ users: [{ name: 'John' }] }); const v2Handler = (req, res) => res.json({ data: [{ firstName: 'John' }] }); ``` ``` Accept-Version: 1.2.0 -> відповідь v1 + лог деплікації Accept-Version: 2.0.0 -> відповідь v2 Accept-Version: 3.0.0 -> 400 помилка ``` ### Заголовки деплікації (deprecation) Коли v1 виводиться з обігу, клієнти мають знати заздалегідь. Додавай заголовки до кожної відповіді v1: ```js function deprecationWarning(req, res, next) { res.set('Deprecation', 'true'); res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT'); res.set('Link', '</api/v2>; rel="successor-version"'); next(); } app.use('/api/v1', deprecationWarning, v1Router); ``` Заголовок `Sunset` - це стандарт IETF (RFC 8594). Більшість HTTP-клієнтів і моніторингових інструментів читають його автоматично. Давай клієнтам 12-18 місяців між анонсом вимкнення і самим вимкненням. ### Як Express маршрутизує версії Express будує ланцюжок middleware: кожен `app.use()` або `app.get()` додає об'єкт `Layer` до стеку. При запиті Express проходить стек і зупиняється на першому відповідному шарі. Шляхи `/api/v1` і `/api/v2` відповідають окремим `Router`-інстанціям і ніколи не перетинаються. HTTP-парсер Node витягує шлях і заголовки ще до того, як Express їх бачить, тому обидва підходи чисто працюють на рівні middleware. Якщо v1 і v2 вказують на один обробник, V8 оптимізує його автоматично. Але якщо логіка розходиться суттєво, ти нарощуєш heap. Тримай спільний код у `services/`. ### Типові помилки **Спільний контролер для всіх версій** ```js // Неправильно: обидві версії вказують на один обробник const getUsers = (req, res) => res.json([{ firstName: 'John' }]); app.use('/api/v1', getUsers); // v1 очікує { name }, а не { firstName } app.use('/api/v2', getUsers); ``` Клієнти v1 ламаються без жодного попередження. Виправлення: розділи обробники або додай трансформер, який映射 поля v2 назад до формату v1: `v1Response.name = v2Response.firstName + ' ' + v2Response.lastName`. **Без заголовків деплікації** Якщо не додавати `Sunset`-заголовки, v1 може обробляти 80% трафіку через два роки. Логуй запити до v1 через Prometheus, щоб знати, коли насправді безпечно його вимкнути. **Версіонування несумісних змін** Додавання необов'язкового поля до відповіді - це не несумісна зміна. Створювати для цього нову версію - зайве ускладнення. Версіонуй тільки те, що реально зламає існуючих клієнтів: перейменовані поля, видалені поля, змінені типи, нові схеми авторизації. **Відсутність `Vary: Accept-Version` при версіонуванні за заголовками** Без нього CDN і реверс-проксі кешують одну версію і віддають її всім клієнтам незалежно від заголовка. Завжди встановлюй `res.set('Vary', 'Accept-Version')` у middleware версіонування. ### Використання в реальних проектах - Stripe: `/v1/charges` - URL-шлях, незмінний з 2011 року, старі клієнти досі працюють - GitHub: `Accept: application/vnd.github.v3+json` - версіонування за заголовками - Twilio: `/2010-04-01/Accounts` - URL з датою як версією - Kubernetes API: кастомний middleware для semver-діапазонів по десятках API-груп - Express Gateway: open-source API gateway з вбудованим middleware для версіонування ### Питання для підготовки до співбесіди **Q:** Які компроміси між версіонуванням за URL і за заголовками? **A:** URL-версіонування явне і добре кешується, але змушує клієнтів оновлювати URL. Версіонування за заголовками зберігає стабільні шляхи, але складніше тестується і потребує `Vary` для правильного кешування. URL краще для браузерних і публічних API, заголовки - для мобільних або внутрішніх сервісів. **Q:** Як мігрувати клієнтів з v1 на v2? **A:** Запускай обидві версії паралельно 12-18 місяців. З першого дня додавай заголовки `Deprecation` і `Sunset` до відповідей v1. По можливості проксуй запити v1 до обробників v2 через трансформер відповіді. Стеж за метриками, щоб знати, коли безпечно вимикати. **Q:** Як обробляти semver-діапазони типу `^1.2.0` в Express? **A:** Використовуй бібліотеку `semver`. Перевіряй `semver.satisfies(currentVersion, requestedRange)` у middleware і маршрутизуй до потрібного обробника. Мінорні та патч-версії можуть використовувати один router, тільки major-версії потребують окремої гілки. **Q:** Що робити, якщо v2 потребує даних, але у форматі v1? **A:** Тримай спільний service layer, який повертає нейтральний внутрішній формат. Кожен версіонований контролер застосовує власний серіалізатор для формування відповіді. Ніколи не викликай контролер v1 з маршруту v2. **Q:** Як масштабувати до 10 і більше версій без вибуху кодової бази? (senior) **A:** Використовуй динамічний завантажувач маршрутів, який сканує директорії `./routes/v*` при старті. Тримай один спільний шар моделей і прикріплюй версіоновані серіалізатори до кожного класу. Деплікуй агресивно: підтримуй одночасно не більше двох активних версій. Документуй lifecycle у OpenAPI-специфікації для кожної версії. ## Приклади ### Базовий: URL-версіонування зі спільною валідацією ```js const express = require('express'); const app = express(); // Спільний middleware для обох версій const validateUserId = (req, res, next) => { if (!req.params.id) return res.status(400).json({ error: 'Missing user ID' }); next(); }; // v1: плоский об'єкт app.get('/api/v1/users/:id', validateUserId, (req, res) => { res.json({ id: req.params.id, name: 'John Doe', email: 'john@example.com' }); }); // v2: вкладений профіль з аватаром (несумісна зміна) app.get('/api/v2/users/:id', validateUserId, (req, res) => { res.json({ id: req.params.id, profile: { name: 'John Doe', email: 'john@example.com' }, avatar: 'https://example.com/avatar.jpg' }); }); app.listen(3000); ``` ``` GET /api/v1/users/123 -> { "id": "123", "name": "John Doe", "email": "john@example.com" } GET /api/v2/users/123 -> { "id": "123", "profile": { "name": "John Doe", "email": "john@example.com" }, "avatar": "https://example.com/avatar.jpg" } ``` `validateUserId` спільний для обох версій. Різниця тільки у формі відповіді. Це правильний поділ: спільна валідація, версіонована серіалізація. ### Середній: Router-версіонування з реекспортом ```js // routes/v1/users.js const router = require('express').Router(); router.get('/', (req, res) => res.json([{ name: 'John Doe' }])); module.exports = router; ``` ```js // routes/v2/users.js const router = require('express').Router(); router.get('/', (req, res) => res.json([{ firstName: 'John', lastName: 'Doe' }])); module.exports = router; ``` ```js // routes/v2/index.js const router = require('express').Router(); router.use('/users', require('./users')); // оновлено у v2 router.use('/products', require('../v1/products')); // без змін, реекспорт v1 module.exports = router; ``` ```js // app.js app.use('/api/v1', require('./routes/v1')); app.use('/api/v2', require('./routes/v2')); ``` Реекспорт незмінених маршрутів v1 у v2 означає, що ніколи не підтримуєш дві копії одної логіки. Коли продукти зміняться у v3, форкаєш тільки цей один файл. ### Просунутий: Middleware деплікації з логуванням використання ```js const express = require('express'); const app = express(); // Додає заголовки деплікації і логує кожен запит до v1 function v1Deprecation(req, res, next) { res.set('Deprecation', 'true'); res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT'); res.set('Link', '</api/v2>; rel="successor-version"'); // Заміни console.warn на свій metrics-клієнт (наприклад лічильник Prometheus) console.warn({ event: 'v1_request', path: req.path, timestamp: new Date().toISOString() }); next(); } const v1Router = require('./routes/v1'); const v2Router = require('./routes/v2'); app.use('/api/v1', v1Deprecation, v1Router); app.use('/api/v2', v2Router); app.listen(3000); ``` Цей підхід дає видимість того, які ендпоінти v1 ще отримують трафік. Коли лічильник `v1_request` впаде до нуля, можна без ризику видаляти router. Команди, які пропускають цей крок, підтримують v1 вічно, бо ніхто не знає, чи хтось ще ним користується.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.