├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Annotation ├── Listener.php └── Route.php ├── Exceptions ├── InvalidControllerException.php ├── MethodNotAllowedException.php ├── RouteNotFoundException.php ├── UriHandlerException.php └── UrlGenerationException.php ├── Handlers ├── CallbackHandler.php ├── FileHandler.php ├── ResourceHandler.php ├── RouteHandler.php └── RouteInvoker.php ├── Interfaces ├── ExceptionInterface.php ├── RouteCompilerInterface.php ├── RouteMatcherInterface.php └── UrlGeneratorInterface.php ├── Middlewares ├── PathMiddleware.php └── UriRedirectMiddleware.php ├── RouteCollection.php ├── RouteCompiler.php ├── RouteUri.php ├── Router.php └── Traits ├── CacheTrait.php ├── PrototypeTrait.php └── ResolverTrait.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 2.1 5 | === 6 | 7 | * [BC BREAK] Replaced `array_merge_recursive` in route to allow total replacement of route vars 8 | * Added support to the router's `setCollection` method to strictly accept type of closure or route collection 9 | * Improved route match performance tremendously nearly over 30% faster than previous version 10 | 11 | 2.0 12 | === 13 | 14 | * [BC BREAK] Removed the route class to use array instead of object 15 | * [BC BREAK] Removed the route matcher class to use only the `Flight\Routing\Router` class for route matching 16 | * [BC BREAK] Removed the `buildRoutes` method from the route collection class, use the `getRoutes` method directly 17 | * [BC BREAK] Removed the `getRoute` method from the route collection class, use the `offGet` method instead 18 | * [BC BREAK] Removed the `routes` method from the route collection class with no replacement 19 | * [BC BREAK] Removed the `addRoute` method from the route collection class, use the `add` method instead 20 | * [BC BREAK] Removed the `isCached` and `addRoute` methods from the default router class 21 | * [BC BREAK] Removed classes, traits and class methods which are unnecessary or affects performance of routing 22 | * [BC BREAK] Improved the route collection class to use array based routes instead of objects 23 | * [BC BREAK] Improved how the default route handler handles array like callable handlers 24 | * [BC BREAK] Replaced the route matcher implementation in the router class for compiler's implementation instead 25 | * [BC BREAK] Replaced unmatched route host exception to instead return null and a route not found exception 26 | * [BC BREAK] Renamed the `Flight\Routing\Generator\GeneratedUri` class to `Flight\Routing\RouteUri` 27 | * Removed `symfony/var-exporter` library support from caching support, using PHP `var-export` function instead 28 | * Added a new `FileHandler` handler class to return contents from a valid file as PSR-7 response 29 | * Added new sets of requirements to the `Flight\Routing\RouteCompiler::SEGMENT_TYPES` constant 30 | * Added a `offGet` method to the route collection class for finding route by it index number 31 | * Added PHP `Countable` support to the route collection class, for faster routes count 32 | * Added PHP `ArrayAccess` support to the route collection class for easier access to routes 33 | * Added support for the default route compiler placeholder's default rule from `\w+` to `.*?` 34 | * Added a static `export` method to the default router class to export php values in a well formatted way for caching 35 | * Improved the route annotation's listener and attribute class for better performance 36 | * Improved the default route matcher's `generateUri` method reversing a route path and strictly matching parameters 37 | * Improved the default route matcher's class `Flight\Routing\Router::match()` method for better performance 38 | * Improved the default route handler's class for easier extending of route handler and arguments rendering 39 | * Improved the default route handler's class ability to detect content type of string 40 | * Improved and fixed route namespacing issues 41 | * Improved thrown exceptions messages for better debugging of errors 42 | * Improved the sorting of routes in the route's collection class 43 | * Improved the `README.md` doc file for better understanding on how to use this library 44 | * Improved coding standard in making the codebase more readable 45 | * Improved benchmarking scenarios for better performance comparison 46 | * Improved performance tremendously, see [Benchmark Results](./BENCHMARK.txt) 47 | * Updated all tests units rewritten with `pestphp/pest` for easier maintenance and improved benchmarking 48 | * Updated minimum requirement for installing this library to PHP 8.0 49 | 50 | 1.6 51 | === 52 | 53 | * Added four public constants to modify the returned value of the `Flight\Routing\Generator\GeneratedUri` class 54 | * Added a third parameter to the router matcher and route's compiler interface `generateUri` method 55 | * Added `symfony/var-exporter` support in providing better performance for cached routes 56 | * Added a `setData` method to the default route class for custom setting of additional data 57 | * Added new sets of requirements to the `Flight\Routing\RouteCompiler::SEGMENT_TYPES` constant 58 | * Improved the default's route class `__set_state` method to work properly with the `var_export` function 59 | * Improved the `Flight\Routing\Generator\GeneratedUri` class in generating reversed route path 60 | * Improved matching cached duplicated dynamic route pattern as unique to avoid regex error 61 | * [BR Break] Replaced the `RouteGeneratorInterface` interface with a `UrlGeneratorInterface` implementation 62 | * [BC Break] Updated the MRM feature on non-cached routes as optional as it affects performance 63 | * Removed the group trait from the route collection class and merge method into the route's collection class 64 | * Removed `PSR-6` cache support from the default router class 65 | * Removed travis CI build support, GitHub Action is the preferred CI 66 | 67 | 1.5 68 | === 69 | 70 | * Added an `attributes` parameter to attributed route class 71 | * Added hasMethod and hasScheme public methods to the route class 72 | * Updated the Route's public const `PRIORITY_REGEX` value 73 | * Updated `biurad/annotation` library to `^1.0` and improved support 74 | * Removed custom route matcher support in the default route matcher class 75 | * Improved priority route's match regex pattern 76 | * Improved static routes generated by the default compiler's build method 77 | * Improved performance matching compiled and non-compiled routes 78 | 79 | 1.4 80 | === 81 | 82 | * [BC Break] Refactored route matching algorithm 83 | * [BC Break] Updated and Renamed `RouteCollector` class to `RouteCollection` 84 | * Added benchmark to compare performance over time 85 | * Added custom route matcher support to the router class 86 | * Added attributes parameter to the route's annotation class 87 | * Updated routes cache from serialization to var_export 88 | * Updated PHP minimum version to 7.4 and added PHP 8.1 support 89 | * Updated README file with documentation of new changes 90 | * Improved PSR-15 middleware performance 91 | * Improved route's handler class code complexity and performance 92 | * Improved overall performance of adding and matching routes 93 | 94 | 1.0 95 | === 96 | 97 | * [BC Break] Refactored route matching algorithm 98 | * [BC Break] Renamed, Added, and Removed unused classes and methods 99 | * Added and reverted debug mode with routes profiling 100 | * Added PHP 7.4 and 8 support to codebase 101 | * Updated README file with documentation of new changes 102 | * Updated phpunit tests coverage to 90+ 103 | * Fixed PSR-4 autoloading standard issues 104 | * Fixed coding standard and static analysers issues 105 | * Improved restful routes implementation 106 | * Improved overall performance of adding and matching routes 107 | 108 | 0.5 109 | === 110 | 111 | * [BC Break] Removed PHP countable interface from route collection's interface 112 | * [BC Break] Renamed missed spelled middleware dispatcher class 113 | * [BC Break] Renamed missed spelled content type middleware class 114 | * Added .github and git community health files 115 | * Added static types analysers (PHPStan, PHPCS, PSalm) 116 | * Updated and fixed PHP types issues and doc commenting 117 | * Updated and fixed phpunit tests cases 118 | * Fixed issue parsing static method as string 119 | * Improved overall performance of adding and matching routes 120 | * Marked `Flight\Routing\Exceptions\UrlGenerationException` class as final 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Divine Niiquaye Ibok 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # The PHP HTTP Flight Routing 4 | 5 | [![PHP Version](https://img.shields.io/packagist/php-v/divineniiquaye/flight-routing.svg?style=flat-square&colorB=%238892BF)](http://php.net) 6 | [![Latest Version](https://img.shields.io/packagist/v/divineniiquaye/flight-routing.svg?style=flat-square)](https://packagist.org/packages/divineniiquaye/flight-routing) 7 | [![Workflow Status](https://img.shields.io/github/workflow/status/divineniiquaye/flight-routing/build?style=flat-square)](https://github.com/divineniiquaye/flight-routing/actions?query=workflow%3Abuild) 8 | [![Code Maintainability](https://img.shields.io/codeclimate/maintainability/divineniiquaye/flight-routing?style=flat-square)](https://codeclimate.com/github/divineniiquaye/flight-routing) 9 | [![Coverage Status](https://img.shields.io/codecov/c/github/divineniiquaye/flight-routing?style=flat-square)](https://codecov.io/gh/divineniiquaye/flight-routing) 10 | [![Psalm Type Coverage](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fshepherd.dev%2Fgithub%2Fdivineniiquaye%2Frade-di%2Fcoverage)](https://shepherd.dev/github/divineniiquaye/flight-routing) 11 | [![Quality Score](https://img.shields.io/scrutinizer/g/divineniiquaye/flight-routing.svg?style=flat-square)](https://scrutinizer-ci.com/g/divineniiquaye/flight-routing) 12 | 13 |
14 | 15 | --- 16 | 17 | Flight routing is yet another high performance HTTP router for [PHP][1]. It is simple, easy to use, scalable and fast. This library depends on [PSR-7][2] for route match and support using [PSR-15][3] for intercepting route before being rendered. 18 | 19 | This library previous versions was inspired by [Sunrise Http Router][4], [Symfony Routing][5], [FastRoute][6] and now completely rewritten for better performance. 20 | 21 | ## 🏆 Features 22 | 23 | - Supports all HTTP request methods (eg. `GET`, `POST` `DELETE`, etc). 24 | - Regex Expression constraints for parameters. 25 | - Reversing named routes paths to full URL with strict parameter checking. 26 | - Route grouping and merging. 27 | - Supports routes caching for performance. 28 | - [PSR-15][3] Middleware (classes that intercepts before the route is rendered). 29 | - Domain and sub-domain routing. 30 | - Restful Routing. 31 | - Supports PHP 8 attribute `#[Route]` and doctrine annotation `@Route` routing. 32 | - Support custom matching strategy using custom route matcher class or compiler class. 33 | 34 | ## 📦 Installation 35 | 36 | This project requires [PHP][1] 8.0 or higher. The recommended way to install, is via [Composer][7]. Simply run: 37 | 38 | ```bash 39 | $ composer require divineniiquaye/flight-routing 40 | ``` 41 | 42 | I recommend reading [my blog post][8] on setting up Apache, Nginx, IIS server configuration for your [PHP][1] project. 43 | 44 | ## 📍 Quick Start 45 | 46 | The default compiler accepts the following constraints in route pattern: 47 | 48 | - `{name}` - required placeholder. 49 | - `{name=foo}` - placeholder with default value. 50 | - `{name:regex}` - placeholder with regex definition. 51 | - `{name:regex=foo}` - placeholder with regex definition and default value. 52 | - `[{name}]` - optional placeholder. 53 | 54 | A name of a placeholder variable is simply an acceptable PHP function/method parameter name expected to be unique, while the regex definition and default value can be any string (i.e [^/]+). 55 | 56 | - `/foo/` - Matches **/foo/** or **/foo**. ending trailing slashes are striped before matching. 57 | - `/user/{id}` - Matches **/user/bob**, **/user/1234** or **/user/23/**. 58 | - `/user/{id:[^/]+}` - Same as the previous example. 59 | - `/user[/{id}]` - Same as the previous example, but also match **/user** or **/user/**. 60 | - `/user[/{id}]/` - Same as the previous example, ending trailing slashes are striped before matching. 61 | - `/user/{id:[0-9a-fA-F]{1,8}}` - Only matches if the id parameter consists of 1 to 8 hex digits. 62 | - `/files/{path:.*}` - Matches any URL starting with **/files/** and captures the rest of the path into the parameter **path**. 63 | - `/[{lang:[a-z]{2}}[-{sublang}]/]{name}[/page-{page=0}]` - Matches **/cs/hello**, **/en-us/hello**, **/hello**, **/hello/page-12**, or **/ru/hello/page-23** 64 | 65 | Route pattern accepts beginning with a `//domain.com` or `https://domain.com`. Route path also support adding controller (i.e `*`) directly at the end of the route path: 66 | 67 | - `*` - translates as a callable of BlogController class with method named indexAction. 68 | - `*` - translates as a function, if a handler class is defined in route, then it turns to a callable. 69 | 70 | Here is an example of how to use the library: 71 | 72 | ```php 73 | use Flight\Routing\{Router, RouteCollection}; 74 | 75 | $router = new Router(); 76 | $router->setCollection(static function (RouteCollection $routes) { 77 | $routes->add('/blog/[{slug}]', handler: [BlogController::class, 'indexAction'])->bind('blog_show'); 78 | //... You can add more routes here. 79 | }); 80 | ``` 81 | 82 | Incase you'll prefer declaring your routes outside a closure scope, try this example: 83 | 84 | ```php 85 | use Flight\Routing\{Router, RouteCollection}; 86 | 87 | $routes = new RouteCollection(); 88 | $routes->get('/blog/{slug}*', handler: BlogController::class)->bind('blog_show'); 89 | 90 | $router = Router::withCollection($routes); 91 | ``` 92 | 93 | > NB: If caching is enabled, using the router's `setCollection()` method has much higher performance than using the `withCollection()` method. 94 | 95 | By default Flight routing does not ship a [PSR-7][2] http library nor a library to send response headers and body to the browser. If you'll like to install this libraries, I recommend installing either [biurad/http-galaxy][9] or [nyholm/psr7][10] and [laminas/laminas-httphandlerrunner][11]. 96 | 97 | ```php 98 | $request = ... // A PSR-7 server request initialized from global request 99 | 100 | // Routing can match routes with incoming request 101 | $route = $router->matchRequest($request); 102 | // Should return an array, if request is made on a a configured route path (i.e /blog/lorem-ipsum) 103 | 104 | // Routing can also generate URLs for a given route 105 | $url = $router->generateUri('blog_show', ['slug' => 'my-blog-post']); 106 | // $url = '/blog/my-blog-post' if stringified else return a GeneratedUri class object 107 | ``` 108 | 109 | In this example below, I'll assume you've installed [nyholm/psr-7][10] and [laminas/laminas-httphandlerrunner][11], So we can use [PSR-15][3] to intercept route before matching and [PSR-17][12] to render route response onto the browser: 110 | 111 | ```php 112 | use Flight\Routing\Handlers\RouteHandler; 113 | use Laminas\HttpHandlerRunner\Emitter\SapiStreamEmitter; 114 | 115 | $router->pipe(...); # Add PSR-15 middlewares ... 116 | 117 | $handlerResolver = ... // You can add your own route handler resolver else default is null 118 | $responseFactory = ... // Add PSR-17 response factory 119 | $request = ... // A PSR-7 server request initialized from global request 120 | 121 | // Default route handler, a custom request route handler can be used also. 122 | $handler = new RouteHandler($responseFactory, $handlerResolver); 123 | 124 | // Match routes with incoming request and return a response 125 | $response = $router->process($request, $handler); 126 | 127 | // Send response to the browser ... 128 | (new SapiStreamEmitter())->emit($response); 129 | ``` 130 | 131 | To use [PHP][1] 8 attribute support, I highly recommend installing [biurad/annotations][13] and if for some reason you decide to use [doctrine/annotations][14] I recommend you install [spiral/attributes][15] to use either one or both. 132 | 133 | An example using annotations/attribute is: 134 | 135 | ```php 136 | use Biurad\Annotations\AnnotationLoader; 137 | use Doctrine\Common\Annotations\AnnotationRegistry; 138 | use Flight\Routing\Annotation\Listener; 139 | use Spiral\Attributes\{AnnotationReader, AttributeReader}; 140 | use Spiral\Attributes\Composite\MergeReader; 141 | 142 | $reader = new AttributeReader(); 143 | 144 | // If you only want to use PHP 8 attribute support, you can skip this step and set reader to null. 145 | if (\class_exists(AnnotationRegistry::class)) $reader = new MergeReader([new AnnotationReader(), $reader]); 146 | 147 | $loader = new AnnotationLoader($reader); 148 | $loader->listener(new Listener(), 'my_routes'); 149 | $loader->resource('src/Controller', 'src/Bundle/BundleName/Controller'); 150 | 151 | $annotation = $loader->load('my_routes'); // Returns a Flight\Routing\RouteCollection class instance 152 | ``` 153 | 154 | You can add more listeners to the annotation loader class to have all your annotations/attributes loaded from one place. 155 | Also use either the `populate()` route collection method or `group()` to merge annotation's route collection into default route collection, or just simple use the annotation's route collection as your default router route collection. 156 | 157 | Finally, use a restful route, refer to this example below, using `Flight\Routing\RouteCollection::resource`, method means, route becomes available for all standard request methods `Flight\Routing\Router::HTTP_METHODS_STANDARD`: 158 | 159 | ```php 160 | namespace Demo\Controller; 161 | 162 | class UserController { 163 | public function getUser(int $id): string { 164 | return "get {$id}"; 165 | } 166 | 167 | public function postUser(int $id): string { 168 | return "post {$id}"; 169 | } 170 | 171 | public function deleteUser(int $id): string { 172 | return "delete {$id}"; 173 | } 174 | } 175 | ``` 176 | 177 | Add route using `Flight\Routing\Handlers\ResourceHandler`: 178 | 179 | ```php 180 | use Flight\Routing\Handlers\ResourceHandler; 181 | 182 | $routes->add('/user/{id:\d+}', ['GET', 'POST'], new ResourceHandler(Demo\UserController::class, 'user')); 183 | ``` 184 | 185 | As of Version 2.0, flight routing is very much stable and can be used in production, Feel free to contribute to the project, report bugs, request features and so on. 186 | 187 | > Kindly take note of these before using: 188 | > * Avoid declaring the same pattern of dynamic route multiple times (eg. `/hello/{name}`), instead use static paths if you choose use same route path with multiple configurations. 189 | > * Route handlers prefixed with a `\` (eg. `\HelloClass` or `['\HelloClass', 'handle']`) should be avoided if you choose to use a different resolver other the default handler's RouteInvoker class. 190 | > * If you decide again to use a custom route's handler resolver, I recommend you include the static `resolveRoute` method from the default's route's RouteInvoker class. 191 | 192 | ## 📓 Documentation 193 | 194 | In-depth documentation on how to use this library, kindly check out the [documentation][16] for this library. It is also recommended to browse through unit tests in the [tests](./tests/) directory. 195 | 196 | ## 🙌 Sponsors 197 | 198 | If this library made it into your project, or you interested in supporting us, please consider [donating][17] to support future development. 199 | 200 | ## 👥 Credits & Acknowledgements 201 | 202 | - [Divine Niiquaye Ibok][18] is the author this library. 203 | - [All Contributors][19] who contributed to this project. 204 | 205 | ## 📄 License 206 | 207 | Flight Routing is completely free and released under the [BSD 3 License](LICENSE). 208 | 209 | [1]: https://php.net 210 | [2]: http://www.php-fig.org/psr/psr-7/ 211 | [3]: http://www.php-fig.org/psr/psr-15/ 212 | [4]: https://github.com/sunrise-php/http-router 213 | [5]: https://github.com/symfony/routing 214 | [6]: https://github.com/nikic/FastRoute 215 | [7]: https://getcomposer.org 216 | [8]: https://divinenii.com/blog/php-web_server_configuration 217 | [9]: https://github.com/biurad/php-http-galaxy 218 | [10]: https://github.com/nyholm/psr7 219 | [11]: https://github.com/laminas/laminas-httphandlerrunner 220 | [12]: https://www.php-fig.org/psr/psr-17/ 221 | [13]: https://github.com/biurad/php-annotations 222 | [14]: https://github.com/doctrine/annotations 223 | [15]: https://github.com/spiral/attributes 224 | [16]: https://divinenii.com/courses/php-flight-routing/ 225 | [17]: https://divinenii.com/sponser 226 | [18]: https://github.com/divineniiquaye 227 | [19]: https://github.com/divineniiquaye/flight-routing/contributors 228 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "divineniiquaye/flight-routing", 3 | "type": "library", 4 | "description": "Flight routing is a simple, fast PHP router that is easy to get integrated with other routers.", 5 | "keywords": ["router", "url routing", "php-17", "psr-15", "psr-7", "php", "biurad"], 6 | "homepage": "https://www.divinenii.com", 7 | "license": "BSD-3-Clause", 8 | "authors": [ 9 | { 10 | "name": "Divine Niiquaye Ibok", 11 | "email": "divineibok@gmail.com" 12 | } 13 | ], 14 | "support": { 15 | "docs": "https://docs.divinenii.com/flight-routing/", 16 | "issues": "https://github.com/divineniiquaye/flight-routing/issues", 17 | "rss": "https://github.com/divineniiquaye/flight-routing/releases.atom", 18 | "source": "https://github.com/divineniiquaye/flight-routing" 19 | }, 20 | "require": { 21 | "php": "^8.0", 22 | "psr/http-factory": "^1.0", 23 | "laminas/laminas-stratigility": "^3.2", 24 | "symfony/polyfill-php81": "^1.23" 25 | }, 26 | "require-dev": { 27 | "biurad/annotations": "^1.0", 28 | "divineniiquaye/php-invoker": "^0.9", 29 | "doctrine/annotations": "^1.11", 30 | "nyholm/psr7": "^1.4", 31 | "nyholm/psr7-server": "^1.0", 32 | "pestphp/pest": "^1.21", 33 | "phpstan/phpstan": "^1.0", 34 | "phpunit/phpunit": "^9.5", 35 | "spiral/attributes": "^2.9", 36 | "squizlabs/php_codesniffer": "^3.6", 37 | "vimeo/psalm": "^4.23" 38 | }, 39 | "suggest": { 40 | "biurad/annotations": "For annotation routing on classes and methods using Annotation/Listener class", 41 | "biurad/http-galaxy": "For handling router, an alternative is nyholm/psr7, slim/psr7 or laminas/laminas-diactoros", 42 | "divineniiquaye/php-invoker": "For auto-configuring route handler parameters as needed with or without PSR-11 support", 43 | "biurad/di": "For full support of PSR-11 and autowiring capabilities on routes (recommended for install).", 44 | "laminas/laminas-httphandlerrunner": "For emitting response headers and body contents to browser" 45 | }, 46 | "autoload": { 47 | "psr-4": { 48 | "Flight\\Routing\\": "src/" 49 | } 50 | }, 51 | "extra": { 52 | "branch-alias": { 53 | "dev-master": "2.x-dev" 54 | } 55 | }, 56 | "scripts": { 57 | "phpcs": "phpcs -q", 58 | "phpstan": "phpstan analyse", 59 | "psalm": "psalm --show-info=true", 60 | "pest": "pest --no-coverage", 61 | "test": [ 62 | "@phpcs", 63 | "@phpstan", 64 | "@psalm", 65 | "@pest" 66 | ] 67 | }, 68 | "config": { 69 | "allow-plugins": { 70 | "pestphp/pest-plugin": true 71 | } 72 | }, 73 | "minimum-stability": "dev", 74 | "prefer-stable": true 75 | } 76 | -------------------------------------------------------------------------------- /src/Annotation/Listener.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Annotation; 17 | 18 | use Biurad\Annotations\{InvalidAnnotationException, ListenerInterface}; 19 | use Flight\Routing\Handlers\ResourceHandler; 20 | use Flight\Routing\RouteCollection; 21 | 22 | /** 23 | * The Biurad Annotation's Listener bridge. 24 | * 25 | * @author Divine Niiquaye Ibok 26 | */ 27 | class Listener implements ListenerInterface 28 | { 29 | private RouteCollection $collector; 30 | 31 | public function __construct(RouteCollection $collector = null) 32 | { 33 | $this->collector = $collector ?? new RouteCollection(); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function load(array $annotations): RouteCollection 40 | { 41 | foreach ($annotations as $annotation) { 42 | $reflection = $annotation['type']; 43 | $attributes = $annotation['attributes'] ?? []; 44 | 45 | if (empty($methods = $annotation['methods'] ?? [])) { 46 | foreach ($attributes as $route) { 47 | $this->addRoute($this->collector, $route, $reflection->getName()); 48 | } 49 | continue; 50 | } 51 | 52 | if (empty($attributes)) { 53 | foreach ($methods as $method) { 54 | foreach (($method['attributes'] ?? []) as $route) { 55 | $controller = ($m = $method['type'])->isStatic() ? $reflection->name.'::'.$m->name : [$reflection->name, $m->name]; 56 | $this->addRoute($this->collector, $route, $controller); 57 | } 58 | } 59 | continue; 60 | } 61 | 62 | foreach ($attributes as $classAnnotation) { 63 | $group = empty($classAnnotation->resource) 64 | ? $this->addRoute($this->collector->group($classAnnotation->name, return: true), $classAnnotation, true) 65 | : throw new InvalidAnnotationException('Restful annotated class cannot contain annotated method(s).'); 66 | 67 | foreach ($methods as $method) { 68 | foreach (($method['attributes'] ?? []) as $methodAnnotation) { 69 | $controller = ($m = $method['type'])->isStatic() ? $reflection->name.'::'.$m->name : [$reflection->name, $m->name]; 70 | $this->addRoute($group, $methodAnnotation, $controller); 71 | } 72 | } 73 | } 74 | } 75 | 76 | return $this->collector; 77 | } 78 | 79 | /** 80 | * {@inheritdoc} 81 | */ 82 | public function getAnnotations(): array 83 | { 84 | return ['Flight\Routing\Annotation\Route']; 85 | } 86 | 87 | protected function addRoute(RouteCollection $collection, Route $route, mixed $handler): RouteCollection 88 | { 89 | if (true !== $handler) { 90 | if (empty($route->path)) { 91 | throw new InvalidAnnotationException('Attributed method route path empty'); 92 | } 93 | 94 | if (!empty($route->resource)) { 95 | $handler = !\is_string($handler) || !\class_exists($handler) 96 | ? throw new InvalidAnnotationException('Restful routing is only supported on attribute route classes.') 97 | : new ResourceHandler($handler, $route->resource); 98 | } 99 | 100 | $collection->add($route->path, $route->methods ?: ['GET'], $handler); 101 | 102 | if (!empty($route->name)) { 103 | $collection->bind($route->name); 104 | } 105 | } else { 106 | if (!empty($route->path)) { 107 | $collection->prefix($route->path); 108 | } 109 | 110 | if (!empty($route->methods)) { 111 | $collection->method(...$route->methods); 112 | } 113 | } 114 | 115 | if (!empty($route->schemes)) { 116 | $collection->scheme(...$route->schemes); 117 | } 118 | 119 | if (!empty($route->hosts)) { 120 | $collection->domain(...$route->hosts); 121 | } 122 | 123 | if (!empty($route->where)) { 124 | $collection->placeholders($route->where); 125 | } 126 | 127 | if (!empty($route->defaults)) { 128 | $collection->defaults($route->defaults); 129 | } 130 | 131 | if (!empty($route->arguments)) { 132 | $collection->arguments($route->arguments); 133 | } 134 | 135 | return $collection; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Annotation/Route.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Annotation; 17 | 18 | /** 19 | * Annotation class for @Route(). 20 | * 21 | * @Annotation 22 | * @NamedArgumentConstructor 23 | * 24 | * On PHP 7.2+ Attributes are supported except you want to use Doctrine annotations: 25 | * ```php 26 | * #[Route('/blog/{_locale}', name: 'blog', defaults: ['_locale' => 'en'])] 27 | * class Blog 28 | * { 29 | * #[Route('/', name: '_index', methods: ['GET', 'HEAD'] schemes: 'https')] 30 | * public function index() 31 | * { 32 | * } 33 | * #[Route('/{id}', name: '_post', methods: 'POST' where: ["id" => '\d+'])] 34 | * public function show() 35 | * { 36 | * } 37 | * } 38 | * ``` 39 | * 40 | * @Target({"CLASS", "METHOD", "FUNCTION"}) 41 | */ 42 | #[\Spiral\Attributes\NamedArgumentConstructor] 43 | #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] 44 | final class Route 45 | { 46 | /** @var array */ 47 | public array $methods, $hosts, $schemes; 48 | 49 | /** 50 | * @param array|string $methods 51 | * @param array|string $schemes 52 | * @param array|string $hosts 53 | * @param array $where 54 | * @param array $defaults 55 | * @param array $arguments 56 | */ 57 | public function __construct( 58 | public ?string $path = null, 59 | public ?string $name = null, 60 | string|array $methods = [], 61 | string|array $schemes = [], 62 | string|array $hosts = [], 63 | public array $where = [], 64 | public array $defaults = [], 65 | public array $arguments = [], 66 | public ?string $resource = null 67 | ) { 68 | $this->methods = (array) $methods; 69 | $this->schemes = (array) $schemes; 70 | $this->hosts = (array) $hosts; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidControllerException.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Exceptions; 17 | 18 | use Flight\Routing\Interfaces\ExceptionInterface; 19 | 20 | /** 21 | * Class InvalidControllerException. 22 | */ 23 | class InvalidControllerException extends \DomainException implements ExceptionInterface 24 | { 25 | } 26 | -------------------------------------------------------------------------------- /src/Exceptions/MethodNotAllowedException.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Exceptions; 17 | 18 | use Flight\Routing\Interfaces\ExceptionInterface; 19 | 20 | /** 21 | * HTTP 405 exception. 22 | */ 23 | class MethodNotAllowedException extends \RuntimeException implements ExceptionInterface 24 | { 25 | /** @var array */ 26 | private array $methods; 27 | 28 | /** 29 | * @param array $methods 30 | */ 31 | public function __construct(array $methods, string $path, string $method) 32 | { 33 | $this->methods = \array_map('strtoupper', $methods); 34 | $message = 'Route with "%s" path requires request method(s) [%s], "%s" is invalid.'; 35 | 36 | parent::__construct(\sprintf($message, $path, \implode(',', $methods), $method), 405); 37 | } 38 | 39 | /** 40 | * Gets allowed methods. 41 | * 42 | * @return array 43 | */ 44 | public function getAllowedMethods(): array 45 | { 46 | return $this->methods; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Exceptions/RouteNotFoundException.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Exceptions; 17 | 18 | use Flight\Routing\Interfaces\ExceptionInterface; 19 | use Psr\Http\Message\UriInterface; 20 | 21 | /** 22 | * Class RouteNotFoundException. 23 | */ 24 | class RouteNotFoundException extends \DomainException implements ExceptionInterface 25 | { 26 | public function __construct(string|UriInterface $message = '', int $code = 404, \Throwable $previous = null) 27 | { 28 | if ($message instanceof UriInterface) { 29 | $message = \sprintf('Unable to find the controller for path "%s". The route is wrongly configured.', $message->getPath()); 30 | } 31 | 32 | parent::__construct($message, $code, $previous); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Exceptions/UriHandlerException.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Exceptions; 17 | 18 | use Flight\Routing\Interfaces\ExceptionInterface; 19 | 20 | class UriHandlerException extends \RuntimeException implements ExceptionInterface 21 | { 22 | } 23 | -------------------------------------------------------------------------------- /src/Exceptions/UrlGenerationException.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Exceptions; 17 | 18 | use Flight\Routing\Interfaces\ExceptionInterface; 19 | 20 | final class UrlGenerationException extends \RuntimeException implements ExceptionInterface 21 | { 22 | } 23 | -------------------------------------------------------------------------------- /src/Handlers/CallbackHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Handlers; 17 | 18 | use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; 19 | use Psr\Http\Server\RequestHandlerInterface; 20 | 21 | final class CallbackHandler implements RequestHandlerInterface 22 | { 23 | /** @var callable */ 24 | private $callback; 25 | 26 | public function __construct(callable $callback) 27 | { 28 | $this->callback = $callback; 29 | } 30 | 31 | /** 32 | * {@inheritdoc} 33 | */ 34 | public function handle(ServerRequestInterface $request): ResponseInterface 35 | { 36 | return ($this->callback)($request); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Handlers/FileHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Handlers; 17 | 18 | use Flight\Routing\Exceptions\InvalidControllerException; 19 | use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface}; 20 | 21 | /** 22 | * Returns the contents from a file. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | final class FileHandler 27 | { 28 | public const MIME_TYPE = [ 29 | 'txt' => 'text/plain', 30 | 'htm' => 'text/html', 31 | 'html' => 'text/html', 32 | 'php' => 'text/html', 33 | 'css' => 'text/css', 34 | 'js' => 'application/javascript', 35 | 'json' => 'application/json', 36 | 'xml' => 'text/xml', 37 | 'swf' => 'application/x-shockwave-flash', 38 | 'flv' => 'video/x-flv', 39 | // images 40 | 'png' => 'image/png', 41 | 'jpe' => 'image/jpeg', 42 | 'jpeg' => 'image/jpeg', 43 | 'jpg' => 'image/jpeg', 44 | 'gif' => 'image/gif', 45 | 'bmp' => 'image/bmp', 46 | 'ico' => 'image/vnd.microsoft.icon', 47 | 'tiff' => 'image/tiff', 48 | 'tif' => 'image/tiff', 49 | 'svg' => 'image/svg+xml', 50 | 'svgz' => 'image/svg+xml', 51 | // archives 52 | 'zip' => 'application/zip', 53 | 'rar' => 'application/x-rar-compressed', 54 | 'exe' => 'application/x-msdownload', 55 | 'msi' => 'application/x-msdownload', 56 | 'cab' => 'application/vnd.ms-cab-compressed', 57 | // audio/video 58 | 'mp3' => 'audio/mpeg', 59 | 'qt' => 'video/quicktime', 60 | 'mov' => 'video/quicktime', 61 | // adobe 62 | 'pdf' => 'application/pdf', 63 | 'psd' => 'image/vnd.adobe.photoshop', 64 | 'ai' => 'application/postscript', 65 | 'eps' => 'application/postscript', 66 | 'ps' => 'application/postscript', 67 | // ms office 68 | 'doc' => 'application/msword', 69 | 'rtf' => 'application/rtf', 70 | 'xls' => 'application/vnd.ms-excel', 71 | 'ppt' => 'application/vnd.ms-powerpoint', 72 | 'docx' => 'application/msword', 73 | 'xlsx' => 'application/vnd.ms-excel', 74 | 'pptx' => 'application/vnd.ms-powerpoint', 75 | // open office 76 | 'odt' => 'application/vnd.oasis.opendocument.text', 77 | 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', 78 | ]; 79 | 80 | public function __construct(private string $filename, private ?string $mimeType = null) 81 | { 82 | } 83 | 84 | public function __invoke(ResponseFactoryInterface $factory): ResponseInterface 85 | { 86 | if (!\file_exists($view = $this->filename) || !$contents = \file_get_contents($view)) { 87 | throw new InvalidControllerException(\sprintf('Failed to fetch contents from file "%s"', $view)); 88 | } 89 | 90 | if (empty($mime = $this->mimeType ?? self::MIME_TYPE[\pathinfo($view, \PATHINFO_EXTENSION)] ?? null)) { 91 | $mime = (new \finfo(\FILEINFO_MIME_TYPE))->file($view); // @codeCoverageIgnoreStart 92 | 93 | if (false === $mime) { 94 | throw new InvalidControllerException(\sprintf('Failed to detect mime type of file "%s"', $view)); 95 | } // @codeCoverageIgnoreEnd 96 | } 97 | 98 | $response = $factory->createResponse()->withHeader('Content-Type', $mime); 99 | $response->getBody()->write($contents); 100 | 101 | return $response; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Handlers/ResourceHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Handlers; 17 | 18 | use Flight\Routing\Exceptions\InvalidControllerException; 19 | 20 | /** 21 | * An extendable HTTP Verb-based route handler to provide a RESTful API for a resource. 22 | * 23 | * @author Divine Niiquaye Ibok 24 | */ 25 | final class ResourceHandler 26 | { 27 | /** 28 | * @param string $method The method name eg: action -> getAction 29 | */ 30 | public function __construct( 31 | private string|object $resource, 32 | private string $method = 'action' 33 | ) { 34 | if (\is_callable($resource) || \is_subclass_of($resource, self::class)) { 35 | throw new \Flight\Routing\Exceptions\InvalidControllerException( 36 | 'Expected a class string or class object, got a type of "callable" instead' 37 | ); 38 | } 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public function __invoke(string $requestMethod, bool $validate = false): array 45 | { 46 | $method = \strtolower($requestMethod).\ucfirst($this->method); 47 | 48 | if (\is_string($class = $this->resource)) { 49 | $class = \ltrim($class, '\\'); 50 | } 51 | 52 | if ($validate && !\method_exists($class, $method)) { 53 | $err = 'Method %s() for resource route "%s" is not found.'; 54 | 55 | throw new InvalidControllerException(\sprintf($err, $method, \is_object($class) ? $class::class : $class)); 56 | } 57 | 58 | return [$class, $method]; 59 | } 60 | 61 | /** 62 | * Append a missing namespace to resource class. 63 | * 64 | * @internal 65 | */ 66 | public function namespace(string $namespace): self 67 | { 68 | if (!\is_string($resource = $this->resource) || '\\' === $resource[0]) { 69 | return $this; 70 | } 71 | 72 | if (!\str_starts_with($resource, $namespace)) { 73 | $this->resource = $namespace.$resource; 74 | } 75 | 76 | return $this; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Handlers/RouteHandler.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Handlers; 17 | 18 | use Flight\Routing\Exceptions\{InvalidControllerException, RouteNotFoundException}; 19 | use Flight\Routing\Router; 20 | use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface, ServerRequestInterface}; 21 | use Psr\Http\Server\RequestHandlerInterface; 22 | 23 | /** 24 | * Default routing request handler. 25 | * 26 | * if route is found in request attribute, dispatch the route handler's 27 | * response to the browser and provides ability to detect right response content-type. 28 | * 29 | * @author Divine Niiquaye Ibok 30 | */ 31 | class RouteHandler implements RequestHandlerInterface 32 | { 33 | /** This allows a response to be served when no route is found. */ 34 | public const OVERRIDE_NULL_ROUTE = 'OVERRIDE_NULL_ROUTE'; 35 | 36 | /** @var callable */ 37 | protected $handlerResolver; 38 | 39 | public function __construct(protected ResponseFactoryInterface $responseFactory, callable $handlerResolver = null) 40 | { 41 | $this->handlerResolver = $handlerResolver ?? new RouteInvoker(); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | * 47 | * @throws InvalidControllerException|RouteNotFoundException 48 | */ 49 | public function handle(ServerRequestInterface $request): ResponseInterface 50 | { 51 | if (null === $route = $request->getAttribute(Router::class)) { 52 | if (true === $res = $request->getAttribute(static::OVERRIDE_NULL_ROUTE)) { 53 | return $this->responseFactory->createResponse(); 54 | } 55 | 56 | return $res instanceof ResponseInterface ? $res : throw new RouteNotFoundException($request->getUri()); 57 | } 58 | 59 | if (empty($handler = $route['handler'] ?? null)) { 60 | return $this->responseFactory->createResponse(204)->withHeader('Content-Type', 'text/plain; charset=utf-8'); 61 | } 62 | 63 | $arguments = fn (ServerRequestInterface $request): array => $this->resolveArguments($request, $route['arguments'] ?? []); 64 | $response = RouteInvoker::resolveRoute($request, $this->handlerResolver, $handler, $arguments); 65 | 66 | if ($response instanceof FileHandler) { 67 | return $response($this->responseFactory); 68 | } 69 | 70 | if (!$response instanceof ResponseInterface) { 71 | if (empty($contents = $response)) { 72 | throw new InvalidControllerException('The route handler\'s content is not a valid PSR7 response body stream.'); 73 | } 74 | ($response = $this->responseFactory->createResponse())->getBody()->write($contents); 75 | } 76 | 77 | return $response->hasHeader('Content-Type') ? $response : $this->negotiateContentType($response); 78 | } 79 | 80 | /** 81 | * A HTTP response Content-Type header negotiator for html, json, svg, xml, and plain-text. 82 | */ 83 | protected function negotiateContentType(ResponseInterface $response): ResponseInterface 84 | { 85 | if (empty($contents = (string) $response->getBody())) { 86 | $mime = 'text/plain; charset=utf-8'; 87 | $response = $response->withStatus(204); 88 | } elseif (false === $mime = (new \finfo(\FILEINFO_MIME_TYPE))->buffer($contents)) { 89 | $mime = 'text/html; charset=utf-8'; // @codeCoverageIgnore 90 | } elseif ('text/xml' === $mime) { 91 | \preg_match('/<(?:\s+)?\/?(?:\s+)?(\w+)(?:\s+)?>$/', $contents, $xml, \PREG_UNMATCHED_AS_NULL); 92 | $mime = 'svg' === $xml[1] ? 'image/svg+xml' : \sprintf('%s; charset=utf-8', 'rss' === $xml[1] ? 'application/rss+xml' : 'text/xml'); 93 | } 94 | 95 | return $response->withHeader('Content-Type', $mime); 96 | } 97 | 98 | /** 99 | * @param array $parameters 100 | * 101 | * @return array 102 | */ 103 | protected function resolveArguments(ServerRequestInterface $request, array $parameters): array 104 | { 105 | foreach ([$request, $this->responseFactory] as $psr7) { 106 | $parameters[$psr7::class] = $psr7; 107 | 108 | foreach ((@\class_implements($psr7) ?: []) as $psr7Interface) { 109 | $parameters[$psr7Interface] = $psr7; 110 | } 111 | } 112 | 113 | return $parameters; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Handlers/RouteInvoker.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Handlers; 17 | 18 | use Flight\Routing\Exceptions\InvalidControllerException; 19 | use Psr\Container\ContainerInterface; 20 | use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; 21 | use Psr\Http\Server\RequestHandlerInterface; 22 | 23 | /** 24 | * Invokes a route's handler with arguments. 25 | * 26 | * If you're using this library with Rade-DI, Yii Inject, DivineNii PHP Invoker, or Laravel DI, 27 | * instead of using this class as callable, use the call method from the container's class. 28 | * 29 | * @author Divine Niiquaye Ibok 30 | */ 31 | class RouteInvoker 32 | { 33 | public function __construct(protected ?ContainerInterface $container = null) 34 | { 35 | } 36 | 37 | /** 38 | * Auto-configure route handler parameters. 39 | * 40 | * @param array $arguments 41 | */ 42 | public function __invoke(mixed $handler, array $arguments): mixed 43 | { 44 | if (\is_string($handler)) { 45 | $handler = \ltrim($handler, '\\'); 46 | 47 | if ($this->container?->has($handler)) { 48 | $handler = $this->container->get($handler); 49 | } elseif (\str_contains($handler, '@')) { 50 | $handler = \explode('@', $handler, 2); 51 | goto maybe_callable; 52 | } elseif (\class_exists($handler)) { 53 | $handler = \is_callable($this->container) ? ($this->container)($handler) : new $handler(); 54 | } 55 | } elseif (\is_array($handler) && ([0, 1] === \array_keys($handler) && \is_string($handler[0]))) { 56 | $handler[0] = \ltrim($handler[0], '\\'); 57 | 58 | maybe_callable: 59 | if ($this->container?->has($handler[0])) { 60 | $handler[0] = $this->container->get($handler[0]); 61 | } elseif (\class_exists($handler[0])) { 62 | $handler[0] = \is_callable($this->container) ? ($this->container)($handler[0]) : new $handler[0](); 63 | } 64 | } 65 | 66 | if (!\is_callable($handler)) { 67 | if (!\is_object($handler)) { 68 | throw new InvalidControllerException(\sprintf('Route has an invalid handler type of "%s".', \gettype($handler))); 69 | } 70 | 71 | return $handler; 72 | } 73 | 74 | $handlerRef = new \ReflectionFunction(\Closure::fromCallable($handler)); 75 | 76 | if ($handlerRef->getNumberOfParameters() > 0) { 77 | $resolvedParameters = $this->resolveParameters($handlerRef->getParameters(), $arguments); 78 | } 79 | 80 | return $handlerRef->invokeArgs($resolvedParameters ?? []); 81 | } 82 | 83 | public function getContainer(): ?ContainerInterface 84 | { 85 | return $this->container; 86 | } 87 | 88 | /** 89 | * Resolve route handler & parameters. 90 | * 91 | * @throws InvalidControllerException 92 | */ 93 | public static function resolveRoute( 94 | ServerRequestInterface $request, 95 | callable $resolver, 96 | mixed $handler, 97 | callable $arguments, 98 | ): ResponseInterface|FileHandler|string|null { 99 | if ($handler instanceof RequestHandlerInterface) { 100 | return $handler->handle($request); 101 | } 102 | $printed = \ob_start(); // Start buffering response output 103 | 104 | try { 105 | if ($handler instanceof ResourceHandler) { 106 | $handler = $handler($request->getMethod(), true); 107 | } 108 | 109 | $response = ($resolver)($handler, $arguments($request)); 110 | } catch (\Throwable $e) { 111 | \ob_get_clean(); 112 | 113 | throw $e; 114 | } finally { 115 | while (\ob_get_level() > 1) { 116 | $printed = \ob_get_clean() ?: null; 117 | } // If more than one output buffers is set ... 118 | } 119 | 120 | if ($response instanceof ResponseInterface || \is_string($response = $printed ?: ($response ?? \ob_get_clean()))) { 121 | return $response; 122 | } 123 | 124 | if ($response instanceof RequestHandlerInterface) { 125 | return $response->handle($request); 126 | } 127 | 128 | if ($response instanceof \Stringable) { 129 | return $response->__toString(); 130 | } 131 | 132 | if ($response instanceof FileHandler) { 133 | return $response; 134 | } 135 | 136 | if ($response instanceof \JsonSerializable || $response instanceof \iterable || \is_array($response)) { 137 | return \json_encode($response, \JSON_THROW_ON_ERROR) ?: null; 138 | } 139 | 140 | return null; 141 | } 142 | 143 | /** 144 | * @param array $refParameters 145 | * @param array $arguments 146 | * 147 | * @return array 148 | */ 149 | protected function resolveParameters(array $refParameters, array $arguments): array 150 | { 151 | $parameters = []; 152 | $nullable = 0; 153 | 154 | foreach ($arguments as $k => $v) { 155 | if (\is_numeric($k) || !\str_contains($k, '&')) { 156 | continue; 157 | } 158 | 159 | foreach (\explode('&', $k) as $i) { 160 | $arguments[$i] = $v; 161 | } 162 | } 163 | 164 | foreach ($refParameters as $index => $parameter) { 165 | $typeHint = $parameter->getType(); 166 | 167 | if ($nullable > 0) { 168 | $index = $parameter->getName(); 169 | } 170 | 171 | if ($typeHint instanceof \ReflectionUnionType || $typeHint instanceof \ReflectionIntersectionType) { 172 | foreach ($typeHint->getTypes() as $unionType) { 173 | if ($unionType->isBuiltin()) { 174 | continue; 175 | } 176 | 177 | if (isset($arguments[$unionType->getName()])) { 178 | $parameters[$index] = $arguments[$unionType->getName()]; 179 | continue 2; 180 | } 181 | 182 | if ($this->container?->has($unionType->getName())) { 183 | $parameters[$index] = $this->container->get($unionType->getName()); 184 | continue 2; 185 | } 186 | } 187 | } elseif ($typeHint instanceof \ReflectionNamedType && !$typeHint->isBuiltin()) { 188 | if (isset($arguments[$typeHint->getName()])) { 189 | $parameters[$index] = $arguments[$typeHint->getName()]; 190 | continue; 191 | } 192 | 193 | if ($this->container?->has($typeHint->getName())) { 194 | $parameters[$index] = $this->container->get($typeHint->getName()); 195 | continue; 196 | } 197 | } 198 | 199 | if (isset($arguments[$parameter->getName()])) { 200 | $parameters[$index] = $arguments[$parameter->getName()]; 201 | } elseif ($parameter->isDefaultValueAvailable()) { 202 | ++$nullable; 203 | } elseif (!$parameter->isVariadic() && ($parameter->isOptional() || $parameter->allowsNull())) { 204 | $parameters[$index] = null; 205 | } 206 | } 207 | 208 | return $parameters; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Interfaces/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Interfaces; 17 | 18 | /** 19 | * Marker interface for package-specific exceptions. 20 | * 21 | * @author Divine Niiquaye Ibok 22 | */ 23 | interface ExceptionInterface 24 | { 25 | } 26 | -------------------------------------------------------------------------------- /src/Interfaces/RouteCompilerInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Interfaces; 17 | 18 | use Flight\Routing\Exceptions\UrlGenerationException; 19 | use Flight\Routing\RouteUri as GeneratedUri; 20 | 21 | /** 22 | * This is the interface that all custom compilers for routes will depend on or implement. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | interface RouteCompilerInterface 27 | { 28 | /** 29 | * Match the Route instance and compiles the current route instance. 30 | * 31 | * This method should strictly return an indexed array of two parts. 32 | * 33 | * - path regex, with starting and ending modifiers. Eg #^\/hello\/world\/(?P[^\/]+)$#sDu 34 | * - variables, which is an unique array of path variables (if available). 35 | * 36 | * @see Flight\Routing\Router::match() implementation 37 | * 38 | * @param string $route the pattern to compile 39 | * @param array $placeholders 40 | * 41 | * @return array 42 | */ 43 | public function compile(string $route, array $placeholders = []): array; 44 | 45 | /** 46 | * Generate a URI from a named route. 47 | * 48 | * @see Flight\Routing\Router::generateUri() implementation 49 | * 50 | * @param array $route 51 | * @param array $parameters 52 | * 53 | * @throws UrlGenerationException if mandatory parameters are missing 54 | * 55 | * @return null|GeneratedUri should return null if this is not implemented 56 | */ 57 | public function generateUri(array $route, array $parameters, int $referenceType = GeneratedUri::ABSOLUTE_PATH): ?GeneratedUri; 58 | } 59 | -------------------------------------------------------------------------------- /src/Interfaces/RouteMatcherInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Interfaces; 17 | 18 | use Flight\Routing\RouteCollection; 19 | use Psr\Http\Message\{ServerRequestInterface, UriInterface}; 20 | 21 | /** 22 | * Interface defining required router compiling capabilities. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | interface RouteMatcherInterface 27 | { 28 | /** 29 | * Find a route by matching with request method and PSR-7 uri. 30 | * 31 | * @return null|array 32 | */ 33 | public function match(string $method, UriInterface $uri): ?array; 34 | 35 | /** 36 | * Find a route by matching with PSR-7 server request. 37 | * 38 | * @return null|array 39 | */ 40 | public function matchRequest(ServerRequestInterface $request): ?array; 41 | 42 | public function getCollection(): RouteCollection; 43 | } 44 | -------------------------------------------------------------------------------- /src/Interfaces/UrlGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Interfaces; 17 | 18 | use Flight\Routing\Exceptions\UrlGenerationException; 19 | use Flight\Routing\RouteUri; 20 | 21 | /** 22 | * A fluent implementation for reversing compiled route paths. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | interface UrlGeneratorInterface 27 | { 28 | /** 29 | * Generate a URI from the named route. 30 | * 31 | * Takes the named route and any parameters, and attempts to generate a 32 | * URI from it. Additional router-dependent query may be passed. 33 | * 34 | * Once there are no missing parameters in the URI we will encode 35 | * the URI and prepare it for returning to the user. If the URI is supposed to 36 | * be absolute, we will return it as-is. Otherwise we will remove the URL's root. 37 | * 38 | * @param string $routeName route name 39 | * @param array $parameters key => value option pairs to pass to the 40 | * router for purposes of generating a URI; 41 | * takes precedence over options 42 | * present in route used to generate URI 43 | * 44 | * @throws UrlGenerationException if the route name is not known 45 | * or a parameter value does not match its regex 46 | */ 47 | public function generateUri(string $routeName, array $parameters = [], int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri; 48 | } 49 | -------------------------------------------------------------------------------- /src/Middlewares/PathMiddleware.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Middlewares; 17 | 18 | use Flight\Routing\Router; 19 | use Psr\Http\Message\{ResponseInterface, ServerRequestInterface, UriInterface}; 20 | use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface}; 21 | 22 | /** 23 | * This middleware increases SEO (search engine optimization) by preventing duplication 24 | * of content at different URLs including resolving sub-directory paths. 25 | * 26 | * The response status code is 302 if the permanent parameter is false (default), 27 | * and 301 if the redirection is permanent on redirection. If keep request method 28 | * parameter is true, response code 307, and if permanent is true status code is 308. 29 | * 30 | * @author Divine Niiquaye Ibok 31 | */ 32 | final class PathMiddleware implements MiddlewareInterface 33 | { 34 | /** 35 | * Slashes supported on browser when used. 36 | */ 37 | public const SUB_FOLDER = __CLASS__.'::subFolder'; 38 | 39 | /** @var array */ 40 | private array $uriSuffixes = []; 41 | 42 | /** 43 | * @param bool $permanent Whether the redirection is permanent 44 | * @param bool $keepRequestMethod Whether redirect action should keep HTTP request method 45 | * @param array $uriSuffixes List of slashes to re-route, defaults to ['/'] 46 | */ 47 | public function __construct( 48 | private bool $permanent = false, 49 | private bool $keepRequestMethod = false, 50 | array $uriSuffixes = [] 51 | ) { 52 | $this->permanent = $permanent; 53 | $this->keepRequestMethod = $keepRequestMethod; 54 | $this->uriSuffixes = empty($uriSuffixes) ? ['/' => '/'] : \array_combine($uriSuffixes, $uriSuffixes); 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 61 | { 62 | $requestPath = ($requestUri = self::resolveUri($request))->getPath(); // Determine right request uri path. 63 | $response = $handler->handle($request); 64 | 65 | if (!empty($route = $request->getAttribute(Router::class, []))) { 66 | $this->uriSuffixes['/'] ??= '/'; 67 | $routeEndTail = $this->uriSuffixes[$route['path'][-1]] ?? null; 68 | $requestEndTail = $this->uriSuffixes[$requestPath[-1]] ?? null; 69 | 70 | if ($requestEndTail === $requestPath || $routeEndTail === $requestEndTail) { 71 | return $response; 72 | } 73 | 74 | // Resolve request tail end to avoid conflicts and infinite redirection looping ... 75 | if (null === $requestEndTail && null !== $routeEndTail) { 76 | $requestPath .= $routeEndTail; 77 | } elseif (null === $routeEndTail && $requestEndTail) { 78 | $requestPath = \substr($requestPath, 0, -1); 79 | } 80 | 81 | $statusCode = $this->keepRequestMethod ? ($this->permanent ? 308 : 307) : ($this->permanent ? 301 : 302); 82 | $response = $response->withHeader('Location', (string) $requestUri->withPath($requestPath))->withStatus($statusCode); 83 | } 84 | 85 | return $response; 86 | } 87 | 88 | public static function resolveUri(ServerRequestInterface &$request): UriInterface 89 | { 90 | $requestUri = $request->getUri(); 91 | $pathInfo = $request->getServerParams()['PATH_INFO'] ?? ''; 92 | 93 | // Checks if the project is in a sub-directory, expect PATH_INFO in $_SERVER. 94 | if ('' !== $pathInfo && $pathInfo !== $requestUri->getPath()) { 95 | $request = $request->withAttribute(self::SUB_FOLDER, \substr($requestUri->getPath(), 0, -\strlen($pathInfo))); 96 | 97 | return $requestUri->withPath($pathInfo); 98 | } 99 | 100 | return $requestUri; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Middlewares/UriRedirectMiddleware.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Middlewares; 17 | 18 | use Flight\Routing\Handlers\RouteHandler; 19 | use Psr\Http\Message\{ResponseInterface, ServerRequestInterface, UriInterface}; 20 | use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface}; 21 | 22 | /** 23 | * The importance of this middleware is to slowly migrate users from old routes 24 | * to new route paths, with or without maintaining route attributes. 25 | * 26 | * Eg: 27 | * 1. Redirect from `/users/\d+` to `/account` 28 | * 2. Redirect from `/sign-up` tp `/register` 29 | * 3. Redirect from `/admin-page` to `#/admin`. The `#` before means, all existing slashes and/or queries are maintained. 30 | * 31 | * NB: Old route paths as treated as regex otherwise actual path redirecting to new paths. 32 | * 33 | * @author Divine Niiquaye Ibok 34 | */ 35 | class UriRedirectMiddleware implements MiddlewareInterface 36 | { 37 | /** 38 | * @param array $redirects [from previous => to new] 39 | * @param bool $keepRequestMethod Whether redirect action should keep HTTP request method 40 | */ 41 | public function __construct(protected array $redirects = [], private bool $keepRequestMethod = false) 42 | { 43 | $this->redirects = $redirects; 44 | $this->keepRequestMethod = $keepRequestMethod; 45 | } 46 | 47 | /** 48 | * Process a request and return a response. 49 | * {@inheritdoc} 50 | */ 51 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 52 | { 53 | $requestPath = ($uri = $request->getUri())->getPath(); 54 | 55 | if ('' === $redirectedUri = (string) ($this->redirects[$requestPath] ?? '')) { 56 | foreach ($this->redirects as $oldPath => $newPath) { 57 | if (1 === \preg_match('#^'.$oldPath.'$#u', $requestPath)) { 58 | $redirectedUri = $newPath; 59 | 60 | break; 61 | } 62 | } 63 | 64 | if (empty($redirectedUri)) { 65 | return $handler->handle($request); 66 | } 67 | } 68 | 69 | if (\is_string($redirectedUri) && '#' === $redirectedUri[0]) { 70 | $redirectedUri = $uri->withPath(\substr($redirectedUri, 1)); 71 | } 72 | 73 | return $handler->handle($request->withAttribute(RouteHandler::OVERRIDE_NULL_ROUTE, true)) 74 | ->withStatus($this->keepRequestMethod ? 308 : 301) 75 | ->withHeader('Location', (string) $redirectedUri) 76 | ; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/RouteCollection.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing; 17 | 18 | /** 19 | * A RouteCollection represents a set of Route instances. 20 | * 21 | * This class provides all(*) methods for creating path+HTTP method-based routes and 22 | * injecting them into the router: 23 | * 24 | * - get 25 | * - post 26 | * - put 27 | * - patch 28 | * - delete 29 | * - options 30 | * - any 31 | * - resource 32 | * 33 | * A general `add()` method allows specifying multiple request methods and/or 34 | * arbitrary request methods when creating a path-based route. 35 | * 36 | * @author Divine Niiquaye Ibok 37 | */ 38 | class RouteCollection implements \Countable, \ArrayAccess 39 | { 40 | use Traits\PrototypeTrait; 41 | 42 | /** 43 | * A Pattern to Locates appropriate route by name, support dynamic route allocation using following pattern: 44 | * Pattern route: `pattern/*` 45 | * Default route: `*` 46 | * Only action: `pattern/*`. 47 | */ 48 | public const RCA_PATTERN = '/^(?:([a-z]+)\:)?(?:\/{2}([^\/]+))?([^*]*)(?:\*\<(?:([\w+\\\\]+)\@)?(\w+)\>)?$/u'; 49 | 50 | /** 51 | * A Pattern to match the route's priority. 52 | * 53 | * If route path matches, 1 is expected return else 0 should be return as priority index. 54 | */ 55 | protected const PRIORITY_REGEX = '/([^<[{:]+\b)/A'; 56 | 57 | protected ?self $parent = null; 58 | protected ?string $namedPrefix = null; 59 | 60 | /** 61 | * @internal 62 | * 63 | * @param array $properties 64 | */ 65 | public static function __set_state(array $properties): static 66 | { 67 | $collection = new static(); 68 | 69 | foreach ($properties as $property => $value) { 70 | $collection->{$property} = $value; 71 | } 72 | 73 | return $collection; 74 | } 75 | 76 | /** 77 | * Sort all routes beginning with static routes. 78 | */ 79 | public function sort(): void 80 | { 81 | if (!empty($this->groups)) { 82 | $this->injectGroups('', $this->routes, $this->defaultIndex); 83 | } 84 | 85 | $this->sorted || $this->sorted = \usort($this->routes, static function (array $a, array $b): int { 86 | $ap = $a['prefix'] ?? null; 87 | $bp = $b['prefix'] ?? null; 88 | 89 | return !($ap && $ap === $a['path']) <=> !($bp && $bp === $b['path']) ?: \strnatcmp($a['path'], $b['path']); 90 | }); 91 | } 92 | 93 | /** 94 | * Get all the routes. 95 | * 96 | * @return array> 97 | */ 98 | public function getRoutes(): array 99 | { 100 | if (!empty($this->groups)) { 101 | $this->injectGroups('', $this->routes, $this->defaultIndex); 102 | } 103 | 104 | return $this->routes; 105 | } 106 | 107 | /** 108 | * Get the total number of routes. 109 | */ 110 | public function count(): int 111 | { 112 | if (!empty($this->groups)) { 113 | $this->injectGroups('', $this->routes, $this->defaultIndex); 114 | } 115 | 116 | return $this->defaultIndex + 1; 117 | } 118 | 119 | /** 120 | * Checks if route by its index exists. 121 | */ 122 | public function offsetExists(mixed $offset): bool 123 | { 124 | return isset($this->routes[$offset]); 125 | } 126 | 127 | /** 128 | * Get the route by its index. 129 | * 130 | * @return null|array 131 | */ 132 | public function offsetGet(mixed $offset): ?array 133 | { 134 | return $this->routes[$offset] ?? null; 135 | } 136 | 137 | public function offsetUnset(mixed $offset): void 138 | { 139 | unset($this->routes[$offset]); 140 | } 141 | 142 | public function offsetSet(mixed $offset, mixed $value): void 143 | { 144 | throw new \BadMethodCallException('The operator "[]" for new route, use the add() method instead.'); 145 | } 146 | 147 | /** 148 | * Maps a pattern to a handler. 149 | * 150 | * You can must specify HTTP methods that should be matched. 151 | * 152 | * @param string $pattern Matched route pattern 153 | * @param string[] $methods Matched HTTP methods 154 | * @param mixed $handler Handler that returns the response when matched 155 | * 156 | * @return $this 157 | */ 158 | public function add(string $pattern, array $methods = Router::DEFAULT_METHODS, mixed $handler = null): self 159 | { 160 | $this->asRoute = true; 161 | $this->routes[++$this->defaultIndex] = ['handler' => $handler]; 162 | $this->path($pattern); 163 | 164 | foreach ($this->prototypes as $route => $arguments) { 165 | if ('prefix' === $route) { 166 | $this->prefix(\implode('', $arguments)); 167 | } elseif ('domain' === $route) { 168 | $this->domain(...$arguments); 169 | } elseif ('namespace' === $route) { 170 | foreach ($arguments as $namespace) { 171 | $this->namespace($namespace); 172 | } 173 | } else { 174 | $this->routes[$this->defaultIndex][$route] = $arguments; 175 | } 176 | } 177 | 178 | foreach ($methods as $method) { 179 | $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true; 180 | } 181 | 182 | return $this; 183 | } 184 | 185 | /** 186 | * Mounts controllers under the given route prefix. 187 | * 188 | * @param null|string $name The route group prefixed name 189 | * @param null|callable|RouteCollection $collection A RouteCollection instance or a callable for defining routes 190 | * @param bool $return If true returns a new collection instance else returns $this 191 | */ 192 | public function group(string $name = null, callable|self $collection = null, bool $return = false): self 193 | { 194 | $this->asRoute = false; 195 | 196 | if (\is_callable($collection)) { 197 | $collection($routes = $this->injectGroup($name, new static()), $return); 198 | } 199 | $route = $routes ?? $this->injectGroup($name, $collection ?? new static(), $return); 200 | $this->groups[] = $route; 201 | 202 | return $return ? $route : $this; 203 | } 204 | 205 | /** 206 | * Merge a collection into base. 207 | * 208 | * @return $this 209 | */ 210 | public function populate(self $collection, bool $asGroup = false) 211 | { 212 | if ($asGroup) { 213 | $this->groups[] = $this->injectGroup($collection->namedPrefix, $collection); 214 | } else { 215 | $routes = $collection->routes; 216 | $asRoute = $this->asRoute; 217 | 218 | if (!empty($collection->groups)) { 219 | $collection->injectGroups($collection->namedPrefix ?? '', $routes, $this->defaultIndex); 220 | } 221 | 222 | foreach ($routes as $route) { 223 | $this->add($route['path'], [], $route['handler']); 224 | $this->routes[$this->defaultIndex] = \array_merge_recursive( 225 | $this->routes[$this->defaultIndex], 226 | \array_diff_key($route, ['path' => null, 'handler' => null, 'prefix' => null]) 227 | ); 228 | } 229 | $this->asRoute = $asRoute; 230 | } 231 | 232 | return $this; 233 | } 234 | 235 | /** 236 | * Maps a GET and HEAD request to a handler. 237 | * 238 | * @param string $pattern Matched route pattern 239 | * @param mixed $handler Handler that returns the response when matched 240 | * 241 | * @return $this 242 | */ 243 | public function get(string $pattern, $handler = null): self 244 | { 245 | return $this->add($pattern, [Router::METHOD_GET, Router::METHOD_HEAD], $handler); 246 | } 247 | 248 | /** 249 | * Maps a POST request to a handler. 250 | * 251 | * @param string $pattern Matched route pattern 252 | * @param mixed $handler Handler that returns the response when matched 253 | * 254 | * @return $this 255 | */ 256 | public function post(string $pattern, $handler = null): self 257 | { 258 | return $this->add($pattern, [Router::METHOD_POST], $handler); 259 | } 260 | 261 | /** 262 | * Maps a PUT request to a handler. 263 | * 264 | * @param string $pattern Matched route pattern 265 | * @param mixed $handler Handler that returns the response when matched 266 | * 267 | * @return $this 268 | */ 269 | public function put(string $pattern, $handler = null): self 270 | { 271 | return $this->add($pattern, [Router::METHOD_PUT], $handler); 272 | } 273 | 274 | /** 275 | * Maps a PATCH request to a handler. 276 | * 277 | * @param string $pattern Matched route pattern 278 | * @param mixed $handler Handler that returns the response when matched 279 | * 280 | * @return $this 281 | */ 282 | public function patch(string $pattern, $handler = null): self 283 | { 284 | return $this->add($pattern, [Router::METHOD_PATCH], $handler); 285 | } 286 | 287 | /** 288 | * Maps a DELETE request to a handler. 289 | * 290 | * @param string $pattern Matched route pattern 291 | * @param mixed $handler Handler that returns the response when matched 292 | * 293 | * @return $this 294 | */ 295 | public function delete(string $pattern, $handler = null): self 296 | { 297 | return $this->add($pattern, [Router::METHOD_DELETE], $handler); 298 | } 299 | 300 | /** 301 | * Maps a OPTIONS request to a handler. 302 | * 303 | * @param string $pattern Matched route pattern 304 | * @param mixed $handler Handler that returns the response when matched 305 | * 306 | * @return $this 307 | */ 308 | public function options(string $pattern, $handler = null): self 309 | { 310 | return $this->add($pattern, [Router::METHOD_OPTIONS], $handler); 311 | } 312 | 313 | /** 314 | * Maps any request to a handler. 315 | * 316 | * @param string $pattern Matched route pattern 317 | * @param mixed $handler Handler that returns the response when matched 318 | * 319 | * @return $this 320 | */ 321 | public function any(string $pattern, $handler = null): self 322 | { 323 | return $this->add($pattern, Router::HTTP_METHODS_STANDARD, $handler); 324 | } 325 | 326 | /** 327 | * Maps any Router::HTTP_METHODS_STANDARD request to a resource handler prefixed to $action's method name. 328 | * 329 | * E.g: Having pattern as "/accounts/{userId}", all request made from supported request methods 330 | * are to have the same url. 331 | * 332 | * @param string $action The prefixed name attached to request method 333 | * @param string $pattern matched path where request should be sent to 334 | * @param class-string|object $resource Handler that returns the response 335 | * 336 | * @return $this 337 | */ 338 | public function resource(string $pattern, string|object $resource, string $action = 'action'): self 339 | { 340 | return $this->any($pattern, new Handlers\ResourceHandler($resource, $action)); 341 | } 342 | 343 | public function generateRouteName(string $prefix, array $route = null): string 344 | { 345 | $route = $route ?? $this->routes[$this->defaultIndex]; 346 | $routeName = \implode('_', \array_keys($route['methods'] ?? [])).'_'.$prefix.$route['path'] ?? ''; 347 | $routeName = \str_replace(['/', ':', '|', '-'], '_', $routeName); 348 | $routeName = (string) \preg_replace('/[^a-z0-9A-Z_.]+/', '', $routeName); 349 | 350 | return (string) \preg_replace(['/\_+/', '/\.+/'], ['_', '.'], $routeName); 351 | } 352 | 353 | // 'next', 'key', 'valid', 'rewind' 354 | 355 | protected function injectGroup(?string $prefix, self $controllers, bool $return = false): self 356 | { 357 | $controllers->prototypes = \array_merge_recursive($this->prototypes, $controllers->prototypes); 358 | 359 | if ($return) { 360 | $controllers->parent = $this; 361 | } 362 | 363 | if (empty($controllers->namedPrefix)) { 364 | $controllers->namedPrefix = $prefix; 365 | } 366 | 367 | return $controllers; 368 | } 369 | 370 | /** 371 | * @param array> $collection 372 | */ 373 | private function injectGroups(string $prefix, array &$collection, int &$count): void 374 | { 375 | $unnamedRoutes = []; 376 | 377 | foreach ($this->groups as $group) { 378 | foreach ($group->routes as $route) { 379 | if (empty($name = $route['name'] ?? '')) { 380 | $name = $group->generateRouteName('', $route); 381 | 382 | if (isset($unnamedRoutes[$name])) { 383 | $name .= ('_' !== $name[-1] ? '_' : '').++$unnamedRoutes[$name]; 384 | } else { 385 | $unnamedRoutes[$name] = 0; 386 | } 387 | } 388 | 389 | $route['name'] = $prefix.$group->namedPrefix.$name; 390 | $collection[] = $route; 391 | ++$count; 392 | } 393 | 394 | if (!empty($group->groups)) { 395 | $group->injectGroups($prefix.$group->namedPrefix, $collection, $count); 396 | } 397 | } 398 | 399 | $this->groups = []; 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/RouteCompiler.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing; 17 | 18 | use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException}; 19 | use Flight\Routing\Interfaces\RouteCompilerInterface; 20 | 21 | /** 22 | * RouteCompiler compiles Route instances to regex. 23 | * 24 | * provides ability to match and generate uris based on given parameters. 25 | * 26 | * @author Divine Niiquaye Ibok 27 | */ 28 | final class RouteCompiler implements RouteCompilerInterface 29 | { 30 | private const DEFAULT_SEGMENT = '[^\/]+'; 31 | 32 | /** 33 | * This string defines the characters that are automatically considered separators in front of 34 | * optional placeholders (with default and no static text following). Such a single separator 35 | * can be left out together with the optional placeholder from matching and generating URLs. 36 | */ 37 | private const PATTERN_REPLACES = ['/[' => '/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.', '/$' => '/?$']; 38 | 39 | /** 40 | * Using the strtr function is faster than the preg_quote function. 41 | */ 42 | private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.']; 43 | 44 | /** 45 | * This regex is used to match a certain rule of pattern to be used for routing. 46 | * 47 | * List of string patterns that regex matches: 48 | * - /{var} - A required variable pattern 49 | * - /[{var}] - An optional variable pattern 50 | * - /foo[/{var}] - A path with an optional sub variable pattern 51 | * - /foo[/{var}[.{format}]] - A path with optional nested variables 52 | * - /{var:[a-z]+} - A required variable with lowercase rule 53 | * - /{var=foo} - A required variable with default value 54 | * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default 55 | */ 56 | private const COMPILER_REGEX = '~\{(\w+)(?:\:(.*?[\}=]?))?(?:\=(.*?))?\}~i'; 57 | 58 | /** 59 | * This regex is used to reverse a pattern path, matching required and options vars. 60 | */ 61 | private const REVERSED_REGEX = '#(?|\<(\w+)\>|\[(.*?\])\]|\[(.*?)\])#'; 62 | 63 | /** 64 | * A matching requirement helper, to ease matching route pattern when found. 65 | */ 66 | private const SEGMENT_TYPES = [ 67 | 'int' => '[0-9]+', 68 | 'lower' => '[a-z]+', 69 | 'upper' => '[A-Z]+', 70 | 'alpha' => '[A-Za-z]+', 71 | 'hex' => '[[:xdigit:]]+', 72 | 'md5' => '[a-f0-9]{32}+', 73 | 'sha1' => '[a-f0-9]{40}+', 74 | 'year' => '[0-9]{4}', 75 | 'month' => '0[1-9]|1[012]+', 76 | 'day' => '0[1-9]|[12][0-9]|3[01]+', 77 | 'date' => '[0-9]{4}-(?:0[1-9]|1[012])-(?:0[1-9]|[12][0-9]|(? '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*', 79 | 'port' => '[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]', 80 | 'UID_BASE32' => '[0-9A-HJKMNP-TV-Z]{26}', 81 | 'UID_BASE58' => '[1-9A-HJ-NP-Za-km-z]{22}', 82 | 'UID_RFC4122' => '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', 83 | 'ULID' => '[0-7][0-9A-HJKMNP-TV-Z]{25}', 84 | 'UUID' => '[0-9a-f]{8}-[0-9a-f]{4}-[1-6][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', 85 | 'UUID_V1' => '[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', 86 | 'UUID_V3' => '[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', 87 | 'UUID_V4' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', 88 | 'UUID_V5' => '[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', 89 | 'UUID_V6' => '[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', 90 | ]; 91 | 92 | /** 93 | * A helper in reversing route pattern to URI. 94 | */ 95 | private const URI_FIXERS = [ 96 | '[]' => '', 97 | '[/]' => '', 98 | '[' => '', 99 | ']' => '', 100 | '://' => '://', 101 | '//' => '/', 102 | '/..' => '/%2E%2E', 103 | '/.' => '/%2E', 104 | ]; 105 | 106 | /** 107 | * The maximum supported length of a PCRE subpattern name 108 | * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16. 109 | * 110 | * @internal 111 | */ 112 | private const VARIABLE_MAXIMUM_LENGTH = 32; 113 | 114 | /** 115 | * {@inheritdoc} 116 | */ 117 | public function compile(string $route, array $placeholders = [], bool $reversed = false): array 118 | { 119 | $variables = $replaces = []; 120 | 121 | if (\strpbrk($route, '{')) { 122 | // Match all variables enclosed in "{}" and iterate over them... 123 | \preg_match_all(self::COMPILER_REGEX, $route, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); 124 | 125 | foreach ($matches as [$placeholder, $varName, $segment, $default]) { 126 | if (1 === \preg_match('/\A\d+/', $varName)) { 127 | throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $route)); 128 | } 129 | 130 | if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) { 131 | throw new UriHandlerException(\sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s".', $varName, self::VARIABLE_MAXIMUM_LENGTH, $route)); 132 | } 133 | 134 | if (\array_key_exists($varName, $variables)) { 135 | throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $route, $varName)); 136 | } 137 | 138 | $segment = self::SEGMENT_TYPES[$segment] ?? self::prepareSegment($varName, $placeholders[$varName] ?? $segment); 139 | [$variables[$varName], $replaces[$placeholder]] = !$reversed ? [$default, '(?P<'.$varName.'>'.$segment.')'] : [[$segment, $default], '<'.$varName.'>']; 140 | } 141 | } 142 | 143 | return !$reversed ? [\strtr('{^'.$route.'$}', $replaces + self::PATTERN_REPLACES), $variables] : [\strtr($route, $replaces), $variables]; 144 | } 145 | 146 | /** 147 | * {@inheritdoc} 148 | */ 149 | public function generateUri(array $route, array $parameters, int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri 150 | { 151 | [$pathRegex, $pathVars] = $this->compile($route['path'], reversed: true); 152 | 153 | $defaults = $route['defaults'] ?? []; 154 | $createUri = new RouteUri(self::interpolate($pathRegex, $pathVars, $parameters + $defaults), $referenceType); 155 | 156 | foreach (($route['hosts'] ?? []) as $host => $exists) { 157 | [$hostRegex, $hostVars] = $this->compile($host, reversed: true); 158 | $createUri->withHost(self::interpolate($hostRegex, $hostVars, $parameters + $defaults)); 159 | break; 160 | } 161 | 162 | if (!empty($schemes = $route['schemes'] ?? [])) { 163 | $createUri->withScheme(isset($schemes['https']) ? 'https' : \array_key_last($schemes) ?? 'http'); 164 | } 165 | 166 | return $createUri; 167 | } 168 | 169 | /** 170 | * Check for mandatory parameters then interpolate $uriRoute with given $parameters. 171 | * 172 | * @param array> $uriVars 173 | * @param array $parameters 174 | */ 175 | private static function interpolate(string $uriRoute, array $uriVars, array $parameters): string 176 | { 177 | $required = []; // Parameters required which are missing. 178 | $replaces = self::URI_FIXERS; 179 | 180 | // Fetch and merge all possible parameters + route defaults ... 181 | \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); 182 | 183 | if (isset($uriVars['*'])) { 184 | [$defaultPath, $required, $optional] = $uriVars['*']; 185 | $replaces = []; 186 | } 187 | 188 | foreach ($matches as $i => [$matched, $varName]) { 189 | if ('[' !== $matched[0]) { 190 | [$segment, $default] = $uriVars[$varName]; 191 | $value = $parameters[$varName] ?? (isset($optional) ? $default : ($parameters[$i] ?? $default)); 192 | 193 | if (!empty($value)) { 194 | if (1 !== \preg_match("~^{$segment}\$~", (string) $value)) { 195 | throw new UriHandlerException( 196 | \sprintf('Expected route path "%s" placeholder "%s" value "%s" to match "%s".', $uriRoute, $varName, $value, $segment) 197 | ); 198 | } 199 | $optional = isset($optional) ? false : null; 200 | $replaces[$matched] = $value; 201 | } elseif (isset($optional) && $optional) { 202 | $replaces[$matched] = ''; 203 | } else { 204 | $required[] = $varName; 205 | } 206 | continue; 207 | } 208 | $replaces[$matched] = self::interpolate($varName, $uriVars + ['*' => [$uriRoute, $required, true]], $parameters); 209 | } 210 | 211 | if (!empty($required)) { 212 | throw new UrlGenerationException(\sprintf( 213 | 'Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', 214 | \implode('", "', $required), 215 | $defaultPath ?? $uriRoute 216 | )); 217 | } 218 | 219 | return !empty(\array_filter($replaces)) ? \strtr($uriRoute, $replaces) : ''; 220 | } 221 | 222 | private static function sanitizeRequirement(string $key, string $regex): string 223 | { 224 | if ('' !== $regex) { 225 | if ('^' === $regex[0]) { 226 | $regex = \substr($regex, 1); 227 | } elseif (\str_starts_with($regex, '\\A')) { 228 | $regex = \substr($regex, 2); 229 | } 230 | 231 | if (\str_ends_with($regex, '$')) { 232 | $regex = \substr($regex, 0, -1); 233 | } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) { 234 | $regex = \substr($regex, 0, -2); 235 | } 236 | } 237 | 238 | if ('' === $regex) { 239 | throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key)); 240 | } 241 | 242 | return \strtr($regex, self::SEGMENT_REPLACES); 243 | } 244 | 245 | /** 246 | * Prepares segment pattern with given constrains. 247 | * 248 | * @param null|array|string $segment 249 | */ 250 | private static function prepareSegment(string $name, string|array|null $segment): string 251 | { 252 | return null === $segment ? self::DEFAULT_SEGMENT : (!\is_array($segment) ? self::sanitizeRequirement($name, $segment) : \implode('|', $segment)); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/RouteUri.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing; 17 | 18 | use Flight\Routing\Exceptions\UrlGenerationException; 19 | 20 | /** 21 | * A generated URI from route made up of only the 22 | * URIs path component (pathinfo, scheme, host, and maybe port.) starting with a slash. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | class RouteUri implements \Stringable 27 | { 28 | /** Generates an absolute URL, e.g. "http://example.com/dir/file". */ 29 | public const ABSOLUTE_URL = 0; 30 | 31 | /** Generates an absolute path, e.g. "/dir/file". */ 32 | public const ABSOLUTE_PATH = 1; 33 | 34 | /** Generates a path with beginning with a single dot, e.g. "./file". */ 35 | public const RELATIVE_PATH = 2; 36 | 37 | /** Generates a network path, e.g. "//example.com/dir/file". */ 38 | public const NETWORK_PATH = 3; 39 | 40 | /** Adopted from symfony's routing component: Symfony\Component\Routing\Generator::QUERY_FRAGMENT_DECODED */ 41 | private const QUERY_DECODED = [ 42 | // RFC 3986 explicitly allows those in the query to reference other URIs unencoded 43 | '%2F' => '/', 44 | '%3F' => '?', 45 | // reserved chars that have no special meaning for HTTP URIs in a query 46 | // this excludes esp. "&", "=" and also "+" because PHP would treat it as a space (form-encoded) 47 | '%40' => '@', 48 | '%3A' => ':', 49 | '%21' => '!', 50 | '%3B' => ';', 51 | '%2C' => ',', 52 | '%2A' => '*', 53 | ]; 54 | 55 | private string $pathInfo; 56 | private int $referenceType; 57 | private ?string $scheme = null, $host = null, $port = null; 58 | 59 | public function __construct(string $pathInfo, int $referenceType) 60 | { 61 | $this->pathInfo = $pathInfo; 62 | $this->referenceType = $referenceType; 63 | } 64 | 65 | /** 66 | * {@inheritdoc} 67 | */ 68 | public function __toString() 69 | { 70 | $pathInfo = '/'.\ltrim($this->pathInfo, '/'); 71 | 72 | if (self::ABSOLUTE_PATH === $type = $this->referenceType) { 73 | return $pathInfo; 74 | } 75 | 76 | if (self::RELATIVE_PATH === $type) { 77 | return '.'.$pathInfo; 78 | } 79 | 80 | if (isset($this->host)) { 81 | $hostPort = $this->host.$this->port; 82 | } else { 83 | $h = \explode(':', $_SERVER['HTTP_HOST'] ?? 'localhost:80', 2); 84 | $hostPort = $h[0].($this->port ?? (!\in_array($h[1] ?? '', ['', '80', '443'], true) ? ':'.$h[0] : '')); 85 | } 86 | 87 | return (self::NETWORK_PATH === $type ? '//' : (isset($this->scheme) ? $this->scheme.'://' : '//')).$hostPort.$pathInfo; 88 | } 89 | 90 | /** 91 | * Set the host component of the URI, may include port too. 92 | */ 93 | public function withHost(string $host): self 94 | { 95 | $this->host = $host; 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * Set the scheme component of the URI. 102 | */ 103 | public function withScheme(string $scheme): self 104 | { 105 | $this->scheme = $scheme; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * Sets the port component of the URI. 112 | */ 113 | public function withPort(int $port): self 114 | { 115 | if (0 > $port || 0xFFFF < $port) { 116 | throw new UrlGenerationException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); 117 | } 118 | 119 | if (!\in_array($port, [80, 443], true)) { 120 | $this->port = ':'.$port; 121 | } 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Set the query component of the URI. 128 | * 129 | * @param array $queryParams 130 | */ 131 | public function withQuery(array $queryParams = []): self 132 | { 133 | // Incase query is added to uri. 134 | if ([] !== $queryParams) { 135 | $queryString = \http_build_query($queryParams, '', '&', \PHP_QUERY_RFC3986); 136 | 137 | if (!empty($queryString)) { 138 | $this->pathInfo .= '?'.\strtr($queryString, self::QUERY_DECODED); 139 | } 140 | } 141 | 142 | return $this; 143 | } 144 | 145 | /** 146 | * Set the fragment component of the URI. 147 | */ 148 | public function withFragment(string $fragment): self 149 | { 150 | if (!empty($fragment)) { 151 | $this->pathInfo .= '#'.$fragment; 152 | } 153 | 154 | return $this; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing; 17 | 18 | use Fig\Http\Message\RequestMethodInterface; 19 | use Flight\Routing\Exceptions\UrlGenerationException; 20 | use Flight\Routing\Interfaces\{RouteCompilerInterface, RouteMatcherInterface, UrlGeneratorInterface}; 21 | use Laminas\Stratigility\Next; 22 | use Psr\Http\Message\{ResponseInterface, ServerRequestInterface, UriInterface}; 23 | use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface}; 24 | 25 | /** 26 | * Aggregate routes for matching and Dispatching. 27 | * 28 | * @author Divine Niiquaye Ibok 29 | */ 30 | class Router implements RouteMatcherInterface, RequestMethodInterface, MiddlewareInterface, UrlGeneratorInterface 31 | { 32 | use Traits\CacheTrait, Traits\ResolverTrait; 33 | 34 | /** @var array Default methods for route. */ 35 | public const DEFAULT_METHODS = [self::METHOD_GET, self::METHOD_HEAD]; 36 | 37 | /** 38 | * Standard HTTP methods for browser requests. 39 | */ 40 | public const HTTP_METHODS_STANDARD = [ 41 | self::METHOD_HEAD, 42 | self::METHOD_GET, 43 | self::METHOD_POST, 44 | self::METHOD_PUT, 45 | self::METHOD_PATCH, 46 | self::METHOD_DELETE, 47 | self::METHOD_PURGE, 48 | self::METHOD_OPTIONS, 49 | self::METHOD_TRACE, 50 | self::METHOD_CONNECT, 51 | ]; 52 | 53 | private RouteCompilerInterface $compiler; 54 | private ?\SplQueue $pipeline = null; 55 | private \Closure|RouteCollection|null $collection = null; 56 | 57 | /** @var array> */ 58 | private array $middlewares = []; 59 | 60 | /** 61 | * @param null|string $cache file path to store compiled routes 62 | */ 63 | public function __construct(RouteCompilerInterface $compiler = null, string $cache = null) 64 | { 65 | $this->cache = $cache; 66 | $this->compiler = $compiler ?? new RouteCompiler(); 67 | } 68 | 69 | /** 70 | * Set a route collection instance into Router in order to use addRoute method. 71 | * 72 | * @param \Closure|RouteCollection|null $collection 73 | * @param null|string $cache file path to store compiled routes 74 | */ 75 | public static function withCollection( 76 | \Closure|RouteCollection|null $collection = null, 77 | RouteCompilerInterface $compiler = null, 78 | string $cache = null 79 | ): static { 80 | $new = new static($compiler, $cache); 81 | $new->collection = $collection; 82 | 83 | return $new; 84 | } 85 | 86 | /** 87 | * {@inheritdoc} 88 | */ 89 | public function match(string $method, UriInterface $uri): ?array 90 | { 91 | return $this->optimized[$method.$uri->__toString()] ??= $this->{$this->cache ? 'resolveCache' : 'resolveRoute'}( 92 | \rtrim(\rawurldecode($uri->getPath()), '/') ?: '/', 93 | $method, 94 | $uri 95 | ); 96 | } 97 | 98 | /** 99 | * {@inheritdoc} 100 | */ 101 | public function matchRequest(ServerRequestInterface $request): ?array 102 | { 103 | $requestUri = $request->getUri(); 104 | $pathInfo = $request->getServerParams()['PATH_INFO'] ?? ''; 105 | 106 | // Resolve request path to match sub-directory or /index.php/path 107 | if ('' !== $pathInfo && $pathInfo !== $requestUri->getPath()) { 108 | $requestUri = $requestUri->withPath($pathInfo); 109 | } 110 | 111 | return $this->match($request->getMethod(), $requestUri); 112 | } 113 | 114 | /** 115 | * {@inheritdoc} 116 | */ 117 | public function generateUri(string $routeName, array $parameters = [], int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri 118 | { 119 | if (empty($matchedRoute = &$this->optimized[$routeName] ?? null)) { 120 | foreach ($this->getCollection()->getRoutes() as $route) { 121 | if (isset($route['name']) && $route['name'] === $routeName) { 122 | $matchedRoute = $route; 123 | break; 124 | } 125 | } 126 | } 127 | 128 | if (!isset($matchedRoute)) { 129 | throw new UrlGenerationException(\sprintf('Route "%s" does not exist.', $routeName)); 130 | } 131 | 132 | return $this->compiler->generateUri($matchedRoute, $parameters, $referenceType) 133 | ?? throw new UrlGenerationException(\sprintf('%s::generateUri() not implemented in compiler.', $this->compiler::class)); 134 | } 135 | 136 | /** 137 | * Attach middleware to the pipeline. 138 | */ 139 | public function pipe(MiddlewareInterface ...$middlewares): void 140 | { 141 | if (null === $this->pipeline) { 142 | $this->pipeline = new \SplQueue(); 143 | } 144 | 145 | foreach ($middlewares as $middleware) { 146 | $this->pipeline->enqueue($middleware); 147 | } 148 | } 149 | 150 | /** 151 | * Attach a name to a group of middlewares. 152 | */ 153 | public function pipes(string $name, MiddlewareInterface ...$middlewares): void 154 | { 155 | $this->middlewares[$name] = $middlewares; 156 | } 157 | 158 | /** 159 | * Sets the RouteCollection instance associated with this Router. 160 | * 161 | * @param \Closure|RouteCollection $collection 162 | */ 163 | public function setCollection(\Closure|RouteCollection $collection): void 164 | { 165 | $this->collection = $collection; 166 | } 167 | 168 | /** 169 | * Get the RouteCollection instance associated with this Router. 170 | */ 171 | public function getCollection(): RouteCollection 172 | { 173 | if ($this->cache) { 174 | return $this->optimized[2] ?? $this->doCache(); 175 | } 176 | 177 | if ($this->collection instanceof \Closure) { 178 | ($this->collection)($this->collection = new RouteCollection()); 179 | } 180 | 181 | return $this->collection ??= new RouteCollection(); 182 | } 183 | 184 | /** 185 | * Set a route compiler instance into Router. 186 | */ 187 | public function setCompiler(RouteCompiler $compiler): void 188 | { 189 | $this->compiler = $compiler; 190 | } 191 | 192 | public function getCompiler(): RouteCompilerInterface 193 | { 194 | return $this->compiler; 195 | } 196 | 197 | /** 198 | * {@inheritdoc} 199 | */ 200 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 201 | { 202 | $route = $this->matchRequest($request); 203 | 204 | if (null !== $route) { 205 | foreach ($route['middlewares'] ?? [] as $a => $b) { 206 | if (isset($this->middlewares[$a])) { 207 | $this->pipe(...$this->middlewares[$a]); 208 | } 209 | } 210 | } 211 | 212 | if (!empty($this->pipeline)) { 213 | $handler = new Next($this->pipeline, $handler); 214 | } 215 | 216 | return $handler->handle($request->withAttribute(self::class, $route)); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Traits/CacheTrait.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Traits; 17 | 18 | use Flight\Routing\Handlers\ResourceHandler; 19 | use Flight\Routing\RouteCollection; 20 | 21 | /** 22 | * A default cache implementation for route match. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | trait CacheTrait 27 | { 28 | private ?string $cache = null; 29 | 30 | /** 31 | * @param string $path file path to store compiled routes 32 | */ 33 | public function setCache(string $path): void 34 | { 35 | $this->cache = $path; 36 | } 37 | 38 | /** 39 | * A well php value formatter, better than (var_export). 40 | */ 41 | public static function export(mixed $value, string $indent = ''): string 42 | { 43 | switch (true) { 44 | case [] === $value: 45 | return '[]'; 46 | case \is_array($value): 47 | if (!($t = \count($value, \COUNT_RECURSIVE) > 15) && \array_is_list($value)) { 48 | \array_walk($value, function ($v) use (&$t, $value) { 49 | if (\count($value) > 1 && \is_string($v) && \strlen($v) > 100) { 50 | $t = true; 51 | } 52 | }); 53 | } 54 | 55 | $j = -1; 56 | $code = $t ? "[\n" : '['; 57 | $subIndent = $t ? $indent.' ' : $indent = ''; 58 | 59 | foreach ($value as $k => $v) { 60 | $code .= $subIndent; 61 | 62 | if (!\is_int($k) || $k !== ++$j) { 63 | $code .= self::export($k, $subIndent).' => '; 64 | } 65 | 66 | $code .= self::export($v, $subIndent).($t ? ",\n" : ', '); 67 | } 68 | 69 | return \rtrim($code, ', ').$indent.']'; 70 | case (\is_string($value) && (':' === $value[0] && ':' === $value[-1])): 71 | return \substr($value, 1, -1); 72 | case $value instanceof ResourceHandler: 73 | return $value::class.'('.self::export($value(''), $indent).')'; 74 | case $value instanceof \stdClass: 75 | return '(object) '.self::export((array) $value, $indent); 76 | case $value instanceof RouteCollection: 77 | return $value::class.'::__set_state('.self::export([ 78 | 'routes' => $value->getRoutes(), 79 | 'defaultIndex' => $value->count() - 1, 80 | 'sorted' => true, 81 | ], $indent).')'; 82 | case \is_object($value): 83 | if (\method_exists($value, '__set_state')) { 84 | return $value::class.'::__set_state('.self::export( 85 | \array_merge(...\array_map(function (\ReflectionProperty $v) use ($value): array { 86 | $v->setAccessible(true); 87 | 88 | return [$v->getName() => $v->getValue($value)]; 89 | }, (new \ReflectionObject($value))->getProperties())) 90 | ); 91 | } 92 | 93 | return 'unserialize(\''.\serialize($value).'\')'; 94 | } 95 | 96 | return \var_export($value, true); 97 | } 98 | 99 | protected function doCache(): RouteCollection 100 | { 101 | if (\is_array($a = @include $this->cache)) { 102 | $this->optimized = $a; 103 | 104 | return $this->optimized[2] ??= $this->collection ?? new RouteCollection(); 105 | } 106 | 107 | if (\is_callable($collection = $this->collection ?? new RouteCollection())) { 108 | $collection($collection = new RouteCollection()); 109 | $collection->sort(); 110 | $doCache = true; 111 | } 112 | 113 | if (!\is_dir($directory = \dirname($this->cache))) { 114 | @\mkdir($directory, 0775, true); 115 | } 116 | 117 | try { 118 | return $collection; 119 | } finally { 120 | $dumpData = $this->buildCache($collection, $doCache ?? false); 121 | \file_put_contents($this->cache, "cache, true); 125 | } 126 | 127 | $this->optimized = require $this->cache; 128 | } 129 | } 130 | 131 | protected function buildCache(RouteCollection $collection, bool $doCache): string 132 | { 133 | $dynamicRoutes = $dynamicVar = $staticRoutes = []; 134 | $dynamicString = ":static function (string \$path): ?array {\n "; 135 | 136 | foreach ($collection->getRoutes() as $i => $route) { 137 | $trimmed = \preg_replace('/\W$/', '', $path = '/'.\ltrim($route['path'], '/')); 138 | 139 | if (\in_array($prefix = '/'.\ltrim($route['prefix'] ?? '/', '/') ?? '/', [$trimmed, $path], true)) { 140 | $staticRoutes[$trimmed ?: '/'][] = $i; 141 | continue; 142 | } 143 | [$path, $var] = $this->getCompiler()->compile($path, $route['placeholders'] ?? []); 144 | $path = \str_replace('\/', '/', \substr($path, 1 + \strpos($path, '^'), -(\strlen($path) - \strrpos($path, '$')))); 145 | 146 | if (($l = \array_key_last($dynamicRoutes)) && !\in_array($l, ['/', $prefix], true)) { 147 | for ($o = 0, $new = ''; $o < \strlen($prefix); ++$o) { 148 | if ($prefix[$o] !== ($l[$o] ?? null)) { 149 | break; 150 | } 151 | $new .= $l[$o]; 152 | } 153 | 154 | if ($new && '/' !== $new) { 155 | if ($l !== $new) { 156 | $dynamicRoutes[$new] = $dynamicRoutes[$l]; 157 | unset($dynamicRoutes[$l]); 158 | } 159 | $prefix = $new; 160 | } 161 | } 162 | $dynamicRoutes[$prefix][] = \preg_replace('#\?(?|P<\w+>|<\w+>|\'\w+\')#', '', $path)."(*:{$i})"; 163 | $dynamicVar[$i] = $var; 164 | } 165 | \ksort($staticRoutes, \SORT_NATURAL); 166 | \uksort($dynamicRoutes, fn (string $a, string $b): int => \in_array('/', [$a, $b], true) ? \strcmp($b, $a) : \strcmp($a, $b)); 167 | 168 | foreach ($dynamicRoutes as $offset => $paths) { 169 | $numParts = \max(1, \round(($c = \count($paths)) / 30)); 170 | $prefix = '/'.\ltrim($offset, '/'); 171 | $indent = ' '; 172 | $chunks = self::export(\array_map(fn (array $p): string => "~^(?|".implode('|', $p).")$~", \array_chunk($paths, (int) \ceil($c / $numParts))), $indent); 173 | $dynamicString .= << 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Traits; 17 | 18 | use Flight\Routing\Exceptions\{InvalidControllerException, UriHandlerException}; 19 | use Flight\Routing\Handlers\ResourceHandler; 20 | 21 | /** 22 | * A trait providing route method prototyping. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | trait PrototypeTrait 27 | { 28 | protected int $defaultIndex = -1; 29 | protected bool $asRoute = false, $sorted = false; 30 | 31 | /** @var array */ 32 | protected array $prototypes = []; 33 | 34 | /** @var array> */ 35 | protected array $routes = []; 36 | 37 | /** @var array */ 38 | protected array $groups = []; 39 | 40 | /** 41 | * Set route's data by calling supported route method in collection. 42 | * 43 | * @param array|true $routeData An array is a list of route method bindings 44 | * Else if true, route bindings can be prototyped 45 | * to all registered routes 46 | * 47 | * @return $this 48 | * 49 | * @throws \InvalidArgumentException if route not defined before calling this method 50 | */ 51 | public function prototype(array|bool $routeData): self 52 | { 53 | if (true === $routeData) { 54 | $this->asRoute = false; 55 | 56 | return $this; 57 | } 58 | 59 | foreach (\array_filter($routeData) as $routeMethod => $arguments) { 60 | $this->{$routeMethod}(...(!\is_array($arguments) || (\count($arguments) > 1 && !\array_is_list($arguments)) ? [$arguments] : $arguments)); 61 | } 62 | 63 | return $this; 64 | } 65 | 66 | /** 67 | * Ending of group chaining stack. (use with caution!). 68 | * 69 | * RISK: This method can break the collection, call this method 70 | * only on the last route of a group stack which the $return parameter 71 | * of the group method is set true. 72 | * 73 | * @return $this 74 | */ 75 | public function end(): self 76 | { 77 | return $this->parent ?? $this; 78 | } 79 | 80 | /** 81 | * Set the route's path. 82 | * 83 | * @return $this 84 | * 85 | * @throws \InvalidArgumentException if you is not set 86 | */ 87 | public function path(string $pattern): self 88 | { 89 | if (!$this->asRoute) { 90 | throw new \InvalidArgumentException('Cannot use the "path()" method if route not defined.'); 91 | } 92 | 93 | if (1 === \preg_match(static::RCA_PATTERN, $pattern, $matches, \PREG_UNMATCHED_AS_NULL)) { 94 | isset($matches[1]) && $this->routes[$this->defaultIndex]['schemes'][$matches[1]] = true; 95 | 96 | if (isset($matches[2])) { 97 | if ('/' !== ($matches[3][0] ?? '')) { 98 | throw new UriHandlerException(\sprintf('The route pattern "%s" is invalid as route path must be present in pattern.', $pattern)); 99 | } 100 | $this->routes[$this->defaultIndex]['hosts'][$matches[2]] = true; 101 | } 102 | 103 | if (isset($matches[5])) { 104 | $handler = $matches[4] ?? $this->routes[$this->defaultIndex]['handler'] ?? null; 105 | $this->routes[$this->defaultIndex]['handler'] = !empty($handler) ? [$handler, $matches[5]] : $matches[5]; 106 | } 107 | 108 | \preg_match(static::PRIORITY_REGEX, $pattern = $matches[3], $m, \PREG_UNMATCHED_AS_NULL); 109 | $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null; 110 | } 111 | 112 | $this->routes[$this->defaultIndex]['path'] = $pattern; 113 | 114 | return $this; 115 | } 116 | 117 | /** 118 | * Set the route's unique name identifier,. 119 | * 120 | * @return $this 121 | * 122 | * @throws \InvalidArgumentException if you is not set 123 | */ 124 | public function bind(string $routeName): self 125 | { 126 | if (!$this->asRoute) { 127 | throw new \InvalidArgumentException('Cannot use the "bind()" method if route not defined.'); 128 | } 129 | $this->routes[$this->defaultIndex]['name'] = $routeName; 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * Set the route's handler. 136 | * 137 | * @param mixed $to PHP class, object or callable that returns the response when matched 138 | * 139 | * @return $this 140 | * 141 | * @throws \InvalidArgumentException if you is not set 142 | */ 143 | public function run(mixed $to): self 144 | { 145 | if (!$this->asRoute) { 146 | throw new \InvalidArgumentException('Cannot use the "run()" method if route not defined.'); 147 | } 148 | 149 | if (!empty($namespace = $this->routes[$this->defaultIndex]['namespace'] ?? null)) { 150 | unset($this->routes[$this->defaultIndex]['namespace']); 151 | } 152 | $this->routes[$this->defaultIndex]['handler'] = $this->resolveHandler($to, $namespace); 153 | 154 | return $this; 155 | } 156 | 157 | /** 158 | * Set the route(s) default value for it's placeholder or required argument. 159 | * 160 | * @return $this 161 | */ 162 | public function default(string $variable, mixed $default): self 163 | { 164 | if ($this->asRoute) { 165 | $this->routes[$this->defaultIndex]['defaults'][$variable] = $default; 166 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 167 | $this->prototypes['defaults'][$variable] = $default; 168 | } else { 169 | foreach ($this->routes as &$route) { 170 | $route['defaults'][$variable] = $default; 171 | } 172 | $this->resolveGroup(__FUNCTION__, [$variable, $default]); 173 | } 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * Set the routes(s) default value for it's placeholder or required argument. 180 | * 181 | * @param array $values 182 | * 183 | * @return $this 184 | */ 185 | public function defaults(array $values): self 186 | { 187 | foreach ($values as $variable => $default) { 188 | $this->default($variable, $default); 189 | } 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * Set the route(s) placeholder requirement. 196 | * 197 | * @param array|string $regexp The regexp to apply 198 | * 199 | * @return $this 200 | */ 201 | public function placeholder(string $variable, string|array $regexp): self 202 | { 203 | if ($this->asRoute) { 204 | $this->routes[$this->defaultIndex]['placeholders'][$variable] = $regexp; 205 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 206 | $this->prototypes['placeholders'][$variable] = $regexp; 207 | } else { 208 | foreach ($this->routes as &$route) { 209 | $route['placeholders'][$variable] = $regexp; 210 | } 211 | 212 | $this->resolveGroup(__FUNCTION__, [$variable, $regexp]); 213 | } 214 | 215 | return $this; 216 | } 217 | 218 | /** 219 | * Set the route(s) placeholder requirements. 220 | * 221 | * @param array|string> $placeholders The regexps to apply 222 | * 223 | * @return $this 224 | */ 225 | public function placeholders(array $placeholders): self 226 | { 227 | foreach ($placeholders as $placeholder => $value) { 228 | $this->placeholder($placeholder, $value); 229 | } 230 | 231 | return $this; 232 | } 233 | 234 | /** 235 | * Set the named parameter supplied to route(s) handler's constructor/factory. 236 | * 237 | * @return $this 238 | */ 239 | public function argument(string $parameter, mixed $value): self 240 | { 241 | $resolver = fn ($value) => \is_numeric($value) ? (int) $value : (\is_string($value) ? \rawurldecode($value) : $value); 242 | 243 | if ($this->asRoute) { 244 | $this->routes[$this->defaultIndex]['arguments'][$parameter] = $resolver($value); 245 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 246 | $this->prototypes['arguments'][$parameter] = $resolver($value); 247 | } else { 248 | foreach ($this->routes as &$route) { 249 | $route['arguments'][$parameter] = $resolver($value); 250 | } 251 | $this->resolveGroup(__FUNCTION__, [$parameter, $value]); 252 | } 253 | 254 | return $this; 255 | } 256 | 257 | /** 258 | * Set the named parameters supplied to route(s) handler's constructor/factory. 259 | * 260 | * @param array $parameters The route handler parameters 261 | * 262 | * @return $this 263 | */ 264 | public function arguments(array $parameters): self 265 | { 266 | foreach ($parameters as $parameter => $value) { 267 | $this->argument($parameter, $value); 268 | } 269 | 270 | return $this; 271 | } 272 | 273 | /** 274 | * Set the missing namespace for route(s) handler(s). 275 | * 276 | * @return $this 277 | * 278 | * @throws InvalidControllerException if namespace does not ends with a \ 279 | */ 280 | public function namespace(string $namespace): self 281 | { 282 | if ('\\' !== $namespace[-1]) { 283 | throw new InvalidControllerException(\sprintf('Cannot set a route\'s handler namespace "%s" without an ending "\\".', $namespace)); 284 | } 285 | 286 | if ($this->asRoute) { 287 | $handler = &$this->routes[$this->defaultIndex]['handler'] ?? null; 288 | 289 | if (!empty($handler)) { 290 | $handler = $this->resolveHandler($handler, $namespace); 291 | } else { 292 | $this->routes[$this->defaultIndex][__FUNCTION__] = $namespace; 293 | } 294 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 295 | $this->prototypes[__FUNCTION__][] = $namespace; 296 | } else { 297 | foreach ($this->routes as &$route) { 298 | $route['handler'] = $this->resolveHandler($route['handler'] ?? null, $namespace); 299 | } 300 | $this->resolveGroup(__FUNCTION__, [$namespace]); 301 | } 302 | 303 | return $this; 304 | } 305 | 306 | /** 307 | * Set the route(s) HTTP request method(s). 308 | * 309 | * @return $this 310 | */ 311 | public function method(string ...$methods): self 312 | { 313 | if ($this->asRoute) { 314 | foreach ($methods as $method) { 315 | $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true; 316 | } 317 | 318 | return $this; 319 | } 320 | 321 | $routeMethods = \array_fill_keys(\array_map('strtoupper', $methods), true); 322 | 323 | if (-1 === $this->defaultIndex && empty($this->groups)) { 324 | $this->prototypes['methods'] = \array_merge($this->prototypes['methods'] ?? [], $routeMethods); 325 | } else { 326 | foreach ($this->routes as &$route) { 327 | $route['methods'] += $routeMethods; 328 | } 329 | $this->resolveGroup(__FUNCTION__, $methods); 330 | } 331 | 332 | return $this; 333 | } 334 | 335 | /** 336 | * Set route(s) HTTP host scheme(s). 337 | * 338 | * @return $this 339 | */ 340 | public function scheme(string ...$schemes): self 341 | { 342 | if ($this->asRoute) { 343 | foreach ($schemes as $scheme) { 344 | $this->routes[$this->defaultIndex]['schemes'][$scheme] = true; 345 | } 346 | 347 | return $this; 348 | } 349 | $routeSchemes = \array_fill_keys($schemes, true); 350 | 351 | if (-1 === $this->defaultIndex && empty($this->groups)) { 352 | $this->prototypes['schemes'] = \array_merge($this->prototypes['schemes'] ?? [], $routeSchemes); 353 | } else { 354 | foreach ($this->routes as &$route) { 355 | $route['schemes'] = \array_merge($route['schemes'] ?? [], $routeSchemes); 356 | } 357 | $this->resolveGroup(__FUNCTION__, $schemes); 358 | } 359 | 360 | return $this; 361 | } 362 | 363 | /** 364 | * Set the route(s) HTTP host name(s). 365 | * 366 | * @return $this 367 | */ 368 | public function domain(string ...$domains): self 369 | { 370 | $resolver = static function (array &$route, array $domains): void { 371 | foreach ($domains as $domain) { 372 | if (1 === \preg_match('/^(?:([a-z]+)\:\/{2})?([^\/]+)?$/u', $domain, $m, \PREG_UNMATCHED_AS_NULL)) { 373 | if (isset($m[1])) { 374 | $route['schemes'][$m[1]] = true; 375 | } 376 | 377 | if (isset($m[2])) { 378 | $route['hosts'][$m[2]] = true; 379 | } 380 | } 381 | } 382 | }; 383 | 384 | if ($this->asRoute) { 385 | $resolver($this->routes[$this->defaultIndex], $domains); 386 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 387 | $this->prototypes[__FUNCTION__] = \array_merge($this->prototypes[__FUNCTION__] ?? [], $domains); 388 | } else { 389 | foreach ($this->routes as &$route) { 390 | $resolver($route, $domains); 391 | } 392 | $this->resolveGroup(__FUNCTION__, $domains); 393 | } 394 | 395 | return $this; 396 | } 397 | 398 | /** 399 | * Set prefix path which should be prepended to route(s) path. 400 | * 401 | * @return $this 402 | */ 403 | public function prefix(string $path): self 404 | { 405 | $resolver = fn (string $a, string $b): string => $a.(($a[-1] ?? '') === $b[0] ? \substr($b, 1) : $b); 406 | 407 | if ($this->asRoute) { 408 | \preg_match( 409 | static::PRIORITY_REGEX, 410 | $this->routes[$this->defaultIndex]['path'] = $resolver( 411 | $path, 412 | $this->routes[$this->defaultIndex]['path'] ?? '', 413 | ), 414 | $m, 415 | \PREG_UNMATCHED_AS_NULL 416 | ); 417 | $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null; 418 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 419 | $this->prototypes[__FUNCTION__][] = $path; 420 | } else { 421 | foreach ($this->routes as &$route) { 422 | \preg_match(static::PRIORITY_REGEX, $route['path'] = $resolver($path, $route['path']), $m); 423 | $route['prefix'] = $m[1] ?? null; 424 | } 425 | 426 | $this->resolveGroup(__FUNCTION__, [$path]); 427 | } 428 | 429 | return $this; 430 | } 431 | 432 | /** 433 | * Set a set of named grouped middleware(s) to route(s). 434 | * 435 | * @return $this 436 | */ 437 | public function piped(string ...$to): self 438 | { 439 | if ($this->asRoute) { 440 | foreach ($to as $middleware) { 441 | $this->routes[$this->defaultIndex]['middlewares'][$middleware] = true; 442 | } 443 | 444 | return $this; 445 | } 446 | $routeMiddlewares = \array_fill_keys($to, true); 447 | 448 | if (-1 === $this->defaultIndex && empty($this->groups)) { 449 | $this->prototypes['middlewares'] = \array_merge($this->prototypes['middlewares'] ?? [], $routeMiddlewares); 450 | } else { 451 | foreach ($this->routes as &$route) { 452 | $route['middlewares'] = \array_merge($route['middlewares'] ?? [], $routeMiddlewares); 453 | } 454 | $this->resolveGroup(__FUNCTION__, $to); 455 | } 456 | 457 | return $this; 458 | } 459 | 460 | /** 461 | * Set a custom key and value to route(s). 462 | * 463 | * @return $this 464 | */ 465 | public function set(string $key, mixed $value): self 466 | { 467 | if (\in_array($key, [ 468 | 'name', 469 | 'handler', 470 | 'arguments', 471 | 'namespace', 472 | 'middlewares', 473 | 'methods', 474 | 'placeholders', 475 | 'prefix', 476 | 'hosts', 477 | 'schemes', 478 | 'defaults', 479 | ], true)) { 480 | throw new \InvalidArgumentException(\sprintf('Cannot replace the default "%s" route binding.', $key)); 481 | } 482 | 483 | if ($this->asRoute) { 484 | $this->routes[$this->defaultIndex][$key] = $value; 485 | } elseif (-1 === $this->defaultIndex && empty($this->groups)) { 486 | $this->prototypes[$key] = !\is_array($value) ? $value : \array_merge($this->prototypes[$key] ?? [], $value); 487 | } else { 488 | foreach ($this->routes as &$route) { 489 | $route[$key] = \is_array($value) ? \array_merge($route[$key] ?? [], $value) : $value; 490 | } 491 | $this->resolveGroup(__FUNCTION__, [$key, $value]); 492 | } 493 | 494 | return $this; 495 | } 496 | 497 | protected function resolveHandler(mixed $handler, string $namespace = null): mixed 498 | { 499 | if (empty($namespace)) { 500 | return $handler; 501 | } 502 | 503 | if (\is_string($handler)) { 504 | if ('\\' === $handler[0] || \str_starts_with($handler, $namespace)) { 505 | return $handler; 506 | } 507 | $handler = $namespace.$handler; 508 | } elseif (\is_array($handler)) { 509 | if (2 !== \count($handler, \COUNT_RECURSIVE)) { 510 | throw new InvalidControllerException('Cannot use a non callable like array as route handler.'); 511 | } 512 | 513 | if (\is_string($handler[0]) && !\str_starts_with($handler[0], $namespace)) { 514 | $handler[0] = $this->resolveHandler($handler[0], $namespace); 515 | } 516 | } elseif ($handler instanceof ResourceHandler) { 517 | $handler = $handler->namespace($namespace); 518 | } 519 | 520 | return $handler; 521 | } 522 | 523 | /** 524 | * @param array $arguments 525 | */ 526 | protected function resolveGroup(string $method, array $arguments): void 527 | { 528 | foreach ($this->groups as $group) { 529 | $asRoute = $group->asRoute; 530 | $group->asRoute = false; 531 | $group->{$method}(...$arguments); 532 | $group->asRoute = $asRoute; 533 | } 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /src/Traits/ResolverTrait.php: -------------------------------------------------------------------------------- 1 | 9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) 10 | * @license https://opensource.org/licenses/BSD-3-Clause License 11 | * 12 | * For the full copyright and license information, please view the LICENSE 13 | * file that was distributed with this source code. 14 | */ 15 | 16 | namespace Flight\Routing\Traits; 17 | 18 | use Flight\Routing\Exceptions\{MethodNotAllowedException, UriHandlerException}; 19 | use Psr\Http\Message\UriInterface; 20 | 21 | /** 22 | * The default implementation for route match. 23 | * 24 | * @author Divine Niiquaye Ibok 25 | */ 26 | trait ResolverTrait 27 | { 28 | /** @var array */ 29 | private array $optimized = []; 30 | 31 | /** 32 | * @param array $route 33 | * @param array> $errors 34 | */ 35 | protected function assertRoute(string $method, UriInterface $uri, array &$route, array &$errors): bool 36 | { 37 | $matched = true; 38 | 39 | if (!\array_key_exists($method, $route['methods'] ?? [])) { 40 | $errors[0] += $route['methods'] ?? []; 41 | $matched = false; 42 | } elseif (isset($route['hosts']) && !\array_key_exists($errors[2] ??= \rtrim($uri->getHost().':'.$uri->getPort(), ':'), $route['hosts'])) { 43 | $hosts = \array_keys($route['hosts'], true, true); 44 | [$hostsRegex, $hostVar] = $this->compiler->compile(\implode('|', $hosts), $route['placeholders'] ?? []); 45 | 46 | if ($matched = 1 === \preg_match($hostsRegex.'i', $errors[2], $matches, \PREG_UNMATCHED_AS_NULL)) { 47 | foreach ($hostVar as $key => $value) { 48 | $route['arguments'][$key] = $matches[$key] ?: $route['defaults'][$key] ?? $value; 49 | } 50 | } 51 | } elseif (isset($route['schemes']) && !\array_key_exists($uri->getScheme(), $route['schemes'])) { 52 | $errors[1] += $route['schemes'] ?? []; 53 | $matched = false; 54 | } 55 | 56 | return $matched; 57 | } 58 | 59 | /** 60 | * @return null|array 61 | */ 62 | protected function resolveRoute(string $path, string $method, UriInterface $uri): ?array 63 | { 64 | $errors = [[], []]; 65 | 66 | foreach ($this->getCollection()->getRoutes() as $i => $r) { 67 | if (isset($r['prefix']) && !\str_starts_with($path, '/'.\ltrim($r['prefix'], '/'))) { 68 | continue; 69 | } 70 | [$p, $v] = $this->optimized[$i] ??= $this->compiler->compile('/'.\ltrim($r['path'], '/'), $r['placeholders'] ?? []); 71 | 72 | if (!\preg_match($p, $path, $m, \PREG_UNMATCHED_AS_NULL) || !$this->assertRoute($method, $uri, $r, $errors)) { 73 | continue; 74 | } 75 | 76 | foreach ($v as $key => $value) { 77 | $r['arguments'][$key] = $m[$key] ?? $r['defaults'][$key] ?? $value; 78 | } 79 | 80 | return $r; 81 | } 82 | 83 | return $this->resolveError($errors, $method, $uri); 84 | } 85 | 86 | /** 87 | * @return null|array 88 | */ 89 | protected function resolveCache(string $path, string $method, UriInterface $uri): ?array 90 | { 91 | $errors = [[], []]; 92 | $routes = $this->optimized[2] ?? $this->doCache(); 93 | 94 | if (!$matched = $this->optimized[0][$path] ?? $this->optimized[1][0]($path)) { 95 | return null; 96 | } 97 | 98 | foreach ($matched as $match) { 99 | $r = $routes[$o = \intval($match['MARK'] ?? $match)] ?? $routes->getRoutes()[$o]; 100 | 101 | if (!$this->assertRoute($method, $uri, $r, $errors)) { 102 | continue; 103 | } 104 | 105 | if (isset($match['MARK'])) { 106 | $i = 0; 107 | 108 | foreach ($this->optimized[1][1][$o] ?? [] as $key => $value) { 109 | $r['arguments'][$key] = $match[++$i] ?: $r['defaults'][$key] ?? $value; 110 | } 111 | } 112 | 113 | return $r; 114 | } 115 | 116 | return $this->resolveError($errors, $method, $uri); 117 | } 118 | 119 | /** 120 | * @param array> $errors 121 | */ 122 | protected function resolveError(array $errors, string $method, UriInterface $uri) 123 | { 124 | if (!empty($errors[0])) { 125 | throw new MethodNotAllowedException(\array_keys($errors[0]), $uri->getPath(), $method); 126 | } 127 | 128 | if (!empty($errors[1])) { 129 | throw new UriHandlerException( 130 | \sprintf( 131 | 'Route with "%s" path requires request scheme(s) [%s], "%s" is invalid.', 132 | $uri->getPath(), 133 | \implode(', ', \array_keys($errors[1])), 134 | $uri->getScheme(), 135 | ), 136 | 400 137 | ); 138 | } 139 | 140 | return null; 141 | } 142 | } 143 | --------------------------------------------------------------------------------