diff --git a/composer.json b/composer.json index 8df1445..ba76682 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,6 @@ "php": "~8.1", "kletellier/pdowrapper": "^1.0", "symfony/http-kernel": "^6.3", - "bramus/router": "~1.6", "filp/whoops": "^2.15", "symfony/var-dumper": "^6.3", "eftec/bladeone": "^4.9", diff --git a/src/Core/Kernel/Application.php b/src/Core/Kernel/Application.php index 80071fc..79266ef 100644 --- a/src/Core/Kernel/Application.php +++ b/src/Core/Kernel/Application.php @@ -2,14 +2,14 @@ namespace Kletellier\Framework\Core\Kernel; -use Kletellier\Framework\Core\Routing\Router; +use Kletellier\Framework\Core\Routing\Routing; use Kletellier\Framework\Core\Kernel\Container; use Dotenv\Dotenv; use Kletellier\PdoWrapper\Connection; class Application { - protected Router $router; + protected Routing $router; protected $_whoops; private $_root; @@ -41,7 +41,7 @@ class Application } // Init router - $this->router = new Router(); + $this->router = new Routing(); } private function isDebug(): bool diff --git a/src/Core/Routing/Router.php b/src/Core/Routing/Router.php index 5ba4385..e99ae6a 100644 --- a/src/Core/Routing/Router.php +++ b/src/Core/Routing/Router.php @@ -2,37 +2,527 @@ namespace Kletellier\Framework\Core\Routing; -use Bramus\Router\Router as RouterRouter; -use Kletellier\Framework\Core\Kernel\Container; - +/** + * @author Bram(us) Van Damme + * @copyright Copyright (c), 2013 Bram(us) Van Damme + * @license MIT public license + */ class Router { - private $_router; - private $_root; + /** + * @var array The route patterns and their handling functions + */ + private $afterRoutes = array(); - public function __construct() - { - $this->_root = Container::get("ROOT"); - $this->init(); - } + /** + * @var array The before middleware route patterns and their handling functions + */ + private $beforeRoutes = array(); - public function run() - { - $this->_router->run(); - } + /** + * @var array [object|callable] The function to be executed when no route has been matched + */ + protected $notFoundCallback = []; - private function init() - { - $this->_router = new RouterRouter(); - $this->loadFromFile($this->_root . DIRECTORY_SEPARATOR . "routes"); - $this->_router->set404("404 - Not found"); - } + /** + * @var string Current base route, used for (sub)route mounting + */ + private $baseRoute = ''; - private function loadFromFile($path) + /** + * @var string The Request Method that needs to be handled + */ + private $requestedMethod = ''; + + /** + * @var string The Server Base Path for Router Execution + */ + private $serverBasePath; + + /** + * @var string Default Controllers Namespace + */ + private $namespace = ''; + + /** + * Store a before middleware route and a handling function to be executed when accessed using one of the specified methods. + * + * @param string $methods Allowed methods, | delimited + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function before($methods, $pattern, $fn) { - if (is_dir($path)) { - $router = $this->_router; - require $path . DIRECTORY_SEPARATOR . "routes.php"; + $pattern = $this->baseRoute . '/' . trim($pattern, '/'); + $pattern = $this->baseRoute ? rtrim($pattern, '/') : $pattern; + + foreach (explode('|', $methods) as $method) { + $this->beforeRoutes[$method][] = array( + 'pattern' => $pattern, + 'fn' => $fn, + ); } } + + /** + * Store a route and a handling function to be executed when accessed using one of the specified methods. + * + * @param string $methods Allowed methods, | delimited + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function match($methods, $pattern, $fn) + { + $pattern = $this->baseRoute . '/' . trim($pattern, '/'); + $pattern = $this->baseRoute ? rtrim($pattern, '/') : $pattern; + + foreach (explode('|', $methods) as $method) { + $this->afterRoutes[$method][] = array( + 'pattern' => $pattern, + 'fn' => $fn, + ); + } + } + + /** + * Shorthand for a route accessed using any method. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function all($pattern, $fn) + { + $this->match('GET|POST|PUT|DELETE|OPTIONS|PATCH|HEAD', $pattern, $fn); + } + + /** + * Shorthand for a route accessed using GET. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function get($pattern, $fn) + { + $this->match('GET', $pattern, $fn); + } + + /** + * Shorthand for a route accessed using POST. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function post($pattern, $fn) + { + $this->match('POST', $pattern, $fn); + } + + /** + * Shorthand for a route accessed using PATCH. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function patch($pattern, $fn) + { + $this->match('PATCH', $pattern, $fn); + } + + /** + * Shorthand for a route accessed using DELETE. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function delete($pattern, $fn) + { + $this->match('DELETE', $pattern, $fn); + } + + /** + * Shorthand for a route accessed using PUT. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function put($pattern, $fn) + { + $this->match('PUT', $pattern, $fn); + } + + /** + * Shorthand for a route accessed using OPTIONS. + * + * @param string $pattern A route pattern such as /about/system + * @param object|callable $fn The handling function to be executed + */ + public function options($pattern, $fn) + { + $this->match('OPTIONS', $pattern, $fn); + } + + /** + * Mounts a collection of callbacks onto a base route. + * + * @param string $baseRoute The route sub pattern to mount the callbacks on + * @param callable $fn The callback method + */ + public function mount($baseRoute, $fn) + { + // Track current base route + $curBaseRoute = $this->baseRoute; + + // Build new base route string + $this->baseRoute .= $baseRoute; + + // Call the callable + call_user_func($fn); + + // Restore original base route + $this->baseRoute = $curBaseRoute; + } + + /** + * Get all request headers. + * + * @return array The request headers + */ + public function getRequestHeaders() + { + $headers = array(); + + // If getallheaders() is available, use that + if (function_exists('getallheaders')) { + $headers = getallheaders(); + + // getallheaders() can return false if something went wrong + if ($headers !== false) { + return $headers; + } + } + + // Method getallheaders() not available or went wrong: manually extract 'm + foreach ($_SERVER as $name => $value) { + if ((substr($name, 0, 5) == 'HTTP_') || ($name == 'CONTENT_TYPE') || ($name == 'CONTENT_LENGTH')) { + $headers[str_replace(array(' ', 'Http'), array('-', 'HTTP'), ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; + } + } + + return $headers; + } + + /** + * Get the request method used, taking overrides into account. + * + * @return string The Request method to handle + */ + public function getRequestMethod() + { + // Take the method as found in $_SERVER + $method = $_SERVER['REQUEST_METHOD']; + + // If it's a HEAD request override it to being GET and prevent any output, as per HTTP Specification + // @url http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4 + if ($_SERVER['REQUEST_METHOD'] == 'HEAD') { + ob_start(); + $method = 'GET'; + } + + // If it's a POST request, check for a method override header + elseif ($_SERVER['REQUEST_METHOD'] == 'POST') { + $headers = $this->getRequestHeaders(); + if (isset($headers['X-HTTP-Method-Override']) && in_array($headers['X-HTTP-Method-Override'], array('PUT', 'DELETE', 'PATCH'))) { + $method = $headers['X-HTTP-Method-Override']; + } + } + + return $method; + } + + /** + * Set a Default Lookup Namespace for Callable methods. + * + * @param string $namespace A given namespace + */ + public function setNamespace($namespace) + { + if (is_string($namespace)) { + $this->namespace = $namespace; + } + } + + /** + * Get the given Namespace before. + * + * @return string The given Namespace if exists + */ + public function getNamespace() + { + return $this->namespace; + } + + /** + * Execute the router: Loop all defined before middleware's and routes, and execute the handling function if a match was found. + * + * @param object|callable $callback Function to be executed after a matching route was handled (= after router middleware) + * + * @return bool + */ + public function run($callback = null) + { + // Define which method we need to handle + $this->requestedMethod = $this->getRequestMethod(); + + // Handle all before middlewares + if (isset($this->beforeRoutes[$this->requestedMethod])) { + $this->handle($this->beforeRoutes[$this->requestedMethod]); + } + + // Handle all routes + $numHandled = 0; + if (isset($this->afterRoutes[$this->requestedMethod])) { + $numHandled = $this->handle($this->afterRoutes[$this->requestedMethod], true); + } + + // If no route was handled, trigger the 404 (if any) + if ($numHandled === 0) { + $this->trigger404($this->afterRoutes[$this->requestedMethod]); + } // If a route was handled, perform the finish callback (if any) + else { + if ($callback && is_callable($callback)) { + $callback(); + } + } + + // If it originally was a HEAD request, clean up after ourselves by emptying the output buffer + if ($_SERVER['REQUEST_METHOD'] == 'HEAD') { + ob_end_clean(); + } + + // Return true if a route was handled, false otherwise + return $numHandled !== 0; + } + + /** + * Set the 404 handling function. + * + * @param object|callable|string $match_fn The function to be executed + * @param object|callable $fn The function to be executed + */ + public function set404($match_fn, $fn = null) + { + if (!is_null($fn)) { + $this->notFoundCallback[$match_fn] = $fn; + } else { + $this->notFoundCallback['/'] = $match_fn; + } + } + + /** + * Triggers 404 response + * + * @param string $pattern A route pattern such as /about/system + */ + public function trigger404($match = null) + { + + // Counter to keep track of the number of routes we've handled + $numHandled = 0; + + // handle 404 pattern + if (count($this->notFoundCallback) > 0) { + // loop fallback-routes + foreach ($this->notFoundCallback as $route_pattern => $route_callable) { + // matches result + $matches = []; + + // check if there is a match and get matches as $matches (pointer) + $is_match = $this->patternMatches($route_pattern, $this->getCurrentUri(), $matches, PREG_OFFSET_CAPTURE); + + // is fallback route match? + if ($is_match) { + // Rework matches to only contain the matches, not the orig string + $matches = array_slice($matches, 1); + + // Extract the matched URL parameters (and only the parameters) + $params = array_map(function ($match, $index) use ($matches) { + + // We have a following parameter: take the substring from the current param position until the next one's position (thank you PREG_OFFSET_CAPTURE) + if (isset($matches[$index + 1]) && isset($matches[$index + 1][0]) && is_array($matches[$index + 1][0])) { + if ($matches[$index + 1][0][1] > -1) { + return trim(substr($match[0][0], 0, $matches[$index + 1][0][1] - $match[0][1]), '/'); + } + } // We have no following parameters: return the whole lot + + return isset($match[0][0]) && $match[0][1] != -1 ? trim($match[0][0], '/') : null; + }, $matches, array_keys($matches)); + + $this->invoke($route_callable); + + ++$numHandled; + } + } + } + if (($numHandled == 0) && (isset($this->notFoundCallback['/']))) { + $this->invoke($this->notFoundCallback['/']); + } elseif ($numHandled == 0) { + header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); + } + } + + /** + * Replace all curly braces matches {} into word patterns (like Laravel) + * Checks if there is a routing match + * + * @param $pattern + * @param $uri + * @param $matches + * @param $flags + * + * @return bool -> is match yes/no + */ + private function patternMatches($pattern, $uri, &$matches, $flags) + { + // Replace all curly braces matches {} into word patterns (like Laravel) + $pattern = preg_replace('/\/{(.*?)}/', '/(.*?)', $pattern); + + // we may have a match! + return boolval(preg_match_all('#^' . $pattern . '$#', $uri, $matches, PREG_OFFSET_CAPTURE)); + } + + /** + * Handle a a set of routes: if a match is found, execute the relating handling function. + * + * @param array $routes Collection of route patterns and their handling functions + * @param bool $quitAfterRun Does the handle function need to quit after one route was matched? + * + * @return int The number of routes handled + */ + private function handle($routes, $quitAfterRun = false) + { + // Counter to keep track of the number of routes we've handled + $numHandled = 0; + + // The current page URL + $uri = $this->getCurrentUri(); + + // Loop all routes + foreach ($routes as $route) { + // get routing matches + $is_match = $this->patternMatches($route['pattern'], $uri, $matches, PREG_OFFSET_CAPTURE); + + // is there a valid match? + if ($is_match) { + // Rework matches to only contain the matches, not the orig string + $matches = array_slice($matches, 1); + + // Extract the matched URL parameters (and only the parameters) + $params = array_map(function ($match, $index) use ($matches) { + + // We have a following parameter: take the substring from the current param position until the next one's position (thank you PREG_OFFSET_CAPTURE) + if (isset($matches[$index + 1]) && isset($matches[$index + 1][0]) && is_array($matches[$index + 1][0])) { + if ($matches[$index + 1][0][1] > -1) { + return trim(substr($match[0][0], 0, $matches[$index + 1][0][1] - $match[0][1]), '/'); + } + } // We have no following parameters: return the whole lot + + return isset($match[0][0]) && $match[0][1] != -1 ? trim($match[0][0], '/') : null; + }, $matches, array_keys($matches)); + + // Call the handling function with the URL parameters if the desired input is callable + $this->invoke($route['fn'], $params); + + ++$numHandled; + + // If we need to quit, then quit + if ($quitAfterRun) { + break; + } + } + } + + // Return the number of routes handled + return $numHandled; + } + + private function invoke($fn, $params = array()) + { + if (is_callable($fn)) { + call_user_func_array($fn, $params); + } + + // If not, check the existence of special parameters + elseif (stripos($fn, '@') !== false) { + // Explode segments of given route + list($controller, $method) = explode('@', $fn); + + // Adjust controller class if namespace has been set + if ($this->getNamespace() !== '') { + $controller = $this->getNamespace() . '\\' . $controller; + } + + try { + $reflectedMethod = new \ReflectionMethod($controller, $method); + // Make sure it's callable + if ($reflectedMethod->isPublic() && (!$reflectedMethod->isAbstract())) { + if ($reflectedMethod->isStatic()) { + forward_static_call_array(array($controller, $method), $params); + } else { + // Make sure we have an instance, because a non-static method must not be called statically + if (\is_string($controller)) { + $controller = new $controller(); + } + call_user_func_array(array($controller, $method), $params); + } + } + } catch (\ReflectionException $reflectionException) { + // The controller class is not available or the class does not have the method $method + } + } + } + + /** + * Define the current relative URI. + * + * @return string + */ + public function getCurrentUri() + { + // Get the current Request URI and remove rewrite base path from it (= allows one to run the router in a sub folder) + $uri = substr(rawurldecode($_SERVER['REQUEST_URI']), strlen($this->getBasePath())); + + // Don't take query params into account on the URL + if (strstr($uri, '?')) { + $uri = substr($uri, 0, strpos($uri, '?')); + } + + // Remove trailing slash + enforce a slash at the start + return '/' . trim($uri, '/'); + } + + /** + * Return server base Path, and define it if isn't defined. + * + * @return string + */ + public function getBasePath() + { + // Check if server base path is defined, if not define it. + if ($this->serverBasePath === null) { + $this->serverBasePath = implode('/', array_slice(explode('/', $_SERVER['SCRIPT_NAME']), 0, -1)) . '/'; + } + + return $this->serverBasePath; + } + + /** + * Explicilty sets the server base path. To be used when your entry script path differs from your entry URLs. + * @see https://github.com/bramus/router/issues/82#issuecomment-466956078 + * + * @param string + */ + public function setBasePath($serverBasePath) + { + $this->serverBasePath = $serverBasePath; + } } diff --git a/src/Core/Routing/Routing.php b/src/Core/Routing/Routing.php new file mode 100644 index 0000000..ac2e9aa --- /dev/null +++ b/src/Core/Routing/Routing.php @@ -0,0 +1,38 @@ +_root = Container::get("ROOT"); + $this->init(); + } + + public function run() + { + $this->_router->run(); + } + + private function init() + { + $this->_router = new Router(); + $this->loadFromFile($this->_root . DIRECTORY_SEPARATOR . "routes"); + $this->_router->set404("404 - Not found"); + } + + private function loadFromFile($path) + { + if (is_dir($path)) { + $router = $this->_router; + require $path . DIRECTORY_SEPARATOR . "routes.php"; + } + } +}