├── 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 | [](http://php.net)
6 | [](https://packagist.org/packages/divineniiquaye/flight-routing)
7 | [](https://github.com/divineniiquaye/flight-routing/actions?query=workflow%3Abuild)
8 | [](https://codeclimate.com/github/divineniiquaye/flight-routing)
9 | [](https://codecov.io/gh/divineniiquaye/flight-routing)
10 | [](https://shepherd.dev/github/divineniiquaye/flight-routing)
11 | [](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 |
--------------------------------------------------------------------------------