Організація HTTP API за принципами REST
Організація HTTP API за принципами REST
1. Від монолітного API до мікросервісів
Розглянемо типовий сценарій: мобільний додаток для замовлення кави. На старті додатку необхідно, використовуючи збережений токен, отримати профіль користувача та його поточні замовлення.
Монолітний підхід
var app = WebApplication.Create(args);
// Один ендпоінт повертає все
app.MapGet("/v1/state", async (HttpContext ctx) =>
{
// 1. Перевірити токен → отримати user_id
var token = ctx.Request.Headers.Authorization
.ToString();
var userId = ValidateToken(token); // stateful!
// 2. Отримати профіль
var profile = await GetProfile(userId);
// 3. Отримати замовлення
var orders = await GetOrders(userId);
return Results.Ok(new { profile, orders });
});
app.Run();
Цей API порушує одразу кілька принципів REST:
| Принцип | Проблема |
|---|---|
| Stateless | Сервер зберігає токени в пам'яті для визначення user_id |
| Кешування | Немає способу кешувати відповідь (замовлення часто змінюються, профіль — рідко) |
| Багатошаровість | Система однорівнева, додати проксі або кеш неможливо |
| Уніфікований інтерфейс | /v1/state — не ресурс, а «зроби мені все» |
Декомпозиція на мікросервіси
З ростом навантаження ми декомпозуємо монолітний бекенд на мікросервіси:
Але наївний підхід також має проблему: сервіси B і C повинні перевіряти токен через сервіс A, створюючи зайве навантаження.
Stateless-рішення: передача user_id
Замість того, щоб кожен сервіс самостійно розшифровував токен, гейтвей D робить це один раз і передає user_id далі:
var app = WebApplication.Create(args);
app.MapGet("/v1/state", async (HttpContext ctx) =>
{
// 1. Гейтвей перевіряє токен ОДИН РАЗ
var token = ctx.Request.Headers.Authorization
.ToString();
var userId = await authService
.ValidateAsync(token);
// 2. Запити до мікросервісів з user_id
// як ЯВНИМ параметром (stateless!)
var profileTask = httpClient
.GetAsync($"/v1/profiles/{userId}");
var ordersTask = httpClient
.GetAsync($"/v1/orders?user_id={userId}");
await Task.WhenAll(profileTask, ordersTask);
var profile = await profileTask.Result.Content
.ReadFromJsonAsync<object>();
var orders = await ordersTask.Result.Content
.ReadFromJsonAsync<object>();
return Results.Ok(new { profile, orders });
});
app.Run();
Тепер сервіс B і C отримують запити, які несуть у собі всю необхідну інформацію:
// Сервісу B НЕ потрібен сервіс авторизації!
// user_id вже є в URL
app.MapGet("/v1/profiles/{userId}", (int userId) =>
{
var profile = db.GetProfile(userId);
return profile is not null
? Results.Ok(profile)
: Results.NotFound();
});
// Сервісу C теж НЕ потрібен сервіс авторизації!
app.MapGet("/v1/orders", (int user_id) =>
{
var orders = db.GetOrdersByUser(user_id);
return Results.Ok(orders);
});
2. Кешування через ETag
Профіль користувача змінюється рідко — його можна кешувати. Замовлення змінюються часто — але і для них є рішення через ETag (Entity Tag).
Що таке ETag
ETag — це заголовок відповіді, що містить «відбиток» (ревізію, хеш) поточного стану ресурсу. Клієнт або проміжний агент може використати його для умовних запитів.
Ревізія (revision) — це мітка версії ресурсу. Кожного разу, коли ресурс змінюється, ревізія отримує нове значення. Це може бути:
- Лічильник —
"rev1","rev2","rev3"(збільшується при кожній зміні) - Хеш —
"a3f8b2c1"(обчислюється від вмісту ресурсу) - Timestamp —
"2024-02-26T12:00:00Z"(час останньої зміни)
Суть одна: якщо ревізія збігається — дані не змінились. Якщо відрізняється — хтось модифікував ресурс.
Сценарій: кешування профілю
Перший запит — отримуємо ETag
GET /v1/profiles/42 HTTP/1.1
→
HTTP/1.1 200 OK
ETag: "v1-abc123"
Cache-Control: max-age=300
{"id": 42, "name": "Олексій", "email": "alex@test.com"}
Повторний запит — перевіряємо актуальність
GET /v1/profiles/42 HTTP/1.1
If-None-Match: "v1-abc123"
→
HTTP/1.1 304 Not Modified
Сервер повертає 304 без тіла — дані не змінились, використовуйте кеш!
Дані змінились — отримуємо нову версію
GET /v1/profiles/42 HTTP/1.1
If-None-Match: "v1-abc123"
→
HTTP/1.1 200 OK
ETag: "v1-def456"
{"id": 42, "name": "Олексій", "email": "new@test.com"}
var app = WebApplication.Create(args);
app.MapGet("/v1/profiles/{id}", (int id, HttpContext ctx) =>
{
var profile = db.GetProfile(id);
if (profile is null)
return Results.NotFound();
// Генеруємо ETag на основі версії даних
var etag = $"\"{profile.Version}\"";
// Перевіряємо умовний запит
var ifNoneMatch = ctx.Request.Headers
.IfNoneMatch.FirstOrDefault();
if (ifNoneMatch == etag)
{
// Дані не змінились — 304 без тіла
return Results.StatusCode(304);
}
// Повертаємо дані з ETag
ctx.Response.Headers.ETag = etag;
ctx.Response.Headers.CacheControl = "max-age=300";
return Results.Ok(profile);
});
app.Run();
3. Оптимістичне керування паралелізмом
ETag має ще одне критичне застосування — захист від конкурентних змін через заголовок If-Match. Якщо If-None-Match відповідає за кешування (розділ вище), то If-Match — за безпечне оновлення.
Два обличчя ETag
| Заголовок | Мета | Питання | Відповідь сервера |
|---|---|---|---|
If-None-Match | Кешування | «Чи змінились дані?» | 304 Not Modified — ні, використовуй кеш |
If-Match | Паралелізм | «Чи я працюю з актуальною версією?» | 412 Precondition Failed — ні, дані вже змінені |
Аналогія: спільний Google Docs
Уявіть, що ви і колега одночасно редагуєте один документ. В Google Docs це працює в реальному часі. Але HTTP API — це не реальний час, а «запит-відповідь». Що станеться, якщо двоє відправлять зміни одночасно?
Без захисту (песимістичний сценарій «Lost Update»):
- 📱 Олексій відкриває замовлення — бачить 2 товари
- 📱 Марія відкриває те саме замовлення — бачить ті ж 2 товари
- 📱 Олексій додає ☕ латте → відправляє запит із 3 товарами
- 📱 Марія додає 🧁 маффін → відправляє запит із 3 товарами (своїми!)
- ❌ Результат: в замовленні 3 товари (Маріні), латте Олексія — втрачено назавжди
Це називається Lost Update — одне з найпідступніших конкурентних порушень, бо жодна зі сторін не отримала помилку.
Рішення: оптимістичне блокування через ETag
Оптимістичне блокування (на відміну від песимістичного) не «замикає» ресурс для інших клієнтів. Натомість воно перевіряє в момент запису: «Чи ніхто не змінив ресурс з того часу, як я його прочитав?»
Механізм простий:
- Клієнт читає ресурс → отримує ETag (ревізію)
- Клієнт змінює ресурс → надсилає
If-Match: <та сама ревізія> - Сервер порівнює: якщо ревізія збігається → виконує операцію. Якщо ні →
412 Precondition Failed
Діаграма: як це працює
Олексій встиг першим — його зміна прийнята, ревізія стала "rev2". Коли Марія намагається відправити свої зміни з If-Match: "rev1" — сервер бачить, що ревізія застаріла, і повертає 412 Precondition Failed. Дані Олексія не втрачені.
Що робить клієнт після 412?
Алгоритм відновлення:
Клієнт отримує 412
Сервер відхилив запит — ревізія не актуальна.
Клієнт перечитує дані
GET /v1/orders?user_id=42 HTTP/1.1
→
HTTP/1.1 200 OK
ETag: "rev2"
[замовлення Олексія вже включене!]
Клієнт повторює свою зміну з новою ревізією
POST /v1/orders HTTP/1.1
If-Match: "rev2"
{"recipe": "muffin"}
→
HTTP/1.1 201 Created
ETag: "rev3" ✅
Тепер в замовленні є і латте, і маффін — нічого не втрачено.
Реалізація у Minimal API
app.MapPost("/v1/orders", (OrderRequest req, HttpContext ctx) =>
{
// 1. If-Match містить очікувану ревізію
var ifMatch = ctx.Request.Headers
.IfMatch.FirstOrDefault();
// 2. Отримуємо поточну ревізію зі сховища
var currentRevision = db.GetOrdersRevision(
req.UserId);
// 3. Перевіряємо: чи актуальна ревізія клієнта?
if (ifMatch is not null
&& ifMatch != $"\"{currentRevision}\"")
{
// 412 — ревізія застаріла, клієнт
// повинен перечитати дані і повторити
return Results.StatusCode(412);
}
// 4. Створюємо замовлення і оновлюємо ревізію
var order = db.CreateOrder(req);
var newRevision = db.GetOrdersRevision(req.UserId);
// 5. Повертаємо оновлений ETag
ctx.Response.Headers.ETag = $"\"{newRevision}\"";
ctx.Response.Headers["Content-Location"] =
$"/v1/orders?user_id={req.UserId}";
return Results.Created($"/v1/orders/{order.Id}",
order);
});
4. JWT: Stateless-авторизація
Ми досі використовуємо сервіс A для перевірки токенів — це залишок stateful-архітектури. Рішення — stateless-токени (JWT).
Stateful vs Stateless токени
Token: "a7f3bc91-session-id"
- Сервер повинен шукати токен у базі/кеші
- Потрібен окремий сервіс авторизації
- Вихід з ладу сервісу A → все ламається
Token: "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lk..."
↓ розшифрувавши:
{
"user_id": 42,
"user_ids": [42, 15], // доступ до ресурсів
"iat": 1708934400, // час створення
"exp": 1709020800 // час закінчення
}
- Кожен сервіс може сам перевірити підпис токена
- Не потрібен зовнішній сервіс
- Уся інформація — в самому токені
Подвійний user_id — не дублювання
user_id вже є в JWT-токені, навіщо передавати його ще й в URL?Відповідь з книги: Це не дублювання. Ці ідентифікатори мають різний зміст:user_idв URL → над яким ресурсом виконується операціяuser_idв токені → хто виконує операцію
app.MapGet("/v1/profiles/{userId}",
(int userId, HttpContext ctx) =>
{
// 1. Розшифрувати JWT (бібліотека/middleware)
var claims = ctx.User;
var tokenUserId = int.Parse(
claims.FindFirst("user_id")!.Value);
// 2. Отримати список дозволених user_id
var allowedIds = claims
.FindAll("allowed_user_id")
.Select(c => int.Parse(c.Value))
.ToList();
// 3. Перевірити: чи має право
// власник токена бачити цей профіль?
if (tokenUserId != userId
&& !allowedIds.Contains(userId))
{
// 403 — токен валідний, але
// доступу до цього ресурсу немає
return Results.Forbid();
}
var profile = db.GetProfile(userId);
return profile is not null
? Results.Ok(profile)
: Results.NotFound();
});
5. Підсумок: Повна відповідність REST
Після всіх перетворень ми отримали систему, яка повністю відповідає принципам REST:
| Принцип REST | Як реалізовано |
|---|---|
| Stateless | Кожен запит несе user_id та JWT — сервісу не потрібен зовнішній стан |
| Кешування | ETag + Cache-Control для профілів; If-None-Match для умовних запитів |
| Уніфікований інтерфейс | Стандартні HTTP-методи, URL ідентифікують ресурси |
| Багатошаровість | Гейтвей можна прибрати — клієнт може звертатись до сервісів напряму |
user_id (або витягує з JWT), робить два паралельних запити через HTTP/2 мультиплексування, і підтримує кеш через стандартні бібліотеки. З точки зору сервісів B і C наявність або відсутність гейтвея не впливає ні на що.6. Практичні завдання
Рівень 1: Базовий
Перетворіть наступний stateful API на stateless:
// Stateful: сервер зберігає сесії
app.MapPost("/api/login", (LoginRequest req) =>
{
var sessionId = Guid.NewGuid().ToString();
sessions[sessionId] = req.Email;
return Results.Ok(new { session_id = sessionId });
});
app.MapGet("/api/my-orders", (HttpContext ctx) =>
{
var sessionId = ctx.Request.Headers["X-Session-Id"];
var email = sessions[sessionId];
var orders = db.GetOrdersByEmail(email);
return Results.Ok(orders);
});
Вимоги:
- Видаліть серверне сховище сесій
- Використайте JWT або передачу user_id в URL
- Зробіть запит
/v1/ordersповністю stateless
Рівень 2: Проєктування
Реалізуйте Minimal API ендпоінт GET /v1/products/{id}, що підтримує:
- ETag у відповіді (використайте хеш даних)
- Умовний запит
If-None-Match→304 Not Modified - Заголовок
Cache-Control: max-age=600
Протестуйте: зробіть два запити — перший має повернути 200, другий з If-None-Match — 304.
Реалізуйте ендпоінт PUT /v1/products/{id}, що:
- Приймає
If-Matchз поточною ревізією - Повертає
412 Precondition Failed, якщо ревізія не збігається - Повертає
200 OKз новимETagпри успіху - Повертає
428 Precondition Required, якщоIf-Matchне передано
Змоделюйте ситуацію «lost update» з двома клієнтами.
7. Резюме
Stateless = масштабування
ETag = розумне кешування
JWT = stateless авторизація
REST на практиці
Далі: у наступній статті ми розберемо номенклатуру URL ресурсів та CRUD-операції — як правильно будувати ієрархію URL, коли використовувати path vs query, і як реалізувати ідемпотентне створення ресурсів.
HTTP-методи, статус-коди та заголовки
Складові HTTP-запитів: URL-адресація, HTTP-методи (GET, POST, PUT, DELETE, PATCH), їх семантика та ідемпотентність, заголовки, статус-коди 1xx–5xx та антипатерни.
Номенклатура URL та CRUD-операції
Правила побудови URL ресурсів: path vs query, версіонування, кросдоменні операції. CRUD — Create, Read, Update, Delete — та чому в реальності потрібно 8-10 ендпоінтів замість чотирьох.