diff --git a/README.md b/README.md index 8aee603..dc18054 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ The **icanboogie/routing** package handles URL rewriting in native PHP. A request is mapped to a route, which in turn gets dispatched to a controller, and possibly an action. If the -process is successful a response is returned. Events are fired during the process to allow -hooks to alter the request, the route, the controller, or the response. +process is successful a response is returned. Events are emitted during the process to allow +listener to alter the request, the route, the controller, or the response. @@ -19,112 +19,37 @@ composer require icanboogie/routing ``` +## A route -## Dispatching a request - -Routes are dispatched by a [RouteDispatcher][] instance, which may be used on its own or -as a _domain dispatcher_ by a [RequestDispatcher][] instance. - -```php - [ - - RouteDefinition::PATTERN => '/articles/', - RouteDefinition::CONTROLLER => ArticlesController::class, - RouteDefinition::ACTION => 'delete', - RouteDefinition::VIA => Request::METHOD_DELETE - - ] - -]); - -$request = Request::from([ - - Request::OPTION_URI => "/articles/123", - Request::OPTION_IS_DELETE => true - -]); - -$dispatcher = new RouteDispatcher($routes); -$response = $dispatcher($request); -$response(); -``` - - +A route is represented with a [Route][] instance. Two parameters are required to create an instance: +`pattern` and `action`. `pattern` is the pattern to match or generate a URL. `action` is an +identifier for an action, which can be used to match with a Responder. -### Rescuing a route - -If an exception is raised during dispatching, the `ICanBoogie\Routing\Route::rescue` event -of class [Route\RescueEvent][] is fired. Event hooks may use this event to rescue the route and -provide a response, or replace the exception that will be thrown if the rescue fails. - - - - - -## Route definitions - -A route definition is an array, which may be created with the following keys: +### The route pattern -- `RouteDefinition::PATTERN`: The pattern of the URL. -- `RouteDefinition::CONTROLLER`: The controller class or a callable. -- `RouteDefinition::ACTION`: An optional action of the controller. -- `RouteDefinition::ID`: The identifier of the route. -- `RouteDefinition::VIA`: If the route needs to respond to one or more HTTP methods, e.g. -`Request::METHOD_GET` or `[ Request::METHOD_PUT, Request::METHOD_PATCH ]`. -Defaults: `Request::METHOD_GET`. -- `RouteDefinition::LOCATION`: To redirect the route to another location. -- `RouteDefinition::CONSTRUCTOR`: If the route should be instantiated from a class other than -[Route][]. - -A route definition is considered valid when the `RouteDefinition::PATTERN` parameter is defined -along one of `RouteDefinition::CONTROLLER` or `RouteDefinition::LOCATION`. [InvalidPattern][] is -thrown if `RouteDefinition::PATTERN` is missing, and [ControllerNotDefined][] is thrown if both -`RouteDefinition::CONTROLLER` and `RouteDefinition::LOCATION` are missing. - -> **Note:** You can add any parameter you want to the route definition, they are used to create -> the route instance, which might be useful to provide additional information to a controller. -> Better use a custom route class though. - - - - - -### Route patterns - -A pattern is used to matches a URL with a route. Placeholders may be used to matches multiple URL to a +A pattern is used to match a URL with a route. Placeholders may be used to match multiple URL to a single route and extract its parameters. Three types of placeholder are available: - Relaxed placeholder: Only the name of the parameter is specified, it matches anything until -the following part. e.g. `/articles/:id/edit` where `:id` is the placeholder for -the `RouteDefinition::ID` parameter. + the following part. e.g. `/articles/:id/edit`. -- Constrained placeholder: A regular expression is used to matches the parameter value. -e.g. `/articles//edit` where `` is the placeholder for the `id` parameter -which value must matches `/^\d+$/`. +- Constrained placeholder: A regular expression is used to match the parameter value. + e.g. `/articles//edit` where `` is the placeholder for the `id` parameter which + value must matches `/^\d+$/`. -- Anonymous constrained placeholder: Same as the constrained placeholder, except the parameter -has no name but an index e.g. `/articles/<\d+>/edit` where `<\d+>` in a placeholder -which index is 0. +- Anonymous constrained placeholder: Same as the constrained placeholder, except the parameter has + no name but an index e.g. `/articles/<\d+>/edit` where `<\d+>` in a placeholder which index is 0. Additionally, the joker character `*`—which can only be used at the end of a pattern—matches anything. e.g. `/articles/123*` matches `/articles/123` and `/articles/123456` as well. -Finally, constraints RegEx are extended with the following: +Finally, constraints RegExp are extended with the following: - `{:sha1:}`: Matches [SHA-1](https://en.wikipedia.org/wiki/SHA-1) hashes. e.g. `/files/`. - `{:uuid:}`: Matches [Universally unique identifiers](https://en.wikipedia.org/wiki/Universally_unique_identifier) -(UUID). e.g. `/articles//edit`. + (UUID). e.g. `/articles//edit`. You can use them in any combination: @@ -134,180 +59,72 @@ You can use them in any combination: +## Route providers +Route providers are used to find the route that matches a predicate. Simple route providers are +often decorated with more sophisticated ones that can improve performance. -### Route controller - -The `RouteDefinition::CONTROLLER` key specifies the callable to invoke, or the class name of a -callable. An action can be specified with `RouteDefinition::ACTION` and if the callable uses -[ActionTrait][] the call will be mapped automatically to the appropriate method. - - - - -## Route collections - -A [RouteCollection][] instance holds route definitions and is used to create [Route][] instances. -A route dispatcher uses an instance to map a request to a route. A route collection is usually -created with an array of route definitions, which may come from configuration fragments, -[RouteMaker][], or an expertly crafted array. After the route collection is created it may be -modified by using the collection as a array, or by adding routes using one of -the supported HTTP methods. Finally, a collection may be created from another using -the `filter()` method. - - - - - -### Defining routes using configuration fragments - -If the package is bound to [ICanBoogie][] using [icanboogie/bind-routing][], routes can be defined -using `routes` configuration fragments. Refer to [icanboogie/bind-routing][] documentation to -learn more about this feature. - -```php -configs['routes']); -# or -$routes = $app->routes; -``` - - - - - -### Defining routes using offsets - -Used as an array, routes can be defined by setting/unsetting the offsets of a [RouteCollection][]. +Here's an overview of a route provider usage, details are available in the [Route Providers +documentation](docs/RouteProviders.md). ```php '/articles', - RouteDefinition::CONTROLLER => ArticlesController::class, - RouteDefinition::ACTION => 'index', - RouteDefinition::VIA => Request::METHOD_GET +use ICanBoogie\HTTP\RequestMethod; +use ICanBoogie\Routing\RouteProvider\ByAction; +use ICanBoogie\Routing\RouteProvider\ByUri; -]; +/* @var RouteProvider $routes */ -unset($routes['articles:index']); +$routes->route_for_predicate(new ByAction('articles:show')); +$routes->route_for_predicate(new ByUri('/articles/123', RequestMethod::METHOD_GET)); +$routes->route_for_predicate(fn(Route $route) => $route->action === 'articles:show'); ``` +## Responding to a request - -### Defining routes using HTTP methods - -Routes may be defined using HTTP methods, such as `get` or `delete`. +A request can be dispatched to a matching Responder provided a route matches the request URI and +method. ```php any('/', function(Request $request) { }, [ RouteDefinition::ID => 'home' ]); -$routes->any('/articles', function(Request $request) { }, [ RouteDefinition::ID => 'articles:index' ]); -$routes->get('/articles/new', function(Request $request) { }, [ RouteDefinition::ID => 'articles:new' ]); -$routes->post('/articles', function(Request $request) { }, [ RouteDefinition::ID => 'articles:create' ]); -$routes->delete('/articles/', function(Request $request) { }, [ RouteDefinition::ID => 'articles:delete' ]); -``` - - - - - -### Filtering a route collection - -Sometimes you want to work with a subset of a route collection, for instance the routes related to -the admin area of a website. The `filter()` method filters routes using a callable filter and -returns a new [RouteCollection][]. - -The following example demonstrates how to filter _index_ routes in an "admin" namespace. -You can provide a closure, but it's best to create filter classes that you can extend and reuse: - -```php -filter(new AdminIndexRouteFilter); -``` - - - - - -## Route providers +use ICanBoogie\HTTP\RequestMethod; +use ICanBoogie\HTTP\Responder; +use ICanBoogie\Routing\RouteProvider; -Route providers implement the [RouteProvider][] interface. The `route_for_predicate()` method is used to find a route -that matches a predicate. A predicate can be as simple as a callable. The following predicates come built-in: +$routes = new RouteProvider\Immutable([ -- [RouteProvider\ById][]: Matches a route against an identifier. -- [RouteProvider\ByAction][]: Matches a route against an action. -- [RouteProvider\ByUri][]: Matches a route against a URI and an optional method. Path parameters and query parameters -are captured in the predicate. + new Route('/articles/', 'articles:delete', RequestMethod::METHOD_DELETE) -The following example demonstrates how to find route matching a URL and method, using the `ByUri` predicate: +]); -```php - "/articles/123", + Request::OPTION_METHOD => RequestMethod::METHOD_DELETE, -/* @var ICanBoogie\Routing\RouteProvider $route_provider */ +]); -$route = $route_provider->route_for_predicate($predicate = new ByUri('/?singer=madonna')); -echo $route->action; // "home" -var_dump($predicate->query_params); // [ 'singer' => 'madonna' ] +/* @var Responder $responder */ -$route = $route_provider->route_for_predicate($predicate = new ByUri('/articles/123', RequestMethod::METHOD_DELETE)); -echo $route->action; // "articles:show" -var_dump($predicate->path_params); // [ 'nid' => 123 ] +$response = $responder->respond($request); ``` -## Route - -A route is represented by a [Route][] instance. It is usually created from a definition array -and contains all the properties of its definition. - -```php - Make::ACTION_LIST - -]); - -// only create the _list_ and _show_ definitions -$definitions = Make::resource('articles', ArticlesController::class, [ - - Make::OPTION_ONLY => [ Make::ACTION_LIST, Make::ACTION_SHOW ] - -]); - -// create definitions except _destroy_ -$definitions = Make::resource('articles', ArticlesController::class, [ - - Make::OPTION_EXCEPT => Make::ACTION_DELETE - -]); - -// create definitions except _updated_ and _destroy_ -$definitions = Make::resource('articles', PhotosController::class, [ - - Make::OPTION_EXCEPT => [ Make::ACTION_UPDATE, Make::ACTION_DELETE ] - -]); - -// specify _key_ property name and its regex constraint -$definitions = Make::resource('articles', ArticlesController::class, [ - - Make::OPTION_ID_NAME => 'uuid', - Make::OPTION_ID_REGEX => '{:uuid:}' - -]); - -// specify the identifier of the _create_ definition -$definitions = Make::resource('articles', ArticlesController::class, [ - - Make::OPTION_AS => [ - - Make::ACTION_CREATE => 'articles:build' - - ] - -]); -``` - -> **Note:** It is not required to define all the resource actions, only define the one you actually need. - - - - - -### Closure based routes - -A simple closure can be used to handle to a route. A [Controller][] instance is created to wrap and -bound the closure, thus you can write your closure like you would a regular controller action -method. - -```php -get('/hello/:name', function ($name) { - - /* @var $this \ICanBoogie\Routing\Controller */ - - return "$name === {$this->request['name']}"; - -}); -``` - - - - - ## Exceptions The exceptions defined by the package implement the `ICanBoogie\Routing\Exception` interface, diff --git a/docs/DefiningRoutes.md b/docs/DefiningRoutes.md new file mode 100644 index 0000000..c985738 --- /dev/null +++ b/docs/DefiningRoutes.md @@ -0,0 +1,123 @@ +# Defining routes + +There are few ways to define routes. You can build the [Route][] instances yourself or use the route +collector. Whatever you choose you will end up with a variant of [RouteProvider][]. + +## Defining routes by hand + +To define your routes and hand, you need to create instance of [Route][] and store them in an +instance of either [RouteProvider\Mutable][] or [RouteProvider\Immutable][], depending on whether you +want to be able to add routes later or not. + +```php +add_routes( + new Route('/', 'page:home'), + new Route('/about.html', 'page:about'), +); + +use ICanBoogie\Routing\RouteProvider\Mutable; + +$routes = new Imutable([ + new Route('/', 'page:home'), + new Route('/about.html', 'page:about'), +]); +``` + +## Defining routes using the collector + +The route collector offers a convenient fluent interface to define your routes. + +```php +route('/', 'page:home') + ->get('/contact.html', 'contact:new') + ->post('/contact.html', 'contact:create') + ->resource('photos') + ->collect(); +``` + +#### Defining resource routes using `RouteMaker` + +Given a resource name and a controller, the `RouteMaker::resource()` method makes the various +routes required to handle a resource. Options can be specified to filter the routes to create, +specify the name of the _key_ property and/or it's regex constraint, or name routes. + +The following example demonstrates how to create routes for an _article_ resource: + +```php + Make::ACTION_LIST + +]); + +// only create the _list_ and _show_ definitions +$definitions = Make::resource('articles', ArticlesController::class, [ + + Make::OPTION_ONLY => [ Make::ACTION_LIST, Make::ACTION_SHOW ] + +]); + +// create definitions except _destroy_ +$definitions = Make::resource('articles', ArticlesController::class, [ + + Make::OPTION_EXCEPT => Make::ACTION_DELETE + +]); + +// create definitions except _updated_ and _destroy_ +$definitions = Make::resource('articles', PhotosController::class, [ + + Make::OPTION_EXCEPT => [ Make::ACTION_UPDATE, Make::ACTION_DELETE ] + +]); + +// specify _key_ property name and its regex constraint +$definitions = Make::resource('articles', ArticlesController::class, [ + + Make::OPTION_ID_NAME => 'uuid', + Make::OPTION_ID_REGEX => '{:uuid:}' + +]); + +// specify the identifier of the _create_ definition +$definitions = Make::resource('articles', ArticlesController::class, [ + + Make::OPTION_AS => [ + + Make::ACTION_CREATE => 'articles:build' + + ] + +]); +``` + +> **Note:** It is not required to define all the resource actions, only define the one you actually need. + + + +[Route]: ../lib/Route.php +[RouteProvider]: ../lib/RouteProvider.php +[RouteProvider\Mutable]: ../lib/RouteProvider/Mutable.php +[RouteProvider\Immutable]: ../lib/RouteProvider/Immutable.php diff --git a/docs/RouteProviders.md b/docs/RouteProviders.md new file mode 100644 index 0000000..84c9e85 --- /dev/null +++ b/docs/RouteProviders.md @@ -0,0 +1,35 @@ +# Route Providers + +Route providers are used to find the route that matches a predicate. Several route provider +implementations are available, for flexibility and performance. And several predicate implementation +are available to match routes by an action or a URI. Simple route providers are often decorated with +more sophisticated ones that can improve performance. + +## Predicates + +Route providers implement the [RouteProvider][] interface. The `route_for_predicate()` method is used to find a route +that matches a predicate. A predicate can be as simple as a callable. The following predicates come built-in: + +- [RouteProvider\ById][]: Matches a route against an identifier. +- [RouteProvider\ByAction][]: Matches a route against an action. +- [RouteProvider\ByUri][]: Matches a route against a URI and an optional method. Path parameters and query parameters + are captured in the predicate. + +The following example demonstrates how to find route matching a URL and method, using the `ByUri` predicate: + +```php +route_for_predicate($predicate = new ByUri('/?singer=madonna')); +echo $route->action; // "home" +var_dump($predicate->query_params); // [ 'singer' => 'madonna' ] + +$route = $route_provider->route_for_predicate($predicate = new ByUri('/articles/123', RequestMethod::METHOD_DELETE)); +echo $route->action; // "articles:show" +var_dump($predicate->path_params); // [ 'nid' => 123 ] +``` diff --git a/lib/RouteCollection.php b/lib/RouteCollection.php deleted file mode 100644 index 700bb21..0000000 --- a/lib/RouteCollection.php +++ /dev/null @@ -1,264 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace ICanBoogie\Routing; - -use ArrayIterator; -use Countable; -use ICanBoogie\Routing\RouteMaker\Options; -use ICanBoogie\Routing\RouteProvider\ById; -use ICanBoogie\Routing\RouteProvider\ByUri; -use IteratorAggregate; -use Traversable; - -use function array_diff_key; -use function count; -use function ICanBoogie\stable_sort; -use function substr_count; - -/** - * A route collection. - * - * @implements IteratorAggregate - */ -final class RouteCollection implements IteratorAggregate, Countable, MutableRouteProvider -{ - /** - * @var array - * Where _key_ is a route identifier or an offset and _value_ is a Route. - */ - private array $routes = []; - - /** - * @param iterable $routes - */ - public function __construct(iterable $routes = []) - { - $this->add_routes(...$routes); - } - - public function add_routes(Route ...$route): void - { - foreach ($route as $r) { - $id = $r->id; - - if ($id) { - $this->routes[$id] = $r; - } else { - $this->routes[] = $r; - } - } - - $this->revoke_cache(); - } - - /** - * Adds resource routes. - * - * **Note:** The respond definitions for the resource are created by - * {@link RouteMaker::resource}. Both methods accept the same arguments. - * - * @see RouteMaker::resource - */ - public function resource(string $name, Options $options = null): self - { - $this->add_routes(...RouteMaker::resource($name, $options)); - - return $this; - } - - /** - * @return Traversable - */ - public function getIterator(): Traversable - { - return new ArrayIterator(array_values($this->routes)); - } - - /** - * Returns the number of routes in the collection. - */ - public function count(): int - { - return count($this->routes); - } - - public function route_for_predicate(callable $predicate): ?Route - { - if ($predicate instanceof ById) { - return $this->routes[$predicate->id] ?? null; - } - - if ($predicate instanceof ByUri) { - return $this->route_for_predicate_by_uri($predicate); - } - - foreach ($this->routes as $route) { - if ($predicate($route)) { - return $route; - } - } - - return null; - } - - private function route_for_predicate_by_uri(ByUri $predicate): ?Route - { - $path = $predicate->path; - $method = $predicate->method; - $path_params = []; - - /** - * Search for a matching static respond. - * - * @param Route[] $routes - */ - $map_static = function (iterable $routes) use ($path, $method): ?Route { - foreach ($routes as $route) { - $pattern = (string) $route->pattern; - - if ($route->method_matches($method) && $pattern === $path) { - return $route; - } - } - - return null; - }; - - /** - * Search for a matching dynamic respond. - * - * @param Route[] $routes - */ - $map_dynamic = function (iterable $routes) use ($path, $method, &$path_params): ?Route { - foreach ($routes as $route) { - $pattern = $route->pattern; - - if (!$route->method_matches($method) || !$pattern->matches($path, $path_params)) { - continue; - } - - return $route; - } - - return null; - }; - - [ $static, $dynamic ] = $this->sort_routes(); - - $route = null; - - if ($static) { - $route = $map_static($static); - } - - if (!$route && $dynamic) { - $route = $map_dynamic($dynamic); - } - - if (!$route) { - return null; - } - - // We update the predicate with the path parameters, and remove matches from the query parameters. - - $predicate->path_params = $path_params; - - if ($predicate->query_params) { - $predicate->query_params = array_diff_key($predicate->query_params, $path_params); - } - - return $route; - } - - /** - * @var Route[]|null - */ - private ?array $static = null; - - /** - * @var Route[]|null - */ - private ?array $dynamic = null; - - /** - * Revokes the cache used by the {@link sort_routes} method. - */ - private function revoke_cache(): void - { - $this->static = null; - $this->dynamic = null; - } - - private const PATH_SEPARATOR = '/'; - - /** - * Sorts routes according to their type and computed weight. - * - * Routes and grouped in two groups: static routes and dynamic routes. The difference between - * static and dynamic routes is that dynamic routes capture parameters from the path and thus - * require a regex to compute the matches, whereas static routes only require is simple string - * comparison. - * - * Dynamic routes are ordered according to their weight, which is computed from the number - * of static parts before the first capture. The more static parts, the lighter the route is. - * - * @return array{0: Route[], 1: Route[]} An array with the static routes and dynamic routes. - */ - private function sort_routes(): array - { - $static = $this->static; - $dynamic = $this->dynamic; - - if ($static !== null && $dynamic !== null) { - return [ $static, $dynamic ]; - } - - $static = []; - $dynamic = []; - $weights = []; - - foreach ($this->routes as $route) { - $pattern = $route->pattern; - - if (!count($pattern->params)) { - $static[] = $route; - } else { - $dynamic[] = $route; - $weights[] = substr_count($pattern->interleaved[0], self::PATH_SEPARATOR); // @phpstan-ignore-line - } - } - - stable_sort($dynamic, fn($v, $k) => -$weights[$k]); - - return [ $this->static = $static, $this->dynamic = $dynamic ]; - } - - /** - * Creates a collection with filtered routes. - * - * @param callable(Route):bool $filter - */ - public function filter(callable $filter): self - { - $routes = []; - - foreach ($this->routes as $route) { - if (!$filter($route)) { - continue; - } - - $routes[] = $route; - } - - return new self($routes); - } -} diff --git a/lib/RouteCollector.php b/lib/RouteCollector.php new file mode 100644 index 0000000..65e7f6d --- /dev/null +++ b/lib/RouteCollector.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ICanBoogie\Routing; + +use ICanBoogie\HTTP\RequestMethod; +use ICanBoogie\Routing\RouteMaker\Options; + +/** + * Collect routes to build a {@link RouteProvider}. + */ +class RouteCollector +{ + private RouteProvider\Mutable $routes; + + public function __construct() + { + $this->routes = new RouteProvider\Mutable(); + } + + public function collect(): IterableRouteProvider + { + return new RouteProvider\Immutable($this->routes); + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:show'. + * @param RequestMethod|RequestMethod[] $methods Request method(s) accepted by the route. + * + * @return $this + */ + public function route( + string $pattern, + string $action, + RequestMethod|array $methods = RequestMethod::METHOD_ANY, + string|null $id = null + ): self { + $this->routes->add_routes(new Route($pattern, $action, $methods, $id)); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:show'. + * + * @return $this + */ + public function any(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_ANY, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:show'. + * + * @return $this + */ + public function get(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_GET, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:create'. + * + * @return $this + */ + public function post(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_POST, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:update'. + * + * @return $this + */ + public function put(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_PUT, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:update'. + * + * @return $this + */ + public function patch(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_PATCH, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:delete'. + * + * @return $this + */ + public function delete(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_DELETE, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:show'. + * + * @return $this + */ + public function head(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_HEAD, $id); + + return $this; + } + + /** + * Add a route. + * + * @param string $pattern Pattern of the route. + * @param string $action Identifier of a qualified action. e.g. 'articles:show'. + * + * @return $this + */ + public function options(string $pattern, string $action, string|null $id = null): self + { + $this->route($pattern, $action, RequestMethod::METHOD_OPTIONS, $id); + + return $this; + } + + /** + * Adds resource routes. + * + * **Note:** The respond definitions for the resource are created by {@link RouteMaker::resource}. Both methods + * accept the same arguments. + * + * @see RouteMaker::resource + */ + public function resource(string $name, Options $options = null): self + { + $this->routes->add_routes(...RouteMaker::resource($name, $options)); + + return $this; + } +} diff --git a/lib/RouteMaker.php b/lib/RouteMaker.php index 2ae66f4..91b8c45 100644 --- a/lib/RouteMaker.php +++ b/lib/RouteMaker.php @@ -28,12 +28,40 @@ final class RouteMaker /* * Unqualified actions. */ + + /** + * Display a list of records. + */ public const ACTION_LIST = 'list'; + + /** + * Display an HTML form for creating a new record. + */ public const ACTION_NEW = 'new'; + + /** + * Create a new record. + */ public const ACTION_CREATE = 'create'; + + /** + * Display a specific record. + */ public const ACTION_SHOW = 'show'; + + /** + * Display an HTML form for editing a new record. + */ public const ACTION_EDIT = 'edit'; + + /** + * Update a specific record. + */ public const ACTION_UPDATE = 'update'; + + /** + * Delete a specific record. + */ public const ACTION_DELETE = 'delete'; /** diff --git a/lib/RouteProvider/Mutable.php b/lib/RouteProvider/Mutable.php index ae063b1..3c162d0 100644 --- a/lib/RouteProvider/Mutable.php +++ b/lib/RouteProvider/Mutable.php @@ -13,13 +13,14 @@ use ArrayIterator; use ICanBoogie\Routing\IterableRouteProvider; +use ICanBoogie\Routing\MutableRouteProvider; use ICanBoogie\Routing\Route; use Traversable; /** * A mutable route provider. */ -final class Mutable implements IterableRouteProvider +final class Mutable implements IterableRouteProvider, MutableRouteProvider { /** * @param array{ 'routes': Route[] } $an_array diff --git a/lib/UrlGenerator/UrlGeneratorWithRouteProvider.php b/lib/UrlGenerator/UrlGeneratorWithRouteProvider.php index f99568d..b91929d 100644 --- a/lib/UrlGenerator/UrlGeneratorWithRouteProvider.php +++ b/lib/UrlGenerator/UrlGeneratorWithRouteProvider.php @@ -14,7 +14,6 @@ use ICanBoogie\Routing\Exception\RouteNotFound; use ICanBoogie\Routing\RouteProvider; use ICanBoogie\Routing\RouteProvider\ByIdOrAction; - use ICanBoogie\Routing\UrlGenerator; use function http_build_query; diff --git a/phpcs.xml b/phpcs.xml index 8680327..442bf55 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -3,6 +3,7 @@ xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/squizlabs/PHP_CodeSniffer/master/phpcs.xsd"> lib tests + tests/sandbox/* diff --git a/tests/ControllerTest.php b/tests/ControllerTest.php index 92e61c8..4d53114 100644 --- a/tests/ControllerTest.php +++ b/tests/ControllerTest.php @@ -19,7 +19,6 @@ use ICanBoogie\Routing\ControllerAbstract; use ICanBoogie\Routing\ControllerTest\MySampleController; use ICanBoogie\Routing\Route; -use ICanBoogie\Routing\RouteCollection; use ICanBoogie\Routing\RouteDispatcher; use PHPUnit\Framework\TestCase; @@ -169,54 +168,6 @@ public function test_should_return_response_if_instantiated() $this->assertSame($status, $response->status->code); } - public function test_closure() - { - $test = $this; - - $routes = new RouteCollection([ - - 'default' => [ - - 'pattern' => '/blog/--:slug.html', - 'controller' => function ($year, $month, $slug) use ($test) { - $test->assertInstanceOf(Request::class, $this->request); - $test->assertEquals(2014, $year); - $test->assertEquals(12, $month); - $test->assertEquals("my-awesome-post", $slug); - - return 'HERE'; - } - ] - ]); - - $dispatcher = new RouteDispatcher($routes); - $request = Request::from("/blog/2014-12-my-awesome-post.html"); - $response = $dispatcher($request); - $this->assertInstanceOf(Response::class, $response); - $this->assertTrue($response->status->is_successful); - $this->assertEquals('HERE', $response->body); - } - - public function test_generic_controller() - { - $routes = new RouteCollection([ - - 'default' => [ - - 'pattern' => '/blog/--:slug.html', - 'controller' => MySampleController::class - ] - ]); - - $dispatcher = new RouteDispatcher($routes); - $request = Request::from("/blog/2014-12-my-awesome-post.html"); - $request->test = $this; - $response = $dispatcher($request); - $this->assertInstanceOf(Response::class, $response); - $this->assertTrue($response->status->is_successful); - $this->assertEquals('HERE', $response->body); - } - public function test_redirect_to_path() { $controller = $this diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php deleted file mode 100644 index 161cd00..0000000 --- a/tests/RouteCollectionTest.php +++ /dev/null @@ -1,283 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Test\ICanBoogie\Routing; - -use ICanBoogie\HTTP\RequestMethod; -use ICanBoogie\Routing\Exception\InvalidPattern; -use ICanBoogie\Routing\Route; -use ICanBoogie\Routing\RouteCollection; -use ICanBoogie\Routing\RouteMaker; -use ICanBoogie\Routing\RouteMaker\Options; -use ICanBoogie\Routing\RouteProvider\ByAction; -use ICanBoogie\Routing\RouteProvider\ByUri; -use PHPUnit\Framework\TestCase; - -use function array_push; -use function uniqid; - -final class RouteCollectionTest extends TestCase -{ - public function test_multiple_routes_may_have_the_same_action(): void - { - $routes = new RouteCollection([ - - new Route(uniqid(), $action = 'articles:show'), - new Route(uniqid(), $action), - - ]); - - $this->assertCount(2, $routes); - } - - public function test_should_fail_on_empty_pattern(): void - { - $this->expectException(InvalidPattern::class); - - new RouteCollection([ new Route('', 'article:list') ]); - } - - public function test_iterator(): void - { - $routes = new RouteCollection([ - $r1 = new Route('/', 'article:list'), - $r2 = new Route('/', 'article:list'), - $r3 = new Route('/', 'article:list'), - ]); - - $this->assertSame([ $r1, $r2, $r3 ], self::to_array($routes)); - } - - public function test_route_for_predicate_by_action(): void - { - $routes = new RouteCollection([ - - $home = new Route('/', 'home'), - $edit = new Route('/articles/new', 'articles:edit', RequestMethod::METHOD_GET), - $list = new Route('/articles', 'articles', [ RequestMethod::METHOD_POST, RequestMethod::METHOD_PATCH ]), - $delete = new Route('/articles/', 'articles:delete', RequestMethod::METHOD_DELETE), - - ]); - - $this->assertSame($home, $routes->route_for_predicate(new ByAction('home'))); - $this->assertSame($list, $routes->route_for_predicate(new ByAction('articles'))); - $this->assertSame($edit, $routes->route_for_predicate(new ByAction('articles:edit'))); - $this->assertSame($delete, $routes->route_for_predicate(new ByAction('articles:delete'))); - } - - public function test_route_for_predicate_by_uri(): void - { - $routes = new RouteCollection([ - - $home = new Route('/', 'home'), - new Route('/articles/new', 'articles:edit', RequestMethod::METHOD_GET), - $create = new Route('/articles', 'articles', [ RequestMethod::METHOD_POST, RequestMethod::METHOD_PATCH ]), - $delete = new Route('/articles/', 'articles:delete', RequestMethod::METHOD_DELETE), - - ]); - - $this->assertSame( - $home, - $routes->route_for_predicate( - new ByUri('/') - ) - ); - $this->assertSame( - $home, - $routes->route_for_predicate( - new ByUri('/', RequestMethod::METHOD_PATCH) - ) - ); - $this->assertSame( - $home, - $routes->route_for_predicate( - $p = new ByUri('/?singer=madonna', RequestMethod::METHOD_ANY) - ) - ); - $this->assertEmpty($p->path_params); - $this->assertEquals([ 'singer' => 'madonna' ], $p->query_params); - - $this->assertNull($routes->route_for_predicate(new ByUri('/undefined'))); - $this->assertNull( - $routes->route_for_predicate( - $p = new ByUri('/undefined?madonna', RequestMethod::METHOD_ANY) - ) - ); - $this->assertEmpty($p->path_params); - - $this->assertSame($create, $routes->route_for_predicate(new ByUri('/articles'))); - $this->assertSame( - $create, - $routes->route_for_predicate( - new ByUri('/articles', RequestMethod::METHOD_POST) - ) - ); - $this->assertSame( - $create, - $routes->route_for_predicate( - new ByUri('/articles', RequestMethod::METHOD_PATCH) - ) - ); - $this->assertNull( - $routes->route_for_predicate( - new ByUri('/articles', RequestMethod::METHOD_GET) - ) - ); - - $this->assertSame( - $delete, - $routes->route_for_predicate( - $p = new ByUri('/articles/123', RequestMethod::METHOD_DELETE) - ) - ); - $this->assertEquals([ 'nid' => 123 ], $p->path_params); - // Parameters already captured from the path are discarded from the query. - $this->assertSame( - $delete, - $routes->route_for_predicate( - $p = new ByUri('/articles/123?nid=456&singer=madonna', RequestMethod::METHOD_DELETE) - ) - ); - $this->assertEquals([ 'nid' => 123 ], $p->path_params); - $this->assertEquals([ 'singer' => 'madonna' ], $p->query_params); - - $this->assertNull($routes->route_for_predicate(new ByUri('/to/the/articles'))); - } - - /** - * 'api:articles:activate' should win over 'api:nodes:activate' because more static parts - * are defined before the first capture. - */ - public function test_weight(): void - { - $routes = new RouteCollection([ - - new Route('/api/:constructor/:id/active', 'api:nodes:activate', RequestMethod::METHOD_PUT), - $ok = new Route('/api/articles/:id/active', 'api:articles:activate', RequestMethod::METHOD_PUT), - - ]); - - $this->assertSame( - $ok, - $routes->route_for_predicate( - new ByUri( - '/api/articles/123/active', - RequestMethod::METHOD_PUT - ) - ) - ); - } - - public function test_nameless_capture(): void - { - $routes = new RouteCollection([ - - $ok = new Route('/admin/articles/<\d+>/edit', 'admin:articles/edit'), - - ]); - - $this->assertSame( - $ok, - $routes->route_for_predicate( - $p = new ByUri('/admin/articles/123/edit', RequestMethod::METHOD_ANY) - ) - ); - $this->assertEquals([ 123 ], $p->path_params); - } - - public function test_resources(): void - { - $routes = new RouteCollection(); - $routes->resource('photos', new Options(only: [ RouteMaker::ACTION_LIST, RouteMaker::ACTION_SHOW ])); - $actions = []; - - foreach ($routes as $route) { - $actions[] = $route->action; - } - - $this->assertSame([ 'photos:list', 'photos:show' ], $actions); - } - - public function test_filter(): void - { - $routes = new RouteCollection([ - - $ok = new Route('/admin/articles', 'admin:articles:list'), - new Route('/articles/', 'articles:show'), - - ]); - - $filtered_routes = $routes->filter( - fn(Route $route): bool => str_starts_with($route->action, 'admin:') - ); - - $this->assertNotSame($routes, $filtered_routes); - $this->assertCount(2, $routes); - $this->assertCount(1, $filtered_routes); - - $this->assertSame([ $ok ], self::to_array($filtered_routes)); - } - - public function test_route_with_id_is_unique(): void - { - $id = uniqid(); - $routes = new RouteCollection([ - - new Route('/' . uniqid(), 'admin:' . uniqid(), id: $id), - new Route('/' . uniqid(), 'admin:' . uniqid(), id: $id), - new Route('/' . uniqid(), 'admin:' . uniqid(), id: $id), - $ok = new Route('/' . uniqid(), 'admin:' . uniqid(), id: $id), - - ]); - - $this->assertCount(1, $routes); - $this->assertSame([ $ok ], self::to_array($routes)); - } - - public function test_routes_with_multiple_methods(): void - { - $routes = new RouteCollection([ - $list = new Route( - '/articles', - 'articles:list', - [ RequestMethod::METHOD_GET, RequestMethod::METHOD_HEAD ] - ), - - $show = new Route( - '/--:slug', - 'articles:show', - [ RequestMethod::METHOD_GET, RequestMethod::METHOD_HEAD ] - ), - ]); - - $this->assertSame( - $list, - $routes->route_for_predicate(new ByUri('/articles')) - ); - - $this->assertSame( - $show, - $routes->route_for_predicate(new ByUri('/2022-04-madonna')) - ); - } - - /** - * @return Route[] - */ - private static function to_array(RouteCollection $routes): array - { - $ar = []; - - array_push($ar, ...$routes); - - return $ar; - } -} diff --git a/tests/RouteCollectorTest.php b/tests/RouteCollectorTest.php new file mode 100644 index 0000000..36c3c00 --- /dev/null +++ b/tests/RouteCollectorTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Test\ICanBoogie\Routing; + +use ICanBoogie\HTTP\RequestMethod; +use ICanBoogie\Routing\Pattern; +use ICanBoogie\Routing\Route; +use ICanBoogie\Routing\RouteCollector; +use PHPUnit\Framework\TestCase; + +final class RouteCollectorTest extends TestCase +{ + public function test_build(): void + { + $provider = (new RouteCollector()) + ->route('/', 'page:home') + ->any('/photos', 'photos:list', id: 'photos:any') + ->get('/photos', 'photos:list') + ->post('/photos', 'photos:create') + ->put('/photos/', 'photos:update', id: 'photos:complete_update') + ->patch('/photos/', 'photos:update', id: 'photos:partial_update') + ->delete('/photos/', 'photos:delete') + ->head('/photos/', 'photos:show', id: 'photos:show_head') + ->options('/photos', 'options') + ->resource('articles') + ->collect(); + + $actual = []; + + foreach ($provider as $route) { + /* @var Route $route */ + $actual[] = [ $route->pattern, $route->action, $route->methods, $route->id ]; + } + + $this->assertEquals([ + + [ Pattern::from('/'), 'page:home', RequestMethod::METHOD_ANY, null ], + [ Pattern::from('/photos'), 'photos:list', RequestMethod::METHOD_ANY, 'photos:any' ], + [ Pattern::from('/photos'), 'photos:list', RequestMethod::METHOD_GET, null ], + [ Pattern::from('/photos'), 'photos:create', RequestMethod::METHOD_POST, null ], + [ Pattern::from('/photos/'), 'photos:update', RequestMethod::METHOD_PUT, 'photos:complete_update' ], + [ Pattern::from('/photos/'), 'photos:update', RequestMethod::METHOD_PATCH, 'photos:partial_update' ], + [ Pattern::from('/photos/'), 'photos:delete', RequestMethod::METHOD_DELETE, null ], + [ Pattern::from('/photos/'), 'photos:show', RequestMethod::METHOD_HEAD, 'photos:show_head' ], + [ Pattern::from('/photos'), 'options', RequestMethod::METHOD_OPTIONS, null ], + [ Pattern::from('/articles'), 'articles:list', RequestMethod::METHOD_GET, null ], + [ Pattern::from('/articles/new'), 'articles:new', RequestMethod::METHOD_GET, null ], + [ Pattern::from('/articles'), 'articles:create', RequestMethod::METHOD_POST, null ], + [ Pattern::from('/articles/'), 'articles:show', RequestMethod::METHOD_GET, null ], + [ Pattern::from('/articles//edit'), 'articles:edit', RequestMethod::METHOD_GET, null ], + [ Pattern::from('/articles/'), 'articles:update', [ RequestMethod::METHOD_PUT, RequestMethod::METHOD_PATCH ], null ], + [ Pattern::from('/articles/'), 'articles:delete', RequestMethod::METHOD_DELETE, null ], + + ], $actual); + } +} diff --git a/tests/RouterTest.php b/tests/RouterTest.php index b8d209a..796ef85 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -18,8 +18,8 @@ use ICanBoogie\Routing\ActionResponderProvider; use ICanBoogie\Routing\RequestResponderProvider; use ICanBoogie\Routing\Route; -use ICanBoogie\Routing\RouteCollection; use ICanBoogie\Routing\RouteProvider\ByUri; +use ICanBoogie\Routing\RouteProvider\Mutable; use ICanBoogie\Routing\Router; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -39,7 +39,7 @@ public function test_method(string $method, RequestMethod $http_method): void return $response; }; - $routes = new RouteCollection(); + $routes = new Mutable(); $responders = new ActionResponderProvider\Mutable(); $router = new Router($routes, $responders); @@ -81,7 +81,7 @@ public function provide_method(): array public function test_route(): void { - $routes = new RouteCollection(); + $routes = new Mutable(); $responders = new ActionResponderProvider\Mutable(); $response = new Response(); diff --git a/tests/UrlGenerator/UrlGeneratorTest.php b/tests/UrlGenerator/UrlGeneratorTest.php index dfce389..7707531 100644 --- a/tests/UrlGenerator/UrlGeneratorTest.php +++ b/tests/UrlGenerator/UrlGeneratorTest.php @@ -11,7 +11,7 @@ namespace Test\ICanBoogie\Routing\UrlGenerator; -use ICanBoogie\Routing\RouteCollection; +use ICanBoogie\Routing\RouteCollector; use ICanBoogie\Routing\UrlGenerator\UrlGeneratorWithRouteProvider; use PHPUnit\Framework\TestCase; @@ -19,8 +19,9 @@ final class UrlGeneratorTest extends TestCase { public function test_generate_url(): void { - $routes = new RouteCollection(); - $routes->resource('articles'); + $routes = (new RouteCollector()) + ->resource('articles') + ->collect(); $generator = new UrlGeneratorWithRouteProvider($routes);