Tero

AltoRouter: Simplified PHP Routing and Best Practices

Feb 26th, 2025
1,268
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
PHP 11.42 KB | None | 0 0
  1. <?php
  2.  
  3. /*
  4. MIT License
  5.  
  6. Copyright (c) 2012 Danny van Kooten <[email protected]>
  7.  
  8. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
  9.  
  10. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
  11.  
  12. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  13. */
  14.  
  15. class AltoRouter
  16. {
  17.     /**
  18.      * @var array Array of all routes (incl. named routes).
  19.      */
  20.     protected $routes = [];
  21.  
  22.     /**
  23.      * @var array Array of all named routes.
  24.      */
  25.     protected $namedRoutes = [];
  26.  
  27.     /**
  28.      * @var string Can be used to ignore leading part of the Request URL (if main file lives in subdirectory of host)
  29.      */
  30.     protected $basePath = '';
  31.  
  32.     /**
  33.      * @var array Array of default match types (regex helpers)
  34.      */
  35.     protected $matchTypes = [
  36.         'i'  => '[0-9]++',
  37.         'a'  => '[0-9A-Za-z]++',
  38.         'h'  => '[0-9A-Fa-f]++',
  39.         '*'  => '.+?',
  40.         '**' => '.++',
  41.         ''   => '[^/\.]++'
  42.     ];
  43.  
  44.     /**
  45.      * Create router in one call from config.
  46.      *
  47.      * @param array $routes
  48.      * @param string $basePath
  49.      * @param array $matchTypes
  50.      * @throws Exception
  51.      */
  52.     public function __construct(array $routes = [], string $basePath = '', array $matchTypes = [])
  53.     {
  54.         $this->addRoutes($routes);
  55.         $this->setBasePath($basePath);
  56.         $this->addMatchTypes($matchTypes);
  57.     }
  58.  
  59.     /**
  60.      * Retrieves all routes.
  61.      * Useful if you want to process or display routes.
  62.      * @return array All routes.
  63.      */
  64.     public function getRoutes(): array
  65.     {
  66.         return $this->routes;
  67.     }
  68.  
  69.     /**
  70.      * Add multiple routes at once from array in the following format:
  71.      *
  72.      *   $routes = [
  73.      *      [$method, $route, $target, $name]
  74.      *   ];
  75.      *
  76.      * @param array $routes
  77.      * @return void
  78.      * @author Koen Punt
  79.      * @throws Exception
  80.      */
  81.     public function addRoutes($routes)
  82.     {
  83.         if (!is_array($routes) && !$routes instanceof Traversable) {
  84.             throw new RuntimeException('Routes should be an array or an instance of Traversable');
  85.         }
  86.         foreach ($routes as $route) {
  87.             call_user_func_array([$this, 'map'], $route);
  88.         }
  89.     }
  90.  
  91.     /**
  92.      * Set the base path.
  93.      * Useful if you are running your application from a subdirectory.
  94.      * @param string $basePath
  95.      */
  96.     public function setBasePath(string $basePath)
  97.     {
  98.         $this->basePath = $basePath;
  99.     }
  100.  
  101.     /**
  102.      * Add named match types. It uses array_merge so keys can be overwritten.
  103.      *
  104.      * @param array $matchTypes The key is the name and the value is the regex.
  105.      */
  106.     public function addMatchTypes(array $matchTypes)
  107.     {
  108.         $this->matchTypes = array_merge($this->matchTypes, $matchTypes);
  109.     }
  110.  
  111.     /**
  112.      * Map a route to a target
  113.      *
  114.      * @param string $method One of 5 HTTP Methods, or a pipe-separated list of multiple HTTP Methods (GET|POST|PATCH|PUT|DELETE)
  115.      * @param string $route The route regex, custom regex must start with an @. You can use multiple pre-set regex filters, like [i:id]
  116.      * @param mixed $target The target where this route should point to. Can be anything.
  117.      * @param string $name Optional name of this route. Supply if you want to reverse route this url in your application.
  118.      * @throws Exception
  119.      */
  120.     public function map(string $method, string $route, $target, ?string $name = null)
  121.     {
  122.  
  123.         $this->routes[] = [$method, $route, $target, $name];
  124.  
  125.         if ($name) {
  126.             if (isset($this->namedRoutes[$name])) {
  127.                 throw new RuntimeException("Can not redeclare route '{$name}'");
  128.             }
  129.             $this->namedRoutes[$name] = $route;
  130.         }
  131.     }
  132.  
  133.     /**
  134.      * Reversed routing
  135.      *
  136.      * Generate the URL for a named route. Replace regexes with supplied parameters
  137.      *
  138.      * @param string $routeName The name of the route.
  139.      * @param array $params Associative array of parameters to replace placeholders with.
  140.      * @return string The URL of the route with named parameters in place.
  141.      * @throws Exception
  142.      */
  143.     public function generate(string $routeName, array $params = []): string
  144.     {
  145.  
  146.         // Check if named route exists
  147.         if (!isset($this->namedRoutes[$routeName])) {
  148.             throw new RuntimeException("Route '{$routeName}' does not exist.");
  149.         }
  150.  
  151.         // Replace named parameters
  152.         $route = $this->namedRoutes[$routeName];
  153.  
  154.         // prepend base path to route url again
  155.         $url = $this->basePath . $route;
  156.  
  157.         if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) {
  158.             foreach ($matches as $index => $match) {
  159.                 list($block, $pre, $type, $param, $optional) = $match;
  160.  
  161.                 if ($pre) {
  162.                     $block = substr($block, 1);
  163.                 }
  164.  
  165.                 if (isset($params[$param])) {
  166.                     // Part is found, replace for param value
  167.                     $url = str_replace($block, $params[$param], $url);
  168.                 } elseif ($optional && $index !== 0) {
  169.                     // Only strip preceding slash if it's not at the base
  170.                     $url = str_replace($pre . $block, '', $url);
  171.                 } else {
  172.                     // Strip match block
  173.                     $url = str_replace($block, '', $url);
  174.                 }
  175.             }
  176.         }
  177.  
  178.         return $url;
  179.     }
  180.  
  181.     /**
  182.      * Match a given Request Url against stored routes
  183.      * @param string $requestUrl
  184.      * @param string $requestMethod
  185.      * @return array|boolean Array with route information on success, false on failure (no match).
  186.      */
  187.     public function match(?string $requestUrl = null, ?string $requestMethod = null)
  188.     {
  189.         $params = [];
  190.        
  191.         // set Request Url if it isn't passed as parameter
  192.         if ($requestUrl === null) {
  193.             $requestUrl = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
  194.         }
  195.    
  196.         // strip base path from request url
  197.         $requestUrl = substr($requestUrl, strlen($this->basePath));
  198.    
  199.         // Strip query string (?a=b) from Request Url
  200.         if (($strpos = strpos($requestUrl, '?')) !== false) {
  201.             $requestUrl = substr($requestUrl, 0, $strpos);
  202.         }
  203.    
  204.         $lastRequestUrlChar = $requestUrl ? $requestUrl[strlen($requestUrl) - 1] : '';
  205.    
  206.         // set Request Method if it isn't passed as a parameter
  207.         if ($requestMethod === null) {
  208.             $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
  209.         }
  210.    
  211.         foreach ($this->routes as $handler) {
  212.             list($methods, $route, $target, $name) = $handler;
  213.    
  214.             $method_match = (stripos($methods, $requestMethod) !== false);
  215.    
  216.             // Method did not match, continue to next route.
  217.             if (!$method_match) {
  218.                 continue;
  219.             }
  220.    
  221.             if ($route === '*') {
  222.                 // * wildcard (matches all)
  223.                 $match = true;
  224.             } elseif (isset($route[0]) && $route[0] === '@') {
  225.                 // @ regex delimiter
  226.                 $pattern = '`' . substr($route, 1) . '`u';
  227.                 $match = preg_match($pattern, $requestUrl, $params) === 1;
  228.             } elseif (($position = strpos($route, '[')) === false) {
  229.                 // No params in url, do string comparison
  230.                 $match = strcmp($requestUrl, $route) === 0;
  231.             } else {
  232.                 // Compare longest non-param string with url before moving on to regex
  233.                 if (strncmp($requestUrl, $route, $position) !== 0 && ($lastRequestUrlChar === '/' || $route[$position - 1] !== '/')) {
  234.                     continue;
  235.                 }
  236.    
  237.                 $regex = $this->compileRoute($route);
  238.                 $match = preg_match($regex, $requestUrl, $params) === 1;
  239.             }
  240.    
  241.             if ($match) {
  242.                 // Remove numeric keys
  243.                 if ($params) {
  244.                     foreach ($params as $key => $value) {
  245.                         if (is_numeric($key)) {
  246.                             unset($params[$key]);
  247.                         }
  248.                     }
  249.                 }
  250.    
  251.                 // Check for optional parameters and set default values
  252.                 $reflection = new ReflectionFunction($target);
  253.                 $paramsList = $reflection->getParameters();
  254.                 foreach ($paramsList as $param) {
  255.                     $paramName = $param->getName();
  256.                     if (isset($params[$paramName])) {
  257.                         // Param is set in the route
  258.                         continue;
  259.                     } elseif ($param->isDefaultValueAvailable()) {
  260.                         // Param is not set in the route, use default
  261.                         $params[$paramName] = $param->getDefaultValue();
  262.                     } else {
  263.                         // Param is required but not provided
  264.                         return false;
  265.                     }
  266.                 }
  267.    
  268.                 return [
  269.                     'target' => $target,
  270.                     'params' => $params,
  271.                     'name' => $name
  272.                 ];
  273.             }
  274.         }
  275.    
  276.         return false;
  277.     }
  278.    
  279.  
  280.     /**
  281.      * Compile the regex for a given route (EXPENSIVE)
  282.      * @param string $route
  283.      * @return string
  284.      */
  285.     protected function compileRoute(string $route): string
  286.     {
  287.         if (preg_match_all('`(/|\.|)\[([^:\]]*+)(?::([^:\]]*+))?\](\?|)`', $route, $matches, PREG_SET_ORDER)) {
  288.             $matchTypes = $this->matchTypes;
  289.             foreach ($matches as $match) {
  290.                 list($block, $pre, $type, $param, $optional) = $match;
  291.  
  292.                 if (isset($matchTypes[$type])) {
  293.                     $type = $matchTypes[$type];
  294.                 }
  295.                 if ($pre === '.') {
  296.                     $pre = '\.';
  297.                 }
  298.  
  299.                 $optional = $optional !== '' ? '?' : null;
  300.  
  301.                 //Older versions of PCRE require the 'P' in (?P<named>)
  302.                 $pattern = '(?:'
  303.                         . ($pre !== '' ? $pre : null)
  304.                         . '('
  305.                         . ($param !== '' ? "?P<$param>" : null)
  306.                         . $type
  307.                         . ')'
  308.                         . $optional
  309.                         . ')'
  310.                         . $optional;
  311.  
  312.                 $route = str_replace($block, $pattern, $route);
  313.             }
  314.         }
  315.         return "`^$route$`u";
  316.     }
  317. }
Tags: AltoRouter
Advertisement