Перейти к содержанию

Роутинг

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: добавленный, удалённый, переставленный или изменённый параметр либо более поздний срок дают 403 Invalid 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.