This commit is contained in:
Wilaak 2025-04-19 23:23:34 +02:00
commit 23d2d69e55
5 changed files with 942 additions and 0 deletions

13
LICENSE Normal file
View File

@ -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.

356
README.md Normal file
View File

@ -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
<?php
require __DIR__.'/vendor/autoload.php';
use Wilaak\Http\Route2;
Route2::get('/{world?}', function($world = 'World') {
echo "Hello, $world!";
});
if (Route2::dispatch()) return;
http_response_code(404);
echo '404 Not Found';
```
### FrankenPHP Worker Mode
Boot your application once and keep it in memory by using [worker mode](https://frankenphp.dev/docs/worker/). This dramatically increases performance.
```php
<?php
ignore_user_abort(true);
require __DIR__.'/vendor/autoload.php';
use Wilaak\Http\Route2;
Route2::get('/{world?}', function($world = 'World') {
echo "Hello, $world!";
});
$handler = static function() {
if (Route2::dispatch()) return;
http_response_code(404);
echo '404 Not Found';
};
while (frankenphp_handle_request($handler)) {
gc_collect_cycles();
}
```
## Basic Routing
The most basic routes accept a URI and a closure, providing a very simple and expressive method of defining routes and behavior:
```php
Route2::get('/greeting', function () {
echo 'Hello World';
});
```
### Available Methods
The router allows you to register routes that respond to any HTTP verb:
```php
Route2::get($uri, $callback);
Route2::post($uri, $callback);
Route2::put($uri, $callback);
Route2::patch($uri, $callback);
Route2::delete($uri, $callback);
Route2::options($uri, $callback);
```
### Multiple HTTP-verbs
Sometimes you may need to register a route that responds to multiple HTTP-verbs. You may do so using the match method. Or, you may even register a route that responds to all HTTP verbs using the any method:
```php
Route2::match('get|post', '/', function() {
// matches any method you like
});
Route2::forms('/', function() {
// matches GET and POST methods
});
Route2::any('/', function() {
// matches any HTTP method
});
```
## Route Parameters
Sometimes you will need to capture segments of the URI within your route. For example, you may need to capture a user's ID from the URL. You may do so by defining route parameters:
**Note**: The parameters injected into the controller will always be of type `string`.
You may define as many route parameters as required by your route:
```php
Route2::get('/posts/{post}/comments/{comment}', function ($post, $comment) {
// ...
});
```
### Required Parameters
These parameters must be provided or the route is skipped.
```php
Route2::get('/user/{id}', function ($id) {
echo 'User '.$id;
});
```
### Optional Parameters
Specify a route parameter that may not always be present in the URI. You may do so by placing a ? mark after the parameter name.
**Note**: Make sure to give the route's corresponding variable a default value:
```php
Route2::get('/user/{name?}', function (string $name = 'John') {
echo $name;
});
```
### Wildcard Parameters
Capture the whole segment including slashes by placing a * after the parameter name.
**Note**: Make sure to give the route's corresponding variable a default value:
```php
Route2::get('/somewhere/{any*}', function ($any = 'Empty') {
// Matches everything after the parameter
});
```
### Parameter Constraints
You can constrain the format of your route parameters by using the named argument `expression`, which accepts an associative array where the key is the parameter name and the value is either a regex string or a function.
```php
Route2::get('/user/{id}', function ($id) {
echo "User ID: $id";
}, expression: ['id' => '[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.<br>";
});
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.

181
benchmark/routes.php Normal file
View File

@ -0,0 +1,181 @@
<?php
use Wilaak\Http\Route2;
Route2::get("/addon/linkers", function() {});
Route2::get("/addon/linkers/{linker_key}", function() {});
Route2::get("/addon/linkers/{linker_key}/values", function() {});
Route2::get("/addon/linkers/{linker_key}/values/{value_id}", function() {});
Route2::get("/hook_events", function() {});
Route2::get("/hook_events/{subject_type}", function() {});
Route2::get("/pullrequests/{selected_user}", function() {});
Route2::get("/repositories", function() {});
Route2::get("/repositories/{workspace}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/branch-restrictions", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/branch-restrictions/{id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/branching-model", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/branching-model/settings", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/approve", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/comments", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/comments/{comment_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/properties/{app_key}/{property_name}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/pullrequests", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/reports", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/reports/{reportId}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/reports/{reportId}/annotations", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/reports/{reportId}/annotations/{annotationId}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/statuses", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/statuses/build", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commit/{commit}/statuses/build/{key}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commits", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/commits/{revision}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/components", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/components/{component_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/default-reviewers", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/default-reviewers/{target_username}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/deploy-keys", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/deploy-keys/{key_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/deployments/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/deployments/{deployment_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/deployments_config/environments/{environment_uuid}/variables", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/deployments_config/environments/{environment_uuid}/variables/{variable_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/diff/{spec}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/diffstat/{spec}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/downloads", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/downloads/{filename}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/environments/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/environments/{environment_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/environments/{environment_uuid}/changes/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/filehistory/{commit}/{path}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/forks", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/hooks", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/hooks/{uid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/export", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/export/{repo_name}-issues-{task_id}.zip", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/import", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/attachments", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/attachments/{path}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/changes", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/changes/{change_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/comments", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/comments/{comment_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/vote", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/issues/{issue_id}/watch", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/merge-base/{revspec}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/milestones", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/milestones/{milestone_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/patch/{spec}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines-config/caches/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines-config/caches/{cache_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines-config/caches/{cache_uuid}/content-uri", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/{step_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/{step_uuid}/log", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/{step_uuid}/logs/{log_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/{step_uuid}/test_reports", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/{step_uuid}/test_reports/test_cases", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/steps/{step_uuid}/test_reports/test_cases/{test_case_uuid}/test_case_reasons", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines/{pipeline_uuid}/stopPipeline", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/build_number", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/schedules/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/schedules/{schedule_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/schedules/{schedule_uuid}/executions/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/ssh/key_pair", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/ssh/known_hosts/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/ssh/known_hosts/{known_host_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/variables/", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pipelines_config/variables/{variable_uuid}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/properties/{app_key}/{property_name}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/activity", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/activity", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/approve", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/comments", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/comments/{comment_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/commits", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/decline", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/diff", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/diffstat", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/merge", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/merge/task-status/{task_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/patch", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/request-changes", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/statuses", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/pullrequests/{pullrequest_id}/properties/{app_key}/{property_name}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/refs", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/refs/branches", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/refs/branches/{name}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/refs/tags", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/refs/tags/{name}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/src", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/src/{commit}/{path}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/versions", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/versions/{version_id}", function() {});
Route2::get("/repositories/{workspace}/{repo_slug}/watchers", function() {});
Route2::get("/snippets", function() {});
Route2::get("/snippets/{workspace}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/comments", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/comments/{comment_id}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/commits", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/commits/{revision}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/files/{path}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/watch", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/watchers", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/{node_id}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/{node_id}/files/{path}", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/{revision}/diff", function() {});
Route2::get("/snippets/{workspace}/{encoded_id}/{revision}/patch", function() {});
Route2::get("/teams", function() {});
Route2::get("/teams/{username}", function() {});
Route2::get("/teams/{username}/followers", function() {});
Route2::get("/teams/{username}/following", function() {});
Route2::get("/teams/{username}/members", function() {});
Route2::get("/teams/{username}/permissions", function() {});
Route2::get("/teams/{username}/permissions/repositories", function() {});
Route2::get("/teams/{username}/permissions/repositories/{repo_slug}", function() {});
Route2::get("/teams/{username}/pipelines_config/variables/", function() {});
Route2::get("/teams/{username}/pipelines_config/variables/{variable_uuid}", function() {});
Route2::get("/teams/{username}/projects/", function() {});
Route2::get("/teams/{username}/projects/{project_key}", function() {});
Route2::get("/teams/{username}/search/code", function() {});
Route2::get("/teams/{workspace}/repositories", function() {});
Route2::get("/user", function() {});
Route2::get("/user/emails", function() {});
Route2::get("/user/emails/{email}", function() {});
Route2::get("/user/permissions/repositories", function() {});
Route2::get("/user/permissions/teams", function() {});
Route2::get("/user/permissions/workspaces", function() {});
Route2::get("/users/{selected_user}", function() {});
Route2::get("/users/{selected_user}/pipelines_config/variables/", function() {});
Route2::get("/users/{selected_user}/pipelines_config/variables/{variable_uuid}", function() {});
Route2::get("/users/{selected_user}/properties/{app_key}/{property_name}", function() {});
Route2::get("/users/{selected_user}/search/code", function() {});
Route2::get("/users/{selected_user}/ssh-keys", function() {});
Route2::get("/users/{selected_user}/ssh-keys/{key_id}", function() {});
Route2::get("/users/{username}/members", function() {});
Route2::get("/users/{workspace}/repositories", function() {});
Route2::get("/workspaces", function() {});
Route2::get("/workspaces/{workspace}", function() {});
Route2::get("/workspaces/{workspace}/hooks", function() {});
Route2::get("/workspaces/{workspace}/hooks/{uid}", function() {});
Route2::get("/workspaces/{workspace}/members", function() {});
Route2::get("/workspaces/{workspace}/members/{member}", function() {});
Route2::get("/workspaces/{workspace}/permissions", function() {});
Route2::get("/workspaces/{workspace}/permissions/repositories", function() {});
Route2::get("/workspaces/{workspace}/permissions/repositories/{repo_slug}", function() {});
Route2::get("/workspaces/{workspace}/pipelines-config/identity/oidc/.well-known/openid-configuration", function() {});
Route2::get("/workspaces/{workspace}/pipelines-config/identity/oidc/keys.json", function() {});
Route2::get("/workspaces/{workspace}/pipelines-config/variables", function() {});
Route2::get("/workspaces/{workspace}/pipelines-config/variables/{variable_uuid}", function() {});
Route2::get("/workspaces/{workspace}/projects", function() {});
Route2::get("/workspaces/{workspace}/projects/{project_key}", function() {});
Route2::get("/workspaces/{workspace}/search/code", function() {});

19
composer.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "wilaak/route2",
"description": "A simple routing library for PHP web applications.",
"type": "library",
"license": "WTFPL",
"authors": [
{
"name": "William",
"email": "54738571+Wilaak@users.noreply.github.com"
}
],
"minimum-stability": "stable",
"autoload": {
"psr-4": {
"Wilaak\\Http\\": "src/"
}
},
"require": {}
}

373
src/Route2.php Normal file
View File

@ -0,0 +1,373 @@
<?php
namespace Wilaak\Http;
use InvalidArgumentException;
/**
* A simple routing library for PHP web applications.
*
* @author Wilaak
* @license WTFPL-1.0
*/
class Route2
{
/**
* Route tree structure.
*
* The tree is structured as follows:
* - Each node represents a segment of the URI.
* - Each segment can have child segments.
* - The `(int) 1337` key contains an array of child nodes.
* - Each child node contains:
* - `methods`: An array of HTTP methods (e.g., GET, POST).
* - `uri`: The URI pattern for the route.
* - `controller`: The controller function to handle the route.
* - `middleware`: An array of middleware functions to execute
*/
private static array $routeTree = [];
/**
* Route addition context.
*/
private static string $routeGroupPrefix = '';
private static array $middlewareStack = [];
private static array $expressionStack = [];
/**
* Adds a new route to the routing tree.
*
* @param string $methods A pipe-separated list of HTTP methods (e.g., "GET|POST").
* @param string $uri The URL pattern for the route.
* Supports:
* - Required parameters `{param}`.
* - Optional parameters `{param?}`.
* - Wildcard parameters `{param*}` (matches everything after the parameter).
* @param callable $controller The controller function to handle the route.
* @param callable|null $middleware Optional middleware to execute before the controller.
* @param array $expression Optional parameter expressions:
* - An associative array where:
* - The key is the parameter name.
* - The value is either:
* - A regex string (e.g., `['id' => '[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;
}
}