Route/Route.php
2025-04-14 22:05:06 +02:00

365 lines
12 KiB
PHP

<?php
namespace WilliamAAK\Http;
use InvalidArgumentException;
/**
* A lightweight routing system for handling HTTP requests.
*
* ### Features:
* - Define routes for specific HTTP methods (GET, POST, PUT, DELETE, etc.).
* - Apply middleware to routes or groups of routes.
* - Group routes under a common prefix for better organization.
* - Support for route parameters (e.g., `/user/$id`) and optional parameters (e.g., `/user/$id?`).
*
* ### Example:
* ```php
* // Define a GET route
* Route::get('/home', function () {
* echo "Welcome to the homepage!";
* });
*
* // Define a route with a parameter
* Route::get('/user/$id', function ($id) {
* echo "User ID: $id";
* });
*
* // Define a route with an optional parameter
* Route::get('/user/$id?', function ($id = null) {
* echo $id ? "User ID: $id" : "No User ID provided.";
* });
*
* // Defining middlewares
* Route::before(function () {
* echo "Middleware executed before the route callback.";
* });
* Route::after(function () {
* echo "Middleware executed after the route callback.";
* });
*
* // Define a route with a middleware attached to it
* Route::post('/submit', function () {
* echo "Form submitted!";
* }, function () {
* echo "Middleware executed before the callback.";
* });
*
* // Group routes under a common prefix
* Route::group('/admin', function () {
* // Middlewares defined here will not affect the routes outside this group
* Route::before(function () {
* echo "Admin Middleware executed.";
* });
* Route::get('/dashboard', function () {
* echo "Admin Dashboard";
* });
* Route::post('/settings', function () {
* echo "Admin Settings";
* });
* });
* ```
*
* @package WilliamAAK\Http
* @version 1.0.0
* @author WilliamAAK
*/
class Route
{
static string $pathPrefix = '';
static array $before = [ [ ] ];
static array $after = [ [ ] ];
/**
* Retrieves the relative path of the current HTTP request.
*
* ### Examples:
* 1. If the request URI is `/index.php/myprofile/sessions` and the script name is `/index.php`,
* this method will return:
* `/myprofile/sessions`
*
* 2. If the request URI is `/myprofile/sessions` and rewrite rules are used to hide `/index.php`,
* this method will return:
* `/myprofile/sessions`
*
* @return string The relative request path.
*/
static function getRelativeRequestPath(): string
{
static $requestUrlPath;
if (isset($requestUrlPath)) {
return $requestUrlPath;
}
$requestUrlPath = urldecode(strtok($_SERVER['REQUEST_URI'], '?'));
$scriptName = $_SERVER['SCRIPT_NAME'];
$scriptDir = dirname($scriptName) . '/';
if (str_starts_with($requestUrlPath, $scriptName)) {
$requestUrlPath = substr($requestUrlPath, strlen($scriptName));
}
if ($requestUrlPath === $scriptDir) {
$requestUrlPath = '/';
}
if ($requestUrlPath === '') {
$requestUrlPath = '/';
}
return $requestUrlPath;
}
/**
* Matches an incoming HTTP request to a defined route and executes the callback.
*
* ### Examples:
* 1. Matching a route with parameters:
* ```php
* Route::match('GET', '/user/$id', function ($id) {
* echo "User ID: $id";
* });
* ```
* - Request: GET /user/123
* - Output: "User ID: 123"
*
* 2. Matching a route with optional parameters:
* ```php
* Route::match('GET', '/user/$id?', function ($id = null) {
* echo $id ? "User ID: $id" : "No User ID";
* });
* ```
* - Request: GET /user/
* - Output: "No User ID"
*
* @param string $methods Allowed HTTP methods (e.g., "GET|POST").
* @param string $route The route path to match (e.g., "/user/$id").
* @param callable $callback The callback to execute if the route matches.
* @param callable|null $middleware Optional middleware to execute before the callback.
*
* @return void
*/
static function match(string $methods, string $route, callable $callback, ?callable $middleware = null): void
{
$allowedMethods = array_map(strtoupper(...), explode('|', $methods));
if (!in_array($_SERVER['REQUEST_METHOD'], $allowedMethods)) {
return;
}
$requestPathParts = explode('/', self::getRelativeRequestPath());
$routeParts = explode('/', self::$pathPrefix . $route);
$callbackArgs = [];
if (count($requestPathParts) !== count($routeParts)) {
return;
}
for ($i = 1; $i < count($routeParts); $i++) {
$routePart = $routeParts[$i] ?? '';
$requestPart = $requestPathParts[$i] ?? '';
if ($routePart === '') {
continue;
}
$isRouteParameter = $routePart[0] === '$';
if (!$isRouteParameter) {
if ($routePart !== $requestPart) {
return;
}
continue;
}
$isOptional = $routePart[-1] === '?';
if (!$isOptional && $requestPart === '') {
return;
}
if ($isOptional && $i !== count($routeParts) - 1) {
throw new InvalidArgumentException('Only the last route parameter can be optional.');
}
if ($requestPart !== '') {
$callbackArgs[] = htmlspecialchars($requestPart);
}
$requestPathParts[$i] = $routePart;
}
if (implode('/', $routeParts) !== implode('/', $requestPathParts)) {
return;
}
if ($middleware !== null) {
self::before($middleware);
}
foreach (self::$before as $middlewares) {
foreach ($middlewares as $middleware) {
$middleware();
}
}
$callback(...$callbackArgs);
foreach (self::$after as $middlewares) {
foreach ($middlewares as $middleware) {
$middleware();
}
}
die();
}
/**
* Shorthand for Route::match('GET', ...).
*/
static function get(string $route, callable $callback, ?callable $middleware = null): void
{self::match('get', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('POST', ...).
*/
static function post(string $route, callable $callback, ?callable $middleware = null): void
{self::match('post', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('PUT', ...).
*/
static function put(string $route, callable $callback, ?callable $middleware = null): void
{self::match('put', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('PATCH', ...).
*/
static function patch(string $route, callable $callback, ?callable $middleware = null): void
{self::match('patch', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('DELETE', ...).
*/
static function delete(string $route, callable $callback, ?callable $middleware = null): void
{self::match('delete', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('OPTIONS', ...).
*/
static function options(string $route, callable $callback, ?callable $middleware = null): void
{self::match('options', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('GET|POST', ...).
*/
static function form(string $route, callable $callback, ?callable $middleware = null): void
{self::match('get|post', $route, $callback, $middleware);}
/**
* Shorthand for Route::match('GET|POST|PUT|PATCH|DELETE|OPTIONS', ...).
*/
static function any(string $route, callable $callback, ?callable $middleware = null): void
{self::match('get|post|put|patch|delete|options', $route, $callback, $middleware);}
/**
* Registers middleware to be executed before the route callback.
*
* ### Example:
* ```php
* Route::before(function () {
* echo "Middleware executed before the route callback.";
* });
* ```
*
* @param callable $middleware The middleware function to execute before the route callback.
*
* @return void
*/
static function before(callable $middleware): void
{
array_push(
self::$before[array_key_last(self::$before)],
$middleware
);
}
/**
* Registers middleware to be executed after the route callback.
*
* ### Example:
* ```php
* Route::after(function () {
* echo "Middleware executed after the route callback.";
* });
* ```
*
* @param callable $middleware The middleware function to execute after the route callback.
*
* @return void
*/
static function after(callable $middleware): void
{
array_push(
self::$after[array_key_last(self::$after)],
$middleware
);
}
/**
* Group routes and middlewares with an optional common prefix.
* It is useful for organizing routes that share a common path or functionality.
*
* ### Examples:
* 1. Grouping routes under a prefix:
* ```php
* Route::group('/admin', function () {
* Route::get('/dashboard', function () {
* echo "Admin Dashboard";
* });
* });
* ```
* - Request: GET /admin/dashboard
* - Output: "Admin Dashboard"
*
* 2. Grouping middlewares and routes but this time without a prefix:
* ```php
* Route::group(callback: function () {
* Route::before(function () {
* echo "Hello routes from within or nested groups! ";
* });
* // Outputs: "Hello routes from within or nested groups! Hello from somewhere!"
* Route::get('/somewhere', function () {
* echo "Hello from somewhere!";
* });
* });
*
* // Outputs: "I am unaffected by that group middleware."
* Route::get('/', function () {
* echo "I am unaffected by that group middleware.";
* });
* ```
* @param string|null $prefix The optional path prefix for the group (e.g., "/admin").
* @param callable|null $callback The callback containing the route definitions for the group.
*
* @throws InvalidArgumentException If the callback is not provided.
*
* @return void
*/
static function group(?string $prefix = null, ?callable $callback = null): void
{
if ($callback === null) {
throw new InvalidArgumentException('You must provide a callback.');
}
if (!str_starts_with(
self::getRelativeRequestPath(),
self::$pathPrefix . $prefix ?? ''
)) return;
self::$pathPrefix = self::$pathPrefix . $prefix ?? '';
array_push(self::$before, []);
array_push(self::$after, []);
$callback();
array_pop(self::$before);
array_pop(self::$after);
self::$pathPrefix = rtrim(self::$pathPrefix, $prefix ?? '');
}
}