I wrote a router class which accepts a URL and calls the appropriate controller method based on it.
I'm a little worried about the amount of dependencies it has (eg ErrorController
, RequestMethod
, etc), because it might make reusing it in other projects difficult.
It feels especially bad because a lot of the dependencies are static (and there might be even more in the future; eg if I add support for multiple languages). On the other hand, I also did not want to pass too many arguments to the router, and I'm not sure if it makes sense to create new objects every time for each of the other classes, and I also did not want them cluttering up my private fields (although that might be best?).
I don't write all that much in PHP, so I'm happy about any other comments regarding code quality as well.
<?php
namespace MyProject;
use MyProject\controller\ErrorController;
use MyProject\inc\String;
use MyProject\model\RequestMethod;
use MyProject\Connection;
use MyProject\model\exceptions\UnknownRequest;
class Router {
private $connection;
function __construct(Connection $connection, $requestUrl, $routes) {
$this->connection = $connection;
try {
$serverRequestMethod = RequestMethod::parseRequestMethod();
} catch(UnknownRequest $unknown) {
ErrorController::notFound("illegal request method", $unknown);
}
$url = $this->cleanURL($requestUrl);
$this->route($url, $serverRequestMethod, $this->routeIdToRegex($routes));
}
/**
* calls a controller method based on the request url and defined routes.
*
* @param string $url the requested url, eg /user/5
* @param string $requestType POST, GET, PUT, or DELETE
* @param array $routes a whitelist of allowed urls + request types mapped to a controller + method. Example entry: "GET /" => "UserController.listAll",
* @return type|null return-value of called controller method. If url is not defined in the routes, ErrorController::notFound will be called.
*/
private function route($url, $requestType, $routes) {
$url = $this->removeQuery($url);
foreach ($routes as $route => $controllerAndMethod) {
if (String::startsWith($route, $requestType) && preg_match("~^" . $this->cleanRoute($requestType, $route) . "$~", $url, $arguments)) {
$arguments = array_slice($arguments, 1);
return $this->executeRoute($controllerAndMethod, $arguments);
}
}
ErrorController::notFound("$requestType $url ");
}
/**
* calls a controller and method with arguments.
* <p>
* The connection will always be passed as the first argument.
*
* @param string $controllerAndMethod controller and method to be called in the form "Controller.method"
* @param array $arguments additional arguments to pass to called method
* @return type|null return-value of called controller method. If url is not defined in the routes, ErrorController::notFound will be called.
*/
private function executeRoute($controllerAndMethod, $arguments) {
list($class, $method) = explode(".", $controllerAndMethod);
if (!method_exists(NAMESPACE_CONTROLLER . $class, $method)) {
ErrorController::internalServerError("called undefined method $class :: $method");
}
return call_user_func_array(
array(NAMESPACE_CONTROLLER . $class, $method),
array_merge(array($this->connection), $arguments));
}
/**
* removes script name and trailing /. If url is then empty, it will return /.
*
* Eg: /your-dir/index.php/user/test/34/bla/ -> /user/test/34/bla
* Eg: /your-dir/index.php -> /
*
* @param string $url the url
* @return string cleaned url
*/
private function cleanURL($url) {
$urlToRemove = str_replace(BASE_DIR, "", $_SERVER["SCRIPT_FILENAME"]);
$url = str_replace($urlToRemove, "", $url);
return String::equals($url, '/') || String::equals($url, '') ? '/' : rtrim($url, '/');
}
/**
* removes request type and whitespace from route.
*
* Eg: "GET /user/[id]" -> "/user/[id]"
*
* @param string $requestType POST, GET, PUT, or DELETE
* @param string $route the route
* @return string cleaned route
*/
private function cleanRoute($requestType, $route) {
return trim(str_replace($requestType, "", $route));
}
/**
* removes the query string from a url.
*
* Eg /your-dir/index.php/user/5?limit=12 -> /your-dir/index.php/user/5
*
* @param string $url the url
* @return string the url without the query string
*/
private function removeQuery($url) {
if (String::contains($url, '?')) {
return substr($url, 0, strpos($url, '?'));
}
return $url;
}
/**
* replaces the keys in an array according to internally defined rules, and removes trailing /
In practice, it can be used to transform eg
$routes = array(
"/user/[id]/" => "UserController.show",
"/user" => "UserController.list",
"/user/[id]/[string]" => "UserController.show",
);
to
$routes = array(
"/user/(\d+)" => "UserController.show",
"/user" => "UserController.list",
"/user/(\d+)/(\w+)" => "UserController.show",
);
*
* @param array $routes
* @return array
*/
private function routeIdToRegex($routes) {
$routeIdToRegexMap = array(
"[id]" => "(\d+)",
"[string]" => "(\w+)",
);
foreach ($routeIdToRegexMap as $routeId => $regex) {
foreach ($routes as $routeKey => $routeValue) {
if (String::contains($routeKey, $routeId)) { // only replace id if it exists, otherwise entry would be deleted
$updatedRouteKey = rtrim(str_replace($routeId, $regex, $routeKey), '/');
$routes[$updatedRouteKey] = $routeValue;
unset($routes[$routeKey]);
}
}
}
return $routes;
}
}
It can then be used like this:
/**
* note: none of the routes may contain "?" in any parts.
* this means that "/user/te?st/[id]/" would not be allowed.
* Normal GET arguments still work though, so routing doesn't forbid
* localhost/your-dir/index.php/user/1?litmit=123
*
* @see MyProject\model\RequestMethod for PUT/DELETE support
*/
$routes = array(
"GET /" => "UserController.listAll",
//
"PUT /user/[id]" => "UserController.update",
"DELETE /user/[id]" => "UserController.delete",
"GET /user/[id]" => "UserController.show",
"GET /user" => "UserController.listAll",
"POST /user" => "UserController.create",
);
try {
$connection = new MyProject\Connection();
} catch (\PDOException $e) {
MyProject\controller\ErrorController::internalServerError('Connection to database failed', $e);
}
$router = new MyProject\Router($connection, $_SERVER["REQUEST_URI"], $routes);
I'm also a little worried about usability, as the user has to look up how to structure the routes array. I thought about adding a addRoute(requestMethod, route, controllerClass, method)
method, which would mean that the router could be used like this:
$router = new MyProject\Router($connection, $_SERVER["REQUEST_URI"]);
$router->addRoute("GET", "/user/[id]", "UserController", "listAll");
$router->route();
But I think that that might be less readable, so I'm unsure.
addRoute
methods for different independent components to add their routing. I also would not pass the current url, because think of a Router like url is input and a controller callable or whatever is output. And don't pass a db connection object. A router has nothing to do with a db connection, unless you build something like the CMF router which saves everythin in the db (github.com/symfony-cmf/Routing) \$\endgroup\$