Номенклатура URL та CRUD-операції
Номенклатура URL та CRUD-операції
1. Семантика частин URL
Традиційно частинам URL приписується наступна семантика:
| Частина | Семантика | Приклад |
|---|---|---|
| Path | Строга ієрархія (вкладеність) | /v1/places/{id}/coffee-machines/{id} |
| Query | Нестрога ієрархія, параметри операції | /v1/recipes?partner_id=42 |
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
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 });
});
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 } }));
GET /orders/42?version=1
Допустимо, але менш читабельно в логах і моніторингу.
GET /orders/42 HTTP/1.1
X-CoffeeAPI-Version: 1
Допустимо, але невидимо в URL, ускладнює дебагінг.
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: дозволені чи ні?
Популярна думка
Реальність (з книги)
/prepare на /preparator додає візуального шуму суфіксами «-ator», «-er», але не покращує однозначність. Водночас, демонстративне ігнорування цього правила теж небажане.5. CRUD: Від мнемоніки до реальності
Акронім CRUD (Create, Read, Update, Delete) став надзвичайно популярним з розвитком HTTP API. Ідея проста: кожна операція над ресурсом «відповідає» HTTP-методу:
| CRUD | HTTP-метод | URL |
|---|---|---|
| Create | POST | /v1/orders |
| Read | GET | /v1/orders/{id} |
| Update | PUT / PATCH | /v1/orders/{id} |
| Delete | DELETE | /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);
});
Клієнт сам генерує ID (UUID). PUT за визначенням ідемпотентний:
PUT /v1/orders/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
{"recipe": "lungo", "coffee_machine_id": 42}
→
HTTP/1.1 201 Created
Повторний PUT з тим самим UUID — оновить ресурс тими самими даними. Дублікат не створюється.
app.MapPut("/v1/orders/{id}",
(Guid id, OrderRequest req) =>
{
if (db.OrderExists(id))
return Results.Ok(db.GetOrder(id));
var order = db.CreateOrder(id, req);
return Results.Created(
$"/v1/orders/{order.Id}", order);
});
Недолік: потрібно довіряти клієнту генерацію ID. Працює для UUID, але не для послідовних числових ID.
Найнадійніший підхід з книги. Розділяємо створення на два кроки:
Крок 1 — POST створює «легку» чернетку. Навіть якщо створяться дублікати чернеток — це не страшно, бо чернетки не є реальними замовленнями.
POST /v1/orders/drafts HTTP/1.1
{"recipe": "lungo", "coffee_machine_id": 42}
→
HTTP/1.1 201 Created
Location: /v1/orders/drafts/abc-123
Крок 2 — PUT підтверджує чернетку. PUT ідемпотентний — повторний виклик безпечний!
PUT /v1/orders/drafts/abc-123/commit HTTP/1.1
→
HTTP/1.1 201 Created
Location: /v1/orders/42
// Крок 1: чернетка (POST — неідемпотентний, але «легкий»)
app.MapPost("/v1/orders/drafts",
(OrderDraft draft) =>
{
var id = db.CreateDraft(draft);
return Results.Created(
$"/v1/orders/drafts/{id}",
new { id });
});
// Крок 2: підтвердження (PUT — ідемпотентний!)
app.MapPut("/v1/orders/drafts/{id}/commit",
(Guid id) =>
{
var draft = db.GetDraft(id);
if (draft is null)
return Results.NotFound();
// Вже підтверджена? Повертаємо замовлення
if (draft.CommittedOrderId is not null)
return Results.Ok(
db.GetOrder(draft.CommittedOrderId.Value));
var order = db.CommitDraft(id);
return Results.Created(
$"/v1/orders/{order.Id}", order);
});
Перевага: невикористані чернетки очищуються автоматично (TTL). Реальне замовлення — завжди одне.
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);
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 ідемпотентний. Кожен субресурс — незалежний. Недолік: багато ендпоінтів для великих сутностей.
PATCH дозволяє передати тільки змінені поля:
PATCH /v1/orders/42 HTTP/1.1
Content-Type: application/json
{"recipe": "americano"}
→
HTTP/1.1 200 OK
{"id": 42, "recipe": "americano", "volume": "350ml", ...}
app.MapPatch("/v1/orders/{id}",
(int id, JsonElement patch) =>
{
var order = db.GetOrder(id);
if (order is null) return Results.NotFound();
// Застосовуємо тільки передані поля
if (patch.TryGetProperty("recipe", out var recipe))
order.Recipe = recipe.GetString()!;
if (patch.TryGetProperty("volume", out var volume))
order.Volume = volume.GetString()!;
db.SaveOrder(order);
return Results.Ok(order);
});
Увага: PATCH за замовчуванням неідемпотентний і може залежати від порядку операцій. Фреймворки HTTP-клієнтів зазвичай не повторюють PATCH автоматично при таймаутах.
Найбезпечніший підхід для складного редагування:
// Крок 1: створити чернетку зі списком змін
app.MapPost("/v1/orders/{id}/drafts",
(int id, ChangesDraft changes) =>
{
var order = db.GetOrder(id);
if (order is null) return Results.NotFound();
var draftId = db.CreateChangesDraft(id, changes);
return Results.Created(
$"/v1/orders/{id}/drafts/{draftId}",
new { id = draftId, changes });
});
// Крок 2: підтвердити чернетку (ідемпотентно!)
app.MapPut("/v1/orders/{id}/drafts/{draftId}/commit",
(int id, Guid draftId) =>
{
var draft = db.GetChangesDraft(draftId);
if (draft is null) return Results.NotFound();
// Вже підтверджена? Повертаємо результат
if (draft.IsCommitted)
return Results.Ok(db.GetOrder(id));
var order = db.CommitChanges(id, draftId);
return Results.Ok(order);
});
Перевага: 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 | Призначення |
|---|---|---|---|
| 1 | GET | /v1/orders?user_id=… | Список замовлень з фільтрами |
| 2 | POST | /v1/orders/drafts?user_id=… | Створити чернетку |
| 3 | GET | /v1/orders/drafts?user_id=… | Список чернеток + ревізія |
| 4 | PUT | /v1/orders/drafts/{id}/commit | Підтвердити чернетку |
| 5 | GET | /v1/orders/{id} | Отримати замовлення |
| 6 | POST | /v1/orders/{id}/drafts | Чернетка списку змін |
| 7 | PUT | /v1/orders/{id}/drafts/{id}/commit | Підтвердити зміни |
| 8 | GET/POST | /v1/orders/search?user_id=… | Пошук з фільтрами |
| 9 | PUT | /v1/orders/{id}/archive | Архівувати замовлення |
| 10 | POST | /v1/orders/{id}/cancel | Скасувати замовлення |
7. Практичні завдання
Рівень 1: Базовий
Для API інтернет-магазину визначте URL для кожної операції. Сутності: магазини, категорії, товари, відгуки.
Вимоги:
- Товар належить магазину ТА категорії
- Відгук належить товару
- Пошук товарів за ціною та рейтингом
- Визначте: де path, а де query?
Напишіть Minimal API маршрути.
Реалізуйте повний CRUD для ресурсу Product у Minimal API:
POST /v1/products— створенняGET /v1/products/{id}— читанняPUT /v1/products/{id}— повна замінаPATCH /v1/products/{id}— часткове оновленняPUT /v1/products/{id}/archive— архівація
Використайте Dictionary<int, Product> як сховище.
Рівень 2: Проєктування
Виберіть предметну область (бібліотека, ресторан, спортзал) і:
- Спроєктуйте «наївний» CRUD (2 URL, 4 методи)
- Додайте вимоги: ідемпотентне створення, пошук, архівація, часткове оновлення
- Розпишіть фінальну номенклатуру з 8-10 URL
- Реалізуйте ключові ендпоінти на Minimal API
8. Резюме
Path vs Query
/v1/...).CRUD — лише старт
Чернетка + підтвердження
Архівація ≠ видалення
Далі: у наступній статті ми розберемо правила дизайну: іменування та стандарти — конвенції для іменування ресурсів, правила запису параметрів, і як забезпечити консистентність API.
Організація HTTP API за принципами REST
Stateless-дизайн, декомпозиція на мікросервіси, кешування через ETag, умовні запити (If-None-Match, If-Match), JWT-авторизація та оптимістичне керування паралелізмом.
Правила дизайну: іменування та стандарти
Принципи дизайну API: явне краще за неявне, конкретні імена, стандарти дат (ISO 8601), валют, дробових чисел, консистентність інтерфейсів та підходи до іменування.