Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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 допомагає тримати цю узгодженість і генерувати клієнти.

Докладніше в документації: REST

Вкладений ресурс відображає відношення «належить до» в 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.

Докладніше в документації: Laravel: вкладені ресурси

Реальні 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.

Докладніше в документації: Google AIP-136: власні методи

Варіант 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):

  1. клієнт просить дозвіл: POST /api/uploads {"filename": "contract.pdf", "size": 52428800};
  2. сервер перевіряє права й параметри і повертає тимчасовий підписаний URL:
['url' => $url, 'headers' => $headers] = Storage::disk('s3')
    ->temporaryUploadUrl("uploads/{$id}.pdf", now()->plus(minutes: 5));
  1. клієнт завантажує файл напряму в сховище (PUT $url);
  2. клієнт повідомляє API: POST /api/documents {"upload_id": "..."} - сервер перевіряє, що файл справді з'явився, його розмір і тип, і створює запис.

Переваги прямого завантаження: сервер застосунку не тримає з'єднання, немає лімітів PHP, сховище масштабується саме, можна завантажувати частинами (multipart upload S3) з продовженням після збою.

Що обов'язково для безпеки:

  • короткий термін дії URL і фіксований шлях - клієнт не обирає, куди писати;
  • перевірка після завантаження: розмір і тип - не довіряти тому, що клієнт заявив у кроці 1. Підозрілі файли - в карантин чи на антивірусну перевірку;
  • очищення завантажених, але не підтверджених файлів (правило життєвого циклу бакета);
  • приватний бакет і видача файлів теж через підписані URL.

Варіант 3 - base64 у JSON: зручно для дуже малих файлів (аватар-мініатюра), але +33% до розміру й весь файл у пам'яті - для решти погано.

Як обрати: невеликі файли (до кількох мегабайтів) - multipart; великі, багато одночасних завантажень, мобільні клієнти з нестабільною мережею - пряме завантаження.

Докладніше в документації: Laravel: тимчасові URL для завантаження