API

Номенклатура URL та CRUD-операції

Правила побудови URL ресурсів: path vs query, версіонування, кросдоменні операції. CRUD — Create, Read, Update, Delete — та чому в реальності потрібно 8-10 ендпоінтів замість чотирьох.

Номенклатура URL та CRUD-операції

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

1. Семантика частин URL

Традиційно частинам URL приписується наступна семантика:

ЧастинаСемантикаПриклад
PathСтрога ієрархія (вкладеність)/v1/places/{id}/coffee-machines/{id}
QueryНестрога ієрархія, параметри операції/v1/recipes?partner_id=42

Path: ієрархія «належить»

Частини path описують строгу підпорядкованість — одна сутність належить іншій:

Path — строга ієрархія
// Кавова машина НАЛЕЖИТЬ конкретному закладу
app.MapGet(
    "/v1/places/{placeId}/coffee-machines/{machineId}",
    (int placeId, int machineId) =>
{
    var machine = db.GetMachine(placeId, machineId);
    return machine is not null
        ? Results.Ok(machine)
        : Results.NotFound();
});

// Шлях може нарощуватися: вкладені ресурси
// Вкладення на кожному рівні — чітке підпорядкування
app.MapGet(
    "/v1/places/{placeId}/coffee-machines/{machineId}/attachments/{attachmentId}",
    (int placeId, int machineId, int attachmentId) =>
    Results.Ok(new { placeId, machineId, attachmentId }));

Query: фільтри та «багато-до-багатьох»

Query використовується для зв'язків «багато-до-багатьох» та параметрів операцій:

Query — нестрога ієрархія
// Рецепт може належати КІЛЬКОМ партнерам
// → нестрога ієрархія → query
app.MapGet("/v1/recipes", (int? partner_id) =>
{
    var recipes = partner_id.HasValue
        ? db.GetRecipesByPartner(partner_id.Value)
        : db.GetAllRecipes();
    return Results.Ok(recipes);
});

// Параметри пошуку — теж query
app.MapGet("/v1/search",
    (string? recipe, double? lat, double? lng) =>
{
    return Results.Ok(new { recipe, lat, lng });
});
З книги Константинова: Якщо є сумніви, чи ієрархія залишиться незмінною в ході розвитку API, краще завести новий верхньорівневий префікс, а не вкладати нові сутності у вже існуючі.

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

Де правильно вказати версію API? Книга розглядає три варіанти:

GET /v1/orders/42
GET /v2/orders/42

Чому: Усі інші способи мають сенс лише якщо номенклатура URL при зміні версії залишається тією самою. Але якщо URL можна зберегти — немає потреби порушувати зворотну сумісність.

app.MapGet("/v1/orders/{id}", (int id) =>
    Results.Ok(new { id, format = "v1" }));

app.MapGet("/v2/orders/{id}", (int id) =>
    Results.Ok(new { id, format = "v2",
        metadata = new { created_at = DateTime.UtcNow } }));

3. Кросдоменні операції

Як побудувати URL для операції, що зачіпає сутності різних рівнів абстракції? Наприклад: «запустити приготування лунго на конкретній кавовій машині».

Можливі варіанти:

/coffee-machines/{id}/recipes/lungo/prepare
/recipes/lungo/coffee-machines/{id}/prepare
/coffee-machines/{id}/prepare?recipe=lungo
/recipes/lungo/prepare?coffee_machine_id={id}
/prepare?coffee_machine_id={id}&recipe=lungo

Усі вони семантично допустимі і рівноправні.

Рекомендація з книги: Для кросдоменних операцій краще завести окремий ресурс, що виконує операцію. Автор книги обрав би /prepare?coffee_machine_id={id}&recipe=lungo.
Кросдоменна операція
// Окремий ресурс для операції приготування
app.MapPost("/v1/prepare",
    (PrepareRequest req) =>
{
    // coffee_machine_id і recipe —
    // сутності різних рівнів абстракції
    var result = coffeeService.Prepare(
        req.CoffeeMachineId, req.Recipe);

    return Results.Created(
        $"/v1/orders/{result.OrderId}", result);
});

record PrepareRequest(
    int CoffeeMachineId,
    string Recipe);

4. Дієслова в URL: дозволені чи ні?

Популярна думка

«URL повинні містити лише іменники. Дієслова заборонені, бо дієслово вже є — це HTTP-метод.»

Реальність (з книги)

Це самопроголошене правило, яке не є стандартом. Заміна /prepare на /preparator додає візуального шуму суфіксами «-ator», «-er», але не покращує однозначність. Водночас, демонстративне ігнорування цього правила теж небажане.

5. CRUD: Від мнемоніки до реальності

Акронім CRUD (Create, Read, Update, Delete) став надзвичайно популярним з розвитком HTTP API. Ідея проста: кожна операція над ресурсом «відповідає» HTTP-методу:

CRUDHTTP-методURL
CreatePOST/v1/orders
ReadGET/v1/orders/{id}
UpdatePUT / PATCH/v1/orders/{id}
DeleteDELETE/v1/orders/{id}

Це зручна стартова мнемоніка — «2 URL, 4 методи». Але книга Константинова попереджає: у складних предметних областях «зрізання кутів» і дотримання мнемонічних правил рідко працює. Нижче — покрокова демонстрація, як кожна CRUD-операція ускладнюється в реальному проєкті.


5.1. Create — Створення ресурсу

Наївний підхід

POST /v1/orders HTTP/1.1
Content-Type: application/json

{"recipe": "lungo", "coffee_machine_id": 42}
HTTP/1.1 201 Created
Location: /v1/orders/42

Все виглядає просто — один POST, один 201. Але що станеться, якщо мережа обірветься після того, як сервер створив замовлення, але до того, як клієнт отримав відповідь?

Проблема: втрата відповіді

📱 Клієнт → POST /v1/orders → Сервер (створив #42)
📱 Клієнт ← ??? ← 🌐 ОБРИВ! ← Сервер (відповідь втрачена)

Клієнт не знає, чи було створено замовлення. Якщо він повторить POST — створиться дублікат (#43). Якщо не повторить — замовлення може бути «загублене».

Рішення: ідемпотентне створення

Три підходи від найпростішого до найнадійнішого:

Клієнт спочатку отримує ETag (ревізію) поточного стану, а потім передає його при створенні:

GET /v1/orders?user_id=42 HTTP/1.1
HTTP/1.1 200 OK
ETag: "rev1"
POST /v1/orders?user_id=42 HTTP/1.1
If-Match: "rev1"

{"recipe": "lungo"}
HTTP/1.1 201 Created
ETag: "rev2"

Якщо клієнт повторить запит з If-Match: "rev1" — сервер поверне 412 Precondition Failed, бо ревізія вже "rev2".

app.MapPost("/v1/orders",
    (OrderRequest req, HttpContext ctx) =>
{
    var ifMatch = ctx.Request.Headers
        .IfMatch.FirstOrDefault();

    var currentRevision = db.GetOrdersRevision(
        req.UserId);
    if (ifMatch is not null
        && ifMatch != $"\"{currentRevision}\"")
    {
        // Ревізія застаріла — дублікат не створюється
        return Results.StatusCode(412);
    }

    var order = db.CreateOrder(req);
    return Results.Created(
        $"/v1/orders/{order.Id}", order);
});

5.2. Read — Читання, пошук, пагінація

«Простий» GET швидко перетворюється на родину ендпоінтів.

Еволюція Read

Отримати один ресурс

Найпростіше — отримати ресурс за ID:

app.MapGet("/v1/orders/{id}", (int id) =>
{
    var order = db.GetOrder(id);
    return order is not null
        ? Results.Ok(order)
        : Results.NotFound();
});

Список ресурсів

Користувач хоче бачити всі свої замовлення:

app.MapGet("/v1/orders", (int user_id) =>
    Results.Ok(db.GetOrders(user_id)));

⚠️ Проблема: якщо замовлень 10 000 — відповідь займе мегабайти. Потрібна пагінація!

Пагінація

app.MapGet("/v1/orders",
    (int user_id, string? cursor, int? limit) =>
{
    // Обмежуємо максимум — без цього клієнт
    // може запросити мільйон записів!
    var safeLimit = Math.Clamp(limit ?? 20, 1, 100);

    var result = db.GetOrdersPaged(
        user_id, cursor, safeLimit);
    return Results.Ok(new
    {
        items = result.Items,
        total = result.Total,
        next_cursor = result.NextCursor
    });
});

Фільтри через query

Користувач хоче бачити тільки замовлення лунго:

GET /v1/orders?user_id=42&recipe=lungo
app.MapGet("/v1/orders",
    (int user_id, string? recipe, string? status,
     string? cursor, int? limit) =>
{
    var safeLimit = Math.Clamp(limit ?? 20, 1, 100);
    return Results.Ok(
        db.SearchOrders(
            user_id, recipe, status, cursor, safeLimit));
});

Складний пошук — через POST

Якщо користувач хоче одночасно «і латте, і лунго, створені за останній тиждень, в радіусі 5 км» — query-параметрів не вистачить. Загальноприйнятого стандарту передачі складних структур через URL не існує.

// POST для складних фільтрів
app.MapPost("/v1/orders/search", (SearchRequest req) =>
{
    // POST, бо фільтри — складний JSON-об'єкт
    return Results.Ok(db.Search(req));
});

record SearchRequest(
    int UserId,
    string[]? Recipes,       // ["latte", "lungo"]
    DateOnly? CreatedAfter,
    GeoPoint? NearLocation,
    double? RadiusKm,
    string? Cursor,
    int Limit = 20);
З книги Константинова: Загальноприйнятого стандарту передачі в URL складніших структур, ніж пари ключ-значення, не існує. Тому довольно скоро поряд з доступом за ID знадобиться и пошуковий ендпоінт з складною семантикою, яку набагато зручніше розмістити за POST.

5.3. Update — Часткове оновлення

Повна заміна ресурсу через PUT здається простою. Але вона швидко наштовхується на проблеми:

ПроблемаПриклад
Обчислювані поляtotal_price рахується сервером. Що робити, якщо клієнт передасть інше значення?
Незмінні поляcreated_at не можна змінювати. Клієнт повинен передавати його в PUT?
Великий обсягЗамовлення з 50 полями — без змін у 49 з них?
Спільне редагуванняДва клієнти одночасно редагують різні поля — хто переможе?

Три підходи від книги:

Замість одного PUT /v1/orders/{id} — окремий ресурс для кожного аспекту:

// Змінити адресу доставки
app.MapPut("/v1/orders/{id}/address",
    (int id, AddressUpdate addr) =>
{
    var order = db.GetOrder(id);
    if (order is null) return Results.NotFound();

    db.UpdateOrderAddress(id, addr);
    return Results.Ok(new { id, address = addr });
});

// Змінити об'єм
app.MapPut("/v1/orders/{id}/volume",
    (int id, VolumeUpdate vol) =>
{
    var order = db.GetOrder(id);
    if (order is null) return Results.NotFound();

    db.UpdateOrderVolume(id, vol);
    return Results.Ok(new { id, volume = vol });
});

Перевага: кожен PUT ідемпотентний. Кожен субресурс — незалежний. Недолік: багато ендпоінтів для великих сутностей.


5.4. Delete — Архівація замість видалення

З книги Константинова: В сучасних сервісах жодні дані не видаляються миттєво — лише архівуються або позначаються видаленими. DELETE — деструктивна операція. У продакшені замість неї використовують архівацію.

Чому DELETE — погана ідея

ПроблемаНаслідок
Фінансова звітністьВидалене замовлення зникає з бухгалтерії
АналітикаДані для ML та статистики втрачені
Юридичні вимогиЗакони можуть вимагати зберігання даних N років
Помилка оператораВипадкове видалення без можливості відновлення
Зовнішні посиланняІнші сервіси посилаються на видалений ресурс → помилки

Архівація — безпечна альтернатива

Архівація замість видалення
// ❌ Справжнє видалення — майже ніколи не потрібне
app.MapDelete("/v1/orders/{id}", (int id) =>
{
    db.HardDelete(id); // Дані втрачені НАЗАВЖДИ!
    return Results.NoContent();
});

// ✅ Архівація — безпечний і зворотній підхід
app.MapPut("/v1/orders/{id}/archive", (int id) =>
{
    var order = db.GetOrder(id);
    if (order is null) return Results.NotFound();

    db.ArchiveOrder(id);
    return Results.Ok(new
    {
        id,
        status = "archived",
        archived_at = DateTime.UtcNow.ToString("o"),
        can_restore = true
    });
});

// Відновлення з архіву
app.MapPut("/v1/orders/{id}/restore", (int id) =>
{
    var order = db.GetArchivedOrder(id);
    if (order is null) return Results.NotFound();

    db.RestoreOrder(id);
    return Results.Ok(new { id, status = "active" });
});

Чому PUT, а не POST? Бо архівація — ідемпотентна операція. Повторний виклик PUT /v1/orders/42/archive не змінить стан — замовлення вже в архіві. GET /v1/orders/42 після архівації може повертати 410 Gone (видалено назавжди) або 200 OK з полем status: "archived" (залежить від вашої бізнес-логіки).


5.5. Медіаресурси (вкладення)

Якщо до замовлення можна прикласти медіадані (фотографії, документи), для них потрібні окремі ендпоінти:

// Завантажити вкладення до замовлення
app.MapPost("/v1/orders/{orderId}/attachments",
    async (int orderId, IFormFile file) =>
{
    var attachmentId = await storage.Upload(
        orderId, file);
    return Results.Created(
        $"/v1/orders/{orderId}/attachments/{attachmentId}",
        new { id = attachmentId, filename = file.FileName });
});

// Отримати конкретне вкладення
app.MapGet(
    "/v1/orders/{orderId}/attachments/{attachmentId}",
    async (int orderId, int attachmentId) =>
{
    var file = await storage.Get(orderId, attachmentId);
    return file is not null
        ? Results.File(file.Stream, file.ContentType)
        : Results.NotFound();
});

// Видалити вкладення
app.MapDelete(
    "/v1/orders/{orderId}/attachments/{attachmentId}",
    async (int orderId, int attachmentId) =>
{
    await storage.Delete(orderId, attachmentId);
    return Results.NoContent();
});

6. CRUD у реальному житті: від 2 URL до 10

Ми почали з ідеї «2 URL, 4 методи»:

POST   /v1/orders          → створити
GET    /v1/orders/{id}     → прочитати
PUT    /v1/orders/{id}     → оновити
DELETE /v1/orders/{id}     → видалити

Але з вимогами реального API ми швидко дійшли до 8-10 URL:

#МетодURLПризначення
1GET/v1/orders?user_id=…Список замовлень з фільтрами
2POST/v1/orders/drafts?user_id=…Створити чернетку
3GET/v1/orders/drafts?user_id=…Список чернеток + ревізія
4PUT/v1/orders/drafts/{id}/commitПідтвердити чернетку
5GET/v1/orders/{id}Отримати замовлення
6POST/v1/orders/{id}/draftsЧернетка списку змін
7PUT/v1/orders/{id}/drafts/{id}/commitПідтвердити зміни
8GET/POST/v1/orders/search?user_id=…Пошук з фільтрами
9PUT/v1/orders/{id}/archiveАрхівувати замовлення
10POST/v1/orders/{id}/cancelСкасувати замовлення
Висновок з книги: CRUD-мнемоніка дає лише стартовий набір гіпотез. Будь-яка конкретна предметна область вимагає вдумливого підходу. Якщо перед вами завдання розробити «універсальний» інтерфейс — одразу починайте з номенклатури в 8-10 методів.

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

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

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


8. Резюме

Path vs Query

Path для строгої ієрархії «належить». Query для нестрогих зв'язків і параметрів операцій. Версія API — в path (/v1/...).

CRUD — лише старт

4 операції швидко перетворюються на 8-10 ендпоінтів. Ідемпотентне створення, пошук, архівація, часткове оновлення — обов'язкові у продакшені.

Чернетка + підтвердження

POST (неідемпотентний) для «легкої» чернетки, PUT (ідемпотентний) для підтвердження — найкращий підхід для створення та редагування.

Архівація ≠ видалення

Дані не видаляються — архівуються. PUT /resource/{id}/archive замість DELETE /resource/{id}.

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

Copyright © 2026