Основы веб-разработки

Запросы и привязка параметров

Winter сам наполняет аргументы метода данными запроса. Вы объявляете, что нужно — тип и атрибут источника — а фреймворк читает запрос, приводит значение к типу и передаёт его в метод. Никаких $_GET, $_POST и json_decode в коде контроллера.

Объект запроса HttpRequestАтрибуты Http\Request\AnnotationПриведение к типу аргумента

Что такое привязка параметров и зачем

Привязка параметров (parameter binding) — это автоматическое преобразование сырого HTTP-запроса в готовые, типизированные аргументы метода.

Проблема. Данные из HTTP приходят строками и лежат в разных местах: часть в пути URL, часть в query-строке, часть в теле (JSON, форма или XML), что-то в заголовках. Разбирать их вручную, проверять наличие, приводить "42" к int — это рутина, которая повторяется в каждом методе и легко даёт ошибки. Причём ошибки тихие: забытая проверка на null всплывёт не здесь, а тремя слоями глубже.

Решение. Опишите параметр типом и атрибутом источника — остальное сделает фреймворк: найдёт значение в нужном месте запроса, приведёт к объявленному типу и передаст в метод, а при несоответствии вернёт 400 ещё до входа в ваш код.

Начнём, однако, с того, что лежит под привязкой, — с самого объекта запроса.

Объект запроса

HttpRequest — это весь входящий запрос одним объектом. Чтобы его получить, достаточно объявить аргумент такого типа; никакого атрибута не нужно:

php
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;

#[PostMapping('webhook')]
public function webhook(HttpRequest $request): ResponseEntity
{
  $signature = $request->getHeader('X-Signature');
  $payload   = $request->getRawBody();
  // ...
}

Это интерфейс, а не класс: под ним лежит либо SwooleRequest, либо FpmRequest, в зависимости от того, как приложение запущено. Ваш код разницы не видит — в этом и смысл.

Данные запроса

Метод Что возвращает
getMethod() HTTP-глагол в верхнем регистре: GET, POST, …
getUri() Путь вместе с query-строкой: /users/42?page=1
getQueryParams() Разобранная query-строка массивом — аналог $_GET
getParsedBody() Разобранное тело формы — аналог $_POST
getRawBody() Тело запроса как есть, байтами
getUploadedFiles() Загруженные файлы — аналог $_FILES

Заголовки

Метод Что возвращает
getHeader(string $name) Один заголовок или null. Имя регистронезависимо
getHeaders() Все заголовки; ключи приведены к нижнему регистру

Кто и откуда пришёл

Эта группа отвечает на вопросы, ответ на которые за обратным прокси не совпадает с тем, что видит сокет. Все методы учитывают заголовки проксирования:

Метод Что возвращает
getClientIp() IP клиента с учётом X-Forwarded-For и Forwarded
getScheme() http или https — с учётом X-Forwarded-Proto
getHost() Имя хоста без порта, как его набрал клиент
getPort() Порт, как его видел клиент, с учётом X-Forwarded-Port
getBaseUrl() Готовое scheme://host[:port], стандартные порты опущены
getClientTimezone() Часовой пояс из заголовка Timezone или X-Timezone
getServerParam(string $key) Переменная окружения запроса: remote_addr, request_time

Заголовкам прокси доверяют безоговорочно

getScheme(), getHost(), getPort() и getClientIp() берут значения из Forwarded и X-Forwarded-*, не проверяя, кто их поставил. Это правильно за обратным прокси, который эти заголовки перезаписывает, и опасно без него: их может прислать кто угодно. Если приложение смотрит в интернет напрямую, вырезайте их на входе.

Нужен и объект ответа — объявляется так же, по типу HttpResponse. Он пригождается редко: обычно ответ собирают возвратом значения из метода, см. Контроллеры.

Как работает привязка

Обращаться к HttpRequest напрямую приходится нечасто — почти всё, что из него достают, фреймворк умеет разложить по аргументам сам.

Когда маршрут найден, перед вызовом метода отрабатывает ParameterResolver. Он идёт по параметрам метода и для каждого:

  1. читает объявленный тип и атрибут источника;
  2. находит значение в нужном месте запроса;
  3. проверяет, обязателен ли параметр, если значения нет;
  4. приводит к объявленному PHP-типу;
  5. подставляет в аргумент.
php
#[GetMapping('orders/{id:\d+}')]
public function show(
  #[PathVariable]  int    $id,             // из пути
  #[RequestParam]  int    $page = 1,       // из query ?page=
  #[RequestHeader] string $authorization,  // из заголовка
): ResponseEntity {
  // всё уже найдено, проверено и приведено к типам
}

Сравните с тем же кодом на сыром запросе — три обращения, три проверки на отсутствие, два приведения типа и ни одного внятного сообщения об ошибке.

Откуда брать данные

Каждому месту в запросе — свой атрибут. Все живут в Flytachi\Winter\Kernel\Http\Request\Annotation:

Атрибут Откуда берёт Что принимает
#[PathVariable] Сегмент пути — /users/{id} Скаляр
#[RequestParam] Один параметр query — ?key=val Скаляр или массив
#[RequestQuery] Всю query-строку целиком Только объект или array
#[RequestHeader] Заголовок запроса Скаляр
#[RequestBody] Тело; формат определяется по Content-Type Строку, массив, объект, DTO
#[RequestJson] Тело — принудительно как JSON Массив, stdClass, DTO
#[RequestForm] Тело — принудительно как форму Массив, stdClass, DTO
#[RequestXml] Тело — принудительно как XML Массив, stdClass, DTO
#[RequestFile] Загруженный файл Массив с данными файла или его содержимое

Дальше — по одному разделу на источник.

Путь — #[PathVariable]

Берёт динамический сегмент URL. Имя аргумента должно совпадать с именем сегмента в маршруте:

php
#[GetMapping('users/{id}/posts/{slug}')]
public function post(
  #[PathVariable] int    $id,     // /users/42/... → 42
  #[PathVariable] string $slug,   // .../hello     → "hello"
): ResponseEntity { /* ... */ }

Связь идёт по имени, а не по позиции — порядок аргументов значения не имеет.

Аргументы атрибута

Аргумент По умолчанию Что делает
name имя аргумента Имя сегмента в маршруте, если оно отличается
php
#[GetMapping('posts/{id}')]
public function show(#[PathVariable('id')] int $postId): ResponseEntity { /* ... */ }

Сегмент обязателен: если маршрут совпал, значение есть всегда. Подробнее про сами шаблоны путей и ограничения регулярными выражениями — на странице Маршрутизация.

Query-строка

Для query есть два атрибута, и выбор между ними — это выбор между «несколько отдельных значений» и «один объект фильтра».

Одно значение — #[RequestParam]

php
#[GetMapping('orders')]
public function list(
  #[RequestParam] int     $page   = 1,      // ?page=2
  #[RequestParam] ?string $search = null,   // ?search=telefon
  #[RequestParam] bool    $active = false,  // ?active=true
  #[RequestParam] array   $ids    = [],     // ?ids[]=1&ids[]=2
): ResponseEntity { /* ... */ }

Имя ищется в трёх написаниях. Аргумент $pageSize поймает ?pageSize=, ?page_size= и ?page-size= — фронтенду не нужно подстраиваться под стиль именования PHP.

Массив приходит массивом строк

?ids[]=1&ids[]=2 даст ["1", "2"], а не [1, 2]: элементы не приводятся к типу, потому что объявить его негде — array в PHP не типизирован. Приводите сами или опишите поле в DTO.

Аргументы атрибута

Аргумент По умолчанию Что делает
name имя аргумента Точное имя параметра в query
php
#[RequestParam('per_page')] int $perPage = 20,   // только ?per_page=

Явное имя отключает поиск в трёх написаниях — совпадение проверяется только точное. Это ловушка: при #[RequestParam('page_size')] присланный ?pageSize=25 не подойдёт и молча уйдёт в значение по умолчанию.

Много значений — #[RequestQuery]

Когда параметров фильтра много, вместо десятка аргументов удобнее один объект. Поля заполняются из query по именам и приводятся к типам конструктора — по тем же правилам, что и #[RequestParam]:

main/OrderFilter.php
class OrderFilter
{
  public function __construct(
      public readonly int     $page   = 1,
      public readonly int     $limit  = 20,
      public readonly ?string $search = null,
  ) {}
}
php
#[GetMapping('orders')]
public function list(#[RequestQuery] OrderFilter $filter): ResponseEntity
{
  // ?page=2&limit=50&search=nokia → $filter->page === 2 (int, не строка)
}

Атрибут всегда необязателен: пустая query-строка даёт объект со значениями по умолчанию, а не ошибку. Поэтому у полей DTO имеет смысл всегда задавать дефолты.

Кроме своего класса принимает array (тогда придёт вся query как есть) и stdClass. Чтобы поля ещё и проверялись, добавьте #[Valid] — см. Валидацию.

Скаляр здесь роняет запрос

#[RequestQuery] принимает только объект или array. На int, string и прочие скаляры он бросает LogicException, то есть 500, а не 400:

Request query string parameter '$page' has unsupported type 'int'

Для одного значения нужен #[RequestParam]. Ошибка легко проходит ревью, потому что выглядит правдоподобно.

Заголовки — #[RequestHeader]

Имя заголовка выводится из имени аргумента: camelCase и snake_case переводятся в kebab-case, а поиск идёт без учёта регистра.

php
public function handle(
  #[RequestHeader] string  $authorization,     // Authorization
  #[RequestHeader] ?string $xRequestedWith,    // X-Requested-With
  #[RequestHeader] ?string $accept_language,   // Accept-Language
): ResponseEntity { /* ... */ }

Аргументы атрибута

Аргумент По умолчанию Что делает
name имя аргумента в kebab-case Точное имя заголовка
php
#[RequestHeader('X-Trace-Id')] string $trace,

Явное имя удобно, когда заголовок не переводится в имя переменной красиво, — но регистр по-прежнему не важен, так что 'x-trace-id' и 'X-Trace-Id' равнозначны.

Отсутствующий заголовок — это 400

Заголовок, как и остальные источники, обязателен по умолчанию. Клиент, забывший Authorization, получит 400, а не null внутри метода. Чтобы сделать заголовок необязательным, объявите тип ?string или задайте значение по умолчанию.

Для нескольких заголовков сразу или для тех, что нужны условно, проще взять HttpRequest и спросить getHeader() — привязка не обязана покрывать всё.

Тело запроса

Тело — самый нагруженный источник: в нём приходит JSON, форма или XML, а положить его нужно то в объект, то в массив, то целиком в строку. Поэтому атрибутов здесь четыре: один с автоопределением формата и три с явно заданным.

#[RequestBody] — формат по Content-Type

Основной вариант. Формат определяется по заголовку, а во что разобрать — по типу аргумента:

php
#[PostMapping('orders')]
public function create(#[RequestBody] CreateOrderDto $dto): ResponseEntity
{
  // тело разобрано и разложено по полям DTO
}
Тип аргумента Что придёт
Класс DTO Объект, поля заполнены из тела и приведены к типам конструктора
array Разобранное тело массивом
stdClass Разобранное тело объектом
string Сырое тело байтами, Content-Type игнорируется
Dto ...$items Список объектов из JSON-массива

Формат определяется по Content-Type:

Заголовок Как разбирается
application/xml, text/xml Как XML
application/x-www-form-urlencoded, multipart/form-data Как форма
Всё остальное Как JSON

То есть форма разбирается наравне с JSON — отдельный атрибут для неё нужен только тогда, когда формат надо зафиксировать, не полагаясь на заголовок клиента.

Сырое тело

Аргумент типа string отдаёт тело как есть, ничего не разбирая. Это то, что нужно для вебхуков с подписью: подпись считается по исходным байтам, и любой разбор с пересборкой её ломает.

php
#[PostMapping('webhook')]
public function webhook(
  #[RequestBody]   string $raw,
  #[RequestHeader] string $xSignature,
): ResponseEntity {
  if (!hash_equals($xSignature, hash_hmac('sha256', $raw, env('WEBHOOK_SECRET')))) {
      throw new RequestException('Bad signature');
  }
  // ...
}

Одно поле — аргумент field

Когда из тела нужно одно значение, необязательно заводить под него класс. Аргумент field достаёт значение по ключу и приводит к типу аргумента:

php
#[PatchMapping('{id:\d+}/name')]
public function rename(
  #[PathVariable] int $id,
  #[RequestBody(field: 'name')] string $name,
): ResponseEntity { /* {"name": "..."} → $name */ }

Поддерживается точечная нотация для вложенных структур:

php
#[RequestJson(field: 'user.email')] string $email,      // {"user":{"email":"..."}}
#[RequestBody(field: 'filter.minPrice')] int $min,      // приведёт к int

Значение обязательно по умолчанию, а ограничения на таком аргументе срабатывают сами#[Valid] для них не нужен:

php
#[RequestBody(field: 'name'), Size(5, 40)] string $name,

Аргумент field есть у всех четырёх атрибутов тела.

Принудительный формат

Когда клиент присылает неверный Content-Type или формат нужно зафиксировать, берите атрибут с явным форматом вместо автоопределения:

Атрибут Как разбирает тело
#[RequestJson] Всегда как JSON
#[RequestForm] Всегда как форму, независимо от заголовка
#[RequestXml] Всегда как XML
php
public function a(#[RequestJson] CreateOrderDto $dto): ResponseEntity {}
public function b(#[RequestForm] array $form): ResponseEntity {}
public function c(#[RequestXml]  \stdClass $node): ResponseEntity {}

Правила по типам аргумента и field у них те же, что у #[RequestBody].

Массив объектов

Вариадик-параметр разворачивает JSON-массив в набор DTO — для массовых операций:

php
#[PostMapping('orders/bulk')]
public function bulk(#[RequestBody, Valid] OrderDto ...$orders): ResponseEntity
{
  // [{"..."}, {"..."}] → $orders[0], $orders[1], ...
}

Тело обязано быть JSON-массивом; объект или что-то иное даст 400 с сообщением Expected JSON array for variadic body. Ошибки нумеруются по индексу элемента, так что видно, какой именно объект в пачке не прошёл.

Ошибки разбора

Поля DTO проверяются на наличие и тип, и все ошибки собираются разом, а не по первой:

text
POST /orders  {}

400 {"name": ["is required"], "qty": ["is required"]}

Это проверка структуры: поле есть, поле нужного типа. Содержательные правила — длина, диапазон, формат — включаются атрибутом #[Valid] на параметре, см. Валидацию.

Файлы

#[RequestFile] привязывает файл, загруженный через multipart/form-data. Кроме самой привязки атрибут умеет проверять размер и тип — то есть отсеивать негодную загрузку до входа в ваш метод.

Один файл

Аргумент типа array получает данные о файле — те же ключи, что в $_FILES:

php
#[PostMapping('avatar')]
public function upload(#[RequestFile('avatar')] array $file): ResponseEntity
{
  // ['name' => 'photo.jpg', 'type' => 'image/jpeg',
  //  'tmp_name' => '/tmp/php7Xy', 'size' => 12345, 'error' => 0]

  move_uploaded_file($file['tmp_name'], $target);
}

Если нужно не описание, а содержимое — объявите аргумент string, и файл будет прочитан за вас:

php
#[PostMapping('import')]
public function import(#[RequestFile('report')] string $csv): ResponseEntity
{
  // $csv — байты файла; временный файл читать самому не нужно
}

Несколько файлов

Поле с <input name="images[]" multiple> разбирается флагом multiple. Приходит список, даже если файл прислали один:

php
#[PostMapping('gallery')]
public function gallery(#[RequestFile('images', multiple: true)] array $images): ResponseEntity
{
  foreach ($images as $image) { /* каждый — такой же массив данных */ }
}

Без имени поля приходит вся карта загруженных файлов — ключи полей формы, значения описания:

php
#[RequestFile] array $files,   // ['avatar' => [...], 'images' => [[...], [...]]]

Проверка размера и типа

Аргумент По умолчанию Что делает
name Имя поля формы. Без него приходит вся карта файлов
multiple false Ожидать список файлов вместо одного
maxSize нет Предельный размер; сверх — 400
accept нет Допустимые типы; не подошёл — 400
php
#[RequestFile('avatar', maxSize: '5MB', accept: ['image/jpeg', 'image/png'])] array $avatar,

maxSize понимает B, KB, MB, GB и дробные значения ('1.5MB'); единицы двоичные, 1KB — это 1024 байта. Голое число считается байтами. При multiple: true предел применяется к каждому файлу отдельно.

accept принимает три формы записи:

Форма Пример Как проверяется
Точный MIME 'application/pdf' По содержимому файла
MIME с маской 'image/*' По содержимому файла
Расширение '.pdf' По имени файла от клиента

Расширение — не проверка

Первые две формы определяют тип по сигнатуре файла, через finfo, а не по Content-Type, который прислал браузер: переименованный или подменённый в запросе файл они отсекут.

Форма с расширением сравнивает лишь имя, пришедшее от клиента. Переименованный shell.php в shell.pdf её пройдёт. Пользуйтесь ею только там, где тип не влияет на безопасность, — во всех остальных случаях перечисляйте MIME.

Чего ждать при ошибке

Все проверки отвечают 400 и не пускают запрос в контроллер:

Что случилось Сообщение
Поле не прислали Uploaded file 'avatar' is missing
Сбой передачи Uploaded file 'avatar' transfer error (code 1)
Слишком большой Uploaded file 'avatar' exceeds maximum size of 5MB
Неподходящий тип Uploaded file 'avatar' type 'application/pdf' is not allowed (accepted: image/jpeg, image/png)

Файл можно сделать необязательным обычным способом — значением по умолчанию или типом ?array:

php
#[RequestFile('avatar')] ?array $avatar = null,   // без файла придёт null

Размер запроса ограничен отдельно

Загрузка крупнее, чем позволяет maxRequestSize сервера (по умолчанию 8 МБ), не дойдёт до этих проверок вовсе — сервер ответит 413 раньше. Поднимается это в настройках веб-слоя, см. Настройка веб-слоя.

Приведение типов

Из HTTP всё приходит строками. Резолвер приводит значение к типу, объявленному у аргумента, и если привести не получается — отвечает 400 ещё до входа в метод.

Тип аргумента Что принимает Пример
int Целое число строкой "42"42
float Число с точкой "3.14"3.14
bool true/false, 1/0, yes/no, on/off "yes"true
string Что угодно принимается как есть
array Только массив (?ids[]=) скаляр отвергается
Backed enum Значение из перечисления "draft"Status::Draft
DateTimeImmutable, DateTime Дату в ISO 8601 "2024-01-31T12:00:00"
BcMath\Number, Decimal\Decimal Числовую строку точное число без потерь

Сообщения об ошибке называют и параметр, и ожидание:

text
?page=abc     → 400  Query parameter 'page' must be an integer, got 'abc'
?status=hz    → 400  Query parameter 'status' must be one of [draft, published], got 'hz'
?from=31-01   → 400  Query parameter 'from' has invalid date '31-01' — expected ISO 8601

Точные числа

BcMath\Number и Decimal\Decimal получают значение строкой, минуя float, поэтому "1.1" остаётся 1.1, а не превращается в 1.1000000000000000888. Для денег это единственный безопасный вариант. Оба типа требуют своего расширения PHP — без него значение придёт как есть, без приведения.

Массив вместо скаляра

?id[]=1 при аргументе int $id даст 400 must be int, got array — клиент не подсунет массив туда, где ждали одно значение. Исключение — типы mixed и iterable: они массив принимают.

Обязательные и опциональные

Параметр из HTTP-источника обязателен по умолчанию. Необязательным его делают двумя способами:

php
int  $page          // обязателен: нет значения → 400
int  $page = 1      // нет значения → 1
?int $page          // нет значения → null
?int $page = null   // то же самое, но нагляднее

Порядок разрешения при отсутствии значения: есть значение по умолчанию — вернуть его; тип допускает null — вернуть null; иначе — 400.

Пустая строка — это присутствующее значение

?page (параметра нет) и ?page= (параметр есть, но пустой) — разные случаи. Первый берёт значение по умолчанию, второй идёт в приведение типа, а там результат зависит от типа:

Тип ?x= даёт
int, float 400 — пустая строка не число
DateTimeImmutable, enum 400
bool false — пустая строка считается ложью
string Пустую строку, это валидное значение

То есть ?active= не ошибка, а выключенный флаг.

Правила разрешения

Резолвер проверяет источники в фиксированном порядке и берёт первое совпадение. Знать порядок нужно в одном случае: если на параметре оказалось два атрибута, сработает тот, что выше.

text
 1. #[PathVariable]     сегмент пути
2. #[RequestParam]     один параметр query
3. #[RequestBody]      тело, формат по Content-Type
4. #[RequestFile]      загруженный файл
5. #[RequestJson]      тело как JSON
6. #[RequestForm]      тело как форма
7. #[RequestXml]       тело как XML
8. #[RequestQuery]     вся query-строка
9. #[RequestHeader]    заголовок
10. тип HttpRequest     объект запроса
11. тип HttpResponse    объект ответа
12. совпадение по имени сегмент пути без атрибута
13. значение по умолчанию
14. тип допускает null  → null

Пункт 12 объясняет, почему #[PathVariable] можно не писать: если имя аргумента совпадает с именем сегмента, значение найдётся и без атрибута. С атрибутом код читается однозначнее, поэтому в примерах он оставлен.

Если не подошло ни одно правило, запрос падает с RuntimeException:

text
Cannot resolve parameter '$foo' in PostController::create()
— add an annotation or a default value

Это ошибка не клиента, а сигнатуры метода, поэтому ответ будет 500. И проявится она при обращении к эндпоинту, а не при запуске приложения: резолвер работает на каждом запросе. Проверяйте новые обработчики хотя бы одним вызовом.

Один тип на параметр

Объединённые и пересечённые типы на HTTP-параметрах не поддерживаются — резолвер отвергает их до всякого приведения:

php
// ✗ LogicException: Union/intersection type on '$value' is not supported
public function a(#[RequestParam] int|string $value): void {}

// ✓ один тип
public function b(#[RequestParam] string $value): void {}

Причина простая: у объединения нет одного правила приведения — непонятно, чем должна стать строка "42".

Дальше