Middle: питання на співбесіді з теми «Проєктування API»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Клієнту API потрібно не лише знати, що пішло не так, а й обробити це програмно. Для цього помилки мають бути в одному передбачуваному форматі на весь API.
Problem Details (RFC 9457, замінив RFC 7807) - стандартний формат з типом application/problem+json:
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Недостатньо коштів",
"status": 422,
"detail": "На рахунку 30 грн, а списання - 50 грн.",
"instance": "/payments/8f3a",
"balance": 3000
}
type- URI типу помилки, стабільний ідентифікатор для програмної обробки (бажано - з документацією за цією адресою).title- короткий опис типу, однаковий для всіх його випадків.status- HTTP-код (дублює заголовок для зручності).detail- пояснення саме цього випадку.instance- ідентифікатор конкретного випадку.- Можна додавати власні поля - як
balanceвище чи список помилок валідації за полями.
Принципи, незалежно від формату:
- Правильний HTTP-код - формат тіла його не замінює.
- Машиночитний код помилки (
typeчиcode), а не лише текст: текст змінюють і перекладають, а клієнти на нього не мають покладатися. - Помилки валідації за полями, щоб клієнт підсвітив поля форми.
- Жодних стек-трейсів, SQL і шляхів до файлів у відповіді на проді.
- ID запиту в тілі чи заголовку - щоб знайти помилку в логах за зверненням клієнта.
Докладніше в документації: RFC 9457: Problem Details for HTTP APIs
Основні правила:
- Іменники, а не дієслова: дія задається HTTP-методом.
GET /orders,POST /orders,DELETE /orders/42- а не/getOrders,/createOrder. - Множина для колекцій:
/ordersі/orders/42- узгоджено для всього API. - Вкладеність для відношень, але неглибока:
/users/42/orders- так;/users/42/orders/7/items/3/reviews- ні. Ресурс з власним ID краще адресувати напряму:/order-items/3. - Нижній регістр і дефіси:
/payment-methods, а не/paymentMethodsчи/payment_methods. - Фільтрація, сортування, пагінація - у query-параметрах:
/orders?status=paid&sort=-created_at&page[size]=20. - Стабільні ідентифікатори в URL: ID чи UUID, а не назви, які можуть змінитися.
Дії, що не вкладаються в CRUD:
- як під-ресурс стану:
POST /orders/42/cancellation; - як дія-під-ресурс:
POST /orders/42/cancel- прагматично й зрозуміло, і так роблять великі API (Stripe, GitHub); - головне - узгодженість у межах API.
Відповіді:
- Узгоджене іменування полів (
snake_caseчиcamelCase- одне на весь API). - Дати - в ISO 8601 з часовим поясом.
- Гроші - цілими числами в мінімальних одиницях або рядками, з валютою.
- Посилання на пов'язані ресурси чи їхні ID, а не дублювання цілих об'єктів без потреби.
Найважливіше - передбачуваність: розробник, побачивши два ендпоінти, має вгадати третій. Опис в OpenAPI допомагає тримати цю узгодженість і генерувати клієнти.
Вкладений ресурс відображає відношення «належить до» в URL:
GET /posts/42/comments # коментарі конкретного поста
POST /posts/42/comments # додати коментар до поста
Коли вкладеність доречна:
- дочірній ресурс не має сенсу без батька (коментар без поста, позиція без замовлення);
- створення - URL одразу задає батька, і його не треба передавати в тілі;
- колекція в контексті: «замовлення цього клієнта» - природний запит.
Де зупинитися:
1. Не глибше одного рівня. /users/7/orders/42/items/3/discounts/1 - важко читати, і всі проміжні ідентифікатори доводиться знати й перевіряти. Якщо в дочірнього ресурсу є власний унікальний id, на ньому можна працювати напряму:
GET /posts/42/comments # колекція - вкладена
GET /comments/15 # окремий коментар - плаский
PATCH /comments/15
DELETE /comments/15
Це неглибока вкладеність (shallow nesting): вкладені лише ті маршрути, де батько потрібен (колекція й створення). У Laravel - Route::resource('posts.comments', CommentController::class)->shallow().
2. Не для фільтрації за довільними полями. /users/7/orders - добре, але /status/paid/orders - ні: це фільтр, а не ієрархія, - GET /orders?status=paid.
3. Не для зв'язків «багато-до-багатьох» без явної власності: /tags/5/posts і /posts/42/tags - обидва варіанти доречні як точки входу, але сам зв'язок - окремий ресурс.
Безпека - головна пастка вкладених URL:
GET /posts/42/comments/15
Перевірити треба не лише, що користувач має доступ до поста 42, а й що коментар 15 належить посту 42. Інакше зловмисник підставить пост, до якого має доступ, і чужий коментар. У Laravel для цього - scopeBindings():
Route::get('/posts/{post}/comments/{comment}', ...)->scopeBindings();
Тоді коментар шукається через $post->comments(), і чужий дасть 404.
Консистентність: якщо вже обрано схему (вкладені колекції + плаский доступ до елементів), - дотримуватися її для всіх ресурсів API.
Реальні API мають операції, які погано виражаються через «створити, прочитати, оновити, видалити»: скасувати замовлення, опублікувати статтю, надіслати рахунок, перерахувати знижку.
Варіант 1 - зміна стану через PATCH:
PATCH /orders/42
{"status": "cancelled"}
Підходить, коли дія справді лише змінює поле. Проблеми починаються, коли:
- переходу потрібні додаткові дані (причина скасування, сума повернення);
- переход має побічні ефекти (повернення коштів, листи, звільнення товару на складі);
- дозволені не всі переходи (не можна скасувати відправлене замовлення).
Тоді PATCH з полем status ховає важливу бізнес-операцію за «оновленням поля», і валідація переходів розмазується.
Варіант 2 - дія як підресурс (найпоширеніший):
POST /orders/42/cancel {"reason": "Помилка в адресі"}
POST /articles/7/publish
POST /invoices/15/send
Дієслово в URL - свідомий виняток із «лише іменників»: операція явна, має власні параметри, права й журнал аудиту.
Варіант 3 - дія як ресурс-іменник:
POST /orders/42/cancellations # створити «скасування»
POST /refunds {"order_id": 42, "amount": 1000}
Корисно, коли у дії є власний життєвий цикл (повернення коштів може бути в обробці, відхиленим, завершеним) і її треба переглядати потім.
Google API (AIP-136) використовує синтаксис з двокрапкою: POST /orders/42:cancel. Він чітко відділяє дію від ієрархії ресурсів, але в Laravel-проєктах частіше бачать варіант 2.
Правила для власних дій:
- метод
POST- дія неідемпотентна чи має побічні ефекти. Для ідемпотентних (повторне «опублікувати» нічого не змінює) варто це задокументувати; - відповідь - оновлений ресурс (
200з тілом) або202 Accepted, якщо дія асинхронна; - недопустимий перехід -
409 Conflict(замовлення вже відправлено) з поясненням у тілі; - права - окремо для кожної дії (
can:cancel,order), а не загальне «можна оновлювати».
Головне - послідовність: один стиль для всіх дій в API.
Варіант 1 - multipart/form-data через сервер API:
POST /api/documents
Content-Type: multipart/form-data; boundary=...
title=Договір
file=<бінарні дані>
$request->validate(['file' => ['required', 'file', 'mimes:pdf', 'max:10240']]);
$path = $request->file('file')->store('documents', 's3');
Просто, валідація й збереження в одному запиті. Але весь файл проходить через сервер застосунку: займає процес PHP на весь час завантаження, обмежений upload_max_filesize/post_max_size і тайм-аутами, а для великих файлів - ще й проксі (client_max_body_size у Nginx).
Варіант 2 - підписаний URL і пряме завантаження в сховище (S3, R2):
- клієнт просить дозвіл:
POST /api/uploads {"filename": "contract.pdf", "size": 52428800}; - сервер перевіряє права й параметри і повертає тимчасовий підписаний URL:
['url' => $url, 'headers' => $headers] = Storage::disk('s3')
->temporaryUploadUrl("uploads/{$id}.pdf", now()->plus(minutes: 5));
- клієнт завантажує файл напряму в сховище (
PUT $url); - клієнт повідомляє API:
POST /api/documents {"upload_id": "..."}- сервер перевіряє, що файл справді з'явився, його розмір і тип, і створює запис.
Переваги прямого завантаження: сервер застосунку не тримає з'єднання, немає лімітів PHP, сховище масштабується саме, можна завантажувати частинами (multipart upload S3) з продовженням після збою.
Що обов'язково для безпеки:
- короткий термін дії URL і фіксований шлях - клієнт не обирає, куди писати;
- перевірка після завантаження: розмір і тип - не довіряти тому, що клієнт заявив у кроці 1. Підозрілі файли - в карантин чи на антивірусну перевірку;
- очищення завантажених, але не підтверджених файлів (правило життєвого циклу бакета);
- приватний бакет і видача файлів теж через підписані URL.
Варіант 3 - base64 у JSON: зручно для дуже малих файлів (аватар-мініатюра), але +33% до розміру й весь файл у пам'яті - для решти погано.
Як обрати: невеликі файли (до кількох мегабайтів) - multipart; великі, багато одночасних завантажень, мобільні клієнти з нестабільною мережею - пряме завантаження.
Докладніше в документації: Laravel: тимчасові URL для завантаження