['r' => '([0-9]+)', 'ro' => '([0-9]*)', 't' => TYPE_UINT], 'page' => ['r' => '([0-9]+)', 'ro' => '([0-9]*)', 't' => TYPE_UINT], 'any' => ['r' => '(.*)', 'ro' => '(.*)', 't' => TYPE_NOTAGS], ]; /** * Текущий роут * @var \bff\base\Route|null */ protected $current = null; /** * Настройки группы роутов * @var array */ protected $group_settings = []; /** * Router constructor. * @param \bff\contracts\Application $app */ public function __construct(\bff\contracts\Application $app) { $this->app = $app; $this->setRequest($this->app->request()); $this->addMany( $this->app->filter('routes', []) ); } public function onNewRequest($request) { $this->setRequest($request); } /** * Устанавливаем объект запроса * @param \bff\http\Request $request * @return void */ public function setRequest($request) { $this->request = $request; $this->uri = false; $this->uri_final = false; $this->current = null; } /** * Получаем текущий URI запроса * @param bool $originalOnly только исходный URI * @return string */ public function getUri(bool $originalOnly = false): string { if ($this->uri === false) { # // => / $uri = preg_replace('/\/+/', '/', $this->request->uri()); # remove first / (left) $uri = ltrim($uri, '/'); # remove query "?xxx" $uri = preg_replace("/^(.*)\?.*$/U", '$1', $uri); # remove segments $uri = Url::segmentsCut($uri, true); # cache $this->uri = $this->uri_final = $uri; } if ($originalOnly) { return $this->uri; } return $this->uri_final; } /** * Добавляем группу роутов с общими настройками * @param array $settings * @param \Closure $callback */ public function group(array $settings, Closure $callback) { $settingsBefore = $this->group_settings; $this->group_settings = $settings; $callback($this); $this->group_settings = $settingsBefore; } /** * Добавление нескольких роутов * @param array|string $routes * @return void */ public function addMany($routes) { # file if (is_string($routes)) { $file = $routes; if (! is_file($file)) { return; } $routes = include modification($file); } if (! is_array($routes)) { return; } # routes foreach ($routes as $key => $value) { if (is_string($value)) { $value = [ 'uri' => $key, 'action' => $value, ]; } if (is_array($value)) { $this->add($key, $value); } } } /** * Create and add route * @param string $id * @param array $settings * @return \bff\base\Route|null */ public function add(string $id, array $settings) { $route = $this->newRoute($id, $settings); if (! is_null($route)) { $this->addRoute($route); } return $route; } /** * Add route * @param \bff\base\Route $route */ public function addRoute(Route $route) { $this->routes[$route->getId()] = $route; } /** * Create new route from settings array * @param string $id * @param array $settings * @return \bff\base\Route|null */ public function newRoute(string $id, array $settings) { # + group settings $settings = array_merge($this->group_settings, $settings); # uri: $uri = $settings['uri'] ?? $settings['pattern'] ?? ''; if (! is_string($uri)) { return null; } $uri = ltrim(bff::filter('routes.pattern.' . $id, $uri, $settings), '/'); $prefix = $settings['prefix'] ?? ''; if (! empty(trim($prefix, '/'))) { $uri = ltrim($prefix, '/') . $uri; } # action: $action = $settings['action'] ?? $settings['callback'] ?? function () { // }; # method: $method = $settings['method'] ?? ['GET','POST','HEAD']; if (is_string($method)) { $method = explode(',', $method); } # Route: $route = new Route($this, $id, $uri, $action, $method); # priority: if (isset($settings['priority'])) { $this->setRoutePriority($id, $settings['priority']); } # before callback: if (isset($settings['before'])) { $route->before($settings['before']); } # url builder callback: if (isset($settings['url'])) { $route->url($settings['url']); } # middleware: $route->middleware($settings['middleware'] ?? 'web'); $route->withoutMiddleware($settings['withoutMiddleware'] ?? []); # where: if (isset($settings['where']) && is_array($settings['where'])) { foreach ($settings['where'] as $key => $value) { $route->where($key, $value); } } # defaults: if (isset($settings['defaults'])) { $route->defaults($settings['defaults']); } # alias: if (isset($settings['alias'])) { $route->alias($settings['alias']); } # block: if (isset($settings['block'])) { $route->block($settings['block'], $settings['wait'] ?? 10); } # page: $route->page($settings['page'] ?? []); return $route; } /** * Register new GET route * @param string $id * @param string $uri * @param mixed $action * @return \bff\base\Route|null */ public function get(string $id, string $uri, $action) { return $this->add($id, [ 'uri' => $uri, 'action' => $action, 'method' => ['GET', 'HEAD'], ]); } /** * Register new GET/POST route * @param string $id * @param string $uri * @param mixed $action * @return \bff\base\Route|null */ public function getpost(string $id, string $uri, $action) { return $this->add($id, [ 'uri' => $uri, 'action' => $action, 'method' => ['GET', 'POST', 'HEAD'], ]); } /** * Register new POST route * @param string $id * @param string $uri * @param mixed $action * @return \bff\base\Route|null */ public function post(string $id, string $uri, $action) { return $this->add($id, [ 'uri' => $uri, 'action' => $action, 'method' => ['POST'], ]); } /** * Get route by id * @param string $id * @return \bff\base\Route|null */ public function getRoute(string $id) { return $this->routes[$id] ?? null; } /** * Get registered routes pages list * @return array */ public function getRoutesPages() { $pages = []; foreach ($this->routes as $route) { $pages = array_merge($pages, $route->getPages()); } return $pages; } /** * Parse pattern and return regexp + placeholders * @param string $uri * @param array $where * @return array */ public function parseUri(string $uri, array $where = []) { $placeholders = []; if (preg_match(static::REGEX_PLACEHOLDER, $uri) > 0) { $i = 1; $uri = preg_replace_callback(static::REGEX_PLACEHOLDER, function ($m) use ($where, &$placeholders, &$i) { $name = $m[1]; $optional = (strpos($m[0], '?') !== false); if (array_key_exists($name, $where)) { if (is_string($where[$name])) { $regex = ['t' => TYPE_STR]; $regex['r'] = $regex['ro'] = $where[$name]; } else { $regex = $where[$name]; } } elseif (array_key_exists($name, $this->wheres)) { $regex = $this->wheres[$name]; } else { $regex = ['r' => '(.*)','ro' => '(.*)', 't' => TYPE_STR]; } $placeholders[$name] = [ 'index' => $i++, 'search' => str_replace('\\', '', $m[0]), 'replace' => str_replace($name, '{v}', trim($m[0], '{}\\?')), 'optional' => $optional, 'type' => ($regex['t'] ?? TYPE_STR), ]; return $regex[($optional ? 'ro' : 'r')]; }, strtr(preg_quote($uri, static::REGEX_DELIMITER), ['\\{' => '{','\\}' => '}'])); } return [ 'regex' => static::REGEX_DELIMITER . '^' . $uri . '$' . static::REGEX_DELIMITER . 'i', 'placeholders' => $placeholders, ]; } /** * Поиск роутов в doc-комментариях к методам класса * @param string|object $className название класса * @return array */ public function parseClass($className): array { $routes = []; $extract = function ($param, $comment, $default = false) { if (preg_match(sprintf(static::REGEX_ANNOTATION, $param), $comment, $matches) > 0 && !empty($matches[1])) { return trim($matches[1]); } return $default; }; $methods = (new ReflectionClass($className))->getMethods(ReflectionMethod::IS_PUBLIC); $extra = false; foreach ($methods as $method) { $name = $method->getName(); $comment = $method->getDocComment(); if (($pattern = $extract('route', $comment)) !== false) { $routes[$name] = [ 'pattern' => $pattern, 'priority' => intval($extract('route-priority', $comment, 0)), 'method' => explode(',', $extract('route-method', $comment, 'GET,POST')), ]; } if ($method->isStatic() && $name === 'routes') { $extra = $method; } } if ($extra !== false) { $routesArr = $className::routes(); if (! empty($routesArr) && is_array($routesArr)) { foreach ($routesArr as $k => $v) { if (isset($routes[$k])) { $routes[$k] = array_merge($routes[$k], $v); } else { $routes[$k] = $v; } } } } return $routes; } /** * Формирование URL роута * @param string|\bff\base\Route $route id роута * @param array $params параметры роута * @param array $options параметры для формирования полного URL * @return string */ public function url($route, array $params = [], array $options = []): string { if (is_string($route) && array_key_exists($route, $this->routes)) { $route = $this->routes[$route]; } if (! ($route instanceof Route)) { return '/'; } return $route->getUrl($params, array_merge([ 'scheme' => $this->request->scheme(), 'dynamic' => false, ], $options)); } /** * Поиск роута исходя из текущего запроса * @param array|bool $opts настройки: * 'direct' - проверка прямого обращения к controller/action * 'seo-landing-pages' - использовать ли SEO Посадочные страницы * 'seo-redirects' - использовать ли SEO Редиректы * @return array|bff\base\Route|\bff\http\Response */ public function search(array $opts = []) { # default options: func::array_defaults($opts, [ 'direct' => false, 'seo-landing-pages' => false, 'seo-redirects' => false, ]); # direct route: if ($opts['direct'] && $direct = $this->isDirectRoute()) { return ($this->current = $direct); } # request uri & input: $req = $opts['uri'] ?? $this->getUri(true); $input = $this->request->input(); # seo landing pages: if ($opts['seo-landing-pages']) { $reqNew = SEO::landingPage($req, $this->request); if ($reqNew !== false && $req !== $reqNew) { $this->uri_final = $req; $req = ltrim($reqNew, '/ '); $query = mb_stripos($req, '?'); if ($query !== false) { list($query, $req) = [mb_substr($req, $query + 1), mb_substr($req, 0, $query)]; if (!empty($query)) { parse_str($query, $query); if (!empty($query)) { foreach ($query as $k => $v) { $input->setGet($k, $v, false); $input->setPost($k, $v, false); } } } } } } # before search: $this->routes = bff::filter('routing.search.before', $this->routes, $opts, $this); # reorder routes by priority $this->reorder(); # first matched route $route = null; $method = $this->request->method(); foreach ($this->routes as $test) { if ($test->isAlias()) { continue; } if ($test->matches($req, $method)) { $route = $test; break; } } if ($route) { # add route params to current request input $params = $route->getParams(); if (! empty($params)) { foreach ($params as $paramKey => $paramValue) { $input->setGet($paramKey, $paramValue); } } } # seo redirects: if ($opts['seo-redirects'] && empty($reqNew)/* skip landing pages */ && $this->request->isGET()) { $redirect = SEO::redirectsProcess($req, $this->request); if ($redirect !== false) { $route = $this->get('_seoredirect', '', function () use ($redirect) { return Response::redirect($redirect['to'], $redirect['status']); }); } } return ($this->current = $route); } /** * Contoller and action were specified in request * @return \bff\base\Route|null */ public function isDirectRoute() { $controller = $this->request->get('s', [TYPE_TEXT, 'len' => 250]); $action = $this->request->get('ev', [TYPE_TEXT, 'len' => 250]); # Set default action for ajax requests if ($this->request->isAJAX() && $this->request->get('bff') === 'ajax') { if ($controller && !$action) { $action = 'ajax'; } } if ($controller && $action) { return $this->get(static::DIRECT_ROUTE, '', $controller . '/' . $action . '/'); } return null; } /** * Gather route middleware * @param \bff\http\Request $request * @param \bff\base\Route $route * @return \bff\http\Response|mixed */ public function runRoute(Request $request, Route $route) { try { # Run $response = $route->run($request); if ($response instanceof Block) { $response = $response->render(); } } catch (ResponseException $e) { # Special type of exception in cases where unable to implement proper "return Response" return $e->getResponse(); } catch (ModelRecordNotFoundException $e) { if (Errors::no()) { Errors::unknownRecord(); } if ($request->isAJAX()) { return Response::json(['data' => [], 'errors' => Errors::get()]); } } catch (NotFoundException $e) { return Response::notFound($e->getResponse()); } catch (Throwable $e) { if (! bff()->isDebug()) { return Errors::error404(); } return Errors::handleException($e); } return $response; } /** * Reoder routes by priority * @return void */ protected function reorder() { $i = 1; $order = []; foreach ($this->routes as $id => $route) { $order[$id] = (!empty($this->routesPriority[$id]) ? $this->routesPriority[$id] : $i++); } array_multisort($order, SORT_ASC, $this->routes); } /** * Set route priority * @param string $id * @param int $priority */ public function setRoutePriority($id, int $priority) { $this->routesPriority[$id] = $priority; } /** * Get current route instance. * @return \bff\base\Route|null */ public function current() { return $this->current; } /** * Проверка соответствует ли текущий запрос указанному роуту * @param string|array $id ключ роута * @param array $params параметры роута * @param array $options * @return bool */ public function isCurrent($id, array $params = [], array $options = []): bool { if (is_string($id)) { $id = [$id]; } if ($this->current !== null && $this->current->is($id)) { return true; } if (is_array($id)) { $url = $this->request->url(true); foreach ($id as $v) { if ($this->url($v, $params, $options) === $url) { return true; } } } return false; } /** * Проверка обрабатывается ли текущий запрос указанным контроллером * @param string $name название контроллера: модуля/плагина/темы или 'контроллер:метод' * @param array $opts доп. параметры: * bool|string|array 'method' проверяем соответствие метода текущего запроса: GET, POST, ... * @return bool */ public function isCurrentController(string $name, array $opts = []): bool { # request method if (! empty($opts['method']) && ! $this->isRequestMethod($opts['method'])) { return false; } # route controller & method if (mb_stripos($name, '::')) { [$controller, $action] = explode('::', $name, 2); return ( $this->controllerName() === $controller && $this->controllerMethod() === $action ); } # route controller return $this->controllerName() === $name; } /** * Get current route controller name * @return string */ public function controllerName() { if ($this->current) { return $this->current->getControllerName(); } return ''; } /** * Get current route controller method * @deprecated use controllerMethod() * @return string */ public function controllerAction() { return $this->controllerMethod(); } /** * Get current route controller method * @return string */ public function controllerMethod() { if ($this->current) { return $this->current->getControllerMethod(); } return ''; } /** * Get current route parameter * @param string $key * @param mixed $default * @param mixed $validate * @return mixed */ public function input($key, $default = null, $validate = TYPE_NOCLEAN) { if ($this->current) { return $this->current->param($key, $default, $validate); } return $default; } /** * Сверяем с текущим методом запроса * @param string|array $method метод запроса или несколько методов * @return bool */ public function isRequestMethod($method): bool { if (! is_array($method)) { $method = [$method]; } $current = $this->request->method(); foreach ($method as $m) { if (is_string($m) && $current === $m) { return true; } } return false; } }