Core/Core/System/class.router.php

426 lines
15 KiB
PHP

<?php
/**
* FuzeWorks.
*
* The FuzeWorks MVC PHP FrameWork
*
* Copyright (C) 2015 TechFuze
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @author TechFuze
* @copyright Copyright (c) 2013 - 2016, Techfuze. (http://techfuze.net)
* @copyright Copyright (c) 1996 - 2015, Free Software Foundation, Inc. (http://www.fsf.org/)
* @license http://opensource.org/licenses/GPL-3.0 GPLv3 License
*
* @link http://fuzeworks.techfuze.net
* @since Version 0.0.1
*
* @version Version 0.0.1
*/
namespace FuzeWorks;
use Application\Init;
/**
* Class Router.
*
* This class handles the framework's routing. The router determines which system should be loaded and called.
* The overall structure of the routing is as follows:
*
* The routes-array will hold a list of RegEx-strings. When the route-method is called, the framework will try
* to match the current path against all the RegEx's. When a RegEx matches, the linked callable will be called.
*
* Every module can register routes and add their own callables. By default, two callables are used:
* The defaultCallable and the moduleCallable.
*
* The defaultCallable is a traditional MVC controller loader. Loading an URL using a default route works as follows:
*
* Let's say the visitor requests /A/B/C
*
* A would be the 'controller' (default: standard)
* B would be the function to be called in the 'controller' (default: index)
* C would be the first parameter
*
* All controllers are to be placed in the /Application/controller-directory.
*
* This is the default behaviour by adding routes to the config.routes.php. It is also possible to load Modules using routes.
* To load a Module using a route, add the route to the moduleInfo.php in a routes array.
* When this route is matched, a moduleCallable gets loaded which loads the module and loads either a controller file, or a routing function.
*
* But because of this RegEx-table, modules can easily listen on completely different paths. You can, for example, make
* a module that only triggers when /admin/<page>/<component>/.. is accessed. Or even complexer structure are
* available, e.g: /webshop/product-<id>/view/<detailID>.
*
* BE AWARE:
*
* Callables are NO controllers!! By default, the 'defaultCallable' will load the correct controller from
* the default controller directory. When you make custom routes, the callable will need to call your own
* controllers. This means that the one callable you provide with your RegEx will be called for EVERYTHING
* the RegEx matches. The names groups 'controller' and 'function' will be passed as first two arguments,
* if no names groups are available; you will need to extract them yourself from the path.
*
* After the core has been loaded, the method setPath will be called with the request URI (e.g. obtained via .htaccess).
* That method will then call the route-method, which will call the right controller and it's method.
*
* @see Router::setPath
* @see Router::route
*
* @author Abel Hoogeveen <abel@techfuze.net>
* @copyright Copyright (c) 2013 - 2016, Techfuze. (http://techfuze.net)
*/
class Router
{
/**
* @var null|string The provided path
*/
private static $path = null;
/**
* @var array Routes
*/
private static $routes = array();
/**
* @var null|mixed The callable
*/
private static $callable = null;
/**
* @var null|array The extracted matches from the regex
*/
private static $matches = null;
/**
* The constructor adds the default route to the routing table.
*/
public static function init()
{
foreach (Config::get('routes') as $route => $callable) {
if (is_int($route)) {
$route = $callable;
$callable = array('\FuzeWorks\Router', 'defaultCallable');
}
self::addRoute($route, $callable, false);
}
}
/**
* Returns the current routing path.
*
* @return bool|string
*/
public static function getPath()
{
return self::$path;
}
/**
* Returns an array with all the routes.
*
* @return array
*/
public static function getRoutes()
{
return self::$routes;
}
/**
* Returns the currently loaded callable.
*
* @return null|callable
*/
public static function getCallable()
{
return self::$callable;
}
/**
* Returns all the matches with the RegEx route.
*
* @return null|array
*/
public static function getMatches()
{
return self::$matches;
}
/**
* Set the current routing path.
*
* @param string $path The routing path (e.g. a/b/c/d/e)
*
* @return bool|string
*/
public static function setPath($path)
{
// Fire the event to notify our modules
$event = Events::fireEvent('routerSetPathEvent', $path);
// The event has been cancelled
if ($event->isCancelled()) {
return false;
}
// Remove double slashes
$path = preg_replace('@[/]+@', '/', $event->path);
// Remove first slash
if (substr($path, 0, 1) == '/') {
$path = substr($path, 1);
}
// Remove trailing slash
if (substr($path, -1, 1) == '/') {
$path = substr($path, 0, strlen($path) - 1);
}
return self::$path = $path;
}
/**
* Add a route.
*
* The path will be checked before custom routes before the default route(/controller/function/param1/param2/etc)
* When the given RegEx matches the current routing-path, the callable will be called.
*
* The callable will be called with three arguments:
*
* Callable($regex_matches = array())
*
* The variables in the array will be the named groups of your RegEx. When one or more named groups are
* not matched, they will be set to NULL. The default RegEx is:
*
* /^(?P<controller>.*?)(|\/(?P<function>.*?)(|\/(?P<parameters>.*?)))$/
*
* ^ Named group 1 ^ Named group 2 ^ Named group 3
*
* Named group 1 is named 'controller' and thus will become $controller
* Named group 2 is named 'function' and thus will become $function
* Named group 3 is named 'parameters' and thus will become $parameters
*
* You can also add aliases with the following:
*
* '/^this-is-an-alias$/' => array(
* 'controller' => 'home',
* 'function' => 'index',
* 'parameters' => array()
* ),
*
* This will link '/this-is-an-alias/ to /home/index. It is also possible to use the three named capture groups
* for the function, parameters or controllers. Like this:
*
* '/^alias(|\-(?P<function>.*?))$/' => array(
* 'controller' => 'home'
* ),
*
* This will mask '/alias' to '/home' and '/alias-test' to 'home/test'.
*
* You do not *have* to use named groups, but when you don't the arguments will be left NULL; and you will need to
* extract the information from the routing-path yourself.
*
* @param string $route This is a RegEx of the route, Every capture group will be a parameter
* @param callable $callable The callable to execute
* @param bool $prepend Whether or not to insert at the beginning of the routing table
*/
public static function addRoute($route, $callable, $prepend = true)
{
if ($prepend) {
self::$routes = array($route => $callable) + self::$routes;
} else {
self::$routes[$route] = $callable;
}
Logger::log('Route added at '.($prepend ? 'top' : 'bottom').': "'.$route.'"');
}
/**
* Removes a route from the array based on the given route.
*
* @param $route string The route to remove
*/
public static function removeRoute($route)
{
unset(self::$routes[$route]);
Logger::log('Route removed: '.$route);
}
/**
* Extracts the routing path from the URL using the routing table.
*
* Determines what callable should be loaded and what data matches the route regex.
*
* @param bool $loadCallable Immediate load the callable when it's route matches
*/
public static function route($loadCallable = true)
{
// Fire the event to notify our modules
$event = Events::fireEvent('routerRouteEvent', self::$routes, $loadCallable, self::$path);
// The event has been cancelled
if ($event->isCancelled()) {
return;
}
// Assign everything to the object to make it accessible, but let modules check it first
$routes = $event->routes;
$loadCallable = $event->loadCallable;
//Check the custom routes
foreach ($routes as $r => $c) {
//A custom route is found
if (preg_match($r, $event->path, $matches)) {
Logger::log('Route matched: '.$r);
// Add the matches to the current class
self::$matches = $matches;
self::$callable = $c;
if (!$loadCallable || !self::loadCallable($matches, $r)) {
break;
}
}
}
// Check if we found a callable anyway
if (self::$callable === null) {
Logger::log('No routes found for given path: "'.$event->path.'"', E_WARNING);
Logger::http_error(404);
return;
}
}
/**
* Load the callable to which the route matched.
*
* First it checks if it is possible to call the callable. If not, the default callable gets selected and a controller, function and parameters get selected.
*
* Then the arguments get prepared and finally the callable is called.
*
* @param array Preg matches with the routing path
* @param string The route that matched
*
* @return bool Whether or not the callable was satisfied
*/
public static function loadCallable($matches = array(), $route)
{
Logger::newLevel('Loading callable');
// Fire the event to notify our modules
$event = Events::fireEvent('routerLoadCallableEvent', self::$callable, $matches, $route);
// The event has been cancelled
if ($event->isCancelled()) {
return false;
}
// Prepare the arguments and add the route
$args = $event->matches;
$args['route'] = $event->route;
if (!is_callable($event->callable)) {
if (isset(self::$callable['controller'])) {
// Reset the arguments and fetch from custom callable
$args = array();
$args['controller'] = isset(self::$callable['controller']) ? self::$callable['controller'] : (isset($matches['controller']) ? $matches['controller'] : null);
$args['function'] = isset(self::$callable['function']) ? self::$callable['function'] : (isset($matches['function']) ? $matches['function'] : null);
$args['parameters'] = isset(self::$callable['parameters']) ? self::$callable['parameters'] : (isset($matches['parameters']) ? explode('/', $matches['parameters']) : null);
self::$callable = array('\FuzeWorks\Router', 'defaultCallable');
} else {
Logger::log('The given callable is not callable!', E_ERROR);
Logger::http_error(500);
Logger::stopLevel();
return true;
}
}
// And log the input to the logger
Logger::newLevel('Calling callable');
foreach ($args as $key => $value) {
Logger::log($key.': '.var_export($value, true).'');
}
Logger::stopLevel();
$skip = call_user_func_array(self::$callable, array($args)) === false;
if ($skip) {
Logger::log('Callable not satisfied, skipping to next callable');
}
Logger::stopLevel();
return $skip;
}
/**
* The default callable.
*
* This callable will do the 'old skool' routing. It will load the controllers from the controller-directory
* in the application-directory.
*/
public static function defaultCallable($arguments = array())
{
Logger::log('Default callable called!');
$controller = empty($arguments['controller']) ? Config::get('main')->default_controller : $arguments['controller'];
$function = empty($arguments['function']) ? Config::get('main')->default_function : $arguments['function'];
$parameters = empty($arguments['parameters']) ? null : $arguments['parameters'];
// Construct file paths and classes
$class = '\Application\Controller\\'.ucfirst($controller);
$file = 'Application/Controller/controller.'.$controller.'.php';
Logger::log('Loading controller '.$class.' from file: '.$file);
// Check if the file exists
if (file_exists($file)) {
if (!class_exists($class)) {
include $file;
}
// Get the path the controller should know about
$path = substr(self::getPath(), ($pos = strpos(self::getPath(), '/')) !== false ? $pos + 1 : 0);
// And create the controller
self::$callable = new $class($path);
// If the controller does not want a function to be loaded, provide a halt parameter.
if (isset(self::$callable->halt)) {
return;
}
// Check if method exists or if there is a caller function
if (method_exists(self::$callable, $function) || method_exists(self::$callable, '__call')) {
// Execute the function on the controller
echo self::$callable->{$function}($parameters);
} else {
// Function could not be found
Logger::log('Could not find function '.$function.' on controller '.$class);
Logger::http_error(404);
}
} else {
// Controller could not be found
Logger::log('Could not find controller '.$class);
Logger::http_error(404);
}
}
}