*/ private static array $withMiddlewares = []; /** * Stores the method and index of the last configured route, e.g., ['get', 0]. * Used for chaining methods like middleware() or reconfigure(). * * @var array $last */ private static array $last; /** * Stores the parameters extracted from the current matching route. * * @var Neuron $params */ public static Neuron $params; /** * The callback function to be executed when no route matches. * * @var callable $notFoundCallback */ public static $notFoundCallback = 'Libs\Router::defaultNotFound'; /** * Default callback function for when * the route is not found. * * @return void */ public static function defaultNotFound(): void { header("HTTP/1.0 404 Not Found"); echo '

Error 404 - Page Not Found

'; } /** * The callback function to be executed when the router's boundary * catches an exception thrown anywhere in the matched route chain. * It receives the exception as a named argument: the handler must * declare its parameter as $exception (typeable as \Throwable). * * @var callable $exceptionCallback */ public static $exceptionCallback = 'Libs\Router::defaultException'; /** * Default callback function for exception responses. * * The HTTP status comes from the exception's code only when it is an * integer in the 400-599 range; anything else (0, arbitrary codes, the * SQLSTATE string carried by PDOException) answers 500. The body is * negotiated from the request's Accept header: JSON when the client asks * for application/json, plain text otherwise. The trace is serialized * from getTraceAsString() because json_encode() of a Throwable yields an * empty object: its properties are protected. * * @param \Throwable $exception * * @return void */ public static function defaultException(\Throwable $exception): void { $code = $exception->getCode(); http_response_code(is_int($code) && $code >= 400 && $code <= 599 ? $code : 500); $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; if (str_contains($accept, 'application/json')) { header('Content-Type: application/json'); print(json_encode([ 'error' => $exception->getMessage(), 'exception' => get_class($exception), 'file' => $exception->getFile(), 'line' => $exception->getLine(), 'trace' => explode(PHP_EOL, $exception->getTraceAsString()), ])); return; } print($exception->getMessage() . PHP_EOL . $exception->getTraceAsString()); } /** * __construct */ private function __construct() { } /** * Parses to detect pseudovariables (e.g., {variable}) * * @param string $path * Route with pseudovariables. * * @param callable $callback * Callback that will be called when the route configured in $path matches. * * @return array * Array with 3 indices: * path - Contains the route with pseudovariables replaced by regular expressions. * callback - Contains the callback in Namespace\Class::Method format. * paramNames - An array of parameter names found in the path. */ private static function parse(string $path, callable $callback): array { preg_match_all('/{(\w+)}/s', $path, $matches, PREG_PATTERN_ORDER); $paramNames = $matches[1]; $path = preg_quote($path, '/'); $path = preg_replace( ['/\\\{\w+\\\}/s'], ['([^\/]+)'], $path ); return [ 'path' => $path, 'callback' => [$callback], 'paramNames' => $paramNames, ]; } /** * Returns the base or root path of the project on which the router will work. * * Ex: If the system URL is "https://example.com/duckbrain" * then the base path would be "/duckbrain" * * @return string */ public static function basePath(): string { if (defined('SITE_URL') && !empty(SITE_URL)) { return rtrim(parse_url(SITE_URL, PHP_URL_PATH), '/') . '/'; } return str_replace($_SERVER['DOCUMENT_ROOT'], '/', ROOT_DIR); } /** * Redirects to an internal relative path. * * @param string $path * The path relative to the base path. * * Ex: If our system is at "https://example.com/duckbrain" * and we call Router::redirect('/docs'), we will be * redirected to "https://example.com/duckbrain/docs". * @return void */ public static function redirect(string $path): void { header('Location: ' . static::basePath() . ltrim($path, '/')); } /** * Adds a middleware to the last used route. * Only one middleware can be added at a time. * * @param callable $callback * @param int|null $priority Optional priority for the middleware execution order. * * @return static * Returns the current instance. */ public static function middleware(callable $callback, ?int $priority = null): static { if (!isset(static::$last)) { return new static(); } $method = static::$last[0]; $index = static::$last[1]; if (isset($priority) && $priority <= 0) { $priority = 1; } if (is_null($priority) || $priority >= count(static::$$method[$index]['callback'])) { static::$$method[$index]['callback'][] = $callback; } else { static::$$method[$index]['callback'] = array_merge( array_slice(static::$$method[$index]['callback'], 0, $priority), [$callback], array_slice(static::$$method[$index]['callback'], $priority) ); } return new static(); } /** * Temporarily applies a set of middlewares to routes defined within a given callback. * The middlewares are only active for the duration of the callback execution * and are reset afterwards. * * @param callable $middleware * @param callable $callback * * @return static */ public static function withMiddleware(callable $middleware, callable $callback): static { $currentMiddlewares = static::$withMiddlewares; array_unshift(static::$withMiddlewares, $middleware); $callback(); static::$withMiddlewares = $currentMiddlewares; // Restore withMiddleware return new static(); } /** * Reconfigures the final callback of the last route. * * @param callable $callback * * @return static */ public static function reconfigure(callable $callback): static { if (empty(static::$last)) { return new static(); } $method = static::$last[0]; $index = static::$last[1]; static::$$method[$index]['callback'][0] = $callback; return new static(); } /** * Configures any method for all routes. * * If no callback is received, it searches for the current route * and only sets the route as the last configured one * provided it has been configured previously. * * @param string $method * HTTP method. * @param string $path * Route with pseudovariables. * @param callable|null $callback * * @return static * Returns the current instance. */ public static function configure(string $method, string $path, ?callable $callback = null): static { if (is_null($callback)) { $path = preg_quote($path, '/'); $path = preg_replace( ['/\\\{\w+\\\}/s'], ['([^\/]+)'], $path ); foreach (static::$$method as $index => $router) { if ($router['path'] == $path) { static::$last = [$method, $index]; break; } } return new static(); } static::$$method[] = static::parse($path, $callback); static::$last = [$method, count(static::$$method) - 1]; foreach (static::$withMiddlewares as $middleware) { static::middleware($middleware); } return new static(); } /** * Defines routers for the GET method. * * @param string $path * Route with pseudovariables. * @param callable|null $callback * Callback that will be called when the route configured in $path matches. * * @return static * Returns the current instance. */ public static function get(string $path, ?callable $callback = null): static { return static::configure('get', $path, $callback); } /** * Defines routers for the POST method. * * @param string $path * Route with pseudovariables. * @param callable|null $callback * Callback that will be called when the route configured in $path matches. * * @return static * Returns the current instance. */ public static function post(string $path, ?callable $callback = null): static { return static::configure('post', $path, $callback); } /** * Defines routers for the PUT method. * * @param string $path * Route with pseudovariables. * @param callable|null $callback * Callback that will be called when the route configured in $path matches. * * @return static * Returns the current instance */ public static function put(string $path, ?callable $callback = null): static { return static::configure('put', $path, $callback); } /** * Defines routers for the PATCH method. * * @param string $path * Route with pseudovariables. * @param callable|null $callback * Callback that will be called when the route configured in $path matches. * * @return static * Returns the current instance */ public static function patch(string $path, ?callable $callback = null): static { return static::configure('patch', $path, $callback); } /** * Defines routers for the DELETE method. * * @param string $path * Route with pseudovariables * @param callable|null $callback * Callback that will be called when the route configured in $path matches. * * @return static * Returns the current instance */ public static function delete(string $path, ?callable $callback = null): static { return static::configure('delete', $path, $callback); } /** * Returns the current path, taking the DuckBrain installation path as the root. * * @return string */ public static function currentPath(): string { return preg_replace( '/' . preg_quote(static::basePath(), '/') . '/', '/', strtok($_SERVER['REQUEST_URI'], '?'), 1 ); } /** * Applies the route configuration. * * This method is the framework's error boundary: any \Throwable thrown * while running the matched route's callback chain, while printing the * returned data, or while resolving the not-found callback is caught * here and rendered through $exceptionCallback. A failing handler is * deliberately left uncaught (double failure falls back to PHP itself). * * @param string|null $path (optional) Path to use. If not defined, it detects the current path. * * @return void */ public static function apply(?string $path = null): void { try { $path = $path ?? static::currentPath(); $routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method 'POST' => static::$post, 'PUT' => static::$put, 'PATCH' => static::$patch, 'DELETE' => static::$delete, default => static::$get }; foreach ($routers as $router) { // Checks all routers to see if they match the current path if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) { unset($matches[0]); // Checking and storing the variable parameters of the route if (isset($matches[1])) { static::$params = new Neuron(); foreach ($matches as $index => $match) { $paramName = $router['paramNames'][$index - 1]; static::$params->{$paramName} = urldecode($match[0]); } } // Processes the callback queue foreach (array_reverse($router['callback']) as $callback) { $data = Synapsis::resolve($callback); } // By default, prints as JSON if something is returned if (isset($data)) { header('Content-Type: application/json'); print(json_encode($data)); } return; } } // If no router matches, call $notFoundCallBack Synapsis::resolve(static::$notFoundCallback); } catch (\Throwable $exception) { Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]); } } }