commit 23d2d69e559c216b4ec4eef55ad101eb2e6d6799 Author: Wilaak <54738571+Wilaak@users.noreply.github.com> Date: Sat Apr 19 23:23:34 2025 +0200 Init diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d3f9e2e --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + +Copyright (C) 2004 Whomst Ever + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document, and changing it is allowed as long +as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a028ea --- /dev/null +++ b/README.md @@ -0,0 +1,356 @@ +# Route2 ðŸ›Ģïļ + +A simple routing library for PHP web applications. + +### Features: +- 🌟 **Parameters**: Flexible routing with required, optional and wildcard parameters. +- 🔍 **Constraints**: Use regex or custom functions for parameter constraints. +- ðŸ›Ąïļ **Middleware**: Execute logic before and after route handling. +- 🗂ïļ **Grouping**: Organize routes with shared functionality for cleaner code. +- ðŸŠķ **Lightweight**: A single-file, no-frills dependency-free routing solution. + +## Install + +Install with composer: + + composer require wilaak/route2 + +Or simply include it in your project: + +```php +require '/path/to/Route2.php' +``` + +Requires PHP 8.1 or newer + +--- + +- [Usage](#usage) + - [FrankenPHP Worker Mode](#frankenphp-worker-mode) + - [Basic Routing](#basic-routing) + - [Available Methods](#available-methods) + - [Multiple HTTP-verbs](#multiple-http-verbs) +- [Route Parameters](#route-parameters) + - [Required Parameters](#required-parameters) + - [Optional Parameters](#optional-parameters) + - [Wildcard Parameters](#wildcard-parameters) + - [Parameter Constraints](#parameter-constraints) + - [Global Constraints](#global-constraints) +- [Middleware](#middleware) + - [Registering Middleware](#registering-middleware) +- [Route Groups](#route-groups) + - [Without prefix](#without-prefix) +- [Troubleshooting](#troubleshooting) + - [Dispatching](#dispatching) + - [Accessing your routes](#accessing-your-routes) +- [Hide scriptname from URL](#hide-scriptname-from-url) + - [FrankenPHP](#frankenphp) + - [NGINX](#nginx) + - [Apache](#apache) +- [Performance](#performance) + - [Benchmark](#benchmark) +- [License](#license) + +## Usage + +Here's a basic getting started example: + +```php + '[0-9]+']); + +Route2::get('/user/{id}', function ($id) { + echo "User ID: $id"; +}, expression: ['id' => is_numeric(...)]); +``` + +### Global Constraints + +If you would like a route parameter to always be constrained by a given expression, you may use the `expression` method. Routes added after this method will inherit the expression constraints. + +```php +Route2::expression([ + 'id' => is_numeric(...) +]); + +Route2::get('/user/{id}', function ($id) { + // Only executed if {id} is numeric... +}); +``` + +## Middleware + +Middleware inspect and filter HTTP requests entering your application. For instance, authentication middleware can redirect unauthenticated users to a login screen, while allowing authenticated users to proceed. Middleware can also handle tasks like logging incoming requests. + +**Note**: Middleware only run if a route is found. + +### Registering Middleware + +You may register middleware by using the `before` and `after` methods. Routes added after this method call will inherit the middleware. You may also assign a middleware to a specific route by using the named argument `middleware`: + +```php +// Runs before the route callback +Route2::before(your_middleware(...)); + +// Runs after the route callback +Route2::after(function() { + echo 'Terminating!'; +}); + +// Runs before the route but after the inherited middleware +Route2::get('/', function() { + // ... +}, middleware: fn() => print('I am also a middleware')); +``` + +## Route Groups + +Route groups let you share attributes like middleware and expressions across multiple routes, avoiding repetition. Nested groups inherit attributes from their parent, similar to variable scopes. + +```php +// Group routes under a common prefix +Route2::group('/admin', function () { + // Middleware for all routes in this group and its nested groups + Route2::before(function () { + echo "Group middleware executed.
"; + }); + Route2::get('/dashboard', function () { + echo "Admin Dashboard"; + }); + Route2::post('/settings', function () { + echo "Admin Settings Updated"; + }); +}); +``` + +In this example, all routes within the group will share the `/admin` prefix. + +### Without prefix + +You may also define a group without a prefix by omitting the first argument and using the named argument `callback`. + +```php +Route2::group(callback: function () { + // ... +}); +``` + +## Troubleshooting + +### Dispatching + +When adding routes they are not going to be executed. To perform the routing call the `dispatch` method. + +```php +// Dispatch the request +if (!Route2::dispatch()) { + http_response_code(404); + echo "404 Not Found"; +} +``` + +### Accessing your routes + +The simplest way to access your routes is to put the file in your folder and run it. + +For example if you request ```http://your.site/yourscript.php/your/route``` it will automatically adjust to `/your/route`. + +## Hide scriptname from URL + +Want to hide that pesky script name (e.g., `index.php`) from the URL? + +### FrankenPHP + +In [FrankenPHP](https://frankenphp.dev); the modern PHP app server. This behavior is enabled by default for `index.php` + +### NGINX + +With PHP already installed and configured you may add this to the server block of your configuration to make requests that don't match a file on your server to be redirected to `index.php` + +```nginx +location / { + try_files $uri $uri/ /index.php?$query_string; +} +``` + +### Apache + +Make sure that mod_rewrite is installed on Apache. On a unix system you can just do `a2enmod rewrite` + +This snippet in your .htaccess will ensure that all requests for files and folders that does not exists will be redirected to `index.php` + +```apache +RewriteEngine on +RewriteCond %{REQUEST_FILENAME} !-d +RewriteCond %{REQUEST_FILENAME} !-f +RewriteRule ^(.*)$ index.php?q=$1 [L,QSA] +``` + +## Performance + +This router is not the fastest especially when using **Classic** PHP modes as it has to compile the routing tree for each request. If performance is crucial consider something else. + +### Benchmark + +Here is a test running against 178 routes. See `benchmark/route2/routes.php`. The baselines are doing no routing and responding immediately. + +**Note**: This is running on year 2014 level desktop shared hardware. (`Xeon E3-1226 v3`) + +``` ++------------------------+-----------+------------+ +| Benchmark | Latency | Per Second | ++------------------------+-----------+------------+ +| Baseline\Worker | 19.94ms | 20765.94 | +| Route2\Worker | 22.65ms | 18092.66 | +| Baseline\Classic | 177.44ms | 7731.38 | +| Route2\Classic | 114.87ms | 3400.63 | ++------------------------+-----------+------------+ +``` + +Test was done using `wrk`on the same machine: + + wrk -t8 -c400 -d5s http://127.0.0.1:8080 + + +## License + +This library is licensed under the **WTFPL-1.0**. Do whatever you want with it. diff --git a/benchmark/routes.php b/benchmark/routes.php new file mode 100644 index 0000000..d652e1b --- /dev/null +++ b/benchmark/routes.php @@ -0,0 +1,181 @@ + '[0-9]+']` to match numbers). + * - A function that validates the parameter + * (e.g., `['id' => is_numeric(...)]`). + * + * @return void + */ + public static function match(string $methods, string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + $methods = explode('|', strtoupper($methods)); + + $middlewareStack = self::$middlewareStack; + if ($middleware) { + $middlewareStack['before'][] = $middleware; + } + + $segments = preg_replace('/\{(\w+)(\?|(\*))?\}/', '*', $uri); + $segments = preg_split('/(?=\/)/', self::$routeGroupPrefix . $segments, -1, PREG_SPLIT_NO_EMPTY); + + $currentNode = &self::$routeTree; + foreach ($segments as $segment) { + $currentNode[$segment] ??= []; + $currentNode = &$currentNode[$segment]; + } + + $currentNode[1337][] = [ + 'uri' => self::$routeGroupPrefix . $uri, + 'methods' => $methods, + 'controller' => $controller, + 'middleware' => $middlewareStack, + 'expression' => self::$expressionStack + $expression, + ]; + } + + /** + * Shorthand for `Route2::match('GET', ...)` + */ + public static function get(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('GET', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('POST', ...)` + */ + public static function post(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('POST', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('PUT', ...)` + */ + public static function put(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('PUT', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('DELETE', ...)` + */ + public static function delete(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('DELETE', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('PATCH', ...)` + */ + public static function patch(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('PATCH', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('OPTIONS', ...)` + */ + public static function options(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('OPTIONS', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('GET|POST', ...)` + */ + public static function form(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('GET|POST', $uri, $controller, $middleware, $expression); + } + + /** + * Shorthand for `Route2::match('GET|POST|PUT|DELETE|PATCH|OPTIONS', ...)` + */ + public static function any(string $uri, callable $controller, ?callable $middleware = null, array $expression = []): void + { + self::match('GET|POST|PUT|DELETE|PATCH|OPTIONS', $uri, $controller, $middleware, $expression); + } + + /** + * Adds a middleware to the "before" middleware stack. + * + * Routes added after this method call will inherit the middleware. + * + * @param callable $middleware The middleware to add to the "before" stack. + * It should be a callable function or method. + * + * @return void + */ + public static function before(callable $middleware): void + { + self::$middlewareStack['before'][] = $middleware; + } + + /** + * Adds a middleware to the "after" middleware stack. + * + * Routes added after this method call will inherit the middleware. + * + * @param callable $middleware The middleware to add to the "after" stack. + * It should be a callable function or method. + * + * @return void + */ + public static function after(callable $middleware): void + { + self::$middlewareStack['after'][] = $middleware; + } + + /** + * Adds expressions to the expression stack. + * + * Routes added after this method call will inherit the expression constraints. + * + * @param array $expressions An associative array where: + * - The key is the parameter name (e.g., 'id'). + * - The value is either: + * - A regex string (e.g., `['id' => '[0-9]+']`) to match specific patterns. + * - A callable function that validates the parameter + * (e.g., `['id' => is_numeric(...)]`). + * + * @throws InvalidArgumentException If an expression is neither a string nor a callable. + * @return void + */ + public static function expression(array $expression): void + { + foreach ($expression as $param => $regex) { + if (!is_string($regex) && !is_callable($regex)) { + throw new InvalidArgumentException("Expression for parameter '{$param}' must be a regex string or a callable function"); + } + } + self::$expressionStack[] = $expression; + } + + /** + * Share attributes across multiple routes + * + * Preserves and restores the previous state after the callback. + * + * @param string|null $prefix Optional prefix to be applied to all routes in the group. + * @param callable|null $callback A callback function that defines the routes within the group. Required. + * + * @return void + */ + public static function group(?string $prefix = null, ?callable $callback = null): void + { + $previousPrefix = self::$routeGroupPrefix; + $previousMiddlewareStack = self::$middlewareStack; + $previousExpressionStack = self::$expressionStack; + + self::$routeGroupPrefix .= $prefix ?? ''; + $callback(); + + self::$routeGroupPrefix = $previousPrefix; + self::$middlewareStack = $previousMiddlewareStack; + self::$expressionStack = $previousExpressionStack; + } + + /** + * Gets the relative URI of the current HTTP request. + * + * Example: + * - `/index.php/myroute` → `/myroute` + * + * @return string Relative request path. + */ + public static function getRelativeRequestUri(): string + { + $requestUri = $_SERVER['REQUEST_URI']; + + $scriptName = $_SERVER['SCRIPT_NAME']; + $scriptDir = dirname($scriptName) . '/'; + + if (str_starts_with($requestUri, $scriptName)) { + $requestUri = substr($requestUri, strlen($scriptName)); + } + + if ($requestUri === $scriptDir) { + $requestUri = '/'; + } + + if ($requestUri === '') { + $requestUri = '/'; + } + + return $requestUri; + } + + + /** + * Matches a route in the tree structure based on the URI. + * + * @param string $requestUri The URI to match against the routes. + * + * @return array Returns the matched route details or an empty array if no match is found. + */ + private static function matchRoute(string $requestUri): array + { + $segments = preg_split('/(?=\/)/', $requestUri, -1, PREG_SPLIT_NO_EMPTY); + return self::matchRouteRecursive(self::$routeTree, $segments); + } + + /** + * Recursively matches a route in the tree structure. + * + * @param array $tree The current level of the route tree. + * @param array $segments The remaining URI segments to match. + * @param bool $wildcard Indicates if a wildcard match is active. + * + * @return array Returns the matched route details or an empty array if no match is found. + */ + private static function matchRouteRecursive(array $tree, array $segments, bool $wildcard = false): array + { + if (!$segments) { + return $tree[1337] ?? []; + } + + $currentSegment = array_shift($segments); + + foreach ([$currentSegment, '/*'] as $key) { + if (isset($tree[$key])) { + $match = self::matchRouteRecursive($tree[$key], $segments, $key === '/*'); + if ($match) { + return $match; + } + } + } + + return $wildcard ? ($tree[1337] ?? []) : []; + } + + /** + * Dispatches the request to the appropriate route. + * + * @param string|null $method The HTTP method to match against the routes. If null, uses the current request method. + * @param string|null $uri The URI to match against the routes. If null, uses the current relative request URI. + * + * @return bool True if a route was matched and dispatched, false otherwise. + */ + public static function dispatch(?string $method = null, ?string $uri = null): bool + { + $method = strtoupper($method ?? $_SERVER['REQUEST_METHOD']); + $uri = rawurldecode($uri = strtok($uri ?? self::getRelativeRequestUri(), '?')); + + $routes = self::matchRoute($uri); + + foreach ($routes as $route) { + if (!in_array($method, $route['methods'])) { + continue; + } + + $pattern = preg_replace( + ['/\{(\w+)\}/', '/\{(\w+)\?\}/', '/\{(\w+)\*\}/'], + ['(?P<$1>[^/]+)', '(?P<$1>[^/]*)', '(?P<$1>.*)'], + $route['uri'] + ); + $pattern = '#^' . $pattern . '$#'; + + if (!preg_match($pattern, $uri, $matches)) { + continue; + } + + foreach ($matches as $key => $value) { + if (is_string($key) && !empty($value)) { + $params[$key] = $value; + } + } + + foreach ($route['expression'] as $param => $expression) { + if (!isset($params[$param])) { + continue; + } + + if (is_callable($expression) && !$expression($params[$param])) { + continue 2; + } + + if (is_string($expression) && !preg_match('#^' . $expression . '$#', $params[$param])) { + continue 2; + } + + if (!is_string($expression) && !is_callable($expression)) { + throw new InvalidArgumentException( + "Expression for parameter '{$param}' must be a regex string or callable function" + ); + } + } + + foreach ($route['middleware']['before'] ?? [] as $middleware) { + $middleware(); + } + + $route['controller'](...$params ?? []); + + foreach ($route['middleware']['after'] ?? [] as $middleware) { + $middleware(); + } + + return true; + } + + return false; + } +}