├── .phpstorm.meta.php ├── LICENSE ├── README.md ├── composer.json └── src ├── Attributes ├── Origin.php ├── Route.php └── RouteNotFound.php ├── Debug ├── RoutingCollection.php ├── RoutingCollector.php └── icons │ └── routing.svg ├── Languages ├── en │ └── routing.php ├── es │ └── routing.php └── pt-br │ └── routing.php ├── PresenterInterface.php ├── Reflector.php ├── ResourceInterface.php ├── Route.php ├── RouteActions.php ├── RouteCollection.php ├── Router.php └── RoutingException.php /.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace PHPSTORM_META; 11 | 12 | registerArgumentsSet( 13 | 'redirect_codes', 14 | \Framework\HTTP\Status::FOUND, 15 | \Framework\HTTP\Status::MOVED_PERMANENTLY, 16 | \Framework\HTTP\Status::MULTIPLE_CHOICES, 17 | \Framework\HTTP\Status::NOT_MODIFIED, 18 | \Framework\HTTP\Status::PERMANENT_REDIRECT, 19 | \Framework\HTTP\Status::SEE_OTHER, 20 | \Framework\HTTP\Status::SWITCH_PROXY, 21 | \Framework\HTTP\Status::TEMPORARY_REDIRECT, 22 | \Framework\HTTP\Status::USE_PROXY, 23 | ); 24 | registerArgumentsSet( 25 | 'methods', 26 | 'DELETE', 27 | 'GET', 28 | 'OPTIONS', 29 | 'PATCH', 30 | 'POST', 31 | 'PUT', 32 | ); 33 | registerArgumentsSet( 34 | 'placeholders', 35 | '{alpha}', 36 | '{alphanum}', 37 | '{any}', 38 | '{hex}', 39 | '{int}', 40 | '{md5}', 41 | '{num}', 42 | '{port}', 43 | '{scheme}', 44 | '{segment}', 45 | '{slug}', 46 | '{subdomain}', 47 | '{title}', 48 | '{uuid}', 49 | ); 50 | expectedArguments( 51 | \Framework\Routing\RouteCollection::redirect(), 52 | 2, 53 | argumentsSet('redirect_codes') 54 | ); 55 | expectedArguments( 56 | \Framework\Routing\RouteCollection::resource(), 57 | 4, 58 | argumentsSet('placeholders') 59 | ); 60 | expectedArguments( 61 | \Framework\Routing\RouteCollection::presenter(), 62 | 4, 63 | argumentsSet('placeholders') 64 | ); 65 | expectedArguments( 66 | \Framework\Routing\Attributes\Route::__construct(), 67 | 0, 68 | argumentsSet('methods') 69 | ); 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Natan Felles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Aplus Framework Routing Library 2 | 3 | # Aplus Framework Routing Library 4 | 5 | - [Home](https://aplus-framework.com/packages/routing) 6 | - [User Guide](https://docs.aplus-framework.com/guides/libraries/routing/index.html) 7 | - [API Documentation](https://docs.aplus-framework.com/packages/routing.html) 8 | 9 | [![tests](https://github.com/aplus-framework/routing/actions/workflows/tests.yml/badge.svg)](https://github.com/aplus-framework/routing/actions/workflows/tests.yml) 10 | [![coverage](https://coveralls.io/repos/github/aplus-framework/routing/badge.svg?branch=master)](https://coveralls.io/github/aplus-framework/routing?branch=master) 11 | [![packagist](https://img.shields.io/packagist/v/aplus/routing)](https://packagist.org/packages/aplus/routing) 12 | [![open-source](https://img.shields.io/badge/open--source-sponsor-magenta)](https://aplus-framework.com/sponsor) 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aplus/routing", 3 | "description": "Aplus Framework Routing Library", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "routing", 8 | "router", 9 | "resource", 10 | "presenter", 11 | "rest", 12 | "restful" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Natan Felles", 17 | "email": "natanfelles@gmail.com", 18 | "homepage": "https://natanfelles.github.io" 19 | } 20 | ], 21 | "homepage": "https://aplus-framework.com/packages/routing", 22 | "support": { 23 | "email": "support@aplus-framework.com", 24 | "issues": "https://github.com/aplus-framework/routing/issues", 25 | "forum": "https://aplus-framework.com/forum", 26 | "source": "https://github.com/aplus-framework/routing", 27 | "docs": "https://docs.aplus-framework.com/guides/libraries/routing/" 28 | }, 29 | "funding": [ 30 | { 31 | "type": "Aplus Sponsor", 32 | "url": "https://aplus-framework.com/sponsor" 33 | } 34 | ], 35 | "require": { 36 | "php": ">=8.3", 37 | "aplus/debug": "^4.3", 38 | "aplus/http": "^6.0", 39 | "aplus/language": "^4.0" 40 | }, 41 | "require-dev": { 42 | "ext-xdebug": "*", 43 | "aplus/coding-standard": "^2.8", 44 | "ergebnis/composer-normalize": "^2.25", 45 | "jetbrains/phpstorm-attributes": "^1.0", 46 | "phpmd/phpmd": "^2.13", 47 | "phpstan/phpstan": "^1.9", 48 | "phpunit/phpunit": "^10.5" 49 | }, 50 | "minimum-stability": "dev", 51 | "prefer-stable": true, 52 | "autoload": { 53 | "psr-4": { 54 | "Framework\\Routing\\": "src/" 55 | } 56 | }, 57 | "autoload-dev": { 58 | "psr-4": { 59 | "Tests\\Routing\\": "tests/" 60 | } 61 | }, 62 | "config": { 63 | "allow-plugins": { 64 | "ergebnis/composer-normalize": true 65 | }, 66 | "optimize-autoloader": true, 67 | "preferred-install": "dist", 68 | "sort-packages": true 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Attributes/Origin.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing\Attributes; 11 | 12 | use Attribute; 13 | 14 | /** 15 | * Class Origin. 16 | * 17 | * @package routing 18 | */ 19 | #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] 20 | class Origin 21 | { 22 | protected string $origin; 23 | 24 | /** 25 | * Origin constructor. 26 | * 27 | * @param string $origin The Route origin 28 | */ 29 | public function __construct(string $origin) 30 | { 31 | $this->origin = $origin; 32 | } 33 | 34 | public function getOrigin() : string 35 | { 36 | return $this->origin; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Attributes/Route.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing\Attributes; 11 | 12 | use Attribute; 13 | 14 | /** 15 | * Class Route. 16 | * 17 | * @package routing 18 | */ 19 | #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] 20 | class Route 21 | { 22 | /** 23 | * @var array 24 | */ 25 | protected array $methods; 26 | protected string $path; 27 | protected string $arguments; 28 | protected ?string $name; 29 | /** 30 | * @var array 31 | */ 32 | protected array $origins; 33 | 34 | /** 35 | * Route constructor. 36 | * 37 | * @param array|string $methods The Route HTTP Methods 38 | * @param string $path The Route path 39 | * @param string $arguments The Route action arguments 40 | * @param string|null $name The Route name 41 | * @param array|string $origins The Route origins 42 | */ 43 | public function __construct( 44 | array | string $methods, 45 | string $path, 46 | string $arguments = '*', 47 | ?string $name = null, 48 | array | string $origins = [], 49 | ) { 50 | $methods = (array) $methods; 51 | foreach ($methods as &$method) { 52 | $method = \strtoupper($method); 53 | } 54 | unset($method); 55 | $this->methods = $methods; 56 | $this->path = $path; 57 | $this->arguments = $arguments; 58 | $this->name = $name; 59 | $this->origins = (array) $origins; 60 | } 61 | 62 | /** 63 | * @return array 64 | */ 65 | public function getMethods() : array 66 | { 67 | return $this->methods; 68 | } 69 | 70 | /** 71 | * @return string 72 | */ 73 | public function getPath() : string 74 | { 75 | return $this->path; 76 | } 77 | 78 | /** 79 | * @return string 80 | */ 81 | public function getArguments() : string 82 | { 83 | return $this->arguments; 84 | } 85 | 86 | /** 87 | * @return string|null 88 | */ 89 | public function getName() : ?string 90 | { 91 | return $this->name; 92 | } 93 | 94 | /** 95 | * @return array 96 | */ 97 | public function getOrigins() : array 98 | { 99 | return $this->origins; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Attributes/RouteNotFound.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing\Attributes; 11 | 12 | use Attribute; 13 | 14 | /** 15 | * Class RouteNotFound. 16 | * 17 | * @package routing 18 | */ 19 | #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] 20 | class RouteNotFound 21 | { 22 | /** 23 | * @var array 24 | */ 25 | protected array $origins; 26 | 27 | /** 28 | * RouteNotFound constructor. 29 | * 30 | * @param array|string $origins The Route Not Found origins 31 | */ 32 | public function __construct(array | string $origins = []) 33 | { 34 | $this->origins = (array) $origins; 35 | } 36 | 37 | /** 38 | * @return array 39 | */ 40 | public function getOrigins() : array 41 | { 42 | return $this->origins; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Debug/RoutingCollection.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing\Debug; 11 | 12 | use Framework\Debug\Collection; 13 | 14 | /** 15 | * Class RoutingCollection. 16 | * 17 | * @package routing 18 | */ 19 | class RoutingCollection extends Collection 20 | { 21 | protected string $iconPath = __DIR__ . '/icons/routing.svg'; 22 | } 23 | -------------------------------------------------------------------------------- /src/Debug/RoutingCollector.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing\Debug; 11 | 12 | use Closure; 13 | use Framework\Debug\Collector; 14 | use Framework\Debug\Debugger; 15 | use Framework\Routing\RouteCollection; 16 | use Framework\Routing\Router; 17 | 18 | /** 19 | * Class RoutingCollector. 20 | * 21 | * @package routing 22 | */ 23 | class RoutingCollector extends Collector 24 | { 25 | protected Router $router; 26 | 27 | public function setRouter(Router $router) : static 28 | { 29 | $this->router = $router; 30 | return $this; 31 | } 32 | 33 | public function getActivities() : array 34 | { 35 | $activities = []; 36 | $serveCount = 0; 37 | foreach ($this->getData() as $data) { 38 | if ($data['type'] === 'serve') { 39 | $serveCount++; 40 | $activities[] = [ 41 | 'collector' => $this->getName(), 42 | 'class' => static::class, 43 | 'description' => 'Serve route collection ' . $serveCount, 44 | 'start' => $data['start'], 45 | 'end' => $data['end'], 46 | ]; 47 | } elseif ($data['type'] === 'match') { 48 | $activities[] = [ 49 | 'collector' => $this->getName(), 50 | 'class' => static::class, 51 | 'description' => 'Match route', 52 | 'start' => $data['start'], 53 | 'end' => $data['end'], 54 | ]; 55 | } elseif ($data['type'] === 'run') { 56 | $activities[] = [ 57 | 'collector' => $this->getName(), 58 | 'class' => static::class, 59 | 'description' => 'Run matched route', 60 | 'start' => $data['start'], 61 | 'end' => $data['end'], 62 | ]; 63 | } 64 | } 65 | return $activities; 66 | } 67 | 68 | public function getContents() : string 69 | { 70 | if (!isset($this->router)) { 71 | return '

A Router instance has not been set on this collector.

'; 72 | } 73 | \ob_start(); ?> 74 |

Matched Route

75 | renderMatchedRoute() ?> 76 |

Route Collections

77 | renderRouteCollections() ?> 78 |

Router Infos

79 |

Auto Methods: router->isAutoMethods() ? 'On' : 'Off' ?>

80 |

Auto Options: router->isAutoOptions() ? 'On' : 'Off' ?>

81 |

82 | Default Route Action Method: router->getDefaultRouteActionMethod()) ?> 83 |

84 | router->defaultRouteNotFound; // @phpstan-ignore-line 86 | if ($notFound): ?> 87 |

Default Route Not Found:

90 | 92 |

Placeholders

93 | router->getPlaceholders() as $placeholder => $pattern) { 96 | $placeholders[\trim($placeholder, '{}')] = $pattern; 97 | } 98 | \ksort($placeholders); ?> 99 |

Total of placeholders.

100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | $pattern): ?> 109 | 110 | 111 | 114 | 115 | 116 | 117 |
PlaceholderPattern
{} 112 |
113 |
118 | router->getMatchedRoute(); 125 | if ($route === null) { 126 | return '

No matching route on this Router instance.

'; 127 | } 128 | \ob_start(); ?> 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 151 | 152 | 153 | 154 | 155 | 158 | 159 | 160 | 166 | 172 | 173 | 174 |
RCMethodOriginPathActionNameHas OptionsTime to MatchRuntime
router->getCollections() as $index => $collection) { 147 | if ($collection === $this->router->getMatchedCollection()) { 148 | echo $index + 1; 149 | } 150 | } ?>router->getResponse()->getRequest()->getMethod() ?>router->getMatchedOrigin()) ?>router->getMatchedPath()) ?>getAction() instanceof Closure 156 | ? 'Closure' 157 | : \htmlentities($route->getAction()) ?>getName()) ?>getOptions() ? 'Yes' : 'No' ?>getData() as $data) { 162 | if ($data['type'] === 'match') { 163 | echo Debugger::roundSecondsToMilliseconds($data['end'] - $data['start']); 164 | } 165 | } ?>getData() as $data) { 168 | if ($data['type'] === 'run') { 169 | echo Debugger::roundSecondsToMilliseconds($data['end'] - $data['start']); 170 | } 171 | } ?>
175 | router->getCollections()); 182 | if ($countCollections === 0) { 183 | return '

No route collection has been set.

'; 184 | } 185 | $plural = $countCollections > 1; 186 | \ob_start(); ?> 187 |

There route collection set. 189 |

190 | router->getCollections() as $index => $collection): ?> 192 |

Route Collection

193 |

Origin: toCodeBrackets($collection->origin) ?>

194 | name !== null): ?> 196 |

Name: name ?>

197 | notFoundAction ?? null; 200 | if ($notFound !== null): 201 | ?> 202 |

Route Not Found:

205 | renderRouteCollectionsTable($collection); 208 | endforeach; 209 | return \ob_get_clean(); // @phpstan-ignore-line 210 | } 211 | 212 | protected function renderRouteCollectionTime(RouteCollection $collection) : string 213 | { 214 | $contents = ''; 215 | foreach ($this->getData() as $data) { 216 | if ($data['type'] === 'serve' && $data['collectionId'] === \spl_object_id($collection)) { 217 | $contents = '

Time to Serve: ' 218 | . Debugger::roundSecondsToMilliseconds($data['end'] - $data['start']) 219 | . ' ms

'; 220 | break; 221 | } 222 | } 223 | return $contents; 224 | } 225 | 226 | protected function renderRouteCollectionsTable(RouteCollection $collection) : string 227 | { 228 | $routesCount = \count($collection); 229 | \ob_start(); 230 | echo '

Routes Count: ' . $routesCount . '

'; 231 | echo $this->renderRouteCollectionTime($collection); 232 | if ($routesCount === 0) { 233 | echo '

No route has been set in this collection.

'; 234 | return \ob_get_clean(); // @phpstan-ignore-line 235 | } 236 | // @phpstan-ignore-next-line 237 | if ($routesCount === 1 && $collection->router->getMatchedOrigin() && $collection->getRouteNotFound()) { 238 | echo '

Only Route Not Found has been set in this collection.

'; 239 | return \ob_get_clean(); // @phpstan-ignore-line 240 | } ?> 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | getRoutes($collection) as $index => $route): ?> 254 | > 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 |
#MethodPathActionNameHas Options
toCodeBrackets(\htmlentities($route['path'])) ?>
265 | > 273 | */ 274 | protected function getRoutes(RouteCollection $collection) : array 275 | { 276 | $result = []; 277 | $collectionRoutes = $collection->routes; 278 | \ksort($collectionRoutes); 279 | foreach ($collectionRoutes as $method => $routes) { 280 | foreach ($routes as $route) { 281 | $result[] = [ 282 | 'method' => $method, 283 | 'path' => $route->getPath(), 284 | 'action' => \is_string($route->getAction()) ? $route->getAction() : 'Closure', 285 | 'name' => $route->getName(), 286 | 'hasOptions' => $route->getOptions() ? 'Yes' : 'No', 287 | 'matched' => $route === $this->router->getMatchedRoute(), 288 | ]; 289 | } 290 | } 291 | return $result; 292 | } 293 | 294 | protected function toCodeBrackets(string $str) : string 295 | { 296 | return \strtr($str, [ 297 | '{' => '{', 298 | '}' => '}', 299 | ]); 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/Debug/icons/routing.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/Languages/en/routing.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | return [ 11 | 'error404' => 'Error 404', 12 | 'pageNotFound' => 'Page not found', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/Languages/es/routing.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | return [ 11 | 'error404' => 'Error 404', 12 | 'pageNotFound' => 'Página no encontrada', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/Languages/pt-br/routing.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | return [ 11 | 'error404' => 'Erro 404', 12 | 'pageNotFound' => 'Página não encontrada', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/PresenterInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use Framework\HTTP\Method; 13 | use Framework\HTTP\Response; 14 | use Framework\HTTP\Status; 15 | 16 | /** 17 | * Interface PresenterInterface. 18 | * 19 | * The interface for data management via a Web Browser UI 20 | * using the HTTP GET and POST methods. 21 | * 22 | * Note: If a presenter needs more than one parameter to get URL path information 23 | * provided by placeholders, in addition to $id, do not implement this interface. 24 | * But this interface can be a reference because its method names are used in 25 | * {@see RouteCollection::presenter()}. 26 | * 27 | * @see https://developer.mozilla.org/en-US/docs/Glossary/UI 28 | * 29 | * @package routing 30 | */ 31 | interface PresenterInterface 32 | { 33 | /** 34 | * Handles a GET request for /. 35 | * 36 | * Common usage: Show a list of paginated items. 37 | * 38 | * @see Method::GET 39 | * 40 | * @return mixed 41 | */ 42 | public function index() : mixed; 43 | 44 | /** 45 | * Handles a GET request for /new. 46 | * 47 | * Common usage: Show a form with inputs to create a new item. 48 | * The POST action must go to the 'create' method URL. 49 | * 50 | * @see PresenterInterface::create() 51 | * @see Method::GET 52 | * 53 | * @return mixed 54 | */ 55 | public function new() : mixed; 56 | 57 | /** 58 | * Handles a POST request for /. 59 | * 60 | * Common usage: Try to create a new item. On success, redirect to the 'show' or 61 | * 'edit' method URL. On fail, back to the 'new' method URL. 62 | * 63 | * @see PresenterInterface::edit() 64 | * @see PresenterInterface::new() 65 | * @see PresenterInterface::show() 66 | * @see Method::POST 67 | * @see Response::redirect() 68 | * 69 | * @return mixed 70 | */ 71 | public function create() : mixed; 72 | 73 | /** 74 | * Handles a GET request for /$id. 75 | * 76 | * Common usage: Show a specific item based on the $id. 77 | * 78 | * @param string $id 79 | * 80 | * @see Method::GET 81 | * @see Status::NOT_FOUND 82 | * 83 | * @return mixed 84 | */ 85 | public function show(string $id) : mixed; 86 | 87 | /** 88 | * Handles a GET request for /$id/edit. 89 | * 90 | * Common usage: Show a form to edit a specific item based on the $id. 91 | * The POST action must go to the 'update' method URL. 92 | * 93 | * @param string $id 94 | * 95 | * @see PresenterInterface::update() 96 | * @see Method::GET 97 | * 98 | * @return mixed 99 | */ 100 | public function edit(string $id) : mixed; 101 | 102 | /** 103 | * Handles a POST request for /$id/update. 104 | * 105 | * Common usage: Try to update an item based on the $id. After the process, back 106 | * to the 'edit' method URL and show a message. 107 | * 108 | * @param string $id 109 | * 110 | * @see PresenterInterface::edit() 111 | * @see Method::POST 112 | * @see Response::redirect() 113 | * 114 | * @return mixed 115 | */ 116 | public function update(string $id) : mixed; 117 | 118 | /** 119 | * Handles a GET request for /$id/remove. 120 | * 121 | * Common usage: Show an alert message about the item to be deleted based on the 122 | * $id. The confirmation action must call a POST request to the 'delete' 123 | * method URL. 124 | * 125 | * @param string $id 126 | * 127 | * @see PresenterInterface::delete() 128 | * @see Method::GET 129 | * 130 | * @return mixed 131 | */ 132 | public function remove(string $id) : mixed; 133 | 134 | /** 135 | * Handles a POST request for /$id/delete. 136 | * 137 | * Common usage: Try to delete an item based on the $id. On success, go to the 138 | * 'index' method URL and show a success message. On fail, back to the 'remove' 139 | * method URL and show the error message. 140 | * 141 | * @param string $id 142 | * 143 | * @see PresenterInterface::index() 144 | * @see PresenterInterface::remove() 145 | * @see Method::POST 146 | * @see Response::redirect() 147 | * 148 | * @return mixed 149 | */ 150 | public function delete(string $id) : mixed; 151 | } 152 | -------------------------------------------------------------------------------- /src/Reflector.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use Framework\Routing\Attributes\Origin; 13 | use Framework\Routing\Attributes\Route; 14 | use Framework\Routing\Attributes\RouteNotFound; 15 | use ReflectionClass; 16 | use ReflectionException; 17 | 18 | /** 19 | * Class Reflector. 20 | * 21 | * @package routing 22 | */ 23 | class Reflector 24 | { 25 | /** 26 | * @var ReflectionClass 27 | */ 28 | protected ReflectionClass $reflection; 29 | 30 | /** 31 | * @template T of object 32 | * 33 | * @param class-string|object $routeActions 34 | * 35 | * @throws ReflectionException 36 | */ 37 | public function __construct(object | string $routeActions) 38 | { 39 | $this->reflection = new ReflectionClass($routeActions); // @phpstan-ignore-line 40 | } 41 | 42 | /** 43 | * @param string $method 44 | * 45 | * @throws ReflectionException 46 | * 47 | * @return array 48 | */ 49 | protected function getMethodRoutes(string $method) : array 50 | { 51 | $reflectionMethod = $this->reflection->getMethod($method); 52 | $routes = []; 53 | foreach ($reflectionMethod->getAttributes() as $attribute) { 54 | if ($attribute->getName() === Route::class) { 55 | $routes[] = $attribute->newInstance(); 56 | } 57 | } 58 | return $routes; // @phpstan-ignore-line 59 | } 60 | 61 | /** 62 | * @template T of object 63 | * 64 | * @param ReflectionClass $reflection 65 | * 66 | * @return array 67 | */ 68 | protected function getObjectOrigins(ReflectionClass $reflection) : array 69 | { 70 | $origins = []; 71 | foreach ($reflection->getAttributes() as $attribute) { 72 | if ($attribute->getName() === Origin::class) { 73 | /** 74 | * @var Origin $origin 75 | */ 76 | $origin = $attribute->newInstance(); 77 | $origins[] = $origin->getOrigin(); 78 | } 79 | } 80 | $parent = $reflection->getParentClass(); 81 | if ($parent) { 82 | $origins = [...$origins, ...$this->getObjectOrigins($parent)]; 83 | } 84 | $origins = \array_unique($origins); 85 | \sort($origins); 86 | return $origins; 87 | } 88 | 89 | /** 90 | * @throws ReflectionException 91 | * 92 | * @return array 93 | */ 94 | public function getRoutes() : array 95 | { 96 | $origins = $this->getObjectOrigins($this->reflection); 97 | $result = []; 98 | foreach ($this->reflection->getMethods() as $method) { 99 | if (!$method->isPublic()) { 100 | continue; 101 | } 102 | $routes = $this->getMethodRoutes($method->getName()); 103 | if (empty($routes)) { 104 | continue; 105 | } 106 | foreach ($routes as $route) { 107 | $result[] = [ 108 | 'origins' => $route->getOrigins() ?: $origins, 109 | 'methods' => $route->getMethods(), 110 | 'path' => $route->getPath(), 111 | 'arguments' => $route->getArguments(), 112 | 'name' => $route->getName(), 113 | 'action' => $this->reflection->name . '::' . $method->name, 114 | ]; 115 | } 116 | } 117 | return $result; 118 | } 119 | 120 | /** 121 | * @param string $method 122 | * 123 | * @throws ReflectionException 124 | * 125 | * @return array 126 | */ 127 | protected function getMethodRoutesNotFound(string $method) : array 128 | { 129 | $reflectionMethod = $this->reflection->getMethod($method); 130 | $routes = []; 131 | foreach ($reflectionMethod->getAttributes() as $attribute) { 132 | if ($attribute->getName() === RouteNotFound::class) { 133 | $routes[] = $attribute->newInstance(); 134 | } 135 | } 136 | return $routes; // @phpstan-ignore-line 137 | } 138 | 139 | /** 140 | * @throws ReflectionException 141 | * 142 | * @return array 143 | */ 144 | public function getRoutesNotFound() : array 145 | { 146 | $origins = $this->getObjectOrigins($this->reflection); 147 | $result = []; 148 | foreach ($this->reflection->getMethods() as $method) { 149 | if (!$method->isPublic()) { 150 | continue; 151 | } 152 | $routes = $this->getMethodRoutesNotFound($method->getName()); 153 | if (empty($routes)) { 154 | continue; 155 | } 156 | foreach ($routes as $route) { 157 | $result[] = [ 158 | 'origins' => $route->getOrigins() ?: $origins, 159 | 'action' => $this->reflection->name . '::' . $method->name, 160 | ]; 161 | } 162 | } 163 | return $result; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/ResourceInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use Framework\HTTP\Method; 13 | use Framework\HTTP\ResponseHeader; 14 | use Framework\HTTP\Status; 15 | 16 | /** 17 | * Interface ResourceInterface. 18 | * 19 | * The interface for data management via RESTful APIs 20 | * using all correct HTTP methods to manage a resource. 21 | * 22 | * Note: If a resource needs more than one parameter to get URL path information 23 | * provided by placeholders, in addition to $id, do not implement this interface. 24 | * But this interface can be a reference because its method names are used in 25 | * {@see RouteCollection::resource()}. 26 | * 27 | * @see https://developer.mozilla.org/en-US/docs/Glossary/REST 28 | * 29 | * @package routing 30 | */ 31 | interface ResourceInterface 32 | { 33 | /** 34 | * Handles a GET request for /. 35 | * 36 | * Common usage: Show a list of paginated items. 37 | * 38 | * @see Method::GET 39 | * 40 | * @return mixed 41 | */ 42 | public function index() : mixed; 43 | 44 | /** 45 | * Handles a POST request for /. 46 | * 47 | * Common usage: Try to create an item. On success, set the Location header to 48 | * the 'show' method URL and return a 201 (Created) status code. On fail, return 49 | * a 400 (Bad Request) status code and list the error messages in the body. 50 | * 51 | * @see Method::POST 52 | * @see ResourceInterface::show() 53 | * @see Status::BAD_REQUEST 54 | * @see Status::CREATED 55 | * @see ResponseHeader::LOCATION 56 | * 57 | * @return mixed 58 | */ 59 | public function create() : mixed; 60 | 61 | /** 62 | * Handles a GET request for /$id. 63 | * 64 | * Common usage: Show a specific item, based on the $id, in the body. If the item 65 | * does not exist, return an 404 (Not Found) status code. 66 | * 67 | * @param string $id 68 | * 69 | * @see Method::GET 70 | * @see Status::NOT_FOUND 71 | * @see Status::OK 72 | * 73 | * @return mixed 74 | */ 75 | public function show(string $id) : mixed; 76 | 77 | /** 78 | * Handles a PATCH request for /$id. 79 | * 80 | * Common usage: Try to update an item based on the $id. On success return a 200 81 | * (OK) status code and set the Location header to the 'show' method URL. On 82 | * fail, return a 400 (Bad Request) with the validation errors in the body. 83 | * 84 | * Note: The HTTP PATCH method allow items to be updated by parts. E.g. 85 | * it is possible to update only one, or more, fields in a database table 86 | * row. 87 | * 88 | * @param string $id 89 | * 90 | * @see Method::PATCH 91 | * @see ResourceInterface::show() 92 | * @see Status::BAD_REQUEST 93 | * @see Status::OK 94 | * @see ResponseHeader::LOCATION 95 | * 96 | * @return mixed 97 | */ 98 | public function update(string $id) : mixed; 99 | 100 | /** 101 | * Handles a PUT request for /$id. 102 | * 103 | * Common usage: Try to replace an item based on the $id. On success return a 200 104 | * (OK) status code and set the Location header to the 'show' method URL. On 105 | * fail, return a 400 (Bad Request) with the validation errors in the body. 106 | * 107 | * Note: The HTTP PUT method requires an entire resource to be updated. E.g. 108 | * all fields in a database table row should be updated/replaced. 109 | * 110 | * @param string $id 111 | * 112 | * @see Method::PUT 113 | * @see ResourceInterface::show() 114 | * @see Status::BAD_REQUEST 115 | * @see Status::OK 116 | * @see ResponseHeader::LOCATION 117 | * 118 | * @return mixed 119 | */ 120 | public function replace(string $id) : mixed; 121 | 122 | /** 123 | * Handles a DELETE request for /$id. 124 | * 125 | * Common usage: Delete an item based on the $id. On success, return a 204 126 | * (No Content) status code. 127 | * 128 | * @param string $id 129 | * 130 | * @see Method::DELETE 131 | * @see Status::NO_CONTENT 132 | * 133 | * @return mixed 134 | */ 135 | public function delete(string $id) : mixed; 136 | } 137 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use Closure; 13 | use Framework\HTTP\Response; 14 | use InvalidArgumentException; 15 | use JetBrains\PhpStorm\ArrayShape; 16 | use JetBrains\PhpStorm\Pure; 17 | use JsonException; 18 | 19 | /** 20 | * Class Route. 21 | * 22 | * @package routing 23 | */ 24 | class Route implements \JsonSerializable 25 | { 26 | protected Router $router; 27 | protected string $origin; 28 | protected string $path; 29 | protected Closure | string $action; 30 | /** 31 | * @var array 32 | */ 33 | protected array $actionArguments = []; 34 | protected ?string $name = null; 35 | /** 36 | * @var array 37 | */ 38 | protected array $options = []; 39 | 40 | /** 41 | * Route constructor. 42 | * 43 | * @param Router $router A Router instance 44 | * @param string $origin URL Origin. A string in the following format: 45 | * {scheme}://{hostname}[:{port}] 46 | * @param string $path URL Path. A string starting with '/' 47 | * @param Closure|string $action The action 48 | */ 49 | public function __construct( 50 | Router $router, 51 | string $origin, 52 | string $path, 53 | Closure | string $action 54 | ) { 55 | $this->router = $router; 56 | $this->setOrigin($origin); 57 | $this->setPath($path); 58 | $this->setAction($action); 59 | } 60 | 61 | /** 62 | * Gets the Route URL origin. 63 | * 64 | * @param string ...$arguments Arguments to fill the URL Origin placeholders 65 | * 66 | * @return string 67 | */ 68 | public function getOrigin(string ...$arguments) : string 69 | { 70 | if ($arguments) { 71 | return $this->router->fillPlaceholders($this->origin, ...$arguments); 72 | } 73 | return $this->origin; 74 | } 75 | 76 | /** 77 | * @param string $origin 78 | * 79 | * @return static 80 | */ 81 | protected function setOrigin(string $origin) : static 82 | { 83 | $this->origin = \ltrim($origin, '/'); 84 | return $this; 85 | } 86 | 87 | /** 88 | * Gets the Route URL. 89 | * 90 | * Note: Arguments must be passed if placeholders need to be filled. 91 | * 92 | * @param array $originArgs Arguments to fill the URL Origin placeholders 93 | * @param array $pathArgs Arguments to fill the URL Path placeholders 94 | * 95 | * @return string 96 | */ 97 | public function getUrl(array $originArgs = [], array $pathArgs = []) : string 98 | { 99 | $originArgs = static::toArrayOfStrings($originArgs); 100 | $pathArgs = static::toArrayOfStrings($pathArgs); 101 | return $this->getOrigin(...$originArgs) . $this->getPath(...$pathArgs); 102 | } 103 | 104 | /** 105 | * Gets Route options. 106 | * 107 | * @return array 108 | */ 109 | #[Pure] 110 | public function getOptions() : array 111 | { 112 | return $this->options; 113 | } 114 | 115 | /** 116 | * Sets options to be used in a specific environment application. 117 | * For example: its possible set Access Control List options, Locations, 118 | * Middleware filters, etc. 119 | * 120 | * @param array $options 121 | * 122 | * @return static 123 | */ 124 | public function setOptions(array $options) : static 125 | { 126 | $this->options = $options; 127 | return $this; 128 | } 129 | 130 | /** 131 | * Gets the Route name. 132 | * 133 | * @return string|null 134 | */ 135 | #[Pure] 136 | public function getName() : ?string 137 | { 138 | return $this->name; 139 | } 140 | 141 | /** 142 | * Sets the Route name. 143 | * 144 | * @param string $name 145 | * 146 | * @return static 147 | */ 148 | public function setName(string $name) : static 149 | { 150 | $this->name = $name; 151 | return $this; 152 | } 153 | 154 | /** 155 | * Sets the Route URL path. 156 | * 157 | * @param string $path 158 | * 159 | * @return static 160 | */ 161 | public function setPath(string $path) : static 162 | { 163 | $this->path = '/' . \trim($path, '/'); 164 | return $this; 165 | } 166 | 167 | /** 168 | * Gets the Route URL path. 169 | * 170 | * @param string ...$arguments Arguments to fill the URL Path placeholders 171 | * 172 | * @return string 173 | */ 174 | public function getPath(string ...$arguments) : string 175 | { 176 | if ($arguments) { 177 | return $this->router->fillPlaceholders($this->path, ...$arguments); 178 | } 179 | return $this->path; 180 | } 181 | 182 | /** 183 | * Gets the Route action. 184 | * 185 | * @return Closure|string 186 | */ 187 | #[Pure] 188 | public function getAction() : Closure | string 189 | { 190 | return $this->action; 191 | } 192 | 193 | /** 194 | * Sets the Route action. 195 | * 196 | * @param Closure|string $action A Closure or a string in the format of the 197 | * `__METHOD__` constant. Example: `App\Blog::show`. 198 | * 199 | * The action can be suffixed with ordered parameters, separated by slashes, 200 | * to set how the arguments will be passed to the class method. 201 | * Example: `App\Blog::show/0/2/1`. 202 | * 203 | * And, also with the asterisk wildcard, to pass all arguments in the 204 | * incoming order. Example: `App\Blog::show/*` 205 | * 206 | * @see Route::setActionArguments() 207 | * @see Route::run() 208 | * 209 | * @return static 210 | */ 211 | public function setAction(Closure | string $action) : static 212 | { 213 | $this->action = \is_string($action) ? \trim($action, '\\') : $action; 214 | return $this; 215 | } 216 | 217 | /** 218 | * Gets the Route action arguments. 219 | * 220 | * @return array 221 | */ 222 | #[Pure] 223 | public function getActionArguments() : array 224 | { 225 | return $this->actionArguments; 226 | } 227 | 228 | /** 229 | * Sets the Route action arguments. 230 | * 231 | * @param array $arguments The arguments. Note that the indexes set 232 | * the order of how the arguments are passed to the Action 233 | * 234 | * @see Route::setAction() 235 | * 236 | * @return static 237 | */ 238 | public function setActionArguments(array $arguments) : static 239 | { 240 | \ksort($arguments); 241 | /*foreach ($arguments as $i => $argument) { 242 | $this->actionArguments[++$i] = $argument; 243 | }*/ 244 | $this->actionArguments = $arguments; 245 | return $this; 246 | } 247 | 248 | /** 249 | * Runs the Route action. 250 | * 251 | * @param mixed ...$construct Class constructor arguments 252 | * 253 | * @throws JsonException if the action result is an array, or an instance of 254 | * JsonSerializable, and the Response cannot be set as JSON 255 | * @throws RoutingException if class is not an instance of {@see RouteActions}, 256 | * action method not exists or if the result of the action method has not 257 | * a valid type 258 | * 259 | * @return Response The Response with the action result appended on the body 260 | */ 261 | public function run(mixed ...$construct) : Response 262 | { 263 | $debug = $this->router->getDebugCollector(); 264 | if ($debug) { 265 | $start = \microtime(true); 266 | $addToDebug = static fn () => $debug->addData([ 267 | 'type' => 'run', 268 | 'start' => $start, 269 | 'end' => \microtime(true), 270 | ]); 271 | } 272 | $action = $this->getAction(); 273 | if ($action instanceof Closure) { 274 | $result = $action($this->getActionArguments(), ...$construct); 275 | $response = $this->makeResponse($result); 276 | if ($debug) { 277 | $addToDebug(); 278 | } 279 | return $response; 280 | } 281 | if (!\str_contains($action, '::')) { 282 | $action .= '::' . $this->router->getDefaultRouteActionMethod(); 283 | } 284 | [$classname, $action] = \explode('::', $action, 2); 285 | [$method, $arguments] = $this->extractMethodAndArguments($action); 286 | if (!\class_exists($classname)) { 287 | throw new RoutingException("Class does not exist: {$classname}"); 288 | } 289 | /** 290 | * @var RouteActions $class 291 | */ 292 | $class = new $classname(...$construct); 293 | if (!$class instanceof RouteActions) { 294 | throw new RoutingException( 295 | 'Class ' . $class::class . ' is not an instance of ' . RouteActions::class 296 | ); 297 | } 298 | if (!\method_exists($class, $method)) { 299 | throw new RoutingException( 300 | "Class action method does not exist: {$classname}::{$method}" 301 | ); 302 | } 303 | $result = $class->beforeAction($method, $arguments); // @phpstan-ignore-line 304 | $ran = false; 305 | if ($result === null) { 306 | $result = $class->{$method}(...$arguments); 307 | $ran = true; 308 | } 309 | $result = $class->afterAction($method, $arguments, $ran, $result); // @phpstan-ignore-line 310 | $response = $this->makeResponse($result); 311 | if ($debug) { 312 | $addToDebug(); 313 | } 314 | return $response; 315 | } 316 | 317 | /** 318 | * Make the final Response used in the 'run' method. 319 | * 320 | * @throws JsonException if the $result is an array, or an instance of 321 | * JsonSerializable, and the Response cannot be set as JSON 322 | * @throws RoutingException if the $result type is invalid 323 | */ 324 | protected function makeResponse(mixed $result) : Response 325 | { 326 | $result = $this->makeResponseBodyPart($result); 327 | return $this->router->getResponse()->appendBody($result); 328 | } 329 | 330 | /** 331 | * Make a string to be appended in the Response body based in the route 332 | * action result. 333 | * 334 | * @param mixed $result The return value of the matched route action 335 | * 336 | * @throws JsonException if the $result is an array, or an instance of 337 | * JsonSerializable, and the Response cannot be set as JSON 338 | * @throws RoutingException if the $result type is invalid 339 | * 340 | * @return string 341 | */ 342 | protected function makeResponseBodyPart(mixed $result) : string 343 | { 344 | if ($result === null || $result instanceof Response) { 345 | return ''; 346 | } 347 | if (\is_scalar($result)) { 348 | return (string) $result; 349 | } 350 | if ( 351 | \is_array($result) 352 | || $result instanceof \stdClass 353 | || $result instanceof \JsonSerializable 354 | ) { 355 | $this->router->getResponse()->setJson($result); 356 | return ''; 357 | } 358 | if (\is_object($result) && \method_exists($result, '__toString')) { 359 | return (string) $result; 360 | } 361 | $type = \get_debug_type($result); 362 | throw new RoutingException( 363 | "Invalid action return type '{$type}'" . $this->onNamedRoutePart() 364 | ); 365 | } 366 | 367 | /** 368 | * @param string $part An action part like: index/0/2/1 369 | * 370 | * @throws InvalidArgumentException for undefined action argument 371 | * 372 | * @return array The action method in the first index, the action 373 | * arguments in the second 374 | */ 375 | #[ArrayShape([0 => 'string', 1 => 'array'])] 376 | protected function extractMethodAndArguments( 377 | string $part 378 | ) : array { 379 | if (!\str_contains($part, '/')) { 380 | return [$part, []]; 381 | } 382 | $part = \rtrim($part, '/'); 383 | $arguments = \explode('/', $part); 384 | $method = $arguments[0]; 385 | unset($arguments[0]); 386 | $actionArguments = $this->getActionArguments(); 387 | foreach ($arguments as $index => $arg) { 388 | if ($arg[0] === '$') { 389 | $arg = \substr($arg, 1); 390 | if (\is_numeric($arg) /*&& $arg > 0*/) { 391 | $arg = (int) $arg; 392 | if (\array_key_exists($arg, $actionArguments)) { 393 | $arguments[$index] = $actionArguments[$arg]; 394 | continue; 395 | } 396 | throw new InvalidArgumentException( 397 | "Undefined action argument: \${$arg}" . $this->onNamedRoutePart() 398 | ); 399 | } 400 | throw new InvalidArgumentException( 401 | "Invalid action argument: \${$arg}" . $this->onNamedRoutePart() 402 | ); 403 | } 404 | if ($arg !== '*') { 405 | $arguments[$index] = $arg; 406 | continue; 407 | } 408 | if ($index > 1 || \count($arguments) > 1) { 409 | throw new InvalidArgumentException( 410 | 'Action arguments can only contain an asterisk wildcard and must be passed alone' 411 | . $this->onNamedRoutePart() 412 | ); 413 | } 414 | $arguments = $actionArguments; 415 | } 416 | return [ 417 | $method, 418 | $arguments, 419 | ]; 420 | } 421 | 422 | #[Pure] 423 | protected function onNamedRoutePart() : string 424 | { 425 | $routeName = $this->getName(); 426 | $part = $routeName ? "named route '{$routeName}'" : 'unnamed route'; 427 | return ', on ' . $part; 428 | } 429 | 430 | public function jsonSerialize() : string 431 | { 432 | return $this->getUrl(); 433 | } 434 | 435 | /** 436 | * @param array $array 437 | * 438 | * @return array 439 | */ 440 | protected static function toArrayOfStrings(array $array) : array 441 | { 442 | if ($array === []) { 443 | return []; 444 | } 445 | return \array_map(static function (mixed $value) : string { 446 | return (string) $value; 447 | }, $array); 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /src/RouteActions.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use BadMethodCallException; 13 | 14 | /** 15 | * Class RouteActions. 16 | * 17 | * @package routing 18 | */ 19 | abstract class RouteActions 20 | { 21 | /** 22 | * @param string $method 23 | * @param array $arguments 24 | * 25 | * @return mixed 26 | */ 27 | public function __call(string $method, array $arguments) : mixed 28 | { 29 | if ($method === 'beforeAction') { 30 | return $this->beforeAction(...$arguments); 31 | } 32 | if ($method === 'afterAction') { 33 | return $this->afterAction(...$arguments); 34 | } 35 | $class = static::class; 36 | if (\method_exists($this, $method)) { 37 | throw new BadMethodCallException( 38 | "Action method not allowed: {$class}::{$method}" 39 | ); 40 | } 41 | throw new BadMethodCallException("Action method not found: {$class}::{$method}"); 42 | } 43 | 44 | /** 45 | * Runs just before the class action method and after the constructor. 46 | * 47 | * Used to prepare settings, filter input data, acts as a middleware between 48 | * the routing and the class action method. 49 | * 50 | * @param string $method The action method name 51 | * @param array $arguments The action method arguments 52 | * 53 | * @return mixed Returns a response (any value, except null) to prevent the 54 | * route action execution or null to continue the process and call the 55 | * action method 56 | */ 57 | protected function beforeAction(string $method, array $arguments) : mixed 58 | { 59 | // Prepare or intercept... 60 | return null; 61 | } 62 | 63 | /** 64 | * Runs just after the class action method and before the destructor. 65 | * 66 | * Used to finalize settings, filter output data, acts as a middleware between 67 | * the action method and the final response. 68 | * 69 | * @param string $method The action method name 70 | * @param array $arguments The action method arguments 71 | * @param bool $ran Indicates if the class action method was executed, true 72 | * if it was not intercepted by the beforeAction method 73 | * @param mixed $result The returned value directly from beforeAction or 74 | * from the class action method, if it was executed 75 | * 76 | * @see RouteActions::beforeAction() 77 | * 78 | * @return mixed 79 | */ 80 | protected function afterAction( 81 | string $method, 82 | array $arguments, 83 | bool $ran, 84 | mixed $result 85 | ) : mixed { 86 | return $result; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/RouteCollection.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use BadMethodCallException; 13 | use Closure; 14 | use Error; 15 | use Framework\HTTP\Method; 16 | use Framework\HTTP\Status; 17 | use InvalidArgumentException; 18 | use LogicException; 19 | 20 | /** 21 | * Class RouteCollection. 22 | * 23 | * @property-read string|null $name 24 | * @property-read string $origin 25 | * @property-read Router $router 26 | * @property-read array $routes 27 | * 28 | * @package routing 29 | */ 30 | class RouteCollection implements \Countable, \JsonSerializable 31 | { 32 | protected Router $router; 33 | protected string $origin; 34 | protected ?string $name; 35 | /** 36 | * Array of HTTP Methods as keys and array of Routes as values. 37 | * 38 | * @var array 39 | */ 40 | protected array $routes = []; 41 | /** 42 | * The Error 404 page action. 43 | */ 44 | protected Closure | string $notFoundAction; 45 | 46 | /** 47 | * RouteCollection constructor. 48 | * 49 | * @param Router $router A Router instance 50 | * @param string $origin URL Origin. A string in the following format: 51 | * `{scheme}://{hostname}[:{port}]` 52 | * @param string|null $name The collection name 53 | */ 54 | public function __construct(Router $router, string $origin, ?string $name = null) 55 | { 56 | $this->router = $router; 57 | $this->setOrigin($origin); 58 | $this->name = $name; 59 | } 60 | 61 | /** 62 | * @param string $method 63 | * @param array $arguments 64 | * 65 | * @throws BadMethodCallException for method not allowed or method not found 66 | * 67 | * @return Route|null 68 | */ 69 | public function __call(string $method, array $arguments) 70 | { 71 | if ($method === 'getRouteNotFound') { 72 | return $this->getRouteNotFound(); 73 | } 74 | $class = static::class; 75 | if (\method_exists($this, $method)) { 76 | throw new BadMethodCallException( 77 | "Method not allowed: {$class}::{$method}" 78 | ); 79 | } 80 | throw new BadMethodCallException("Method not found: {$class}::{$method}"); 81 | } 82 | 83 | /** 84 | * @param string $property 85 | * 86 | * @throws Error if cannot access property 87 | * 88 | * @return mixed 89 | */ 90 | public function __get(string $property) : mixed 91 | { 92 | if ($property === 'name') { 93 | return $this->name; 94 | } 95 | if ($property === 'notFoundAction') { 96 | return $this->notFoundAction; 97 | } 98 | if ($property === 'origin') { 99 | return $this->origin; 100 | } 101 | if ($property === 'router') { 102 | return $this->router; 103 | } 104 | if ($property === 'routes') { 105 | return $this->routes; 106 | } 107 | throw new Error( 108 | 'Cannot access property ' . static::class . '::$' . $property 109 | ); 110 | } 111 | 112 | public function __isset(string $property) : bool 113 | { 114 | return isset($this->{$property}); 115 | } 116 | 117 | /** 118 | * @param string $origin 119 | * 120 | * @return static 121 | */ 122 | protected function setOrigin(string $origin) : static 123 | { 124 | $this->origin = \ltrim($origin, '/'); 125 | return $this; 126 | } 127 | 128 | /** 129 | * Get a Route name. 130 | * 131 | * @param string $name The current Route name 132 | * 133 | * @return string The Route name prefixed with the collection name and a 134 | * dot if it is set 135 | */ 136 | protected function getRouteName(string $name) : string 137 | { 138 | if (isset($this->name)) { 139 | $name = $this->name . '.' . $name; 140 | } 141 | return $name; 142 | } 143 | 144 | /** 145 | * @param string $httpMethod 146 | * @param Route $route 147 | * 148 | * @throws InvalidArgumentException for invalid method 149 | * 150 | * @return static 151 | */ 152 | protected function addRoute(string $httpMethod, Route $route) : static 153 | { 154 | $method = \strtoupper($httpMethod); 155 | if (!\in_array($method, [ 156 | 'DELETE', 157 | 'GET', 158 | 'OPTIONS', 159 | 'PATCH', 160 | 'POST', 161 | 'PUT', 162 | ], true)) { 163 | throw new InvalidArgumentException('Invalid method: ' . $httpMethod); 164 | } 165 | $this->routes[$method][] = $route; 166 | return $this; 167 | } 168 | 169 | /** 170 | * Sets the Route Not Found action for this collection. 171 | * 172 | * @param Closure|string $action the Route function to run when no Route 173 | * path is found for this collection 174 | */ 175 | public function notFound(Closure | string $action) : void 176 | { 177 | $this->notFoundAction = $action; 178 | } 179 | 180 | /** 181 | * Gets the Route Not Found for this collection. 182 | * 183 | * @see RouteCollection::notFound() 184 | * 185 | * @return Route|null The Route containing the Not Found Action or null if 186 | * the Action was not set 187 | */ 188 | protected function getRouteNotFound() : ?Route 189 | { 190 | if (isset($this->notFoundAction)) { 191 | $this->router->getResponse()->setStatus(404); 192 | return (new Route( 193 | $this->router, 194 | $this->router->getMatchedOrigin(), 195 | $this->router->getMatchedPath(), 196 | $this->notFoundAction 197 | ))->setName( 198 | $this->getRouteName('collection-not-found') 199 | ); 200 | } 201 | return null; 202 | } 203 | 204 | /** 205 | * Adds a Route to match many HTTP Methods. 206 | * 207 | * @param array $httpMethods The HTTP Methods 208 | * @param string $path The URL path 209 | * @param Closure|array|string $action The Route action 210 | * @param string|null $name The Route name 211 | * 212 | * @see Method::DELETE 213 | * @see Method::GET 214 | * @see Method::OPTIONS 215 | * @see Method::PATCH 216 | * @see Method::POST 217 | * @see Method::PUT 218 | * 219 | * @return Route 220 | */ 221 | public function add( 222 | array $httpMethods, 223 | string $path, 224 | Closure | array | string $action, 225 | ?string $name = null 226 | ) : Route { 227 | $route = $this->makeRoute($path, $action, $name); 228 | foreach ($httpMethods as $method) { 229 | $this->addRoute($method, $route); 230 | } 231 | return $route; 232 | } 233 | 234 | /** 235 | * @param string $path 236 | * @param Closure|array|string $action 237 | * @param string|null $name 238 | * 239 | * @return Route 240 | */ 241 | protected function makeRoute( 242 | string $path, 243 | Closure | array | string $action, 244 | ?string $name = null 245 | ) : Route { 246 | if (\is_array($action)) { 247 | $action = $this->makeRouteActionFromArray($action); 248 | } 249 | $route = new Route($this->router, $this->origin, $path, $action); 250 | if ($name !== null) { 251 | $route->setName($this->getRouteName($name)); 252 | } 253 | return $route; 254 | } 255 | 256 | /** 257 | * @param string $method 258 | * @param string $path 259 | * @param Closure|array|string $action 260 | * @param string|null $name 261 | * 262 | * @return Route 263 | */ 264 | protected function addSimple( 265 | string $method, 266 | string $path, 267 | Closure | array | string $action, 268 | ?string $name = null 269 | ) : Route { 270 | return $this->routes[$method][] = $this->makeRoute($path, $action, $name); 271 | } 272 | 273 | /** 274 | * @param array $action 275 | * 276 | * @return string 277 | */ 278 | protected function makeRouteActionFromArray(array $action) : string 279 | { 280 | if (empty($action[0])) { 281 | throw new LogicException( 282 | 'When adding a route action as array, the index 0 must be a FQCN' 283 | ); 284 | } 285 | if (!isset($action[1])) { 286 | $action[1] = $this->router->getDefaultRouteActionMethod(); 287 | } 288 | if (!isset($action[2])) { 289 | $action[2] = '*'; 290 | } 291 | if ($action[2] !== '') { 292 | $action[2] = '/' . $action[2]; 293 | } 294 | return $action[0] . '::' . $action[1] . $action[2]; 295 | } 296 | 297 | /** 298 | * Adds a Route to match the HTTP GET Method. 299 | * 300 | * @param string $path The URL path 301 | * @param Closure|array|string $action The Route action 302 | * @param string|null $name The Route name 303 | * 304 | * @see Method::GET 305 | * 306 | * @return Route The Route added to the collection 307 | */ 308 | public function get( 309 | string $path, 310 | Closure | array | string $action, 311 | ?string $name = null 312 | ) : Route { 313 | return $this->addSimple('GET', $path, $action, $name); 314 | } 315 | 316 | /** 317 | * Adds a Route to match the HTTP POST Method. 318 | * 319 | * @param string $path The URL path 320 | * @param Closure|array|string $action The Route action 321 | * @param string|null $name The Route name 322 | * 323 | * @see Method::POST 324 | * 325 | * @return Route The Route added to the collection 326 | */ 327 | public function post( 328 | string $path, 329 | Closure | array | string $action, 330 | ?string $name = null 331 | ) : Route { 332 | return $this->addSimple('POST', $path, $action, $name); 333 | } 334 | 335 | /** 336 | * Adds a Route to match the HTTP PUT Method. 337 | * 338 | * @param string $path The URL path 339 | * @param Closure|array|string $action The Route action 340 | * @param string|null $name The Route name 341 | * 342 | * @see Method::PUT 343 | * 344 | * @return Route The Route added to the collection 345 | */ 346 | public function put( 347 | string $path, 348 | Closure | array | string $action, 349 | ?string $name = null 350 | ) : Route { 351 | return $this->addSimple('PUT', $path, $action, $name); 352 | } 353 | 354 | /** 355 | * Adds a Route to match the HTTP PATCH Method. 356 | * 357 | * @param string $path The URL path 358 | * @param Closure|array|string $action The Route action 359 | * @param string|null $name The Route name 360 | * 361 | * @see Method::PATCH 362 | * 363 | * @return Route The Route added to the collection 364 | */ 365 | public function patch( 366 | string $path, 367 | Closure | array | string $action, 368 | ?string $name = null 369 | ) : Route { 370 | return $this->addSimple('PATCH', $path, $action, $name); 371 | } 372 | 373 | /** 374 | * Adds a Route to match the HTTP DELETE Method. 375 | * 376 | * @param string $path The URL path 377 | * @param Closure|array|string $action The Route action 378 | * @param string|null $name The Route name 379 | * 380 | * @see Method::DELETE 381 | * 382 | * @return Route The Route added to the collection 383 | */ 384 | public function delete( 385 | string $path, 386 | Closure | array | string $action, 387 | ?string $name = null 388 | ) : Route { 389 | return $this->addSimple('DELETE', $path, $action, $name); 390 | } 391 | 392 | /** 393 | * Adds a Route to match the HTTP OPTIONS Method. 394 | * 395 | * @param string $path The URL path 396 | * @param Closure|array|string $action The Route action 397 | * @param string|null $name The Route name 398 | * 399 | * @see Method::OPTIONS 400 | * 401 | * @return Route The Route added to the collection 402 | */ 403 | public function options( 404 | string $path, 405 | Closure | array | string $action, 406 | ?string $name = null 407 | ) : Route { 408 | return $this->addSimple('OPTIONS', $path, $action, $name); 409 | } 410 | 411 | /** 412 | * Adds a GET Route to match a path and automatically redirects to a URL. 413 | * 414 | * @param string $path The URL path 415 | * @param string $location The URL to redirect 416 | * @param int $code The status code of the response 417 | * @param string|null $name The Route name 418 | * 419 | * @return Route The Route added to the collection 420 | */ 421 | public function redirect( 422 | string $path, 423 | string $location, 424 | int $code = Status::TEMPORARY_REDIRECT, 425 | ?string $name = null 426 | ) : Route { 427 | $response = $this->router->getResponse(); 428 | return $this->addSimple( 429 | 'GET', 430 | $path, 431 | static function (array $args) use ($response, $location, $code) : void { 432 | foreach ($args as $key => $value) { 433 | $location = \strtr($location, ['$' . $key => $value]); 434 | } 435 | $response->redirect($location, [], $code); 436 | }, 437 | $name 438 | ); 439 | } 440 | 441 | /** 442 | * Groups many Routes into a URL path. 443 | * 444 | * @param string $basePath The URL path to group in 445 | * @param array> $routes The Routes to be grouped 446 | * @param array $options Custom options passed to the Routes 447 | * 448 | * @return array> The same $routes with updated paths and options 449 | */ 450 | public function group(string $basePath, array $routes, array $options = []) : array 451 | { 452 | $basePath = \rtrim($basePath, '/'); 453 | foreach ($routes as $route) { 454 | if (\is_array($route)) { 455 | $this->group($basePath, $route, $options); 456 | continue; 457 | } 458 | $route->setPath($basePath . $route->getPath()); 459 | if ($options) { 460 | $specificOptions = $options; 461 | if ($route->getOptions()) { 462 | $specificOptions = \array_replace_recursive($options, $route->getOptions()); 463 | } 464 | $route->setOptions($specificOptions); 465 | } 466 | } 467 | return $routes; 468 | } 469 | 470 | /** 471 | * Updates Routes actions, which are strings, prepending a namespace. 472 | * 473 | * @param string $namespace The namespace 474 | * @param array> $routes The Routes 475 | * 476 | * @return array> The same $routes with updated actions 477 | */ 478 | public function namespace(string $namespace, array $routes) : array 479 | { 480 | $namespace = \trim($namespace, '\\'); 481 | foreach ($routes as $route) { 482 | if (\is_array($route)) { 483 | $this->namespace($namespace, $route); 484 | continue; 485 | } 486 | if (\is_string($route->getAction())) { 487 | $route->setAction($namespace . '\\' . $route->getAction()); 488 | } 489 | } 490 | return $routes; 491 | } 492 | 493 | /** 494 | * Adds many Routes that can be used as a REST Resource. 495 | * 496 | * @param string $path The URL path 497 | * @param string $class The name of the class where the resource will point 498 | * @param string $baseName The base name used as a Route name prefix 499 | * @param array $except Actions not added. Allowed values are: 500 | * index, create, show, update, replace and delete 501 | * @param string $placeholder The placeholder. Normally it matches an id, a number 502 | * 503 | * @see ResourceInterface 504 | * @see Router::$placeholders 505 | * 506 | * @return array The Routes added to the collection 507 | */ 508 | public function resource( 509 | string $path, 510 | string $class, 511 | string $baseName, 512 | array $except = [], 513 | string $placeholder = '{int}' 514 | ) : array { 515 | $path = \rtrim($path, '/') . '/'; 516 | $class .= '::'; 517 | if ($except) { 518 | $except = \array_flip($except); 519 | } 520 | $routes = []; 521 | if (!isset($except['index'])) { 522 | $routes[] = $this->get( 523 | $path, 524 | $class . 'index/*', 525 | $baseName . '.index' 526 | ); 527 | } 528 | if (!isset($except['create'])) { 529 | $routes[] = $this->post( 530 | $path, 531 | $class . 'create/*', 532 | $baseName . '.create' 533 | ); 534 | } 535 | if (!isset($except['show'])) { 536 | $routes[] = $this->get( 537 | $path . $placeholder, 538 | $class . 'show/*', 539 | $baseName . '.show' 540 | ); 541 | } 542 | if (!isset($except['update'])) { 543 | $routes[] = $this->patch( 544 | $path . $placeholder, 545 | $class . 'update/*', 546 | $baseName . '.update' 547 | ); 548 | } 549 | if (!isset($except['replace'])) { 550 | $routes[] = $this->put( 551 | $path . $placeholder, 552 | $class . 'replace/*', 553 | $baseName . '.replace' 554 | ); 555 | } 556 | if (!isset($except['delete'])) { 557 | $routes[] = $this->delete( 558 | $path . $placeholder, 559 | $class . 'delete/*', 560 | $baseName . '.delete' 561 | ); 562 | } 563 | return $routes; 564 | } 565 | 566 | /** 567 | * Adds many Routes that can be used by a User Interface. 568 | * 569 | * @param string $path The URL path 570 | * @param string $class The name of the class where the resource will point 571 | * @param string $baseName The base name used as a Route name prefix 572 | * @param array $except Actions not added. Allowed values are: 573 | * index, new, create, show, edit, update, remove and delete 574 | * @param string $placeholder The placeholder. Normally it matches an id, a number 575 | * 576 | * @see PresenterInterface 577 | * @see Router::$placeholders 578 | * 579 | * @return array The Routes added to the collection 580 | */ 581 | public function presenter( 582 | string $path, 583 | string $class, 584 | string $baseName, 585 | array $except = [], 586 | string $placeholder = '{int}' 587 | ) : array { 588 | $path = \rtrim($path, '/') . '/'; 589 | $class .= '::'; 590 | if ($except) { 591 | $except = \array_flip($except); 592 | } 593 | $routes = []; 594 | if (!isset($except['index'])) { 595 | $routes[] = $this->get( 596 | $path, 597 | $class . 'index/*', 598 | $baseName . '.index' 599 | ); 600 | } 601 | if (!isset($except['new'])) { 602 | $routes[] = $this->get( 603 | $path . 'new', 604 | $class . 'new/*', 605 | $baseName . '.new' 606 | ); 607 | } 608 | if (!isset($except['create'])) { 609 | $routes[] = $this->post( 610 | $path, 611 | $class . 'create/*', 612 | $baseName . '.create' 613 | ); 614 | } 615 | if (!isset($except['show'])) { 616 | $routes[] = $this->get( 617 | $path . $placeholder, 618 | $class . 'show/*', 619 | $baseName . '.show' 620 | ); 621 | } 622 | if (!isset($except['edit'])) { 623 | $routes[] = $this->get( 624 | $path . $placeholder . '/edit', 625 | $class . 'edit/*', 626 | $baseName . '.edit' 627 | ); 628 | } 629 | if (!isset($except['update'])) { 630 | $routes[] = $this->post( 631 | $path . $placeholder . '/update', 632 | $class . 'update/*', 633 | $baseName . '.update' 634 | ); 635 | } 636 | if (!isset($except['remove'])) { 637 | $routes[] = $this->get( 638 | $path . $placeholder . '/remove', 639 | $class . 'remove/*', 640 | $baseName . '.remove' 641 | ); 642 | } 643 | if (!isset($except['delete'])) { 644 | $routes[] = $this->post( 645 | $path . $placeholder . '/delete', 646 | $class . 'delete/*', 647 | $baseName . '.delete' 648 | ); 649 | } 650 | return $routes; 651 | } 652 | 653 | /** 654 | * Count routes in the collection. 655 | * 656 | * @return int 657 | */ 658 | public function count() : int 659 | { 660 | $count = isset($this->notFoundAction) ? 1 : 0; 661 | foreach ($this->routes as $routes) { 662 | $count += \count($routes); 663 | } 664 | return $count; 665 | } 666 | 667 | /** 668 | * @return array 669 | */ 670 | public function jsonSerialize() : array 671 | { 672 | return [ 673 | 'origin' => $this->origin, 674 | 'routes' => $this->routes, 675 | 'hasNotFound' => isset($this->notFoundAction), 676 | ]; 677 | } 678 | } 679 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | use Closure; 13 | use Framework\HTTP\Method; 14 | use Framework\HTTP\Request; 15 | use Framework\HTTP\Response; 16 | use Framework\HTTP\ResponseHeader; 17 | use Framework\HTTP\Status; 18 | use Framework\Language\Language; 19 | use Framework\Routing\Debug\RoutingCollector; 20 | use InvalidArgumentException; 21 | use JetBrains\PhpStorm\Pure; 22 | use OutOfBoundsException; 23 | use RuntimeException; 24 | 25 | /** 26 | * Class Router. 27 | * 28 | * @package routing 29 | */ 30 | class Router implements \JsonSerializable 31 | { 32 | protected string $defaultRouteActionMethod = 'index'; 33 | protected Closure | string $defaultRouteNotFound; 34 | /** 35 | * @var array 36 | */ 37 | protected static array $placeholders = [ 38 | '{alpha}' => '([a-zA-Z]+)', 39 | '{alphanum}' => '([a-zA-Z0-9]+)', 40 | '{any}' => '(.*)', 41 | '{hex}' => '([[:xdigit:]]+)', 42 | '{int}' => '([0-9]{1,18}+)', 43 | '{md5}' => '([a-f0-9]{32}+)', 44 | '{num}' => '([0-9]+)', 45 | '{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])', 46 | '{scheme}' => '(https?)', 47 | '{segment}' => '([^/]+)', 48 | '{slug}' => '([a-z0-9_-]+)', 49 | '{subdomain}' => '([^.]+)', 50 | //'{subdomain}' => '([A-Za-z0-9](?:[a-zA-Z0-9\-]{0,61}[A-Za-z0-9])?)', 51 | '{title}' => '([a-zA-Z0-9_-]+)', 52 | '{uuid}' => '([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}+)', 53 | ]; 54 | /** 55 | * @var array 56 | */ 57 | protected array $collections = []; 58 | protected ?RouteCollection $matchedCollection = null; 59 | protected ?Route $matchedRoute = null; 60 | protected ?string $matchedOrigin = null; 61 | /** 62 | * @var array 63 | */ 64 | protected array $matchedOriginArguments = []; 65 | protected ?string $matchedPath = null; 66 | /** 67 | * @var array 68 | */ 69 | protected array $matchedPathArguments = []; 70 | protected bool $autoOptions = false; 71 | protected bool $autoMethods = false; 72 | protected Response $response; 73 | protected Language $language; 74 | protected RoutingCollector $debugCollector; 75 | 76 | /** 77 | * Router constructor. 78 | * 79 | * @param Response $response 80 | * @param Language|null $language 81 | */ 82 | public function __construct(Response $response, ?Language $language = null) 83 | { 84 | $this->response = $response; 85 | if ($language) { 86 | $this->setLanguage($language); 87 | } 88 | } 89 | 90 | public function __get(string $property) : mixed 91 | { 92 | if (\property_exists($this, $property)) { 93 | return $this->{$property} ?? null; 94 | } 95 | throw new OutOfBoundsException( 96 | 'Property not exists: ' . static::class . '::$' . $property 97 | ); 98 | } 99 | 100 | /** 101 | * Gets the HTTP Response instance. 102 | * 103 | * @return Response 104 | */ 105 | #[Pure] 106 | public function getResponse() : Response 107 | { 108 | return $this->response; 109 | } 110 | 111 | public function setLanguage(?Language $language = null) : static 112 | { 113 | $this->language = $language ?? new Language(); 114 | $this->language->addDirectory(__DIR__ . '/Languages'); 115 | return $this; 116 | } 117 | 118 | public function getLanguage() : Language 119 | { 120 | if (!isset($this->language)) { 121 | $this->setLanguage(); 122 | } 123 | return $this->language; 124 | } 125 | 126 | /** 127 | * Gets the default route action method. 128 | * 129 | * Normally, it is "index". 130 | * 131 | * @see Router::setDefaultRouteActionMethod() 132 | * 133 | * @return string 134 | */ 135 | #[Pure] 136 | public function getDefaultRouteActionMethod() : string 137 | { 138 | return $this->defaultRouteActionMethod; 139 | } 140 | 141 | /** 142 | * Set the class method name to be called when a Route action is set without 143 | * a method. 144 | * 145 | * @param string $action 146 | * 147 | * @return static 148 | */ 149 | public function setDefaultRouteActionMethod(string $action) : static 150 | { 151 | $this->defaultRouteActionMethod = $action; 152 | return $this; 153 | } 154 | 155 | protected function getDefaultRouteNotFound() : Route 156 | { 157 | return (new Route( 158 | $this, 159 | $this->getMatchedOrigin(), 160 | $this->getMatchedPath(), 161 | $this->defaultRouteNotFound ?? function () { 162 | $this->response->setStatus(Status::NOT_FOUND); 163 | if ($this->response->getRequest()->isJson()) { 164 | return $this->response->setJson([ 165 | 'status' => [ 166 | 'code' => Status::NOT_FOUND, 167 | 'reason' => Status::getReason(Status::NOT_FOUND), 168 | ], 169 | ]); 170 | } 171 | $language = $this->getLanguage(); 172 | $lang = $language->getCurrentLocale(); 173 | $dir = $language->getCurrentLocaleDirection(); 174 | $title = $language->render('routing', 'error404'); 175 | $message = $language->render('routing', 'pageNotFound'); 176 | return $this->response->setBody( 177 | << 179 | 180 | 181 | 182 | 183 | {$title} 184 | 194 | 195 | 196 |

{$title}

197 |

{$message}

198 | 199 | 200 | 201 | HTML 202 | ); 203 | } 204 | ))->setName('not-found'); 205 | } 206 | 207 | /** 208 | * Sets the Default Route Not Found action. 209 | * 210 | * @param Closure|string $action the function to run when no Route path is found 211 | * 212 | * @return static 213 | */ 214 | public function setDefaultRouteNotFound(Closure | string $action) : static 215 | { 216 | $this->defaultRouteNotFound = $action; 217 | return $this; 218 | } 219 | 220 | /** 221 | * Gets the Route Not Found. 222 | * 223 | * Must be called after {@see Router::match()} and will return the Route 224 | * Not Found from the matched collection or the Default Route Not Found 225 | * from the router. 226 | * 227 | * @see RouteCollection::notFound() 228 | * @see Router::setDefaultRouteNotFound() 229 | * 230 | * @return Route 231 | */ 232 | public function getRouteNotFound() : Route 233 | { 234 | // @phpstan-ignore-next-line 235 | return $this->getMatchedCollection()?->getRouteNotFound() 236 | ?? $this->getDefaultRouteNotFound(); 237 | } 238 | 239 | /** 240 | * Adds Router placeholders. 241 | * 242 | * @param array|string $placeholder 243 | * @param string|null $pattern 244 | * 245 | * @return static 246 | */ 247 | public function addPlaceholder(array | string $placeholder, ?string $pattern = null) : static 248 | { 249 | if (\is_array($placeholder)) { 250 | foreach ($placeholder as $key => $value) { 251 | static::$placeholders['{' . $key . '}'] = $value; 252 | } 253 | return $this; 254 | } 255 | static::$placeholders['{' . $placeholder . '}'] = $pattern; 256 | return $this; 257 | } 258 | 259 | /** 260 | * Gets all Router placeholders. 261 | * 262 | * @return array 263 | */ 264 | #[Pure] 265 | public function getPlaceholders() : array 266 | { 267 | return static::$placeholders; 268 | } 269 | 270 | /** 271 | * Replaces string placeholders with patterns or patterns with placeholders. 272 | * 273 | * @param string $string The string with placeholders or patterns 274 | * @param bool $flip Set true to replace patterns with placeholders 275 | * 276 | * @return string 277 | */ 278 | #[Pure] 279 | public function replacePlaceholders( 280 | string $string, 281 | bool $flip = false 282 | ) : string { 283 | $placeholders = $this->getPlaceholders(); 284 | if ($flip) { 285 | $placeholders = \array_flip($placeholders); 286 | } 287 | return \strtr($string, $placeholders); 288 | } 289 | 290 | /** 291 | * Fills argument values into a string with placeholders. 292 | * 293 | * @param string $string The input string 294 | * @param string ...$arguments Values to fill the string placeholders 295 | * 296 | * @throws InvalidArgumentException if param not required, empty or invalid 297 | * @throws RuntimeException if a pattern position is not found 298 | * 299 | * @return string The string with argument values in place of placeholders 300 | */ 301 | public function fillPlaceholders(string $string, string ...$arguments) : string 302 | { 303 | $string = $this->replacePlaceholders($string); 304 | \preg_match_all('#\(([^)]+)\)#', $string, $matches); 305 | if (empty($matches[0])) { 306 | if ($arguments) { 307 | throw new InvalidArgumentException( 308 | 'String has no placeholders. Arguments not required' 309 | ); 310 | } 311 | return $string; 312 | } 313 | foreach ($matches[0] as $index => $pattern) { 314 | if (!isset($arguments[$index])) { 315 | throw new InvalidArgumentException("Placeholder argument is not set: {$index}"); 316 | } 317 | if (!\preg_match('#' . $pattern . '#', $arguments[$index])) { 318 | throw new InvalidArgumentException("Placeholder argument is invalid: {$index}"); 319 | } 320 | $string = \substr_replace( 321 | $string, 322 | $arguments[$index], 323 | \strpos($string, $pattern), // @phpstan-ignore-line 324 | \strlen($pattern) 325 | ); 326 | } 327 | return $string; 328 | } 329 | 330 | /** 331 | * Serves a RouteCollection to a specific Origin. 332 | * 333 | * @param string|null $origin URL Origin. A string in the following format: 334 | * `{scheme}://{hostname}[:{port}]`. Null to auto-detect. 335 | * @param callable $callable A function receiving an instance of RouteCollection 336 | * as the first parameter 337 | * @param string|null $collectionName The RouteCollection name 338 | * 339 | * @return static 340 | */ 341 | public function serve(?string $origin, callable $callable, ?string $collectionName = null) : static 342 | { 343 | if (isset($this->debugCollector)) { 344 | $start = \microtime(true); 345 | $this->addServedCollection($origin, $callable, $collectionName); 346 | $end = \microtime(true); 347 | $this->debugCollector->addData([ 348 | 'type' => 'serve', 349 | 'start' => $start, 350 | 'end' => $end, 351 | 'collectionId' => \spl_object_id( 352 | $this->collections[\array_key_last($this->collections)] 353 | ), 354 | ]); 355 | return $this; 356 | } 357 | return $this->addServedCollection($origin, $callable, $collectionName); 358 | } 359 | 360 | protected function addServedCollection( 361 | ?string $origin, 362 | callable $callable, 363 | ?string $collectionName = null 364 | ) : static { 365 | if ($origin === null) { 366 | $origin = $this->response->getRequest()->getUrl()->getOrigin(); 367 | } 368 | $collection = new RouteCollection($this, $origin, $collectionName); 369 | $callable($collection); 370 | $this->addCollection($collection); 371 | return $this; 372 | } 373 | 374 | /** 375 | * @param RouteCollection $collection 376 | * 377 | * @return static 378 | */ 379 | protected function addCollection(RouteCollection $collection) : static 380 | { 381 | $this->collections[] = $collection; 382 | return $this; 383 | } 384 | 385 | /** 386 | * Gets all Route Collections. 387 | * 388 | * @return array 389 | */ 390 | #[Pure] 391 | public function getCollections() : array 392 | { 393 | return $this->collections; 394 | } 395 | 396 | /** 397 | * Gets the matched Route Collection. 398 | * 399 | * Note: Will return null if no URL Origin was matched in a Route Collection 400 | * 401 | * @return RouteCollection|null 402 | */ 403 | #[Pure] 404 | public function getMatchedCollection() : ?RouteCollection 405 | { 406 | return $this->matchedCollection; 407 | } 408 | 409 | protected function setMatchedCollection(RouteCollection $matchedCollection) : static 410 | { 411 | $this->matchedCollection = $matchedCollection; 412 | return $this; 413 | } 414 | 415 | /** 416 | * Gets the matched Route. 417 | * 418 | * @return Route|null 419 | */ 420 | #[Pure] 421 | public function getMatchedRoute() : ?Route 422 | { 423 | return $this->matchedRoute; 424 | } 425 | 426 | /** 427 | * @param Route $route 428 | * 429 | * @return static 430 | */ 431 | protected function setMatchedRoute(Route $route) : static 432 | { 433 | $this->matchedRoute = $route; 434 | return $this; 435 | } 436 | 437 | /** 438 | * Gets the matched URL Path. 439 | * 440 | * @return string|null 441 | */ 442 | #[Pure] 443 | public function getMatchedPath() : ?string 444 | { 445 | return $this->matchedPath; 446 | } 447 | 448 | /** 449 | * @param string $path 450 | * 451 | * @return static 452 | */ 453 | protected function setMatchedPath(string $path) : static 454 | { 455 | $this->matchedPath = $path; 456 | return $this; 457 | } 458 | 459 | /** 460 | * Gets the matched URL Path arguments. 461 | * 462 | * @return array 463 | */ 464 | #[Pure] 465 | public function getMatchedPathArguments() : array 466 | { 467 | return $this->matchedPathArguments; 468 | } 469 | 470 | /** 471 | * @param array $arguments 472 | * 473 | * @return static 474 | */ 475 | protected function setMatchedPathArguments(array $arguments) : static 476 | { 477 | $this->matchedPathArguments = $arguments; 478 | return $this; 479 | } 480 | 481 | /** 482 | * Gets the matched URL. 483 | * 484 | * Note: This method does not return the URL query. If it is needed, get 485 | * with {@see Request::getUrl()}. 486 | * 487 | * @return string|null 488 | */ 489 | #[Pure] 490 | public function getMatchedUrl() : ?string 491 | { 492 | return $this->getMatchedOrigin() 493 | ? $this->getMatchedOrigin() . $this->getMatchedPath() 494 | : null; 495 | } 496 | 497 | /** 498 | * Gets the matched URL Origin. 499 | * 500 | * @return string|null 501 | */ 502 | #[Pure] 503 | public function getMatchedOrigin() : ?string 504 | { 505 | return $this->matchedOrigin; 506 | } 507 | 508 | /** 509 | * @param string $origin 510 | * 511 | * @return static 512 | */ 513 | protected function setMatchedOrigin(string $origin) : static 514 | { 515 | $this->matchedOrigin = $origin; 516 | return $this; 517 | } 518 | 519 | /** 520 | * Gets the matched URL Origin arguments. 521 | * 522 | * @return array 523 | */ 524 | #[Pure] 525 | public function getMatchedOriginArguments() : array 526 | { 527 | return $this->matchedOriginArguments; 528 | } 529 | 530 | /** 531 | * @param array $arguments 532 | * 533 | * @return static 534 | */ 535 | protected function setMatchedOriginArguments(array $arguments) : static 536 | { 537 | $this->matchedOriginArguments = $arguments; 538 | return $this; 539 | } 540 | 541 | /** 542 | * Match HTTP Method and URL against RouteCollections to process the request. 543 | * 544 | * @see Router::serve() 545 | * 546 | * @return Route Always returns a Route, even if it is the Route Not Found 547 | */ 548 | public function match() : Route 549 | { 550 | if (isset($this->debugCollector)) { 551 | $start = \microtime(true); 552 | $route = $this->makeMatchedRoute(); 553 | $end = \microtime(true); 554 | $this->debugCollector->addData([ 555 | 'type' => 'match', 556 | 'start' => $start, 557 | 'end' => $end, 558 | ]); 559 | return $route; 560 | } 561 | return $this->makeMatchedRoute(); 562 | } 563 | 564 | protected function makeMatchedRoute() : Route 565 | { 566 | $method = $this->response->getRequest()->getMethod(); 567 | if ($method === 'HEAD') { 568 | $method = 'GET'; 569 | } 570 | $url = $this->response->getRequest()->getUrl(); 571 | $path = $this->makePath($url->getPath()); 572 | $this->setMatchedPath($path); 573 | $this->setMatchedOrigin($url->getOrigin()); 574 | $this->matchedCollection = $this->matchCollection($url->getOrigin()); 575 | if (!$this->matchedCollection) { 576 | return $this->matchedRoute = $this->getDefaultRouteNotFound(); 577 | } 578 | return $this->matchedRoute = $this->matchRoute( 579 | $method, 580 | $this->matchedCollection, 581 | $path 582 | ) ?? $this->getAlternativeRoute($method, $this->matchedCollection); 583 | } 584 | 585 | /** 586 | * Creates a path without a trailing slash to be able to match both with and 587 | * without a slash at the end. 588 | * 589 | * @since 3.4.3 590 | * 591 | * @param string $path 592 | * 593 | * @return string 594 | */ 595 | protected function makePath(string $path) : string 596 | { 597 | return '/' . \trim($path, '/'); 598 | } 599 | 600 | protected function getAlternativeRoute(string $method, RouteCollection $collection) : Route 601 | { 602 | if ($method === 'OPTIONS' && $this->isAutoOptions()) { 603 | $route = $this->getRouteWithAllowHeader($collection, Status::OK); 604 | } elseif ($this->isAutoMethods()) { 605 | $route = $this->getRouteWithAllowHeader( 606 | $collection, 607 | Status::METHOD_NOT_ALLOWED 608 | ); 609 | } 610 | if (!isset($route)) { 611 | // @phpstan-ignore-next-line 612 | $route = $collection->getRouteNotFound() ?? $this->getDefaultRouteNotFound(); 613 | } 614 | return $route; 615 | } 616 | 617 | protected function matchCollection(string $origin) : ?RouteCollection 618 | { 619 | foreach ($this->getCollections() as $collection) { 620 | $pattern = $this->replacePlaceholders($collection->origin); 621 | $matched = \preg_match( 622 | '#^' . $pattern . '$#', 623 | $origin, 624 | $matches 625 | ); 626 | if ($matched) { 627 | $this->setMatchedOrigin($matches[0]); 628 | unset($matches[0]); 629 | $this->setMatchedOriginArguments(\array_values($matches)); 630 | return $collection; 631 | } 632 | } 633 | return null; 634 | } 635 | 636 | protected function matchRoute( 637 | string $method, 638 | RouteCollection $collection, 639 | string $path 640 | ) : ?Route { 641 | $routes = $collection->routes; 642 | if (empty($routes[$method])) { 643 | return null; 644 | } 645 | foreach ($routes[$method] as $route) { 646 | $pattern = $this->replacePlaceholders($route->getPath()); 647 | $matched = \preg_match( 648 | '#^' . $pattern . '$#', 649 | $path, 650 | $matches 651 | ); 652 | if ($matched) { 653 | unset($matches[0]); 654 | $this->setMatchedPathArguments(\array_values($matches)); 655 | $route->setActionArguments($this->getMatchedPathArguments()); 656 | return $route; 657 | } 658 | } 659 | return null; 660 | } 661 | 662 | /** 663 | * Enable/disable the feature of auto-detect and show HTTP allowed methods 664 | * via the Allow header when the Request has the OPTIONS method. 665 | * 666 | * @param bool $enabled true to enable, false to disable 667 | * 668 | * @see Method::OPTIONS 669 | * @see ResponseHeader::ALLOW 670 | * 671 | * @return static 672 | */ 673 | public function setAutoOptions(bool $enabled = true) : static 674 | { 675 | $this->autoOptions = $enabled; 676 | return $this; 677 | } 678 | 679 | /** 680 | * Tells if auto options is enabled. 681 | * 682 | * @see Router::setAutoOptions() 683 | * 684 | * @return bool 685 | */ 686 | #[Pure] 687 | public function isAutoOptions() : bool 688 | { 689 | return $this->autoOptions; 690 | } 691 | 692 | /** 693 | * Enable/disable the feature of auto-detect and show HTTP allowed methods 694 | * via the Allow header when a route with the requested method does not exist. 695 | * 696 | * A response with code 405 "Method Not Allowed" will trigger. 697 | * 698 | * @param bool $enabled true to enable, false to disable 699 | * 700 | * @see Status::METHOD_NOT_ALLOWED 701 | * @see ResponseHeader::ALLOW 702 | * 703 | * @return static 704 | */ 705 | public function setAutoMethods(bool $enabled = true) : static 706 | { 707 | $this->autoMethods = $enabled; 708 | return $this; 709 | } 710 | 711 | /** 712 | * Tells if auto methods is enabled. 713 | * 714 | * @see Router::setAutoMethods() 715 | * 716 | * @return bool 717 | */ 718 | #[Pure] 719 | public function isAutoMethods() : bool 720 | { 721 | return $this->autoMethods; 722 | } 723 | 724 | protected function getRouteWithAllowHeader(RouteCollection $collection, int $code) : ?Route 725 | { 726 | $allowed = $this->getAllowedMethods($collection); 727 | $response = $this->response; 728 | return empty($allowed) 729 | ? null 730 | : (new Route( 731 | $this, 732 | $this->getMatchedOrigin(), 733 | $this->getMatchedPath(), 734 | static function () use ($allowed, $code, $response) : void { 735 | $response->setStatus($code); 736 | $response->setHeader('Allow', \implode(', ', $allowed)); 737 | } 738 | ))->setName('auto-allow-' . $code); 739 | } 740 | 741 | /** 742 | * @param RouteCollection $collection 743 | * 744 | * @return array 745 | */ 746 | protected function getAllowedMethods(RouteCollection $collection) : array 747 | { 748 | $allowed = []; 749 | foreach ($collection->routes as $method => $routes) { 750 | foreach ($routes as $route) { 751 | $pattern = $this->replacePlaceholders($route->getPath()); 752 | $matched = \preg_match( 753 | '#^' . $pattern . '$#', 754 | $this->getMatchedPath() 755 | ); 756 | if ($matched) { 757 | $allowed[] = $method; 758 | continue 2; 759 | } 760 | } 761 | } 762 | if ($allowed) { 763 | if (\in_array('GET', $allowed, true)) { 764 | $allowed[] = 'HEAD'; 765 | } 766 | if ($this->isAutoOptions()) { 767 | $allowed[] = 'OPTIONS'; 768 | } 769 | $allowed = \array_unique($allowed); 770 | \sort($allowed); 771 | } 772 | return $allowed; 773 | } 774 | 775 | /** 776 | * Gets a named route. 777 | * 778 | * @param string $name 779 | * 780 | * @throws RuntimeException if named route not found 781 | * 782 | * @return Route 783 | */ 784 | public function getNamedRoute(string $name) : Route 785 | { 786 | foreach ($this->getCollections() as $collection) { 787 | foreach ($collection->routes as $routes) { 788 | foreach ($routes as $route) { 789 | if ($route->getName() === $name) { 790 | return $route; 791 | } 792 | } 793 | } 794 | } 795 | throw new RuntimeException('Named route not found: ' . $name); 796 | } 797 | 798 | /** 799 | * Tells if it has a named route. 800 | * 801 | * @param string $name 802 | * 803 | * @return bool 804 | */ 805 | #[Pure] 806 | public function hasNamedRoute( 807 | string $name 808 | ) : bool { 809 | foreach ($this->getCollections() as $collection) { 810 | foreach ($collection->routes as $routes) { 811 | foreach ($routes as $route) { 812 | if ($route->getName() === $name) { 813 | return true; 814 | } 815 | } 816 | } 817 | } 818 | return false; 819 | } 820 | 821 | /** 822 | * Gets all routes, except the not found. 823 | * 824 | * @return array The HTTP Methods as keys and its Routes as 825 | * values 826 | */ 827 | #[Pure] 828 | public function getRoutes() : array 829 | { 830 | $result = []; 831 | foreach ($this->getCollections() as $collection) { 832 | foreach ($collection->routes as $method => $routes) { 833 | foreach ($routes as $route) { 834 | $result[$method][] = $route; 835 | } 836 | } 837 | } 838 | return $result; 839 | } 840 | 841 | /** 842 | * @return array 843 | */ 844 | public function jsonSerialize() : array 845 | { 846 | return [ 847 | 'matched' => $this->getMatchedRoute(), 848 | 'collections' => $this->getCollections(), 849 | 'isAutoMethods' => $this->isAutoMethods(), 850 | 'isAutoOptions' => $this->isAutoOptions(), 851 | 'placeholders' => $this->getPlaceholders(), 852 | ]; 853 | } 854 | 855 | public function setDebugCollector(RoutingCollector $debugCollector) : static 856 | { 857 | $this->debugCollector = $debugCollector; 858 | $this->debugCollector->setRouter($this); 859 | return $this; 860 | } 861 | 862 | public function getDebugCollector() : ?RoutingCollector 863 | { 864 | return $this->debugCollector ?? null; 865 | } 866 | } 867 | -------------------------------------------------------------------------------- /src/RoutingException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Routing; 11 | 12 | /** 13 | * Class RoutingException. 14 | * 15 | * @package routing 16 | */ 17 | class RoutingException extends \RuntimeException 18 | { 19 | } 20 | --------------------------------------------------------------------------------