├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── bin ├── autoroute-create.php └── autoroute-dump.php ├── composer.json ├── phpstan.neon ├── phpunit.php ├── phpunit.xml.dist ├── resources └── templates │ └── action.tpl ├── src ├── Action.php ├── Actions.php ├── AutoRoute.php ├── Config.php ├── Creator.php ├── Dumper.php ├── Exception │ ├── Exception.php │ ├── InvalidArgument.php │ ├── InvalidNamespace.php │ ├── MethodNotAllowed.php │ └── NotFound.php ├── Filter.php ├── Generator.php ├── Helper.php ├── Logger.php ├── Reflector.php ├── Reverse.php ├── Reverser.php ├── Route.php └── Router.php └── tests ├── CreatorTest.php ├── DumperTest.php ├── GeneratorTest.php ├── HelperTest.php ├── Http ├── Admin │ ├── Dashboard │ │ └── GetAdminDashboard.php │ └── Empty │ │ └── .gitkeep ├── FooItem │ ├── Add │ │ ├── GetFooItemAdd.php │ │ └── HeadFooItemAdd.php │ ├── DeleteFooItem.php │ ├── Edit │ │ └── GetFooItemEdit.php │ ├── Extras │ │ └── GetFooItemExtras.php │ ├── FooItem.php │ ├── GetFooItem.php │ ├── HeadFooItem.php │ ├── PatchFooItem.php │ ├── PostFooItem.php │ └── Variadic │ │ └── GetFooItemVariadic.php ├── FooItems │ ├── Archive │ │ └── GetFooItemsArchive.php │ └── GetFooItems.php ├── Get.php ├── Getoptional.php ├── Getrequired.php ├── Getvariadic.php └── Repo │ ├── GetRepo.php │ └── Issue │ ├── Comment │ ├── Add │ │ └── GetRepoIssueCommentAdd.php │ └── GetRepoIssueComment.php │ └── GetRepoIssue.php ├── HttpIgnore ├── Admin │ └── Dashboard │ │ └── GetAdminDashboard.php ├── FooItem │ ├── Add │ │ └── GetFooItemAdd.php │ ├── DeleteFooItem.php │ ├── Edit │ │ └── GetFooItemEdit.php │ ├── Extras │ │ └── GetFooItemExtras.php │ ├── FooItem.php │ ├── GetFooItem.php │ ├── PatchFooItem.php │ ├── PostFooItem.php │ └── Variadic │ │ └── GetFooItemVariadic.php ├── FooItems │ ├── Archive │ │ └── GetFooItemsArchive.php │ └── GetFooItems.php ├── Get.php └── Repo │ ├── GetRepo.php │ └── Issue │ ├── Comment │ ├── Add │ │ └── GetRepoIssueCommentAdd.php │ └── GetRepoIssueComment.php │ └── GetRepoIssue.php ├── HttpSuffix ├── Admin │ └── Dashboard │ │ ├── GetAdminDashboardAction.php │ │ └── GetAdminDashboardResponder.php ├── FooItem │ ├── Add │ │ └── GetFooItemAddAction.php │ ├── DeleteFooItemAction.php │ ├── Edit │ │ └── GetFooItemEditAction.php │ ├── Extras │ │ └── GetFooItemExtrasAction.php │ ├── FooItemAction.php │ ├── GetFooItemAction.php │ ├── PatchFooItemAction.php │ ├── PostFooItemAction.php │ └── Variadic │ │ └── GetFooItemVariadicAction.php ├── FooItems │ ├── Archive │ │ └── GetFooItemsArchiveAction.php │ └── GetFooItemsAction.php ├── GetAction.php └── Repo │ ├── GetRepoAction.php │ └── Issue │ ├── Comment │ ├── Add │ │ └── GetRepoIssueCommentAddAction.php │ └── GetRepoIssueCommentAction.php │ └── GetRepoIssueAction.php ├── HttpValued ├── Admin │ └── Dashboard │ │ └── GetAdminDashboard.php ├── FooItem │ ├── Add │ │ ├── GetFooItemAdd.php │ │ └── HeadFooItemAdd.php │ ├── DeleteFooItem.php │ ├── Edit │ │ └── GetFooItemEdit.php │ ├── Extras │ │ └── GetFooItemExtras.php │ ├── FooItem.php │ ├── GetFooItem.php │ ├── HeadFooItem.php │ ├── PatchFooItem.php │ ├── PostFooItem.php │ └── Variadic │ │ └── GetFooItemVariadic.php ├── FooItems │ ├── Archive │ │ └── GetFooItemsArchive.php │ └── GetFooItems.php ├── Get.php └── Repo │ ├── GetRepo.php │ └── Issue │ ├── Comment │ ├── Add │ │ └── GetRepoIssueCommentAdd.php │ └── GetRepoIssueComment.php │ └── GetRepoIssue.php ├── IgnoreTest.php ├── LoggerTest.php ├── ReflectorTest.php ├── RouteTest.php ├── RouterTest.php ├── SuffixTest.php ├── Value ├── Id.php ├── OwnerRepo.php └── Ymd.php └── ValuedTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor/ 3 | /tmp/ 4 | /.phpunit.result.cache 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 2.1.1 4 | 5 | - Improve capitalization logic on word-separated segments in Creator 6 | 7 | - Improve json-encodability of Route::$exception 8 | 9 | 10 | ## 2.1.0 11 | 12 | - Added Route::asArray() 13 | 14 | - The Route now carries the Router messages that led to the route 15 | 16 | - Added invokable helper class for route generation 17 | 18 | - Added support for root-level catchalls with params 19 | 20 | - Route is now JsonSerializable 21 | 22 | 23 | ## 2.0.0 24 | 25 | Initial release. 26 | 27 | ### Upgrading from 1.x to 2.0.0 28 | 29 | #### Configuration 30 | 31 | In 1.x, the _AutoRoute_ options were configured with setters ... 32 | 33 | ```php 34 | $autoRoute = new AutoRoute( 35 | 'Project\Http', 36 | dirname(__DIR__) . '/src/Project/Http/' 37 | ); 38 | 39 | $autoRoute->setBaseUrl('/api'); 40 | $autoRoute->setIgnoreParams(1); 41 | $autoRoute->setMethod('exec'); 42 | $autoRoute->setSuffix('Action'); 43 | $autoRoute->setWordSeparator('_'); 44 | ``` 45 | ... but in 2.x they are configured with named constructor parameters: 46 | 47 | ```php 48 | $autoRoute = new AutoRoute( 49 | namespace: 'Project\Http', 50 | directory: dirname(__DIR__) . '/src/Project/Http/', 51 | baseUrl: '/api', 52 | ignoreParams: 1, 53 | method: 'exec', 54 | suffix: 'Action', 55 | wordSeparator: '_', 56 | ); 57 | ``` 58 | 59 | #### Retrieving Objects 60 | 61 | The methods to retrieve _AutoRoute_ objects have been renamed from `new*()` to 62 | `get*()`: 63 | 64 | ```php 65 | // 1.x // 2.x 66 | $autoRoute->newRouter(); $autoRoute->getRouter(); 67 | $autoRoute->newGenerator(); $autoRoute->getGenerator(); 68 | $autoRoute->newDumper(); $autoRoute->getDumper(); 69 | $autoRoute->newCreator(); $autoRoute->getCreator(); 70 | ``` 71 | 72 | #### Route Properties 73 | 74 | The 1.x _Route_ property `$params` has been renamed to `$arguments`. 75 | 76 | ```php 77 | // 1.x 78 | $response = call_user_func([$action, $route->method], ...$route->params); 79 | 80 | // 2.x 81 | $response = call_user_func([$action, $route->method], ...$route->arguments); 82 | ``` 83 | 84 | #### Error Handling 85 | 86 | In 1.x, the _Router_ would throw exceptions on errors: 87 | 88 | ```php 89 | try { 90 | $route = $router->route($request->method, $request->url[PHP_URL_PATH]); 91 | 92 | } catch (\AutoRoute\InvalidNamespace $e) { 93 | // 400 Bad Request 94 | 95 | } catch (\AutoRoute\InvalidArgument $e) { 96 | // 400 Bad Request 97 | 98 | } catch (\AutoRoute\NotFound $e) { 99 | // 404 Not Found 100 | 101 | } catch (\AutoRoute\MethodNotAllowed $e) { 102 | // 405 Method Not Allowed 103 | 104 | } 105 | ``` 106 | 107 | In 2.x, the _Router_ always returns a _Route_, and captures exceptions into the 108 | returned _Route_ property `$error`. Examine that property instead of catching 109 | exceptions: 110 | 111 | ```php 112 | use AutoRoute\Exception; 113 | 114 | switch ($route->error) { 115 | case null: 116 | // no errors! create the action class instance 117 | // and call it with the method and arguments. 118 | $action = Factory::newInstance($route->class); 119 | $method = $route->method; 120 | $arguments = $route->arguments; 121 | $response = $action->$method(...$arguments); 122 | break; 123 | 124 | case Exception\InvalidArgument::CLASS: 125 | $response = /* 400 Bad Request */; 126 | break; 127 | 128 | case Exception\NotFound::CLASS: 129 | $response = /* 404 Not Found */; 130 | break; 131 | 132 | case Exception\MethodNotAllowed::CLASS: 133 | $response = /* 405 Not Allowed */; 134 | /* N.b.: Examine $route->headers to find the 'allowed' methods for the 135 | resource, if any. */ 136 | break; 137 | 138 | default: 139 | $response = /* 500 Server Error */; 140 | break; 141 | } 142 | ``` 143 | 144 | Note that _InvalidNamespace_ has been combined with _InvalidArgument_ and is no 145 | longer a separate exception type. Likewise, the new `$headers` property contains 146 | suggested headers to return with the response. 147 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We are happy to review any contributions you want to make. 4 | 5 | The time between submitting a contribution and its review may be extensive; do 6 | not be discouraged if there is not immediate feedback. 7 | 8 | Thanks! 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2022, Paul M. Jones 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutoRoute 2 | 3 | AutoRoute automatically maps incoming HTTP requests (by verb and path) to PHP 4 | action classes in a specified namespace, reflecting on a specified action 5 | method within that class to determine the dynamic URL argument values. Those 6 | parameters may be typical scalar values (int, float, string, bool), or arrays, 7 | or even [value objects](#value-objects-as-action-parameters) of your own 8 | creation. AutoRoute also helps you generate URL paths based on action class 9 | names, and checks the dynamic argument typehints for you automatically. 10 | 11 | Install AutoRoute using Composer: 12 | 13 | ``` 14 | composer require pmjones/auto-route ^2.0 15 | ``` 16 | 17 | AutoRoute is low-maintenance. Merely adding a class to your source code, in the 18 | recognized namespace and with the recognized action method name, automatically 19 | makes it available as a route. No more managing a routes file to keep it in 20 | sync with your action classes! 21 | 22 | AutoRoute is fast. In fact, it is [roughly 2x faster than FastRoute][benchmark] 23 | in common cases -- even when FastRoute is using cached route definitions. 24 | 25 | [benchmark]: https://github.com/pmjones/AutoRoute-benchmark 26 | 27 | > **Note:** 28 | > 29 | > When comparing alternatives, please consider AutoRoute as being in the same 30 | > category as 31 | > [AltoRouter](https://github.com/dannyvankooten/AltoRouter), 32 | > [FastRoute](https://github.com/nikic/FastRoute), 33 | > [Klein](https://github.com/klein/klein.php), 34 | > etc., and not of 35 | > [Aura](https://github.com/auraphp/Aura.Router), 36 | > [Laminas](https://github.com/laminas/laminas-router), 37 | > [Laravel](https://github.com/illuminate/routing), 38 | > [Symfony](https://github.com/symfony/Routing), 39 | > etc. 40 | 41 | **Contents** 42 | 43 | - [Motivation](#motivation) 44 | - [Examples](#examples) 45 | - [How It Works](#how-it-works) 46 | - [Usage](#usage) 47 | - [Generating Route Paths](#generating-route-paths) 48 | - [Custom Configuration](#custom-configuration) 49 | - [Dumping All Routes](#dumping-all-routes) 50 | - [Creating Classes From Routes](#creating-classes-from-routes) 51 | - [Questions and Recipes](#questions-and-recipes) 52 | 53 | 54 | ## Motivation 55 | 56 | Regular-expression (regex) routers generally duplicate important information 57 | that can be found by reflection instead. If you change the action method 58 | parameters targeted by a route, you need to change the route regex itself as 59 | well. As such, regex router usage may be considered a violation of the DRY 60 | principle. For systems with only a few routes, maintaining a routes file as 61 | duplicated information is not such a chore. But for systems with a hundred or 62 | more routes, keeping the routes in sync with their target action classes and 63 | methods can be onerous. 64 | 65 | Similarly, annotation-based routers place routing instructions in comments, 66 | often duplicating dynamic parameters that are already present in explicit 67 | method signatures. 68 | 69 | As an alternative to regex and annotation-based routers, this router 70 | implementation eliminates the need for route definitions by automatically 71 | mapping the HTTP action class hierarchy to the HTTP method verb and URL path, 72 | reflecting on typehinted action method parameters to determine the dynamic 73 | portions of the URL. It presumes that the action class names conform to a 74 | well-defined convention, and that the action method parameters indicate the 75 | dynamic portions of the URL. This makes the implementation both flexible and 76 | relatively maintenance-free. 77 | 78 | ## Examples 79 | 80 | Given a base namespace of `Project\Http` and a base url of `/`, this request ... 81 | 82 | GET /photos 83 | 84 | ... auto-routes to the class `Project\Http\Photos\GetPhotos`. 85 | 86 | Likewise, this request ... 87 | 88 | POST /photo 89 | 90 | ... auto-routes to the class `Project\Http\Photo\PostPhoto`. 91 | 92 | Given an action class with method parameters, such as this ... 93 | 94 | ```php 95 | namespace Project\Http\Photo; 96 | 97 | class GetPhoto 98 | { 99 | public function __invoke(int $photoId) 100 | { 101 | // ... 102 | } 103 | } 104 | ``` 105 | 106 | ... the following request will route to it ... 107 | 108 | GET /photo/1 109 | 110 | ... recognizing that `1` should be the value of `$photoId`. 111 | 112 | AutoRoute supports static "tail" parameters on the URL. If the URL ends in a 113 | path segment that matches the tail portion of a class name, 114 | and the action class method has the same number and type of parameters as its 115 | parent or grandparent class, it will route to that class name. For example, 116 | given an action class with method parameters such as this ... 117 | 118 | ```php 119 | namespace Project\Http\Photo\Edit; 120 | 121 | class GetPhotoEdit // parent: GetPhoto 122 | { 123 | public function __invoke(int $photoId) 124 | { 125 | // ... 126 | } 127 | } 128 | ``` 129 | 130 | ... the following request will route to it: 131 | 132 | GET /photo/1/edit 133 | 134 | Finally, a request for the root URL ... 135 | 136 | GET / 137 | 138 | ... auto-routes to the class `Project\Http\Get`. 139 | 140 | > **Tip:** 141 | > 142 | > Any HEAD request will auto-route to an explicit `Project\Http\...\Head*` class, 143 | > if one exists. If an explicit `Head` class does not exist, the request will 144 | > implicitly be auto-routed to the matching `Project\Http\...\Get*` class, if one 145 | > exists. 146 | 147 | ## How It Works 148 | 149 | ### Class File Naming 150 | 151 | Action class files are presumed to be named according to PSR-4 standards; 152 | further: 153 | 154 | 1. The class name starts with the HTTP verb it responds to; 155 | 156 | 2. Followed by the concatenated names of preceding subnamespaces; 157 | 158 | 3. Ending in `.php`. 159 | 160 | Thus, given a base namespace of `Project\Http`, the class `Project\Http\Photo\PostPhoto` 161 | will be the action for `POST /photo[/*]`. 162 | 163 | Likewise, `Project\Http\Photos\GetPhotos` will be the action class for `GET /photos[/*]`. 164 | 165 | And `Project\Http\Photo\Edit\GetPhotoEdit` will be the action class for `GET /photo[/*]/edit`. 166 | 167 | An explicit `Project\Http\Photos\HeadPhotos` will be the action class for 168 | `HEAD /photos[/*]`. If the `HeadPhotos` class does not exist, the action class 169 | is inferred to be `Project\Http\Photos\HeadPhotos` instead. 170 | 171 | Finally, at the URL root path, `Project\Http\Get` will be the action class for `GET /`. 172 | 173 | ### Dynamic Parameters 174 | 175 | The action method parameter typehints are honored by the _Router_. For example, 176 | the following action ... 177 | 178 | ```php 179 | namespace Project\Http\Photos\Archive; 180 | 181 | class GetPhotosArchive 182 | { 183 | public function __invoke(int $year = null, int $month = null) 184 | { 185 | // ... 186 | } 187 | } 188 | ``` 189 | 190 | ... will respond to the following: 191 | 192 | GET /photos/archive 193 | GET /photos/archive/1970 194 | GET /photos/archive/1970/08 195 | 196 | ... but not to the following ... 197 | 198 | GET /photos/archive/z 199 | GET /photos/archive/1970/z 200 | 201 | ... because `z` is not recognized as an integer. (More finely-tuned validations 202 | of the method parameters must be accomplished in the action method itself, or 203 | more preferably in the domain logic, and cannot be intuited by the _Router_.) 204 | 205 | The _Router_ can recognize typehints of `int`, `float`, `string`, `bool`, and 206 | `array`. 207 | 208 | For `bool`, the _Router_ will case-insensitively cast these URL segment values 209 | to `true`: `1, t, true, y, yes`. Similarly, it will case-insensitively cast 210 | these URL segment values to `false`: `0, f, false, n, no`. 211 | 212 | For `array`, the _Router_ will use `str_getcsv()` on the URL segment value to 213 | generate an array. E.g., an array typehint for a segment value of `a,b,c` will 214 | receive `['a', 'b', 'c']`. 215 | 216 | Finally, trailing variadic parameters are also honored by the _Router_. Given an 217 | action method like the following ... 218 | 219 | ```php 220 | namespace Project\Http\Photos\ByTag; 221 | 222 | class GetPhotosByTag 223 | { 224 | public function __invoke(string $tag, string ...$tags) 225 | { 226 | // ... 227 | } 228 | } 229 | ``` 230 | 231 | ... the _Router_ will honor this request ... 232 | 233 | GET /photos/by-tag/foo/bar/baz/ 234 | 235 | ... and recognize the method parameters as `__invoke('foo', 'bar', 'baz')`. 236 | 237 | ### Extended Example 238 | 239 | By way of an extended example, these classes would be routed to by these URLs: 240 | 241 | ``` 242 | App/ 243 | Http/ 244 | Get.php GET / (root) 245 | Photos/ 246 | GetPhotos.php GET /photos (browse/index) 247 | Photo/ 248 | DeletePhoto.php DELETE /photo/1 (delete) 249 | GetPhoto.php GET /photo/1 (read) 250 | PatchPhoto.php PATCH /photo/1 (update) 251 | PostPhoto.php POST /photo (create) 252 | Add/ 253 | GetPhotoAdd.php GET /photo/add (form for creating) 254 | Edit/ 255 | GetPhotoEdit.php GET /photo/1/edit (form for updating) 256 | ``` 257 | 258 | ### HEAD Requests 259 | 260 | [RFC 2616](http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.1) 261 | requires that "methods GET and HEAD **must** be supported by all general-purpose 262 | servers". 263 | 264 | As such, AutoRoute will automatically fall back to a `Get*` action class if a 265 | relevant `Head*` action class is not found. This keeps you from having to create 266 | a `Head*` class for every possible `Get*` action. 267 | 268 | However, you may still define any `Head*` action class you like, and AutoRoute 269 | will use it. 270 | 271 | ## Usage 272 | 273 | Instantiate the AutoRoute container class with the top-level HTTP action 274 | namespace and the directory path to classes in that namespace: 275 | 276 | ```php 277 | use AutoRoute\AutoRoute; 278 | 279 | $autoRoute = new AutoRoute( 280 | 'Project\Http', 281 | dirname(__DIR__) . '/src/Project/Http/' 282 | ); 283 | ``` 284 | 285 | You may use named constructor parameters if you wish: 286 | 287 | ```php 288 | use AutoRoute\AutoRoute; 289 | 290 | $autoRoute = new AutoRoute( 291 | namespace: 'Project\Http', 292 | directory: dirname(__DIR__) . '/src/Project/Http/', 293 | ); 294 | ``` 295 | 296 | Then pull the _Router_ out of the container, and call `route()` with the HTTP 297 | request method verb and the path string to get back a _Route_: 298 | 299 | ```php 300 | $router = $autoRoute->getRouter(); 301 | $route = $router->route($request->method->name, $request->url->path); 302 | ``` 303 | 304 | You can then dispatch to the action class method using the returned _Route_ 305 | information, or handle errors: 306 | 307 | ```php 308 | use AutoRoute\Exception; 309 | 310 | switch ($route->error) { 311 | case null: 312 | // no errors! create the action class instance 313 | // ... and call it with the method and arguments. 314 | $action = Factory::newInstance($route->class); 315 | $method = $route->method; 316 | $arguments = $route->arguments; 317 | $response = $action->$method(...$arguments); 318 | break; 319 | 320 | case Exception\InvalidArgument::CLASS: 321 | $response = /* 400 Bad Request */; 322 | break; 323 | 324 | case Exception\NotFound::CLASS: 325 | $response = /* 404 Not Found */; 326 | break; 327 | 328 | case Exception\MethodNotAllowed::CLASS: 329 | $response = /* 405 Not Allowed */; 330 | /* 331 | N.b.: Examine $route->headers to find the 'allowed' methods for the 332 | resource, if any. 333 | */ 334 | break; 335 | 336 | default: 337 | $response = /* 500 Server Error */; 338 | break; 339 | } 340 | ``` 341 | 342 | 343 | ## Debugging 344 | 345 | To see how the _Router_ gets where it does, examine the _Route_ `$messages` 346 | property: 347 | 348 | ```php 349 | $route = $router->route($request->method->name, $request->url->path); 350 | print_r($route->messages); 351 | ``` 352 | 353 | In addition, you may inject a custom PSR-3 _LoggerInterface_ implementation 354 | factory as part of [custom configuration](#custom-configuration). 355 | 356 | ## Generating Route Paths 357 | 358 | Using the AutoRoute container, pull out the _Generator_: 359 | 360 | ```php 361 | $generator = $autoRoute->getGenerator(); 362 | ``` 363 | 364 | Then call the `generate()` method with the action class name, along with any 365 | action method parameters as variadic arguments: 366 | 367 | ```php 368 | use Project\Http\Photo\Edit\GetPhotoEdit; 369 | use Project\Http\Photos\ByTag\GetPhotosByTag; 370 | 371 | $path = $generator->generate(GetPhotoEdit::CLASS, 1); 372 | // /photo/1/edit 373 | 374 | $path = $generator->generate(GetPhotosByTag::CLASS, 'foo', 'bar', 'baz'); 375 | // /photos/by-tag/foo/bar/baz 376 | ``` 377 | 378 | > **Tip**: 379 | > 380 | > Using the action class name for the route name means that all routes in 381 | > AutoRoute are automatically named routes. 382 | 383 | The _Generator_ will automatically check the argument values against the action 384 | method signature to make sure the values will be recognized by the _Router_. 385 | This means that you cannot (or at least, should not!) be able to generate a 386 | path that the _Router_ will not recognize. 387 | 388 | ## Custom Configuration 389 | 390 | You may set these named constructor parameters at AutoRoute instantiation time 391 | to configure its behavior. 392 | 393 | ### `baseUrl` 394 | 395 | You may specify a base URL (i.e., a URL path prefix) using the following 396 | named constructor parameter: 397 | 398 | ```php 399 | $autoRoute = new AutoRoute( 400 | // ... 401 | baseUrl: '/api', 402 | ); 403 | ``` 404 | 405 | The _Router_ will ignore the base URL when determining the target action class 406 | for the route, and the _Generator_ will prefix all paths with the base URL. 407 | 408 | 409 | ### `ignore` 410 | 411 | Some UI systems may use a shared Request object, in which case it is easy to 412 | inject the Request into the action constructor. However, other systems may 413 | not have access to a shared Request object, or may be using a Request that is 414 | fully-formed only at the moment the Action is called, so it must be passed in 415 | some way other than via the constructor. 416 | 417 | Typically, these kinds of parameters are passed at the moment the action is 418 | called, which means they must be part of the action method signature. However, 419 | AutoRoute will see that parameter and incorrectly interpret it as a dynamic 420 | segment; for example: 421 | 422 | ```php 423 | namespace Project\Http\Photo; 424 | 425 | use SapiRequest; 426 | 427 | class PatchPhoto 428 | { 429 | public function __invoke(SapiRequest $request, int $id) 430 | { 431 | // ... 432 | } 433 | } 434 | ``` 435 | 436 | To remedy this, AutoRoute can skip over any number of leading parameters 437 | on the action method. To do so, set the number of parameters to ignore using the 438 | following named constructor parameter: 439 | 440 | ```php 441 | $autoRoute = new AutoRoute( 442 | // ... 443 | ignoreParams: 1, 444 | ); 445 | ``` 446 | 447 | ... and then any new _Router_ and _Generator_ will ignore the first parameter. 448 | 449 | Note that you will need to pass that first parameter yourself when you invoke 450 | the action: 451 | 452 | ```php 453 | // determine the route 454 | $route = $router->route($request->method, $request->url[PHP_URL_PATH]); 455 | 456 | // create the action object 457 | $action = Factory::newInstance($route->class); 458 | 459 | // pass the request first, then any route params 460 | $response = call_user_func([$action, $route->method], $request, ...$route->arguments); 461 | ``` 462 | 463 | ### `loggerFactory` 464 | 465 | To inject a custom PSR-3 Logger instance into the _Router_, use the following 466 | named constructor parameter: 467 | 468 | ```php 469 | $autoRoute = new AutoRoute( 470 | // ... 471 | loggerFactory: function () { 472 | // return a \Psr\Log\LoggerInterface implementation 473 | }, 474 | ); 475 | ``` 476 | 477 | ### `method` 478 | 479 | If you use an action method name other than `__invoke()`, such as `exec()` or 480 | `run()`, you can tell AutoRoute to reflect on its parameters instead using the 481 | following named constructor parameter: 482 | 483 | ```php 484 | $autoRoute = new AutoRoute( 485 | // ... 486 | method: 'exec', 487 | ); 488 | ``` 489 | 490 | The _Router_ and _Generator_ will now examine the `exec()` method to determine 491 | the dynamic segments of the URL path. 492 | 493 | ### `suffix` 494 | 495 | If your code base gives all action class names the same suffix, such as 496 | "Action", you can tell AutoRoute to disregard that suffix using the following 497 | named constructor parameter: 498 | 499 | ```php 500 | $autoRoute = new AutoRoute( 501 | // ... 502 | suffix: 'Action', 503 | ); 504 | 505 | ``` 506 | 507 | The _Router_ and _Generator_ will now ignore the suffix portion of the class 508 | name. 509 | 510 | ### `wordSeparator` 511 | 512 | By default, the _Router_ and _Generator_ will inflect static URL path segments 513 | from `foo-bar` to `FooBar`, using the dash as a word separator. If you want to 514 | use a different word separator, such as an underscore, you may do using the 515 | following named constructor parameter: 516 | 517 | ```php 518 | $autoRoute = new AutoRoute( 519 | // ... 520 | wordSeparator: '_', 521 | ); 522 | ``` 523 | 524 | This will cause the _Router_ and _Generator_ to inflect from `foo_bar` to 525 | `FooBar` (and back again). 526 | 527 | 528 | ## Dumping All Routes 529 | 530 | You can dump a list of all recognized routes, and their target action classes, 531 | using the `bin/autoroute-dump.php` command line tool. Pass the base HTTP action 532 | namespace, and the directory where the action classes are stored: 533 | 534 | ``` 535 | $ php bin/autoroute-dump.php Project\\Http ./src/Http 536 | ``` 537 | 538 | The output will look something like this: 539 | 540 | ``` 541 | GET / 542 | Project\Http\Get 543 | POST /photo 544 | Project\Http\Photo\PostPhoto 545 | GET /photo/add 546 | Project\Http\Photo\Add\GetPhotoAdd 547 | DELETE /photo/{int:id} 548 | Project\Http\Photo\DeletePhoto 549 | GET /photo/{int:id} 550 | Project\Http\Photo\GetPhoto 551 | PATCH /photo/{int:id} 552 | Project\Http\Photo\PatchPhoto 553 | GET /photo/{int:id}/edit 554 | Project\Http\Photo\Edit\GetPhotoEdit 555 | GET /photos/archive[/{int:year}][/{int:month}][/{int:day}] 556 | Project\Http\Photos\Archive\GetPhotosArchive 557 | GET /photos[/{int:page}] 558 | Project\Http\Photos\GetPhotos 559 | ``` 560 | 561 | You can specify alternative configurations with these command line options: 562 | 563 | - `--base-url=` to set the base URL 564 | - `--ignore-params=` to ignore a number of leading method parameters 565 | - `--method=` to set the action class method name 566 | - `--suffix=` to note a standard action class suffix 567 | - `--word-separator=` to specify an alternative word separator 568 | 569 | 570 | ## Creating Classes From Routes 571 | 572 | AutoRoute provides minimalist support for creating class files based on a 573 | route verb and path, using a template. 574 | 575 | To do so, invoke `autoroute-create.php` with the base namespace, the directory 576 | for that namespace, the HTTP verb, and the URL path with parameter token 577 | placeholders. 578 | 579 | For example, the following command ... 580 | 581 | ``` 582 | $ php bin/autoroute-create.php Project\\Http ./src/Http GET /photo/{photoId} 583 | ``` 584 | 585 | ... will create this class file at `./src/Http/Photo/GetPhoto.php`: 586 | 587 | ```php 588 | namespace Project\Http\Photo; 589 | 590 | class GetPhoto 591 | { 592 | public function __invoke($photoId) 593 | { 594 | } 595 | } 596 | ``` 597 | 598 | The command will not overwrite existing files. 599 | 600 | You can specify alternative configurations with these command line options: 601 | 602 | - `--method=` to set the action class method name 603 | - `--suffix=` to note a standard action class suffix 604 | - `--template=` to specify the path to a custom template 605 | - `--word-separator=` to specify an alternative word separator 606 | 607 | The default class template file is `resources/templates/action.tpl`. If you 608 | decide to write a custom template of your own, the available string-replacement 609 | placeholders are: 610 | 611 | - `{NAMESPACE}` 612 | - `{CLASS}` 613 | - `{METHOD}` 614 | - `{PARAMETERS}` 615 | 616 | These names should be self-explanatory. 617 | 618 | > **Note:** 619 | > 620 | > Even with a custom template, you will almost certainly need to edit the new 621 | > file to add a constructor, typehints, default values, and so on. The file 622 | > creation functionality is necessarily minimalist, and cannot account for all 623 | > possible variability in your specific situation. 624 | 625 | 626 | ## Questions and Recipes 627 | 628 | ### Child Resources 629 | 630 | > N.b.: Deeply-nested child resources are currently considered a poor practice, 631 | > but they are common enough that they demand attention here. 632 | 633 | Deeply-nested child resources are supported, but their action class method 634 | parameters must conform to a "routine" signature, so that the _Router_ and 635 | _Generator_ can recognize which segments of the URL are dynamic and which are 636 | static. 637 | 638 | 1. A child resource action MUST have at least the same number and type of 639 | parameters as its "parent" resource action; OR, in the case of static tail 640 | parameter actions, exactly the same number and type or parameters as its 641 | "grandparent" resource action. (If there is no parent or grandparent resource 642 | action, then it need not have any parameters.) 643 | 644 | 2. A child resource action MAY add parameters after that, either as required or 645 | optional. 646 | 647 | 3. When the URL path includes any of the optional parameter segments, routing to 648 | further child resource actions beneath it will be terminated. 649 | 650 | > **Tip**: 651 | > 652 | > The above terms "parent" and "grandparent" are used in the URL path sense, 653 | > not in the class hierarchy sense. 654 | 655 | ```php 656 | /* GET /company/{companyId} # get an existing company */ 657 | namespace Project\Http\Company; 658 | 659 | class GetCompany // no parent resource 660 | { 661 | public function __invoke(int $companyId) 662 | { 663 | // ... 664 | } 665 | } 666 | 667 | /* POST /company # add a new company*/ 668 | class PostCompany // no parent resource 669 | { 670 | public function __invoke() 671 | { 672 | // ... 673 | } 674 | } 675 | 676 | /* PATCH /company/{companyId} # edit an existing company */ 677 | class PatchCompany // no parent resource 678 | { 679 | public function __invoke(int $companyId) 680 | { 681 | // ... 682 | } 683 | } 684 | 685 | /* GET /company/{companyId}/employee/{employeeNum} # get an existing company employee */ 686 | namespace Project\Http\Company\Employee; 687 | 688 | class GetCompanyEmployee // parent resource: GetCompany 689 | { 690 | public function __invoke(int $companyId, int $employeeNum) 691 | { 692 | // ... 693 | } 694 | } 695 | 696 | /* POST /company/{companyId}/employee # add a new company employee */ 697 | namespace Project\Http\Company\Employee; 698 | 699 | class PostCompanyEmployee // parent resource: PostCompany 700 | { 701 | public function __invoke(int $companyId) 702 | { 703 | // ... 704 | } 705 | } 706 | 707 | /* PATCH /company/{companyId}/employee/{employeeNum} # edit an existing company employee */ 708 | namespace Project\Http\Company\Employee; 709 | 710 | class PatchCompanyEmployee // parent resource: PatchCompany 711 | { 712 | public function __invoke(int $companyId, int $employeeNum) 713 | { 714 | // ... 715 | } 716 | } 717 | ``` 718 | 719 | ### Fine-Grained Input Validation 720 | 721 | Q: How do I specify something similar to the regex route `path('/foo/{id}')->token(['id' => '\d{4}'])` ? 722 | 723 | A: You don't. (However, see the topic on "Value Objects as Action Parameters", below.) 724 | 725 | Your domain does fine validation of the inputs, not your routing system (coarse 726 | validation only). AutoRoute, in casting the params to arguments, will set the 727 | type on the argument, which may raise an _InvalidArgument_ or _NotFound_ 728 | exception if the value cannot be typecast correctly. 729 | 730 | For example, in the action: 731 | 732 | ```php 733 | namespace Project\Http\Photos\Archive; 734 | 735 | use SapiResponse; 736 | 737 | class GetPhotosArchive 738 | { 739 | public function __invoke( 740 | int $year = null, 741 | int $month = null, 742 | int $day = null 743 | ) : SapiResponse 744 | { 745 | $payload = $this->domain->fetchAllBySpan($year, $month, $day); 746 | return $this->responder->response($payload); 747 | } 748 | } 749 | ``` 750 | 751 | Then, in the domain: 752 | 753 | ```php 754 | namespace Project\Domain; 755 | 756 | class PhotoService 757 | { 758 | public function fetchAllBySpan( 759 | ?int $year = null, 760 | ?int $month = null, 761 | ?int $day = null 762 | ) : Payload 763 | { 764 | $select = $this->atlas 765 | ->select(Photos::class) 766 | ->orderBy('year DESC', 'month DESC', 'day DESC'); 767 | 768 | if ($year !== null) { 769 | $select->where('year = ', $year); 770 | } 771 | 772 | if ($month !== null) { 773 | $select->where('month = ', $month); 774 | } 775 | 776 | if ($day !== null) { 777 | $select->where('day = ', $day); 778 | } 779 | 780 | $result = $select->fetchRecordSet(); 781 | if ($result->isEmpty()) { 782 | return Payload::notFound(); 783 | } 784 | 785 | return Payload::found($result); 786 | } 787 | } 788 | ``` 789 | 790 | ### Value Objects as Action Parameters 791 | 792 | Q: Can I use an object (instead of a scalar or array) as an action parameter? 793 | 794 | A: Yes, with some caveats. 795 | 796 | Although you cannot specify input validation in the routing itself, per se, you 797 | *can* specify a value object as parameter, and do validation within its 798 | constructor. These value objects may come from anywhere, including the Domain. 799 | 800 | For example, your underlying Application Service classes might need Domain 801 | value objects as inputs, with the action creating those value objects itself: 802 | 803 | ```php 804 | namespace Project\Http\Company; 805 | 806 | use Domain\Company\CompanyId; 807 | 808 | class GetCompany 809 | { 810 | // ... 811 | public function __invoke(int $companyId) 812 | { 813 | // ... 814 | $payload = $this->domain->fetchCompany( 815 | new CompanyId($companyId) 816 | ); 817 | // ... 818 | } 819 | } 820 | ``` 821 | 822 | The corresponding value object might look like this: 823 | 824 | ```php 825 | namespace Domain\Company; 826 | 827 | use Domain\ValueObject; 828 | 829 | class CompanyId extends ValueObject 830 | { 831 | public function __construct(protected int $companyId) 832 | { 833 | } 834 | } 835 | ``` 836 | 837 | To avoid the manual conversion of dynamic path segments to value objects, you 838 | may use the value object type itself as an action parameter, like so: 839 | 840 | ```php 841 | namespace Project\Http\Company; 842 | 843 | use Domain\Company\CompanyId; 844 | 845 | class GetCompany 846 | { 847 | // ... 848 | public function __invoke(CompanyId $companyId) 849 | { 850 | // ... 851 | $payload = $this->domain->fetchCompany($companyId); 852 | // ... 853 | } 854 | } 855 | ``` 856 | 857 | Given the HTTP request `GET /company/1`, the _Router_ will notice that the 858 | action parameter is of the _CompanyId_ type, and use the relevant segments of 859 | the URL path to build the _CompanyId_ argument. 860 | 861 | Further, you can attempt to validate and/or sanitize the value object arguments, 862 | throwing an exception on invalidation. For example: 863 | 864 | ```php 865 | namespace Domain\Photo; 866 | 867 | use Domain\Exception\InvalidValue; 868 | use Domain\ValueObject; 869 | 870 | class Year extends ValueObject 871 | { 872 | public function __construct(protected int $year) 873 | { 874 | if ($this->year < 0 || $this->year > 9999) { 875 | throw new InvalidValue("The year must be between 0000 and 9999."); 876 | } 877 | } 878 | } 879 | ``` 880 | 881 | It is up to you to examine `Route::$error` for these exceptions and send the 882 | appropriate HTTP response. 883 | 884 | Some additional notes: 885 | 886 | - You can use as many value object constructor parameters as you like; each 887 | parameter will capture one path segment, in order. 888 | 889 | - The path segments will be cast to the correct data types for you, per the 890 | value object constructor parameter typehints. 891 | 892 | - Using a class type as a value object parameter will not work correctly; use 893 | only scalars and arrays as value object parameter types. 894 | 895 | - Using optional or variadic parameters in a value object may not always work as 896 | intended. If your value objects have optional or variadic parameters, save 897 | those value objects for the terminating portions of URL paths. 898 | 899 | - You can combine value object parameters with scalar and array parameters in 900 | the action method signature. 901 | 902 | #### Generating Paths With Value Objects 903 | 904 | When generating a path for an action that uses value objects, you need to pass 905 | the individual arguments as they would appear in the URL, not as they appear 906 | when calling the action. Given the above _GetCompany_ action, you would not 907 | instantiate _CompanyId_; instead, you would pass the integer value argument. 908 | 909 | ```php 910 | // wrong: 911 | $path = $generator->generate(GetCompany::CLASS, new CompanyId(1)); 912 | 913 | // right: 914 | $path = $generator->generate(GetCompany::CLASS, 1); 915 | 916 | ``` 917 | 918 | #### Dumping Paths With Value Objects 919 | 920 | When you dump the routes via the _Dumper_, you will find that the dynamic 921 | segments associated with value objects are named for the value object 922 | constructor parameters. If you have multiple value objects in an action method 923 | signature, and those value objects use the same parameter names in their 924 | constructors, you will see repetition of those names in the dumped path. This 925 | does not cause any ill effect to AutoRoute itself, though it might be confusing 926 | when reviewing the path strings. 927 | 928 | ### Capturing Other Request Values 929 | 930 | Q: How to capture the hostname? Headers? Query parameters? Body? 931 | 932 | A: Read them from your Request object. 933 | 934 | For example, in the action: 935 | 936 | ```php 937 | namespace Project\Http\Foos; 938 | 939 | use SapiRequest; 940 | 941 | class GetFoos 942 | { 943 | public function __construct( 944 | SapiRequest $request, 945 | FooService $fooService 946 | ) { 947 | $this->request = $request; 948 | $this->fooService = $fooService; 949 | } 950 | 951 | public function __invoke(int $fooId) 952 | { 953 | $host = $this->request->headers['host'] ?? null; 954 | $bar = $this->request->get['bar'] ?? null; 955 | $body = json_decode($this->request->content, true) ?? []; 956 | 957 | $payload = $this->fooService->fetch($host, $foo, $body); 958 | // ... 959 | } 960 | } 961 | ``` 962 | 963 | Then, in the domain: 964 | 965 | ```php 966 | namespace Project\Domain; 967 | 968 | class FooService 969 | { 970 | public function fetch(int $fooId, string $host, string $bar, array $body) 971 | { 972 | // ... 973 | } 974 | } 975 | ``` 976 | -------------------------------------------------------------------------------- /bin/autoroute-create.php: -------------------------------------------------------------------------------- 1 | getCreator(); 55 | 56 | [$file, $code] = $creator->create( 57 | $verb, 58 | $path, 59 | file_get_contents($template) 60 | ); 61 | 62 | echo $file . PHP_EOL; 63 | if (file_exists($file)) { 64 | echo "Already exists; not overwriting." . PHP_EOL; 65 | exit(1); 66 | } 67 | 68 | $dir = dirname($file); 69 | if (! is_dir($dir)) { 70 | mkdir($dir, 0777, true); 71 | } 72 | 73 | file_put_contents($file, $code); 74 | 75 | exit(0); 76 | -------------------------------------------------------------------------------- /bin/autoroute-dump.php: -------------------------------------------------------------------------------- 1 | addPsr4($namespace, $realpath); 39 | 40 | $autoRoute = new AutoRoute( 41 | namespace: $namespace, 42 | directory: $realpath, 43 | baseUrl: $options['base-url'] ?? '/', 44 | ignoreParams: (int) ($options['ignore-params'] ?? 0), 45 | method: $options['method'] ?? '__invoke', 46 | suffix: $options['suffix'] ?? '', 47 | wordSeparator: $options['word-separator'] ?? '-', 48 | ); 49 | 50 | // --- 51 | 52 | $dumper = $autoRoute->getDumper(); 53 | 54 | try { 55 | $urls = $dumper->dump(); 56 | } catch (Throwable $e) { 57 | echo $e->getMessage() . PHP_EOL; 58 | exit(1); 59 | } 60 | 61 | foreach ($urls as $path => $info) { 62 | foreach ($info as $verb => $class) { 63 | $verb = str_pad(strtoupper($verb), 7); 64 | echo "$verb $path" . PHP_EOL; 65 | echo " $class" . PHP_EOL; 66 | } 67 | } 68 | 69 | exit(0); 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pmjones/auto-route", 3 | "type": "library", 4 | "description": "Automatically routes HTTP request to action classes.", 5 | "keywords": [ "route", "router", "routing", "action", "adr" ], 6 | "homepage": "http://github.com/pmjones/auto-route", 7 | "license": "MIT", 8 | "require": { 9 | "php": "^8.0", 10 | "psr/log": "^3.0", 11 | "pmjones/throwable-properties": "^1.0" 12 | }, 13 | "autoload": { 14 | "psr-4": { 15 | "AutoRoute\\": "src/" 16 | } 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "AutoRoute\\": "tests/" 21 | } 22 | }, 23 | "require-dev": { 24 | "pds/skeleton": "^1.0", 25 | "phpunit/phpunit": "^9.0", 26 | "phpstan/phpstan": "^0.12.82" 27 | }, 28 | "bin": [ 29 | "bin/autoroute-dump.php", 30 | "bin/autoroute-create.php" 31 | ], 32 | "scripts": { 33 | "test": "./vendor/bin/phpunit", 34 | "stan": "./vendor/bin/phpstan analyze -c phpstan.neon src", 35 | "testan": "composer test && composer stan" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | checkGenericClassInNonGenericObjectType: false 4 | paths: 5 | - src 6 | ignoreErrors: 7 | - "#no value type specified in iterable type#" 8 | - "#Cannot call method getName\\(\\) on ReflectionClass\\|null#" 9 | - "#Call to an undefined method ReflectionType::getName\\(\\)#" 10 | -------------------------------------------------------------------------------- /phpunit.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | 10 | tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/templates/action.tpl: -------------------------------------------------------------------------------- 1 | class; 25 | } 26 | 27 | public function getRequiredParameters(int $offset = 0) : array 28 | { 29 | return array_slice($this->requiredParameters, $offset, null, true); 30 | } 31 | 32 | public function getOptionalParameters() : array 33 | { 34 | return $this->optionalParameters; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Actions.php: -------------------------------------------------------------------------------- 1 | reverser = new Reverser($this->config, $this); 32 | } 33 | 34 | public function getAction(string $class) : Action 35 | { 36 | if (! isset($this->instances[$class])) { 37 | $this->instances[$class] = $this->newAction($class); 38 | } 39 | 40 | return $this->instances[$class]; 41 | } 42 | 43 | protected function newAction(string $class) : Action 44 | { 45 | $ns = substr($class, 0, $this->config->namespaceLen); 46 | 47 | if ($ns !== $this->config->namespace) { 48 | throw new Exception\InvalidNamespace( 49 | "Expected namespace {$this->config->namespace}, actually {$class}" 50 | ); 51 | } 52 | 53 | if (! class_exists($class)) { 54 | throw new Exception\NotFound( 55 | "Expected class {$class}, actually not found" 56 | ); 57 | } 58 | 59 | $parameters = $this->reflector->getActionParameters($class); 60 | 61 | for ($i = 0; $i < $this->config->ignoreParams; $i ++) { 62 | array_shift($parameters); 63 | } 64 | 65 | $requiredParameters = []; 66 | $optionalParameters = []; 67 | 68 | foreach ($parameters as $i => $rp) { 69 | if ($rp->isOptional()) { 70 | $optionalParameters[$i] = $rp; 71 | } else { 72 | $requiredParameters[$i] = $rp; 73 | } 74 | } 75 | 76 | return new Action( 77 | $class, 78 | $requiredParameters, 79 | $optionalParameters 80 | ); 81 | } 82 | 83 | public function getReverse(string $class) : Reverse 84 | { 85 | if (! isset($this->reversals[$class])) { 86 | $this->reversals[$class] = $this->reverser->reverse($class); 87 | } 88 | 89 | return $this->reversals[$class]; 90 | } 91 | 92 | public function getClass( 93 | string $verb, 94 | string $subNamespace, 95 | string $tail = '' 96 | ) : ?string 97 | { 98 | $base = rtrim($this->config->namespace, '\\') 99 | . $subNamespace 100 | . '\\'; 101 | 102 | if ($tail !== '') { 103 | $base .= $tail . '\\'; 104 | } 105 | 106 | $ending = str_replace('\\', '', $subNamespace . $tail) 107 | . $this->config->suffix; 108 | 109 | return $base . $verb . $ending; 110 | } 111 | 112 | public function hasAction( 113 | string $verb, 114 | string $subNamespace, 115 | string $tail = '' 116 | ) : ?string 117 | { 118 | $class = $this->getClass($verb, $subNamespace, $tail); 119 | 120 | if ($class !== null && class_exists($class)) { 121 | return $class; 122 | } 123 | 124 | if ($verb !== 'Head') { 125 | return null; 126 | } 127 | 128 | $class = $this->getClass('Get', $subNamespace, $tail); 129 | return $class !== null && class_exists($class) ? $class : null; 130 | } 131 | 132 | public function hasSubNamespace(string $subNamespace) : bool 133 | { 134 | if (strpos($subNamespace, '..') !== false) { 135 | throw new Exception\NotFound( 136 | "Directory dots not allowed in segments" 137 | ); 138 | } 139 | 140 | $dir = $this->config->directory 141 | . str_replace('\\', DIRECTORY_SEPARATOR, $subNamespace); 142 | 143 | return is_dir($dir); 144 | } 145 | 146 | public function getAllowed(string $subNamespace) : array 147 | { 148 | $verbs = []; 149 | $class = $this->getClass('', $subNamespace) ?? ''; 150 | $parts = explode('\\', $class); 151 | $main = end($parts). '.php'; 152 | $mainLen = -1 * strlen($main); 153 | $dir = $this->config->directory 154 | . str_replace('\\', DIRECTORY_SEPARATOR, $subNamespace); 155 | $items = new DirectoryIterator($dir); 156 | 157 | foreach ($items as $item) { 158 | $file = $item->getFilename(); 159 | 160 | if (substr($file, -4) !== '.php') { 161 | continue; 162 | } 163 | 164 | $verb = substr($file, 0, $mainLen); 165 | 166 | if ($verb !== '') { 167 | $verbs[] = strtoupper($verb); 168 | } 169 | } 170 | 171 | if (in_array('GET', $verbs) && ! in_array('HEAD', $verbs)) { 172 | $verbs[] = 'HEAD'; 173 | } 174 | 175 | sort($verbs); 176 | return $verbs; 177 | } 178 | 179 | public function getClasses() : array 180 | { 181 | $classes = []; 182 | 183 | $files = new RegexIterator( 184 | new RecursiveIteratorIterator( 185 | new RecursiveDirectoryIterator( 186 | $this->config->directory 187 | ) 188 | ), 189 | '/^.*\.php$/', 190 | RecursiveRegexIterator::GET_MATCH 191 | ); 192 | 193 | foreach ($files as $file) { 194 | $class = $this->fileToClass($file[0]); 195 | 196 | if ($class !== null) { 197 | $classes[] = $class; 198 | } 199 | } 200 | 201 | sort($classes); 202 | return $classes; 203 | } 204 | 205 | protected function fileToClass(string $file) : ?string 206 | { 207 | $file = str_replace( 208 | $this->config->directory . DIRECTORY_SEPARATOR, 209 | '', 210 | substr($file, 0, -4) 211 | ); 212 | $parts = explode(DIRECTORY_SEPARATOR, $file); 213 | $last = array_pop($parts); 214 | $core = implode('', $parts); 215 | $verb = substr( 216 | $last, 217 | 0, 218 | strlen($last) - strlen($core) - $this->config->suffixLen 219 | ); 220 | 221 | if ($verb === '') { 222 | return null; 223 | } 224 | 225 | $subNamespace = ''; 226 | 227 | if (! empty($parts)) { 228 | $subNamespace = '\\' . implode('\\', $parts); 229 | } 230 | 231 | return $this->hasAction($verb, $subNamespace); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/AutoRoute.php: -------------------------------------------------------------------------------- 1 | config = new Config( 48 | $namespace, 49 | $directory, 50 | $baseUrl, 51 | $ignoreParams, 52 | $method, 53 | $suffix, 54 | $wordSeparator, 55 | ); 56 | 57 | if ($loggerFactory === null) { 58 | $loggerFactory = function () : LoggerInterface { 59 | return new Logger(); 60 | }; 61 | } 62 | 63 | $this->loggerFactory = $loggerFactory; 64 | } 65 | 66 | public function getActions() : Actions 67 | { 68 | if ($this->actions === null) { 69 | $this->actions = new Actions( 70 | $this->getConfig(), 71 | $this->getReflector(), 72 | ); 73 | } 74 | 75 | return $this->actions; 76 | } 77 | 78 | public function getConfig() : Config 79 | { 80 | return $this->config; 81 | } 82 | 83 | public function getCreator() : Creator 84 | { 85 | if ($this->creator === null) { 86 | $this->creator = new Creator($this->getConfig()); 87 | } 88 | 89 | return $this->creator; 90 | } 91 | 92 | public function getDumper() : Dumper 93 | { 94 | if ($this->dumper === null) { 95 | $this->dumper = new Dumper( 96 | $this->getConfig(), 97 | $this->getReflector(), 98 | $this->getActions(), 99 | ); 100 | } 101 | 102 | return $this->dumper; 103 | } 104 | 105 | public function getFilter() : Filter 106 | { 107 | if ($this->filter === null) { 108 | $this->filter = new Filter($this->getReflector()); 109 | } 110 | 111 | return $this->filter; 112 | } 113 | 114 | public function getGenerator() : Generator 115 | { 116 | if ($this->generator === null) { 117 | $this->generator = new Generator( 118 | $this->getActions(), 119 | $this->getFilter() 120 | ); 121 | } 122 | 123 | return $this->generator; 124 | } 125 | 126 | public function getLogger() : LoggerInterface 127 | { 128 | if ($this->logger === null) { 129 | $this->logger = ($this->loggerFactory)(); 130 | } 131 | 132 | return $this->logger; 133 | } 134 | 135 | public function getReflector() : Reflector 136 | { 137 | if ($this->reflector === null) { 138 | $this->reflector = new Reflector($this->getConfig()); 139 | } 140 | 141 | return $this->reflector; 142 | } 143 | 144 | public function getRouter() : Router 145 | { 146 | if ($this->router === null) { 147 | $this->router = new Router( 148 | $this->getConfig(), 149 | $this->getActions(), 150 | $this->getFilter(), 151 | $this->getLogger(), 152 | ); 153 | } 154 | 155 | return $this->router; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | baseUrl = trim($this->baseUrl, '/'); 43 | $this->baseUrlLen = strlen($this->baseUrl); 44 | $this->directory = rtrim($this->directory, DIRECTORY_SEPARATOR); 45 | $this->namespace = trim($this->namespace, '\\') . '\\'; 46 | $this->namespaceLen = strlen($this->namespace); 47 | $this->suffixLen = strlen($this->suffix); 48 | } 49 | 50 | public function __get(string $key) : mixed 51 | { 52 | return $this->$key; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Creator.php: -------------------------------------------------------------------------------- 1 | parse($verb, $path); 23 | $file = "{$parsed['directory']}/{$parsed['class']}.php"; 24 | $vars = [ 25 | '{NAMESPACE}' => $parsed['namespace'], 26 | '{CLASS}' => $parsed['class'], 27 | '{METHOD}' => $parsed['method'], 28 | '{PARAMETERS}' => $parsed['parameters'], 29 | ]; 30 | $code = strtr($template, $vars); 31 | return [$file, $code]; 32 | } 33 | 34 | public function parse(string $verb, string $path) : array 35 | { 36 | $segments = explode('/', trim($path, '/')); 37 | $namespace = []; 38 | $parameters = []; 39 | 40 | while (! empty($segments)) { 41 | $segment = array_shift($segments); 42 | 43 | if (substr($segment, 0, 1) == '{') { 44 | $parameters[] = '$' . trim($segment, '{} '); 45 | continue; 46 | } 47 | 48 | $segment = str_replace($this->config->wordSeparator, ' ', $segment); 49 | $segment = str_replace(' ', '', ucwords($segment)); 50 | $namespace[] = $segment; 51 | } 52 | 53 | $namespace = implode('\\', $namespace); 54 | 55 | $class = ucfirst(strtolower($verb)) 56 | . str_replace('\\', '', $namespace) 57 | . $this->config->suffix; 58 | 59 | $directory = $this->config->directory . DIRECTORY_SEPARATOR 60 | . str_replace('\\', DIRECTORY_SEPARATOR, $namespace); 61 | 62 | return [ 63 | 'namespace' => rtrim($this->config->namespace . $namespace, '\\'), 64 | 'directory' => $directory, 65 | 'class' => $class, 66 | 'method' => $this->config->method, 67 | 'parameters' => implode(', ', $parameters) 68 | ]; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Dumper.php: -------------------------------------------------------------------------------- 1 | actions->getClasses(); 27 | $urls = $this->getUrlsFromClasses($classes); 28 | return $urls; 29 | } 30 | 31 | protected function getUrlsFromClasses(array $classes) : array 32 | { 33 | $urls = []; 34 | 35 | foreach ($classes as $class) { 36 | $reverse = $this->actions->getReverse($class); 37 | $path = $this->namedPath($reverse); 38 | 39 | // hack to fix optional catchall param when no base url 40 | if (substr($path, 0, 4) === '/[/{') { 41 | $path = '/[{' . substr($path, 4); 42 | } elseif (substr($path, 0, 3) === '//{') { 43 | $path = '/{' . substr($path, 3); 44 | } 45 | 46 | $urls[$path][$reverse->verb] = $class; 47 | 48 | if ($reverse->verb === 'Get' && ! isset($urls[$path]['Head'])) { 49 | $urls[$path]['Head'] = $class; 50 | } 51 | } 52 | 53 | ksort($urls); 54 | return $urls; 55 | } 56 | 57 | protected function namedPath(Reverse $reverse) : string 58 | { 59 | $namedPath = $reverse->path; 60 | $pairs = []; 61 | 62 | foreach ($reverse->parameters as $pos => $rp) { 63 | $this->namedPathPairs($reverse, $pos, $rp, $namedPath, $pairs); 64 | } 65 | 66 | return strtr($namedPath, $pairs); 67 | } 68 | 69 | protected function namedPathPairs( 70 | Reverse $reverse, 71 | int $pos, 72 | ReflectionParameter $rp, 73 | string &$namedPath, 74 | array &$pairs 75 | ) : void 76 | { 77 | if ($pos < $reverse->requiredParametersTotal) { 78 | $key = '/{' . $pos . '}'; 79 | $pairs[$key] = $this->namedPathTokens($rp); 80 | } else { 81 | $namedPath .= $this->namedPathTokens($rp); 82 | } 83 | } 84 | 85 | protected function namedPathTokens(ReflectionParameter $rp) : string 86 | { 87 | $type = $this->reflector->getParameterType($rp); 88 | 89 | if (! class_exists($type)) { 90 | $name = $rp->getName(); 91 | $vari = $rp->isVariadic() ? '...' : ''; 92 | $dump = '/{' . $vari . $type . ':' . $name . '}'; 93 | return $rp->isOptional() ? "[{$dump}]" : $dump; 94 | } 95 | 96 | $tokens = []; 97 | $ctorParams = $this->reflector->getConstructorParameters($type); 98 | 99 | foreach ($ctorParams as $ctorParam) { 100 | $tokens[] = $this->namedPathTokens($ctorParam); 101 | } 102 | 103 | return implode('', $tokens); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Exception/Exception.php: -------------------------------------------------------------------------------- 1 | reflector->getParameterType($rp); 24 | 25 | if (class_exists($type)) { 26 | return $this->toObject($rp, $type, $values); 27 | } 28 | 29 | $value = array_shift($values); 30 | 31 | if ($this->isBlank($value)) { 32 | throw $this->invalidArgument($rp, 'non-blank', $value); 33 | } 34 | 35 | $method = 'to' . ucfirst($type); 36 | return $this->$method($rp, $value); 37 | } 38 | 39 | protected function isBlank(mixed $value) : bool 40 | { 41 | if ($value === null) { 42 | return true; 43 | } 44 | 45 | if (is_string($value)) { 46 | return trim($value) === ''; 47 | } 48 | 49 | if (is_array($value)) { 50 | return empty($value); 51 | } 52 | 53 | return false; 54 | } 55 | 56 | protected function toArray(ReflectionParameter $rp, mixed $value) : array 57 | { 58 | if (is_array($value)) { 59 | return $value; 60 | } 61 | 62 | return str_getcsv((string) $value); 63 | } 64 | 65 | protected function toBool(ReflectionParameter $rp, mixed $value) : bool 66 | { 67 | if (is_bool($value)) { 68 | return $value; 69 | } 70 | 71 | if (in_array(strtolower($value), ['1', 't', 'true', 'y', 'yes'])) { 72 | return true; 73 | } 74 | 75 | if (in_array(strtolower($value), ['0', 'f', 'false', 'n', 'no'])) { 76 | return false; 77 | } 78 | 79 | throw $this->invalidArgument($rp, 'boolean-equivalent', $value); 80 | } 81 | 82 | protected function toInt(ReflectionParameter $rp, mixed $value) : int 83 | { 84 | if (is_int($value)) { 85 | return $value; 86 | } 87 | 88 | if (is_numeric($value) && (int) $value == $value) { 89 | return (int) $value; 90 | } 91 | 92 | throw $this->invalidArgument($rp, 'numeric integer', $value); 93 | } 94 | 95 | protected function toFloat(ReflectionParameter $rp, mixed $value) : float 96 | { 97 | if (is_float($value)) { 98 | return $value; 99 | } 100 | 101 | if (is_numeric($value)) { 102 | return (float) $value; 103 | } 104 | 105 | throw $this->invalidArgument($rp, 'numeric float', $value); 106 | } 107 | 108 | protected function toString(ReflectionParameter $rp, mixed $value) : string 109 | { 110 | return (string) $value; 111 | } 112 | 113 | protected function toMixed(ReflectionParameter $rp, mixed $value) : string 114 | { 115 | return (string) $value; 116 | } 117 | 118 | protected function toObject( 119 | ReflectionParameter $rp, 120 | string $type, 121 | array &$values 122 | ) : object 123 | { 124 | $args = []; 125 | $ctorParams = $this->reflector->getConstructorParameters($type); 126 | 127 | while (! empty($values) && ! empty($ctorParams)) { 128 | $ctorParam = array_shift($ctorParams); 129 | $paramType = $ctorParam->getType()->getName(); 130 | $method = 'to' . ucfirst($paramType); 131 | $args[] = $this->$method($ctorParam, array_shift($values)); 132 | } 133 | 134 | return new $type(...$args); 135 | } 136 | 137 | protected function invalidArgument( 138 | ReflectionParameter $rp, 139 | string $type, 140 | mixed $value 141 | ) : Exception\InvalidArgument 142 | { 143 | $pos = $rp->getPosition(); 144 | $name = $rp->getName(); 145 | $class = $rp->getDeclaringClass()->getName(); 146 | $method = $rp->getDeclaringFunction()->getName(); 147 | $value = var_export($value, true); 148 | return new Exception\InvalidArgument( 149 | "Expected {$type} argument " 150 | . "for {$class}::{$method}() " 151 | . "parameter {$pos} (\$$name), " 152 | . "actually $value" 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Generator.php: -------------------------------------------------------------------------------- 1 | actions->getReverse($class); 26 | $path = $reverse->path; 27 | $count = count($values); 28 | 29 | $pairs = []; 30 | $parameters = $reverse->parameters; 31 | $i = 0; 32 | 33 | while (! empty($parameters)) { 34 | $rp = array_shift($parameters); 35 | 36 | if ($rp->isVariadic()) { 37 | while (! empty($values)) { 38 | $path .= $this->segments($rp, $values); 39 | } 40 | 41 | break; 42 | } 43 | 44 | $segments = $this->segments($rp, $values); 45 | 46 | if ($rp->isOptional()) { 47 | $path .= $segments; 48 | } else { 49 | $pairs['{' . $i . '}'] = ltrim($segments, '/'); 50 | $i ++; 51 | } 52 | } 53 | 54 | if (! empty($values)) { 55 | throw new Exception\NotFound( 56 | "Too many arguments provided for {$class}" 57 | ); 58 | } 59 | 60 | $path = strtr($path, $pairs); 61 | return '/' . trim($path, '/'); 62 | } 63 | 64 | protected function segments( 65 | ReflectionParameter $rp, 66 | array &$values 67 | ) : string 68 | { 69 | if (empty($values) && $rp->isOptional()) { 70 | return ''; 71 | } 72 | 73 | $original = $values; 74 | 75 | // do not capture the filtered value, just validate it 76 | $this->filter->parameter($rp, $values); 77 | 78 | // capture the original values 79 | $segments = []; 80 | $k = count($original) - count($values); 81 | for ($i = 0; $i < $k; $i ++) { 82 | $value = array_shift($original); 83 | 84 | // only arrays cannot be cast to string 85 | if (is_array($value)) { 86 | $value = implode(',', $value); 87 | } 88 | 89 | // cast to a string for the url path 90 | $segments[] = (string) $value; 91 | } 92 | 93 | return '/' . implode('/', $segments); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Helper.php: -------------------------------------------------------------------------------- 1 | generator->generate($class, ...$values); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Logger.php: -------------------------------------------------------------------------------- 1 | messages[] = "($level) $message"; 27 | } 28 | 29 | public function getMessages() : array 30 | { 31 | return $this->messages; 32 | } 33 | 34 | public function reset() : void 35 | { 36 | $this->messages = []; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Reflector.php: -------------------------------------------------------------------------------- 1 | constructorParameters[$class])) { 32 | $this->setConstructorParameters($class); 33 | } 34 | 35 | return $this->constructorParameters[$class]; 36 | } 37 | 38 | protected function setConstructorParameters(string $class) : void 39 | { 40 | $this->constructorParameters[$class] = []; 41 | $rclass = $this->getClass($class); 42 | $rctor = $rclass->getConstructor(); 43 | 44 | if ($rctor !== null) { 45 | $this->constructorParameters[$class] = $rctor->getParameters(); 46 | } 47 | } 48 | 49 | public function getActionParameters(string $class) : array 50 | { 51 | if (! isset($this->actionParameters[$class])) { 52 | $this->setActionParameters($class); 53 | } 54 | 55 | return $this->actionParameters[$class]; 56 | } 57 | 58 | protected function setActionParameters(string $class) : void 59 | { 60 | $this->actionParameters[$class] = []; 61 | $rclass = $this->getClass($class); 62 | $rmethod = $rclass->getMethod($this->config->method); 63 | 64 | if ($rmethod !== null) { 65 | $this->actionParameters[$class] = $rmethod->getParameters(); 66 | } 67 | } 68 | 69 | protected function getClass(string $class) : ReflectionClass 70 | { 71 | if (! class_exists($class)) { 72 | throw new Exception\NotFound("Class not found: {$class}"); 73 | } 74 | 75 | if (! isset($this->classes[$class])) { 76 | $this->classes[$class] = new ReflectionClass($class); 77 | } 78 | 79 | return $this->classes[$class]; 80 | } 81 | 82 | public function getParameterType(ReflectionParameter $parameter) : string 83 | { 84 | $type = $parameter->getType(); 85 | return $type === null ? 'mixed' : $type->getName(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Reverse.php: -------------------------------------------------------------------------------- 1 | $key; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Reverser.php: -------------------------------------------------------------------------------- 1 | parts = explode( 43 | '\\', 44 | substr($class, $this->config->namespaceLen) 45 | ); 46 | $last = array_pop($this->parts); 47 | $impl = implode('', $this->parts); 48 | $this->verb = substr( 49 | $last, 50 | 0, 51 | strlen($last) - strlen($impl) - $this->config->suffixLen 52 | ); 53 | $this->path = $this->config->baseUrl; 54 | $this->requiredParametersTotal = 0; 55 | $this->subNamespace = ''; 56 | $this->currClass = ''; 57 | $this->prevClass = ''; 58 | 59 | if (empty($this->parts)) { 60 | return $this->reverseCatchall($class); 61 | } 62 | 63 | while (! empty($this->parts)) { 64 | $this->reverseSegments(); 65 | } 66 | 67 | $action = $this->actions->getAction($class); 68 | $parameters = $action->getRequiredParameters() 69 | + $action->getOptionalParameters(); 70 | 71 | return new Reverse( 72 | $class, 73 | $this->verb, 74 | '/' . ltrim($this->path, '/'), 75 | $parameters, 76 | $this->requiredParametersTotal 77 | ); 78 | } 79 | 80 | protected function reverseCatchall(string $class) : Reverse 81 | { 82 | $action = $this->actions->getAction($class); 83 | $this->reverseRequiredSegments($action); 84 | $parameters = $action->getRequiredParameters() 85 | + $action->getOptionalParameters(); 86 | 87 | return new Reverse( 88 | $class, 89 | $this->verb, 90 | '/' . ltrim($this->path, '/'), 91 | $parameters, 92 | $this->requiredParametersTotal 93 | ); 94 | } 95 | 96 | protected function reverseSegments() : void 97 | { 98 | $this->prevClass = $this->currClass; 99 | $part = array_shift($this->parts); 100 | $this->path .= '/' . $this->namespaceToSegment($part); 101 | $this->subNamespace .= '\\' . $part; 102 | $class = $this->actions->hasAction($this->verb, $this->subNamespace); 103 | 104 | if ($class === null) { 105 | return; 106 | } 107 | 108 | $staticTailSegment = $this->reverseStaticTailSegment(); 109 | 110 | if ($staticTailSegment) { 111 | return; 112 | } 113 | 114 | $this->currClass = $class; 115 | $action = $this->actions->getAction($this->currClass); 116 | $this->reverseRequiredSegments($action); 117 | } 118 | 119 | protected function reverseStaticTailSegment() : bool 120 | { 121 | if (count($this->parts) !== 1) { 122 | return false; 123 | } 124 | 125 | $prevRequired = 0; 126 | 127 | if ($this->prevClass !== '') { 128 | $prevRequired = count($this->actions 129 | ->getAction($this->prevClass) 130 | ->getRequiredParameters() 131 | ); 132 | } 133 | 134 | $nextClass = (string) $this->actions->hasAction( 135 | $this->verb, 136 | $this->subNamespace, 137 | $this->parts[0] 138 | ); 139 | 140 | $nextRequired = count($this->actions 141 | ->getAction($nextClass) 142 | ->getRequiredParameters() 143 | ); 144 | 145 | if ($prevRequired !== $nextRequired) { 146 | return false; 147 | } 148 | 149 | $part = array_shift($this->parts); 150 | $this->path .= '/' . $this->namespaceToSegment($part); 151 | return true; 152 | } 153 | 154 | protected function reverseRequiredSegments(Action $action) : void 155 | { 156 | $count = count($action->getRequiredParameters()) 157 | - $this->requiredParametersTotal; 158 | 159 | while ($count > 0) { 160 | $this->path .= '/{' . $this->requiredParametersTotal . '}'; 161 | $this->requiredParametersTotal ++; 162 | $count --; 163 | } 164 | } 165 | 166 | protected function namespaceToSegment(string $part) : string 167 | { 168 | $part = (string) preg_replace( 169 | '/([a-z])([A-Z])/', 170 | "\$1{$this->config->wordSeparator}\$2", 171 | trim($part) 172 | ); 173 | return strtolower($part); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | $key; 42 | } 43 | 44 | public function asArray() : array 45 | { 46 | return get_object_vars($this); 47 | } 48 | 49 | public function jsonSerialize() : mixed 50 | { 51 | $array = $this->asArray(); 52 | 53 | if ($array['exception'] !== null) { 54 | $array['exception'] = new ThrowableProperties($array['exception']); 55 | } 56 | 57 | return $array; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | logger; 46 | } 47 | 48 | public function route(string $verb, string $path) : Route 49 | { 50 | $this->verb = ucfirst(strtolower($verb)); 51 | $this->subNamespace = ''; 52 | $this->class = ''; 53 | $this->action = new Action('', [], []); 54 | $this->arguments = []; 55 | $this->messages = []; 56 | $this->log("{$verb} {$path}"); 57 | 58 | try { 59 | $this->capture($path); 60 | return new Route( 61 | $this->action->getClass(), 62 | $this->config->method, 63 | $this->arguments, 64 | messages: $this->messages, 65 | ); 66 | } catch (Throwable $e) { 67 | return new Route( 68 | $this->class, 69 | $this->config->method, 70 | $this->arguments, 71 | get_class($e), 72 | $e, 73 | $this->headers, 74 | $this->messages, 75 | ); 76 | } 77 | } 78 | 79 | protected function capture(string $path) : void 80 | { 81 | $this->segments = $this->getSegments($path); 82 | 83 | do { 84 | $this->captureNextSegment(); 85 | } while (! empty($this->segments)); 86 | 87 | $this->log('segments empty, capture complete'); 88 | 89 | $requiredCount = count($this->action->getRequiredParameters()); 90 | $argumentCount = count($this->arguments); 91 | 92 | if ($argumentCount >= $requiredCount) { 93 | return; 94 | } 95 | 96 | $message = "{$this->class} needs {$requiredCount} argument(s), " 97 | . "{$argumentCount} found"; 98 | 99 | $this->log($message); 100 | throw new Exception\NotFound($message); 101 | } 102 | 103 | protected function captureNextSegment() : void 104 | { 105 | $subNamespace = ''; 106 | 107 | // capture next segment as a subnamespace 108 | if (! empty($this->segments)) { 109 | $segment = $this->segmentToNamespace(reset($this->segments)); 110 | $this->log("candidate namespace segment: {$segment}"); 111 | $subNamespace = $this->subNamespace . '\\' . $segment; 112 | } 113 | 114 | $this->log("find subnamespace: {$subNamespace}"); 115 | 116 | // does the subnamespace exist? 117 | if (! $this->actions->hasSubNamespace($subNamespace)) { 118 | $ns = rtrim($this->config->namespace, '\\') . $subNamespace; 119 | $this->log('subnamespace not found'); 120 | 121 | // are we are the very top of the url? 122 | if ($this->subNamespace === '') { 123 | // yes, try to capture arguments for it 124 | $this->captureRootClass(); 125 | return; 126 | } 127 | 128 | // no, so no need to keep matching 129 | throw new Exception\NotFound("Not a known namespace: {$ns}"); 130 | } 131 | 132 | $this->log('subnamespace found'); 133 | $this->subNamespace = $subNamespace; 134 | array_shift($this->segments); 135 | $this->captureMainClass(); 136 | } 137 | 138 | protected function captureRootClass() : void 139 | { 140 | $expect = $this->actions->getClass($this->verb, $this->subNamespace); 141 | $this->log("find root class: {$expect}"); 142 | $class = $this->actions->hasAction($this->verb, $this->subNamespace); 143 | 144 | if ($class === null) { 145 | $this->log('root class not found'); 146 | 147 | $allowed = $this->actions->getAllowed($this->subNamespace); 148 | 149 | if (empty($allowed)) { 150 | throw new Exception\NotFound("No actions found"); 151 | } 152 | 153 | $verb = strtoupper($this->verb); 154 | $this->headers = ['allowed' => implode(',', $allowed)]; 155 | throw new Exception\MethodNotAllowed( 156 | "{$verb} action not found" 157 | ); 158 | } 159 | 160 | $this->log('root class found'); 161 | $this->class = $class; 162 | $this->action = $this->actions->getAction($this->class); 163 | 164 | $this->log('capture all segments as arguments'); 165 | 166 | $this->captureRequiredArguments(); 167 | $this->captureOptionalArguments(); 168 | 169 | if (empty($this->segments)) { 170 | return; 171 | } 172 | 173 | $this->log('leftover segments'); 174 | $class = $this->action->getClass(); 175 | throw new Exception\NotFound("Too many router segments for {$class}"); 176 | } 177 | 178 | protected function captureMainClass() : void 179 | { 180 | $expect = $this->actions->getClass($this->verb, $this->subNamespace); 181 | $this->log("find class: {$expect}"); 182 | $class = $this->actions->hasAction($this->verb, $this->subNamespace); 183 | 184 | if ($class === null) { 185 | $this->classNotFound(); 186 | return; 187 | } 188 | 189 | $this->log('class found'); 190 | $this->class = $class; 191 | $this->action = $this->actions->getAction($this->class); 192 | 193 | if (count($this->segments) === 1) { 194 | $this->captureTailClass(); 195 | } 196 | 197 | $this->captureRequiredArguments(); 198 | $this->captureOptionalArguments(); 199 | } 200 | 201 | protected function classNotFound() : void 202 | { 203 | $this->log('class not found'); 204 | 205 | if (! empty($this->segments)) { 206 | return; 207 | } 208 | 209 | // no class, and no more segments 210 | $this->log('segments empty too soon'); 211 | $ns = rtrim($this->config->namespace, '\\') . $this->subNamespace; 212 | $allowed = $this->actions->getAllowed($this->subNamespace); 213 | 214 | if (empty($allowed)) { 215 | throw new Exception\NotFound("No actions found in namespace {$ns}"); 216 | } 217 | 218 | $verb = strtoupper($this->verb); 219 | $this->headers = ['allowed' => implode(',', $allowed)]; 220 | throw new Exception\MethodNotAllowed( 221 | "{$verb} action not found in namespace {$ns}" 222 | ); 223 | } 224 | 225 | protected function captureTailClass() : void 226 | { 227 | $segment = $this->segmentToNamespace($this->segments[0]); 228 | $this->log("candidate static tail namepace segment: {$segment}"); 229 | $tailClass = $this->actions->hasAction( 230 | $this->verb, 231 | $this->subNamespace, 232 | $segment 233 | ); 234 | 235 | if ($tailClass === null) { 236 | $this->log('static tail subnamespace not found'); 237 | return; 238 | } 239 | 240 | $this->log("static tail class found: {$tailClass}"); 241 | array_shift($this->segments); 242 | $this->subNamespace .= '\\' . $segment; 243 | $this->class = $tailClass; 244 | $this->action = $this->actions->getAction($this->class); 245 | } 246 | 247 | protected function captureRequiredArguments() : void 248 | { 249 | if (empty($this->segments)) { 250 | return; 251 | } 252 | 253 | $offset = count($this->arguments); 254 | $requiredParameters = $this->action->getRequiredParameters($offset); 255 | 256 | if (empty($requiredParameters)) { 257 | $this->log('no additional required arguments'); 258 | return; 259 | } 260 | 261 | $this->log('capture additional required arguments'); 262 | $this->captureArguments($requiredParameters); 263 | } 264 | 265 | protected function captureOptionalArguments() : void 266 | { 267 | if (empty($this->segments)) { 268 | return; 269 | } 270 | 271 | $segment = $this->segmentToNamespace($this->segments[0]); 272 | $temp = $this->subNamespace . '\\' . $segment; 273 | 274 | if ($this->actions->hasSubNamespace($temp)) { 275 | $this->log('next segment is a namespace'); 276 | return; 277 | } 278 | 279 | $optionalParameters = $this->action->getOptionalParameters(); 280 | 281 | if (empty($optionalParameters)) { 282 | $this->log('no optional arguments'); 283 | return; 284 | } 285 | 286 | $this->log('capture optional arguments'); 287 | $this->captureArguments($optionalParameters); 288 | 289 | if (empty($this->segments)) { 290 | return; 291 | } 292 | 293 | $this->log('leftover segments'); 294 | $class = $this->action->getClass(); 295 | throw new Exception\NotFound("Too many router segments for {$class}"); 296 | } 297 | 298 | protected function captureArguments(array $parameters) : void 299 | { 300 | foreach ($parameters as $i => $parameter) { 301 | $this->captureArgument($parameter, $i); 302 | } 303 | } 304 | 305 | protected function captureArgument( 306 | ReflectionParameter $parameter, 307 | int $i 308 | ) : void 309 | { 310 | if (empty($this->segments)) { 311 | return; 312 | } 313 | 314 | if ($parameter->isVariadic()) { 315 | $this->captureVariadic($parameter, $i); 316 | return; 317 | } 318 | 319 | $this->arguments[] = $this->filter->parameter( 320 | $parameter, 321 | $this->segments 322 | ); 323 | $name = $parameter->getName(); 324 | $this->log("captured argument {$i} (\${$name})"); 325 | } 326 | 327 | protected function captureVariadic( 328 | ReflectionParameter $parameter, 329 | int $i 330 | ) : void 331 | { 332 | $name = $parameter->getName(); 333 | 334 | while (! empty($this->segments)) { 335 | $this->arguments[] = $this->filter->parameter( 336 | $parameter, 337 | $this->segments 338 | ); 339 | $this->log("captured variadic argument {$i} (\${$name})"); 340 | } 341 | } 342 | 343 | protected function segmentToNamespace(string $segment) : string 344 | { 345 | $segment = trim($segment); 346 | 347 | if ($segment === '') { 348 | throw new Exception\NotFound( 349 | 'Cannot convert empty segment to namespace part' 350 | ); 351 | } 352 | 353 | return str_replace( 354 | $this->config->wordSeparator, 355 | '', 356 | ucwords($segment, $this->config->wordSeparator) 357 | ); 358 | } 359 | 360 | protected function getSegments(string $path) : array 361 | { 362 | $path = trim($path, '/'); 363 | $base = substr($path, 0, $this->config->baseUrlLen); 364 | 365 | if ($base !== $this->config->baseUrl) { 366 | throw new Exception\NotFound( 367 | "Expected base URL /{$this->config->baseUrl}, actually /{$base}" 368 | ); 369 | } 370 | 371 | $segments = []; 372 | 373 | $path = trim(substr($path, $this->config->baseUrlLen), '/'); 374 | 375 | if (! empty($path)) { 376 | $segments = explode('/', $path); 377 | } 378 | 379 | return $segments; 380 | } 381 | 382 | protected function log(string $message) : void 383 | { 384 | $this->messages[] = $message; 385 | $this->logger->debug($message); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /tests/CreatorTest.php: -------------------------------------------------------------------------------- 1 | getCreator(); 18 | 19 | $template = file_get_contents( 20 | dirname(__DIR__) . '/resources/templates/action.tpl' 21 | ); 22 | 23 | [$file, $code] = $creator->create( 24 | 'GET', 25 | '/company/{companyId}/employee/{employeeNum}', 26 | $template 27 | ); 28 | 29 | $expect = str_replace( 30 | '/', 31 | DIRECTORY_SEPARATOR, 32 | __DIR__ . DIRECTORY_SEPARATOR . 'Http/Company/Employee/GetCompanyEmployeeAction.php' 33 | ); 34 | 35 | $this->assertSame($expect, $file); 36 | 37 | $expect = 'assertSame($expect, $code); 49 | } 50 | 51 | public function testWordSeparation() 52 | { 53 | $autoRoute = new AutoRoute( 54 | namespace: 'AutoRoute\\Http', 55 | directory: __DIR__ . DIRECTORY_SEPARATOR . 'Http', 56 | ); 57 | 58 | $creator = $autoRoute->getCreator(); 59 | 60 | $template = file_get_contents( 61 | dirname(__DIR__) . '/resources/templates/action.tpl' 62 | ); 63 | 64 | [$file, $code] = $creator->create( 65 | 'GET', 66 | '/foo-bar/{id}', 67 | $template 68 | ); 69 | 70 | $expect = str_replace( 71 | '/', 72 | DIRECTORY_SEPARATOR, 73 | __DIR__ . DIRECTORY_SEPARATOR . 'Http/FooBar/GetFooBar.php' 74 | ); 75 | 76 | $this->assertSame($expect, $file); 77 | 78 | $expect = 'assertSame($expect, $code); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/DumperTest.php: -------------------------------------------------------------------------------- 1 | getDumper(); 17 | 18 | $expect = array ( 19 | '/api' => 20 | array ( 21 | 'Get' => 'AutoRoute\\Http\\Get', 22 | 'Head' => 'AutoRoute\\Http\\Get', 23 | ), 24 | '/api/admin/dashboard' => 25 | array ( 26 | 'Get' => 'AutoRoute\\Http\\Admin\\Dashboard\\GetAdminDashboard', 27 | 'Head' => 'AutoRoute\\Http\\Admin\\Dashboard\\GetAdminDashboard', 28 | ), 29 | '/api/foo-item' => 30 | array ( 31 | 'Post' => 'AutoRoute\\Http\\FooItem\\PostFooItem', 32 | ), 33 | '/api/foo-item/add' => 34 | array ( 35 | 'Get' => 'AutoRoute\\Http\\FooItem\\Add\\GetFooItemAdd', 36 | 'Head' => 'AutoRoute\\Http\\FooItem\\Add\\HeadFooItemAdd', 37 | ), 38 | '/api/foo-item/{int:id}' => 39 | array ( 40 | 'Delete' => 'AutoRoute\\Http\\FooItem\\DeleteFooItem', 41 | 'Get' => 'AutoRoute\\Http\\FooItem\\GetFooItem', 42 | 'Head' => 'AutoRoute\\Http\\FooItem\\HeadFooItem', 43 | 'Patch' => 'AutoRoute\\Http\\FooItem\\PatchFooItem', 44 | ), 45 | '/api/foo-item/{int:id}/edit' => 46 | array ( 47 | 'Get' => 'AutoRoute\\Http\\FooItem\\Edit\\GetFooItemEdit', 48 | 'Head' => 'AutoRoute\\Http\\FooItem\\Edit\\GetFooItemEdit', 49 | ), 50 | '/api/foo-item/{int:id}/extras/{float:foo}/{string:bar}/{mixed:baz}/{bool:dib}[/{array:gir}]' => 51 | array ( 52 | 'Get' => 'AutoRoute\\Http\\FooItem\\Extras\\GetFooItemExtras', 53 | 'Head' => 'AutoRoute\\Http\\FooItem\\Extras\\GetFooItemExtras', 54 | ), 55 | '/api/foo-item/{int:id}/variadic[/{...string:more}]' => 56 | array ( 57 | 'Get' => 'AutoRoute\\Http\\FooItem\\Variadic\\GetFooItemVariadic', 58 | 'Head' => 'AutoRoute\\Http\\FooItem\\Variadic\\GetFooItemVariadic', 59 | ), 60 | '/api/foo-items/archive[/{int:year}][/{int:month}][/{int:day}]' => 61 | array ( 62 | 'Get' => 'AutoRoute\\Http\\FooItems\\Archive\\GetFooItemsArchive', 63 | 'Head' => 'AutoRoute\\Http\\FooItems\\Archive\\GetFooItemsArchive', 64 | ), 65 | '/api/foo-items[/{int:page}]' => 66 | array ( 67 | 'Get' => 'AutoRoute\\Http\\FooItems\\GetFooItems', 68 | 'Head' => 'AutoRoute\\Http\\FooItems\\GetFooItems', 69 | ), 70 | '/api/repo/{string:ownerName}/{string:repoName}' => 71 | array ( 72 | 'Get' => 'AutoRoute\\Http\\Repo\\GetRepo', 73 | 'Head' => 'AutoRoute\\Http\\Repo\\GetRepo', 74 | ), 75 | '/api/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}' => 76 | array ( 77 | 'Get' => 'AutoRoute\\Http\\Repo\\Issue\\GetRepoIssue', 78 | 'Head' => 'AutoRoute\\Http\\Repo\\Issue\\GetRepoIssue', 79 | ), 80 | '/api/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/add' => 81 | array ( 82 | 'Get' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 83 | 'Head' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 84 | ), 85 | '/api/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/{int:commentNum}' => 86 | array ( 87 | 'Get' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\GetRepoIssueComment', 88 | 'Head' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\GetRepoIssueComment', 89 | ), 90 | '/api/{string:name}' => 91 | array ( 92 | 'Getrequired' => 'AutoRoute\\Http\\Getrequired', 93 | ), 94 | '/api[/{...string:args}]' => 95 | array ( 96 | 'Getvariadic' => 'AutoRoute\\Http\\Getvariadic', 97 | ), 98 | '/api[/{string:name}]' => 99 | array ( 100 | 'Getoptional' => 'AutoRoute\\Http\\Getoptional', 101 | ), 102 | ); 103 | 104 | $actual = $dumper->dump(); 105 | $this->assertSame($expect, $actual); 106 | } 107 | 108 | public function testDumpRoutes_noBaseUrl() 109 | { 110 | $autoRoute = new AutoRoute( 111 | 'AutoRoute\\Http', 112 | __DIR__ . DIRECTORY_SEPARATOR . 'Http' 113 | ); 114 | 115 | $dumper = $autoRoute->getDumper(); 116 | 117 | $expect = array ( 118 | '/' => 119 | array ( 120 | 'Get' => 'AutoRoute\\Http\\Get', 121 | 'Head' => 'AutoRoute\\Http\\Get', 122 | ), 123 | '/[{...string:args}]' => 124 | array ( 125 | 'Getvariadic' => 'AutoRoute\\Http\\Getvariadic', 126 | ), 127 | '/[{string:name}]' => 128 | array ( 129 | 'Getoptional' => 'AutoRoute\\Http\\Getoptional', 130 | ), 131 | '/admin/dashboard' => 132 | array ( 133 | 'Get' => 'AutoRoute\\Http\\Admin\\Dashboard\\GetAdminDashboard', 134 | 'Head' => 'AutoRoute\\Http\\Admin\\Dashboard\\GetAdminDashboard', 135 | ), 136 | '/foo-item' => 137 | array ( 138 | 'Post' => 'AutoRoute\\Http\\FooItem\\PostFooItem', 139 | ), 140 | '/foo-item/add' => 141 | array ( 142 | 'Get' => 'AutoRoute\\Http\\FooItem\\Add\\GetFooItemAdd', 143 | 'Head' => 'AutoRoute\\Http\\FooItem\\Add\\HeadFooItemAdd', 144 | ), 145 | '/foo-item/{int:id}' => 146 | array ( 147 | 'Delete' => 'AutoRoute\\Http\\FooItem\\DeleteFooItem', 148 | 'Get' => 'AutoRoute\\Http\\FooItem\\GetFooItem', 149 | 'Head' => 'AutoRoute\\Http\\FooItem\\HeadFooItem', 150 | 'Patch' => 'AutoRoute\\Http\\FooItem\\PatchFooItem', 151 | ), 152 | '/foo-item/{int:id}/edit' => 153 | array ( 154 | 'Get' => 'AutoRoute\\Http\\FooItem\\Edit\\GetFooItemEdit', 155 | 'Head' => 'AutoRoute\\Http\\FooItem\\Edit\\GetFooItemEdit', 156 | ), 157 | '/foo-item/{int:id}/extras/{float:foo}/{string:bar}/{mixed:baz}/{bool:dib}[/{array:gir}]' => 158 | array ( 159 | 'Get' => 'AutoRoute\\Http\\FooItem\\Extras\\GetFooItemExtras', 160 | 'Head' => 'AutoRoute\\Http\\FooItem\\Extras\\GetFooItemExtras', 161 | ), 162 | '/foo-item/{int:id}/variadic[/{...string:more}]' => 163 | array ( 164 | 'Get' => 'AutoRoute\\Http\\FooItem\\Variadic\\GetFooItemVariadic', 165 | 'Head' => 'AutoRoute\\Http\\FooItem\\Variadic\\GetFooItemVariadic', 166 | ), 167 | '/foo-items/archive[/{int:year}][/{int:month}][/{int:day}]' => 168 | array ( 169 | 'Get' => 'AutoRoute\\Http\\FooItems\\Archive\\GetFooItemsArchive', 170 | 'Head' => 'AutoRoute\\Http\\FooItems\\Archive\\GetFooItemsArchive', 171 | ), 172 | '/foo-items[/{int:page}]' => 173 | array ( 174 | 'Get' => 'AutoRoute\\Http\\FooItems\\GetFooItems', 175 | 'Head' => 'AutoRoute\\Http\\FooItems\\GetFooItems', 176 | ), 177 | '/repo/{string:ownerName}/{string:repoName}' => 178 | array ( 179 | 'Get' => 'AutoRoute\\Http\\Repo\\GetRepo', 180 | 'Head' => 'AutoRoute\\Http\\Repo\\GetRepo', 181 | ), 182 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}' => 183 | array ( 184 | 'Get' => 'AutoRoute\\Http\\Repo\\Issue\\GetRepoIssue', 185 | 'Head' => 'AutoRoute\\Http\\Repo\\Issue\\GetRepoIssue', 186 | ), 187 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/add' => 188 | array ( 189 | 'Get' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 190 | 'Head' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 191 | ), 192 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/{int:commentNum}' => 193 | array ( 194 | 'Get' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\GetRepoIssueComment', 195 | 'Head' => 'AutoRoute\\Http\\Repo\\Issue\\Comment\\GetRepoIssueComment', 196 | ), 197 | '/{string:name}' => 198 | array ( 199 | 'Getrequired' => 'AutoRoute\\Http\\Getrequired', 200 | ), 201 | ); 202 | 203 | $actual = $dumper->dump(); 204 | $this->assertSame($expect, $actual); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/GeneratorTest.php: -------------------------------------------------------------------------------- 1 | generator = $autoRoute->getGenerator(); 32 | } 33 | 34 | public function testGenerate() 35 | { 36 | $actual = $this->generator->generate(GetFooItemEdit::CLASS, 1); 37 | $expect = '/api/foo-item/1/edit'; 38 | $this->assertSame($expect, $actual); 39 | 40 | $actual = $this->generator->generate(GetAdminDashboard::CLASS); 41 | $expect = '/api/admin/dashboard'; 42 | $this->assertSame($expect, $actual); 43 | 44 | // repeat for coverage 45 | $actual = $this->generator->generate(GetAdminDashboard::CLASS); 46 | $expect = '/api/admin/dashboard'; 47 | $this->assertSame($expect, $actual); 48 | 49 | $actual = $this->generator->generate(GetFooItemAdd::CLASS); 50 | $expect = '/api/foo-item/add'; 51 | $this->assertSame($expect, $actual); 52 | 53 | // root 54 | $actual = $this->generator->generate(Get::CLASS); 55 | $expect = '/api'; 56 | $this->assertSame($expect, $actual); 57 | 58 | // root, required parameter 59 | $actual = $this->generator->generate(Getrequired::CLASS, 'catch-me'); 60 | $expect = '/api/catch-me'; 61 | $this->assertSame($expect, $actual); 62 | 63 | // root, variadic parameter 64 | $actual = $this->generator->generate(Getvariadic::CLASS, 'catch-me', 'if-you', 'can'); 65 | $expect = '/api/catch-me/if-you/can'; 66 | $this->assertSame($expect, $actual); 67 | 68 | // root, optional parameter missing 69 | $actual = $this->generator->generate(Getoptional::CLASS); 70 | $expect = '/api'; 71 | $this->assertSame($expect, $actual); 72 | 73 | // root, optional parameter present 74 | $actual = $this->generator->generate(Getoptional::CLASS, 'catch-me'); 75 | $expect = '/api/catch-me'; 76 | $this->assertSame($expect, $actual); 77 | 78 | // root, variadic parameter 79 | $actual = $this->generator->generate(Getvariadic::CLASS, 'catch-me', 'if-you', 'can'); 80 | $expect = '/api/catch-me/if-you/can'; 81 | $this->assertSame($expect, $actual); 82 | 83 | // parameter types 84 | $actual = $this->generator->generate( 85 | GetFooItemExtras::CLASS, 86 | 1, 87 | 2.3, 88 | 'bar', 89 | 'baz', 90 | true, 91 | ['a', 'b', 'c'] 92 | ); 93 | $expect = '/api/foo-item/1/extras/2.3/bar/baz/1/a,b,c'; 94 | $this->assertSame($expect, $actual); 95 | 96 | // variadics 97 | $actual = $this->generator->generate( 98 | GetFooItemVariadic::CLASS, 99 | 1, 100 | 'foo', 101 | 'bar', 102 | 'baz' 103 | ); 104 | $expect = '/api/foo-item/1/variadic/foo/bar/baz'; 105 | $this->assertSame($expect, $actual); 106 | 107 | // optionals 108 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS); 109 | $expect = '/api/foo-items/archive'; 110 | $this->assertSame($expect, $actual); 111 | 112 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970'); 113 | $expect = '/api/foo-items/archive/1970'; 114 | $this->assertSame($expect, $actual); 115 | 116 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970', '11'); 117 | $expect = '/api/foo-items/archive/1970/11'; 118 | $this->assertSame($expect, $actual); 119 | 120 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970', '11', '07'); 121 | $expect = '/api/foo-items/archive/1970/11/07'; 122 | $this->assertSame($expect, $actual); 123 | } 124 | 125 | public function testGenerateInvalidNamespace() 126 | { 127 | $this->expectException(Exception\InvalidNamespace::CLASS); 128 | $this->expectExceptionMessage('Expected namespace AutoRoute\Http\, actually Mismatched\Namespace\GetFooItemEdit'); 129 | $this->generator->generate('Mismatched\Namespace\GetFooItemEdit'); 130 | } 131 | 132 | public function testGenerateNoSuchClass() 133 | { 134 | $this->expectException(Exception\NotFound::CLASS); 135 | $this->expectExceptionMessage('Expected class AutoRoute\Http\NoSuchAction, actually not found'); 136 | $this->generator->generate(\AutoRoute\Http\NoSuchAction::CLASS); 137 | } 138 | 139 | public function testGenerateTooManySegments() 140 | { 141 | $this->expectException(Exception\NotFound::CLASS); 142 | $this->expectExceptionMessage('Too many arguments provided for AutoRoute\Http\FooItem\Extras\GetFooItemExtras'); 143 | $this->generator->generate( 144 | GetFooItemExtras::CLASS, 145 | 1, 146 | 2.3, 147 | 'bar', 148 | 'baz', 149 | true, 150 | ['a', 'b', 'c'], 151 | 'one-too-many' 152 | ); 153 | } 154 | 155 | public function testGenerateNotEnoughSegments() 156 | { 157 | $this->expectException(Exception\InvalidArgument::CLASS); 158 | $this->expectExceptionMessage('Expected non-blank argument for AutoRoute\Http\FooItem\Edit\GetFooItemEdit::__invoke() parameter 0 ($id), actually NULL'); 159 | $this->generator->generate(GetFooItemEdit::CLASS); 160 | } 161 | 162 | public function testGenerate_noBaseUrl() 163 | { 164 | $autoRoute = new AutoRoute( 165 | 'AutoRoute\\Http', 166 | __DIR__ . DIRECTORY_SEPARATOR . 'Http' 167 | ); 168 | 169 | $generator = $autoRoute->getGenerator(); 170 | 171 | $actual = $generator->generate(GetFooItemEdit::CLASS, 1); 172 | $expect = '/foo-item/1/edit'; 173 | $this->assertSame($expect, $actual); 174 | 175 | $actual = $generator->generate(Get::CLASS); 176 | $expect = '/'; 177 | $this->assertSame($expect, $actual); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /tests/HelperTest.php: -------------------------------------------------------------------------------- 1 | generator = $autoRoute->getGenerator(); 21 | } 22 | 23 | public function test() 24 | { 25 | $helper = new Helper($this->generator); 26 | $actual = $helper(GetFooItemEdit::CLASS, 1); 27 | $expect = '/api/foo-item/1/edit'; 28 | $this->assertSame($expect, $actual); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Http/Admin/Dashboard/GetAdminDashboard.php: -------------------------------------------------------------------------------- 1 | router = $autoRoute->getRouter(); 39 | $this->generator = $autoRoute->getGenerator(); 40 | $this->dumper = $autoRoute->getDumper(); 41 | } 42 | 43 | public function testRouter() 44 | { 45 | $route = $this->router->route('GET', '/foo_item/add'); 46 | $this->assertSame(GetFooItemAdd::CLASS, $route->class); 47 | $this->assertSame([], $route->arguments); 48 | 49 | $route = $this->router->route('GET', '/foo_item/1'); 50 | $this->assertSame(GetFooItem::CLASS, $route->class); 51 | $this->assertSame([1], $route->arguments); 52 | 53 | $route = $this->router->route('GET', '/foo_item/1/edit'); 54 | $this->assertSame(GetFooItemEdit::CLASS, $route->class); 55 | $this->assertSame([1], $route->arguments); 56 | } 57 | 58 | public function testGenerator() 59 | { 60 | $actual = $this->generator->generate(GetFooItemEdit::CLASS, 1); 61 | $expect = '/foo_item/1/edit'; 62 | $this->assertSame($expect, $actual); 63 | 64 | $actual = $this->generator->generate(GetAdminDashboard::CLASS); 65 | $expect = '/admin/dashboard'; 66 | $this->assertSame($expect, $actual); 67 | 68 | // repeat for coverage 69 | $actual = $this->generator->generate(GetAdminDashboard::CLASS); 70 | $expect = '/admin/dashboard'; 71 | $this->assertSame($expect, $actual); 72 | 73 | $actual = $this->generator->generate(GetFooItemAdd::CLASS); 74 | $expect = '/foo_item/add'; 75 | $this->assertSame($expect, $actual); 76 | 77 | // root 78 | $actual = $this->generator->generate(Get::CLASS); 79 | $expect = '/'; 80 | $this->assertSame($expect, $actual); 81 | 82 | // parameter types 83 | $actual = $this->generator->generate( 84 | GetFooItemExtras::CLASS, 85 | 1, 86 | 2.3, 87 | 'bar', 88 | 'baz', 89 | true, 90 | ['a', 'b', 'c'] 91 | ); 92 | $expect = '/foo_item/1/extras/2.3/bar/baz/1/a,b,c'; 93 | $this->assertSame($expect, $actual); 94 | 95 | // variadics 96 | $actual = $this->generator->generate( 97 | GetFooItemVariadic::CLASS, 98 | 1, 99 | 'foo', 100 | 'bar', 101 | 'baz' 102 | ); 103 | $expect = '/foo_item/1/variadic/foo/bar/baz'; 104 | $this->assertSame($expect, $actual); 105 | 106 | // optionals 107 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS); 108 | $expect = '/foo_items/archive'; 109 | $this->assertSame($expect, $actual); 110 | 111 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970'); 112 | $expect = '/foo_items/archive/1970'; 113 | $this->assertSame($expect, $actual); 114 | 115 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970', '11'); 116 | $expect = '/foo_items/archive/1970/11'; 117 | $this->assertSame($expect, $actual); 118 | 119 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970', '11', '07'); 120 | $expect = '/foo_items/archive/1970/11/07'; 121 | $this->assertSame($expect, $actual); 122 | } 123 | 124 | public function testDumper() 125 | { 126 | $expect = array ( 127 | '/' => 128 | array ( 129 | 'Get' => 'AutoRoute\\HttpIgnore\\Get', 130 | 'Head' => 'AutoRoute\\HttpIgnore\\Get', 131 | ), 132 | '/admin/dashboard' => 133 | array ( 134 | 'Get' => 'AutoRoute\\HttpIgnore\\Admin\\Dashboard\\GetAdminDashboard', 135 | 'Head' => 'AutoRoute\\HttpIgnore\\Admin\\Dashboard\\GetAdminDashboard', 136 | ), 137 | '/foo_item' => 138 | array ( 139 | 'Post' => 'AutoRoute\\HttpIgnore\\FooItem\\PostFooItem', 140 | ), 141 | '/foo_item/add' => 142 | array ( 143 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItem\\Add\\GetFooItemAdd', 144 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItem\\Add\\GetFooItemAdd', 145 | ), 146 | '/foo_item/{int:id}' => 147 | array ( 148 | 'Delete' => 'AutoRoute\\HttpIgnore\\FooItem\\DeleteFooItem', 149 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItem\\GetFooItem', 150 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItem\\GetFooItem', 151 | 'Patch' => 'AutoRoute\\HttpIgnore\\FooItem\\PatchFooItem', 152 | ), 153 | '/foo_item/{int:id}/edit' => 154 | array ( 155 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItem\\Edit\\GetFooItemEdit', 156 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItem\\Edit\\GetFooItemEdit', 157 | ), 158 | '/foo_item/{int:id}/extras/{float:foo}/{string:bar}/{mixed:baz}/{bool:dib}[/{array:gir}]' => 159 | array ( 160 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItem\\Extras\\GetFooItemExtras', 161 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItem\\Extras\\GetFooItemExtras', 162 | ), 163 | '/foo_item/{int:id}/variadic[/{...string:more}]' => 164 | array ( 165 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItem\\Variadic\\GetFooItemVariadic', 166 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItem\\Variadic\\GetFooItemVariadic', 167 | ), 168 | '/foo_items/archive[/{int:year}][/{int:month}][/{int:day}]' => 169 | array ( 170 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItems\\Archive\\GetFooItemsArchive', 171 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItems\\Archive\\GetFooItemsArchive', 172 | ), 173 | '/foo_items[/{int:page}]' => 174 | array ( 175 | 'Get' => 'AutoRoute\\HttpIgnore\\FooItems\\GetFooItems', 176 | 'Head' => 'AutoRoute\\HttpIgnore\\FooItems\\GetFooItems', 177 | ), 178 | '/repo/{string:ownerName}/{string:repoName}' => 179 | array ( 180 | 'Get' => 'AutoRoute\\HttpIgnore\\Repo\\GetRepo', 181 | 'Head' => 'AutoRoute\\HttpIgnore\\Repo\\GetRepo', 182 | ), 183 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}' => 184 | array ( 185 | 'Get' => 'AutoRoute\\HttpIgnore\\Repo\\Issue\\GetRepoIssue', 186 | 'Head' => 'AutoRoute\\HttpIgnore\\Repo\\Issue\\GetRepoIssue', 187 | ), 188 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/add' => 189 | array ( 190 | 'Get' => 'AutoRoute\\HttpIgnore\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 191 | 'Head' => 'AutoRoute\\HttpIgnore\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 192 | ), 193 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/{int:commentNum}' => 194 | array ( 195 | 'Get' => 'AutoRoute\\HttpIgnore\\Repo\\Issue\\Comment\\GetRepoIssueComment', 196 | 'Head' => 'AutoRoute\\HttpIgnore\\Repo\\Issue\\Comment\\GetRepoIssueComment', 197 | ), 198 | ); 199 | 200 | $actual = $this->dumper->dump(); 201 | $this->assertSame($expect, $actual); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/LoggerTest.php: -------------------------------------------------------------------------------- 1 | debug('foo'); 12 | $expect = ['(debug) foo']; 13 | $actual = $logger->getMessages(); 14 | $this->assertSame($expect, $actual); 15 | 16 | $logger->reset(); 17 | $expect = []; 18 | $actual = $logger->getMessages(); 19 | $this->assertSame($expect, $actual); 20 | 21 | $logger->debug('bar'); 22 | $expect = ['(debug) bar']; 23 | $actual = $logger->getMessages(); 24 | $this->assertSame($expect, $actual); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/ReflectorTest.php: -------------------------------------------------------------------------------- 1 | expectException(Exception\NotFound::CLASS); 16 | $this->expectExceptionMessage("Class not found: NoSuchClass"); 17 | $reflector->getConstructorParameters('NoSuchClass'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/RouteTest.php: -------------------------------------------------------------------------------- 1 | 'FooClass', 20 | 'method' => '__invoke', 21 | 'arguments' => [ 22 | 'arg0', 23 | 'arg1', 24 | ], 25 | 'error' => null, 26 | 'exception' => null, 27 | 'headers' => [], 28 | 'messages' => [], 29 | ]; 30 | 31 | $this->assertSame($expect, $route->asArray()); 32 | 33 | $expect = json_encode($expect); 34 | $actual = json_encode($route); 35 | $this->assertSame($expect, $actual); 36 | } 37 | 38 | public function testJsonEncode() 39 | { 40 | $route = new Route( 41 | 'FooClass', 42 | '__invoke', 43 | ['arg0', 'arg1'], 44 | LogicException::CLASS, 45 | new LogicException('fake message', 88), 46 | ); 47 | 48 | $actual = json_decode(json_encode($route)); 49 | $this->assertSame('FooClass', $actual->class); 50 | $this->assertSame('__invoke', $actual->method); 51 | $this->assertSame(['arg0', 'arg1'], $actual->arguments); 52 | $this->assertSame(LogicException::CLASS, $actual->error); 53 | $this->assertSame(LogicException::CLASS, $actual->exception->class); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/RouterTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectClass, $actual->error); 34 | $this->assertInstanceOf($expectClass, $actual->exception); 35 | $this->assertSame($expectMessage, $actual->exception->getMessage()); 36 | } 37 | 38 | protected function setUp() : void 39 | { 40 | $autoRoute = new AutoRoute( 41 | namespace: 'AutoRoute\\Http', 42 | directory: __DIR__ . DIRECTORY_SEPARATOR . 'Http', 43 | baseUrl: '/api/', 44 | ); 45 | 46 | $this->router = $autoRoute->getRouter(); 47 | } 48 | 49 | public function testGetLogger() 50 | { 51 | $this->assertInstanceOf(Logger::CLASS, $this->router->getLogger()); 52 | } 53 | 54 | public function testDeepPaths() 55 | { 56 | $route = $this->router->route('GET', '/api/repo/pmjones/auto-route'); 57 | $this->assertSame(GetRepo::CLASS, $route->class); 58 | $this->assertSame(['pmjones', 'auto-route'], $route->arguments); 59 | 60 | $route = $this->router->route('HEAD', '/api/repo/pmjones/auto-route'); 61 | $this->assertSame(GetRepo::CLASS, $route->class); 62 | $this->assertSame(['pmjones', 'auto-route'], $route->arguments); 63 | 64 | $route = $this->router->route('GET', '/api/repo/pmjones/auto-route/issue/11'); 65 | $this->assertSame(GetRepoIssue::CLASS, $route->class); 66 | $this->assertSame(['pmjones', 'auto-route', 11], $route->arguments); 67 | 68 | $route = $this->router->route('HEAD', '/api/repo/pmjones/auto-route/issue/11'); 69 | $this->assertSame(GetRepoIssue::CLASS, $route->class); 70 | $this->assertSame(['pmjones', 'auto-route', 11], $route->arguments); 71 | 72 | $route = $this->router->route('GET', '/api/repo/pmjones/auto-route/issue/11/comment/22'); 73 | $this->assertSame(GetRepoIssueComment::CLASS, $route->class); 74 | $this->assertSame(['pmjones', 'auto-route', 11, 22], $route->arguments); 75 | 76 | $route = $this->router->route('HEAD', '/api/repo/pmjones/auto-route/issue/11/comment/22'); 77 | $this->assertSame(GetRepoIssueComment::CLASS, $route->class); 78 | $this->assertSame(['pmjones', 'auto-route', 11, 22], $route->arguments); 79 | 80 | $route = $this->router->route('GET', '/api/repo/pmjones/auto-route/issue/11/comment/add'); 81 | $this->assertSame(GetRepoIssueCommentAdd::CLASS, $route->class); 82 | $this->assertSame(['pmjones', 'auto-route', 11], $route->arguments); 83 | 84 | $route = $this->router->route('HEAD', '/api/repo/pmjones/auto-route/issue/11/comment/add'); 85 | $this->assertSame(GetRepoIssueCommentAdd::CLASS, $route->class); 86 | $this->assertSame(['pmjones', 'auto-route', 11], $route->arguments); 87 | 88 | $route = $this->router->route('GET', '/api/repo/issue/comment/pmjones/auto-route/11/22'); 89 | $this->assertRouteError( 90 | Exception\NotFound::CLASS, 91 | "Not a known namespace: AutoRoute\Http\Repo\Pmjones", 92 | $route 93 | ); 94 | } 95 | 96 | public function testHappyPaths() 97 | { 98 | $route = $this->router->route('GET', '/api/foo-item/add'); 99 | $this->assertSame(GetFooItemAdd::CLASS, $route->class); 100 | $this->assertSame([], $route->arguments); 101 | 102 | $route = $this->router->route('HEAD', '/api/foo-item/add'); 103 | $this->assertSame(HeadFooItemAdd::CLASS, $route->class); 104 | $this->assertSame([], $route->arguments); 105 | 106 | $route = $this->router->route('HEAD', '/api/foo-item/add'); 107 | $this->assertSame(HeadFooItemAdd::CLASS, $route->class); 108 | $this->assertSame([], $route->arguments); 109 | 110 | $route = $this->router->route('GET', '/api/foo-item/1'); 111 | $this->assertSame(GetFooItem::CLASS, $route->class); 112 | $this->assertSame([1], $route->arguments); 113 | 114 | $route = $this->router->route('HEAD', '/api/foo-item/1'); 115 | $this->assertSame(HeadFooItem::CLASS, $route->class); 116 | $this->assertSame([1], $route->arguments); 117 | 118 | $route = $this->router->route('GET', '/api/foo-item/1/edit'); 119 | $this->assertSame(GetFooItemEdit::CLASS, $route->class); 120 | $this->assertSame([1], $route->arguments); 121 | } 122 | 123 | public function testWithoutBaseUrl() 124 | { 125 | $autoRoute = new AutoRoute( 126 | 'AutoRoute\Http\\', 127 | __DIR__ . '/Http/' 128 | ); 129 | 130 | $router = $autoRoute->getRouter(); 131 | 132 | $route = $router->route('GET', '/foo-item/1/edit'); 133 | $this->assertSame(GetFooItemEdit::CLASS, $route->class); 134 | $this->assertSame([1], $route->arguments); 135 | 136 | $route = $router->route('GET', '/foo-item/1'); 137 | $this->assertSame(GetFooItem::CLASS, $route->class); 138 | $this->assertSame([1], $route->arguments); 139 | } 140 | 141 | public function testIncorrectBaseUrl() 142 | { 143 | $route = $this->router->route('GET', '/wrong-base-url/foo-item/1/edit'); 144 | $this->assertRouteError( 145 | Exception\NotFound::CLASS, 146 | "Expected base URL /api, actually /wro", 147 | $route 148 | ); 149 | } 150 | 151 | public function testInvalidNamespace() 152 | { 153 | $route = $this->router->route('GET', '/api/admin/no-such-url'); 154 | $this->assertRouteError( 155 | Exception\NotFound::CLASS, 156 | "Not a known namespace: AutoRoute\Http\Admin\NoSuchUrl", 157 | $route 158 | ); 159 | } 160 | 161 | public function testInvalidNamespace_emptySegment() 162 | { 163 | $route = $this->router->route('GET', '/api/admin//dashboard'); 164 | $this->assertRouteError( 165 | Exception\NotFound::CLASS, 166 | 'Cannot convert empty segment to namespace part', 167 | $route 168 | ); 169 | } 170 | 171 | public function testInvalidNamepace_dotsNotAllowed() 172 | { 173 | $route = $this->router->route('GET', '/api/../etc/passwd'); 174 | $this->assertRouteError( 175 | Exception\NotFound::CLASS, 176 | "Directory dots not allowed in segments", 177 | $route 178 | ); 179 | } 180 | 181 | public function testInvalidNamespace_tailSegment() 182 | { 183 | $route = $this->router->route('GET', '/api/foo-item/1/no-such-url'); 184 | $this->assertRouteError( 185 | Exception\NotFound::CLASS, 186 | "Not a known namespace: AutoRoute\Http\FooItem\NoSuchUrl", 187 | $route 188 | ); 189 | } 190 | 191 | public function testInvalidNamespace_tooManySegments() 192 | { 193 | $route = $this->router->route('GET', '/api/foo-item/1/2/3/edit'); 194 | $this->assertRouteError( 195 | Exception\NotFound::CLASS, 196 | "Not a known namespace: AutoRoute\Http\FooItem\\2", 197 | $route 198 | ); 199 | } 200 | 201 | public function testInvalidNamespace_notEnoughArguments() 202 | { 203 | $route = $this->router->route('GET', '/api/foo-item/'); 204 | $this->assertRouteError( 205 | Exception\NotFound::CLASS, 206 | "AutoRoute\Http\FooItem\GetFooItem needs 1 argument(s), 0 found", 207 | $route 208 | ); 209 | } 210 | 211 | public function testClassNotFound_emptyNamespace() 212 | { 213 | $route = $this->router->route('GET', '/api/admin/empty'); 214 | $this->assertRouteError( 215 | Exception\NotFound::CLASS, 216 | "No actions found in namespace AutoRoute\Http\Admin\Empty", 217 | $route 218 | ); 219 | } 220 | 221 | public function testClassNotFound_methodNotAllowed() 222 | { 223 | $route = $this->router->route('PUT', '/api/foo-item'); 224 | 225 | $this->assertRouteError( 226 | Exception\MethodNotAllowed::CLASS, 227 | 'PUT action not found in namespace AutoRoute\Http\FooItem', 228 | $route 229 | ); 230 | 231 | $this->assertSame( 232 | ['allowed' => 'DELETE,GET,HEAD,PATCH,POST'], 233 | $route->headers 234 | ); 235 | 236 | $route = $this->router->route('PUT', '/api/foo-items'); 237 | 238 | $this->assertRouteError( 239 | Exception\MethodNotAllowed::CLASS, 240 | 'PUT action not found in namespace AutoRoute\Http\FooItems', 241 | $route 242 | ); 243 | 244 | $this->assertSame( 245 | ['allowed' => 'GET,HEAD'], 246 | $route->headers 247 | ); 248 | } 249 | 250 | public function testTooManySegments() 251 | { 252 | $route = $this->router->route('GET', '/api/foo-item/1/extras/1/2.0/bar/true/a,b,c/one-too-many'); 253 | $this->assertRouteError( 254 | Exception\NotFound::CLASS, 255 | 'Too many router segments for AutoRoute\Http\FooItem\Extras\GetFooItemExtras', 256 | $route 257 | ); 258 | } 259 | 260 | public function testOptionalParams() 261 | { 262 | $route = $this->router->route('GET', '/api/foo-items'); 263 | $this->assertSame(GetFooItems::CLASS, $route->class); 264 | $this->assertSame([], $route->arguments); 265 | 266 | $route = $this->router->route('GET', '/api/foo-items/2'); 267 | $this->assertSame(GetFooItems::CLASS, $route->class); 268 | $this->assertSame([2], $route->arguments); 269 | 270 | $route = $this->router->route('GET', '/api/foo-items/archive'); 271 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 272 | $this->assertSame([], $route->arguments); 273 | 274 | $route = $this->router->route('GET', '/api/foo-items/archive/1979'); 275 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 276 | $this->assertSame([1979], $route->arguments); 277 | 278 | $route = $this->router->route('GET', '/api/foo-items/archive/1979/11'); 279 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 280 | $this->assertSame([1979, 11], $route->arguments); 281 | 282 | $route = $this->router->route('GET', '/api/foo-items/archive/1979/11/07'); 283 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 284 | $this->assertSame([1979, 11, 7], $route->arguments); 285 | } 286 | 287 | public function testRoot() 288 | { 289 | $route = $this->router->route('GET', '/api'); 290 | $this->assertSame(Get::CLASS, $route->class); 291 | $this->assertEmpty($route->error); 292 | } 293 | 294 | public function testRoot_requiredParam() 295 | { 296 | $route = $this->router->route('GETREQUIRED', '/api/catch-me'); 297 | $this->assertSame(Getrequired::CLASS, $route->class); 298 | $this->assertSame(['catch-me'], $route->arguments); 299 | $this->assertEmpty($route->error); 300 | 301 | // too many segments 302 | $route = $this->router->route('GETREQUIRED', '/api/catch-me/too-many'); 303 | $this->assertRouteError( 304 | Exception\NotFound::CLASS, 305 | 'Too many router segments for AutoRoute\Http\Getrequired', 306 | $route 307 | ); 308 | } 309 | 310 | public function testRoot_optionalParam() 311 | { 312 | $route = $this->router->route('GETOPTIONAL', '/api'); 313 | $this->assertSame(Getoptional::CLASS, $route->class); 314 | $this->assertEmpty($route->error); 315 | 316 | $route = $this->router->route('GETOPTIONAL', '/api/catch-me'); 317 | $this->assertSame(Getoptional::CLASS, $route->class); 318 | $this->assertSame(['catch-me'], $route->arguments); 319 | $this->assertEmpty($route->error); 320 | 321 | // too many segments 322 | $route = $this->router->route('GETOPTIONAL', '/api/catch-me/too-many'); 323 | $this->assertRouteError( 324 | Exception\NotFound::CLASS, 325 | 'Too many router segments for AutoRoute\Http\Getoptional', 326 | $route 327 | ); 328 | } 329 | 330 | public function testRoot_variadicParam() 331 | { 332 | $route = $this->router->route('GETVARIADIC', '/api'); 333 | $this->assertSame(Getvariadic::CLASS, $route->class); 334 | $this->assertEmpty($route->error); 335 | 336 | $route = $this->router->route('GETVARIADIC', '/api/catch-me/if-you/can'); 337 | $this->assertSame(Getvariadic::CLASS, $route->class); 338 | $this->assertSame(['catch-me', 'if-you', 'can'], $route->arguments); 339 | $this->assertEmpty($route->error); 340 | } 341 | 342 | public function testVariadicParam() 343 | { 344 | $route = $this->router->route('GET', '/api/foo-item/1/variadic/bar/baz/dib'); 345 | $this->assertSame(GetFooItemVariadic::CLASS, $route->class); 346 | $this->assertSame([1, 'bar', 'baz', 'dib'], $route->arguments); 347 | 348 | $route = $this->router->route('HEAD', '/api/foo-item/1/variadic/bar/baz/dib'); 349 | $this->assertSame(GetFooItemVariadic::CLASS, $route->class); 350 | $this->assertSame([1, 'bar', 'baz', 'dib'], $route->arguments); 351 | } 352 | 353 | public function testParamCasting() 354 | { 355 | // true 356 | $route = $this->router->route('GET', '/api/foo-item/1/extras/2/3/4/true/5,6,7'); 357 | $this->assertSame(GetFooItemExtras::CLASS, $route->class); 358 | $this->assertSame([1, 2.0, '3', '4', true, ['5', '6', '7']], $route->arguments); 359 | 360 | // false 361 | $route = $this->router->route('GET', '/api/foo-item/1/extras/2/3/4/false/5,6,7'); 362 | $this->assertSame(GetFooItemExtras::CLASS, $route->class); 363 | $this->assertSame([1, 2.0, '3', '4', false, ['5', '6', '7']], $route->arguments); 364 | } 365 | 366 | public function testBadIntParam() 367 | { 368 | $route = $this->router->route('GET', '/api/foo-item/z/extras/2/3/4/true/5,6,7'); 369 | $this->assertRouteError( 370 | Exception\InvalidArgument::CLASS, 371 | "Expected numeric integer argument for AutoRoute\Http\FooItem\GetFooItem::__invoke() parameter 0 (\$id), actually 'z'", 372 | $route 373 | ); 374 | } 375 | 376 | public function testBadFloatParam() 377 | { 378 | $route = $this->router->route('GET', '/api/foo-item/1/extras/z/3/4/true/5,6,7'); 379 | $this->assertRouteError( 380 | Exception\InvalidArgument::CLASS, 381 | "Expected numeric float argument for AutoRoute\Http\FooItem\Extras\GetFooItemExtras::__invoke() parameter 1 (\$foo), actually 'z'", 382 | $route 383 | ); 384 | } 385 | 386 | public function testBadBoolParam() 387 | { 388 | $route = $this->router->route('GET', '/api/foo-item/1/extras/2/3/4/z/5,6,7'); 389 | $this->assertRouteError( 390 | Exception\InvalidArgument::CLASS, 391 | "Expected boolean-equivalent argument for AutoRoute\Http\FooItem\Extras\GetFooItemExtras::__invoke() parameter 4 (\$dib), actually 'z'", 392 | $route 393 | ); 394 | } 395 | 396 | public function testEmptyParamEarly() 397 | { 398 | $route = $this->router->route('GET', '/api/foo-item/ /extras/2/3/4true//5,6,7'); 399 | $this->assertRouteError( 400 | Exception\InvalidArgument::CLASS, 401 | "Expected non-blank argument for AutoRoute\Http\FooItem\GetFooItem::__invoke() parameter 0 (\$id), actually ' '", 402 | $route 403 | ); 404 | } 405 | 406 | public function testEmptyParamLater() 407 | { 408 | $route = $this->router->route('GET', '/api/foo-item/1/extras/ /3/4/true/5,6,7'); 409 | $this->assertRouteError( 410 | Exception\InvalidArgument::CLASS, 411 | "Expected non-blank argument for AutoRoute\Http\FooItem\Extras\GetFooItemExtras::__invoke() parameter 1 (\$foo), actually ' '", 412 | $route 413 | ); 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /tests/SuffixTest.php: -------------------------------------------------------------------------------- 1 | router = $autoRoute->getRouter(); 37 | $this->generator = $autoRoute->getGenerator(); 38 | $this->dumper = $autoRoute->getDumper(); 39 | } 40 | 41 | public function testRouter() 42 | { 43 | $route = $this->router->route('GET', '/foo-item/add'); 44 | $this->assertSame(GetFooItemAddAction::CLASS, $route->class); 45 | $this->assertSame([], $route->arguments); 46 | 47 | $route = $this->router->route('GET', '/foo-item/1'); 48 | $this->assertSame(GetFooItemAction::CLASS, $route->class); 49 | $this->assertSame([1], $route->arguments); 50 | 51 | $route = $this->router->route('GET', '/foo-item/1/edit'); 52 | $this->assertSame(GetFooItemEditAction::CLASS, $route->class); 53 | $this->assertSame([1], $route->arguments); 54 | } 55 | 56 | public function testGenerator() 57 | { 58 | $actual = $this->generator->generate(GetFooItemEditAction::CLASS, 1); 59 | $expect = '/foo-item/1/edit'; 60 | $this->assertSame($expect, $actual); 61 | 62 | $actual = $this->generator->generate(GetAdminDashboardAction::CLASS); 63 | $expect = '/admin/dashboard'; 64 | $this->assertSame($expect, $actual); 65 | 66 | // repeat for coverage 67 | $actual = $this->generator->generate(GetAdminDashboardAction::CLASS); 68 | $expect = '/admin/dashboard'; 69 | $this->assertSame($expect, $actual); 70 | 71 | $actual = $this->generator->generate(GetFooItemAddAction::CLASS); 72 | $expect = '/foo-item/add'; 73 | $this->assertSame($expect, $actual); 74 | 75 | // root 76 | $actual = $this->generator->generate(GetAction::CLASS); 77 | $expect = '/'; 78 | $this->assertSame($expect, $actual); 79 | 80 | // parameter types 81 | $actual = $this->generator->generate( 82 | GetFooItemExtrasAction::CLASS, 83 | 1, 84 | 2.3, 85 | 'bar', 86 | 'baz', 87 | true, 88 | ['a', 'b', 'c'] 89 | ); 90 | $expect = '/foo-item/1/extras/2.3/bar/baz/1/a,b,c'; 91 | $this->assertSame($expect, $actual); 92 | 93 | // variadics 94 | $actual = $this->generator->generate( 95 | GetFooItemVariadicAction::CLASS, 96 | 1, 97 | 'foo', 98 | 'bar', 99 | 'baz' 100 | ); 101 | $expect = '/foo-item/1/variadic/foo/bar/baz'; 102 | $this->assertSame($expect, $actual); 103 | 104 | // optionals 105 | $actual = $this->generator->generate(GetFooItemsArchiveAction::CLASS); 106 | $expect = '/foo-items/archive'; 107 | $this->assertSame($expect, $actual); 108 | 109 | $actual = $this->generator->generate(GetFooItemsArchiveAction::CLASS, '1970'); 110 | $expect = '/foo-items/archive/1970'; 111 | $this->assertSame($expect, $actual); 112 | 113 | $actual = $this->generator->generate(GetFooItemsArchiveAction::CLASS, '1970', '11'); 114 | $expect = '/foo-items/archive/1970/11'; 115 | $this->assertSame($expect, $actual); 116 | 117 | $actual = $this->generator->generate(GetFooItemsArchiveAction::CLASS, '1970', '11', '07'); 118 | $expect = '/foo-items/archive/1970/11/07'; 119 | $this->assertSame($expect, $actual); 120 | } 121 | 122 | public function testDumper() 123 | { 124 | $expect = array ( 125 | '/' => 126 | array ( 127 | 'Get' => 'AutoRoute\\HttpSuffix\\GetAction', 128 | 'Head' => 'AutoRoute\\HttpSuffix\\GetAction', 129 | ), 130 | '/admin/dashboard' => 131 | array ( 132 | 'Get' => 'AutoRoute\\HttpSuffix\\Admin\\Dashboard\\GetAdminDashboardAction', 133 | 'Head' => 'AutoRoute\\HttpSuffix\\Admin\\Dashboard\\GetAdminDashboardAction', 134 | ), 135 | '/foo-item' => 136 | array ( 137 | 'Post' => 'AutoRoute\\HttpSuffix\\FooItem\\PostFooItemAction', 138 | ), 139 | '/foo-item/add' => 140 | array ( 141 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItem\\Add\\GetFooItemAddAction', 142 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItem\\Add\\GetFooItemAddAction', 143 | ), 144 | '/foo-item/{int:id}' => 145 | array ( 146 | 'Delete' => 'AutoRoute\\HttpSuffix\\FooItem\\DeleteFooItemAction', 147 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItem\\GetFooItemAction', 148 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItem\\GetFooItemAction', 149 | 'Patch' => 'AutoRoute\\HttpSuffix\\FooItem\\PatchFooItemAction', 150 | ), 151 | '/foo-item/{int:id}/edit' => 152 | array ( 153 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItem\\Edit\\GetFooItemEditAction', 154 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItem\\Edit\\GetFooItemEditAction', 155 | ), 156 | '/foo-item/{int:id}/extras/{float:foo}/{string:bar}/{mixed:baz}/{bool:dib}[/{array:gir}]' => 157 | array ( 158 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItem\\Extras\\GetFooItemExtrasAction', 159 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItem\\Extras\\GetFooItemExtrasAction', 160 | ), 161 | '/foo-item/{int:id}/variadic[/{...string:more}]' => 162 | array ( 163 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItem\\Variadic\\GetFooItemVariadicAction', 164 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItem\\Variadic\\GetFooItemVariadicAction', 165 | ), 166 | '/foo-items/archive[/{int:year}][/{int:month}][/{int:day}]' => 167 | array ( 168 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItems\\Archive\\GetFooItemsArchiveAction', 169 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItems\\Archive\\GetFooItemsArchiveAction', 170 | ), 171 | '/foo-items[/{int:page}]' => 172 | array ( 173 | 'Get' => 'AutoRoute\\HttpSuffix\\FooItems\\GetFooItemsAction', 174 | 'Head' => 'AutoRoute\\HttpSuffix\\FooItems\\GetFooItemsAction', 175 | ), 176 | '/repo/{string:ownerName}/{string:repoName}' => 177 | array ( 178 | 'Get' => 'AutoRoute\\HttpSuffix\\Repo\\GetRepoAction', 179 | 'Head' => 'AutoRoute\\HttpSuffix\\Repo\\GetRepoAction', 180 | ), 181 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}' => 182 | array ( 183 | 'Get' => 'AutoRoute\\HttpSuffix\\Repo\\Issue\\GetRepoIssueAction', 184 | 'Head' => 'AutoRoute\\HttpSuffix\\Repo\\Issue\\GetRepoIssueAction', 185 | ), 186 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/add' => 187 | array ( 188 | 'Get' => 'AutoRoute\\HttpSuffix\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAddAction', 189 | 'Head' => 'AutoRoute\\HttpSuffix\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAddAction', 190 | ), 191 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/{int:commentNum}' => 192 | array ( 193 | 'Get' => 'AutoRoute\\HttpSuffix\\Repo\\Issue\\Comment\\GetRepoIssueCommentAction', 194 | 'Head' => 'AutoRoute\\HttpSuffix\\Repo\\Issue\\Comment\\GetRepoIssueCommentAction', 195 | ), 196 | ); 197 | 198 | $actual = $this->dumper->dump(); 199 | $this->assertSame($expect, $actual); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /tests/Value/Id.php: -------------------------------------------------------------------------------- 1 | id = $id; 11 | } 12 | 13 | public function get() : int 14 | { 15 | return $this->id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/Value/OwnerRepo.php: -------------------------------------------------------------------------------- 1 | ownerName . '/' . $this->repoName; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Value/Ymd.php: -------------------------------------------------------------------------------- 1 | year; 18 | } 19 | 20 | public function getMonth() : ?int 21 | { 22 | return $this->month; 23 | } 24 | 25 | public function getDay() : ?int 26 | { 27 | return $this->day; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/ValuedTest.php: -------------------------------------------------------------------------------- 1 | router = $autoRoute->getRouter(); 37 | $this->generator = $autoRoute->getGenerator(); 38 | $this->dumper = $autoRoute->getDumper(); 39 | } 40 | 41 | public function testRouter() 42 | { 43 | $route = $this->router->route('GET', '/foo-item/1'); 44 | $this->assertSame(GetFooItem::CLASS, $route->class); 45 | $this->assertInstanceOf(Id::CLASS, $route->arguments[0]); 46 | $this->assertSame(1, $route->arguments[0]->get()); 47 | 48 | $route = $this->router->route('GET', '/foo-items/archive'); 49 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 50 | 51 | $route = $this->router->route('GET', '/foo-items/archive/1979'); 52 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 53 | 54 | $route = $this->router->route('GET', '/foo-items/archive/1979/11'); 55 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 56 | 57 | $route = $this->router->route('GET', '/foo-items/archive/1979/11/07'); 58 | $this->assertSame(GetFooItemsArchive::CLASS, $route->class); 59 | } 60 | 61 | public function testGenerator() 62 | { 63 | $actual = $this->generator->generate(GetFooItemEdit::CLASS, 1); 64 | $expect = '/foo-item/1/edit'; 65 | $this->assertSame($expect, $actual); 66 | 67 | $actual = $this->generator->generate(GetAdminDashboard::CLASS); 68 | $expect = '/admin/dashboard'; 69 | $this->assertSame($expect, $actual); 70 | 71 | // repeat for coverage 72 | $actual = $this->generator->generate(GetAdminDashboard::CLASS); 73 | $expect = '/admin/dashboard'; 74 | $this->assertSame($expect, $actual); 75 | 76 | $actual = $this->generator->generate(GetFooItemAdd::CLASS); 77 | $expect = '/foo-item/add'; 78 | $this->assertSame($expect, $actual); 79 | 80 | // root 81 | $actual = $this->generator->generate(Get::CLASS); 82 | $expect = '/'; 83 | $this->assertSame($expect, $actual); 84 | 85 | // parameter types 86 | $actual = $this->generator->generate( 87 | GetFooItemExtras::CLASS, 88 | 1, 89 | 2.3, 90 | 'bar', 91 | 'baz', 92 | true, 93 | ['a', 'b', 'c'] 94 | ); 95 | $expect = '/foo-item/1/extras/2.3/bar/baz/1/a,b,c'; 96 | $this->assertSame($expect, $actual); 97 | 98 | // variadics 99 | $actual = $this->generator->generate( 100 | GetFooItemVariadic::CLASS, 101 | 1, 102 | 'foo', 103 | 'bar', 104 | 'baz' 105 | ); 106 | $expect = '/foo-item/1/variadic/foo/bar/baz'; 107 | $this->assertSame($expect, $actual); 108 | 109 | // optionals 110 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS); 111 | $expect = '/foo-items/archive'; 112 | $this->assertSame($expect, $actual); 113 | 114 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970'); 115 | $expect = '/foo-items/archive/1970'; 116 | $this->assertSame($expect, $actual); 117 | 118 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970', '11'); 119 | $expect = '/foo-items/archive/1970/11'; 120 | $this->assertSame($expect, $actual); 121 | 122 | $actual = $this->generator->generate(GetFooItemsArchive::CLASS, '1970', '11', '07'); 123 | $expect = '/foo-items/archive/1970/11/07'; 124 | $this->assertSame($expect, $actual); 125 | } 126 | 127 | public function testDumper() 128 | { 129 | $expect = array ( 130 | '/' => 131 | array ( 132 | 'Get' => 'AutoRoute\\HttpValued\\Get', 133 | 'Head' => 'AutoRoute\\HttpValued\\Get', 134 | ), 135 | '/admin/dashboard' => 136 | array ( 137 | 'Get' => 'AutoRoute\\HttpValued\\Admin\\Dashboard\\GetAdminDashboard', 138 | 'Head' => 'AutoRoute\\HttpValued\\Admin\\Dashboard\\GetAdminDashboard', 139 | ), 140 | '/foo-item' => 141 | array ( 142 | 'Post' => 'AutoRoute\\HttpValued\\FooItem\\PostFooItem', 143 | ), 144 | '/foo-item/add' => 145 | array ( 146 | 'Get' => 'AutoRoute\\HttpValued\\FooItem\\Add\\GetFooItemAdd', 147 | 'Head' => 'AutoRoute\\HttpValued\\FooItem\\Add\\HeadFooItemAdd', 148 | ), 149 | '/foo-item/{int:id}' => 150 | array ( 151 | 'Delete' => 'AutoRoute\\HttpValued\\FooItem\\DeleteFooItem', 152 | 'Get' => 'AutoRoute\\HttpValued\\FooItem\\GetFooItem', 153 | 'Head' => 'AutoRoute\\HttpValued\\FooItem\\HeadFooItem', 154 | 'Patch' => 'AutoRoute\\HttpValued\\FooItem\\PatchFooItem', 155 | ), 156 | '/foo-item/{int:id}/edit' => 157 | array ( 158 | 'Get' => 'AutoRoute\\HttpValued\\FooItem\\Edit\\GetFooItemEdit', 159 | 'Head' => 'AutoRoute\\HttpValued\\FooItem\\Edit\\GetFooItemEdit', 160 | ), 161 | '/foo-item/{int:id}/extras/{float:foo}/{string:bar}/{mixed:baz}/{bool:dib}[/{array:gir}]' => 162 | array ( 163 | 'Get' => 'AutoRoute\\HttpValued\\FooItem\\Extras\\GetFooItemExtras', 164 | 'Head' => 'AutoRoute\\HttpValued\\FooItem\\Extras\\GetFooItemExtras', 165 | ), 166 | '/foo-item/{int:id}/variadic[/{...string:more}]' => 167 | array ( 168 | 'Get' => 'AutoRoute\\HttpValued\\FooItem\\Variadic\\GetFooItemVariadic', 169 | 'Head' => 'AutoRoute\\HttpValued\\FooItem\\Variadic\\GetFooItemVariadic', 170 | ), 171 | '/foo-items/archive[/{int:year}][/{int:month}][/{int:day}]' => 172 | array ( 173 | 'Get' => 'AutoRoute\\HttpValued\\FooItems\\Archive\\GetFooItemsArchive', 174 | 'Head' => 'AutoRoute\\HttpValued\\FooItems\\Archive\\GetFooItemsArchive', 175 | ), 176 | '/foo-items[/{int:page}]' => 177 | array ( 178 | 'Get' => 'AutoRoute\\HttpValued\\FooItems\\GetFooItems', 179 | 'Head' => 'AutoRoute\\HttpValued\\FooItems\\GetFooItems', 180 | ), 181 | '/repo/{string:ownerName}/{string:repoName}' => 182 | array ( 183 | 'Get' => 'AutoRoute\\HttpValued\\Repo\\GetRepo', 184 | 'Head' => 'AutoRoute\\HttpValued\\Repo\\GetRepo', 185 | ), 186 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}' => 187 | array ( 188 | 'Get' => 'AutoRoute\\HttpValued\\Repo\\Issue\\GetRepoIssue', 189 | 'Head' => 'AutoRoute\\HttpValued\\Repo\\Issue\\GetRepoIssue', 190 | ), 191 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/add' => 192 | array ( 193 | 'Get' => 'AutoRoute\\HttpValued\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 194 | 'Head' => 'AutoRoute\\HttpValued\\Repo\\Issue\\Comment\\Add\\GetRepoIssueCommentAdd', 195 | ), 196 | '/repo/{string:ownerName}/{string:repoName}/issue/{int:issueNum}/comment/{int:commentNum}' => 197 | array ( 198 | 'Get' => 'AutoRoute\\HttpValued\\Repo\\Issue\\Comment\\GetRepoIssueComment', 199 | 'Head' => 'AutoRoute\\HttpValued\\Repo\\Issue\\Comment\\GetRepoIssueComment', 200 | ), 201 | ); 202 | 203 | $actual = $this->dumper->dump(); 204 | $this->assertSame($expect, $actual); 205 | } 206 | } 207 | --------------------------------------------------------------------------------