I made a routing system for PHP inspired by Symfony's router composed of a few classes.
First I am using Symfony's HTTP Foundations component. Then, I am emulating the classes in the routing component but with almost completely my own implementation (I did copy few lines).
The whole system is not simple so I won't be making it the focus of the question, though, this is the GitHub link, and I would be grateful for a full review (rip me apart).
I will provide the class that matches and parses the routes and I would like to know what I can improve.
There is a parser class
<?php
namespace Routing;
/**
* Takes an instance of Routing\Route object
* Extracts the variables from wildcards
* Calculates a regex that can be used to match routes with urls
*/
class RouteParser
{
/**
* Asks for a route, extracts info that can be used later
*
* @param Route Routing/Route
*
* @return array Array with parsed values
*/
public function parseRoute(Route $route)
{
$variables = array();
$parsed = self::parse($route->getPath());
return ['variables' => $parsed['variables'], 'matcherReg' => $parsed['regex']];
}
/**
* Takes a string pattern, matches it with regexes
*
* @param string The pattern
*
* @return array Array with parsed values
*/
private static function parse($pattern)
{
$matches = '';
$variables = array();
$pos = 0;
$reg = '#'; //It seems that regex must start and end with a delimiter
$nextText = '';
if($pattern == '/')
{
$reg = '#^[\/]+$#';
return ['variables' => '', 'regex' => $reg];
}
//Check if generated regexes are stored, if so it skips the whole process
if(apc_exists($pattern))
{
$cacheI = apc_fetch($pattern);
return $cacheI;
}
//Extracts the variables enclosed in {}
preg_match_all('#\{\w+\}#', $pattern, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
//Puts each variable in array
//Uses the text before and after to create a regex for the rest of the pattern - $precedingText, $nextText
//If no wildcard is detected in the path it splits it into segments and compiles are regex
foreach ($matches as $match)
{
$varName = substr($match[0][0], 1, -1);
$precedingText = substr($pattern, $pos, $match[0][1] - $pos);
$pos = $match[0][1] + strlen($match[0][0]);
$nxt = $pos - strlen($pattern);
if($nxt == 0) $nxt = strlen($pattern);
$nextText = substr($pattern, $nxt);
$precSegments = explode('/', $precedingText);
$precSegments = array_splice($precSegments, 1);
//Pulls a regex from the preeceding segment, each variable segment is replaced with '\/[a-zA-Z0-9]+'
if(strlen($precedingText) > 1)
{
foreach($precSegments as $key => $value) {
$reg .= '\/';
$reg .= $value;
}
$reg .= '[a-zA-Z0-9]+';
}
else
{
$reg .= '\/[a-zA-Z0-9]+';
}
$nextText = str_replace('/', '\/', $nextText);
if(is_numeric($varName)) {
throw new \Exception('Argument cannot be a number');
}
if (in_array($varName, $variables)) {
throw new \Exception(sprintf('More then one occurrence of variable name "%s".', $varName));
}
$variables[] = $varName;
}
//If no variable names, wildcards are found in pattern : /hello/static/path it will replace it with \/hello\/static\/path
if(count($matches) < 1 && $pattern != '/')
{
$reg .= str_replace('/', '\/', $pattern);
}
$reg = $reg . $nextText;
$reg .= '#';
apc_store($pattern, ['variables' => $variables, 'regex' => $reg]);
return ['variables' => $variables, 'regex' => $reg];
}
}
and a matcher class
<?php
namespace Routing;
use Symfony\Component\HttpFoundation\Request;
class Matcher
{
/**
* @var RouteCollection
*/
private $routes;
/**
* @var Symfony\Component\HttpFoundation\Request
*/
private $request;
/**
* Constructor.
*
* @param RouteCollection $routes A RouteCollection instance
* @param Symfony\Component\HttpFoundation\Request A Symfony Request
*/
public function __construct(Request $request, RouteCollection $collection)
{
$this->routes = $collection;
$this->request = $request;
}
public function matchRequest()
{
$return = array();
foreach ($this->routes->all() as $name => $route)
{
if(preg_match($route['parsed']['matcherReg'], $this->request->getPathInfo()))
{
if(!in_array($this->request->getMethod(), $route['route']->getHttpMethods()))
{
throw new \Exception(sprintf('Method "%s" not allowed', $this->request->getMethod()), 1);
}
//var_dump($this->request);
return [
'_vars' => $route['parsed']['variables'],
'_controller' => $route['route']->getController(),
'_varValues' => self::realVariables($route['route']->getPath(), $this->request->getPathInfo())
];
}
}
throw new \Exception(sprintf('Route for "%s" not found', $this->request->getPathInfo()), 1);
}
private static function realVariables($routePath, $pathInfo)
{
$i = 0;
$poss = [];
$vars = [];
$routeSegs = explode('/', $routePath);
$segs = explode('/', $pathInfo);
foreach ($routeSegs as $key => $value) {
if(preg_match('#\{\w+\}#', $value))
{
$poss[] = $i;
}
$i++;
}
$segs = array_splice($segs, 1);
foreach ($poss as $key => $index) {
$vars[] = $segs[$index];
}
return $vars;
}
}
They are used in an index.php
, as in this excerpt:
<?php
$request = Request::createFromGlobals();
$response = new Response();
$routes->add('name', new Route(['GET'], 'hi/{p1}/cicki/{p2}', function($p1, $p2) use ($response) {
$response->setContent($p1 . ' - ' . $p2);
}));
try
{
$urlMatcher = new Matcher($request, $routes);
$rez = $urlMatcher->matchRequest();
}
catch(Exception $e) {echo $e;}
call_user_func_array($rez['_controller'], $rez['_varValues']);
$response->send();
So it basically takes a path pattern, with static strings or parameters enclosed in {}, extracts the parameters and generates a regex to be compared to the URL, if it matches it returns the parameters, their values, the controller (a PHP closure) and it doesn't yet support optional parameters.
Edit: You may notice that I am caching my regexes with apc, so I would like to underline that after a route removal I am not invalidating the cache(A feature I still have to work on).