Питання на співбесіді з API
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Стайлгайд API («ресурси в множині», «поля в snake_case», «кожна помилка має опис») у вікі мало хто читає. Якщо API описано специфікацією OpenAPI, правила можна перевіряти автоматично, як код лінтером.
Spectral - лінтер для OpenAPI (і AsyncAPI) з набором вбудованих правил і власними правилами:
# .spectral.yaml
extends: ["spectral:oas"]
rules:
operation-description: error
paths-kebab-case:
description: Шляхи мають бути в kebab-case
severity: error
given: $.paths[*]~
then:
function: pattern
functionOptions:
match: "^(/[a-z0-9-{}]+)+$"
properties-snake-case:
description: Властивості схем - snake_case
severity: warn
given: $.components.schemas[*].properties[*]~
then:
function: casing
functionOptions: { type: snake }
errors-documented:
description: Кожна операція описує відповідь 4xx
severity: warn
given: $.paths[*][*].responses
then:
function: schema
functionOptions:
schema: { anyOf: [{ required: ["400"] }, { required: ["404"] }, { required: ["422"] }] }
npx @stoplight/spectral-cli lint openapi.json
given - JSONPath до частини документа, then - перевірка (pattern, casing, truthy, schema, власні функції на JavaScript).
Що варто перевіряти:
- узгодженість: однаковий стиль назв, однакова пагінація й формат помилок у всіх ендпойнтах;
- повноту: описи операцій, приклади, задокументовані помилки й автентифікація;
- безпеку: кожна операція має схему безпеки, немає ендпойнтів без автентифікації випадково;
- заборонені практики: дієслова в шляхах,
200з тілом помилки, відсутність обмеження розміру сторінки.
У CI разом з лінтером - пошук змін, що ламають клієнтів: порівняння специфікації гілки зі специфікацією основної гілки (інструменти на кшталт oasdiff). Видалене поле, новий обов'язковий параметр, змінений тип - помилка збирання, якщо зміна не позначена як навмисна.
Як впроваджувати:
- починати з попереджень, а не помилок, - інакше існуюче API не пройде перевірку;
- нові правила - як
errorлише для нових ендпойнтів чи після виправлення старих; - стайлгайд у вигляді набору правил Spectral - виконуваний, і його можна перевикористати між командами (публікуючи як npm-пакет).
Для Laravel з генерацією специфікації (Scramble): експорт специфікації в CI → лінтинг → порівняння з основною гілкою. Так правила застосовуються до реального API, а не лише до документації.
Найнебезпечніші зміни API - ті, що непомітно ламають клієнтів: розробник «трохи покращив» ресурс, тести бекенду зелені, а інтеграція партнера падає. Захист - зробити зміни контракту видимими й перевірюваними.
Що ламає клієнтів (breaking changes):
- видалення чи перейменування поля, ендпойнта, параметра;
- зміна типу поля (число → рядок) чи формату (дата без поясу → з поясом);
- новий обов'язковий параметр запиту чи поле тіла;
- звуження допустимих значень (нові обмеження валідації, видалене значення enum);
- зміна кодів статусу й формату помилок;
- зміна поведінки за замовчуванням (сортування, розмір сторінки).
Що зазвичай безпечно: нові необов'язкові параметри, нові поля у відповіді, нові ендпойнти. «Зазвичай» - бо клієнт зі строгою десеріалізацією чи вичерпною перевіркою enum може зламатися й від нового значення. Це варто прямо прописати в політиці API: «клієнти мають ігнорувати невідомі поля й значення».
Автоматична перевірка в CI:
oasdiff breaking main-openapi.yaml branch-openapi.yaml --fail-on ERR
oasdiff changelog main-openapi.yaml branch-openapi.yaml
breaking- список змін, що ламають, з рівнями серйозності; збирання падає, якщо зміна не дозволена;changelog- людиночитний перелік змін, який можна використати в описі pull request і журналі змін.
Специфікацію для порівняння беруть з основної гілки (згенеровану Scramble чи написану вручну) - так навіть «непомітні» зміни ресурсу Laravel стають видимими в рев'ю.
Журнал змін для споживачів:
- дата й версія кожної зміни, групування: додано, змінено, застаріло, видалено;
- посилання на документацію й інструкція з міграції для кожної зміни, що ламає;
- оголошення заздалегідь: дата застарівання і дата видалення; для відповідей - заголовки
DeprecationіSunset; - канал повідомлень для інтеграторів (розсилка, RSS, сторінка статусу), а не лише сторінка, яку ніхто не відкриває.
Процес для змін, що ламають: нова версія ендпойнта чи поля, паралельна підтримка старого, моніторинг використання застарілого (логування запитів зі старими полями й версіями, щоб знати, хто ще залежить), повідомлення конкретним клієнтам, лише потім видалення.
Найкращий журнал змін - коротший: більшість змін, що ламають, можна замінити адитивними (нове поле поруч зі старим), і тоді клієнтам взагалі нічого не треба робити.
Докладніше в документації: oasdiff: порівняння OpenAPI-специфікацій
GraphQL дає клієнту змогу самому будувати запит - а отже й побудувати дуже дорогий. Один HTTP-запит може змусити сервер виконати мільйони операцій.
Типові атаки й проблеми:
# глибина: циклічні зв'язки дають експоненційне зростання
{ user(id: 1) { friends { friends { friends { friends { name } } } } } }
# ширина: величезні списки
{ posts(first: 100000) { comments(first: 1000) { author { name } } } }
# псевдоніми: одне поле, викликане тисячу разів в одному запиті
{ a1: login(email: "...", password: "1") a2: login(email: "...", password: "2") ... }
Останній приклад обходить обмеження частоти на рівні HTTP: один запит - тисяча спроб входу.
Захист - кілька рівнів:
- обмеження глибини запиту (наприклад, 8-10 рівнів);
- аналіз складності: кожному полю призначається «вартість» (списки - помножена на
first), запит понад ліміт відхиляється до виконання; - обов'язкова пагінація з максимумом - жодних списків без
firstчи зfirst: 100000; - обмеження частоти за складністю, а не за кількістю HTTP-запитів: бюджет «очок» на клієнта за хвилину (так працює GitHub GraphQL API);
- обмеження псевдонімів і пакетних запитів (batching кількох операцій в одному HTTP-запиті);
- тайм-аути виконання запиту.
Інтроспекція в продакшені. Вона показує всю схему, включно з внутрішніми полями й мутаціями. Для публічних API зі схемою як документацією це нормально; для API лише свого фронтенду - вимкнути.
Збережені (persisted) запити / довірені документи: клієнт надсилає не текст запиту, а його хеш з переліку, відомого серверу під час збирання. Сервер виконує лише заздалегідь відомі запити - довільні атаки неможливі взагалі. Найсильніший захист для API, яке використовує лише ваш фронтенд, плюс бонус - запити можна робити через GET і кешувати на CDN.
Авторизація на рівні полів. Перевірка лише на верхньому запиті недостатня: доступ до order не означає доступу до order.customer.paymentMethods. Кожен резолвер чутливих даних має перевіряти права.
Повідомлення про помилки: у продакшені не віддавати стек і внутрішні деталі в errors - це витік інформації про реалізацію.
У Lighthouse обмеження задаються в config/lighthouse.php (max_query_depth, max_query_complexity, disable_introspection), а вартість полів - директивою @complexity. За замовчуванням ліміти вимкнені - їх треба ввімкнути свідомо.
Чому REST кешується легко: кожен ресурс має свою адресу, читання - через GET, і вся інфраструктура HTTP (браузер, CDN, проксі) розуміє Cache-Control і ETag без жодної участі застосунку.
Чому з GraphQL складніше:
- одна адреса
/graphqlдля всього; - запити через
POST- HTTP-кеші їх не кешують; - кожен клієнт формує свій запит - навіть однакові дані запитуються різними наборами полів, і ключ кешу «URL» не працює;
- одна відповідь змішує дані з різним терміном актуальності (назва товару - години, залишок на складі - секунди).
Рішення на різних рівнях:
1. Нормалізований кеш на клієнті (Apollo Client, urql з Graphcache, Relay). Кеш зберігає об'єкти за __typename + id, а не відповіді цілком. Мутація, що повертає змінений об'єкт, автоматично оновлює його в усіх екранах. Тому корисно мати глобально унікальні ідентифікатори і завжди запитувати id.
2. Збережені запити + GET. Клієнт надсилає хеш заздалегідь відомого запиту і змінні в рядку запиту:
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"ab12..."}}&variables={"id":42}
Тепер відповідь має стабільну адресу, і її можна кешувати на CDN - як REST.
3. Підказки кешування в схемі. Сервер обчислює Cache-Control для відповіді з найкоротшого терміну серед полів (директиви на кшталт @cacheControl(maxAge: 60)), а персональні поля позначає приватними. Відповідь з даними користувача не повинна потрапити в спільний кеш.
4. Кеш на сервері:
- на рівні резолверів чи DataLoader - кешування окремих сутностей у Redis;
- кеш цілих відповідей за нормалізованим текстом запиту + змінними + користувачем - простий, але інвалідація складна.
Інвалідація - найважче місце. Зміна одного товару торкається безлічі різних запитів, що його містять. Тому популярний підхід - теги: відповідь позначається тегами сутностей (Product:42), і зміна сутності скидає всі відповіді з цим тегом (так працюють CDN з purge за ключами).
Практичний висновок: якщо важливий кеш на CDN для публічних даних (каталог, статті), - або REST для цих частин, або збережені запити через GET. Для персональних даних основну роботу робить нормалізований кеш клієнта.
У Protocol Buffers у повідомленні передаються не назви полів, а їхні номери. Звідси головні правила еволюції схеми: важливо не те, як поле називається, а який у нього номер і тип.
Безпечні зміни:
- додати нове поле з новим номером. Старі клієнти його проігнорують, нові - отримають значення за замовчуванням, якщо старий сервер його не надіслав;
- перейменувати поле - номер той самий, на «дроті» нічого не змінилося (але згенерований код зміниться - це зміна API для коду, не для формату);
- видалити поле, якщо його номер і назву зарезервувати.
Небезпечні зміни:
- змінити номер поля - для формату це видалення старого поля і поява нового;
- повторно використати номер видаленого поля з іншим змістом - старі клієнти розберуть нові дані як старе поле, з тихим пошкодженням даних;
- змінити тип на несумісний (
string→int64). Деякі пари сумісні на рівні формату (int32/int64/bool), але з можливим обрізанням значень - покладатися на це не варто; - перетворити одиничне поле на
repeatedчи навпаки - з неочевидними наслідками для різних мов.
Резервування видалених полів:
message Invoice {
reserved 3, 7;
reserved "discount", "legacy_status";
int64 id = 1;
string number = 2;
int64 total_cents = 4;
}
Компілятор не дасть використати зарезервовані номери й назви повторно.
Інші практики:
- значення за замовчуванням у proto3 -
0,"",falseнеможливо відрізнити від «не задано». Якщо різниця важлива -optional(явна присутність) чи обгорткові типи; - перелічення: перше значення має бути
0і означати «невідомо» (STATUS_UNSPECIFIED = 0), бо саме його отримає старий клієнт для нових значень; - версія в назві пакета (
billing.v1,billing.v2) - для справді несумісних змін створюється новий пакет і обидва обслуговуються паралельно; - автоматична перевірка в CI: інструмент
buf breakingпорівнює схему з попередньою версією й знаходить ламаючі зміни до злиття.
Порядок розгортання: спершу оновлюються ті, хто читає нове поле (сервери, що його приймають), потім ті, хто починає його надсилати. Видалення - у зворотному порядку: спершу всі припиняють використовувати поле, потім воно резервується.
Докладніше в документації: Protocol Buffers: оновлення типу повідомлення
Подієві API - вебхуки, повідомлення в брокері (Kafka, RabbitMQ), канали WebSocket - мають ті самі проблеми, що й REST: контракт, документація, сумісність. Але є й власні - порядок і дублікати.
Документація - AsyncAPI. Те, чим OpenAPI є для REST, AsyncAPI є для подій: специфікація каналів, повідомлень і їхніх схем:
asyncapi: 3.0.0
info:
title: Orders events
version: 1.4.0
channels:
orderPaid:
address: orders.paid
messages:
orderPaid:
payload:
type: object
required: [id, orderId, paidAt]
properties:
id: { type: string }
orderId: { type: integer }
paidAt: { type: string, format: date-time }
З неї генеруються документація, типи для споживачів і перевірки повідомлень.
Версіонування подій:
- додавання полів - безпечне, якщо споживачі ігнорують невідомі поля (це треба вимагати в документації);
- ламаючі зміни - нова назва чи версія події (
order.paid.v2) і паралельна публікація обох версій на перехідний період; - версія у вебхуках - часто прив'язується до облікового запису одержувача (як
api_versionу Stripe): одержувач сам обирає, коли перейти на новий формат.
Порядок подій не гарантований. Повтори, паралельні воркери й мережа змішують порядок: order.shipped може прийти раніше за order.paid. Стратегії для одержувача:
- мітка часу чи номер версії об'єкта в події - застосовувати лише якщо подія новіша за вже відомий стан;
- «тонкі» події: подія лише повідомляє «замовлення 42 змінилося», а одержувач запитує актуальний стан через API - порядок перестає мати значення;
- впорядкування за ключем у брокері (партиції Kafka за
order_id) - порядок гарантується в межах однієї сутності, а не глобально.
Дублікати - наслідок доставки «щонайменше один раз»: одержувач зберігає оброблені id подій і пропускає повтори.
Документуйте явно: гарантії доставки (щонайменше один раз), відсутність гарантії порядку, тайм-аути й політику повторів, як перевіряти підпис. Більшість помилок інтеграцій - від неявних припущень одержувача про порядок і унікальність.
Коли сторонній сервіс лежить, повтори й тайм-аути не допомагають, а шкодять: кожен запит чекає повний тайм-аут, воркери й процеси PHP зайняті очікуванням, черга росте, а сервіс, що намагається піднятися, отримує лавину запитів.
Circuit Breaker («запобіжник») відстежує помилки й після порогу перестає викликати сервіс на певний час:
Closed (норма) -- N помилок поспіль --> Open (виклики не йдуть, одразу помилка)
^ |
| минув час
| v
+----- успіх ---- Half-open (пропустити пробний запит) ---- помилка ---> Open
Що це дає:
- швидка відмова замість 30-секундного очікування: користувач одразу бачить запасний варіант, воркери вільні;
- сервісу дають відновитися без лавини повторів;
- запасна поведінка: закешовані дані, відкладена обробка, повідомлення «сервіс тимчасово недоступний».
У Laravel для фонових задач роль запобіжника виконує middleware ThrottlesExceptions:
public function middleware(): array
{
return [
(new ThrottlesExceptions(maxAttempts: 10, decaySeconds: 5 * 60))
->by('payment-provider') // спільно для всіх джоб провайдера
->when(fn (Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError())),
];
}
Після 10 помилок джоби з цим ключем не виконуються 5 хвилин, а повертаються в чергу. З retryUntil() вони не губляться.
Для синхронних викликів простий запобіжник на кеші:
final class CircuitBreaker
{
public function __construct(private string $service, private int $threshold = 5, private int $cooldown = 60) {}
public function call(callable $callback, callable $fallback): mixed
{
if (Cache::has("circuit:{$this->service}:open")) {
return $fallback();
}
try {
$result = $callback();
Cache::forget("circuit:{$this->service}:failures");
return $result;
} catch (ConnectionException|RequestException $e) {
if (Cache::increment("circuit:{$this->service}:failures") >= $this->threshold) {
Cache::put("circuit:{$this->service}:open", true, $this->cooldown);
}
return $fallback();
}
}
}
Що важливо:
- рахувати лише «системні» помилки (мережа, 5xx, тайм-аути), а не 4xx від ваших же некоректних запитів;
- стан спільний для всіх процесів - у Redis, а не в пам'яті одного воркера;
- сповіщення: перехід у стан Open - подія для моніторингу й чергового;
- короткі тайм-аути - запобіжник не замінює їх, а доповнює.
Готові рішення є й у вигляді пакетів, але для більшості Laravel-застосунків ThrottlesExceptions у черзі + короткі тайм-аути + кеш як запасний варіант покривають потреби.
У інтеграціях дублікати неминучі: провайдер повторює вебхук, бо не дочекався відповіді; джоба повторюється після тайм-ауту, хоча перша спроба насправді завершилася; користувач двічі натискає «Синхронізувати». Обробка має давати той самий результат незалежно від кількості повторів.
1. Унікальність на вході - журнал оброблених подій:
Schema::create('processed_events', function (Blueprint $table) {
$table->string('provider');
$table->string('event_id');
$table->timestamp('processed_at');
$table->primary(['provider', 'event_id']);
});
public function handle(): void
{
DB::transaction(function () {
$inserted = DB::table('processed_events')->insertOrIgnore([
'provider' => 'stripe',
'event_id' => $this->event['id'],
'processed_at' => now(),
]);
if ($inserted === 0) {
return; // уже оброблено
}
$this->applyPayment($this->event);
});
}
Унікальний ключ у базі - єдиний надійний захист від гонитви: перевірка «чи є запис» і вставка окремими запитами пропускають паралельні дублікати.
2. Ідемпотентні операції за природою:
updateOrCreate/upsertза зовнішнім ідентифікатором (external_id) замістьcreate;- «встановити статус
paid» замість «збільшити суму на X»; - переходи станів з перевіркою: «оплачене» не можна оплатити вдруге.
3. Унікальні джоби - щоб не ставити в чергу однакову роботу:
#[UniqueFor(600)]
final class SyncCustomer implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return (string) $this->customerId;
}
}
Поки джоба для цього клієнта в черзі, нові такі ж відкидаються. Потребує кешу з атомарними блокуваннями і спільного кешу для всіх серверів.
4. Вихідні операції з побічними ефектами (створення платежу, відправка SMS) - ключ ідемпотентності в запиті до провайдера, стабільний для повторів тієї самої операції (Idempotency-Key: order-42-capture). Тоді повтор джоби після тайм-ауту не створить другий платіж.
5. Звірка (reconciliation) - останній рубіж: періодичне порівняння стану у вас і в провайдера (оплати за добу, залишки на складі), автоматичне виправлення розбіжностей і звіт про них. Ловить те, що загубилося попри всі механізми.
Типова помилка: покладатися на ShouldBeUnique як на захист від повторної обробки. Він не дає поставити дублікат у чергу, але не захищає від повторного виконання вже взятої джоби після збою воркера - для цього потрібні ідемпотентні операції й унікальні ключі в базі.
Saloon - бібліотека для PHP (з інтеграцією в Laravel), що структурує роботу з API у класи: конектор описує API загалом, запит - один ендпойнт.
use Saloon\Http\Connector;
use Saloon\Traits\Plugins\AcceptsJson;
final class CrmConnector extends Connector
{
use AcceptsJson;
public ?int $tries = 3;
public ?int $retryInterval = 500;
public ?bool $useExponentialBackoff = true;
public function __construct(private readonly string $token) {}
public function resolveBaseUrl(): string
{
return 'https://crm.example.com/api/v2';
}
protected function defaultAuth(): TokenAuthenticator
{
return new TokenAuthenticator($this->token);
}
}
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
final class GetDeal extends Request
{
protected Method $method = Method::GET;
public function __construct(private readonly int $id) {}
public function resolveEndpoint(): string
{
return "/deals/{$this->id}";
}
public function createDtoFromResponse(Response $response): Deal
{
return Deal::fromArray($response->json('data'));
}
}
$deal = $connector->send(new GetDeal(42))->dto(); // Deal, а не масив
Що дає порівняно з Http:: у класі-клієнті:
- один запит - один клас: ендпойнт, метод, тіло, заголовки, перетворення відповіді в DTO живуть разом. Великий API (десятки ендпойнтів) не перетворюється на клас-клієнт на тисячу рядків;
- DTO на виході (
createDtoFromResponse+dto()) - решта застосунку не бачить формату провайдера; - повтори, автентифікація, пагінація, OAuth2 - готові механізми на рівні конектора;
- тести:
MockClientз відповідями для конкретних класів запитів і фікстури - записані справжні відповіді, що відтворюються в тестах; - middleware на рівні конектора - логування, метрики, ідентифікатори кореляції в одному місці.
Як не перестаратися:
- для двох-трьох викликів Saloon - зайва церемонія; макрос чи невеликий клас-клієнт простіші;
- DTO мають відображати потреби вашого коду, а не повністю копіювати відповідь провайдера: мапінг лише потрібних полів, перетворення типів (дати, гроші в мінімальних одиницях, енуми статусів) і явна обробка відсутніх полів;
- не тягнути SDK у предметну область: сервіси застосунку залежать від вашого інтерфейсу (
CrmGateway), а Saloon - деталь його реалізації. Тоді зміна провайдера чи бібліотеки не зачіпає бізнес-логіку.
Коли Saloon особливо доречний: ключова інтеграція з великою кількістю ендпойнтів, кілька інтеграцій з однаковими підходами в команді, публікація власного SDK для свого API.
Коли «не приходять SMS» чи «замовлення не потрапили в CRM», перше питання - що саме відбувалося між вашим застосунком і провайдером. Без записів про вихідні запити відповісти неможливо.
Події HTTP-клієнта - точка для централізованого логування без зміни коду інтеграцій:
use Illuminate\Http\Client\Events\ConnectionFailed;
use Illuminate\Http\Client\Events\ResponseReceived;
Event::listen(function (ResponseReceived $event) {
Log::channel('integrations')->info('http.out', [
'host' => parse_url($event->request->url(), PHP_URL_HOST),
'method' => $event->request->method(),
'path' => parse_url($event->request->url(), PHP_URL_PATH),
'status' => $event->response->status(),
'duration_ms' => round(($event->response->transferStats?->getTransferTime() ?? 0) * 1000),
]);
});
Event::listen(fn (ConnectionFailed $event) => Log::channel('integrations')->warning('http.out.failed', [
'url' => $event->request->url(),
]));
Також є RequestSending перед відправкою і глобальні middleware клієнта (Http::globalRequestMiddleware, globalResponseMiddleware) - наприклад, щоб додати заголовок кореляції до всіх вихідних запитів.
Що варто записувати:
- провайдер, ендпойнт (без параметрів з персональними даними), метод, статус, тривалість, номер спроби;
- ідентифікатор запиту провайдера з заголовка відповіді (
X-Request-Id,CF-Ray) - саме його попросить підтримка провайдера; - ваш ідентифікатор кореляції - щоб зв'язати вихідний запит з HTTP-запитом користувача чи джобою, що його породили (
Context::add('trace_id', ...)в Laravel додає його в усі журнали); - для збоїв - фрагмент тіла відповіді з помилкою.
Чого НЕ записувати: токени й ключі (Authorization), паролі, номери карток, повні тіла з персональними даними. Маскування - в одному місці, у слухачі подій.
Метрики й сповіщення важливіші за журнали:
- частка помилок і латентність за провайдером (p95);
- кількість 429 - наближення до лімітів;
- довжина черги інтеграцій і кількість невдалих джоб;
- сповіщення, коли частка помилок провайдера перевищує поріг.
Інструменти в Laravel-екосистемі: Pulse (картки повільних вихідних запитів), Telescope (на staging - детально кожен запит), Nightwatch, OpenTelemetry для розподіленого трасування.
Журнал бізнес-операцій інтеграції (таблиця «синхронізації»: що відправлено, коли, результат, зовнішній ID) - окремо від технічних журналів. Його показують підтримці й адміністраторам і використовують для звірки.
Пісочниці провайдерів у staging - щоб збої інтеграцій ловилися до продакшену, а моніторинг працював і там.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 146 відкритих вакансій Laravel і PHP. Переглянути вакансії