Роутинг¶
Namespace Keel\Routing. Маршруты объявляются атрибутами на методах контроллеров,
компилируются в таблицы сопоставления и кэшируются в PHP-файл.
Объявление¶
use Keel\Routing\Attribute\{Get, Post, Put, Delete, Route, Prefix, Middleware, Status};
#[Prefix('/api/users')]
#[Middleware(AuthMiddleware::class)]
final class UserController
{
#[Get('/{id:\d+}', name: 'users.show')]
public function show(int $id): UserDto { /* ... */ }
#[Post('')]
#[Status(201)]
public function create(#[FromBody] CreateUser $command): UserDto { /* ... */ }
#[Route('/export', methods: ['GET', 'POST'], name: 'users.export')]
#[Middleware(RateLimit::class)]
public function export(): ResponseInterface { /* ... */ }
}
#[Get],#[Post],#[Put],#[Patch],#[Delete]— сокращения для#[Route]с одним методом. Атрибуты повторяемые: один метод может обслуживать несколько путей.#[Prefix]на классе добавляется ко всем путям класса. Пустой путь''или'/'в методе даёт ровно префикс (/api, а не/api/). Префикс родительского класса идёт первым: родительские классы — это группы маршрутов, см. ниже.#[Middleware]на классе применяется ко всем маршрутам класса и выполняется раньше middleware метода.#[MiddlewareWith(Throttle::class, limit: 5, window: 60)]добавляет один middleware, собранный с аргументами конструктора, — для middleware без собственного атрибута (сторонних или универсальных). Именованные аргументы идут в одноимённые параметры, остальные автовайрятся; экземпляр создаётся один раз на маршрут и живёт дальше, поэтому не должен хранить состояние запроса. Значения проходят через кэш маршрутов и должны быть экспортируемыми. Та же парная форма,[Throttle::class, ['limit' => 5]], работает в глобальном списке middleware вHttpConfig.#[Redirect('/old', '/new', 308)]на классе объявляет маршрут, который только перенаправляет; см. Редиректы.#[Status]задаёт статус ответа, которыйResponderпостроит из возвращённого значения.- Хэндлер — только публичный нестатический метод. Замыкания не поддерживаются: их нельзя скомпилировать в кэш.
Атрибуты-расширения¶
Два интерфейса позволяют другим атрибутам путешествовать вместе с маршрутом. RouteOption
сохраняет значение на маршруте (Route::$options[key], читается Route::option('key')): на классе
действует на все маршруты, атрибут на методе с тем же ключом перекрывает его; значения проходят
через кэш маршрутов, поэтому должны быть экспортируемыми (скаляры, массивы). MiddlewareProvider
добавляет к маршруту классы middleware; сам #[Middleware] — один из них. #[Auth] и
#[RateLimit] реализуют оба, так они и применяют себя сами: атрибут записывает настройки и
добавляет guard, который читает их из атрибута запроса RouteMatch.
Синтаксис пути¶
{name}— сегмент без/([^/]+).{name:regex}— своё регулярное выражение, например{id:\d+},{year:\d{4}},{lang:(ru|en)}. Обычные захватывающие группы внутри превращаются в незахватывающие (\(и[(]остаются литералами); именованную группу вроде(?<n>...)нейтрализовать нельзя, и она отклоняется сRoutingException.- Паттерн проверяется по percent-encoded пути (см. «Сопоставление»):
\d+и[a-z-]+работают, а\p{L}и любое другое не-ASCII ограничение никогда не совпадёт с закодированным%C3%B6. Для не-ASCII значений оставляйте[^/]+по умолчанию и проверяйте декодированное значение в хэндлере. - Путь начинается с
/. Завершающий слэш — часть пути:/usersи/users/разные маршруты. [...]в конце пути — опциональные сегменты, при необходимости вложенные:/users[/{id:\d+}[/{tab}]]обслуживает/users,/users/5и/users/5/posts. Путь метода, целиком опциональный (#[Get('[/{path:.+}]')]), приклеивается к префиксу класса. Короткое совпадение не кладёт свои плейсхолдеры в параметры, и аргумент хэндлера берёт значение по умолчанию (?int $id = null,string $tab = 'profile'); параметр без умолчания — ошибка конфигурации. Скобки допустимы только в конце:/a[/b]/cи/a[/b][/c]отклоняются.[внутри ограничения ({slug:[a-z]+}) — символьный класс, а не маркер.
Группы¶
Родительские классы контроллера работают как группы маршрутов. #[Prefix], #[Middleware] и
любой атрибут RouteOption (#[Auth], #[RateLimit], #[CacheControl], ...), объявленный на
родителе, действует на наследника: префиксы склеиваются от корня, middleware родителя
выполняется первым, а опция, объявленная заново на наследнике или методе, перекрывает
родительскую.
#[Prefix('/api/v1')]
#[Middleware(ApiKeyMiddleware::class)]
#[Auth]
abstract class ApiController {}
#[Prefix('/users')] // маршруты начинаются с /api/v1/users
final class UserController extends ApiController
{
#[Get('/{id}')]
public function show(int $id): UserDto { /* ... */ }
}
Абстрактный родитель сам контроллером не является (discovery пропускает классы, которые нельзя создать), так что ничего не объявляется дважды. Вложенность не ограничена.
Редиректы¶
#[Prefix('/api')]
#[Redirect('/v1/users/{id}', '/api/v2/users/{id}', 308, name: 'users.v1')] // /api/v1/users/{id}
#[Redirect('/docs', 'https://docs.example.com/', methods: ['GET', 'POST'])]
final class UserController { /* ... */ }
#[Redirect(from, to, status, methods, name)] — повторяемый атрибут на классе контроллера,
метод ему не нужен: маршрут обслуживает Keel\Routing\Redirector, отвечая статусом (302 по
умолчанию; 301, 303, 307 или 308) и Location, построенным из to. Префикс и middleware
класса применяются к from как к любому маршруту; to используется как написано — путь или
абсолютный http(s) URL — и может повторять плейсхолдеры from, чьи совпавшие значения
подставляются в percent-encoded виде. Маршруты-редиректы видны в keel:routes и не попадают в
документ OpenAPI.
Из хэндлера верните Keel\Http\Redirect: Redirect::to('/login'), Redirect::to('/done', 303)
или Redirect::route('users.show', ['id' => 5]) — Responder превратит его в ответ (именованная
форма требует Router, который ядро внедряет само). Responder::redirect($location, $status)
строит такой же ответ напрямую.
Сопоставление¶
Статические пути ищутся в хэше, динамические — одним preg_match на чанк из 10 маршрутов
(регулярные выражения объединяются через (?|...), номер совпавшей группы определяет
маршрут, как в FastRoute). Сопоставление идёт по сырому пути запроса без декодирования,
чтобы %2F не менял структуру; значения плейсхолдеров декодируются через rawurldecode
по отдельности. Ограничение \d+ проверяется до декодирования, поэтому /users/%34%32
не проходит.
Статический путь всегда побеждает динамический. Среди динамических маршрутов одного метода
побеждает первый объявленный подходящий, поэтому при пересечении паттернов
(/items/{id:\d+} и /items/{slug} оба подходят к /items/42) объявляйте более узкий первым.
Порядок объявления — это порядок методов с атрибутами в контроллере, контроллеров в
fromControllers() или списка в fromRoutes(); кэш маршрутов его сохраняет.
Результат Router::match($method, $path) — MatchResult со статусом Found
(RouteMatch с маршрутом и параметрами), NotFound или MethodNotAllowed со списком
allowed. HEAD без своего маршрута обслуживается маршрутом GET, а allowed (заголовок
Allow у 405) содержит HEAD всякий раз, когда содержит GET. Пустой путь запроса (PSR-7
допускает его для http://host) роутится как /.
Кэш¶
Router::fromControllers(UserController::class)->write('var/cache/routes.php'); // при сборке
$router = Router::fromCache('var/cache/routes.php'); // в проде
Файл кэша — PHP-массив, читается одним require и попадает в opcache. В dev
Router::fromControllers() на каждый запрос.
Генерация URL¶
$router->generate('users.show', ['id' => 5]); // /api/users/5
$router->generate('users.show', ['id' => 5, 'page' => 2]); // /api/users/5?page=2
Лишние параметры уходят в query string, null пропускается, значения кодируются
rawurlencode. С опциональными сегментами генерируется самый длинный вариант, все плейсхолдеры
которого заданы (и не null): generate('users') даёт /users, generate('users', ['id' => 5])
— /users/5. Каждое значение проверяется по ограничению плейсхолдера — либо как есть (с /u,
так что {slug:[\p{L}-]+} принимает wörld), либо в закодированном виде (a/b удовлетворяет
[^/]+ как a%2Fb, ровно так его увидит и матчер). Отсутствующий параметр, нарушение
паттерна и неизвестное имя дают RoutingException.
Подписанные URL¶
Keel\Auth\UrlSigner подписывает ссылки, которые сами дают доступ: скачивание, отписка,
подтверждение email. Путь строит роутер, подпись — CookieSigner со своим назначением, а
#[Signed] проверяет её на входе.
// config/signed_url.php
return static fn(Env $env): SignedUrlConfig => new SignedUrlConfig(
secret: $env->require('APP_SECRET'), // от 32 символов
ttl: 86_400, // срок по умолчанию в секундах; null — бессрочно
baseUrl: 'https://app.example.com', // для ссылок, уходящих из приложения, например в письма
);
#[Get('/downloads/{id:\d+}', name: 'downloads.show')]
#[Signed] // 403 без верной и не истёкшей подписи
public function download(int $id): Response { /* ... */ }
$signer->route('downloads.show', ['id' => 5, 'format' => 'pdf'], expires: 3600);
// https://app.example.com/downloads/5?format=pdf&expires=1767225600&signature=Xb3...
$signer->sign('/reports/q3?inline=1', new \DateTimeImmutable('+1 day')); // любой путь
$signer->verify($url); // true / false
$signer->status($request->getUri()); // SignedUrlStatus::Valid, Missing, Invalid, Expired
- Подпись покрывает путь и всю query string ровно в том виде, в каком она отправлена, вместе с
expires: добавленный, удалённый, переставленный или изменённый параметр либо более поздний срок дают 403Invalid URL signature; истёкший срок —This link has expired; отсутствие подписи —This URL must be signed. - Схема, хост и
baseUrlне подписываются: ссылка верна везде, где приложение получает тот же путь. За прокси, который монтирует приложение под префиксом, укажите префикс вbaseUrl(https://example.com/app), и ссылки останутся верными. expires— секунды от текущего момента илиDateTimeInterface; именаsignatureиexpiresв query зарезервированы.purpose(по умолчаниюsigned-url) отделяет эти подписи от cookie сессии, подписанных тем же секретом.- Ссылка действует, пока не истечёт срок или не сменится секрет. Чтобы отзывать отдельные ссылки, кладите в них что-то отзываемое (токен, версию) и проверяйте это в обработчике.
- В OpenAPI у операций с
#[Signed]есть query-параметрыexpiresиsignatureи ответ 403.
Ошибки конфигурации¶
Keel\Routing\Exception\RoutingException (наследник LogicException): путь без ведущего
/, повторённое имя плейсхолдера, невалидный паттерн или паттерн с захватывающей группой,
которую нельзя нейтрализовать, дважды объявленное имя маршрута, дважды объявленный для одного
метода статический путь или динамический паттерн (/u/{id} и /u/{slug} компилируются в одну
регулярку), статический или абстрактный хэндлер, опциональные сегменты не в конце пути,
#[Redirect] со статусом не 3xx или с целью, которая не путь и не http(s) URL.