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

Senior: питання на співбесіді з теми «Проєктування API»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

Проблема: клієнт відправив POST /payments, а відповідь не дійшла - таймаут, обрив мережі. Чи пройшов платіж? Повторити запит небезпечно (подвійне списання), не повторити - теж (платіж може не пройти).

Рішення: клієнт генерує унікальний ключ (UUID) для кожної логічної операції і передає його в заголовку. Повтор з тим самим ключем не виконує операцію вдруге, а повертає збережену відповідь першого виконання.

POST /payments
Idempotency-Key: 4f0f9c2e-8b1a-4c3e-9d5f-2a7b6c8d9e01
{"amount": 5000, "currency": "UAH"}

Реалізація на сервері:

  1. Отримати ключ і атомарно зарезервувати його (унікальний індекс у таблиці чи SET NX у Redis) разом з ID користувача.
  2. Якщо ключ новий - виконати операцію й зберегти статус і тіло відповіді.
  3. Якщо ключ уже є й операція завершена - повернути збережену відповідь.
  4. Якщо ключ є, але операція ще виконується (паралельний повтор) - 409 Conflict, щоб клієнт повторив пізніше.
  5. Якщо той самий ключ прийшов з іншим тілом запиту - 422: це помилка клієнта, ключ використано повторно для іншої операції.

Деталі, про які питають:

  • Ключ прив'язують до користувача, щоб чужий ключ не дав доступ до чужої відповіді.
  • Термін зберігання ключів - зазвичай 24 години.
  • Зберігати відповідь потрібно в тій самій транзакції, що й результат операції, інакше падіння між ними знову дасть подвійне виконання.
  • Помилки валідації (4xx) зазвичай зберігають, а тимчасові збої (5xx) - ні, щоб повтор мав шанс пройти.

Саме так працюють Stripe і більшість платіжних API. IETF стандартизує заголовок Idempotency-Key в окремій специфікації.

Докладніше в документації: Чернетка IETF: Idempotency-Key

Опублікований API - контракт з клієнтами, яких ви не контролюєте: мобільні застосунки старих версій, інтеграції партнерів. Їх не можна оновити одночасно з сервером.

Сумісні зміни (можна будь-коли):

  • додати новий ендпоінт;
  • додати необов'язкове поле в запит;
  • додати поле у відповідь (клієнти мають ігнорувати невідомі поля - це варто прописати в документації);
  • додати нове значення в перелік - обережно: клієнт зі строгою перевіркою enum може зламатися.

Несумісні (ламаючі): видалити чи перейменувати поле, змінити тип чи формат, зробити поле обов'язковим, змінити зміст коду відповіді, змінити поведінку за замовчуванням.

Версіонування для ламаючих змін:

  • в URL - /v1/orders, /v2/orders: найпростіше й найпомітніше;
  • в заголовку - Accept: application/vnd.example.v2+json або власний заголовок;
  • датою - як Stripe (Stripe-Version: 2025-03-31): кожен клієнт закріплений на версії API на момент інтеграції, а сервер перетворює відповіді для старих версій.

Плавне виведення старого:

  1. Оголосити застарілість у документації й журналі змін.
  2. Додати заголовки: Deprecation (RFC 9745) - що ресурс застарів, Sunset (RFC 8594) - дата, після якої він перестане працювати, і Link на документацію з міграцією.
  3. Моніторити використання старої версії за клієнтами й писати тим, хто ще на ній.
  4. Вимикати лише після дати й коли трафік зійшов нанівець.

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

Докладніше в документації: RFC 9745: заголовок Deprecation

HATEOAS (Hypermedia as the Engine of Application State) - обмеження REST, за яким клієнт не знає URL наперед, а знаходить наступні можливі дії в посиланнях з відповідей, як людина переходить за посиланнями на сайті.

{
  "id": 42,
  "status": "pending",
  "total": 1250,
  "_links": {
    "self":   { "href": "/orders/42" },
    "pay":    { "href": "/orders/42/payments", "method": "POST" },
    "cancel": { "href": "/orders/42/cancel", "method": "POST" }
  }
}

Після оплати посилання pay зникне, а з'явиться, наприклад, invoice. Клієнт не вирішує сам, чи можна скасувати замовлення, - він бачить, чи є посилання cancel.

Рой Філдінг наполягав: API без гіпермедіа - не REST. Тому більшість «REST API» в його термінах насправді «HTTP API».

Що обіцяє HATEOAS:

  • сервер може змінювати URL без оновлення клієнтів;
  • бізнес-правила на сервері: клієнт показує кнопку «Скасувати», лише якщо є посилання, - логіку дозволених переходів не дублюють у мобільному застосунку й фронтенді;
  • самоописуваність: API можна «досліджувати».

Чому на практиці його використовують рідко:

  • клієнти все одно знають домен: мобільний застосунок «знає», що в замовлення є оплата, і має для неї окремий екран. Він не будує інтерфейс динамічно з посилань;
  • URL і так стабільні й задокументовані в OpenAPI; генеровані клієнти працюють зі статичними шляхами;
  • накладні витрати: розмір відповідей, складність серверу, слабка підтримка інструментами;
  • немає єдиного стандарту формату посилань (HAL, JSON:API, Siren, JSON-LD).

Що з HATEOAS корисно взяти навіть без повної реалізації:

  • посилання пагінації (next, prev) - ресурси Laravel додають їх автоматично. Курсорну пагінацію інакше й не реалізувати зручно;
  • прапорці дозволених дій у відповіді - практичний компроміс: "can": {"cancel": true, "refund": false} - клієнт не повторює правила авторизації;
  • Location після створення і посилання на асинхронний статус (202 + URL задачі).

JSON:API (Laravel 13 має вбудований JsonApiResource) включає links у стандарт - це найпоширеніший спосіб отримати частину переваг гіпермедіа без власного формату.

Докладніше в документації: Рой Філдінг: REST API мають керуватися гіпертекстом

Ці типи найчастіше «ламаються» на межі між системами - і помилки проявляються не одразу, а в окремих часових поясах, на певних сумах чи при великих ID.

Дата й час - мить у часі:

  • формат RFC 3339 (профіль ISO 8601) з поясом: 2026-10-04T07:15:00Z чи 2026-10-04T10:15:00+03:00;
  • сервер зберігає й віддає в UTC, а в місцевий час перетворює клієнт для показу;
  • ніколи без поясу: 2026-10-04 10:15:00 різні клієнти зрозуміють по-різному.

Дата без часу (день народження, дата події) - окремий тип "2026-10-04". Перетворення на мить у часі зсуне її на день у деяких поясах.

Локальний час із поясом користувача (зустріч о 10:00 за Києвом наступного вівторка) - зберігають локальний час + ідентифікатор поясу (Europe/Kyiv), а не зміщення: правила переходу на літній час змінюються, і +03:00 для майбутньої дати може стати неправильним.

Тривалість - ISO 8601 (PT15M) або явні одиниці в назві поля (duration_seconds).

Гроші:

  • не float: 0.1 + 0.2 - класика. JSON-число парсери перетворюють на double;
  • мінімальні одиниці цілим числом ("amount": 125050 - копійки) або рядок ("125.50");
  • валюта завжди поруч ("currency": "UAH", ISO 4217) - бо кількість знаків після коми різна (у японської єни - нуль);
  • округлення - на сервері, за явними правилами; клієнт не перераховує суми сам.

Великі ідентифікатори:

  • JavaScript точно представляє цілі лише до 2^53 - 1. BIGINT з бази, Snowflake-ID, ідентифікатори Twitter - рядком: "id": "1844712345678901234";
  • для UUID - рядок у канонічному вигляді.

Числа з високою точністю (координати, курси, наукові дані) - рядок або явно задокументована точність.

Перелічення - рядки-коди ("status": "paid"), а не числа: порядок і значення не залежать від внутрішнього enum.

Що варто зафіксувати в документації API: формат кожного такого поля з прикладом. Помилки «в нас усе працює, у клієнта з Нью-Йорка - ні» майже завжди про недописаний формат дат.

У Laravel: касти datetime серіалізуються в ISO 8601 UTC; decimal:2 - рядком; для великих BIGINT у ресурсі - явне (string) $this->id.

Докладніше в документації: RFC 3339: дата й час в Інтернеті

Більшість бізнес-сутностей мають життєвий цикл: замовлення (нове → оплачене → відправлене → доставлене / скасоване), стаття (чернетка → на модерації → опублікована), заявка, платіж. API має відображати цей цикл явно.

1. Поле стану - перелічення з документованими значеннями:

{ "id": 42, "state": "paid", "paid_at": "2026-10-04T07:15:00Z" }

Не набір булевих прапорців (is_paid, is_shipped, is_cancelled) - вони допускають неможливі комбінації.

2. Стан змінюють не напряму, а через дії:

PATCH /orders/42 {"state": "shipped"}          # погано: обходить правила
POST  /orders/42/ship {"tracking": "UA123"}    # добре: явний перехід з даними

Google AIP-216 радить робити поле стану лише для читання (output only) і змінювати його власними методами. Тоді:

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

3. Недопустимий перехід - явна помилка:

POST /orders/42/cancel
409 Conflict
{ "type": "/problems/invalid-state-transition", "title": "Замовлення вже відправлено", "current_state": "shipped" }

4. Клієнт має знати, що дозволено зараз:

{ "state": "paid", "allowed_actions": ["ship", "refund"] }

Інтерфейс показує кнопки за цим списком - логіку переходів не дублюють на клієнтах.

5. Історія переходів - окремий ресурс (GET /orders/42/events): хто, коли, з якого стану в який. Потрібна для підтримки, аудиту й спорів.

6. Конкурентні переходи: два запити «скасувати» й «відправити» одночасно. На сервері - перевірка поточного стану атомарно (UPDATE ... WHERE state = 'paid' чи блокування рядка), а в API - оптимістичне блокування через If-Match/версію.

7. Нові стани - зміна, що може зламати клієнтів: клієнт з switch по відомих станах не знає, що робити з новим. Документуйте, що перелік може розширюватися, і вимагайте від клієнтів обробки невідомих значень.

На бекенді Laravel це зручно оформити енумом стану з методом canTransitionTo() або пакетом станів (spatie/laravel-model-states), а дії - окремими класами-actions, на які спираються контролери.

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