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

Junior: питання на співбесіді з теми «Інтеграції зі сторонніми API»

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

5 питань

HTTP-клієнт Laravel (обгортка над Guzzle) робить запити до сторонніх API коротко й зручно:

use Illuminate\Support\Facades\Http;

$response = Http::withToken(config('services.nova_poshta.key'))
    ->acceptJson()
    ->connectTimeout(3)
    ->timeout(10)
    ->get('https://api.example.com/v1/parcels/123');

$status = $response->json('data.status');

Тайм-аути - обов'язкові для свідомого вибору:

  • connectTimeout - скільки чекати на встановлення з'єднання (за замовчуванням 10 секунд);
  • timeout - скільки чекати на всю відповідь (за замовчуванням 30 секунд).

Тридцять секунд очікування повільного API в обробнику запиту користувача - це тридцять секунд зайнятого процесу PHP і користувач, що дивиться на спінер. Для запитів у веб-обробнику тайм-аути варто ставити короткими.

Помилки - клієнт не кидає винятки сам. Відповідь 404 чи 500 - це звичайний об'єкт Response, і код спокійно продовжить роботу з порожніми даними. Перевіряйте явно:

if ($response->failed()) {            // 4xx або 5xx
    // ...
}

$response->successful();   // 2xx
$response->clientError();  // 4xx
$response->serverError();  // 5xx

$data = $response->throw()->json();   // кинути RequestException на 4xx/5xx

Два різні типи збою:

  • Illuminate\Http\Client\RequestException - сервер відповів з помилкою (throw());
  • Illuminate\Http\Client\ConnectionException - відповіді не було взагалі: тайм-аут, DNS, відмова в з'єднанні. Кидається завжди.

Що ще варто зробити відразу:

  • ключі й адреси - у config/services.php з .env, не в коді;
  • базову адресу й автентифікацію зібрати в одному місці (макрос або клас-клієнт), щоб не дублювати по проєкту;
  • логувати збої з контекстом (ендпойнт, статус, ідентифікатор запиту), але без токенів і персональних даних;
  • не довіряти формату відповіді: сторонній API може змінити поле - $response->json('data.status') поверне null, і це треба обробити.

Довгі чи масові виклики - у черзі, а не в запиті користувача.

Докладніше в документації: HTTP-клієнт: тайм-аути

Тести не повинні ходити в реальні сторонні API: вони стають повільними, нестабільними, залежать від мережі й можуть створювати справжні платежі чи відправляти справжні SMS.

Http::fake() підміняє відповіді:

use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

it('stores the tracking status', function () {
    Http::fake([
        'api.example.com/v1/parcels/*' => Http::response(['data' => ['status' => 'delivered']]),
        'api.example.com/*' => Http::response(status: 404),
    ]);

    app(ParcelTracker::class)->refresh($parcel);

    expect($parcel->fresh()->status)->toBe('delivered');

    Http::assertSent(fn (Request $request) =>
        $request->url() === 'https://api.example.com/v1/parcels/123'
        && $request->hasHeader('Authorization')
    );
});

Сценарії збоїв - так само просто:

Http::fake(['api.example.com/*' => Http::response(status: 503)]);

// послідовність: спершу помилка, потім успіх - перевірка повторів
Http::fake([
    'api.example.com/*' => Http::sequence()
        ->pushStatus(503)
        ->push(['data' => ['status' => 'delivered']]),
]);

// мережевий збій (ConnectionException)
Http::fake(['api.example.com/*' => Http::failedConnection()]);

Http::preventStrayRequests() - будь-який запит без підготовленої фейкової відповіді кидає виняток. Найкраще ввімкнути глобально в базовому класі тестів чи Pest.php: тоді жоден тест випадково не піде в мережу, навіть якщо хтось забув Http::fake.

Корисні перевірки:

  • Http::assertSentCount(2) - скільки запитів зроблено;
  • Http::assertNothingSent() - запитів не було (наприклад, через кеш);
  • Http::assertSentInOrder([...]) - порядок.

Реалістичні відповіді. Найкраще зберігати справжні (знеособлені) відповіді API у файлах-фікстурах і віддавати їх з Http::response(json_decode(file_get_contents(...))). Тести тоді перевіряють розбір саме того формату, що надсилає провайдер.

Пісочниці провайдерів (тестові ключі Stripe, LiqPay, Monobank) - для ручної перевірки й рідкісних інтеграційних тестів, які запускаються окремо від основного набору, а не для кожного прогону CI.

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

Докладніше в документації: HTTP-клієнт: тестування

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

Що відбувається без черги:

public function store(Request $request)
{
    $order = Order::create($request->validated());

    Http::post('https://crm.example.com/api/deals', [...]);   // 8 секунд...
    Http::post('https://sms.example.com/send', [...]);        // ...і впав з 503

    return redirect()->route('orders.show', $order);
}
  • користувач чекає, поки відповідять усі сторонні сервіси;
  • падіння CRM ламає оформлення замовлення, хоча замовлення вже створене;
  • процеси PHP-FPM зайняті очікуванням мережі - під навантаженням вони закінчуються, і сайт гальмує повністю;
  • повторити невдалий виклик ніхто не спробує.

З чергою:

public function store(Request $request)
{
    $order = Order::create($request->validated());

    SyncOrderToCrm::dispatch($order)->afterCommit();
    SendOrderSms::dispatch($order)->afterCommit();

    return redirect()->route('orders.show', $order);
}

Що дає черга:

  • швидка відповідь користувачу незалежно від сторонніх сервісів;
  • повторні спроби з затримкою ($tries, backoff()) - тимчасові збої вирішуються самі;
  • ізоляція: окрема черга для інтеграцій з власними воркерами - повільний провайдер не затримує листи чи генерацію звітів;
  • обмеження частоти через middleware джоби (RateLimited, ThrottlesExceptions) - під ліміти провайдера;
  • видимість: невдалі джоби в failed_jobs чи Horizon, їх можна повторити вручну.

afterCommit() - джоба потрапить у чергу лише після коміту транзакції. Інакше воркер може взяти її раніше, ніж замовлення з'явиться в базі.

Коли синхронний виклик виправданий: результат потрібен одразу для відповіді (перевірка адреси доставки, розрахунок вартості, ініціалізація оплати з перенаправленням). Тоді - короткий тайм-аут і зрозуміле повідомлення користувачу при збої.

Джоба має бути ідемпотентною: повтор не повинен створити в CRM дві угоди. Зберігайте зовнішній ідентифікатор після першого успішного виклику і перевіряйте його перед створенням.

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

Базове правило: ключі не потрапляють у код і в Git. Вони живуть у змінних оточення, а код звертається до них через конфігурацію.

# .env - у .gitignore
STRIPE_SECRET=sk_live_...
NOVA_POSHTA_API_KEY=...
// config/services.php
'nova_poshta' => [
    'key' => env('NOVA_POSHTA_API_KEY'),
    'url' => env('NOVA_POSHTA_URL', 'https://api.novaposhta.ua/v2.0/json/'),
],

// у коді
Http::withToken(config('services.nova_poshta.key'));

Чому config(), а не env() у коді: після php artisan config:cache файл .env більше не читається, і env() поза конфігураційними файлами поверне null.

Що ще важливо:

  • .env.example містить назви змінних без значень - новий розробник бачить, що потрібно налаштувати;
  • різні ключі для середовищ: тестові ключі пісочниці на локальній машині й staging, бойові - лише в продакшені;
  • мінімальні права ключа: якщо провайдер дозволяє обмежити ключ (лише читання, певні IP, певні операції), - обмежувати;
  • ротація: план заміни ключа без простою (новий ключ → деплой → відкликання старого) і обов'язкова заміна, якщо ключ міг витекти;
  • не логувати секрети: заголовки Authorization, тіла з ключами. У PHP 8.2+ атрибут #[\SensitiveParameter] приховує значення параметра в стеку помилки - Laravel використовує його, наприклад, у withToken().

Зашифрований .env у репозиторії - вбудований механізм Laravel:

php artisan env:encrypt --env=production      # створює .env.production.encrypted
php artisan env:decrypt --env=production --key=...

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

Для більших систем - менеджери секретів (Vault, AWS Secrets Manager, Doppler, секрети платформи деплою), де є аудит доступу й централізована ротація.

Токени користувачів до сторонніх сервісів (OAuth-доступ до їхніх Google чи GitHub) - це вже дані, а не конфігурація: зберігаються в базі зашифрованими (каст encrypted в Eloquent).

Ключ, що потрапив у Git, вважається скомпрометованим, навіть якщо коміт видалили: історія й форки лишаються. Тільки відкликання.

Докладніше в документації: Шифрування файлів оточення

Задача: дізнатися, що в сторонньому сервісі щось змінилося - оплата пройшла, посилка доставлена, клієнт оновився в CRM.

Опитування (polling) - ваш застосунок періодично питає API:

Schedule::job(new RefreshParcelStatuses)->everyFifteenMinutes();
  • плюси: просто, працює з будь-яким API, ви контролюєте частоту й момент; не потрібна публічна адреса;
  • мінуси: затримка до інтервалу опитування; більшість запитів - марні («нічого не змінилося»); з'їдає ліміти частоти провайдера; погано масштабується на тисячі об'єктів.

Вебхуки - провайдер сам надсилає запит на ваш URL, коли подія сталася:

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

На практиці найнадійніше - поєднання:

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

Так система не залежить від того, що кожен вебхук дійде.

Правила обробки вхідних вебхуків:

  • перевірити підпис, відповісти 200 швидко, а обробку - в чергу;
  • ідемпотентність за id події: дублікат не повинен обробитися двічі;
  • не покладатися на порядок подій - порівнювати з поточним станом чи запитувати свіжі дані з API;
  • для локальної розробки - тунель (Expose, ngrok) чи CLI провайдера, що пересилає події на localhost.

Коли лише опитування: провайдер не має вебхуків; дані змінюються рідко і затримка неважлива; застосунок не має публічної адреси (внутрішня мережа).

Коли лише вебхуки (без звірки): втрата події некритична, або провайдер гарантує повторну доставку довго й надійно, а у вас є журнал отриманих подій.

Докладніше в документації: Про вебхуки GitHub