*/
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]);
}
}
}