Url routing regex PHP - php

I am writing a class that handles routing of my PHP webservice but I need to correct the regex, and I want to know what would be the most effecient way to parse the url.
Example urls:
POST /users
GET /users
GET /users&limit=10&offset=0
GET /users/search&keyword=Richard
GET /users/15/posts/38
What I want to create in PHP for class is this:
$router = new Router();
$router->addRoute('POST', '/users', function(){});
$router->addRoute('GET', '/users/:uid/posts/:pid', function($uid, $pid){});
$target = $router->doRouting();
The target variable would now contain an array with:
method
url
callback method
This is what I got so far:
class Router{
use Singleton;
private $routes = [];
private $routeCount = 0;
public function addRoute($method, $url, $callback){
$this->routes[] = ['method' => $method, 'url' => $url, 'callback' => $callback];
$this->routeCount++;
}
public function doRouting(){
$reqUrl = $_SERVER['REQUEST_URI'];
$reqMet = $_SERVER['REQUEST_METHOD'];
for($i = 0; $i < $this->routeCount; $i++){
// Check if the url matches ...
// Parse the arguments of the url ...
}
}
}
So I need a regex that first of all:
/mainAction/:argumentName/secondaryAction/:secondaryActionName
checks if that matches the $reqUrl (see at the for loop above)
Extracts the arguments, so we can use them in our callback function.
What I tried myself:
(code should be in the for loop # doRouting function)
// Extract arguments ...
$this->routing[$i]['url'] = str_replace(':arg', '.+', $this->routing[$i]['url']);
// Does the url matches the routing url?
if(preg_match('#^' . $this->routes[$i]['url'] . '$#', $reqUrl)){
return $this->routes[$i];
}
I really appreciate all help, thanks alot.

this basicly works now.
public function doRouting(){
// I used PATH_INFO instead of REQUEST_URI, because the
// application may not be in the root direcory
// and we dont want stuff like ?var=value
$reqUrl = $_SERVER['PATH_INFO'];
$reqMet = $_SERVER['REQUEST_METHOD'];
foreach($this->routes as $route) {
// convert urls like '/users/:uid/posts/:pid' to regular expression
$pattern = "#^" . preg_replace('/\\\:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', preg_quote($route['url'])) . "$#D";
$matches = Array();
// check if the current request matches the expression
if($reqMet == $route['method'] && preg_match($pattern, $reqUrl, $matches)) {
// remove the first match
array_shift($matches);
// call the callback with the matched positions as params
return call_user_func_array($route['callback'], $matches);
}
}
}
PS: You dont need the $routeCount attribute

Great answer #MarcDefiant. Cleanest PHP router I came across. Did a small modification to support regular expression as well. Not sure why you use preq_quote ?
Small todo would be to cast the array to a assoc array. E.g. replace ['0' => 1] with ['id' => 1]
function matchRoute($routes = [], $url = null, $method = 'GET')
{
// I used PATH_INFO instead of REQUEST_URI, because the
// application may not be in the root direcory
// and we dont want stuff like ?var=value
$reqUrl = $url ?? $_SERVER['PATH_INFO'];
$reqMet = $method ?? $_SERVER['REQUEST_METHOD'];
$reqUrl = rtrim($reqUrl,"/");
foreach ($routes as $route) {
// convert urls like '/users/:uid/posts/:pid' to regular expression
// $pattern = "#^" . preg_replace('/\\\:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', preg_quote($route['url'])) . "$#D";
$pattern = "#^" . preg_replace('/:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', $route['url']) . "$#D";
// echo $pattern."\n";
$params = [];
// check if the current request params the expression
$match = preg_match($pattern, $reqUrl, $params);
if ($reqMet == $route['method'] && $match) {
// remove the first match
array_shift($params);
// call the callback with the matched positions as params
// return call_user_func_array($route['callback'], $params);
return [$route, $params];
}
}
return [];
}
$match = matchRoute([
[
'method' => 'GET',
'url' => '/:id',
'callback' => function($req) {
exit('Hello');
}
],
[
'method' => 'GET',
'url' => '/api/(.*)', // Match all /api/hello/test/...
'callback' => function($req) {
print_r($req);
exit('cool');
}
]
]);
list($route,$params) = $match;
call_user_func_array($route['callback'], [$params]);

Related

PHP routing - How can I implement 404 page on wrong url routes?

everyone.
I have a basic router created in PHP.
I can redirect to any page I want, if there is a callback function the callback function gets executed and if there is a page (String instead of a function) the page loads the correct file. However I can't figure out how to implement 404 page on non-existing route.
I tried to reuse the preg_match() function, but that gave me no results and if I place the notFound() (404 page) in the else block, it always gets executed regardless of the correct url or not.
if(preg_match($pattern, $path, $matches) && $httpMethod === $route['method']) {
}else{
self::notFound(); //THIS GETS EXECUTED ON EVERY ROUTE
}
This is my Code.
<?php
class Router{
public static $routes = [];
public static function get($route, $callback){
self::$routes[] = [
'route' => $route,
'callback' => $callback,
'method' => 'GET'
];
}
public static function resolve(){
$path = $_SERVER['REQUEST_URI'];
$httpMethod = $_SERVER['REQUEST_METHOD'];
$methodMatch = false;
$routeMatch = false;
foreach(self::$routes as $route){
// convert urls like '/users/:uid/posts/:pid' to regular expression
$pattern = "#^" . preg_replace('/\\\:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', preg_quote($route['route'])) . "$#D";
$matches = Array();
// check if the current request matches the expression
if(preg_match($pattern, $path, $matches) && $httpMethod === $route['method']) {
// remove the first match
array_shift($matches);
// call the callback with the matched positions as params
if(is_callable($route['callback'])){
call_user_func_array($route['callback'], $matches);
}else{
self::render($route['callback']);
}
}
}
}
public static function render($file, $viewsFolder='./views/'){
include($viewsFolder . $file);
}
public static function notFound(){
http_response_code(400);
include('./views/404.php');
exit();
}
}
Router::get("/", "home.php");
Router::get("/user/:id", function($val1) {
$data = array(
"Nicole",
"Sarah",
"Jinx",
"Sarai"
);
echo $data[$val1] ?? "No data";
});
Router::get("/user/profile/:id", "admin.php");
Router::resolve();
?>
You can add notFound() at the very end of resolve() method, and a return when you hit a match:
public static function resolve(){
$path = $_SERVER['REQUEST_URI'];
$httpMethod = $_SERVER['REQUEST_METHOD'];
$methodMatch = false;
$routeMatch = false;
foreach(self::$routes as $route){
$pattern = "#^" . preg_replace('/\\\:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', preg_quote($route['route'])) . "$#D";
$matches = Array();
if(preg_match($pattern, $path, $matches) && $httpMethod === $route['method']) {
array_shift($matches);
if(is_callable($route['callback'])){
call_user_func_array($route['callback'], $matches);
}else{
self::render($route['callback']);
}
return;
}
}
notFound();
}

How to do URL matching regex for routing framework?

I already have a routing method that matches this pattern:
/hello/:name
that set name to be a dynamic path, I want to know how to make it:
/hello/{name}
with the same regex. How to add optional trailing slash to it, like this?
/hello/:name(/)
or
/hello/{name}(/)
This is the regex I use for /hello/:name
#^/hello/([a-zA-Z0-9\-\_]+)$#D
The regex is auto generated from PHP class
private function getRegex($pattern){
$patternAsRegex = "#^" . preg_replace('/\\\:[a-zA-Z0-9\_\-]+/', '([a-zA-Z0-9\-\_]+)', preg_quote($pattern)) . "$#D";
return $patternAsRegex;
}
If the route is /hello/:name(/) I want it to make the match with optional thing else continue normal
This will create a regular expression for the $pattern route with both :name and {name} parameters, as well as the optional slash. As a bonus, it will also add a ?<name> to make the parameter easier to handle down the line.
For example, a route pattern of /hello/:name(/) will get the regular expression #^/hello/(?<name>[a-zA-Z0-9\_\-]+)/?$#D. When matched with a URL, like preg_match( <regex above>, '/hello/sarah', $matches) that would give you $matches['name'] == 'sarah'.
There are some tests to be found below the actual function.
function getRegex($pattern){
if (preg_match('/[^-:\/_{}()a-zA-Z\d]/', $pattern))
return false; // Invalid pattern
// Turn "(/)" into "/?"
$pattern = preg_replace('#\(/\)#', '/?', $pattern);
// Create capture group for ":parameter"
$allowedParamChars = '[a-zA-Z0-9\_\-]+';
$pattern = preg_replace(
'/:(' . $allowedParamChars . ')/', # Replace ":parameter"
'(?<$1>' . $allowedParamChars . ')', # with "(?<parameter>[a-zA-Z0-9\_\-]+)"
$pattern
);
// Create capture group for '{parameter}'
$pattern = preg_replace(
'/{('. $allowedParamChars .')}/', # Replace "{parameter}"
'(?<$1>' . $allowedParamChars . ')', # with "(?<parameter>[a-zA-Z0-9\_\-]+)"
$pattern
);
// Add start and end matching
$patternAsRegex = "#^" . $pattern . "$#D";
return $patternAsRegex;
}
// Test it
$testCases = [
[
'route' => '/hello/:name',
'url' => '/hello/sarah',
'expectedParam' => ['name' => 'sarah'],
],
[
'route' => '/bye/:name(/)',
'url' => '/bye/stella/',
'expectedParam' => ['name' => 'stella'],
],
[
'route' => '/find/{what}(/)',
'url' => '/find/cat',
'expectedParam' => ['what' => 'cat'],
],
[
'route' => '/pay/:when',
'url' => '/pay/later',
'expectedParam' => ['when' => 'later'],
],
];
printf('%-5s %-16s %-39s %-14s %s' . PHP_EOL, 'RES', 'ROUTE', 'PATTERN', 'URL', 'PARAMS');
echo str_repeat('-', 91), PHP_EOL;
foreach ($testCases as $test) {
// Make regexp from route
$patternAsRegex = getRegex($test['route']);
if ($ok = !!$patternAsRegex) {
// We've got a regex, let's parse a URL
if ($ok = preg_match($patternAsRegex, $test['url'], $matches)) {
// Get elements with string keys from matches
$params = array_intersect_key(
$matches,
array_flip(array_filter(array_keys($matches), 'is_string'))
);
// Did we get the expected parameter?
$ok = $params == $test['expectedParam'];
// Turn parameter array into string
list ($key, $value) = each($params);
$params = "$key = $value";
}
}
// Show result of regex generation
printf('%-5s %-16s %-39s %-14s %s' . PHP_EOL,
$ok ? 'PASS' : 'FAIL',
$test['route'], $patternAsRegex,
$test['url'], $params
);
}
Output:
RES ROUTE PATTERN URL PARAMS
-------------------------------------------------------------------------------------------
PASS /hello/:name #^/hello/(?<name>[a-zA-Z0-9\_\-]+)$#D /hello/sarah name = sarah
PASS /bye/:name(/) #^/bye/(?<name>[a-zA-Z0-9\_\-]+)/?$#D /bye/stella/ name = stella
PASS /find/{what}(/) #^/find/(?<what>[a-zA-Z0-9\_\-]+)/?$#D /find/cat what = cat
PASS /pay/:when #^/pay/(?<when>[a-zA-Z0-9\_\-]+)$#D /pay/later when = later
Simply replace your regex with this for optional / :
#^/hello/([a-zA-Z0-9-_]+)/?$#

preg replace doesn't replace right adds an extra backslash

Good day everyone. I have the following two functions one for adding a rule and the other one for matching that rule. The problem is that when i use two params one of them doesn't get changed and i don't understand why it doesn't work. any help is apreciated.
public function add($name, $pattern, $controller, $action = null, array $params = array())
{
if(!isset($this->routeCollection[$name]))
$this->routeCollection[$name] =
array(
'pattern' => $pattern,
'controller' => $controller,
'action' => $action,
'params' => $params,
);
}
public function findMatch($url)
{
foreach ($this->routeCollection as $routeMap) {
$this->regex = $this->buildRegex($routeMap['pattern'], $routeMap['params']);
// Let's test the route.
if (preg_match($this->regex, $url)) {
return array('controller' => $routeMap['controller'], 'action' => $routeMap['action']);
}
}
return array('controller' => $this->routeCollection['404']['controller'], 'action' => $this->routeCollection['404']['action']);
}
public function buildRegex($uri, array $params)
{
// Find {params} in URI
if (preg_match_all('/\{(?:[^\}]+)\}/', $uri, $this->matches, PREG_SET_ORDER)) {
foreach ($this->matches as $isMatch) {
// Swap {param} with a placeholder
$this->uri = str_replace($isMatch, "%s", $uri);
}
// Build final Regex
$this->finalRegex = '/^' . preg_quote($this->uri, '/') . '$/';
$this->finalRegex = vsprintf($this->finalRegex, $params);
var_dump($this->finalRegex);
} else {
$this->finalRegex = '/^(' . preg_quote($uri, '/') . ')$/';
$this->finalRegex = str_replace(array('\.', '\-'), array('.', '-'), $this->finalRegex);
}
return $this->finalRegex;
}
// Usage:
$routeCollection->add('CatalogByCategory', '/catalog/category/{slugLink}', 'Ex:Controller:Catalog', 'ViewByCategory',
array('slugLink' => ('[a-z0-9]+(?:-[a-z0-9]+)*') ));
$routeCollection->add('ListCatalogPageByCategory', '/catalog/category/{sluglinks}/{pageNumber}', 'Ex:Controller:Catalog', 'ListCatalog',
array('sluglinks' => ('[a-z0-9]+(?:-[a-z0-9]+)*'), 'pageNumber' => ('[1-9][0-9]*') ));
// From Dump:
string '/^\/catalog\/category\/[a-z0-9]+(?:-[a-z0-9]+)*$/' (length=49)
string '/^\/catalog\/category\/\{sluglinks\}\/[a-z0-9]+(?:-[a-z0-9]+)*$/' (length=64)
foreach ($this->matches as $isMatch) {
// Swap {param} with a placeholder
$this->uri = str_replace($isMatch, "%s", $uri);
}
You keep overwriting $this->uri with the value of $uri being run through a replacement - in this case, it's getting set with {sluglinks} being replaced, then set again with only {pageNumber} being replaced.
You should use $this->uri = $uri;, and then always use $this->uri.

Remove specific variable from URI string

With $_SERVER['REQUEST_URI'], I get a URL that could be:
index.php
or
index.php?id=x&etc..
I'd like to do two things:
Find if there is a ?something after index.php name with regular expression.
If there is in the url a specific var (id=x) and delete it from the url.
For example:
index.php?id=x => index.php
index.php?a=11&id=x => index.php?a=11
How can I do this?
To check if there is a ?something after index.php, you could use the built-in function parse_url(), like so:
if (parse_url($url, PHP_URL_QUERY)) {
// ?something exists
}
To remove the id, you could use parse_str(), get the query parameters, store them in an array, and unset the particular id.
And since you also want to re-create the URL after the particular element is deleted from the query part of the URL, then you could use http_build_query().
Here's a function for that:
function removeQueryString($url, $toBeRemoved, $match)
{
// check if url has query part
if (parse_url($url, PHP_URL_QUERY)) {
// parse_url and store the values
$parts = parse_url($url);
$scriptname = $parts['path'];
$query_part = $parts['query'];
// parse the query parameters from the url and store it in $arr
$query = parse_str($query_part, $arr);
// if id == x, unset it
if (isset($arr[$toBeRemoved]) && $arr[$toBeRemoved] == $match) {
unset($arr[$toBeRemoved]);
// if there less than 1 query parameter, don't add '?'
if (count($arr) < 1) {
$query = $scriptname . http_build_query($arr);
} else {
$query = $scriptname . '?' . http_build_query($arr);
}
} else {
// no matches found, so return the url
return $url;
}
return $query;
} else {
return $url;
}
}
Test cases:
echo removeQueryString('index.php', 'id', 'x');
echo removeQueryString('index.php?a=11&id=x', 'id', 'x');
echo removeQueryString('index.php?a=11&id=x&qid=51', 'id', 'x');
echo removeQueryString('index.php?a=11&foo=bar&id=x', 'id', 'x');
Output:
index.php
index.php?a=11
index.php?a=11&qid=51
index.php?a=11&foo=bar
Demo!
If it must be a regular expression :
$url='index.php?a=11&id=1234';
$pattern = '#\id=\d+#';
$url = preg_replace($pattern, '', $url);
echo $url;
output
index.php?a=11&
There is a trailing &, but the above removes any id=xxxxxxxx

Having trouble setting up API call using array of parameters

I am building a class to send API calls to Rapidshare and return the results of said call. Here's how I want the call to be done:
$rs = new rs();
$params = array(
'sub' => 'listfiles_v1',
'type' => 'prem',
'login' => '10347455',
'password' => 'not_real_pass',
'realfolder' => '0',
'fields' => 'filename,downloads,size',
);
print_r($rs->apiCall($params));
And here's the class so far:
class RS
{
var $baseUrl = 'http://api.rapidshare.com/cgi-bin/rsapi.cgi?sub=';
function apiCall($params)
{
$newUrl = $baseUrl;
$keys = array_keys($params);
$count = count($params);
for($i = 0; $i < $count; $i++)
{
$newUrl .= $keys[$i];
$newUrl .= '&';
$newUrl .= $params[$keys[$i]];
}
return $newUrl;
}
}
Obviously i'm returning $newUrl and using print_r() to test the query string, and this is what it comes out as with the code shown above:
sub&listfiles_v1type&premlogin&10347455password&_not_real_passrealfolder&0fields&filename,downloads,size
When it should be:
http://api.rapidshare.com/cgi-bin/rsapi.cgi?sub=listfiles_v1&type=prem&login=10347455&password=not_real_pass&realfolder=0&fields=filename,downloads,size
Hopefully you can see what I'm trying to do here :P It's probably a silly mistake that I'm failing to find or a logical error.
Thanks in advance.
You should have:
$newUrl = $this->baseUrl;
You need to use $this to refer to members of that class from within that class. Also don't use var to declare members. It's PHP4 and (afaik) deprecated. Instead use private (etc.
Lastly, your loop to create the parameters can be greatly simplified and the logic isn't correct for what you want to achieve. Try:
class RS {
private $baseUrl = 'http://api.rapidshare.com/cgi-bin/rsapi.cgi?';
function apiCall($params) {
$newUrl = $this->baseUrl;
foreach ($params as $k => $v) {
$newUrl .= urlencode($k) . '=' . urlencode($v) . '&';
}
return $newUrl;
}
}
Or, even better, use http_build_query():
class RS {
private $baseUrl = 'http://api.rapidshare.com/cgi-bin/rsapi.cgi?';
function apiCall($params) {
return $this->baseUrl . http_build_query($params);
}
}

Categories