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
Перевірка в браузері
-
Відкрийте
http://localhost:8089/api/ping
— JSON з
"message":"pong". -
Для
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).
Усі кроки — Огляд.