API

Організація HTTP API за принципами REST

Stateless-дизайн, декомпозиція на мікросервіси, кешування через ETag, умовні запити (If-None-Match, If-Match), JWT-авторизація та оптимістичне керування паралелізмом.

Організація HTTP API за принципами REST

У попередніх статтях ми вивчили складові HTTP-запитів. Тепер переходимо до головного: як правильно організувати 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 — не ресурс, а «зроби мені все»

Декомпозиція на мікросервіси

З ростом навантаження ми декомпозуємо монолітний бекенд на мікросервіси:

Loading diagram...
graph TD
    Client["📱 Клієнт"] --> D["Gateway D"]
    D --> A["Сервіс A<br/>Авторизація"]
    D --> B["Сервіс B<br/>Профілі"]
    D --> C["Сервіс C<br/>Замовлення"]

    A -.->|"user_id"| D

    style Client fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style A fill:#10b981,stroke:#059669,color:#ffffff
    style B fill:#10b981,stroke:#059669,color:#ffffff
    style C fill:#10b981,stroke:#059669,color:#ffffff

Але наївний підхід також має проблему: сервіси B і C повинні перевіряти токен через сервіс A, створюючи зайве навантаження.

Stateless-рішення: передача user_id

Замість того, щоб кожен сервіс самостійно розшифровував токен, гейтвей D робить це один раз і передає user_id далі:

Gateway D — stateless перенаправлення
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 — профілі (stateless)
// Сервісу 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 — замовлення (stateless)
// Сервісу C теж НЕ потрібен сервіс авторизації!
app.MapGet("/v1/orders", (int user_id) =>
{
    var orders = db.GetOrdersByUser(user_id);
    return Results.Ok(orders);
});
Принцип stateless (з книги Константинова): Мікросервіси розробляються так, щоб мати чітко окреслену зону відповідальності і не зберігати дані, що належать іншим рівням абстракції. «Зовнішні» дані — це лише ідентифікатори контекстів.

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"}
ETag у Minimal API
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();
Loading diagram...
sequenceDiagram
    participant C as 📱 Клієнт
    participant G as Gateway
    participant S as Сервіс B

    C->>G: GET /v1/profiles/42
    G->>S: GET /v1/profiles/42
    S-->>G: 200 OK, ETag: "v1"
    G-->>C: 200 OK (зберігає в кеш)

    Note over C,G: Повторний запит

    C->>G: GET /v1/profiles/42
    G->>S: GET /v1/profiles/42<br/>If-None-Match: "v1"
    S-->>G: 304 Not Modified
    G-->>C: 200 OK (з кешу)

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»):

  1. 📱 Олексій відкриває замовлення — бачить 2 товари
  2. 📱 Марія відкриває те саме замовлення — бачить ті ж 2 товари
  3. 📱 Олексій додає ☕ латте → відправляє запит із 3 товарами
  4. 📱 Марія додає 🧁 маффін → відправляє запит із 3 товарами (своїми!)
  5. Результат: в замовленні 3 товари (Маріні), латте Олексія — втрачено назавжди

Це називається Lost Update — одне з найпідступніших конкурентних порушень, бо жодна зі сторін не отримала помилку.

Рішення: оптимістичне блокування через ETag

Оптимістичне блокування (на відміну від песимістичного) не «замикає» ресурс для інших клієнтів. Натомість воно перевіряє в момент запису: «Чи ніхто не змінив ресурс з того часу, як я його прочитав?»

Механізм простий:

  1. Клієнт читає ресурс → отримує ETag (ревізію)
  2. Клієнт змінює ресурс → надсилає If-Match: <та сама ревізія>
  3. Сервер порівнює: якщо ревізія збігається → виконує операцію. Якщо ні → 412 Precondition Failed

Діаграма: як це працює

Loading diagram...
sequenceDiagram
    participant A as 📱 Олексій
    participant S as Сервер
    participant B as 📱 Марія

    A->>S: GET /v1/orders?user_id=42
    S-->>A: 200 OK, ETag: "rev1"

    B->>S: GET /v1/orders?user_id=42
    S-->>B: 200 OK, ETag: "rev1"

    Note over A,B: Обидва бачать однакову версію

    A->>S: POST /v1/orders (додає латте)<br/>If-Match: "rev1"
    S-->>A: 201 Created<br/>ETag: "rev2" ✅

    Note over S: Ревізія тепер "rev2"

    B->>S: POST /v1/orders (додає маффін)<br/>If-Match: "rev1"
    S-->>B: 412 Precondition Failed ❌
    Note over B: Ревізія "rev1" застаріла!<br/>Потрібно перечитати дані

Олексій встиг першим — його зміна прийнята, ревізія стала "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 → все ламається

Подвійний user_id — не дублювання

Поширена помилка: якщо user_id вже є в JWT-токені, навіщо передавати його ще й в URL?Відповідь з книги: Це не дублювання. Ці ідентифікатори мають різний зміст:
  • user_id в URL → над яким ресурсом виконується операція
  • user_id в токені → хто виконує операцію
Збіг — лише частковий випадок. Директор компанії може переглядати замовлення своїх співробітників:
JWT-авторизація у Minimal API
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();
});
Loading diagram...
graph LR
    subgraph "Stateful (старий підхід)"
        C1["Клієнт"] -->|"token"| G1["Gateway"]
        G1 -->|"token"| A1["Auth Service"]
        A1 -->|"user_id"| G1
        G1 -->|"user_id"| S1["Сервіс"]
    end

    subgraph "Stateless (JWT)"
        C2["Клієнт"] -->|"JWT"| S2["Сервіс"]
        S2 -->|"перевірка підпису<br/>локально"| S2
    end

    style A1 fill:#ef4444,stroke:#dc2626,color:#ffffff
    style C1 fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style C2 fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style G1 fill:#f59e0b,stroke:#b45309,color:#ffffff
    style S1 fill:#10b981,stroke:#059669,color:#ffffff
    style S2 fill:#10b981,stroke:#059669,color:#ffffff

5. Підсумок: Повна відповідність REST

Після всіх перетворень ми отримали систему, яка повністю відповідає принципам REST:

Принцип RESTЯк реалізовано
StatelessКожен запит несе user_id та JWT — сервісу не потрібен зовнішній стан
КешуванняETag + Cache-Control для профілів; If-None-Match для умовних запитів
Уніфікований інтерфейсСтандартні HTTP-методи, URL ідентифікують ресурси
БагатошаровістьГейтвей можна прибрати — клієнт може звертатись до сервісів напряму
Ключовий висновок з книги Константинова: Після всіх перетворень ми можемо прибрати гейтвей D і покласти його функції безпосередньо на клієнта. Клієнт зберігає user_id (або витягує з JWT), робить два паралельних запити через HTTP/2 мультиплексування, і підтримує кеш через стандартні бібліотеки. З точки зору сервісів B і C наявність або відсутність гейтвея не впливає ні на що.

6. Практичні завдання

Рівень 1: Базовий

Рівень 2: Проєктування


7. Резюме

Stateless = масштабування

Запит повинен нести в собі всю інформацію для обробки. Це дозволяє додавати/прибирати проміжні шари без зміни сервісів.

ETag = розумне кешування

ETag + If-None-Match для кешування. ETag + If-Match для оптимістичного керування паралелізмом. Один механізм — два застосування.

JWT = stateless авторизація

Stateless-токени дозволяють кожному сервісу самостійно перевіряти права. user_id в URL і user_id в токені — це різні речі.

REST на практиці

Після правильної декомпозиції система відповідає REST: stateless, кешована, багатошарова, з уніфікованим інтерфейсом.

Далі: у наступній статті ми розберемо номенклатуру URL ресурсів та CRUD-операції — як правильно будувати ієрархію URL, коли використовувати path vs query, і як реалізувати ідемпотентне створення ресурсів.

Copyright © 2026