4. JSON API

Web-форма вже вміє валідувати POST. Для API хочеться тих самих правил — але клієнт шле JSON і чекає JSON, не Twig. Якщо просто «додати Accept» на існуючий web-стек, легко випадково навісити CSRF, flash і HTML-middleware на API-клієнтів, яким вони не потрібні.

Тому API-трек збирає лише Validation + Http і виносить surface у routes/api.php: middleware списком видно, хто парсить body і хто форсує JSON Content-Type. Session / CSRF тут немає (крок 5 на web) — API не тягне web-стек «за компанію». Помилки валідації знову віддаємо вручну через factory — той самий підхід, що на кроці 3, інший формат відповіді.

Що додається і навіщо

  • php-concept/extension-json — окремий пакет middleware; Stack його не підтягує «мовчки», щоб HTTP без JSON не платив зайвою залежністю
  • routes/api.php замість web.php — JSON surface ізольований від HTML-маршрутів; на кроці 6 обидва файли знову зійдуться в одному Bootstrap
  • ParseJsonBodyMiddleware — щоб getParsedBody() / FormRequest бачили поля з JSON, а не порожній body
  • ForceJsonResponseMiddleware — Accept / Content-Type JSON навіть якщо handler забув заголовок (клієнт не отримає HTML «випадково»)
  • GET /api/ping, POST /api/contact — той самий ContactFormRequest, інший транспорт відповіді

View, Twig і Bootstrap-ассети з web-кроків на цьому checkout прибрані: якщо capability немає в Bootstrap — її сервісів немає. Так видно ціну ізоляції: API-збірка легша, бо не тягне те, чого не викликали.

composer.json

JSON middleware живуть у extension-json. Їх немає в транзитивних залежностях Stack навмисно: API-пакет підключаєш, лише коли будуєш JSON surface. Інакше кожен web-додаток тягнув би JSON-middleware «про запас».

{
    "require": {
        "php": "^8.4",
        "php-concept/core-2": "^1.0",
        "php-concept/stack": "^1.0",
        "php-concept/extension-json": "^1.0"
    }
}

ApplicationStackBootstrap.php

Без addView(): API не повинен тягнути Twig, щоб віддати JSON. Validation лишається — ті самі правила contact, інший формат відповіді. Middleware не реєструємо в Stack: список належить route file, щоб поведінка surface була видна поруч із URL.

$stack->addValidation();

$stack->addHttp()
    ->setRoutes([$rootPath . 'routes/api.php']);

routes/api.php

Група /api з lazyMiddlewares показує ланцюжок обробки до controller: спочатку body → array, потім гарантія JSON headers. Так поведінка API не розмазана по глобальних kernel hooks — і web пізніше зможе мати свій список, не наслідуючи цей.

$router->group('/api', function (RouteGroup $router): void {
    $router->get('/ping', [ApiController::class, 'ping'])->setName('api.ping');
    $router->post('/contact', [ApiController::class, 'contact'])->setName('api.contact');
})->lazyMiddlewares([
    ParseJsonBodyMiddleware::class,
    ForceJsonResponseMiddleware::class,
]);

lazyMiddlewares резолвить класи з контейнера. Обидва JSON-middleware без залежностей у constructor — їх можна підключити одразу, без додаткових bindings у Stack.

ApiController.php

Той самий FormRequest, що на web: правила не дублюємо. Відповідь — jsonSuccess / jsonError, щоб клієнт завжди отримував передбачуваний envelope, а не «сирий» масив і не HTML з 422. Ручний validate() знову свідомий: на api-треку ще немає ErrorHandling з JSON-гілкою для ValidationException.

public function ping(): ResponseInterface
{
    return $this->response->jsonSuccess(['message' => 'pong']);
}

public function contact(ServerRequestInterface $request): ResponseInterface
{
    $form = $this->formRequests->make(ContactFormRequest::class, $request);

    if (!$form->validate()) {
        return $this->response->jsonError(
            'Validation failed',
            HttpStatusCode::UNPROCESSABLE_ENTITY,
            $form->errors(),
        );
    }

    return $this->response->jsonSuccess([
        'validated' => $form->validated(),
    ]);
}

Після ParseJsonBodyMiddleware FormRequest читає поля з parsed JSON body так само, як web читає form-urlencoded. Фабрика відповідей — з extension-http.

Запуск і перевірка

Тег step-04-json-api. Очікуємо JSON envelope: GET /api/ping → 200; невалідний POST /api/contact → 422 + errors; валідний POST → 200 + validated.

Запуск

git checkout step-04-json-api
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build

Перевірка в браузері

  1. Відкрийте http://localhost:8089/api/ping — JSON з "message":"pong".
  2. Для POST /api/contact зручніше DevTools або розширення REST-клієнта; очікуйте ті самі коди, що в CLI нижче.

Перевірка з CLI

curl -s http://localhost:8089/api/ping
# {"status":"success","code":200,"data":{"message":"pong"}}

curl -s -X POST http://localhost:8089/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"a","email":"bad","message":"hi"}'
# 422 + errors

curl -s -X POST http://localhost:8089/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Alice","email":"alice@example.com","message":"Hello from the JSON API demo."}'
# 200 + validated

Ланцюжок

Порівняйте з кроком 3: замість Twig — JSON envelope; замість form body — ParseJsonBody. FormRequest посередині той самий.

addValidation() + addHttp(routes/api.php)
POST /api/contact
  → ParseJsonBody → ForceJsonResponse
  → ApiController::contact()
    → FormRequestFactory::make(ContactFormRequest)
    → validate() → jsonError 422  або  jsonSuccess 200

Далі на web-треку — крок 5, Session і CSRF (git checkout step-05-session або step-03-validation для web без CSRF). API і web зійдуться знову на кроці 6 (Database). Усі кроки — Огляд.