From 4d870f12b116d27c053e5a0caf2c2dd678c246ad Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:23:34 +0000 Subject: [PATCH 01/40] Updated coding standard and phpcs fixer rules --- phpcs.xml.dist | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 11c8d11b..7d142335 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -20,6 +20,14 @@ + + + + + + + + From 438ceee71ed669dbdc3807911f3c67ab4b4c1d6a Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:27:27 +0000 Subject: [PATCH 02/40] Fixed minor coding standard issue in the generated uri class --- src/Generator/GeneratedUri.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Generator/GeneratedUri.php b/src/Generator/GeneratedUri.php index d1252995..74dab13c 100644 --- a/src/Generator/GeneratedUri.php +++ b/src/Generator/GeneratedUri.php @@ -114,12 +114,12 @@ public function withScheme(string $scheme): self */ public function withPort(int $port): self { - if (0 > $port || 0xffff < $port) { + if (0 > $port || 0xFFFF < $port) { throw new UrlGenerationException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); } - if (!\in_array($port, ['', 80, 443], true)) { - $this->port = ':' . $port; + if (!\in_array($port, [80, 443], true)) { + $this->port = ':'.$port; } return $this; @@ -137,7 +137,7 @@ public function withQuery(array $queryParams = []): self $queryString = \http_build_query($queryParams, '', '&', \PHP_QUERY_RFC3986); if (!empty($queryString)) { - $this->pathInfo .= '?' . \strtr($queryString, self::QUERY_DECODED); + $this->pathInfo .= '?'.\strtr($queryString, self::QUERY_DECODED); } } @@ -145,12 +145,12 @@ public function withQuery(array $queryParams = []): self } /** - * Set the fragment component of the URI + * Set the fragment component of the URI. */ public function withFragment(string $fragment): self { if (!empty($fragment)) { - $this->pathInfo .= '#' . $fragment; + $this->pathInfo .= '#'.$fragment; } return $this; From 5264a85fd14e9d842b3ee1fc1c97f571bb698c5d Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:28:07 +0000 Subject: [PATCH 03/40] Improved the stringable returned value of the generated uri class --- src/Generator/GeneratedUri.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Generator/GeneratedUri.php b/src/Generator/GeneratedUri.php index 74dab13c..3338ce61 100644 --- a/src/Generator/GeneratedUri.php +++ b/src/Generator/GeneratedUri.php @@ -69,24 +69,24 @@ public function __construct(string $pathInfo, int $referenceType) */ public function __toString() { - $prefixed = '/'; - $type = $this->referenceType; + $pathInfo = '/'.\ltrim($this->pathInfo, '/'); - if ($this->scheme) { - $prefixed = $this->scheme . '://'; + if (self::ABSOLUTE_PATH === $type = $this->referenceType) { + return $pathInfo; } - if ($this->host) { - if ('/' === $prefixed) { - $prefixed = \in_array($type, [self::ABSOLUTE_URL, self::NETWORK_PATH], true) ? '//' : ''; - } + if (self::RELATIVE_PATH === $type) { + return '.'.$pathInfo; + } - $prefixed .= \ltrim($this->host, './') . $this->port . '/'; - } elseif ('/' === $prefixed && self::RELATIVE_PATH === $type) { - $prefixed = '.' . $prefixed; + if (isset($this->host)) { + $hostPort = $this->host.$this->port; + } else { + $h = \explode(':', $_SERVER['HTTP_HOST'] ?? 'localhost:80', 2); + $hostPort = $h[0].($this->port ?? (!\in_array($h[1] ?? '', ['', '80', '443'], true) ? ':'.$h[0] : '')); } - return $prefixed . \ltrim($this->pathInfo, '/'); + return (self::NETWORK_PATH === $type ? '//' : (isset($this->scheme) ? $this->scheme.'://' : '//')).$hostPort.$pathInfo; } /** From d15111c8ac3bed756931c41e5adbfc8eeeb62fce Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:35:44 +0000 Subject: [PATCH 04/40] Renamed the generated uri class to route uri class --- src/{Generator/GeneratedUri.php => RouteUri.php} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/{Generator/GeneratedUri.php => RouteUri.php} (98%) diff --git a/src/Generator/GeneratedUri.php b/src/RouteUri.php similarity index 98% rename from src/Generator/GeneratedUri.php rename to src/RouteUri.php index 3338ce61..ce0ebf1d 100644 --- a/src/Generator/GeneratedUri.php +++ b/src/RouteUri.php @@ -15,7 +15,7 @@ * file that was distributed with this source code. */ -namespace Flight\Routing\Generator; +namespace Flight\Routing; use Flight\Routing\Exceptions\UrlGenerationException; @@ -25,7 +25,7 @@ * * @author Divine Niiquaye Ibok */ -class GeneratedUri implements \Stringable +class RouteUri implements \Stringable { /** Generates an absolute URL, e.g. "http://example.com/dir/file". */ public const ABSOLUTE_URL = 0; From b2dc14cfac88b15ead99af7b0a7c7fae3b797042 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:36:29 +0000 Subject: [PATCH 05/40] Removed the regex generator class --- src/Generator/RegexGenerator.php | 221 ------------------------------- 1 file changed, 221 deletions(-) delete mode 100644 src/Generator/RegexGenerator.php diff --git a/src/Generator/RegexGenerator.php b/src/Generator/RegexGenerator.php deleted file mode 100644 index fd6ca677..00000000 --- a/src/Generator/RegexGenerator.php +++ /dev/null @@ -1,221 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Generator; - -/** - * A helper Prefix tree class to help help in the compilation of routes in - * preserving routes order as a full regex excluding modifies. - * - * This class is retrieved from symfony's routing component to add - * high performance into Flight Routing and avoid requiring the whole component. - * - * @author Frank de Jonge - * @author Nicolas Grekas - * @author Divine Niiquaye Ibok - * - * @internal - */ -class RegexGenerator -{ - private string $prefix; - - /** @var string[] */ - private array $staticPrefixes = [], $prefixes = []; - - /** @var array[]|self[] */ - private array $items = []; - - public function __construct(string $prefix = '/') - { - $this->prefix = $prefix; - } - - public function getPrefix(): string - { - return $this->prefix; - } - - /** - * @return array[]|self[] - */ - public function getRoutes(): array - { - return $this->items; - } - - /** - * Compiles a regexp tree of sub-patterns that matches nested same-prefix routes. - * - * The route item should contain a pathRegex and an id used for (*:MARK) - */ - public function compile(int $prefixLen): string - { - $code = ''; - - foreach ($this->items as $route) { - $code .= '|'; - - if ($route instanceof self) { - $nested = '(?' . $route->compile($prefixLen + \strlen($prefix = \substr($route->prefix, $prefixLen))) . ')'; - $code .= \ltrim($prefix, '?') . (\str_starts_with($nested, '(?|?') ? '?(?|' . \substr($nested, 4) : $nested); - } else { - if (\preg_match('/^\|(.*?\|)\?(\(\*\:\d+\))\|$/', $code, $m)) { - $code = '|?' . $m[1] . $m[2] . '|'; - } - $code .= \substr($route[0], $prefixLen) . '(*:' . $route[1] . ')'; - } - } - - return $code; - } - - /** - * Adds a route to a group. - * - * @param array|self $route - */ - public function addRoute(string $prefix, $route): void - { - [$prefix, $staticPrefix] = $this->getCommonPrefix($prefix, $prefix); - - for ($i = \count($this->items) - 1; 0 <= $i; --$i) { - $item = $this->items[$i]; - - [$commonPrefix, $commonStaticPrefix] = $this->getCommonPrefix($prefix, $this->prefixes[$i]); - - if ($this->prefix === $commonPrefix) { - // the new route and a previous one have no common prefix, let's see if they are exclusive to each others - - if ($this->prefix !== $staticPrefix && $this->prefix !== $this->staticPrefixes[$i]) { - // the new route and the previous one have exclusive static prefixes - continue; - } - - if ($this->prefix === $staticPrefix && $this->prefix === $this->staticPrefixes[$i]) { - // the new route and the previous one have no static prefix - break; - } - - if ($this->prefixes[$i] !== $this->staticPrefixes[$i] && $this->prefix === $this->staticPrefixes[$i]) { - // the previous route is non-static and has no static prefix - break; - } - - if ($prefix !== $staticPrefix && $this->prefix === $staticPrefix) { - // the new route is non-static and has no static prefix - break; - } - - continue; - } - - if ($item instanceof self && $this->prefixes[$i] === $commonPrefix) { - // the new route is a child of a previous one, let's nest it - $item->addRoute($prefix, $route); - } else { - // the new route and a previous one have a common prefix, let's merge them - $child = new self($commonPrefix); - [$child->prefixes[0], $child->staticPrefixes[0]] = $child->getCommonPrefix($this->prefixes[$i], $this->prefixes[$i]); - [$child->prefixes[1], $child->staticPrefixes[1]] = $child->getCommonPrefix($prefix, $prefix); - $child->items = [$this->items[$i], $route]; - - $this->staticPrefixes[$i] = $commonStaticPrefix; - $this->prefixes[$i] = $commonPrefix; - $this->items[$i] = $child; - } - - return; - } - - // No optimised case was found, in this case we simple add the route for possible - // grouping when new routes are added. - $this->staticPrefixes[] = $staticPrefix; - $this->prefixes[] = $prefix; - $this->items[] = $route; - } - - public static function handleError(int $type, string $msg): bool - { - return false !== \strpos($msg, 'Compilation failed: lookbehind assertion is not fixed length'); - } - - /** - * Gets the full and static common prefixes between two route patterns. - * - * The static prefix stops at last at the first opening bracket. - */ - private function getCommonPrefix(string $prefix, string $anotherPrefix): array - { - $baseLength = \strlen($this->prefix); - $end = \min(\strlen($prefix), \strlen($anotherPrefix)); - $staticLength = null; - \set_error_handler([__CLASS__, 'handleError']); - - for ($i = $baseLength; $i < $end && $prefix[$i] === $anotherPrefix[$i]; ++$i) { - if ('(' === $prefix[$i]) { - $staticLength = $staticLength ?? $i; - - for ($j = 1 + $i, $n = 1; $j < $end && 0 < $n; ++$j) { - if ($prefix[$j] !== $anotherPrefix[$j]) { - break 2; - } - - if ('(' === $prefix[$j]) { - ++$n; - } elseif (')' === $prefix[$j]) { - --$n; - } elseif ('\\' === $prefix[$j] && (++$j === $end || $prefix[$j] !== $anotherPrefix[$j])) { - --$j; - - break; - } - } - - if (0 < $n) { - break; - } - - if (('?' === ($prefix[$j] ?? '') || '?' === ($anotherPrefix[$j] ?? '')) && ($prefix[$j] ?? '') !== ($anotherPrefix[$j] ?? '')) { - break; - } - $subPattern = \substr($prefix, $i, $j - $i); - - if ($prefix !== $anotherPrefix && !\preg_match('/^\(\[[^\]]++\]\+\+\)$/', $subPattern) && !\preg_match('{(?> 6) && \preg_match('//u', $prefix . ' ' . $anotherPrefix)) { - do { - // Prevent cutting in the middle of an UTF-8 characters - --$i; - } while (0b10 === (\ord($prefix[$i]) >> 6)); - } - - return [\substr($prefix, 0, $i), \substr($prefix, 0, $staticLength ?? $i)]; - } -} From 0b54dc6db05660f001ef13cf7d712fb6a651995f Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:42:45 +0000 Subject: [PATCH 06/40] Dropped support for route class to use arrays instead - Removed the __clone method from the route collection class - Removed the lock feature of the route collection class --- src/Route.php | 158 ----------- src/RouteCollection.php | 294 +++++++------------- src/Traits/DataTrait.php | 424 ---------------------------- src/Traits/PrototypeTrait.php | 500 +++++++++++++++++++++++++--------- 4 files changed, 462 insertions(+), 914 deletions(-) delete mode 100644 src/Route.php delete mode 100644 src/Traits/DataTrait.php diff --git a/src/Route.php b/src/Route.php deleted file mode 100644 index e73264db..00000000 --- a/src/Route.php +++ /dev/null @@ -1,158 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing; - -/** - * Value object representing a single route. - * - * @author Divine Niiquaye Ibok - */ -class Route -{ - use Traits\DataTrait; - - /** - * A Pattern to Locates appropriate route by name, support dynamic route allocation using following pattern: - * Pattern route: `pattern/*` - * Default route: `*` - * Only action: `pattern/*`. - */ - public const RCA_PATTERN = '#^(?:([a-z]+)\:)?(?:\/{2}([^\/]+))?([^*]*)(?:\*\<(?:([\w+\\\\]+)\@)?(\w+)\>)?$#u'; - - /** - * A Pattern to match protocol, host and port from a url. - * - * Examples of urls that can be matched: http://en.example.domain, {sub}.example.domain, https://example.com:34, example.com, etc. - */ - public const URL_PATTERN = '#^(?:([a-z]+)\:\/{2})?([^\/]+)?$#u'; - - /** - * A Pattern to match the route's priority. - * - * If route path matches, 1 is expected return else 0 should be return as priority index. - */ - public const PRIORITY_REGEX = '#^([\/\w+][^<[{:]+\b)(.*)#'; - - /** - * Slashes supported on browser when used. - */ - public const URL_PREFIX_SLASHES = ['/' => '/', ':' => ':', '-' => '-', '_' => '_', '~' => '~', '@' => '@']; - - /** @var array Default methods for route. */ - public const DEFAULT_METHODS = [Router::METHOD_GET, Router::METHOD_HEAD]; - - /** - * Create a new Route constructor. - * - * @param string $pattern The route pattern - * @param string|string[] $methods the route HTTP methods - * @param mixed $handler The PHP class, object or callable that returns the response when matched - */ - public function __construct(string $pattern, $methods = self::DEFAULT_METHODS, $handler = null) - { - $this->data = ['handler' => $handler]; - - foreach ((array) $methods as $method) { - $this->data['methods'][\strtoupper($method)] = true; - } - - if (!empty($pattern)) { - $this->path($pattern); - } - } - - /** - * @internal - */ - public function __serialize(): array - { - return $this->data; - } - - /** - * @internal - * - * @param array $data - */ - public function __unserialize(array $data): void - { - $this->data = $data; - } - - /** - * @internal - * - * @param array $properties The route data properties - * - * @return static - */ - public static function __set_state(array $properties) - { - $route = new static($properties['path'] ?? '', $properties['methods'] ?? [], $properties['handler'] ?? null); - $route->data += $properties['data'] ?? \array_diff_key($properties, ['path' => null, 'methods' => [], 'handler' => null]); - - return $route; - } - - /** - * Create a new Route statically. - * - * @param string $pattern The route pattern - * @param string|string[] $methods the route HTTP methods - * @param mixed $handler The PHP class, object or callable that returns the response when matched - * - * @return static - */ - public static function to(string $pattern, $methods = self::DEFAULT_METHODS, $handler = null) - { - return new static($pattern, $methods, $handler); - } - - /** - * Sets a custom key and value into route - * - * @param mixed $value - * - * @return $this - */ - public function setData(string $key, $value) - { - $this->data[$key] = $value; - - return $this; - } - - /** - * Get the route's data. - * - * @return array - */ - public function getData(): array - { - return $this->data; - } - - public function generateRouteName(string $prefix): string - { - $routeName = \implode('_', $this->getMethods()) . '_' . $prefix . $this->data['path'] ?? ''; - $routeName = \str_replace(['/', ':', '|', '-'], '_', $routeName); - $routeName = (string) \preg_replace('/[^a-z0-9A-Z_.]+/', '', $routeName); - - return (string) \preg_replace(['/\_+/', '/\.+/'], ['_', '.'], $routeName); - } -} diff --git a/src/RouteCollection.php b/src/RouteCollection.php index f0c5cfc3..3ed2bfd1 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -23,7 +23,6 @@ * This class provides all(*) methods for creating path+HTTP method-based routes and * injecting them into the router: * - * - head * - get * - post * - put @@ -33,7 +32,7 @@ * - any * - resource * - * A general `addRoute()` method allows specifying multiple request methods and/or + * A general `add()` method allows specifying multiple request methods and/or * arbitrary request methods when creating a path-based route. * * @author Divine Niiquaye Ibok @@ -42,101 +41,54 @@ class RouteCollection { use Traits\PrototypeTrait; - private ?string $namedPrefix; - private ?Route $route = null; - private ?self $parent = null; - private bool $sortRoutes, $locked = false; - - /** @var array */ - private array $routes = []; - - /** @var array */ - private array $groups = []; - /** - * @param string $namedPrefix The unqiue name for this group + * A Pattern to Locates appropriate route by name, support dynamic route allocation using following pattern: + * Pattern route: `pattern/*` + * Default route: `*` + * Only action: `pattern/*`. */ - public function __construct(string $namedPrefix = null, bool $sortRoutes = false) - { - $this->namedPrefix = $namedPrefix; - $this->sortRoutes = $sortRoutes; - } + public const RCA_PATTERN = '/^(?:([a-z]+)\:)?(?:\/{2}([^\/]+))?([^*]*)(?:\*\<(?:([\w+\\\\]+)\@)?(\w+)\>)?$/u'; /** - * Nested collection and routes should be cloned. + * A Pattern to match the route's priority. + * + * If route path matches, 1 is expected return else 0 should be return as priority index. */ - public function __clone() - { - $this->includeRoute(); // Incase of missing end method call on route. + protected const PRIORITY_REGEX = '/([^<[{:]+\b)/A'; - foreach ($this->routes as $offset => $route) { - $this->routes[$offset] = clone $route; - } - } + protected ?self $parent = null; + protected ?string $namedPrefix = null; /** - * Inject Groups and sort routes in a natural order. + * @internal + * + * @param array $properties */ - final public function buildRoutes(): void + public static function __set_state(array $properties): static { - $this->includeRoute(); // Incase of missing end method call on route. - $routes = $this->routes; + $collection = new static(); - if (!empty($this->groups)) { - $this->injectGroups('', $routes); - } - - if ($this->sortRoutes) { - \usort($routes, static function (Route $a, Route $b): int { - return !$a->getStaticPrefix() <=> !$b->getStaticPrefix() ?: \strnatcmp($a->getPath(), $b->getPath()); - }); + foreach ($properties as $property => $value) { + $collection->{$property} = $value; } - $this->routes = $routes; + return $collection; } /** * Get all the routes. * - * @return array + * @return array> */ public function getRoutes(): array { - if (!$this->locked) { - $this->buildRoutes(); - $this->locked = true; // Lock grouping and prototyping + if (!empty($this->groups)) { + $this->injectGroups('', $this->routes, $this->defaultIndex); } return $this->routes; } - /** - * Get the current route in stack. - */ - public function getRoute(): ?Route - { - return $this->route; - } - - /** - * Add route to the collection. - * - * @param bool $inject Whether to call injectRoute() on route - * - * @return $this - */ - public function add(Route $route, bool $inject = true) - { - if ($this->locked) { - throw new \RuntimeException('Cannot add a route to a frozen routes collection.'); - } - - $this->includeRoute(); // Incase of missing end method call on route. - $this->route = $inject ? $this->injectRoute($route) : $route; - - return $this; - } - /** * Maps a pattern to a handler. * @@ -148,32 +100,28 @@ public function add(Route $route, bool $inject = true) * * @return $this */ - public function addRoute(string $pattern, array $methods, $handler = null) - { - return $this->add(new Route($pattern, $methods, $handler)); - } - - /** - * Add routes to the collection. - * - * @param array $routes - * @param bool $inject Whether to call injectRoute() on each route - * - * @throws \TypeError if $routes doesn't contain a route instance - * @throws \RuntimeException if locked - * - * @return $this - */ - public function routes(array $routes, bool $inject = true) + public function add(string $pattern, array $methods = Router::DEFAULT_METHODS, mixed $handler = null): self { - if ($this->locked) { - throw new \RuntimeException('Cannot add a route to a frozen routes collection.'); + $this->asRoute = true; + $this->routes[++$this->defaultIndex] = ['handler' => $handler]; + $this->path($pattern); + + foreach ($this->prototypes as $route => $arguments) { + if ('prefix' === $route) { + $this->prefix(\implode('', $arguments)); + } elseif ('domain' === $route) { + $this->domain(...$arguments); + } elseif ('namespace' === $route) { + foreach ($arguments as $namespace) { + $this->namespace($namespace); + } + } else { + $this->routes[$this->defaultIndex][$route] = $arguments; + } } - $this->includeRoute(); // Incase of missing end method call on route. - - foreach ($routes as $route) { - $this->routes[] = $inject ? $this->injectRoute($route) : $route; + foreach ($methods as $method) { + $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true; } return $this; @@ -182,61 +130,51 @@ public function routes(array $routes, bool $inject = true) /** * Mounts controllers under the given route prefix. * - * @param string|null $name The route group prefixed name - * @param callable|RouteCollection|null $controllers A RouteCollection instance or a callable for defining routes - * - * @throws \TypeError if $controllers not instance of route collection's class - * @throws \RuntimeException if locked - * - * @return $this + * @param null|string $name The route group prefixed name + * @param null|callable|RouteCollection $collection A RouteCollection instance or a callable for defining routes + * @param bool $return If true returns a new collection instance else returns $this */ - public function group(string $name = null, $controllers = null) + public function group(string $name = null, callable|self $collection = null, bool $return = false): self { - $this->includeRoute(); // Incase of missing end method call on route. + $this->asRoute = false; - if (\is_callable($controllers)) { - $controllers($routes = $this->injectGroup($name, new static($name))); + if (\is_callable($collection)) { + $collection($routes = $this->injectGroup($name, new static()), $return); } + $route = $routes ?? $this->injectGroup($name, $collection ?? new static(), $return); + $this->groups[] = $route; - return $this->groups[] = $routes ?? $this->injectGroup($name, $controllers ?? new static($name)); + return $return ? $route : $this; } /** * Merge a collection into base. * - * @throws \RuntimeException if locked + * @return $this */ - public function populate(self $collection, bool $asGroup = false): void + public function populate(self $collection, bool $asGroup = false) { - $this->includeRoute(); - if ($asGroup) { $this->groups[] = $this->injectGroup($collection->namedPrefix, $collection); } else { - $collection->includeRoute(); // Incase of missing end method call on route. $routes = $collection->routes; + $asRoute = $this->asRoute; if (!empty($collection->groups)) { - $collection->injectGroups($collection->namedPrefix ?? '', $routes); + $collection->injectGroups($collection->namedPrefix ?? '', $routes, $this->defaultIndex); } foreach ($routes as $route) { - $this->routes[] = $this->injectRoute($route); + $this->add($route['path'], [], $route['handler']); + $this->routes[$this->defaultIndex] = \array_merge_recursive( + $this->routes[$this->defaultIndex], + \array_diff_key($route, ['path' => null, 'handler' => null]) + ); } + $this->asRoute = $asRoute; } - } - /** - * Maps a HEAD request to a handler. - * - * @param string $pattern Matched route pattern - * @param mixed $handler Handler that returns the response when matched - * - * @return $this - */ - public function head(string $pattern, $handler = null) - { - return $this->addRoute($pattern, [Router::METHOD_HEAD], $handler); + return $this; } /** @@ -247,9 +185,9 @@ public function head(string $pattern, $handler = null) * * @return $this */ - public function get(string $pattern, $handler = null) + public function get(string $pattern, $handler = null): self { - return $this->addRoute($pattern, [Router::METHOD_GET, Router::METHOD_HEAD], $handler); + return $this->add($pattern, [Router::METHOD_GET, Router::METHOD_HEAD], $handler); } /** @@ -260,9 +198,9 @@ public function get(string $pattern, $handler = null) * * @return $this */ - public function post(string $pattern, $handler = null) + public function post(string $pattern, $handler = null): self { - return $this->addRoute($pattern, [Router::METHOD_POST], $handler); + return $this->add($pattern, [Router::METHOD_POST], $handler); } /** @@ -273,9 +211,9 @@ public function post(string $pattern, $handler = null) * * @return $this */ - public function put(string $pattern, $handler = null) + public function put(string $pattern, $handler = null): self { - return $this->addRoute($pattern, [Router::METHOD_PUT], $handler); + return $this->add($pattern, [Router::METHOD_PUT], $handler); } /** @@ -286,9 +224,9 @@ public function put(string $pattern, $handler = null) * * @return $this */ - public function patch(string $pattern, $handler = null) + public function patch(string $pattern, $handler = null): self { - return $this->addRoute($pattern, [Router::METHOD_PATCH], $handler); + return $this->add($pattern, [Router::METHOD_PATCH], $handler); } /** @@ -299,9 +237,9 @@ public function patch(string $pattern, $handler = null) * * @return $this */ - public function delete(string $pattern, $handler = null) + public function delete(string $pattern, $handler = null): self { - return $this->addRoute($pattern, [Router::METHOD_DELETE], $handler); + return $this->add($pattern, [Router::METHOD_DELETE], $handler); } /** @@ -312,9 +250,9 @@ public function delete(string $pattern, $handler = null) * * @return $this */ - public function options(string $pattern, $handler = null) + public function options(string $pattern, $handler = null): self { - return $this->addRoute($pattern, [Router::METHOD_OPTIONS], $handler); + return $this->add($pattern, [Router::METHOD_OPTIONS], $handler); } /** @@ -325,9 +263,9 @@ public function options(string $pattern, $handler = null) * * @return $this */ - public function any(string $pattern, $handler = null) + public function any(string $pattern, $handler = null): self { - return $this->addRoute($pattern, Router::HTTP_METHODS_STANDARD, $handler); + return $this->add($pattern, Router::HTTP_METHODS_STANDARD, $handler); } /** @@ -342,67 +280,31 @@ public function any(string $pattern, $handler = null) * * @return $this */ - public function resource(string $pattern, $resource, string $action = 'action') + public function resource(string $pattern, string|object $resource, string $action = 'action'): self { return $this->any($pattern, new Handlers\ResourceHandler($resource, $action)); } - /** - * @throws \RuntimeException if locked - */ - protected function injectRoute(Route $route): Route + public function generateRouteName(string $prefix, array $route = null): string { - if (!empty($defaultsStack = $this->prototypes)) { - foreach ($defaultsStack as $routeMethod => $arguments) { - if ('prefix' === $routeMethod) { - $route->prefix(\implode('', \array_merge(...$arguments))); - continue; - } - - foreach ($arguments as $parameters) { - \call_user_func_array([$route, $routeMethod], $parameters); - } - } - } + $route = $route ?? $this->routes[$this->defaultIndex]; + $routeName = \implode('_', \array_keys($route['methods'] ?? [])).'_'.$prefix.$route['path'] ?? ''; + $routeName = \str_replace(['/', ':', '|', '-'], '_', $routeName); + $routeName = (string) \preg_replace('/[^a-z0-9A-Z_.]+/', '', $routeName); - return $route; + return (string) \preg_replace(['/\_+/', '/\.+/'], ['_', '.'], $routeName); } - /** - * Include route to stack if not done. - */ - protected function includeRoute(): void - { - if (null !== $this->route) { - $this->defaultIndex = -1; + // 'next', 'key', 'valid', 'rewind' - $this->routes[] = $this->route; // Incase an end method is missing at the end of a route call. - $this->route = null; - } - } - - /** - * @return self|$this - */ - protected function injectGroup(?string $prefix, self $controllers) + protected function injectGroup(?string $prefix, self $controllers, bool $return = false): self { - if ($this->locked) { - throw new \RuntimeException('Cannot add a nested routes collection to a frozen routes collection.'); - } + $controllers->prototypes = \array_merge_recursive($this->prototypes, $controllers->prototypes); - if ($controllers->sortRoutes) { - throw new \RuntimeException('Cannot sort routes in a nested collection.'); + if ($return) { + $controllers->parent = $this; } - $controllers->includeRoute(); // Incase of missing end method call on route. - - if (empty($controllers->routes)) { - $controllers->defaultIndex = 1; - } - - $controllers->prototypes = \array_merge($this->prototypes, $controllers->prototypes); - $controllers->parent = $this; - if (empty($controllers->namedPrefix)) { $controllers->namedPrefix = $prefix; } @@ -411,31 +313,31 @@ protected function injectGroup(?string $prefix, self $controllers) } /** - * @param array $collection + * @param array> $collection */ - private function injectGroups(string $prefix, array &$collection): void + private function injectGroups(string $prefix, array &$collection, int &$count): void { $unnamedRoutes = []; foreach ($this->groups as $group) { - $group->includeRoute(); // Incase of missing end method call on route. - foreach ($group->routes as $route) { - if (empty($name = $route->getName())) { - $name = $route->generateRouteName(''); + if (empty($name = $route['name'] ?? '')) { + $name = $group->generateRouteName('', $route); if (isset($unnamedRoutes[$name])) { - $name .= ('_' !== $name[-1] ? '_' : '') . ++$unnamedRoutes[$name]; + $name .= ('_' !== $name[-1] ? '_' : '').++$unnamedRoutes[$name]; } else { $unnamedRoutes[$name] = 0; } } - $collection[] = $route->bind($prefix . $group->namedPrefix . $name); + $route['name'] = $prefix.$group->namedPrefix.$name; + $collection[] = $route; + ++$count; } if (!empty($group->groups)) { - $group->injectGroups($prefix . $group->namedPrefix, $collection); + $group->injectGroups($prefix.$group->namedPrefix, $collection, $count); } } diff --git a/src/Traits/DataTrait.php b/src/Traits/DataTrait.php deleted file mode 100644 index 8689b732..00000000 --- a/src/Traits/DataTrait.php +++ /dev/null @@ -1,424 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Traits; - -use Flight\Routing\Exceptions\{InvalidControllerException, UriHandlerException}; -use Flight\Routing\Route; -use Flight\Routing\Handlers\ResourceHandler; - -trait DataTrait -{ - /** @var array */ - protected array $data; - - /** - * Sets the route path prefix. - * - * @return $this - */ - public function prefix(string $path) - { - if (!empty($path)) { - $uri = $this->data['path'] ?? '/'; - - if (\strlen($uri) > 1 && isset(Route::URL_PREFIX_SLASHES[$uri[1]])) { - $uri = \substr($uri, 1); - } - - if (isset(Route::URL_PREFIX_SLASHES[$path[-1]])) { - $uri = \substr($uri, 1); - } - - \preg_match(Route::PRIORITY_REGEX, $this->data['path'] = ('/' . \ltrim($path, '/')) . $uri, $pM); - $this->data['prefix'] = !empty($pM[1] ?? null) ? $pM[1] : null; - } - - return $this; - } - - /** - * Sets the route path pattern. - * - * @return $this - */ - public function path(string $pattern) - { - if (\preg_match(Route::RCA_PATTERN, $pattern, $matches, \PREG_UNMATCHED_AS_NULL)) { - if (null !== $matches[1]) { - $this->data['schemes'][$matches[1]] = true; - } - - if (null !== $matches[2]) { - $this->data['hosts'][$matches[2]] = true; - } - - if (null !== $matches[5]) { - $handler = $matches[4] ?? $this->data['handler'] ?? null; - $this->data['handler'] = !empty($handler) ? [$handler, $matches[5]] : $matches[5]; - } - - if (empty($matches[3])) { - throw new UriHandlerException(\sprintf('The route pattern "%s" is invalid as route path must be present in pattern.', $pattern)); - } - - \preg_match(Route::PRIORITY_REGEX, $resolved = '/' . \ltrim($matches[3], '/'), $pM); - $this->data['prefix'] = !empty($pM[1] ?? null) ? $pM[1] : null; - } - - return $this->setData('path', $resolved ?? '/' . \ltrim($pattern, '/')); - } - - /** - * Sets the requirement for the HTTP method. - * - * @param string $methods the HTTP method(s) name - * - * @return $this - */ - public function method(string ...$methods) - { - foreach ($methods as $method) { - $this->data['methods'][\strtoupper($method)] = true; - } - - return $this; - } - - /** - * Sets the requirement of host on this Route. - * - * @param string $hosts The host for which this route should be enabled - * - * @return $this - */ - public function domain(string ...$hosts) - { - foreach ($hosts as $host) { - \preg_match(Route::URL_PATTERN, $host, $matches, \PREG_UNMATCHED_AS_NULL); - - if (isset($matches[1])) { - $this->data['schemes'][$matches[1]] = true; - } - - if (isset($matches[2])) { - $this->data['hosts'][$matches[2]] = true; - } - } - - return $this; - } - - /** - * Sets the requirement of domain scheme on this Route. - * - * @param string ...$schemes - * - * @return $this - */ - public function scheme(string ...$schemes) - { - foreach ($schemes as $scheme) { - $this->data['schemes'][$scheme] = true; - } - - return $this; - } - - /** - * Sets the route name. - * - * @return $this - */ - public function bind(string $routeName) - { - $this->data['name'] = $routeName; - - return $this; - } - - /** - * Sets the parameter value for a route handler. - * - * @param mixed $value The parameter value - * - * @return $this - */ - public function argument(string $parameter, $value) - { - if (\is_numeric($value)) { - $value = (int) $value; - } elseif (\is_string($value)) { - $value = \rawurldecode($value); - } - - $this->data['arguments'][$parameter] = $value; - - return $this; - } - - /** - * Sets the parameter values for a route handler. - * - * @param array $parameters The route handler parameters - * - * @return $this - */ - public function arguments(array $parameters) - { - foreach ($parameters as $variable => $value) { - $this->argument($variable, $value); - } - - return $this; - } - - /** - * Sets the route code that should be executed when matched. - * - * @param mixed $to PHP class, object or callable that returns the response when matched - * - * @return $this - */ - public function run($to) - { - if (isset($this->data['namespace'])) { - $to = $this->resolveNamespace($this->data['namespace'], $to); - unset($this->data['namespace']); // No longer needed. - } - - $this->data['handler'] = $to; - - return $this; - } - - /** - * Sets the missing namespace on route's handler. - * - * @throws InvalidControllerException if $namespace is invalid - * - * @return $this - */ - public function namespace(string $namespace) - { - if (!empty($namespace)) { - if ('\\' === $namespace[-1]) { - throw new InvalidControllerException(\sprintf('Namespace "%s" provided for routes must not end with a "\\".', $namespace)); - } - - if (isset($this->data['handler'])) { - $this->data['handler'] = $this->resolveNamespace($namespace, $this->data['handler']); - } else { - $this->data['namespace'] = ($this->data['namespace'] ?? '') . $namespace; - } - } - - return $this; - } - - /** - * Attach a named middleware group(s) to route. - * - * @return $this - */ - public function piped(string ...$to) - { - foreach ($to as $namedMiddleware) { - $this->data['middlewares'][$namedMiddleware] = true; - } - - return $this; - } - - /** - * Sets the requirement for a route variable. - * - * @param string|string[] $regexp The regexp to apply - * - * @return $this - */ - public function assert(string $variable, $regexp) - { - $this->data['patterns'][$variable] = $regexp; - - return $this; - } - - /** - * Sets the requirements for a route variable. - * - * @param array $regexps The regexps to apply - * - * @return $this - */ - public function asserts(array $regexps) - { - foreach ($regexps as $variable => $regexp) { - $this->assert($variable, $regexp); - } - - return $this; - } - - /** - * Sets the default value for a route variable. - * - * @param mixed $default The default value - * - * @return $this - */ - public function default(string $variable, $default) - { - $this->data['defaults'][$variable] = $default; - - return $this; - } - - /** - * Sets the default values for a route variables. - * - * @param array $values - * - * @return $this - */ - public function defaults(array $values) - { - foreach ($values as $variable => $default) { - $this->default($variable, $default); - } - - return $this; - } - - public function hasMethod(string $method): bool - { - return isset($this->data['methods'][$method]); - } - - public function hasScheme(string $scheme): bool - { - return empty($s = $this->data['schemes'] ?? []) || isset($s[$scheme]); - } - - public function getName(): ?string - { - return $this->data['name'] ?? null; - } - - public function getPath(): string - { - return $this->data['path'] ?? '/'; - } - - /** - * @return array - */ - public function getMethods(): array - { - return \array_keys($this->data['methods'] ?? []); - } - - /** - * @return array - */ - public function getSchemes(): array - { - return \array_keys($this->data['schemes'] ?? []); - } - - /** - * @return array - */ - public function getHosts(): array - { - return \array_keys($this->data['hosts'] ?? []); - } - - /** - * @return array - */ - public function getArguments(): array - { - return $this->data['arguments'] ?? []; - } - - /** - * @return mixed - */ - public function getHandler() - { - return $this->data['handler'] ?? null; - } - - /** - * @return array - */ - public function getDefaults(): array - { - return $this->data['defaults'] ?? []; - } - - /** - * @return array - */ - public function getPatterns(): array - { - return $this->data['patterns'] ?? []; - } - - /** - * Return the list of attached grouped middlewares. - * - * @return array - */ - public function getPiped(): array - { - return \array_keys($this->data['middlewares'] ?? []); - } - - /** - * Return's the static prefixed portion of the route path else null. - * - * @see Flight\Routing\RouteCollection::getRoutes() - */ - public function getStaticPrefix(): ?string - { - return $this->data['prefix'] ?? null; - } - - /** - * @internal skip throwing an exception and return existing $controller - * - * @param callable|object|string|string[] $controller - * - * @return mixed - */ - protected function resolveNamespace(string $namespace, $controller) - { - if ($controller instanceof ResourceHandler) { - return $controller->namespace($namespace); - } - - if (\is_string($controller) && '\\' === $controller[0]) { - $controller = $namespace . $controller; - } elseif ((\is_array($controller) && 2 == \count($controller, \COUNT_RECURSIVE)) && \is_string($controller[0])) { - $controller[0] = $this->resolveNamespace($namespace, $controller[0]); - } - - return $controller; - } -} diff --git a/src/Traits/PrototypeTrait.php b/src/Traits/PrototypeTrait.php index 597af355..b083b7f6 100644 --- a/src/Traits/PrototypeTrait.php +++ b/src/Traits/PrototypeTrait.php @@ -17,6 +17,9 @@ namespace Flight\Routing\Traits; +use Flight\Routing\Exceptions\{InvalidControllerException, UriHandlerException}; +use Flight\Routing\Handlers\ResourceHandler; + /** * A trait providing route method prototyping. * @@ -24,296 +27,521 @@ */ trait PrototypeTrait { - private int $defaultIndex = 0; + protected int $defaultIndex = -1; + protected bool $asRoute = false, $sorted = false; + + /** @var array */ + protected array $prototypes = []; + + /** @var array> */ + protected array $routes = []; - /** @var array */ - private array $prototypes = []; + /** @var array */ + protected array $groups = []; /** - * Allows a proxied method call to route(s). + * Set route's data by calling supported route method in collection. * - * @throws \RuntimeException if locked - * @throws \UnexpectedValueException if bind method is called used for route + * @param array|true $routeData An array is a list of route method bindings + * Else if true, route bindings can be prototyped + * to all registered routes * * @return $this + * + * @throws \InvalidArgumentException if route not defined before calling this method */ - public function prototype(array $routeData) + public function prototype(array|bool $routeData): self { - foreach ($routeData as $routeMethod => $arguments) { - $arguments = \is_array($arguments) ? $arguments : [$arguments]; + if (true === $routeData) { + $this->asRoute = false; - if (null === $this->route && 'bind' === $routeMethod) { - throw new \UnexpectedValueException(\sprintf('Binding the name "%s" is only supported on routes.', $arguments[0])); - } - - if ('s' === $routeMethod[-1]) { - $arguments = [$arguments]; - } + return $this; + } - $this->doPrototype($routeMethod, $arguments); + foreach ($routeData as $routeMethod => $arguments) { + \call_user_func_array([$this, $routeMethod], \is_array($arguments) ? $arguments : [$arguments]); } return $this; } /** - * This method performs two functions. + * Ending of group chaining stack. (use with caution!). * - * - Unmounts a group collection to continue routes stalk. - * - Adds a route into collection's stack. + * RISK: This method can break the collection, call this method + * only on the last route of a group stack which the $return parameter + * of the group method is set true. * * @return $this */ - public function end() + public function end(): self + { + return $this->parent ?? $this; + } + + /** + * Set the route's path. + * + * @return $this + * + * @throws \InvalidArgumentException if you is not set + */ + public function path(string $pattern): self { - if (null !== $this->route) { - $this->routes[] = $this->route; - $this->route = null; + if (!$this->asRoute) { + throw new \InvalidArgumentException('Cannot use the "path()" method if route not defined.'); + } + + if (1 === \preg_match(static::RCA_PATTERN, $pattern, $matches, \PREG_UNMATCHED_AS_NULL)) { + isset($matches[1]) && $this->routes[$this->defaultIndex]['schemes'][$matches[1]] = true; - $defaultIndex = $this->defaultIndex; - $this->defaultIndex = 0; + if (isset($matches[2])) { + if ('/' !== ($matches[3][0] ?? '')) { + throw new UriHandlerException(\sprintf('The route pattern "%s" is invalid as route path must be present in pattern.', $pattern)); + } + $this->routes[$this->defaultIndex]['hosts'][$matches[2]] = true; + } - if ($defaultIndex >= 0) { - return $this; + if (isset($matches[5])) { + $handler = $matches[4] ?? $this->routes[$this->defaultIndex]['handler'] ?? null; + $this->routes[$this->defaultIndex]['handler'] = !empty($handler) ? [$handler, $matches[5]] : $matches[5]; } + + \preg_match(static::PRIORITY_REGEX, $pattern = $matches[3], $m, \PREG_UNMATCHED_AS_NULL); + $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null; } - return $this->parent ?? $this; + $this->routes[$this->defaultIndex]['path'] = '/'.\ltrim($pattern, '/'); + + return $this; } /** - * Prototype a unique name to a route. - * - * @see Route::bind() for more information - * - * @throws \UnderflowException if route doesn't exist + * Set the route's unique name identifier,. * * @return $this + * + * @throws \InvalidArgumentException if you is not set */ - public function bind(string $routeName) + public function bind(string $routeName): self { - if (null === $this->route) { - throw new \UnderflowException(\sprintf('Binding the name "%s" is only supported on routes.', $routeName)); + if (!$this->asRoute) { + throw new \InvalidArgumentException('Cannot use the "bind()" method if route not defined.'); } - - $this->route->bind($routeName); + $this->routes[$this->defaultIndex]['name'] = $routeName; return $this; } /** - * Prototype a handler to a route. + * Set the route's handler. * * @param mixed $to PHP class, object or callable that returns the response when matched * - * @throws \UnderflowException if route doesn't exist - * * @return $this + * + * @throws \InvalidArgumentException if you is not set */ - public function run($to) + public function run(mixed $to): self { - if (null === $this->route) { - throw new \UnderflowException(sprintf('Binding a handler with type of "%s", is only supported on routes.', \get_debug_type($to))); + if (!$this->asRoute) { + throw new \InvalidArgumentException('Cannot use the "run()" method if route not defined.'); } - $this->route->run($to); + if (!empty($namespace = $this->routes[$this->defaultIndex]['namespace'] ?? null)) { + unset($this->routes[$this->defaultIndex]['namespace']); + } + $this->routes[$this->defaultIndex]['handler'] = $this->resolveHandler($to, $namespace); return $this; } /** - * Prototype optional default values to route(s) - * - * @param mixed $default The default value - * - * @see Route::default() for more information + * Set the route(s) default value for it's placeholder or required argument. * * @return $this */ - public function default(string $variable, $default) + public function default(string $variable, mixed $default): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + if ($this->asRoute) { + $this->routes[$this->defaultIndex]['defaults'][$variable] = $default; + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes['defaults'] = \array_merge_recursive($this->prototypes['defaults'] ?? [], [$variable => $default]); + } else { + foreach ($this->routes as &$route) { + $route['defaults'] = \array_merge_recursive($route['defaults'] ?? [], [$variable => $default]); + } + $this->resolveGroup(__FUNCTION__, [$variable, $default]); + } + + return $this; } /** - * Prototype optional default values to route(s) + * Set the routes(s) default value for it's placeholder or required argument. * * @param array $values * - * @see Route::defaults() for more information - * * @return $this */ - public function defaults(array $values) + public function defaults(array $values): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + foreach ($values as $variable => $default) { + $this->default($variable, $default); + } + + return $this; } /** - * Prototype a rule to a named placeholder in route pattern. - * - * @param string|string[] $regexp The regexp to apply + * Set the route(s) placeholder requirement. * - * @see Route::assert() for more information + * @param array|string $regexp The regexp to apply * * @return $this */ - public function assert(string $variable, $regexp) + public function placeholder(string $variable, string|array $regexp): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + if ($this->asRoute) { + $this->routes[$this->defaultIndex]['placeholders'][$variable] = $regexp; + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes['placeholders'] = \array_merge_recursive($this->prototypes['placeholders'] ?? [], [$variable => $regexp]); + } else { + foreach ($this->routes as &$route) { + $route['placeholders'] = \array_merge_recursive($route['placeholders'] ?? [], [$variable => $regexp]); + } + + $this->resolveGroup(__FUNCTION__, [$variable, $regexp]); + } + + return $this; } /** - * Prototype a set of rules to a named placeholder in route pattern. + * Set the route(s) placeholder requirements. * - * @param array $regexps The regexps to apply - * - * @see Route::asserts() for more information + * @param array|string> $placeholders The regexps to apply * * @return $this */ - public function asserts(array $regexps) + public function placeholders(array $placeholders): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + foreach ($placeholders as $placeholder => $value) { + $this->placeholder($placeholder, $value); + } + + return $this; } /** - * Prototype the parameter supplied to route handler's constructor/factory. - * - * @param mixed $value The parameter value - * - * @see Route::argument() for more information + * Set the named parameter supplied to route(s) handler's constructor/factory. * * @return $this */ - public function argument(string $parameter, $value) + public function argument(string $parameter, mixed $value): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + $resolver = fn ($value) => \is_numeric($value) ? (int) $value : (\is_string($value) ? \rawurldecode($value) : $value); + + if ($this->asRoute) { + $this->routes[$this->defaultIndex]['arguments'][$parameter] = $resolver($value); + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes['arguments'] = \array_merge_recursive($this->prototypes['arguments'] ?? [], [$parameter => $value]); + } else { + foreach ($this->routes as &$route) { + $route['arguments'] = \array_merge_recursive($route['arguments'] ?? [], [$parameter => $resolver($value)]); + } + $this->resolveGroup(__FUNCTION__, [$parameter, $value]); + } + + return $this; } /** - * Prototype the parameters supplied to route handler's constructor/factory. - * - * @param array $parameters The route handler parameters + * Set the named parameters supplied to route(s) handler's constructor/factory. * - * @see Route::arguments() for more information + * @param array $parameters The route handler parameters * * @return $this */ - public function arguments(array $parameters) + public function arguments(array $parameters): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + foreach ($parameters as $parameter => $value) { + $this->argument($parameter, $value); + } + + return $this; } /** - * Prototype the missing namespace for all routes handlers. - * - * @see Route::namespace() for more information + * Set the missing namespace for route(s) handler(s). * * @return $this + * + * @throws InvalidControllerException if namespace does not ends with a \ */ - public function namespace(string $namespace) + public function namespace(string $namespace): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + if ('\\' !== $namespace[-1]) { + throw new InvalidControllerException(\sprintf('Cannot set a route\'s handler namespace "%s" without an ending "\\".', $namespace)); + } + + if ($this->asRoute) { + $handler = &$this->routes[$this->defaultIndex]['handler'] ?? null; + + if (!empty($handler)) { + $handler = $this->resolveHandler($handler, $namespace); + } else { + $this->routes[$this->defaultIndex][__FUNCTION__] = $namespace; + } + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes[__FUNCTION__][] = $namespace; + } else { + foreach ($this->routes as &$route) { + $route['handler'] = $this->resolveHandler($route['handler'] ?? null, $namespace); + } + $this->resolveGroup(__FUNCTION__, [$namespace]); + } + + return $this; } /** - * Prototype HTTP request method(s) to all routes. - * - * @see Route::method() for more information + * Set the route(s) HTTP request method(s). * * @return $this */ - public function method(string ...$methods) + public function method(string ...$methods): self { - return $this->doPrototype(__FUNCTION__, $methods); + if ($this->asRoute) { + foreach ($methods as $method) { + $this->routes[$this->defaultIndex]['methods'][\strtoupper($method)] = true; + } + + return $this; + } + + $routeMethods = \array_fill_keys(\array_map('strtoupper', $methods), true); + + if (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes['methods'] = \array_merge($this->prototypes['methods'] ?? [], $routeMethods); + } else { + foreach ($this->routes as &$route) { + $route['methods'] += $routeMethods; + } + $this->resolveGroup(__FUNCTION__, $methods); + } + + return $this; } /** - * Prototype HTTP host scheme(s) to route(s) - * - * @see Route::scheme() for more information + * Set route(s) HTTP host scheme(s). * * @return $this */ - public function scheme(string ...$schemes) + public function scheme(string ...$schemes): self { - return $this->doPrototype(__FUNCTION__, $schemes); + if ($this->asRoute) { + foreach ($schemes as $scheme) { + $this->routes[$this->defaultIndex]['schemes'][$scheme] = true; + } + + return $this; + } + $routeSchemes = \array_fill_keys($schemes, true); + + if (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes['schemes'] = \array_merge($this->prototypes['schemes'] ?? [], $routeSchemes); + } else { + foreach ($this->routes as &$route) { + $route['schemes'] = \array_merge($route['schemes'] ?? [], $routeSchemes); + } + $this->resolveGroup(__FUNCTION__, $schemes); + } + + return $this; } /** - * Prototype HTTP host name(s) to route(s) - * - * @see Route::domain() for more information + * Set the route(s) HTTP host name(s). * * @return $this */ - public function domain(string ...$hosts) + public function domain(string ...$domains): self { - return $this->doPrototype(__FUNCTION__, $hosts); + $resolver = static function (array &$route, array $domains): void { + foreach ($domains as $domain) { + if (1 === \preg_match('/^(?:([a-z]+)\:\/{2})?([^\/]+)?$/u', $domain, $m, \PREG_UNMATCHED_AS_NULL)) { + if (isset($m[1])) { + $route['schemes'][$m[1]] = true; + } + + if (isset($m[2])) { + $route['hosts'][$m[2]] = true; + } + } + } + }; + + if ($this->asRoute) { + $resolver($this->routes[$this->defaultIndex], $domains); + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes[__FUNCTION__] = \array_merge($this->prototypes[__FUNCTION__] ?? [], $domains); + } else { + foreach ($this->routes as &$route) { + $resolver($route, $domains); + } + $this->resolveGroup(__FUNCTION__, $domains); + } + + return $this; } /** - * Prototype prefix path to route(s) - * - * @see Route::prefix() for more information + * Set prefix path which should be prepended to route(s) path. * * @return $this */ - public function prefix(string $path) + public function prefix(string $path): self { - return $this->doPrototype(__FUNCTION__, \func_get_args()); + $resolver = static function (string $prefix, string $path): string { + if ('/' !== ($prefix[0] ?? '')) { + $prefix = '/'.$prefix; + } + + if ($prefix[-1] === $path[0] || 1 === \preg_match('/^\W+$/', $prefix[-1])) { + return $prefix.\substr($path, 1); + } + + return $prefix.$path; + }; + + if ($this->asRoute) { + \preg_match( + static::PRIORITY_REGEX, + $this->routes[$this->defaultIndex]['path'] = $resolver( + $path, + $this->routes[$this->defaultIndex]['path'] ?? '', + ), + $m, + \PREG_UNMATCHED_AS_NULL + ); + $this->routes[$this->defaultIndex]['prefix'] = $m[1] ?? null; + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes[__FUNCTION__][] = $path; + } else { + foreach ($this->routes as &$route) { + \preg_match(static::PRIORITY_REGEX, $route['path'] = $resolver($path, $route['path']), $m); + $route['prefix'] = $m[1] ?? null; + } + + $this->resolveGroup(__FUNCTION__, [$path]); + } + + return $this; } /** - * Prototype a set of named grouped middleware(s) to route(s) - * - * @see Route::piped() for more information + * Set a set of named grouped middleware(s) to route(s). * * @return $this */ - public function piped(string ...$to) + public function piped(string ...$to): self { - return $this->doPrototype(__FUNCTION__, $to); + if ($this->asRoute) { + foreach ($to as $middleware) { + $this->routes[$this->defaultIndex]['middlewares'][$middleware] = true; + } + + return $this; + } + $routeMiddlewares = \array_fill_keys($to, true); + + if (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes['middlewares'] = \array_merge($this->prototypes['middlewares'] ?? [], $routeMiddlewares); + } else { + foreach ($this->routes as &$route) { + $route['middlewares'] = \array_merge($route['middlewares'] ?? [], $routeMiddlewares); + } + $this->resolveGroup(__FUNCTION__, $to); + } + + return $this; } /** - * Prototype a custom key and value to route(s) - * - * @param mixed $value + * Set a custom key and value to route(s). * * @return $this */ - public function set(string $key, $value) + public function set(string $key, mixed $value): self { - return $this->doPrototype('setData', \func_get_args()); + if (\in_array($key, [ + 'name', + 'handler', + 'arguments', + 'namespace', + 'middlewares', + 'methods', + 'placeholders', + 'prefix', + 'hosts', + 'schemes', + 'defaults', + ], true)) { + throw new \InvalidArgumentException(\sprintf('Cannot replace the default "%s" route binding.', $key)); + } + + if ($this->asRoute) { + $this->routes[$this->defaultIndex][$key] = $value; + } elseif (-1 === $this->defaultIndex && empty($this->groups)) { + $this->prototypes[$key] = !\is_array($value) ? $value : \array_merge($this->prototypes[$key] ?? [], $value); + } else { + foreach ($this->routes as &$route) { + $route[$key] = \is_array($value) ? \array_merge($route[$key] ?? [], $value) : $value; + } + $this->resolveGroup(__FUNCTION__, [$key, $value]); + } + + return $this; } - /** - * @param array $arguments - * - * @return $this - */ - protected function doPrototype(string $routeMethod, array $arguments) + protected function resolveHandler(mixed $handler, string $namespace = null): mixed { - if ($this->locked) { - throw new \RuntimeException(\sprintf('Prototyping "%s" route method failed as routes collection is frozen.', $routeMethod)); + if (empty($namespace)) { + return $handler; } - if (null !== $this->route) { - \call_user_func_array([$this->route, $routeMethod], $arguments); - } elseif ($this->defaultIndex > 0 || \count($routes = $this->routes) < 1) { - $this->prototypes[$routeMethod][] = $arguments; - } else { - foreach ($routes as $route) { - \call_user_func_array([$route, $routeMethod], $arguments); + if (\is_string($handler)) { + if ('\\' === $handler[0] || \str_starts_with($handler, $namespace)) { + return $handler; + } + $handler = $namespace.$handler; + } elseif (\is_array($handler)) { + if (2 !== \count($handler, \COUNT_RECURSIVE)) { + throw new InvalidControllerException('Cannot use a non callable like array as route handler.'); } - foreach ($this->groups as $group) { - \call_user_func_array([$group, $routeMethod], $arguments); + if (\is_string($handler[0]) && !\str_starts_with($handler[0], $namespace)) { + $handler[0] = $this->resolveHandler($handler[0], $namespace); } + } elseif ($handler instanceof ResourceHandler) { + $handler = $handler->namespace($namespace); } - return $this; + return $handler; + } + + /** + * @param array $arguments + */ + protected function resolveGroup(string $method, array $arguments): void + { + foreach ($this->groups as $group) { + $asRoute = $group->asRoute; + $group->asRoute = false; + \call_user_func_array([$group, $method], $arguments); + $group->asRoute = $asRoute; + } } } From 7548a389faf36e726eed824e8048310a0e98cce5 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:44:10 +0000 Subject: [PATCH 07/40] Added sort method to the route collection class --- src/RouteCollection.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/RouteCollection.php b/src/RouteCollection.php index 3ed2bfd1..2be3b3bf 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -75,6 +75,23 @@ public static function __set_state(array $properties): static return $collection; } + /** + * Sort all routes beginning with static routes. + */ + public function sort(): void + { + if (!empty($this->groups)) { + $this->injectGroups('', $this->routes, $this->defaultIndex); + } + + $this->sorted || $this->sorted = \usort($this->routes, static function (array $a, array $b): int { + $ap = $a['prefix'] ?? null; + $bp = $b['prefix'] ?? null; + + return !($ap && $ap === $a['path']) <=> !($bp && $bp === $b['path']) ?: \strnatcmp($a['path'], $b['path']); + }); + } + /** * Get all the routes. * From ef83bb7c4d64a1367f846ca5260ee31fe9a9b76f Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:45:06 +0000 Subject: [PATCH 08/40] Added countable implementation to the route collection class --- src/RouteCollection.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/RouteCollection.php b/src/RouteCollection.php index 2be3b3bf..20d8f8a4 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -37,7 +37,7 @@ * * @author Divine Niiquaye Ibok */ -class RouteCollection +class RouteCollection implements \Countable { use Traits\PrototypeTrait; @@ -106,6 +106,18 @@ public function getRoutes(): array return $this->routes; } + /** + * Get the total number of routes. + */ + public function count(): int + { + if (!empty($this->groups)) { + $this->injectGroups('', $this->routes, $this->defaultIndex); + } + + return $this->defaultIndex + 1; + } + /** * Maps a pattern to a handler. * From 836a1949c2ce4f328d8b177a394a862e2d130289 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:45:51 +0000 Subject: [PATCH 09/40] Added array access implementation to the route collection class --- src/RouteCollection.php | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/RouteCollection.php b/src/RouteCollection.php index 20d8f8a4..cbed2387 100644 --- a/src/RouteCollection.php +++ b/src/RouteCollection.php @@ -37,7 +37,7 @@ * * @author Divine Niiquaye Ibok */ -class RouteCollection implements \Countable +class RouteCollection implements \Countable, \ArrayAccess { use Traits\PrototypeTrait; @@ -118,6 +118,34 @@ public function count(): int return $this->defaultIndex + 1; } + /** + * Checks if route by its index exists. + */ + public function offsetExists(mixed $offset): bool + { + return isset($this->routes[$offset]); + } + + /** + * Get the route by its index. + * + * @return null|array + */ + public function offsetGet(mixed $offset): ?array + { + return $this->routes[$offset] ?? null; + } + + public function offsetUnset(mixed $offset): void + { + unset($this->routes[$offset]); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \BadMethodCallException('The operator "[]" for new route, use the add() method instead.'); + } + /** * Maps a pattern to a handler. * From 900b19e13fd96075459966e4c9f177d04f070e38 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:49:39 +0000 Subject: [PATCH 10/40] Upgraded php minimum version requirement from 7.4 to 8.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d07cba2e..1c368c41 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "source": "https://github.com/divineniiquaye/flight-routing" }, "require": { - "php": "^7.4 || ^8.0", + "php": "^8.0", "psr/http-factory": "^1.0", "laminas/laminas-stratigility": "^3.2", "symfony/polyfill-php80": "^1.22" From 0575a50f389455ded5cd209b07dca5052a8943a1 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Sun, 4 Sep 2022 12:51:13 +0000 Subject: [PATCH 11/40] Updated annotation/attribute to use new route type --- src/Annotation/Listener.php | 135 ++++++++++++++++-------------------- src/Annotation/Route.php | 70 +++++-------------- 2 files changed, 76 insertions(+), 129 deletions(-) diff --git a/src/Annotation/Listener.php b/src/Annotation/Listener.php index 0b8f6050..7c0fe433 100644 --- a/src/Annotation/Listener.php +++ b/src/Annotation/Listener.php @@ -18,7 +18,8 @@ namespace Flight\Routing\Annotation; use Biurad\Annotations\{InvalidAnnotationException, ListenerInterface}; -use Flight\Routing\{Route as BaseRoute, RouteCollection}; +use Flight\Routing\Handlers\ResourceHandler; +use Flight\Routing\RouteCollection; /** * The Biurad Annotation's Listener bridge. @@ -28,18 +29,9 @@ class Listener implements ListenerInterface { private RouteCollection $collector; - private ?string $unNamedPrefix; - /** @var array */ - private array $defaultUnnamedIndex = []; - - /** - * @param string $unNamedPrefix Setting a prefix or empty string will generate a name for all routes. - * If set to null, only named grouped class routes names will be generated. - */ - public function __construct(RouteCollection $collector = null, ?string $unNamedPrefix = '') + public function __construct(RouteCollection $collector = null) { - $this->unNamedPrefix = $unNamedPrefix; $this->collector = $collector ?? new RouteCollection(); } @@ -48,58 +40,42 @@ public function __construct(RouteCollection $collector = null, ?string $unNamedP */ public function load(array $annotations): RouteCollection { - $foundAnnotations = []; - foreach ($annotations as $annotation) { $reflection = $annotation['type']; - $methodAnnotations = []; + $attributes = $annotation['attributes'] ?? []; if (empty($methods = $annotation['methods'] ?? [])) { - $this->getRoutes($annotation['attributes'], $reflection->name, $foundAnnotations, $reflection instanceof \ReflectionClass); + foreach ($attributes as $route) { + $this->addRoute($this->collector, $route, $reflection->getName()); + } continue; } - foreach ($methods as $method) { - $controller = ($m = $method['type'])->isStatic() ? $reflection->name . '::' . $m->name : [$reflection->name, $m->name]; - $this->getRoutes($method['attributes'], $controller, $methodAnnotations); - } - - foreach ($methodAnnotations as $methodAnnotation) { - if (empty($annotation['attributes'])) { - if (!empty($routeName = $this->resolveRouteName(null, $methodAnnotation))) { - $methodAnnotation->bind($routeName); + if (empty($attributes)) { + foreach ($methods as $method) { + foreach (($method['attributes'] ?? []) as $route) { + $controller = ($m = $method['type'])->isStatic() ? $reflection->name.'::'.$m->name : [$reflection->name, $m->name]; + $this->addRoute($this->collector, $route, $controller); } - - $foundAnnotations[] = $methodAnnotation; - continue; } + continue; + } - foreach ($annotation['attributes'] as $classAnnotation) { - if (null !== $classAnnotation->resource) { - throw new InvalidAnnotationException('Restful annotated class cannot contain annotated method(s).'); - } - - $annotatedMethod = clone $methodAnnotation->method(...$classAnnotation->methods) - ->scheme(...$classAnnotation->schemes) - ->domain(...$classAnnotation->hosts) - ->defaults($classAnnotation->defaults) - ->arguments($classAnnotation->arguments) - ->asserts($classAnnotation->patterns); - - if (null !== $classAnnotation->path) { - $annotatedMethod->prefix($classAnnotation->path); - } + foreach ($attributes as $classAnnotation) { + $group = empty($classAnnotation->resource) + ? $this->addRoute($this->collector->group($classAnnotation->name, return: true), $classAnnotation, true) + : throw new InvalidAnnotationException('Restful annotated class cannot contain annotated method(s).'); - if (!empty($routeName = $this->resolveRouteName($classAnnotation->name, $annotatedMethod, true))) { - $annotatedMethod->bind($routeName); + foreach ($methods as $method) { + foreach (($method['attributes'] ?? []) as $methodAnnotation) { + $controller = ($m = $method['type'])->isStatic() ? $reflection->name.'::'.$m->name : [$reflection->name, $m->name]; + $this->addRoute($group, $methodAnnotation, $controller); } - - $foundAnnotations[] = $annotatedMethod; } } } - return $this->collector->routes($foundAnnotations); + return $this->collector; } /** @@ -110,47 +86,54 @@ public function getAnnotations(): array return ['Flight\Routing\Annotation\Route']; } - /** - * @param array $annotations - * @param mixed $handler - * @param BaseRoute[] $foundAnnotations - */ - protected function getRoutes(array $annotations, $handler, array &$foundAnnotations, bool $single = false): void + protected function addRoute(RouteCollection $collection, Route $route, mixed $handler): RouteCollection { - foreach ($annotations as $annotation) { - if (!$single && null !== $annotation->resource) { - throw new InvalidAnnotationException('Restful annotation is only supported on classes.'); + if (true !== $handler) { + if (empty($route->path)) { + throw new InvalidAnnotationException('Attributed method route path empty'); + } + + if (!empty($route->resource)) { + $handler = !\is_string($handler) || !\class_exists($handler) + ? throw new InvalidAnnotationException('Restful routing is only supported on attribute route classes.') + : new ResourceHandler($handler, $route->resource); } - $route = $annotation->getRoute($handler); + $collection->add($route->path, $route->methods ?: ['GET'], $handler); - if ($single && $routeName = $this->resolveRouteName(null, $route)) { - $route->bind($routeName); + if (!empty($route->name)) { + $collection->bind($route->name); + } + } else { + if (!empty($route->path)) { + $collection->prefix($route->path); } - $foundAnnotations[] = $route; + if (!empty($route->methods)) { + $collection->method(...$route->methods); + } } - } - /** - * Resolve route naming. - */ - private function resolveRouteName(?string $prefix, BaseRoute $route, bool $force = false): string - { - $name = $route->getName(); + if (!empty($route->schemes)) { + $collection->scheme(...$route->schemes); + } + + if (!empty($route->hosts)) { + $collection->domain(...$route->hosts); + } - if ((null !== $this->unNamedPrefix || $force) && empty($name)) { - $name = $base = $prefix . $route->generateRouteName($this->unNamedPrefix ?? ''); + if (!empty($route->where)) { + $collection->placeholders($route->where); + } - if (isset($this->defaultUnnamedIndex[$name])) { - $name = $base . '_' . ++$this->defaultUnnamedIndex[$name]; - } else { - $this->defaultUnnamedIndex[$name] = 0; - } + if (!empty($route->defaults)) { + $collection->defaults($route->defaults); + } - return $name; + if (!empty($route->arguments)) { + $collection->arguments($route->arguments); } - return (string) $prefix . $name; + return $collection; } } diff --git a/src/Annotation/Route.php b/src/Annotation/Route.php index 7e6da2c4..b4440b0e 100644 --- a/src/Annotation/Route.php +++ b/src/Annotation/Route.php @@ -17,9 +17,6 @@ namespace Flight\Routing\Annotation; -use Flight\Routing\Route as BaseRoute; -use Flight\Routing\Handlers\ResourceHandler; - /** * Annotation class for @Route(). * @@ -48,63 +45,30 @@ #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] final class Route { - public ?string $path, $name, $resource; - public array $methods, $hosts, $schemes, $patterns, $defaults, $arguments; + /** @var array */ + public array $methods, $hosts, $schemes; /** - * @param string|string[] $methods - * @param string|string[] $schemes - * @param string|string[] $hosts - * @param string[] $where - * @param string[] $defaults + * @param array|string $methods + * @param array|string $schemes + * @param array|string $hosts + * @param array $where + * @param array $defaults + * @param array $arguments */ public function __construct( - string $path = null, - string $name = null, - $methods = [], - $schemes = [], - $hosts = [], - array $where = [], - array $defaults = [], - array $attributes = [], - string $resource = null + public ?string $path = null, + public ?string $name = null, + string|array $methods = [], + string|array $schemes = [], + string|array $hosts = [], + public array $where = [], + public array $defaults = [], + public array $arguments = [], + public ?string $resource = null ) { - $this->path = $path; - $this->name = $name; - $this->resource = $resource; $this->methods = (array) $methods; $this->schemes = (array) $schemes; $this->hosts = (array) $hosts; - $this->patterns = $where; - $this->defaults = $defaults; - $this->arguments = $attributes; - } - - /** - * @param mixed $handler - */ - public function getRoute($handler): BaseRoute - { - $routeData = [ - 'handler' => !empty($this->resource) ? new ResourceHandler($handler, $this->resource) : $handler, - 'name' => $this->name, - 'path' => $this->path, - 'methods' => $this->methods, - 'patterns' => $this->patterns, - 'defaults' => $this->defaults, - 'arguments' => $this->arguments, - ]; - - $route = BaseRoute::__set_state($routeData); - - if (!empty($this->hosts)) { - $route->domain(...$this->hosts); - } - - if (!empty($this->schemes)) { - $route->scheme(...$this->schemes); - } - - return $route; } } From 1ea27d8e167cf529d627370798fe9dddbd38d659 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:02:28 +0000 Subject: [PATCH 12/40] Added minor change to method not found exception message --- src/Exceptions/MethodNotAllowedException.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Exceptions/MethodNotAllowedException.php b/src/Exceptions/MethodNotAllowedException.php index c506407c..1952d35f 100644 --- a/src/Exceptions/MethodNotAllowedException.php +++ b/src/Exceptions/MethodNotAllowedException.php @@ -32,8 +32,8 @@ class MethodNotAllowedException extends \RuntimeException implements ExceptionIn */ public function __construct(array $methods, string $path, string $method) { - $this->methods = array_map('strtoupper', $methods); - $message = 'Route with "%s" path is allowed on request method(s) [%s], "%s" is invalid.'; + $this->methods = \array_map('strtoupper', $methods); + $message = 'Route with "%s" path requires request method(s) [%s], "%s" is invalid.'; parent::__construct(\sprintf($message, $path, \implode(',', $methods), $method), 405); } From 4e35b7564856d2e147a62b18fc2f0d329a32fd6d Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:04:31 +0000 Subject: [PATCH 13/40] Updated route not found exception handling --- src/Exceptions/RouteNotFoundException.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Exceptions/RouteNotFoundException.php b/src/Exceptions/RouteNotFoundException.php index 28bcbb7f..98147a49 100644 --- a/src/Exceptions/RouteNotFoundException.php +++ b/src/Exceptions/RouteNotFoundException.php @@ -18,10 +18,19 @@ namespace Flight\Routing\Exceptions; use Flight\Routing\Interfaces\ExceptionInterface; +use Psr\Http\Message\UriInterface; /** * Class RouteNotFoundException. */ class RouteNotFoundException extends \DomainException implements ExceptionInterface { + public function __construct(string|UriInterface $message = '', int $code = 404, \Throwable $previous = null) + { + if ($message instanceof UriInterface) { + $message = \sprintf('Unable to find the controller for path "%s". The route is wrongly configured.', $message->getPath()); + } + + parent::__construct($message, $code, $previous); + } } From 5d419b6ffb861f502649c9951c44b83c8aa30e62 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:08:04 +0000 Subject: [PATCH 14/40] Added a file handler class to return a response with file's mime --- src/Handlers/FileHandler.php | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/Handlers/FileHandler.php diff --git a/src/Handlers/FileHandler.php b/src/Handlers/FileHandler.php new file mode 100644 index 00000000..274fa623 --- /dev/null +++ b/src/Handlers/FileHandler.php @@ -0,0 +1,105 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flight\Routing\Handlers; + +use Flight\Routing\Exceptions\InvalidControllerException; +use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface}; + +/** + * Returns the contents from a file. + * + * @author Divine Niiquaye Ibok + */ +final class FileHandler +{ + public const MIME_TYPE = [ + 'txt' => 'text/plain', + 'htm' => 'text/html', + 'html' => 'text/html', + 'php' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'text/xml', + 'swf' => 'application/x-shockwave-flash', + 'flv' => 'video/x-flv', + // images + 'png' => 'image/png', + 'jpe' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'ico' => 'image/vnd.microsoft.icon', + 'tiff' => 'image/tiff', + 'tif' => 'image/tiff', + 'svg' => 'image/svg+xml', + 'svgz' => 'image/svg+xml', + // archives + 'zip' => 'application/zip', + 'rar' => 'application/x-rar-compressed', + 'exe' => 'application/x-msdownload', + 'msi' => 'application/x-msdownload', + 'cab' => 'application/vnd.ms-cab-compressed', + // audio/video + 'mp3' => 'audio/mpeg', + 'qt' => 'video/quicktime', + 'mov' => 'video/quicktime', + // adobe + 'pdf' => 'application/pdf', + 'psd' => 'image/vnd.adobe.photoshop', + 'ai' => 'application/postscript', + 'eps' => 'application/postscript', + 'ps' => 'application/postscript', + // ms office + 'doc' => 'application/msword', + 'rtf' => 'application/rtf', + 'xls' => 'application/vnd.ms-excel', + 'ppt' => 'application/vnd.ms-powerpoint', + 'docx' => 'application/msword', + 'xlsx' => 'application/vnd.ms-excel', + 'pptx' => 'application/vnd.ms-powerpoint', + // open office + 'odt' => 'application/vnd.oasis.opendocument.text', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + ]; + + public function __construct(private string $filename, private ?string $mimeType = null) + { + } + + public function __invoke(ResponseFactoryInterface $factory): ResponseInterface + { + if (!\file_exists($view = $this->filename) || !$contents = \file_get_contents($view)) { + throw new InvalidControllerException(\sprintf('Failed to fetch contents from file "%s"', $view)); + } + + if (empty($mime = $this->mimeType ?? self::MIME_TYPE[\pathinfo($view, \PATHINFO_EXTENSION)] ?? null)) { + $mime = (new \finfo(\FILEINFO_MIME_TYPE))->file($view); // @codeCoverageIgnoreStart + + if (false === $mime) { + throw new InvalidControllerException(\sprintf('Failed to detect mime type of file "%s"', $view)); + } // @codeCoverageIgnoreEnd + } + + $response = $factory->createResponse()->withHeader('Content-Type', $mime); + $response->getBody()->write($contents); + + return $response; + } +} From 595b008f826313b8bb152d5560aefec357ff0e29 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:09:12 +0000 Subject: [PATCH 15/40] Fixed minor issues in the resource handler class --- src/Handlers/ResourceHandler.php | 55 ++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/Handlers/ResourceHandler.php b/src/Handlers/ResourceHandler.php index bc07be90..af0d48a3 100644 --- a/src/Handlers/ResourceHandler.php +++ b/src/Handlers/ResourceHandler.php @@ -17,6 +17,8 @@ namespace Flight\Routing\Handlers; +use Flight\Routing\Exceptions\InvalidControllerException; + /** * An extendable HTTP Verb-based route handler to provide a RESTful API for a resource. * @@ -24,19 +26,38 @@ */ final class ResourceHandler { - /** @var string|object */ - private $classResource; - - private string $actionResource; + /** + * @param string $method The method name eg: action -> getAction + */ + public function __construct( + private string|object $resource, + private string $method = 'action' + ) { + if (\is_callable($resource) || \is_subclass_of($resource, self::class)) { + throw new \Flight\Routing\Exceptions\InvalidControllerException( + 'Expected a class string or class object, got a type of "callable" instead' + ); + } + } /** - * @param class-string|object $class of class string or class object - * @param string $action The method name eg: action -> getAction + * @return array */ - public function __construct($class, string $action = 'action') + public function __invoke(string $requestMethod, bool $validate = false): array { - $this->classResource = $class; - $this->actionResource = \ucfirst($action); + $method = \strtolower($requestMethod).\ucfirst($this->method); + + if (\is_string($class = $this->resource)) { + $class = \ltrim($class, '\\'); + } + + if ($validate && !\method_exists($class, $method)) { + $err = 'Method %s() for resource route "%s" is not found.'; + + throw new InvalidControllerException(\sprintf($err, $method, \is_object($class) ? $class::class : $class)); + } + + return [$class, $method]; } /** @@ -46,20 +67,14 @@ public function __construct($class, string $action = 'action') */ public function namespace(string $namespace): self { - $resource = $this->classResource; + if (!\is_string($resource = $this->resource) || '\\' === $resource[0]) { + return $this; + } - if (\is_string($resource) && '\\' === $resource[0]) { - $this->classResource = $namespace . $resource; + if (!\str_starts_with($resource, $namespace)) { + $this->resource = $namespace.$resource; } return $this; } - - /** - * @return array - */ - public function __invoke(string $requestMethod): array - { - return [$this->classResource, \strtolower($requestMethod) . $this->actionResource]; - } } From bfb1eda7f1e984031fe9305d69c2f5c2ac5a3b09 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:14:07 +0000 Subject: [PATCH 16/40] Fixed and improved route's compiler implementation - Removed the build method - Improved reversed route paths regex and parameter strict checking - Improved the compile method for better performance --- src/Interfaces/RouteCompilerInterface.php | 36 ++-- src/RouteCompiler.php | 239 ++++++---------------- 2 files changed, 81 insertions(+), 194 deletions(-) diff --git a/src/Interfaces/RouteCompilerInterface.php b/src/Interfaces/RouteCompilerInterface.php index 43f3e04d..b3112ee4 100644 --- a/src/Interfaces/RouteCompilerInterface.php +++ b/src/Interfaces/RouteCompilerInterface.php @@ -1,6 +1,4 @@ -|null - */ - public function build(RouteCollection $routes): ?array; - /** * Match the Route instance and compiles the current route instance. * - * This method should strictly return an indexed array of three parts. + * This method should strictly return an indexed array of two parts. * * - path regex, with starting and ending modifiers. Eg #^\/hello\/world\/(?P[^\/]+)$#sDu - * - hosts regex, modifies same as path regex. Implode hosts with a | inside a (?|...) if more than once - * - variables, which is an unique array of path vars merged into hosts vars (if available). + * - variables, which is an unique array of path variables (if available). + * + * @see Flight\Routing\Router::match() implementation * - * @see Flight\Routing\RouteMatcher::match() implementation + * @param string $route the pattern to compile + * @param array $placeholders * * @return array */ - public function compile(Route $route): array; + public function compile(string $route, array $placeholders = []): array; /** * Generate a URI from a named route. * - * @see Flight\Routing\RouteMatcher::generateUri() implementation + * @see Flight\Routing\Router::generateUri() implementation * + * @param array $route * @param array $parameters * * @throws UrlGenerationException if mandatory parameters are missing * - * @return GeneratedUri|null should return null if this is not implemented + * @return null|GeneratedUri should return null if this is not implemented */ - public function generateUri(Route $route, array $parameters, int $referenceType = GeneratedUri::ABSOLUTE_PATH): ?GeneratedUri; + public function generateUri(array $route, array $parameters, int $referenceType = GeneratedUri::ABSOLUTE_PATH): ?GeneratedUri; } diff --git a/src/RouteCompiler.php b/src/RouteCompiler.php index bd86b257..6272a63d 100644 --- a/src/RouteCompiler.php +++ b/src/RouteCompiler.php @@ -18,7 +18,7 @@ namespace Flight\Routing; use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException}; -use Flight\Routing\Generator\{GeneratedUri, RegexGenerator}; +use Flight\Routing\RouteUri; use Flight\Routing\Interfaces\RouteCompilerInterface; /** @@ -37,7 +37,7 @@ final class RouteCompiler implements RouteCompilerInterface * optional placeholders (with default and no static text following). Such a single separator * can be left out together with the optional placeholder from matching and generating URLs. */ - private const PATTERN_REPLACES = ['/' => '\\/', '/[' => '\/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.']; + private const PATTERN_REPLACES = ['/[' => '/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.', '/$' => '/?$']; /** * Using the strtr function is faster than the preg_quote function. @@ -56,17 +56,12 @@ final class RouteCompiler implements RouteCompilerInterface * - /{var=foo} - A required variable with default value * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default */ - private const COMPILER_REGEX = '~\{(\w+)(?:\:(.*?\}?))?(?:\=(\w+))?\}~iu'; + private const COMPILER_REGEX = '~\{(\w+)(?:\:(.*?\}?))?(?:\=(.*?))?\}~i'; /** * This regex is used to reverse a pattern path, matching required and options vars. */ - private const REVERSED_REGEX = '#(?|\<(\w+)\>|(\[(.*)]))#'; - - /** - * This regex is used to strip off a name attached to a group in a regex pattern. - */ - private const STRIP_REGEX = '#\?(?|P<\w+>|<\w+>|\'\w+\')#'; + private const REVERSED_REGEX = '#(?|\<(\w+)\>|\[(.*?\])\]|\[(.*?)\])#'; /** * A matching requirement helper, to ease matching route pattern when found. @@ -118,104 +113,53 @@ final class RouteCompiler implements RouteCompilerInterface /** * {@inheritdoc} */ - public function build(RouteCollection $routes): array + public function compile(string $route, array $placeholders = [], bool $reversed = false): array { - $uriPrefixRegex = '#[^a-zA-Z0-9]+$#'; - $variables = $staticRegex = $dynamicRegex = []; + $variables = $replaces = []; - foreach ($routes->getRoutes() as $i => $route) { - [$pathRegex, $hostsRegex, $compiledVars] = $this->compile($route); - $pathRegex = self::resolveRegex($pathRegex); - - if (!empty($hostsRegex)) { - $variables[$i] = [self::resolveRegex($hostsRegex), []]; - } + if (\strpbrk($route, '{')) { + // Match all variables enclosed in "{}" and iterate over them... + \preg_match_all(self::COMPILER_REGEX, $route, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); - if (!empty($compiledVars)) { - $variables[$i] = [$variables[$i][0] ?? [], $compiledVars]; - } - - if ('?' === $pos = $pathRegex[-1]) { - if (!\preg_match($uriPrefixRegex, $pathRegex[-2])) { - $pathRegex = \substr($pathRegex, 0, -1); + foreach ($matches as [$placeholder, $varName, $segment, $default]) { + if (1 === \preg_match('/\A\d+/', $varName)) { + throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $route)); } - $dynamicRegex[$i] = $pathRegex; - continue; - } - - if (\preg_match($uriPrefixRegex, $pos)) { - $staticRegex[\substr($pathRegex, 0, -1)][] = $i; - } - - $staticRegex[$pathRegex][] = $i; - } - - if (!empty($dynamicRegex)) { - \natsort($dynamicRegex); - $tree = new RegexGenerator(); - - foreach (\array_unique($dynamicRegex) as $k => $regex) { - $tree->addRoute($regex, [$regex, $k]); - } - - $compiledRegex = '~^' . \substr($tree->compile(0), 1) . '$~sDu'; - } - return [$staticRegex, $compiledRegex ?? null, $variables]; - } - - /** - * {@inheritdoc} - */ - public function compile(Route $route): array - { - [$pathRegex, $variables] = self::compilePattern($route->getPath(), false, $rPs = $route->getPatterns()); + if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) { + throw new UriHandlerException(\sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s".', $varName, self::VARIABLE_MAXIMUM_LENGTH, $route)); + } - if ($hosts = $route->getHosts()) { - $hostsRegex = []; + if (\array_key_exists($varName, $variables)) { + throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $route, $varName)); + } - foreach ($hosts as $host) { - [$hostRegex, $hostVars] = self::compilePattern($host, false, $rPs); - $variables += $hostVars; - $hostsRegex[] = $hostRegex; + $segment = self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $placeholders); + [$variables[$varName], $replaces[$placeholder]] = !$reversed ? [$default, '(?P<'.$varName.'>'.$segment.')'] : [[$segment, $default], '<'.$varName.'>']; } - - $hostsRegex = '{^' . \implode('|', $hostsRegex) . '$}ui'; } - if ('?' !== $pathRegex[-1]) { - $pathRegex .= '?'; - } - - return ['{^' . $pathRegex . '$}u', $hostsRegex ?? null, $variables]; + return !$reversed ? [\strtr('{^'.$route.'$}', $replaces + self::PATTERN_REPLACES), $variables] : [\strtr($route, $replaces), $variables]; } /** * {@inheritdoc} */ - public function generateUri(Route $route, array $parameters, int $referenceType = GeneratedUri::ABSOLUTE_PATH): GeneratedUri + public function generateUri(array $route, array $parameters, int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri { - [$pathRegex, $pathVariables] = self::compilePattern($route->getPath(), true); + [$pathRegex, $pathVars] = $this->compile($route['path'], reversed: true); - $defaults = $route->getDefaults(); - $createUri = new GeneratedUri(self::interpolate($pathRegex, $parameters, $defaults + $pathVariables), $referenceType); - - foreach ($route->getHosts() as $host) { - [$hostRegex, $hostVariables] = self::compilePattern($host, true); + $defaults = $route['defaults'] ?? []; + $createUri = new RouteUri(self::interpolate($pathRegex, $pathVars, $parameters + $defaults), $referenceType); + foreach (($route['hosts'] ?? []) as $host => $exists) { + [$hostRegex, $hostVars] = $this->compile($host, reversed: true); + $createUri->withHost(self::interpolate($hostRegex, $hostVars, $parameters + $defaults)); break; } - if (!empty($schemes = $route->getSchemes())) { - $createUri->withScheme(\in_array('https', $schemes, true) ? 'https' : \end($schemes) ?? 'http'); - - if (!isset($hostRegex)) { - $createUri->withHost($_SERVER['HTTP_HOST'] ?? ''); - } - } - - if (isset($hostRegex)) { - $createUri->withHost(self::interpolate($hostRegex, $parameters, $defaults + ($hostVariables ?? []))); + if (!empty($schemes = $route['schemes'] ?? [])) { + $createUri->withScheme(isset($schemes['https']) ? 'https' : \array_key_last($schemes) ?? 'http'); } return $createUri; @@ -224,10 +168,10 @@ public function generateUri(Route $route, array $parameters, int $referenceType /** * Check for mandatory parameters then interpolate $uriRoute with given $parameters. * - * @param array $parameters - * @param array $defaults + * @param array> $uriVars + * @param array $parameters */ - private static function interpolate(string $uriRoute, array $parameters, array $defaults): string + private static function interpolate(string $uriRoute, array $uriVars, array $parameters): string { $required = []; // Parameters required which are missing. $replaces = self::URI_FIXERS; @@ -235,29 +179,43 @@ private static function interpolate(string $uriRoute, array $parameters, array $ // Fetch and merge all possible parameters + route defaults ... \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); - foreach ($matches as $matched) { - if (3 === \count($matched) && isset($matched[2])) { - \preg_match_all('#\<(\w+)\>#', $matched[2], $optionalVars, \PREG_SET_ORDER); + if (isset($uriVars['*'])) { + [$defaultPath, $required, $optional] = $uriVars['*']; + $replaces = []; + } - foreach ($optionalVars as [$type, $var]) { - $replaces[$type] = $parameters[$var] ?? $defaults[$var] ?? null; + foreach ($matches as $i => [$matched, $varName]) { + if ('[' !== $matched[0]) { + [$segment, $default] = $uriVars[$varName]; + $value = $parameters[$varName] ?? (isset($optional) ? $default : ($parameters[$i] ?? $default)); + + if (!empty($value)) { + if (1 !== \preg_match("~^{$segment}\$~", (string) $value)) { + throw new UriHandlerException( + \sprintf('Expected route path "%s" placeholder "%s" value "%s" to match "%s".', $uriRoute, $varName, $value, $segment) + ); + } + $optional = isset($optional) ? false : null; + $replaces[$matched] = $value; + } elseif (isset($optional) && $optional) { + $replaces[$matched] = ''; + } else { + $required[] = $varName; } - continue; } - - $replaces[$matched[0]] = $parameters[$matched[1]] ?? $defaults[$matched[1]] ?? null; - - if (null === $replaces[$matched[0]]) { - $required[] = $matched[1]; - } + $replaces[$matched] = self::interpolate($varName, $uriVars + ['*' => [$uriRoute, $required, true]], $parameters); } if (!empty($required)) { - throw new UrlGenerationException(\sprintf('Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', \implode('", "', $required), $uriRoute)); + throw new UrlGenerationException(\sprintf( + 'Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', + \implode('", "', $required), + $defaultPath ?? $uriRoute + )); } - return \strtr($uriRoute, $replaces); + return !empty(\array_filter($replaces)) ? \strtr($uriRoute, $replaces) : ''; } private static function sanitizeRequirement(string $key, string $regex): string @@ -265,70 +223,24 @@ private static function sanitizeRequirement(string $key, string $regex): string if ('' !== $regex) { if ('^' === $regex[0]) { $regex = \substr($regex, 1); - } elseif (0 === \strpos($regex, '\\A')) { + } elseif (\str_starts_with($regex, '\\A')) { $regex = \substr($regex, 2); } - } - if (\str_ends_with($regex, '$')) { - $regex = \substr($regex, 0, -1); - } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) { - $regex = \substr($regex, 0, -2); + if (\str_ends_with($regex, '$')) { + $regex = \substr($regex, 0, -1); + } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) { + $regex = \substr($regex, 0, -2); + } } if ('' === $regex) { - throw new \InvalidArgumentException(\sprintf('Routing requirement for "%s" cannot be empty.', $key)); + throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key)); } return \strtr($regex, self::SEGMENT_REPLACES); } - /** - * @param array $requirements - * - * @throws UriHandlerException if a variable name starts with a digit or - * if it is too long to be successfully used as a PCRE subpattern or - * if a variable is referenced more than once - */ - private static function compilePattern(string $uriPattern, bool $reversed = false, array $requirements = []): array - { - // A path which doesn't contain {}, should be ignored. - if (!\str_contains($uriPattern, '{')) { - return [\strtr($uriPattern, $reversed ? ['?' => ''] : self::SEGMENT_REPLACES), []]; - } - - $variables = []; // VarNames mapping to values use by route's handler. - $replaces = $reversed ? ['?' => ''] : self::PATTERN_REPLACES; - - // correct [/ first occurrence] - if (1 === \strpos($uriPattern, '[/')) { - $uriPattern = '/[' . \substr($uriPattern, 3); - } - - // Match all variables enclosed in "{}" and iterate over them... - \preg_match_all(self::COMPILER_REGEX, $uriPattern, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); - - foreach ($matches as [$placeholder, $varName, $segment, $default]) { - // A PCRE subpattern name must start with a non-digit. - if (1 === \preg_match('/\d/A', $varName)) { - throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $uriPattern)); - } - - if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) { - throw new UriHandlerException(\sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s".', $varName, self::VARIABLE_MAXIMUM_LENGTH, $uriPattern)); - } - - if (\array_key_exists($varName, $variables)) { - throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $uriPattern, $varName)); - } - - $variables[$varName] = $default; - $replaces[$placeholder] = !$reversed ? '(?P<' . $varName . '>' . (self::SEGMENT_TYPES[$segment] ?? $segment ?? self::prepareSegment($varName, $requirements)) . ')' : "<$varName>"; - } - - return [\strtr($uriPattern, $replaces), $variables]; - } - /** * Prepares segment pattern with given constrains. * @@ -346,19 +258,4 @@ private static function prepareSegment(string $name, array $requirements): strin return \implode('|', $segment); } - - /** - * Strips starting and ending modifiers from a path regex. - */ - private static function resolveRegex(string $pathRegex): string - { - $pos = (int) \strrpos($pathRegex, '$'); - $pathRegex = \substr($pathRegex, 1 + \strpos($pathRegex, '^'), -(\strlen($pathRegex) - $pos)); - - if (\preg_match('/\\(\\?P\\<\w+\\>.*\\)/', $pathRegex)) { - return \preg_replace(self::STRIP_REGEX, '', $pathRegex); - } - - return \str_replace(['\\', '?'], '', $pathRegex); - } } From 2821540b5892fe8bf9974440541454a09c2c232b Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:15:22 +0000 Subject: [PATCH 17/40] Added a few more uri segments to route compiler --- src/RouteCompiler.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/RouteCompiler.php b/src/RouteCompiler.php index 6272a63d..f5cb2715 100644 --- a/src/RouteCompiler.php +++ b/src/RouteCompiler.php @@ -71,11 +71,15 @@ final class RouteCompiler implements RouteCompilerInterface 'lower' => '[a-z]+', 'upper' => '[A-Z]+', 'alpha' => '[A-Za-z]+', + 'hex' => '[[:xdigit:]]+', + 'md5' => '[a-f0-9]{32}+', + 'sha1' => '[a-f0-9]{40}+', 'year' => '[0-9]{4}', 'month' => '0[1-9]|1[012]+', 'day' => '0[1-9]|[12][0-9]|3[01]+', 'date' => '[0-9]{4}-(?:0[1-9]|1[012])-(?:0[1-9]|[12][0-9]|(? '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*', + 'port' => '[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]', 'UID_BASE32' => '[0-9A-HJKMNP-TV-Z]{26}', 'UID_BASE58' => '[1-9A-HJ-NP-Za-km-z]{22}', 'UID_RFC4122' => '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', From b8c23641f3daa2246eaa3e3ca2958f730d9ca61e Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:23:55 +0000 Subject: [PATCH 18/40] Improved the default route handler and resolver - Fixed minor issues resolving route's handlers - Added support to resolve multiple named parameters with same value using a `&` as separator - Moved support for resolving route's handler to response from the route's handler class to the route invokers class as a static `resolveRoute` method - Improved mime detection from response body --- src/Handlers/RouteHandler.php | 120 +++++++++---------------------- src/Handlers/RouteInvoker.php | 129 ++++++++++++++++++++++++++-------- 2 files changed, 134 insertions(+), 115 deletions(-) diff --git a/src/Handlers/RouteHandler.php b/src/Handlers/RouteHandler.php index 64c1cdd5..5a740c6c 100644 --- a/src/Handlers/RouteHandler.php +++ b/src/Handlers/RouteHandler.php @@ -17,8 +17,8 @@ namespace Flight\Routing\Handlers; -use Flight\Routing\Route; use Flight\Routing\Exceptions\{InvalidControllerException, RouteNotFoundException}; +use Flight\Routing\Router; use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface, ServerRequestInterface}; use Psr\Http\Server\RequestHandlerInterface; @@ -32,102 +32,51 @@ */ class RouteHandler implements RequestHandlerInterface { - /** - * This allows a response to be served when no route is found. - */ - public const OVERRIDE_HTTP_RESPONSE = ResponseInterface::class; - - protected const CONTENT_TYPE = 'Content-Type'; - protected const CONTENT_REGEX = '#(?|\{\"[\w\,\"\:\[\]]+\}|\["[\w\"\,]+\]|\<(?|\?(xml)|\w+).*>.*<\/(\w+)>)$#s'; - - protected ResponseFactoryInterface $responseFactory; + /** This allows a response to be served when no route is found. */ + public const OVERRIDE_NULL_ROUTE = 'OVERRIDE_NULL_ROUTE'; /** @var callable */ protected $handlerResolver; - public function __construct(ResponseFactoryInterface $responseFactory, callable $handlerResolver = null) + public function __construct(protected ResponseFactoryInterface $responseFactory, callable $handlerResolver = null) { - $this->responseFactory = $responseFactory; $this->handlerResolver = $handlerResolver ?? new RouteInvoker(); } /** * {@inheritdoc} * - * @throws RouteNotFoundException|InvalidControllerException + * @throws InvalidControllerException|RouteNotFoundException */ public function handle(ServerRequestInterface $request): ResponseInterface { - if (null === $route = $request->getAttribute(Route::class)) { - if (true === $notFoundResponse = $request->getAttribute(static::OVERRIDE_HTTP_RESPONSE)) { + if (null === $route = $request->getAttribute(Router::class)) { + if (true === $res = $request->getAttribute(static::OVERRIDE_NULL_ROUTE)) { return $this->responseFactory->createResponse(); } - if (!$notFoundResponse instanceof ResponseInterface) { - throw new RouteNotFoundException( - \sprintf( - 'Unable to find the controller for path "%s". The route is wrongly configured.', - $request->getUri()->getPath() - ), - 404 - ); - } - - return $notFoundResponse; - } - - // Resolve route handler arguments ... - if (!$response = $this->resolveRoute($route, $request)) { - throw new InvalidControllerException('The route handler\'s content is not a valid PSR7 response body stream.'); + return $res instanceof ResponseInterface ? $res : throw new RouteNotFoundException($request->getUri()); } - if (!$response instanceof ResponseInterface) { - ($result = $this->responseFactory->createResponse())->getBody()->write($response); - $response = $result; + if (empty($handler = $route['handler'] ?? null)) { + return $this->responseFactory->createResponse(204)->withHeader('Content-Type', 'text/plain; charset=utf-8'); } - return $response->hasHeader(self::CONTENT_TYPE) ? $response : $this->negotiateContentType($response); - } - - /** - * @return ResponseInterface|string|false - */ - protected function resolveRoute(Route $route, ServerRequestInterface $request) - { - \ob_start(); // Start buffering response output - - try { - // The route handler to resolve ... - $handler = $route->getHandler(); - - if ($handler instanceof ResourceHandler) { - $handler = $handler($request->getMethod()); - } - - $response = ($this->handlerResolver)($handler, $this->resolveArguments($request, $route)); - - if ($response instanceof RequestHandlerInterface) { - return $response->handle($request); - } - - if ($response instanceof ResponseInterface || \is_string($response)) { - return $response; - } + $arguments = fn (ServerRequestInterface $request): array => $this->resolveArguments($request, $route['arguments'] ?? []); + $response = RouteInvoker::resolveRoute($request, $this->handlerResolver, $handler, $arguments); - if ($response instanceof \JsonSerializable || \is_iterable($response) || \is_array($response)) { - return \json_encode($response, \JSON_THROW_ON_ERROR); - } - } catch (\Throwable $e) { - \ob_get_clean(); + if ($response instanceof FileHandler) { + return $response($this->responseFactory); + } - throw $e; - } finally { - while (\ob_get_level() > 1) { - $response = \ob_get_clean(); // If more than one output buffers is set ... + if (!$response instanceof ResponseInterface) { + if (empty($contents = $response)) { + throw new InvalidControllerException('The route handler\'s content is not a valid PSR7 response body stream.'); } + ($response = $this->responseFactory->createResponse())->getBody()->write($contents); } - return $response ?? \ob_get_clean(); + return $response->hasHeader('Content-Type') ? $response : $this->negotiateContentType($response); } /** @@ -135,31 +84,28 @@ protected function resolveRoute(Route $route, ServerRequestInterface $request) */ protected function negotiateContentType(ResponseInterface $response): ResponseInterface { - $contents = (string) $response->getBody(); - $contentType = 'text/html; charset=utf-8'; // Default content type. - - if (1 === $matched = \preg_match(static::CONTENT_REGEX, $contents, $matches, \PREG_UNMATCHED_AS_NULL)) { - if (null === $matches[2]) { - $contentType = 'application/json'; - } elseif ('xml' === $matches[1]) { - $contentType = 'svg' === $matches[2] ? 'image/svg+xml' : \sprintf('application/%s; charset=utf-8', 'rss' === $matches[2] ? 'rss+xml' : 'xml'); - } - } elseif (0 === $matched) { - $contentType = 'text/plain; charset=utf-8'; + if (empty($contents = (string) $response->getBody())) { + $mime = 'text/plain; charset=utf-8'; + $response = $response->withStatus(204); + } elseif (false === $mime = (new \finfo(\FILEINFO_MIME_TYPE))->buffer($contents)) { + $mime = 'text/html; charset=utf-8'; // @codeCoverageIgnore + } elseif ('text/xml' === $mime) { + \preg_match('/<(?:\s+)?\/?(?:\s+)?(\w+)(?:\s+)?>$/', $contents, $xml, \PREG_UNMATCHED_AS_NULL); + $mime = 'svg' === $xml[1] ? 'image/svg+xml' : \sprintf('%s; charset=utf-8', 'rss' === $xml[1] ? 'application/rss+xml' : 'text/xml'); } - return $response->withHeader(self::CONTENT_TYPE, $contentType); + return $response->withHeader('Content-Type', $mime); } /** + * @param array $parameters + * * @return array */ - protected function resolveArguments(ServerRequestInterface $request, Route $route): array + protected function resolveArguments(ServerRequestInterface $request, array $parameters): array { - $parameters = $route->getArguments(); - foreach ([$request, $this->responseFactory] as $psr7) { - $parameters[\get_class($psr7)] = $psr7; + $parameters[$psr7::class] = $psr7; foreach ((@\class_implements($psr7) ?: []) as $psr7Interface) { $parameters[$psr7Interface] = $psr7; diff --git a/src/Handlers/RouteInvoker.php b/src/Handlers/RouteInvoker.php index 9abc43f6..386ab096 100644 --- a/src/Handlers/RouteInvoker.php +++ b/src/Handlers/RouteInvoker.php @@ -1,6 +1,4 @@ -container = $container; } /** * Auto-configure route handler parameters. * - * @param mixed $handler * @param array $arguments - * - * @return mixed */ - public function __invoke($handler, array $arguments) + public function __invoke(mixed $handler, array $arguments): mixed { if (\is_string($handler)) { - if (null !== $this->container && $this->container->has($handler)) { + $handler = \ltrim($handler, '\\'); + + if ($this->container?->has($handler)) { $handler = $this->container->get($handler); } elseif (\str_contains($handler, '@')) { $handler = \explode('@', $handler, 2); goto maybe_callable; } elseif (\class_exists($handler)) { - $handler = new $handler(); + $handler = \is_callable($this->container) ? ($this->container)($handler) : new $handler(); } - } elseif ((\is_array($handler) && [0, 1] === \array_keys($handler)) && \is_string($handler[0])) { + } elseif (\is_array($handler) && ([0, 1] === \array_keys($handler) && \is_string($handler[0]))) { + $handler[0] = \ltrim($handler[0], '\\'); + maybe_callable: - if (null !== $this->container && $this->container->has($handler[0])) { + if ($this->container?->has($handler[0])) { $handler[0] = $this->container->get($handler[0]); } elseif (\class_exists($handler[0])) { - $handler[0] = new $handler[0](); + $handler[0] = \is_callable($this->container) ? ($this->container)($handler[0]) : new $handler[0](); } } @@ -82,52 +80,127 @@ public function __invoke($handler, array $arguments) return $handlerRef->invokeArgs($resolvedParameters ?? []); } + public function getContainer(): ?ContainerInterface + { + return $this->container; + } + + /** + * Resolve route handler & parameters. + * + * @throws InvalidControllerException + */ + public static function resolveRoute( + ServerRequestInterface $request, + callable $resolver, + mixed $handler, + callable $arguments, + ): ResponseInterface|FileHandler|string|null { + if ($handler instanceof RequestHandlerInterface) { + return $handler->handle($request); + } + $printed = \ob_start(); // Start buffering response output + + try { + if ($handler instanceof ResourceHandler) { + $handler = $handler($request->getMethod(), true); + } + + $response = ($resolver)($handler, $arguments($request)); + } catch (\Throwable $e) { + \ob_get_clean(); + + throw $e; + } finally { + while (\ob_get_level() > 1) { + $printed = \ob_get_clean() ?: null; + } // If more than one output buffers is set ... + } + + if ($response instanceof ResponseInterface || \is_string($response = $printed ?: ($response ?? \ob_get_clean()))) { + return $response; + } + + if ($response instanceof RequestHandlerInterface) { + return $response->handle($request); + } + + if ($response instanceof \Stringable) { + return $response->__toString(); + } + + if ($response instanceof FileHandler) { + return $response; + } + + if ($response instanceof \JsonSerializable || $response instanceof \iterable || \is_array($response)) { + return \json_encode($response, \JSON_THROW_ON_ERROR) ?: null; + } + + return null; + } + /** * @param array $refParameters * @param array $arguments * * @return array */ - private function resolveParameters(array $refParameters, array $arguments): array + protected function resolveParameters(array $refParameters, array $arguments): array { $parameters = []; + $nullable = 0; + + foreach ($arguments as $k => $v) { + if (\is_numeric($k) || !\str_contains($k, '&')) { + continue; + } + + foreach (\explode('&', $k) as $i) { + $arguments[$i] = $v; + } + } foreach ($refParameters as $index => $parameter) { $typeHint = $parameter->getType(); - if ($typeHint instanceof \ReflectionUnionType) { + if ($nullable > 0) { + $index = $parameter->getName(); + } + + if ($typeHint instanceof \ReflectionUnionType || $typeHint instanceof \ReflectionIntersectionType) { foreach ($typeHint->getTypes() as $unionType) { + if ($unionType->isBuiltin()) { + continue; + } + if (isset($arguments[$unionType->getName()])) { $parameters[$index] = $arguments[$unionType->getName()]; - continue 2; } - if (null !== $this->container && $this->container->has($unionType->getName())) { + if ($this->container?->has($unionType->getName())) { $parameters[$index] = $this->container->get($unionType->getName()); - continue 2; } } - } elseif ($typeHint instanceof \ReflectionNamedType) { + } elseif ($typeHint instanceof \ReflectionNamedType && !$typeHint->isBuiltin()) { if (isset($arguments[$typeHint->getName()])) { $parameters[$index] = $arguments[$typeHint->getName()]; - continue; } - if (null !== $this->container && $this->container->has($typeHint->getName())) { + if ($this->container?->has($typeHint->getName())) { $parameters[$index] = $this->container->get($typeHint->getName()); - continue; } } if (isset($arguments[$parameter->getName()])) { $parameters[$index] = $arguments[$parameter->getName()]; - } elseif (null !== $this->container && $this->container->has($parameter->getName())) { - $parameters[$index] = $this->container->get($parameter->getName()); - } elseif ($parameter->allowsNull() && !$parameter->isDefaultValueAvailable()) { + } elseif ($parameter->isDefaultValueAvailable()) { + ++$nullable; + } elseif (!$parameter->isVariadic() && ($parameter->isOptional() || $parameter->allowsNull())) { $parameters[$index] = null; } } From e4a4851aff33bfb8094f0700db5e7ed5f72cd3ee Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:25:31 +0000 Subject: [PATCH 19/40] Improved the route matcher interface implementation --- src/Interfaces/RouteMatcherInterface.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Interfaces/RouteMatcherInterface.php b/src/Interfaces/RouteMatcherInterface.php index 23339025..c960ae05 100644 --- a/src/Interfaces/RouteMatcherInterface.php +++ b/src/Interfaces/RouteMatcherInterface.php @@ -1,6 +1,4 @@ - */ - public function match(string $method, UriInterface $uri): ?Route; + public function match(string $method, UriInterface $uri): ?array; /** - * @see RouteMatcherInterface::match() implementation + * Find a route by matching with PSR-7 server request. + * + * @return null|array */ - public function matchRequest(ServerRequestInterface $request): ?Route; + public function matchRequest(ServerRequestInterface $request): ?array; + + public function getCollection(): RouteCollection; } From 0da451d261889a2dcf6a3ddf8f69a6f0ab24dfa0 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:30:06 +0000 Subject: [PATCH 20/40] Fixed minor issues & improved the path middleware class --- src/Middlewares/PathMiddleware.php | 46 ++++++++++++++++++------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/Middlewares/PathMiddleware.php b/src/Middlewares/PathMiddleware.php index bd6f750f..2763443d 100644 --- a/src/Middlewares/PathMiddleware.php +++ b/src/Middlewares/PathMiddleware.php @@ -17,7 +17,7 @@ namespace Flight\Routing\Middlewares; -use Flight\Routing\Route; +use Flight\Routing\Router; use Psr\Http\Message\{ResponseInterface, ServerRequestInterface, UriInterface}; use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface}; @@ -33,18 +33,30 @@ */ final class PathMiddleware implements MiddlewareInterface { - public const SUB_FOLDER = __CLASS__ . '::subFolder'; + /** + * Slashes supported on browser when used. + */ + public const SUB_FOLDER = __CLASS__.'::subFolder'; - private bool $permanent, $keepRequestMethod; + /** @var array */ + private array $uriSuffixes = []; /** - * @param bool $permanent Whether the redirection is permanent - * @param bool $keepRequestMethod Whether redirect action should keep HTTP request method + * @param bool $permanent Whether the redirection is permanent + * @param bool $keepRequestMethod Whether redirect action should keep HTTP request method + * @param array $uriSuffixes List of slashes to re-route, defaults to ['/'] */ - public function __construct(bool $permanent = false, bool $keepRequestMethod = false) - { + public function __construct( + private bool $permanent = false, + private bool $keepRequestMethod = false, + array $uriSuffixes = [] + ) { $this->permanent = $permanent; $this->keepRequestMethod = $keepRequestMethod; + + if (!empty($uriSuffixes)) { + $this->uriSuffixes = \array_combine($uriSuffixes, $uriSuffixes); + } } /** @@ -53,16 +65,12 @@ public function __construct(bool $permanent = false, bool $keepRequestMethod = f public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $requestPath = ($requestUri = self::resolveUri($request))->getPath(); // Determine right request uri path. - $response = $handler->handle($request); - $route = $request->getAttribute(Route::class); - - if ($route instanceof Route) { - // Determine the response code should keep HTTP request method ... - $statusCode = $this->keepRequestMethod ? ($this->permanent ? 308 : 307) : ($this->permanent ? 301 : 302); - $routeEndTail = Route::URL_PREFIX_SLASHES[$route->getPath()[-1]] ?? null; - $requestEndTail = Route::URL_PREFIX_SLASHES[$requestPath[-1]] ?? null; + if (!empty($route = $request->getAttribute(Router::class, []))) { + $this->uriSuffixes['/'] ??= '/'; + $routeEndTail = $this->uriSuffixes[$route['path'][-1]] ?? null; + $requestEndTail = $this->uriSuffixes[$requestPath[-1]] ?? null; if ($requestEndTail === $requestPath || $routeEndTail === $requestEndTail) { return $response; @@ -71,10 +79,12 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface // Resolve request tail end to avoid conflicts and infinite redirection looping ... if (null === $requestEndTail && null !== $routeEndTail) { $requestPath .= $routeEndTail; + } elseif (null === $routeEndTail && $requestEndTail) { + $requestPath = \substr($requestPath, 0, -1); } - // Allow Redirection if exists and avoid static request. - return $response->withHeader('Location', (string) $requestUri->withPath($requestPath))->withStatus($statusCode); + $statusCode = $this->keepRequestMethod ? ($this->permanent ? 308 : 307) : ($this->permanent ? 301 : 302); + $response = $response->withHeader('Location', (string) $requestUri->withPath($requestPath))->withStatus($statusCode); } return $response; @@ -87,7 +97,7 @@ public static function resolveUri(ServerRequestInterface &$request): UriInterfac // Checks if the project is in a sub-directory, expect PATH_INFO in $_SERVER. if ('' !== $pathInfo && $pathInfo !== $requestUri->getPath()) { - $request = $request->withAttribute(self::SUB_FOLDER, \substr($requestUri->getPath(), 0, -(\strlen($pathInfo)))); + $request = $request->withAttribute(self::SUB_FOLDER, \substr($requestUri->getPath(), 0, -\strlen($pathInfo))); return $requestUri->withPath($pathInfo); } From 138584d32e7eea6a5a4ea2ed4d766290acbf2cc1 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 5 Sep 2022 08:31:56 +0000 Subject: [PATCH 21/40] Fixed minor import issue in the URL generator interface --- src/Interfaces/UrlGeneratorInterface.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Interfaces/UrlGeneratorInterface.php b/src/Interfaces/UrlGeneratorInterface.php index a1bdd30d..874f8331 100644 --- a/src/Interfaces/UrlGeneratorInterface.php +++ b/src/Interfaces/UrlGeneratorInterface.php @@ -1,6 +1,4 @@ - Date: Tue, 6 Sep 2022 08:34:58 +0000 Subject: [PATCH 22/40] Improved benchmarking --- phpbench.json | 18 +- tests/Benchmarks/PerformanceBench.php | 122 ++++++++++++ tests/Benchmarks/RealExampleBench.php | 182 +++++++++++++++++ tests/Benchmarks/RouteBench.php | 276 +++++++------------------- tests/Fixtures/bench_autoload.php | 4 + 5 files changed, 395 insertions(+), 207 deletions(-) create mode 100644 tests/Benchmarks/PerformanceBench.php create mode 100644 tests/Benchmarks/RealExampleBench.php create mode 100644 tests/Fixtures/bench_autoload.php diff --git a/phpbench.json b/phpbench.json index 337cbd91..037bdb34 100644 --- a/phpbench.json +++ b/phpbench.json @@ -1,19 +1,31 @@ { - "runner.bootstrap": "vendor/autoload.php", + "runner.bootstrap": "tests/Fixtures/bench_autoload.php", "runner.path": "tests/Benchmarks", + "runner.file_pattern": "*Bench.php", "runner.output_mode": "time", "runner.retry_threshold": 5, "runner.php_config": { - "max_execution_time": 60, "opcache.enable": true, "opcache.enable_cli": true, "opcache.jit": 1235, "xdebug.mode": "off" }, "report.generators": { + "all": { + "generator": "composite", + "reports": [ "env", "benchmark" ] + }, "default": { "extends": "expression", - "cols": [ "subject", "set", "mem_peak", "mode", "best", "mean", "worst", "stdev", "rstdev" ] + "break": [ "benchmark" ], + "cols": [ "benchmark", "subject", "set", "mem_peak", "mode", "best", "mean", "worst", "stdev", "rstdev" ] + } + }, + "report.outputs": { + "html": { + "renderer": "html", + "path": "build/bench-report.html", + "title": "Flight Routing Benchmark" } } } diff --git a/tests/Benchmarks/PerformanceBench.php b/tests/Benchmarks/PerformanceBench.php new file mode 100644 index 00000000..eaae7c35 --- /dev/null +++ b/tests/Benchmarks/PerformanceBench.php @@ -0,0 +1,122 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flight\Routing\Tests\Benchmarks; + +use Flight\Routing\Exceptions\MethodNotAllowedException; +use Flight\Routing\{RouteCollection, Router}; +use Flight\Routing\Interfaces\RouteMatcherInterface; + +/** + * @Groups({"performance"}) + */ +final class PerformanceBench extends RouteBench +{ + /** + * {@inheritdoc} + */ + public function createDispatcher(string $cache = null): RouteMatcherInterface + { + $router = new Router(null, $cache); + $router->setCollection(static function (RouteCollection $routes): void { + for ($i = 1; $i < self::MAX_ROUTES; ++$i) { + if (199 === $i) { + $routes->add('//localhost.com/route'.$i, ['GET'])->bind('static-'.$i); + $routes->add('//{host}/route{foo}/'.$i, ['GET'])->bind('no-static-'.$i); + continue; + } + + $routes->add('/route'.$i, ['GET'])->bind('static-'.$i); + $routes->add('/route{foo}/'.$i, ['GET'])->bind('no-static-'.$i); + } + }); + + return $router; + } + + /** + * {@inheritdoc} + */ + public function provideStaticRoutes(): iterable + { + yield 'first' => [ + 'method' => 'GET', + 'route' => '/route0', + 'result' => ['handler' => null, 'prefix' => '/route0', 'path' => '/route0', 'methods' => ['GET' => true], 'name' => 'static-0'], + ]; + + yield 'middle' => [ + 'method' => 'GET', + 'route' => '//localhost/route199', + 'result' => ['handler' => null, 'hosts' => ['localhost' => true], 'prefix' => '/route199', 'path' => '/route199', 'methods' => ['GET' => true], 'name' => 'static-199'], + ]; + + yield 'last' => [ + 'method' => 'GET', + 'route' => '/route399', + 'result' => ['handler' => null, 'prefix' => '/route399', 'path' => '/route399', 'methods' => ['GET' => true], 'name' => 'static-399'], + ]; + + yield 'invalid-method' => [ + 'method' => 'PUT', + 'route' => '/route399', + 'result' => MethodNotAllowedException::class, + ]; + } + + /** + * {@inheritdoc} + */ + public function provideDynamicRoutes(): iterable + { + yield 'first' => [ + 'method' => 'GET', + 'route' => '/routebar/0', + 'result' => ['handler' => null, 'prefix' => '/route', 'path' => '/route{foo}/0', 'methods' => ['GET' => true], 'name' => 'not-static-0', ['arguments' => ['foo' => 'bar']]], + ]; + + yield 'middle' => [ + 'method' => 'GET', + 'route' => '//localhost/routebar/199', + 'result' => ['handler' => null, 'hosts' => ['{host}' => true], 'prefix' => '/route', 'path' => '/route{foo}/199', 'methods' => ['GET' => true], 'name' => 'not-static-199', ['arguments' => ['foo' => 'bar']]], + ]; + + yield 'last' => [ + 'method' => 'GET', + 'route' => '/routebar/399', + 'result' => ['handler' => null, 'prefix' => '/route', 'path' => '/route{foo}/399', 'methods' => ['GET' => true], 'name' => 'not-static-399', ['arguments' => ['foo' => 'bar']]], + ]; + + yield 'invalid-method' => [ + 'method' => 'PUT', + 'route' => '/routebar/399', + 'result' => MethodNotAllowedException::class, + ]; + } + + /** + * {@inheritdoc} + */ + public function provideOtherScenarios(): iterable + { + yield 'non-existent' => [ + 'method' => 'GET', + 'route' => '/testing', + 'result' => null, + ]; + } +} diff --git a/tests/Benchmarks/RealExampleBench.php b/tests/Benchmarks/RealExampleBench.php new file mode 100644 index 00000000..56bc1158 --- /dev/null +++ b/tests/Benchmarks/RealExampleBench.php @@ -0,0 +1,182 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flight\Routing\Tests\Benchmarks; + +use Flight\Routing\Exceptions\MethodNotAllowedException; +use Flight\Routing\Interfaces\RouteMatcherInterface; +use Flight\Routing\{RouteCollection, Router}; + +/** + * @Groups({"real"}) + */ +final class RealExampleBench extends RouteBench +{ + /** + * {@inheritdoc} + */ + public function createDispatcher(string $cache = null): RouteMatcherInterface + { + $router = new Router(null, $cache); + $router->setCollection(static function (RouteCollection $routes): void { + $routes->add('/', ['GET'])->bind('home'); + $routes->add('/page/{page_slug:[a-zA-Z0-9\-]+}', ['GET'])->bind('page.show'); + $routes->add('/about-us', ['GET'])->bind('about-us'); + $routes->add('/contact-us', ['GET'])->bind('contact-us'); + $routes->add('/contact-us', ['POST'])->bind('contact-us.submit'); + $routes->add('/blog', ['GET'])->bind('blog.index'); + $routes->add('/blog/recent', ['GET'])->bind('blog.recent'); + $routes->add('/blog/post/{post_slug:[a-zA-Z0-9\-]+}', ['GET'])->bind('blog.post.show'); + $routes->add('/blog/post/{post_slug:[a-zA-Z0-9\-]+}/comment', ['POST'])->bind('blog.post.comment'); + $routes->add('/shop', ['GET'])->bind('shop.index'); + $routes->add('/shop/category', ['GET'])->bind('shop.category.index'); + $routes->add('/shop/category/search/{filter_by:[a-zA-Z]+}:{filter_value}', ['GET'])->bind('shop.category.search'); + $routes->add('/shop/category/{category_id:\d+}', ['GET'])->bind('shop.category.show'); + $routes->add('/shop/category/{category_id:\d+}/product', ['GET'])->bind('shop.category.product.index'); + $routes->add('/shop/category/{category_id:\d+}/product/search/{filter_by:[a-zA-Z]+}:{filter_value}', ['GET'])->bind('shop.category.product.search'); + $routes->add('/shop/product', ['GET'])->bind('shop.product.index'); + $routes->any('/shop[/{type:\w+}[:{filter_by:\@.*}]')->bind('shop.api')->domain('api.shop.com'); + $routes->add('/shop/product/search/{filter_by:[a-zA-Z]+}:{filter_value}', ['GET'])->bind('shop.product.search'); + $routes->add('/shop/product/{product_id:\d+}', ['GET'])->bind('shop.product.show'); + $routes->add('/shop/cart', ['GET'])->bind('shop.cart.show'); + $routes->add('/shop/cart', ['PUT', 'DELETE'])->bind('shop.cart.add_remove'); + $routes->add('/shop/cart/checkout', ['GET', 'POST'])->bind('shop.cart.checkout'); + $routes->add('/admin/login', ['GET', 'POST'])->bind('admin.login'); + $routes->add('/admin/logout', ['GET'])->bind('admin.logout'); + $routes->add('/admin', ['GET'])->bind('admin.index'); + $routes->add('/admin/product', ['GET'])->bind('admin.product.index'); + $routes->add('/admin/product/create', ['GET'])->bind('admin.product.create'); + $routes->add('/admin/product', ['POST'])->bind('admin.product.store'); + $routes->add('/admin/product/{product_id:\d+}', ['GET', 'PUT', 'PATCH', 'DELETE'])->bind('admin.product'); + $routes->add('/admin/product/{product_id:\d+}/edit', ['GET'])->bind('admin.product.edit'); + $routes->add('/admin/category', ['GET', 'POST'])->bind('admin.category.index_store'); + $routes->add('/admin/category/create', ['GET'])->bind('admin.category.create'); + $routes->add('/admin/category/{category_id:\d+}', ['GET', 'PUT', 'PATCH', 'DELETE'])->bind('admin.category'); + $routes->add('/admin/category/{category_id:\d+}/edit', ['GET'])->bind('admin.category.edit'); + $routes->sort(); // Sort routes by giving more priority to static like routes + }); + + return $router; + } + + /** + * {@inheritdoc} + */ + public function provideStaticRoutes(): iterable + { + yield 'first' => [ + 'method' => 'GET', + 'route' => '/', + 'result' => ['handler' => null, 'prefix' => '/', 'path' => '/', 'methods' => ['GET' => true], 'name' => 'home'], + ]; + + yield 'middle' => [ + 'method' => 'GET', + 'route' => '/shop/product', + 'result' => ['handler' => null, 'prefix' => '/shop/product', 'path' => '/shop/product', 'methods' => ['GET' => true], 'name' => 'shop.product.index'], + ]; + + yield 'last' => [ + 'method' => 'GET', + 'route' => '/admin/category', + 'result' => ['handler' => null, 'prefix' => '/admin/category', 'path' => '/admin/category', 'methods' => ['GET' => true, 'POST' => true], 'name' => 'admin.category.index_store'], + ]; + + yield 'invalid-method' => [ + 'method' => 'PUT', + 'route' => '/about-us', + 'result' => MethodNotAllowedException::class, + ]; + } + + /** + * {@inheritdoc} + */ + public function provideDynamicRoutes(): iterable + { + yield 'first' => [ + 'method' => 'GET', + 'route' => '/page/hello-word', + 'result' => [ + 'handler' => null, + 'prefix' => '/page', + 'path' => '/page/{page_slug:[a-zA-Z0-9\-]+}', + 'methods' => ['GET' => true], + 'name' => 'page.show', + 'arguments' => ['page_slug' => 'hello-word'], + ], + ]; + + yield 'middle' => [ + 'method' => 'GET', + 'route' => '//api.shop.com/shop/category_search:filter_by@furniture?value=chair', + 'result' => [ + 'handler' => null, + 'hosts' => ['api.shop.com' => true], + 'prefix' => '/shop', + 'path' => '/shop[/{type:\w+}[:{filter_by:\@.*}]', + 'methods' => \array_fill_keys(Router::HTTP_METHODS_STANDARD, true), + 'name' => 'shop.api', + 'arguments' => ['type' => 'category_search', 'filter_by' => 'furniture'], + ], + ]; + + yield 'last' => [ + 'method' => 'GET', + 'route' => '/admin/category/123/edit', + 'result' => [ + 'handler' => null, + 'prefix' => '/admin/category', + 'path' => '/admin/category/{category_id:\d+}/edit', + 'methods' => ['GET' => true], + 'name' => 'admin.category.edit', + 'arguments' => ['category_id' => '123'], + ], + ]; + + yield 'invalid-method' => [ + 'method' => 'PATCH', + 'route' => '/shop/category/123', + 'result' => MethodNotAllowedException::class, + ]; + } + + /** + * {@inheritdoc} + */ + public function provideOtherScenarios(): iterable + { + yield 'non-existent' => [ + 'method' => 'GET', + 'route' => '/shop/product/awesome', + 'result' => null, + ]; + + yield 'longest-route' => [ + 'method' => 'GET', + 'route' => '/shop/category/123/product/search/status:sale', + 'result' => [ + 'handler' => null, + 'prefix' => '/admin/category/123/edit', + 'path' => '/shop/category/{category_id:\d+}/product/search/{filter_by:[a-zA-Z]+}:{filter_value}', + 'methods' => ['GET' => true], + 'name' => 'shop.category.product.search', + 'arguments' => ['category_id' => '123', 'filter_by' => 'status', 'filter_value' => 'sale'], + ], + ]; + } +} diff --git a/tests/Benchmarks/RouteBench.php b/tests/Benchmarks/RouteBench.php index 4ddc3eca..8d7062d1 100644 --- a/tests/Benchmarks/RouteBench.php +++ b/tests/Benchmarks/RouteBench.php @@ -17,11 +17,8 @@ namespace Flight\Routing\Tests\Benchmarks; -use Flight\Routing\Exceptions\UrlGenerationException; -use Flight\Routing\Generator\GeneratedUri; -use Flight\Routing\Route; -use Flight\Routing\RouteCollection; -use Flight\Routing\Router; +use Flight\Routing\Exceptions\MethodNotAllowedException; +use Flight\Routing\Interfaces\RouteMatcherInterface; use Nyholm\Psr7\Uri; /** @@ -30,267 +27,138 @@ * @Iterations(5) * @BeforeClassMethods({"before"}) */ -class RouteBench +abstract class RouteBench { - private static int $maxRoutes = 400; + protected const MAX_ROUTES = 400; + protected const CACHE_FILE = __DIR__.'/../Fixtures/compiled_test.php'; - private Router $router; + /** @var array */ + private array $dispatchers = []; public static function before(): void { - if (\file_exists($cacheFile = __DIR__ . '/compiled_test.php')) { - @unlink($cacheFile); + if (\file_exists(self::CACHE_FILE)) { + @\unlink(self::CACHE_FILE); } } - /** @return \Generator */ - public function init(): iterable - { - yield 'Best Case' => ['/route/1']; - - yield 'Average Case' => ['/route/199']; + /** @return \Generator> */ + abstract public function provideStaticRoutes(): iterable; - yield 'Real Case' => ['/route/' . \rand(0, self::$maxRoutes)]; + /** @return \Generator> */ + abstract public function provideDynamicRoutes(): iterable; - yield 'Worst Case' => ['/route/399']; + /** @return \Generator> */ + abstract public function provideOtherScenarios(): iterable; - yield 'Domain Case' => ['//localhost.com/route/401']; - - yield 'Non-Existent Case' => ['/none']; + /** @return \Generator> */ + public function provideAllScenarios(): iterable + { + yield 'static(first,middle,last,invalid-method)' => \array_values(\iterator_to_array($this->provideStaticRoutes())); + yield 'dynamic(first,middle,last,invalid-method)' => \array_values(\iterator_to_array($this->provideDynamicRoutes())); + yield 'others(non-existent,...)' => \array_values(\iterator_to_array($this->provideOtherScenarios())); } - public function initUnoptimized(): void + /** @return \Generator> */ + public function provideDispatcher(): iterable { - $router = new Router(); - $router->setCollection(static function (RouteCollection $routes): void { - $collection = []; - - for ($i = 1; $i <= self::$maxRoutes; ++$i) { - $collection[] = Route::to("/route/$i", ['GET'])->bind('static_' . $i); - $collection[] = Route::to("/route/{$i}/{foo}", ['GET'])->bind('no_static_' . $i); - } - - $collection[] = Route::to("//localhost.com/route/401", ['GET'])->bind('static_' . 401); - $collection[] = Route::to("//{host}/route/401/{foo}", ['GET'])->bind('no_static_' . 401); - - $routes->routes($collection, false); - }); - - $this->router = $router; + yield 'not_cached' => ['dispatcher' => 'not_cached']; + yield 'cached' => ['dispatcher' => 'cached']; } - public function initOptimized(): void + public function initDispatchers(): void { - $router = new Router(null, __DIR__ . '/compiled_test.php'); - $router->setCollection(static function (RouteCollection $routes): void { - $collection = []; - - for ($i = 1; $i <= self::$maxRoutes; ++$i) { - $collection[] = Route::to("/route/$i", ['GET'])->bind('static_' . $i); - $collection[] = Route::to("/route/{$i}/{foo}", ['GET'])->bind('no_static_' . $i); - } - - $collection[] = Route::to("//localhost.com/route/401", ['GET'])->bind('static_' . 401); - $collection[] = Route::to("//{host}/route/401/{foo}", ['GET'])->bind('no_static_' . 401); - - $routes->routes($collection, false); - }); - - $this->router = $router; + $this->dispatchers['not_cached'] = $this->createDispatcher(); + $this->dispatchers['cached'] = $this->createDispatcher(self::CACHE_FILE); } /** - * @Groups(value={"static_routes"}) - * @BeforeMethods({"initUnoptimized"}, extend=true) - * @ParamProviders({"init"}) + * @BeforeMethods({"initDispatchers"}) + * @ParamProviders({"provideDispatcher", "provideStaticRoutes"}) */ public function benchStaticRoutes(array $params): void { - $result = $this->router->match('GET', new Uri($params[0])); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); + $this->runScenario($params); } /** - * @Groups(value={"dynamic_routes"}) - * @BeforeMethods({"initUnoptimized"}, extend=true) - * @ParamProviders({"init"}) + * @BeforeMethods({"initDispatchers"}) + * @ParamProviders({"provideDispatcher", "provideDynamicRoutes"}) */ public function benchDynamicRoutes(array $params): void { - $result = $this->router->match('GET', new Uri($params[0] . '/bar')); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); + $this->runScenario($params); } /** - * @Groups(value={"static_routes"}) - * @BeforeMethods({"initUnoptimized"}, extend=true) - * @ParamProviders({"init"}) + * @BeforeMethods({"initDispatchers"}) + * @ParamProviders({"provideDispatcher", "provideOtherScenarios"}) */ - public function benchStaticRouteUri(array $params): void + public function benchOtherRoutes(array $params): void { - $value = \substr($params[0], \stripos($params[0], '/') ?: -1); - - try { - $result = $this->router->generateUri('static_' . $value); - $result = $result instanceof GeneratedUri; - } catch (\Throwable $e) { - $result = '/none' === $params[0] ? $e instanceof UrlGenerationException : false; - } - - \assert($result, new \RuntimeException(\sprintf('Route uri generation failed, for route name "%s" request path.', $value))); + $this->runScenario($params); } /** - * @Groups(value={"dynamic_routes"}) - * @BeforeMethods({"initUnoptimized"}, extend=true) - * @ParamProviders({"init"}) + * @BeforeMethods({"initDispatchers"}) + * @ParamProviders({"provideDispatcher", "provideAllScenarios"}) */ - public function benchDynamicRouteUri(array $params): void + public function benchAll(array $params): void { - $value = \substr($params[0], \stripos($params[0], '/') ?: -1); + $dispatcher = \array_shift($params); - try { - $result = $this->router->generateUri('no_static_' . $value, ['foo' => 'bar', 'host' => 'biurad.com']); - $result = $result instanceof GeneratedUri; - } catch (\Throwable $e) { - $result = '/none' === $params[0] ? $e instanceof UrlGenerationException : false; + foreach ($params as $param) { + $this->runScenario($param + \compact('dispatcher')); } - - \assert($result, new \RuntimeException(\sprintf('Route uri generation failed, for route name "%s" request path.', $value))); - } - - /** - * @Groups(value={"cached_routes:static"}) - * @BeforeMethods({"initOptimized"}, extend=true) - * @ParamProviders({"init"}) - */ - public function benchStaticCachedRoutes(array $params): void - { - $result = $this->router->match('GET', new Uri($params[0])); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); - } - - /** - * @Groups(value={"cached_routes:dynamic"}) - * @BeforeMethods({"initOptimized"}, extend=true) - * @ParamProviders({"init"}) - */ - public function benchDynamicCachedRoutes(array $params): void - { - $result = $this->router->match('GET', new Uri($params[0] . '/bar')); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); - } - - /** - * @Groups(value={"optimized:static"}) - * @ParamProviders({"init"}) - */ - public function benchOptimizedStatic(array $params): void - { - $this->initOptimized(); - $result = $this->router->match('GET', new Uri($params[0])); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); } /** - * @Groups(value={"optimized:dynamic"}) - * @ParamProviders({"init"}) - */ - public function benchOptimizedDynamic(array $params): void - { - $this->initOptimized(); - $result = $this->router->match('GET', new Uri($params[0])); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); - } - - /** - * @Groups(value={"optimized"}) + * @ParamProviders({"provideAllScenarios"}) * @Revs(4) */ - public function benchOptimized(): void + public function benchWithRouter(array $params): void { - $this->initOptimized(); - $s = $d = 0; - - for (;;) { - if ($s <= self::$maxRoutes) { - $path = '/route/' . $s; - $s++; - } elseif ($d <= self::$maxRoutes) { - $path = '/route' . $d . '/foo'; - $d++; - } else { - break; - } + $this->dispatchers['router'] = $this->createDispatcher(); - $result = $this->router->match('GET', new Uri($path)); - assert($this->runScope($path, $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $path))); + foreach ($params as $param) { + $this->runScenario($param + ['dispatcher' => 'router']); } } /** - * @Groups(value={"unoptimized:static"}) - * @ParamProviders({"init"}) + * @ParamProviders({"provideAllScenarios"}) + * @Revs(4) */ - public function benchUnoptimizedStatic(array $params): void + public function benchWithCache(array $params): void { - $this->initUnoptimized(); - $result = $this->router->match('GET', new Uri($params[0])); + $this->dispatchers['cached'] = $this->createDispatcher(self::CACHE_FILE); - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); + foreach ($params as $param) { + $this->runScenario($param + ['dispatcher' => 'cached']); + } } - /** - * @Groups(value={"unoptimized:dynamic"}) - * @ParamProviders({"init"}) - */ - public function benchUnoptimizedDynamic(array $params): void - { - $this->initUnoptimized(); - $result = $this->router->match('GET', new Uri($params[0])); - - assert($this->runScope($params[0], $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $params[0]))); - } + abstract protected function createDispatcher(string $cache = null): RouteMatcherInterface; /** - * @Groups(value={"unoptimized"}) - * @Revs(4) + * @param array|string> $params */ - public function benchUnoptimized(): void + private function runScenario(array $params): void { - $this->initUnoptimized(); - $s = $d = 0; - - for (;;) { - if ($s <= self::$maxRoutes) { - $path = '/route/' . $s; - $s++; - } elseif ($d <= self::$maxRoutes) { - $path = '/route' . $d . '/foo'; - $d++; - } else { - break; - } - - $result = $this->router->match('GET', new Uri($path)); - assert($this->runScope($path, $result), new \RuntimeException(\sprintf('Route match failed, expected a route instance for "%s" request path.', $path))); - } - } - - private function runScope(string $requestPath, ?Route $route): bool - { - if ($route instanceof Route) { - return 'GET' === $route->getMethods()[0]; - } elseif ('/none' === $requestPath) { - return null === $route; + try { + $dispatcher = $this->dispatchers[$params['dispatcher']]; + $result = $params['result'] === $dispatcher->match($params['method'], new Uri($params['route'])); + } catch (MethodNotAllowedException $e) { + $result = $params['result'] === $e::class; } - return false; + \assert($result, new \RuntimeException( + \sprintf( + 'Benchmark "%s: %s" failed with method "%s"', + $params['dispatcher'], + $params['route'], + $params['method'] + ) + )); } } diff --git a/tests/Fixtures/bench_autoload.php b/tests/Fixtures/bench_autoload.php new file mode 100644 index 00000000..08bd5e17 --- /dev/null +++ b/tests/Fixtures/bench_autoload.php @@ -0,0 +1,4 @@ + Date: Tue, 6 Sep 2022 08:44:02 +0000 Subject: [PATCH 23/40] [BC BREAK] Improved the route's matcher implementation - Removed the route matcher's class to use only the router class instead - Added CacheTrait class for handling caching the route collection - Added the ResolverTrait class for matching routes - Fixed minor issues and improved the router class --- src/RouteMatcher.php | 348 ----------------------------------- src/Router.php | 160 +++++++--------- src/Traits/CacheTrait.php | 171 +++++++++++++++++ src/Traits/ResolverTrait.php | 164 +++++++++++++++++ 4 files changed, 398 insertions(+), 445 deletions(-) delete mode 100644 src/RouteMatcher.php create mode 100644 src/Traits/CacheTrait.php create mode 100644 src/Traits/ResolverTrait.php diff --git a/src/RouteMatcher.php b/src/RouteMatcher.php deleted file mode 100644 index cb823ecb..00000000 --- a/src/RouteMatcher.php +++ /dev/null @@ -1,348 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing; - -use Flight\Routing\Exceptions\{MethodNotAllowedException, UriHandlerException, UrlGenerationException}; -use Flight\Routing\Generator\GeneratedUri; -use Flight\Routing\Interfaces\{RouteCompilerInterface, RouteMatcherInterface, UrlGeneratorInterface}; -use Psr\Http\Message\{ServerRequestInterface, UriInterface}; - -/** - * The bidirectional route matcher responsible for matching - * HTTP request and generating url from routes. - * - * @author Divine Niiquaye Ibok - */ -class RouteMatcher implements RouteMatcherInterface, UrlGeneratorInterface -{ - private RouteCompilerInterface $compiler; - - /** @var RouteCollection|array */ - private $routes; - - /** @var array */ - private ?array $compiledData = null; - - /** @var array */ - private array $optimized = []; - - public function __construct(RouteCollection $collection, RouteCompilerInterface $compiler = null) - { - $this->compiler = $compiler ?? new RouteCompiler(); - $this->routes = $collection; - } - - /** - * @internal - */ - public function __serialize(): array - { - return [$this->compiler->build($this->routes), $this->getRoutes(), $this->compiler]; - } - - /** - * @internal - * - * @param array $data - */ - public function __unserialize(array $data): void - { - [$this->compiledData, $this->routes, $this->compiler] = $data; - } - - /** - * {@inheritdoc} - */ - public function matchRequest(ServerRequestInterface $request): ?Route - { - $requestUri = $request->getUri(); - - // Resolve request path to match sub-directory or /index.php/path - if ('' !== ($pathInfo = $request->getServerParams()['PATH_INFO'] ?? '') && $pathInfo !== $requestUri->getPath()) { - $requestUri = $requestUri->withPath($pathInfo); - } - - return $this->match($request->getMethod(), $requestUri); - } - - /** - * {@inheritdoc} - */ - public function match(string $method, UriInterface $uri): ?Route - { - return $this->optimized[$method . $uri] ??= $this->{($c = $this->compiledData) ? 'matchCached' : 'matchCollection'}($method, $uri, $c ?? $this->routes); - } - - /** - * {@inheritdoc} - */ - public function generateUri(string $routeName, array $parameters = [], int $referenceType = GeneratedUri::ABSOLUTE_PATH): GeneratedUri - { - if (!$optimized = &$this->optimized[$routeName] ?? null) { - foreach ($this->getRoutes() as $offset => $route) { - if ($routeName === $route->getName()) { - if (null === $matched = $this->compiler->generateUri($route, $parameters, $referenceType)) { - throw new UrlGenerationException(\sprintf('The route compiler class does not support generating uri for named route: %s', $routeName)); - } - - $optimized = $offset; // Cache the route index ... - - return $matched; - } - } - - throw new UrlGenerationException(\sprintf('Unable to generate a URL for the named route "%s" as such route does not exist.', $routeName), 404); - } - - return $this->compiler->generateUri($this->getRoutes()[$optimized], $parameters, $referenceType); - } - - /** - * Get the compiler associated with this class. - */ - public function getCompiler(): RouteCompilerInterface - { - return $this->compiler; - } - - /** - * Get the routes associated with this class. - * - * @return array - */ - public function getRoutes(): array - { - if (\is_array($routes = $this->routes)) { - return $routes; - } - - return $routes->getRoutes(); - } - - /** - * Tries to match a route from a set of routes. - */ - protected function matchCollection(string $method, UriInterface $uri, RouteCollection $routes): ?Route - { - $requirements = []; - $requestPath = \rawurldecode($uri->getPath()) ?: '/'; - $requestScheme = $uri->getScheme(); - - foreach ($routes->getRoutes() as $offset => $route) { - if (!empty($staticPrefix = $route->getStaticPrefix()) && !\str_starts_with($requestPath, $staticPrefix)) { - continue; - } - - [$pathRegex, $hostsRegex, $variables] = $this->optimized[$offset] ??= $this->compiler->compile($route); - $hostsVar = []; - - if (!\preg_match($pathRegex, $requestPath, $matches, \PREG_UNMATCHED_AS_NULL)) { - continue; - } - - if (!$route->hasMethod($method)) { - $requirements[0] = \array_merge($requirements[0] ?? [], $route->getMethods()); - continue; - } - - if (!$route->hasScheme($requestScheme)) { - $requirements[1] = \array_merge($requirements[1] ?? [], $route->getSchemes()); - continue; - } - - if (empty($hostsRegex) || $this->matchHost($hostsRegex, $uri, $hostsVar)) { - if (!empty($variables)) { - $matchInt = 0; - - foreach ($variables as $key => $value) { - $route->argument($key, $matches[++$matchInt] ?? $matches[$key] ?? $hostsVar[$key] ?? $value); - } - } - - return $route; - } - - $requirements[2][] = $hostsRegex; - } - - return $this->assertMatch($method, $uri, $requirements); - } - - /** - * Tries matching routes from cache. - */ - public function matchCached(string $method, UriInterface $uri, array $optimized): ?Route - { - [$staticRoutes, $regexList, $variables] = $optimized; - $requestPath = \rawurldecode($uri->getPath()) ?: '/'; - $requestScheme = $uri->getScheme(); - $requirements = $matches = []; - $index = 0; - - if (null === $matchedIds = $staticRoutes[$requestPath] ?? (!$regexList || 1 !== \preg_match($regexList, $requestPath, $matches, \PREG_UNMATCHED_AS_NULL) ? null : [(int) $matches['MARK']])) { - return null; - } - - do { - $route = $this->routes[$i = $matchedIds[$index]]; - - if (!$route->hasMethod($method)) { - $requirements[0] = \array_merge($requirements[0] ?? [], $route->getMethods()); - continue; - } - - if (!$route->hasScheme($requestScheme)) { - $requirements[1] = \array_merge($requirements[1] ?? [], $route->getSchemes()); - continue; - } - - if (!\array_key_exists($i, $variables)) { - return $route; - } - - [$hostsRegex, $routeVar] = $variables[$i]; - $hostsVar = []; - - if (empty($hostsRegex) || $this->matchHost($hostsRegex, $uri, $hostsVar)) { - if (!empty($routeVar)) { - $matchInt = 0; - - foreach ($routeVar as $key => $value) { - $route->argument($key, $matches[++$matchInt] ?? $matches[$key] ?? $hostsVar[$key] ?? $value); - } - } - - return $route; - } - - $requirements[2][] = $hostsRegex; - } while (isset($matchedIds[++$index])); - - return $this->assertMatch($method, $uri, $requirements); - } - - protected function matchHost(string $hostsRegex, UriInterface $uri, array &$hostsVar): bool - { - $hostAndPort = $uri->getHost(); - - if ($uri->getPort()) { - $hostAndPort .= ':' . $uri->getPort(); - } - - if ($hostsRegex === $hostAndPort) { - return true; - } - - if (!\str_contains($hostsRegex, '^')) { - $hostsRegex = '#^' . $hostsRegex . '$#ui'; - } - - return 1 === \preg_match($hostsRegex, $hostAndPort, $hostsVar, \PREG_UNMATCHED_AS_NULL); - } - - /** - * @param array $requirements - */ - protected function assertMatch(string $method, UriInterface $uri, array $requirements) - { - if (!empty($requirements)) { - if (isset($requirements[0])) { - $this->assertMethods($method, $uri->getPath(), $requirements[0]); - } - - if (isset($requirements[1])) { - $this->assertSchemes($uri, $requirements[1]); - } - - if (isset($requirements[2])) { - $this->assertHosts($uri, $requirements[2]); - } - } - - return null; - } - - /** - * @param array $requiredMethods - */ - protected function assertMethods(string $method, string $uriPath, array $requiredMethods): void - { - $allowedMethods = []; - - foreach (\array_unique($requiredMethods) as $requiredMethod) { - if ($method === $requiredMethod || 'HEAD' === $requiredMethod) { - continue; - } - - $allowedMethods[] = $requiredMethod; - } - - if (!empty($allowedMethods)) { - throw new MethodNotAllowedException($allowedMethods, $uriPath, $method); - } - } - - /** - * @param array $requiredSchemes - */ - protected function assertSchemes(UriInterface $uri, array $requiredSchemes): void - { - $allowedSchemes = []; - - foreach (\array_unique($requiredSchemes) as $requiredScheme) { - if ($uri->getScheme() !== $requiredScheme) { - $allowedSchemes[] = $requiredScheme; - } - } - - if (!empty($allowedSchemes)) { - throw new UriHandlerException( - \sprintf( - 'Route with "%s" path is not allowed on requested uri "%s" with invalid scheme, supported scheme(s): [%s].', - $uri->getPath(), - (string) $uri, - \implode(', ', $allowedSchemes) - ), - 400 - ); - } - } - - /** - * @param array $requiredHosts - */ - protected function assertHosts(UriInterface $uri, array $requiredHosts): void - { - $allowedHosts = 0; - - foreach ($requiredHosts as $requiredHost) { - $hostsVar = []; - - if (!empty($requiredHost) && !$this->matchHost($requiredHost, $uri, $hostsVar)) { - ++$allowedHosts; - } - } - - if ($allowedHosts > 0) { - throw new UriHandlerException( - \sprintf('Route with "%s" path is not allowed on requested uri "%s" as uri host is invalid.', $uri->getPath(), (string) $uri), - 400 - ); - } - } -} diff --git a/src/Router.php b/src/Router.php index 42c49a80..af56ddd2 100644 --- a/src/Router.php +++ b/src/Router.php @@ -19,12 +19,10 @@ use Fig\Http\Message\RequestMethodInterface; use Flight\Routing\Exceptions\UrlGenerationException; -use Flight\Routing\Generator\GeneratedUri; use Flight\Routing\Interfaces\{RouteCompilerInterface, RouteMatcherInterface, UrlGeneratorInterface}; use Laminas\Stratigility\Next; use Psr\Http\Message\{ResponseInterface, ServerRequestInterface, UriInterface}; use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface}; -use Symfony\Component\VarExporter\VarExporter; /** * Aggregate routes for matching and Dispatching. @@ -33,6 +31,11 @@ */ class Router implements RouteMatcherInterface, RequestMethodInterface, MiddlewareInterface, UrlGeneratorInterface { + use Traits\CacheTrait, Traits\ResolverTrait; + + /** @var array Default methods for route. */ + public const DEFAULT_METHODS = [self::METHOD_GET, self::METHOD_HEAD]; + /** * Standard HTTP methods for browser requests. */ @@ -49,78 +52,86 @@ class Router implements RouteMatcherInterface, RequestMethodInterface, Middlewar self::METHOD_CONNECT, ]; - private ?RouteCompilerInterface $compiler; - private ?RouteMatcherInterface $matcher = null; + private RouteCompilerInterface $compiler; private ?\SplQueue $pipeline = null; - private string $matcherClass = RouteMatcher::class; - private ?string $cacheData; + private \Closure|RouteCollection|null $collection = null; /** @var array> */ private array $middlewares = []; - /** @var RouteCollection|(callable(RouteCollection): void)|null */ - private $collection; - /** - * @param string|null $cache file path to store compiled routes + * @param null|string $cache file path to store compiled routes */ public function __construct(RouteCompilerInterface $compiler = null, string $cache = null) { - $this->compiler = $compiler; - $this->cacheData = $cache; + $this->cache = $cache; + $this->compiler = $compiler ?? new RouteCompiler(); } /** * Set a route collection instance into Router in order to use addRoute method. * - * @param string|null $cache file path to store compiled routes - * - * @return static + * @param null|string $cache file path to store compiled routes */ - public static function withCollection(RouteCollection $collection = null, RouteCompilerInterface $compiler = null, string $cache = null) - { + public static function withCollection( + \Closure|RouteCollection $collection = null, + RouteCompilerInterface $compiler = null, + string $cache = null + ): static { $new = new static($compiler, $cache); - $new->collection = $collection ?? new RouteCollection(); + $new->collection = $collection; return $new; } - /** - * This method works only if withCollection method is used. - */ - public function addRoute(Route ...$routes): void - { - $this->getCollection()->routes($routes, false); - } - /** * {@inheritdoc} */ - public function match(string $method, UriInterface $uri): ?Route + public function match(string $method, UriInterface $uri): ?array { - return $this->getMatcher()->match($method, $uri); + return $this->optimized[$method.$uri->__toString()] ??= [$this, $this->cache ? 'resolveCache' : 'resolveRoute']( + \rtrim(\rawurldecode($uri->getPath()), '/') ?: '/', + $method, + $uri + ); } /** * {@inheritdoc} */ - public function matchRequest(ServerRequestInterface $request): ?Route + public function matchRequest(ServerRequestInterface $request): ?array { - return $this->getMatcher()->matchRequest($request); + $requestUri = $request->getUri(); + $pathInfo = $request->getServerParams()['PATH_INFO'] ?? ''; + + // Resolve request path to match sub-directory or /index.php/path + if ('' !== $pathInfo && $pathInfo !== $requestUri->getPath()) { + $requestUri = $requestUri->withPath($pathInfo); + } + + return $this->match($request->getMethod(), $requestUri); } /** * {@inheritdoc} */ - public function generateUri(string $routeName, array $parameters = [], int $referenceType = GeneratedUri::ABSOLUTE_PATH): GeneratedUri + public function generateUri(string $routeName, array $parameters = [], int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri { - $matcher = $this->getMatcher(); + if (empty($matchedRoute = &$this->optimized[$routeName] ?? null)) { + foreach ($this->getCollection()->getRoutes() as $route) { + if (isset($route['name']) && $route['name'] === $routeName) { + $matchedRoute = $route; + break; + } + } + } - if (!$matcher instanceof UrlGeneratorInterface) { - throw new UrlGenerationException(\sprintf('The route matcher does not support using the %s implementation', UrlGeneratorInterface::class)); + if (!isset($matchedRoute)) { + throw new UrlGenerationException(\sprintf('Route "%s" does not exist.', $routeName)); } - return $matcher->generateUri($routeName, $parameters, $referenceType); + return $this->compiler->generateUri($matchedRoute, $parameters, $referenceType) + ?? throw new UrlGenerationException(\sprintf('%s::generateUri() not implemented in compiler.', $this->compiler::class)); } /** @@ -160,50 +171,28 @@ public function setCollection(callable $routeDefinitionCallback): void */ public function getCollection(): RouteCollection { - if (\is_callable($collection = $this->collection)) { - $collection($collection = new RouteCollection()); - } elseif (null !== $collection) { - return $this->collection; + if ($this->cache) { + return $this->optimized[2] ?? $this->doCache(); } - return $this->collection = $collection ?? new RouteCollection(); - } - - /** - * Set where cached data will be stored. - * - * @param string $cache file path to store compiled routes - */ - public function setCache(string $cache): void - { - $this->cacheData = $cache; - } + if ($this->collection instanceof \Closure) { + ($this->collection)($this->collection = new RouteCollection()); + } - /** - * If RouteCollection's data has been cached. - */ - public function isCached(): bool - { - return ($cache = $this->cacheData) && \file_exists($cache); + return $this->collection ??= new RouteCollection(); } /** - * Set a matcher class associated with this Router. + * Set a route compiler instance into Router. */ - public function setMatcher(string $matcherClass): void + public function setCompiler(RouteCompiler $compiler): void { - if (!\is_subclass_of($matcherClass, RouteMatcherInterface::class)) { - throw new \InvalidArgumentException(\sprintf('"%s" must be a subclass of "%s".', $matcherClass, RouteMatcherInterface::class)); - } - $this->matcherClass = $matcherClass; + $this->compiler = $compiler; } - /** - * Gets the Route matcher instance associated with this Router. - */ - public function getMatcher(): RouteMatcherInterface + public function getCompiler(): RouteCompilerInterface { - return $this->matcher ??= $this->cacheData ? $this->getCachedData($this->cacheData) : new $this->matcherClass($this->getCollection(), $this->compiler); + return $this->compiler; } /** @@ -211,43 +200,20 @@ public function getMatcher(): RouteMatcherInterface */ public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $route = $this->getMatcher()->matchRequest($request); + $route = $this->matchRequest($request); if (null !== $route) { - foreach ($route->getPiped() as $middleware) { - if (isset($this->middlewares[$middleware])) { - $this->pipe(...$this->middlewares[$middleware]); + foreach ($route['middlewares'] ?? [] as $a => $b) { + if (isset($this->middlewares[$a])) { + $this->pipe(...$this->middlewares[$a]); } } } - if (null !== $this->pipeline) { + if (!empty($this->pipeline)) { $handler = new Next($this->pipeline, $handler); } - return $handler->handle($request->withAttribute(Route::class, $route)); - } - - protected function getCachedData(string $cache): RouteMatcherInterface - { - $cachedData = @include $cache; - - if (!$cachedData instanceof RouteMatcherInterface) { - $cachedData = new $this->matcherClass($this->getCollection(), $this->compiler); - $dumpData = \class_exists(VarExporter::class) ? VarExporter::export($cachedData) : "\unserialize(<<<'SERIALIZED'\n" . \serialize($cachedData) . "\nSERIALIZED)"; - - if (!\is_dir($directory = \dirname($cache))) { - @\mkdir($directory, 0775, true); - } - - \file_put_contents($cache, "handle($request->withAttribute(self::class, $route)); } } diff --git a/src/Traits/CacheTrait.php b/src/Traits/CacheTrait.php new file mode 100644 index 00000000..b3737ff6 --- /dev/null +++ b/src/Traits/CacheTrait.php @@ -0,0 +1,171 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flight\Routing\Traits; + +use Flight\Routing\Handlers\ResourceHandler; +use Flight\Routing\RouteCollection; + +/** + * A default cache implementation for route match. + * + * @author Divine Niiquaye Ibok + */ +trait CacheTrait +{ + private ?string $cache = null; + + /** + * @param string $path file path to store compiled routes + */ + public function setCache(string $path): void + { + $this->cache = $path; + } + + /** + * A well php value formatter, better than (var_export). + */ + public static function export(mixed $value, string $indent = ''): string + { + switch (true) { + case [] === $value: + return '[]'; + case \is_array($value): + $j = -1; + $code = ($t = \count($value, \COUNT_RECURSIVE)) > 15 ? "[\n" : '['; + $subIndent = $t > 15 ? $indent.' ' : $indent = ''; + + foreach ($value as $k => $v) { + $code .= $subIndent; + + if (!\is_int($k) || $k !== ++$j) { + $code .= self::export($k, $subIndent).' => '; + } + + $code .= self::export($v, $subIndent).($t > 15 ? ",\n" : ', '); + } + + return \rtrim($code, ', ').$indent.']'; + case $value instanceof ResourceHandler: + return $value::class.'('.self::export($value(''), $indent).')'; + case $value instanceof \stdClass: + return '(object) '.self::export((array) $value, $indent); + case $value instanceof RouteCollection: + return $value::class.'::__set_state('.self::export([ + 'routes' => $value->getRoutes(), + 'defaultIndex' => $value->count() - 1, + 'sorted' => true, + ], $indent).')'; + case \is_object($value): + if (\method_exists($value, '__set_state')) { + return $value::class.'::__set_state('.self::export( + \array_merge(...\array_map(function (\ReflectionProperty $v) use ($value): array { + return [$v->getName() => $v->getValue($value)]; + }, (new \ReflectionObject($value))->getProperties())) + ); + } + return 'unserialize(\''.\serialize($value).'\')'; + } + + return \var_export($value, true); + } + + protected function doCache(): RouteCollection + { + if (\is_array($a = @include $this->cache)) { + $this->optimized = $a; + + return $this->optimized[2] ??= $this->collection ?? new RouteCollection(); + } + + if (\is_callable($collection = $this->collection ?? new RouteCollection())) { + $collection($collection = new RouteCollection()); + $collection->sort(); + $doCache = true; + } + + if (!\is_dir($directory = \dirname($this->cache))) { + @\mkdir($directory, 0775, true); + } + + try { + return $collection; + } finally { + $dumpData = $this->buildCache($collection, $doCache ?? false); + \file_put_contents($this->cache, "cache, true); + } + } + } + + protected function buildCache(RouteCollection $collection, bool $doCache): string + { + $dynamicRoutes = []; + $this->optimized = [[], [[], []]]; + + foreach ($collection->getRoutes() as $i => $route) { + $trimmed = \preg_replace('/\W$/', '', $path = $route['path']); + + if (\in_array($prefix = $route['prefix'] ?? '/', [$trimmed, $path], true)) { + $this->optimized[0][$trimmed ?: '/'][] = $i; + continue; + } + [$path, $var] = $this->getCompiler()->compile($path, $route['placeholders'] ?? []); + $path = \str_replace('\/', '/', \substr($path, 1 + \strpos($path, '^'), -(\strlen($path) - \strrpos($path, '$')))); + + if (($l = \array_key_last($dynamicRoutes)) && !\in_array($l, ['/', $prefix], true)) { + for ($o = 0, $new = ''; $o < \strlen($prefix); ++$o) { + if ($prefix[$o] !== ($l[$o] ?? null)) { + break; + } + $new .= $l[$o]; + } + + if ($new && '/' !== $new) { + if ($l !== $new) { + $dynamicRoutes[$new] = $dynamicRoutes[$l]; + unset($dynamicRoutes[$l]); + } + $prefix = $new; + } + } + $dynamicRoutes[$prefix][] = \preg_replace('#\?(?|P<\w+>|<\w+>|\'\w+\')#', '', $path)."(*:{$i})"; + $this->optimized[1][1][$i] = $var; + } + \ksort($this->optimized[0], \SORT_NATURAL); + \ksort($dynamicRoutes, \SORT_NATURAL); + + foreach ($dynamicRoutes as $offset => $paths) { + $numParts = \max(1, \round(($c = \count($paths)) / 30)); + + foreach (\array_chunk($paths, (int) \ceil($c / $numParts)) as $chunk) { + $this->optimized[1][0]['/'.\ltrim($offset, '/')][] = '~^(?|'.\implode('|', $chunk).')$~'; + } + } + + if (isset($this->optimized[1][0]['/'])) { + $last = $this->optimized[1][0]['/']; + unset($this->optimized[1][0]['/']); + $this->optimized[1][0]['/'] = $last; + } + + return self::export([...$this->optimized, $doCache ? $collection : null]); + } +} diff --git a/src/Traits/ResolverTrait.php b/src/Traits/ResolverTrait.php new file mode 100644 index 00000000..7faaf937 --- /dev/null +++ b/src/Traits/ResolverTrait.php @@ -0,0 +1,164 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flight\Routing\Traits; + +use Flight\Routing\Exceptions\{MethodNotAllowedException, UriHandlerException}; +use Psr\Http\Message\UriInterface; + +/** + * The default implementation for route match. + * + * @author Divine Niiquaye Ibok + */ +trait ResolverTrait +{ + /** @var array */ + private array $optimized = []; + + /** + * @param array $route + * @param array> $errors + */ + protected function assertRoute(string $method, UriInterface $uri, array &$route, array &$errors): bool + { + if (!\array_key_exists($method, $route['methods'] ?? [])) { + $errors[0] += $route['methods'] ?? []; + + return false; + } + + if (\array_key_exists('hosts', $route)) { + $hosts = \array_keys($route['hosts'], true, true); + [$hostsRegex, $hostVar] = $this->compiler->compile(\implode('|', $hosts), $route['placeholders'] ?? []); + + if (!\preg_match($hostsRegex.'i', $errors[2] ??= \rtrim($uri->getHost().':'.$uri->getPort(), ':'), $matches, \PREG_UNMATCHED_AS_NULL)) { + return false; + } + + foreach ($hostVar as $key => $value) { + $route['arguments'][$key] = $matches[$key] ?? $route['defaults'][$key] ?? $value; + } + } + + if (\array_key_exists('schemes', $route)) { + $hasScheme = isset($route['schemes'][$uri->getScheme()]); + + if (!$hasScheme) { + $errors[1] += $route['schemes'] ?? []; + + return false; + } + } + + return true; + } + + /** + * @return null|array + */ + protected function resolveRoute(string $path, string $method, UriInterface $uri): ?array + { + $errors = [[], []]; + + foreach ($this->getCollection()->getRoutes() as $i => $r) { + if (isset($r['prefix']) && !\str_starts_with($path, $r['prefix'])) { + continue; + } + [$p, $v] = $this->optimized[$i] ??= $this->compiler->compile($r['path'], $r['placeholders'] ?? []); + + if (\preg_match($p, $path, $m, \PREG_UNMATCHED_AS_NULL)) { + if (!$this->assertRoute($method, $uri, $r, $errors)) { + continue; + } + + foreach ($v as $key => $value) { + $r['arguments'][$key] = $m[$key] ?? $r['defaults'][$key] ?? $value; + } + + return $r; + } + } + + return $this->resolveError($errors, $method, $uri); + } + + /** + * @return null|array + */ + protected function resolveCache(string $path, string $method, UriInterface $uri): ?array + { + $errors = [[], []]; + $routes = $this->optimized[2] ?? $this->doCache(); + + foreach ($this->optimized[0][$path] ?? $this->optimized[1][0] ?? [] as $s => $h) { + if (\is_int($s)) { + $r = $routes[$h] ?? $routes->getRoutes()[$h]; + + if (!$this->assertRoute($method, $uri, $r, $errors)) { + continue; + } + + return $r; + } + + if (!\str_starts_with($path, $s)) { + continue; + } + + foreach ($h as $p) { + if (\preg_match($p, $path, $m, \PREG_UNMATCHED_AS_NULL)) { + $r = $routes[$o = (int) $m['MARK']] ?? $routes->getRoutes()[$o]; + + if ($this->assertRoute($method, $uri, $r, $errors)) { + $i = 0; + + foreach ($this->optimized[1][1][$o] ?? [] as $key => $value) { + $r['arguments'][$key] = $m[++$i] ?? $r['defaults'][$key] ?? $value; + } + + return $r; + } + } + } + } + + return $this->resolveError($errors, $method, $uri); + } + + /** + * @param array> $errors + */ + protected function resolveError(array $errors, string $method, UriInterface $uri) + { + if (!empty($errors[0])) { + throw new MethodNotAllowedException(\array_keys($errors[0]), $uri->getPath(), $method); + } + + if (!empty($errors[1])) { + throw new UriHandlerException( + \sprintf( + 'Route with "%s" path requires request scheme(s) [%s], "%s" is invalid.', + $uri->getPath(), + \implode(', ', \array_keys($errors[1])), + $uri->getScheme(), + ), + 400 + ); + } + + return null; + } +} From 17a3bfa07710ce5951989c83feb78d8b5ec36ac3 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:47:16 +0000 Subject: [PATCH 24/40] Rewritten all tests using the `pestphp/pest` library --- composer.json | 11 +- tests/Annotation/ListenerTest.php | 562 ----- tests/Annotation/RouteTest.php | 66 - tests/BaseTestCase.php | 70 - tests/CompilerTest.php | 329 +++ .../Annotation/Route/Invalid/PathEmpty.php | 2 +- .../Route/Valid/InvokableController.php | 2 +- tests/Fixtures/BlankController.php | 74 - .../BlankMiddlewarableRequestHandler.php | 46 - tests/Fixtures/BlankMiddleware.php | 80 - tests/Fixtures/BlankRequestHandler.php | 38 +- tests/Fixtures/Helper.php | 78 - tests/Fixtures/InvokeController.php | 35 - tests/Fixtures/NamedBlankMiddleware.php | 52 - tests/Fixtures/PhpInfoListener.php | 44 - tests/Fixtures/RouteMiddleware.php | 44 - tests/Fixtures/style.css | 9 + tests/Fixtures/template.html | 11 + tests/HandlersTest.php | 378 +++ tests/Matchers/SimpleRouteCompilerTest.php | 454 ---- tests/Matchers/SimpleRouteMatcherTest.php | 446 ---- tests/MiddlewareTest.php | 238 ++ tests/Middlewares/PathMiddlewareTest.php | 210 -- .../Middlewares/UriRedirectMiddlewareTest.php | 128 - tests/Pest.php | 118 + tests/RegexGeneratorTest.php | 190 -- tests/RouteCollectionTest.php | 2063 ++++++++++------- tests/RouteHandlerTest.php | 210 -- tests/RouteInvokerTest.php | 110 - tests/RouteTest.php | 281 --- tests/RouterTest.php | 885 +++---- 31 files changed, 2566 insertions(+), 4698 deletions(-) delete mode 100644 tests/Annotation/ListenerTest.php delete mode 100644 tests/Annotation/RouteTest.php delete mode 100644 tests/BaseTestCase.php create mode 100644 tests/CompilerTest.php delete mode 100644 tests/Fixtures/BlankController.php delete mode 100644 tests/Fixtures/BlankMiddlewarableRequestHandler.php delete mode 100644 tests/Fixtures/BlankMiddleware.php delete mode 100644 tests/Fixtures/Helper.php delete mode 100644 tests/Fixtures/InvokeController.php delete mode 100644 tests/Fixtures/NamedBlankMiddleware.php delete mode 100644 tests/Fixtures/PhpInfoListener.php delete mode 100644 tests/Fixtures/RouteMiddleware.php create mode 100644 tests/Fixtures/style.css create mode 100644 tests/Fixtures/template.html create mode 100644 tests/HandlersTest.php delete mode 100644 tests/Matchers/SimpleRouteCompilerTest.php delete mode 100644 tests/Matchers/SimpleRouteMatcherTest.php create mode 100644 tests/MiddlewareTest.php delete mode 100644 tests/Middlewares/PathMiddlewareTest.php delete mode 100644 tests/Middlewares/UriRedirectMiddlewareTest.php create mode 100644 tests/Pest.php delete mode 100644 tests/RegexGeneratorTest.php delete mode 100644 tests/RouteHandlerTest.php delete mode 100644 tests/RouteInvokerTest.php delete mode 100644 tests/RouteTest.php diff --git a/composer.json b/composer.json index 1c368c41..847cab23 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "doctrine/annotations": "^1.11", "nyholm/psr7": "^1.4", "nyholm/psr7-server": "^1.0", + "pestphp/pest": "^1.21", "phpstan/phpstan": "^1.0", "phpunit/phpunit": "^9.5", "spiral/attributes": "^2.9", @@ -49,11 +50,6 @@ "Flight\\Routing\\": "src/" } }, - "autoload-dev": { - "psr-4": { - "Flight\\Routing\\Tests\\": "tests/" - } - }, "extra": { "branch-alias": { "dev-master": "2.x-dev" @@ -63,7 +59,7 @@ "phpcs": "phpcs -q", "phpstan": "phpstan analyse", "psalm": "psalm --show-info=true", - "phpunit": "phpunit --no-coverage", + "pest": "pest --no-coverage", "test": [ "@phpcs", "@phpstan", @@ -73,7 +69,8 @@ }, "config": { "allow-plugins": { - "composer/package-versions-deprecated": false + "composer/package-versions-deprecated": false, + "pestphp/pest-plugin": true } }, "minimum-stability": "dev", diff --git a/tests/Annotation/ListenerTest.php b/tests/Annotation/ListenerTest.php deleted file mode 100644 index 114ab7f7..00000000 --- a/tests/Annotation/ListenerTest.php +++ /dev/null @@ -1,562 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Annotation; - -use Biurad\Annotations\AnnotationLoader; -use Biurad\Annotations\InvalidAnnotationException; -use Flight\Routing\Annotation; -use Flight\Routing\Exceptions\UriHandlerException; -use Flight\Routing\Handlers\ResourceHandler; -use Flight\Routing\Route; -use Flight\Routing\RouteCollection; -use Flight\Routing\Router; -use Spiral\Attributes\AnnotationReader; -use Spiral\Attributes\AttributeReader; -use Spiral\Attributes\Composite\MergeReader; -use Flight\Routing\Tests\Fixtures; -use PHPUnit\Framework\TestCase; - -/** - * ListenerTest. - */ -class ListenerTest extends TestCase -{ - /** - * @runInSeparateProcess - */ - public function testDefaultLoadWithListener(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener(), 'test'); - $loader->resource(__DIR__ . '/../Fixtures/Annotation/Route/Valid'); - - $collection = $loader->load('test'); - $this->assertInstanceOf(RouteCollection::class, $collection); - - $names = Fixtures\Helper::routesToNames($collection->getRoutes()); - \sort($names); - - $this->assertSame([ - 'GET_HEAD_get', - 'GET_HEAD_get_1', - 'GET_HEAD_testing_', - 'GET_POST_default', - 'POST_post', - 'PUT_put', - 'action', - 'class_group@GET_HEAD_CONNECT_get', - 'class_group@POST_CONNECT_post', - 'class_group@PUT_CONNECT_put', - 'do.action', - 'do.action_two', - 'english_locale', - 'french_locale', - 'hello_with_default', - 'hello_without_default', - 'home', - 'lol', - 'method_not_array', - 'ping', - 'sub-dir:bar', - 'sub-dir:foo', - 'user__restful', - ], $names); - $this->assertCount(23, $names); - } - - /** - * @runInSeparateProcess - */ - public function testDefaultLoadWithoutListener(): void - { - $loader = $this->createLoader(); - $loader->resource(__DIR__ . '/../Fixtures/Annotation/Route/Valid'); - $this->assertIsArray($collection = $loader->load(Annotation\Route::class)); - - $collection = (new Annotation\Listener())->load($collection); - $this->assertInstanceOf(RouteCollection::class, $collection); - - $names = Fixtures\Helper::routesToNames($collection->getRoutes()); - \sort($names); - - $this->assertSame([ - 'GET_HEAD_get', - 'GET_HEAD_get_1', - 'GET_HEAD_testing_', - 'GET_POST_default', - 'POST_post', - 'PUT_put', - 'action', - 'class_group@GET_HEAD_CONNECT_get', - 'class_group@POST_CONNECT_post', - 'class_group@PUT_CONNECT_put', - 'do.action', - 'do.action_two', - 'english_locale', - 'french_locale', - 'hello_with_default', - 'hello_without_default', - 'home', - 'lol', - 'method_not_array', - 'ping', - 'sub-dir:bar', - 'sub-dir:foo', - 'user__restful', - ], $names); - $this->assertCount(23, $names); - } - - /** - * @runInSeparateProcess - */ - public function testDefaultLoadWithNullPrefix(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener(null, null), 'test'); - $loader->resource(__DIR__ . '/../Fixtures/Annotation/Route/Valid'); - - $collection = $loader->load('test'); - $this->assertInstanceOf(RouteCollection::class, $collection); - - $names = Fixtures\Helper::routesToNames($collection->getRoutes()); - \sort($names); - - $this->assertEquals([ - 'action', - 'class_group@GET_HEAD_CONNECT_get', - 'class_group@POST_CONNECT_post', - 'class_group@PUT_CONNECT_put', - 'do.action', - 'do.action_two', - 'english_locale', - 'french_locale', - 'hello_with_default', - 'hello_without_default', - 'home', - 'lol', - 'method_not_array', - 'ping', - 'sub-dir:bar', - 'sub-dir:foo', - 'user__restful', - ], \array_values(\array_filter($names))); - $this->assertCount(23, $names); - } - - /** - * @runInSeparateProcess - */ - public function testResourceCount(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener()); - $loader->resource(...[ - __DIR__ . '/../Fixtures/Annotation/Route/Valid', - __DIR__ . '/../Fixtures/Annotation/Route/Containerable', - __DIR__ . '/../Fixtures/Annotation/Route/Attribute', - __DIR__ . '/../Fixtures/Annotation/Route/Abstracts', // Abstract should be excluded - ]); - - $collection = new RouteCollection(); - $collection->populate($loader->load(Annotation\Listener::class)); - - $this->assertCount(26, $collection->getRoutes()); - } - - /** - * @requires OS WIN32|WINNT|Linux - * @runInSeparateProcess - */ - public function testLoadingWithAnnotationBuildWithPrefix(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener($collection = new RouteCollection(), 'annotated')); - $loader->resource(__DIR__ . '/../Fixtures/Annotation/Route/Valid'); - - $loader->load(); - - $routes = $collection->getRoutes(); - \uasort($routes, static function (Route $a, Route $b): int { - return \strcmp($a->getName(), $b->getName()); - }); - - $routes = Fixtures\Helper::routesToArray($routes); - - $this->assertEquals([ - 'name' => 'GET_POST_annotated_default', - 'path' => '/default', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\DefaultNameController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[3]); - - $this->assertEquals([ - 'name' => 'GET_HEAD_annotated_get', - 'path' => '/get', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => [Fixtures\Annotation\Route\Valid\MultipleMethodRouteController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[0]); - - $this->assertEquals([ - 'name' => 'GET_HEAD_annotated_get_1', - 'path' => '/get', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => [Fixtures\Annotation\Route\Valid\DefaultNameController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[1]); - - $this->assertEquals([ - 'name' => 'POST_annotated_post', - 'path' => '/post', - 'hosts' => [], - 'methods' => [Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\MultipleMethodRouteController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[4]); - - $this->assertEquals([ - 'name' => 'PUT_annotated_put', - 'path' => '/put', - 'hosts' => [], - 'methods' => [Router::METHOD_PUT], - 'handler' => [Fixtures\Annotation\Route\Valid\MultipleMethodRouteController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[5]); - - $this->assertEquals([ - 'name' => 'class_group@GET_HEAD_CONNECT_annotated_get', - 'path' => '/get', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD, Router::METHOD_CONNECT], - 'handler' => [Fixtures\Annotation\Route\Valid\ClassGroupWithoutPath::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[7]); - - $this->assertEquals([ - 'name' => 'class_group@POST_CONNECT_annotated_post', - 'path' => '/post', - 'hosts' => [], - 'methods' => [Router::METHOD_POST, Router::METHOD_CONNECT], - 'handler' => [Fixtures\Annotation\Route\Valid\ClassGroupWithoutPath::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[8]); - - $this->assertEquals([ - 'name' => 'class_group@PUT_CONNECT_annotated_put', - 'path' => '/put', - 'hosts' => [], - 'methods' => [Router::METHOD_PUT, Router::METHOD_CONNECT], - 'handler' => [Fixtures\Annotation\Route\Valid\ClassGroupWithoutPath::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[9]); - - $this->assertEquals([ - 'name' => 'GET_HEAD_annotated_testing_', - 'path' => '/testing/', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => [Fixtures\Annotation\Route\Valid\MethodOnRoutePattern::class, 'handleSomething'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[2]); - - $this->assertEquals([ - 'name' => 'english_locale', - 'path' => '/en/locale', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => [Fixtures\Annotation\Route\Valid\MultipleClassRouteController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[12]); - - $this->assertEquals([ - 'name' => 'french_locale', - 'path' => '/fr/locale', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => [Fixtures\Annotation\Route\Valid\MultipleClassRouteController::class, 'default'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[13]); - - $this->assertEquals([ - 'name' => 'action', - 'path' => '/{default}/path', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\DefaultValueController::class, 'action'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[6]); - - $this->assertEquals([ - 'name' => 'hello_without_default', - 'path' => '/hello/{name:\w+}', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\DefaultValueController::class, 'hello'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[15]); - - $this->assertEquals([ - 'name' => 'hello_with_default', - 'path' => '/cool/{name=}', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\DefaultValueController::class, 'hello'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => ['name' => '\w+'], - 'arguments' => [], - ], $routes[14]); - - $this->assertEquals([ - 'name' => 'home', - 'path' => '/', - 'hosts' => ['biurad.com'], - 'methods' => [Router::METHOD_HEAD, Router::METHOD_GET], - 'handler' => Fixtures\Annotation\Route\Valid\HomeRequestHandler::class, - 'schemes' => ['https'], - 'defaults' => [ - 'foo' => 'bar', - 'bar' => 'baz', - ], - 'patterns' => [], - 'arguments' => [], - ], $routes[16]); - - $this->assertEquals([ - 'name' => 'lol', - 'path' => '/here', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => Fixtures\Annotation\Route\Valid\InvokableController::class, - 'schemes' => ['https'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => ['hello' => 'world'], - ], $routes[17]); - - $this->assertEquals([ - 'name' => 'ping', - 'path' => '/ping', - 'hosts' => [], - 'methods' => [Router::METHOD_HEAD, Router::METHOD_GET], - 'handler' => Fixtures\Annotation\Route\Valid\PingRequestHandler::class, - 'schemes' => [], - 'defaults' => [ - 'foo' => 'bar', - 'bar' => 'baz', - ], - 'patterns' => [], - 'arguments' => [], - ], $routes[19]); - - $this->assertEquals([ - 'name' => 'method_not_array', - 'path' => '/method_not_array', - 'hosts' => [], - 'methods' => [Router::METHOD_GET], - 'handler' => Fixtures\Annotation\Route\Valid\MethodsNotArray::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[18]); - - $this->assertEquals([ - 'name' => 'do.action', - 'path' => '/prefix/path', - 'hosts' => ['biurad.com'], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\RouteWithPrefixController::class, 'action'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[10]); - - $this->assertEquals([ - 'name' => 'do.action_two', - 'path' => '/prefix/path_two', - 'hosts' => ['biurad.com'], - 'methods' => [Router::METHOD_GET, Router::METHOD_POST], - 'handler' => [Fixtures\Annotation\Route\Valid\RouteWithPrefixController::class, 'actionTwo'], - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[11]); - - $this->assertEquals([ - 'name' => 'sub-dir:foo', - 'path' => '/sub-dir/foo', - 'hosts' => [], - 'methods' => [Router::METHOD_HEAD, Router::METHOD_GET], - 'handler' => Fixtures\Annotation\Route\Valid\Subdir\FooRequestHandler::class, - 'schemes' => [], - 'defaults' => [ - 'foo' => 'bar', - 'bar' => 'baz', - ], - 'patterns' => [], - 'arguments' => [], - ], $routes[21]); - - $this->assertEquals([ - 'name' => 'sub-dir:bar', - 'path' => '/sub-dir/bar', - 'hosts' => [], - 'methods' => [Router::METHOD_HEAD, Router::METHOD_GET], - 'handler' => Fixtures\Annotation\Route\Valid\Subdir\BarRequestHandler::class, - 'schemes' => [], - 'defaults' => [ - 'foo' => 'bar', - 'bar' => 'baz', - ], - 'patterns' => [], - 'arguments' => [], - ], $routes[20]); - - $this->assertEquals([ - 'name' => 'user__restful', - 'path' => '/user/{id:\d+}', - 'hosts' => [], - 'methods' => [], - 'handler' => ResourceHandler::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[22]); - } - - /** - * @runInSeparateProcess - */ - public function testLoadAttribute(): void - { - $loader = new AnnotationLoader(new AttributeReader()); - $loader->listener(new Annotation\Listener()); - $loader->resource(__DIR__ . '/../Fixtures/Annotation/Route/Attribute'); - - $router = Router::withCollection($loader->load(Annotation\Listener::class)); - $routes = $router->getMatcher()->getRoutes(); - - $this->assertEquals([ - [ - 'name' => 'attribute_specific_name', - 'path' => '/defaults/{locale}/specific-name', - 'hosts' => [], - 'methods' => [Router::METHOD_GET], - 'handler' => [Fixtures\Annotation\Route\Attribute\GlobalDefaultsClass::class, 'withName'], - 'schemes' => [], - 'defaults' => ['foo' => 'bar'], - 'patterns' => ['locale' => 'en|fr'], - 'arguments' => [], - ], - [ - 'name' => 'attribute_GET_HEAD_defaults_locale_specific_none', - 'path' => '/defaults/{locale}/specific-none', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => [Fixtures\Annotation\Route\Attribute\GlobalDefaultsClass::class, 'noName'], - 'schemes' => [], - 'defaults' => ['foo' => 'bar'], - 'patterns' => ['locale' => 'en|fr'], - 'arguments' => [], - ], - ], Fixtures\Helper::routesToArray($routes)); - } - - public function testInvalidPath(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener()); - $loader->resource(Fixtures\Annotation\Route\Invalid\PathEmpty::class); - - $this->expectExceptionObject(new UriHandlerException('The route pattern "//localhost" is invalid as route path must be present in pattern.')); - $loader->load(); - } - - public function testClassGroupWithResource(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener()); - $loader->resource(Fixtures\Annotation\Route\Invalid\ClassGroupWithResource::class); - - $this->expectExceptionObject(new InvalidAnnotationException('Restful annotated class cannot contain annotated method(s).')); - $loader->load(); - } - - public function testMethodWithResource(): void - { - $loader = $this->createLoader(); - $loader->listener(new Annotation\Listener()); - $loader->resource(Fixtures\Annotation\Route\Invalid\MethodWithResource::class); - - $this->expectExceptionObject(new InvalidAnnotationException('Restful annotation is only supported on classes.')); - $loader->load(); - } - - protected function createLoader(): AnnotationLoader - { - return new AnnotationLoader(new MergeReader([new AnnotationReader(), new AttributeReader()])); - } -} diff --git a/tests/Annotation/RouteTest.php b/tests/Annotation/RouteTest.php deleted file mode 100644 index 48c61f41..00000000 --- a/tests/Annotation/RouteTest.php +++ /dev/null @@ -1,66 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Annotation; - -use Flight\Routing\Annotation\Route; -use Flight\Routing\Exceptions\UriHandlerException; -use Flight\Routing\Tests\Fixtures\Helper; -use PHPUnit\Framework\TestCase; - -/** - * RouteTest. - */ -class RouteTest extends TestCase -{ - public function testConstructor(): void - { - $params = [ - 'name' => 'foo', - 'path' => '/foo', - 'methods' => ['GET'], - ]; - - $route = new Route($params['path'], $params['name'], $params['methods']); - - $this->assertSame($params['name'], $route->name); - $this->assertSame($params['path'], $route->path); - $this->assertSame($params['methods'], $route->methods); - - // default property values... - $this->assertSame([], $route->defaults); - $this->assertSame([], $route->patterns); - $this->assertSame([], $route->schemes); - $this->assertSame([], $route->hosts); - } - - public function testExportToRoute(): void - { - $params = [ - 'name' => 'foo', - 'path' => '/foo', - 'methods' => ['GET'], - ]; - - $route = new Route($params['path'], $params['name'], $params['methods']); - $routeData = Helper::routesToArray([$route->getRoute(null)], true); - - $this->assertEquals($params['name'], $routeData['name']); - $this->assertEquals($params['path'], $routeData['path']); - $this->assertEquals($params['methods'], $routeData['methods']); - } -} diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php deleted file mode 100644 index 5b2ee8de..00000000 --- a/tests/BaseTestCase.php +++ /dev/null @@ -1,70 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests; - -use Flight\Routing\Handlers\RouteHandler; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7\Response; -use Nyholm\Psr7\ServerRequest; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\UriFactoryInterface; - -class BaseTestCase extends TestCase -{ - /** - * @return ResponseFactoryInterface - */ - protected function getResponseFactory(ResponseInterface $response = null) - { - $responseFactory = $this->createMock(ResponseFactoryInterface::class); - $responseFactory->method('CreateResponse') - ->willReturn($response ?? new Response()); - - return $responseFactory; - } - - protected function getUriFactory(): UriFactoryInterface - { - return new Psr17Factory(); - } - - /** - * @param string $method — HTTP method - * @param string|UriInterface $uri — URI - * @param array $headers — Request headers - * @param resource|StreamInterface|string|null $body — Request body - * - * @return ServerRequestFactoryInterface - */ - protected function getServerRequestFactory(string $method, $uri, $headers = [], $body = null) - { - $serverRequestFactory = $this->createMock(ServerRequestFactoryInterface::class); - $serverRequestFactory->method('createServerRequest') - ->willReturn(new ServerRequest($method, $uri, $headers, $body)); - - return $serverRequestFactory; - } - - protected function getRequestHandler(ResponseInterface $response = null): RouteHandler - { - return new RouteHandler($this->getResponseFactory($response)); - } -} diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php new file mode 100644 index 00000000..58864dd3 --- /dev/null +++ b/tests/CompilerTest.php @@ -0,0 +1,329 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException}; +use Flight\Routing\RouteUri as GeneratedUri; +use PHPUnit\Framework as t; + +dataset('patterns', [ + [ + '/foo', + '{^/foo$}', + [], + ['/foo'], + ], + [ + '/foo/', + '{^/foo/?$}', + [], + ['/foo', '/foo/'], + ], + [ + '/foo/{bar}', + '{^/foo/(?P[^\/]+)$}', + ['bar' => null], + ['/foo/bar', '/foo/baz'], + ], + [ + '/foo/{bar}@', + '{^/foo/(?P[^\/]+)@$}', + ['bar' => null], + ['/foo/bar@', '/foo/baz@'], + ], + [ + '/foo-{bar}', + '{^/foo-(?P[^\/]+)$}', + ['bar' => null], + ['/foo-bar', '/foo-baz'], + ], + [ + '/foo/{bar}/{baz}/', + '{^/foo/(?P[^\/]+)/(?P[^\/]+)/?$}', + ['bar' => null, 'baz' => null], + ['/foo/bar/baz', '/foo/bar/baz/'], + ], + [ + '/foo/{bar:\d+}', + '{^/foo/(?P\d+)$}', + ['bar' => null], + ['/foo/123', '/foo/444'], + ], + [ + '/foo/{bar:\d+}/{baz}/', + '{^/foo/(?P\d+)/(?P[^\/]+)/?$}', + ['bar' => null, 'baz' => null], + ['/foo/123/baz', '/foo/123/baz/'], + ], + [ + '/foo/{bar:\d+}/{baz:slug}', + '{^/foo/(?P\d+)/(?P[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)$}', + ['bar' => null, 'baz' => null], + ['/foo/123/foo', '/foo/44/baz'], + ], + [ + '/foo/{bar=0}', + '{^/foo/(?P[^\/]+)$}', + ['bar' => '0'], + ['/foo/0'], + ], + [ + '/foo/{bar=baz}/{baz}/', + '{^/foo/(?P[^\/]+)/(?P[^\/]+)/?$}', + ['bar' => 'baz', 'baz' => null], + ['/foo/baz/baz', '/foo/baz/baz/'], + ], + [ + '/[{foo}]', + '{^/?(?:(?P[^\/]+))?$}', + ['foo' => null], + ['/', '/foo', '/bar'], + ], + [ + '/[{bar:(foo|bar)}]', + '{^/?(?:(?P(foo|bar)))?$}', + ['bar' => null], + ['/', '/foo', '/bar'], + ], + [ + '/foo[/{bar}]/', + '{^/foo(?:/(?P[^\/]+))?/?$}', + ['bar' => null], + ['/foo', '/foo/', '/foo/bar', '/foo/bar/'], + ], + [ + '/[{foo:upper}]/[{bar:lower}]', + '{^/?(?:(?P[A-Z]+))?/?(?:(?P[a-z]+))?$}', + ['foo' => null, 'bar' => null], + ['/', '/FOO', '/FOO/', '/FOO/bar', '/bar'], + ], + [ + '/[{foo}][/{bar:month}]', + '{^/?(?:(?P[^\/]+))?(?:/(?P0[1-9]|1[012]+))?$}', + ['foo' => null, 'bar' => null], + ['/', '/foo', '/bar', '/foo/12', '/foo/01'], + ], + [ + '/[{foo:lower}/[{bar:upper}]]', + '{^/?(?:(?P[a-z]+)/?(?:(?P[A-Z]+))?)?$}', + ['foo' => null, 'bar' => null], + ['/', '/foo', '/foo/', '/foo/BAR', '/foo/BAZ'], + ], + [ + '/[{foo}/{bar}]', + '{^/?(?:(?P[^\/]+)/(?P[^\/]+))?$}', + ['foo' => null, 'bar' => null], + ['/', '/foo/bar', '/foo/baz'], + ], + [ + '/who{are}you', + '{^/who(?P[^\/]+)you$}', + ['are' => null], + ['/whoareyou', '/whoisyou'], + ], + [ + '/[{lang:[a-z]{2}}/]hello', + '{^/?(?:(?P[a-z]{2})/)?hello$}', + ['lang' => null], + ['/hello', '/en/hello', '/fr/hello'], + ], + [ + '/[{lang:[\w+\-]+=english}/]hello', + '{^/?(?:(?P[\w+\-]+)/)?hello$}', + ['lang' => 'english'], + ['/hello', '/en/hello', '/fr/hello'], + ], + [ + '/[{lang:[a-z]{2}}[-{sublang}]/]{name}[/page-{page=0}]', + '{^/?(?:(?P[a-z]{2})(?:-(?P[^\/]+))?/)?(?P[^\/]+)(?:/page-(?P[^\/]+))?$}', + ['lang' => null, 'sublang' => null, 'name' => null, 'page' => '0'], + ['/hello', '/en/hello', '/en-us/hello', '/en-us/hello/page-1', '/en-us/hello/page-2'], + ], + [ + '/hello/{foo:[a-z]{3}=bar}{baz}/[{id:[0-9a-fA-F]{1,8}}[.{format:html|php}]]', + '{^/hello/(?P[a-z]{3})(?P[^\/]+)/?(?:(?P[0-9a-fA-F]{1,8})(?:\.(?Phtml|php))?)?$}', + ['foo' => 'bar', 'baz' => null, 'id' => null, 'format' => null], + ['/hello/barbaz', '/hello/barbaz/', '/hello/barbaz/1', '/hello/barbaz/1.html', '/hello/barbaz/1.php'], + ], + [ + '/hello/{foo:\w{3}}{bar=bar1}/world/[{name:[A-Za-z]+}[/{page:int=1}[/{baz:year}]]]/abs.{format:html|php}', + '{^/hello/(?P\w{3})(?P[^\/]+)/world/?(?:(?P[A-Za-z]+)(?:/(?P[0-9]+)(?:/(?P[0-9]{4}))?)?)?/abs\.(?Phtml|php)$}', + ['foo' => null, 'bar' => 'bar1', 'name' => null, 'page' => '1', 'baz' => null, 'format' => null], + [ + '/hello/foobar/world/abs.html', + '/hello/barfoo/world/divine/abs.php', + '/hello/foobaz/world/abs.php', + '/hello/bar100/world/divine/11/abs.html', + '/hello/true/world/divine/11/2022/abs.html' + ], + ], + [ + '{foo}.example.com', + '{^(?P[^\/]+)\.example\.com$}', + ['foo' => null], + ['foo.example.com', 'bar.example.com'], + ], + [ + '{locale}.example.{tld}', + '{^(?P[^\/]+)\.example\.(?P[^\/]+)$}', + ['locale' => null, 'tld' => null], + ['en.example.com', 'en.example.org', 'en.example.co.uk'], + ], + [ + '[{lang:[a-z]{2}}.]example.com', + '{^(?:(?P[a-z]{2})\.)?example\.com$}', + ['lang' => null], + ['en.example.com', 'example.com', 'fr.example.com'], + ], + [ + '[{lang:[a-z]{2}}.]example.{tld=com}', + '{^(?:(?P[a-z]{2})\.)?example\.(?P[^\/]+)$}', + ['lang' => null, 'tld' => 'com'], + ['en.example.com', 'example.com', 'fr.example.gh'], + ], + [ + '{id:int}.example.com', + '{^(?P[0-9]+)\.example\.com$}', + ['id' => null], + ['1.example.com', '2.example.com'], + ], +]); + +dataset('reversed', [ + [ + '/foo', + ['/foo' => []], + ], + [ + '/foo/{bar}', + ['/foo/bar' => ['bar' => 'bar']], + ], + [ + '/foo/{bar}/{baz}', + ['/foo/bar/baz' => ['bar' => 'bar', 1 => 'baz']], + ], + [ + '/divine/{id:\d+}[/{a}{b}[{c}]][.p[{d}]]', + [ + '/divine/23' => ['id' => '23'], + '/divine/23/ab' => ['23', 'a' => 'a', 'b' => 'b'], + '/divine/23/abc' => ['23', 'a' => 'a', 'b' => 'b', 'c' => 'c'], + '/divine/23/abc.php' => ['23', 'a' => 'a', 'b' => 'b', 'c' => 'c', 'd' => 'hp'], + '/divine/23.phtml' => ['id' => '23', 'd' => 'html'], + ] + ] +]); + +test('if route path is a valid regex', function (string $path, string $regex, array $vars, array $matches): void { + $compiler = new \Flight\Routing\RouteCompiler(); + [$pathRegex, $pathVar] = $compiler->compile($path); + + t\assertEquals($regex, $pathRegex); + t\assertSame($vars, $pathVar); + + // Match every pattern... + foreach ($matches as $match) { + t\assertMatchesRegularExpression($pathRegex, $match); + } +})->with('patterns'); + +test('if compiled route path is reversible', function (string $path, array $matches): void { + $compiler = new \Flight\Routing\RouteCompiler(); + + foreach ($matches as $match => $params) { + t\assertEquals($match, (string) $compiler->generateUri(['path' => $path], $params)); + } +})->with('reversed'); + +test('if route path placeholder is characters length is invalid', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->compile('/{sfkdfglrjfdgrfhgklfhgjhfdjghrtnhrnktgrelkrngldrjhglhkjdfhgkj}'); +})->throws( + UriHandlerException::class, + \sprintf( + 'Variable name "%s" cannot be longer than 32 characters in route pattern "/{%1$s}".', + 'sfkdfglrjfdgrfhgklfhgjhfdjghrtnhrnktgrelkrngldrjhglhkjdfhgkj' + ) +); + +test('if route path placeholder begins with a number', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->compile('/{1foo}'); +})->throws( + UriHandlerException::class, + 'Variable name "1foo" cannot start with a digit in route pattern "/{1foo}". Use a different name.' +); + +test('if route path placeholder is used more than once', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->compile('/{foo}/{foo}'); +})->throws( + UriHandlerException::class, + 'Route pattern "/{foo}/{foo}" cannot reference variable name "foo" more than once.' +); + +test('if route path placeholder has regex values', function (string $path, array $segment): void { + $compiler = new \Flight\Routing\RouteCompiler(); + [$pathRegex,] = $compiler->compile($path, $segment); + t\assertMatchesRegularExpression($pathRegex, '/a'); + t\assertMatchesRegularExpression($pathRegex, '/b'); +})->with([ + ['/{foo}', ['foo' => ['a', 'b']]], + ['/{foo}', ['foo' => 'a|b']], +]); + +test('if route path placeholder requirement is empty', function (string $assert): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->compile('/{foo}', ['foo' => $assert]); +})->with(['', '^$', '^', '$', '\A\z', '\A', '\z'])->throws( + UriHandlerException::class, + 'Routing requirement for "foo" cannot be empty.' +); + +test('if reversed generated route path can contain http scheme and host', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $_SERVER['HTTP_HOST'] = 'example.com'; + $route = ['path' => '/{foo}', 'schemes' => ['http' => true]]; + + t\assertEquals('/a', (string) $compiler->generateUri($route, ['foo' => 'a'])); + t\assertEquals('./b', (string) $compiler->generateUri($route, ['foo' => 'b'], GeneratedUri::RELATIVE_PATH)); + t\assertEquals('//example.com/c', (string) $compiler->generateUri($route, ['c'], GeneratedUri::NETWORK_PATH)); + t\assertEquals('http://example.com/d', (string) $compiler->generateUri($route, [0 => 'd'], GeneratedUri::ABSOLUTE_URL)); + t\assertEquals('http://localhost/e', (string) $compiler->generateUri( + $route += ['hosts' => ['localhost' => true]], + ['foo' => 'e'], + GeneratedUri::ABSOLUTE_URL + )); +}); + +test('if reversed generated route fails to certain placeholders', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->generateUri(['path' => '/{foo:int}'], ['foo' => 'a']); +})->throws( + UriHandlerException::class, + 'Expected route path "/" placeholder "foo" value "a" to match "[0-9]+".' +); + +test('if reversed generate route is missing required placeholders', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->generateUri(['path' => '/{foo}'], []); +})->throws( + UrlGenerationException::class, + 'Some mandatory parameters are missing ("foo") to generate a URL for route path "/".' +); + +test('if reversed generate route can contain a negative port port', function (): void { + $compiler = new \Flight\Routing\RouteCompiler(); + $compiler->generateUri(['path' => '/{foo}'], ['flight-routing'])->withPort(-9); +})->throws(UrlGenerationException::class, 'Invalid port: -9. Must be between 0 and 65535'); diff --git a/tests/Fixtures/Annotation/Route/Invalid/PathEmpty.php b/tests/Fixtures/Annotation/Route/Invalid/PathEmpty.php index 1d8e915e..43c1e190 100644 --- a/tests/Fixtures/Annotation/Route/Invalid/PathEmpty.php +++ b/tests/Fixtures/Annotation/Route/Invalid/PathEmpty.php @@ -23,7 +23,7 @@ /** * @Route( * name="home", - * path="//localhost", + * path="", * methods={"GET"} * ) */ diff --git a/tests/Fixtures/Annotation/Route/Valid/InvokableController.php b/tests/Fixtures/Annotation/Route/Valid/InvokableController.php index ab0e2564..62e6ee9c 100644 --- a/tests/Fixtures/Annotation/Route/Valid/InvokableController.php +++ b/tests/Fixtures/Annotation/Route/Valid/InvokableController.php @@ -20,7 +20,7 @@ use Flight\Routing\Annotation\Route; /** - * @Route("/here", "lol", methods={"GET", "POST"}, schemes={"https"}, attributes={"hello": "world"}) + * @Route("/here", "lol", methods={"GET", "POST"}, schemes={"https"}, arguments={"hello": "world"}) */ class InvokableController { diff --git a/tests/Fixtures/BlankController.php b/tests/Fixtures/BlankController.php deleted file mode 100644 index 2b780feb..00000000 --- a/tests/Fixtures/BlankController.php +++ /dev/null @@ -1,74 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Nyholm\Psr7\Factory\Psr17Factory; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - -/** - * BlankController. - */ -class BlankController -{ - /** - * @var bool - */ - private $isRunned = false; - - /** - * @var array - */ - private $attributes = []; - - public function isRunned(): bool - { - return $this->isRunned; - } - - /** - * @return array - */ - public function getAttributes(): array - { - return $this->attributes; - } - - /** - * @param mixed $key - * - * @return mixed - */ - public function getAttribute($key) - { - return $this->attributes[$key] ?? null; - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $this->isRunned = true; - $this->attributes = $request->getAttributes(); - - return (new Psr17Factory())->createResponse(); - } - - public static function process(ServerRequestInterface $request): ResponseInterface - { - return (new Psr17Factory())->createResponse(); - } -} diff --git a/tests/Fixtures/BlankMiddlewarableRequestHandler.php b/tests/Fixtures/BlankMiddlewarableRequestHandler.php deleted file mode 100644 index 60acd6af..00000000 --- a/tests/Fixtures/BlankMiddlewarableRequestHandler.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Nyholm\Psr7\Factory\Psr17Factory; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * BlankMiddlewarableRequestHandler. - */ -class BlankMiddlewarableRequestHandler implements MiddlewareInterface, RequestHandlerInterface -{ - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - return $handler->handle($request); - } - - /** - * {@inheritdoc} - */ - public function handle(ServerRequestInterface $request): ResponseInterface - { - return (new Psr17Factory())->createResponse(); - } -} diff --git a/tests/Fixtures/BlankMiddleware.php b/tests/Fixtures/BlankMiddleware.php deleted file mode 100644 index 1074f634..00000000 --- a/tests/Fixtures/BlankMiddleware.php +++ /dev/null @@ -1,80 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Nyholm\Psr7\Factory\Psr17Factory; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * BlankMiddleware. - */ -class BlankMiddleware implements MiddlewareInterface -{ - /** - * @var bool - */ - private $isBroken; - - /** - * @var bool - */ - private $isRunned = false; - - public function __construct(bool $isBroken = false) - { - $this->isBroken = $isBroken; - } - - public function getHash(): string - { - return \spl_object_hash($this); - } - - public function isBroken(): bool - { - return $this->isBroken; - } - - public function isRunned(): bool - { - return $this->isRunned; - } - - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $this->isRunned = true; - - if ($this->isBroken) { - return (new Psr17Factory())->createResponse(); - } - - $response = $handler->handle($request); - - if (\array_key_exists('Broken', $request->getServerParams())) { - $response = $response->withHeader('Middleware-Broken', 'broken'); - } - - return $response->withHeader('Middleware', 'test'); - } -} diff --git a/tests/Fixtures/BlankRequestHandler.php b/tests/Fixtures/BlankRequestHandler.php index b0e40181..97d834b9 100644 --- a/tests/Fixtures/BlankRequestHandler.php +++ b/tests/Fixtures/BlankRequestHandler.php @@ -27,24 +27,30 @@ */ class BlankRequestHandler implements RequestHandlerInterface { - /** - * @var bool - */ - private $isRunned = false; + private bool $isDone = false; /** - * @var array + * @param array $attributes */ - private $attributes; - - public function __construct(array $attributes = []) + public function __construct(private array $attributes = []) { $this->attributes = $attributes; } - public function isRunned(): bool + public static function __set_state(array $properties): static + { + $new = new static(); + + foreach ($properties as $property => $value) { + $new->{$property} = $value; + } + + return $new; + } + + public function isDone(): bool { - return $this->isRunned; + return $this->isDone; } /** @@ -56,11 +62,8 @@ public function getAttributes(): array } /** - * @param mixed $key - * - * @return mixed */ - public function getAttribute($key) + public function getAttribute(string $key): mixed { return $this->attributes[$key] ?? null; } @@ -70,9 +73,12 @@ public function getAttribute($key) */ public function handle(ServerRequestInterface $request): ResponseInterface { - $this->isRunned = true; $this->attributes = $request->getAttributes(); - return (new Psr17Factory())->createResponse(); + try { + return (new Psr17Factory())->createResponse(); + } finally { + $this->isDone = true; + } } } diff --git a/tests/Fixtures/Helper.php b/tests/Fixtures/Helper.php deleted file mode 100644 index 6a7481a4..00000000 --- a/tests/Fixtures/Helper.php +++ /dev/null @@ -1,78 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Flight\Routing\Route; - -/** - * Helper. - */ -class Helper -{ - /** - * @param iterable $routes - * - * @return array>|array - */ - public static function routesToArray(iterable $routes, bool $first = false): array - { - $result = []; - - foreach ($routes as $route) { - $item = []; - $item['name'] = $route->getName(); - $item['path'] = $route->getPath(); - $item['methods'] = $route->getMethods(); - $item['schemes'] = $route->getSchemes(); - $item['hosts'] = $route->getHosts(); - - if (\is_object($handler = $route->getHandler())) { - $handler = \get_class($handler); - } - - $item['handler'] = $handler; - $item['arguments'] = $route->getArguments(); - $item['patterns'] = $route->getPatterns(); - $item['defaults'] = $route->getDefaults(); - - if ($first) { - return $item; - } - - $result[] = $item; - } - - return $result; - } - - /** - * @param iterable $routes - * - * @return string[] - */ - public static function routesToNames(iterable $routes): array - { - $result = []; - - foreach ($routes as $route) { - $result[] = $route->getName(); - } - - return $result; - } -} diff --git a/tests/Fixtures/InvokeController.php b/tests/Fixtures/InvokeController.php deleted file mode 100644 index 0ad6d17a..00000000 --- a/tests/Fixtures/InvokeController.php +++ /dev/null @@ -1,35 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - -/** - * InvokeController. - */ -class InvokeController extends BlankController -{ - /** - * @link https://www.php.net/manual/en/language.oop5.magic.php#object.invoke - */ - public function __invoke(ServerRequestInterface $request): ResponseInterface - { - return $this->handle($request); - } -} diff --git a/tests/Fixtures/NamedBlankMiddleware.php b/tests/Fixtures/NamedBlankMiddleware.php deleted file mode 100644 index 2177f1b6..00000000 --- a/tests/Fixtures/NamedBlankMiddleware.php +++ /dev/null @@ -1,52 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * NamedBlankMiddleware. - */ -class NamedBlankMiddleware implements MiddlewareInterface -{ - /** - * @var string - */ - private $name; - - public function __construct(string $name) - { - $this->name = $name; - } - - public function getName(): string - { - return $this->name; - } - - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - return $handler->handle($request); - } -} diff --git a/tests/Fixtures/PhpInfoListener.php b/tests/Fixtures/PhpInfoListener.php deleted file mode 100644 index 571f44fb..00000000 --- a/tests/Fixtures/PhpInfoListener.php +++ /dev/null @@ -1,44 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Flight\Routing\Route; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\MiddlewareInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * PhpInfoListener. - */ -class PhpInfoListener implements MiddlewareInterface -{ - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $route = $request->getAttribute(Route::class); - - if ($route instanceof Route && 'phpinfo' === $route->getHandler()) { - $request = $request->withAttribute(Route::class, $route->argument('what', \INFO_ALL)); - } - - return $handler->handle($request); - } -} diff --git a/tests/Fixtures/RouteMiddleware.php b/tests/Fixtures/RouteMiddleware.php deleted file mode 100644 index 6aaa7b21..00000000 --- a/tests/Fixtures/RouteMiddleware.php +++ /dev/null @@ -1,44 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Fixtures; - -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Server\MiddlewareInterface; - -class RouteMiddleware implements MiddlewareInterface -{ - /** @var string */ - private $sampleText; - - public function __construct(string $sampleText = 'test') - { - $this->sampleText = $sampleText; - } - - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $response = $handler->handle($request); - - return $response->withHeader('NamedRoute', $this->sampleText); - } -} diff --git a/tests/Fixtures/style.css b/tests/Fixtures/style.css new file mode 100644 index 00000000..a9f8e416 --- /dev/null +++ b/tests/Fixtures/style.css @@ -0,0 +1,9 @@ +body { + padding-top: 10px; +} +svg text { + font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif; + font-size: 11px; + color: #666; + fill: #666; +} diff --git a/tests/Fixtures/template.html b/tests/Fixtures/template.html new file mode 100644 index 00000000..3b4e6126 --- /dev/null +++ b/tests/Fixtures/template.html @@ -0,0 +1,11 @@ + + + + + + + +

Hello World from a template file

+ + + diff --git a/tests/HandlersTest.php b/tests/HandlersTest.php new file mode 100644 index 00000000..796969e2 --- /dev/null +++ b/tests/HandlersTest.php @@ -0,0 +1,378 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Flight\Routing\Exceptions\InvalidControllerException; +use Flight\Routing\Handlers\{CallbackHandler, FileHandler, ResourceHandler, RouteHandler, RouteInvoker}; +use Flight\Routing\RouteCollection; +use Flight\Routing\Router; +use Flight\Routing\Tests\Fixtures; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\Response; +use Nyholm\Psr7\ServerRequest; +use PHPUnit\Framework as t; +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +test('if route callback handler will return a response', function (): void { + $callback = new CallbackHandler(function (ServerRequestInterface $req): ResponseInterface { + ($res = new Response())->getBody()->write($req->getMethod()); + + return $res; + }); + t\assertSame('GET', (string) $callback->handle(new ServerRequest('GET', '/hello'))->getBody()); +}); + +test('if route resource handler does not contain valid data', function (): void { + new ResourceHandler(new ResourceHandler('phpinfo')); +})->throws( + InvalidControllerException::class, + 'Expected a class string or class object, got a type of "callable" instead' +); + +test('if route handler does return a plain valid response', function (): void { + $collection = new RouteCollection(); + $collection->add('/a', handler: fn (): ResponseInterface => new Response()); + $collection->add('/b', handler: fn (): string => 'Hello World'); + $handler = new RouteHandler(new Psr17Factory()); + + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, $collection->offsetGet(0)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/plain; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(204, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/b'))->withAttribute(Router::class, $collection->offsetGet(1)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/plain', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/c'))->withAttribute(Router::class, []); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/plain; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(204, $res->getStatusCode()); +}); + +test('if route handler does return a html valid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function (): string { + return <<<'HTML' + + +

Hello World

+ + HTML; + }]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/html', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler does return a xml valid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function (): string { + return <<<'XML' + + + + + + + XML; + }]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/xml; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); +test('if route handler does return a rss valid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function (): string { + return <<<'XML' + + + XML; + }]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('application/rss+xml; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler does return a svg valid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, [ + 'handler' => fn (): string => 'Hello World', + ]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('image/svg+xml', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler does return a csv valid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function (): string { + return <<<'CSV' + a,b,c + d,e,f + g,h,i + + CSV; + }]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/csv', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler does return a json valid response', function (): void { + $collection = new RouteCollection(); + $collection->add('/a', handler: fn () => \json_encode(['Hello', 'World', 'Cool' => 'Yeah', 2022])); + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, $collection->offsetGet(0)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('application/json', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler cannot detect content type', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function () { + return <<<'CSS' + body { + padding-top: 10px; + } + svg text { + font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif; + font-size: 11px; + color: #666; + fill: #666; + } + CSS; + }]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/plain', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler does return a echoed xml valid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function (): void { + echo <<<'XML' + + + XML; + }]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('application/rss+xml; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler can handle exception from route', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => function (): void { + throw new \RuntimeException('Testing error'); + }]); + $handler->handle($req); + $this->fail('Expected an invalid controller exception as route handler is invalid'); +})->throws(\RuntimeException::class, 'Testing error'); + +test('if route handler does return an invalid response', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => fn () => new \finfo()]); + $handler->handle($req); + $this->fail('Expected an invalid controller exception as route handler is invalid'); +})->throws( + InvalidControllerException::class, + 'The route handler\'s content is not a valid PSR7 response body stream.' +); + +test('if route handler is a request handler type', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $collection = new RouteCollection(); + $collection->add('/a', handler: new CallbackHandler(function (ServerRequestInterface $req): ResponseInterface { + $res = new Response(); + $method = $req->getMethod(); + $res->getBody()->write(<< + +

Hello World in {$method} REQUEST

+ + HTML); + + return $res; + })); + $collection->add('/b', handler: Fixtures\BlankRequestHandler::class); + + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, $collection->offsetGet(0)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertStringContainsString('GET REQUEST', (string) $res->getBody()); + t\assertSame('text/html', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/b'))->withAttribute(Router::class, $collection->offsetGet(1)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertEmpty((string) $res->getBody()); + t\assertSame('text/plain; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(204, $res->getStatusCode()); +}); + +test('if route handler is an array like type', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $collection = new RouteCollection(); + $collection->add('/a', handler: [Fixtures\BlankRequestHandler::class, 'handle']); + $collection->add('/b', handler: [$h = new Fixtures\BlankRequestHandler(), 'handle']); + $collection->add('/c', handler: fn (): array => [1, 2, 3, 4, 5]); + $collection->add('/d', handler: [1, 2, 3, 'Error']); + + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, $collection->offsetGet(0)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertEmpty((string) $res->getBody()); + t\assertSame('text/plain; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(204, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/b'))->withAttribute(Router::class, $collection->offsetGet(1)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertEmpty((string) $res->getBody()); + t\assertSame('text/plain; charset=utf-8', $res->getHeaderLine('Content-Type')); + t\assertSame(204, $res->getStatusCode()); + t\assertTrue($h->isDone()); + + $req = (new ServerRequest('GET', '/c'))->withAttribute(Router::class, $collection->offsetGet(2)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('[1,2,3,4,5]', (string) $res->getBody()); + t\assertSame('application/json', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/d'))->withAttribute(Router::class, $collection->offsetGet(3)); + $handler->handle($req); + $this->fail('Expected an invalid controller exception as route handler is invalid'); +})->throws(InvalidControllerException::class, 'Route has an invalid handler type of "array".'); + +test('if route handler is a stringable type', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, ['handler' => new \RuntimeException()]); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('RuntimeException', \substr((string) $res->getBody(), 0, 16)); + t\assertSame('text/plain', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +}); + +test('if route handler is a file handler type', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $collection = new RouteCollection(); + $collection->add('/a', handler: new FileHandler(__DIR__.'/../tests/Fixtures/template.html')); + $collection->add('/b', handler: fn () => new FileHandler(__DIR__.'/../tests/Fixtures/style.css')); + + $req = (new ServerRequest('GET', '/a'))->withAttribute(Router::class, $collection->offsetGet(0)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/html', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/b'))->withAttribute(Router::class, $collection->offsetGet(1)); + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('text/css', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); + + $req = (new ServerRequest('GET', '/b'))->withAttribute(Router::class, ['handler' => new FileHandler('hello')]); + $handler->handle($req); + $this->fail('Expected an invalid controller exception as route handler is invalid'); +})->throws(InvalidControllerException::class, 'Failed to fetch contents from file "hello"'); + +test('if route invoker can support a number of parameters binding', function (): void { + $handler = new RouteInvoker(); + $container = new RouteInvoker(new class () implements ContainerInterface { + public function get(string $id) + { + return match ($id) { + \Countable::class => new \ArrayObject(), + \Iterator::class => new \ArrayIterator([1, 2, 3]), + 'func' => fn (int $a): ContainerInterface => $this, + }; + } + + public function has(string $id): bool + { + return \Countable::class === $id || \Iterator::class === $id || 'func' === $id; + } + }); + $h0 = $handler(fn ($var): bool => true, []); + $h1 = $handler(fn (string $name = '', array $values = []): bool => true, []); + $h2 = $handler(fn (?string $a, string $b = 'iiv'): string => $a.$b, []); + $h3 = $handler(fn (string $a): array => \unpack('C*', $a), ['a' => '🚀']); + $h4 = $handler(fn (string $a, string $b, string $c): string => $a.$b.$c, ['a&b' => 'i', 'c' => 'v']); + $h5 = $handler(fn (int|string $a, int|bool $b = 3): string => $a.$b, ['a' => 1]); + $h6 = $handler(fn (int|string|null $a): string|bool => null == $a ? '13' : false, []); + $h7 = $handler(fn (string|RouteCollection $a, RouteCollection $b): bool => $a === $b, ['a&b' => new RouteCollection()]); + $h8 = $handler($u = fn (RouteCollection|\Countable $a): bool => $a instanceof \Countable, [RouteCollection::class => new RouteCollection()]); + $h9 = $container(fn (\Iterator $a): bool => [1, 2, 3] === \iterator_to_array($a), []); + $h10 = $container(fn (mixed $a): bool => '🚀' === $a, ['a' => \pack('C*', 0xF0, 0x9F, 0x9A, 0x80)]); + $h11 = $container(fn (?string $a, ?string $b): bool => $a === $b, []); + + t\assertSame($h0, $h1); + t\assertSame($h2, $h4); + t\assertSame($h5, $h6); + t\assertSame($h7, $h8); + t\assertSame($h9, $h10); + t\assertSame($h11, $container($u, [])); + t\assertSame([1 => 0xF0, 2 => 0x9F, 3 => 0x9A, 4 => 0x80], $h3); + t\assertSame($container('func', ['a' => 0]), $container->getContainer()); +}); + +test('if route invoker can execute handlers which is prefixed with a \\', function (): void { + $handler = new RouteInvoker(new class () implements ContainerInterface { + public function get(string $id) + { + return new Fixtures\BlankRequestHandler(); + } + + public function has(string $id): bool + { + return Fixtures\BlankRequestHandler::class === $id; + } + }); + t\assertSame('123', $handler('\\debugFormat', ['value' => 123])); + t\assertInstanceOf(ResponseInterface::class, $handler( + '\\'.Fixtures\BlankRequestHandler::class.'@handle', + [ServerRequestInterface::class => new ServerRequest('GET', '/a')] + )); +}); + +test('if route invoker can parse a resource handler with parameters', function (string $method): void { + $handler = new RouteHandler(new Psr17Factory()); + $resource = new ResourceHandler(new class () { + public function getHandler(string $method): string + { + return 'I am in a '.$method.' request'; + } + + public function postHandler(string $method): string + { + return 'I am in a '.$method.' request'; + } + }, 'handler'); + + $req = (new ServerRequest($method, '/a'))->withAttribute( + Router::class, + ['handler' => $resource, 'arguments' => \compact('method')] + ); + + if ('PUT' === $method) { + $this->expectExceptionObject(new InvalidControllerException('Method putHandler() for resource route ')); + } + + t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); + t\assertSame('I am in a '.$method.' request', (string) $res->getBody()); + t\assertSame('text/plain', $res->getHeaderLine('Content-Type')); + t\assertSame(200, $res->getStatusCode()); +})->with(['GET', 'POST', 'PUT']); diff --git a/tests/Matchers/SimpleRouteCompilerTest.php b/tests/Matchers/SimpleRouteCompilerTest.php deleted file mode 100644 index 1f67d742..00000000 --- a/tests/Matchers/SimpleRouteCompilerTest.php +++ /dev/null @@ -1,454 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Matchers; - -use Flight\Routing\Exceptions\UriHandlerException; -use Flight\Routing\Exceptions\UrlGenerationException; -use Flight\Routing\Generator\GeneratedUri; -use Flight\Routing\RouteCollection; -use Flight\Routing\RouteCompiler; -use Flight\Routing\Route; -use PHPUnit\Framework\TestCase; - -/** - * SimpleRouteCompilerTest. - */ -class SimpleRouteCompilerTest extends TestCase -{ - public function testSerialize(): void - { - $route = new Route('/prefix/{foo}', ['FOO', 'BAR']); - $route->default('foo', 'default') - ->assert('foo', '\d+'); - - $compiler = new RouteCompiler(); - $compiled = $compiler->compile($route); - - $serialized = \serialize($compiled); - $deserialized = \unserialize($serialized); - - $this->assertEquals($compiled, $deserialized); - $this->assertSame($compiled, $deserialized); - } - - /** - * @dataProvider provideCompilePathData - * - * @param string[] $matches - * @param array $variables - */ - public function testCompile(string $path, array $matches, string $regex, array $variables = []): void - { - $route = new Route($path, ['FOO', 'BAR']); - $compiler = new RouteCompiler(); - $compiled = $compiler->compile($route); - - $this->assertEquals($regex, $compiled[0]); - $this->assertEquals($variables, $compiled[2] + $route->getDefaults()); - - // Match every pattern... - foreach ($matches as $match) { - $this->assertMatchesRegularExpression($regex, $match); - } - } - - /** - * @dataProvider provideCompileHostData - * - * @param string[] $matches - * @param array $variables - */ - public function testCompileDomainRegex(string $path, array $matches, string $regex, array $variables = []): void - { - $route = new Route($path, ['FOO', 'BAR']); - $compiler = new RouteCompiler(); - $compiled = $compiler->compile($route); - - $this->assertEquals($regex, $compiled[1]); - $this->assertEquals($variables, $compiled[2] + $route->getDefaults()); - - // Match every pattern... - foreach ($matches as $match) { - $this->assertMatchesRegularExpression($regex, $match); - } - } - - /** - * @dataProvider getInvalidVariableName - */ - public function testCompileVariables(string $variable, string $exceptionMessage): void - { - $route = new Route('/{' . $variable . '}', ['FOO', 'BAR']); - $compiler = new RouteCompiler(); - - $this->expectExceptionMessage(\sprintf($exceptionMessage, $variable)); - $this->expectException(UriHandlerException::class); - - $compiler->compile($route); - } - - public function testCompilerOnRouteCollection(): void - { - $compiler = new RouteCompiler(); - $matches = []; - $actualCount = 0; - $routes = \array_map(static function (array $values) use (&$matches): Route { - $matches[$values[2]] = $values[1]; - - return new Route($values[0]); - }, \iterator_to_array($this->provideCompilePathData())); - - $collection = new RouteCollection(); - $collection->routes(\array_values($routes)); - - foreach ($collection->getRoutes() as $route) { - $compiledRoute = $compiler->compile($route); - - if (isset($matches[$compiledRoute[0]])) { - foreach ($matches[$compiledRoute[0]] as $match) { - if (1 === \preg_match($compiledRoute[0], '/' . \ltrim($match, '/'))) { - ++$actualCount; - } - } - } - } - - $this->assertEquals(62, $actualCount); - } - - public function testCompilerOnRouteCollectionWithSerialization(): void - { - $matches = []; - $actualCount = 0; - $routes = \array_map(static function (array $values) use (&$matches): Route { - foreach ($values[1] as $value) { - $matches[] = $value; - } - - return new Route($values[0]); - }, \iterator_to_array($this->provideCompilePathData())); - - $compiler = new RouteCompiler(); - $collection = new RouteCollection(); - $collection->routes($routes, true); - - [$staticList, $regexList] = $compiler->build($collection); - - foreach ($matches as $match) { - $match = '/' . \ltrim($match, '/'); - - if (isset($staticList[$match])) { - $matched = true; - } elseif (\preg_match($regexList, $match)) { - $matched = true; - } - - if ($matched) { - ++$actualCount; - } - } - - $this->assertEquals(62, $actualCount); - } - - /** - * @dataProvider getInvalidRequirements - */ - public function testSetInvalidRequirement(string $req): void - { - $this->expectErrorMessage('Routing requirement for "foo" cannot be empty.'); - $this->expectException(\InvalidArgumentException::class); - - $route = new Route('/{foo}', ['FOO', 'BAR']); - $route->assert('foo', $req); - - $compiler = new RouteCompiler(); - $compiler->compile($route); - } - - public function testSameMultipleVariable(): void - { - $this->expectErrorMessage('Route pattern "/{foo}{foo}" cannot reference variable name "foo" more than once.'); - $this->expectException(UriHandlerException::class); - - $route = new Route('/{foo}{foo}', ['FOO', 'BAR']); - - $compiler = new RouteCompiler(); - $compiler->compile($route); - } - - public function testGeneratedUriInstance(): void - { - $route1 = new Route('/{foo}'); - $route2 = new Route('/[{bar}]'); - $compiler = new RouteCompiler(); - - $this->assertEquals('/hello', (string) $compiler->generateUri($route1, ['foo' => 'hello'])); - $this->assertEquals('./', (string) $compiler->generateUri($route2, [], GeneratedUri::RELATIVE_PATH)); - $this->assertEquals('./hello', new GeneratedUri('hello', GeneratedUri::RELATIVE_PATH)); - } - - public function testGeneratedUriInstanceWithHostAndScheme(): void - { - $_SERVER['HTTP_HOST'] = 'localhost'; - $route1 = new Route('/{foo}'); - $route2 = Route::to('/[{bar}]')->scheme('https'); - $compiler = new RouteCompiler(); - - $generatedUri = $compiler->generateUri($route1, ['foo' => 'hello']); - $this->assertEquals('biurad.com/hello', (string) $generatedUri->withHost('biurad.com')); - - $this->assertEquals('https://localhost/', (string) $compiler->generateUri($route2, [])); - } - - public function testGeneratedUriWithMandatoryParameter(): void - { - $this->expectExceptionMessage('Some mandatory parameters are missing ("foo") to generate a URL for route path "/".'); - $this->expectException(UrlGenerationException::class); - - $route = new Route('/{foo}'); - (new RouteCompiler())->generateUri($route, []); - } - - /** - * @return string[] - */ - public function getInvalidVariableName(): array - { - return [ - [ - 'sfkdfglrjfdgrfhgklfhgjhfdjghrtnhrnktgrelkrngldrjhglhkjdfhgkj', - 'Variable name "%s" cannot be longer than 32 characters in route pattern "/{%1$s}".', - ], - [ - '2425', - 'Variable name "%s" cannot start with a digit in route pattern "/{%1$s}". Use a different name.', - ], - ]; - } - - /** - * @return string[] - */ - public function getInvalidRequirements(): array - { - return [ - [''], - ['^$'], - ['^'], - ['$'], - ['\A\z'], - ['\A'], - ['\z'], - ]; - } - - public function provideCompilePathData(): \Generator - { - yield 'Static route' => [ - '/foo', - ['/foo'], - '{^\/foo?$}u', - ]; - - yield 'Route with a variable' => [ - '/foo/{bar}', - ['/foo/bar'], - '{^\/foo\/(?P[^\/]+)?$}u', - ['bar' => null], - ]; - - yield 'Route with a variable that has a default value' => [ - '/foo/{bar=bar}', - ['/foo/bar'], - '{^\/foo\/(?P[^\/]+)?$}u', - ['bar' => 'bar'], - ]; - - yield 'Route with several variables' => [ - '/foo/{bar}/{foobar}', - ['/foo/bar/baz'], - '{^\/foo\/(?P[^\/]+)\/(?P[^\/]+)?$}u', - ['bar' => null, 'foobar' => null], - ]; - - yield 'Route with several variables that have default values' => [ - '/foo/{bar=bar}/{foobar=0}', - ['/foo/foobar/baz'], - '{^\/foo\/(?P[^\/]+)\/(?P[^\/]+)?$}u', - ['bar' => 'bar', 'foobar' => 0], - ]; - - yield 'Route with several variables but some of them have no default values' => [ - '/foo/{bar=bar}/{foobar}', - ['/foo/barfoo/baz'], - '{^\/foo\/(?P[^\/]+)\/(?P[^\/]+)?$}u', - ['bar' => 'bar', 'foobar' => null], - ]; - - yield 'Route with an optional variable as the first segment' => [ - '/[{bar}]', - ['/', 'bar', '/bar'], - '{^\/?(?:(?P[^\/]+))?$}u', - ['bar' => null], - ]; - - yield 'Route with an optional variable as the first occurrence' => [ - '[/{foo}]', - ['/', '/foo'], - '{^\/?(?:(?P[^\/]+))?$}u', - ['foo' => null], - ]; - - yield 'Route with an optional variable with inner separator /' => [ - 'foo[/{bar}]', - ['/foo', '/foo/bar'], - '{^\/foo(?:\/(?P[^\/]+))?$}u', - ['bar' => null], - ]; - - yield 'Route with a requirement of 0' => [ - '/{bar:0}', - ['/0'], - '{^\/(?P0)?$}u', - ['bar' => 0], - ]; - - yield 'Route with a requirement and in optional placeholder' => [ - '/[{lang:[a-z]{2}}/]hello', - ['/hello', 'hello', '/en/hello', 'en/hello'], - '{^\/?(?:(?P[a-z]{2})\/)?hello?$}u', - ['lang' => null], - ]; - - yield 'Route with a requirement and in optional placeholder and default' => [ - '/[{lang:lower=english}/]hello', - ['/hello', 'hello', '/en/hello', 'en/hello'], - '{^\/?(?:(?P[a-z]+)\/)?hello?$}u', - ['lang' => 'english'], - ]; - - yield 'Route with a requirement, optional and required placeholder' => [ - '/[{lang:[a-z]{2}}[-{sublang}]/]{name}[/page-{page=0}]', - ['en-us/foo', '/en-us/foo', 'foo', '/foo', 'en/foo', '/en/foo', 'en-us/foo/page-12', '/en-us/foo/page-12'], - '{^\/?(?:(?P[a-z]{2})(?:-(?P[^\/]+))?\/)?(?P[^\/]+)(?:\/page-(?P[^\/]+))?$}u', - ['lang' => null, 'sublang' => null, 'name' => null, 'page' => 0], - ]; - - yield 'Route with an optional variable as the first segment with requirements' => [ - '/[{bar:(foo|bar)}]', - ['/', '/foo', 'bar', 'foo', 'bar'], - '{^\/?(?:(?P(foo|bar)))?$}u', - ['bar' => null], - ]; - - yield 'Route with only optional variables with separator /' => [ - '/[{foo}]/[{bar}]', - ['/', '/foo/', '/foo/bar', 'foo'], - '{^\/?(?:(?P[^\/]+))?\/?(?:(?P[^\/]+))?$}u', - ['foo' => null, 'bar' => null], - ]; - - yield 'Route with only optional variables with inner separator /' => [ - '/[{foo}][/{bar}]', - ['/', '/foo/bar', 'foo', '/foo'], - '{^\/?(?:(?P[^\/]+))?(?:\/(?P[^\/]+))?$}u', - ['foo' => null, 'bar' => null], - ]; - - yield 'Route with a variable in last position' => [ - '/foo-{bar}', - ['/foo-bar'], - '{^\/foo-(?P[^\/]+)?$}u', - ['bar' => null], - ]; - - yield 'Route with a variable and no real separator' => [ - '/static{var}static', - ['/staticfoostatic'], - '{^\/static(?P[^\/]+)static?$}u', - ['var' => null], - ]; - - yield 'Route with nested optional parameters' => [ - '/[{foo}/[{bar}]]', - ['/foo', '/foo', '/foo/', '/foo/bar', 'foo/bar'], - '{^\/?(?:(?P[^\/]+)\/?(?:(?P[^\/]+))?)?$}u', - ['foo' => null, 'bar' => null], - ]; - - yield 'Route with nested optional parameters 1' => [ - '/[{foo}/{bar}]', - ['/', '/foo/bar', 'foo/bar'], - '{^\/?(?:(?P[^\/]+)\/(?P[^\/]+))?$}u', - ['foo' => null, 'bar' => null], - ]; - - yield 'Route with complex matches' => [ - '/hello/{foo:[a-z]{3}=bar}{baz}/[{id:[0-9a-fA-F]{1,8}}[.{format:html|php}]]', - ['/hello/foobar/', '/hello/foobar', '/hello/foobar/0A0AB5', '/hello/foobar/0A0AB5.html'], - '{^\/hello\/(?P[a-z]{3})(?P[^\/]+)\/?(?:(?P[0-9a-fA-F]{1,8})(?:\.(?Phtml|php))?)?$}u', - ['foo' => 'bar', 'baz' => null, 'id' => null, 'format' => null], - ]; - - yield 'Route with more complex matches' => [ - '/hello/{foo:\w{3}}{bar=bar1}/world/[{name:[A-Za-z]+}[/{page:int=1}[/{baz:year}]]]/abs.{format:html|php}', - ['/hello/foo1/world/abs.html', '/hello/bar1/world/divine/abs.php', '/hello/foo1/world/abs.php', '/hello/bar1/world/divine/11/abs.html', '/hello/foo1/world/divine/11/2021/abs.html'], - '{^\/hello\/(?P\w{3})(?P[^\/]+)\/world\/?(?:(?P[A-Za-z]+)(?:\/(?P[0-9]+)(?:\/(?P[0-9]{4}))?)?)?\/abs\.(?Phtml|php)?$}u', - ['foo' => null, 'bar' => 'bar1', 'name' => null, 'page' => '1', 'baz' => null, 'format' => null], - ]; - } - - public function provideCompileHostData(): \Generator - { - yield 'Route domain with variable' => [ - '//{foo}.example.com/', - ['cool.example.com'], - '{^(?P[^\/]+)\.example\.com$}ui', - ['foo' => null], - ]; - - yield 'Route domain with requirement' => [ - '//{lang:[a-z]{2}}.example.com/', - ['en.example.com'], - '{^(?P[a-z]{2})\.example\.com$}ui', - ['lang' => null], - ]; - - yield 'Route with variable at beginning of host' => [ - '//{locale}.example.{tld}/', - ['en.example.com'], - '{^(?P[^\/]+)\.example\.(?P[^\/]+)$}ui', - ['locale' => null, 'tld' => null], - ]; - - yield 'Route domain with requirement and optional variable' => [ - '//[{lang:[a-z]{2}}.]example.com/', - ['en.example.com', 'example.com'], - '{^(?:(?P[a-z]{2})\.)?example\.com$}ui', - ['lang' => null], - ]; - - yield 'Route domain with a default requirement on variable and path variable' => [ - '//{id:int}.example.com/', - ['23.example.com'], - '{^(?P[0-9]+)\.example\.com$}ui', - ['id' => 0], - ]; - } -} diff --git a/tests/Matchers/SimpleRouteMatcherTest.php b/tests/Matchers/SimpleRouteMatcherTest.php deleted file mode 100644 index 8a2b6a87..00000000 --- a/tests/Matchers/SimpleRouteMatcherTest.php +++ /dev/null @@ -1,446 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Matchers; - -use Flight\Routing\Exceptions\MethodNotAllowedException; -use Flight\Routing\Exceptions\UriHandlerException; -use Flight\Routing\Exceptions\UrlGenerationException; -use Flight\Routing\Generator\GeneratedUri; -use Flight\Routing\Interfaces\RouteCompilerInterface; -use Flight\Routing\Interfaces\RouteMatcherInterface; -use Flight\Routing\Route; -use Flight\Routing\RouteMatcher; -use Flight\Routing\RouteCollection; -use Nyholm\Psr7\ServerRequest; -use Nyholm\Psr7\Uri; -use PHPUnit\Framework\TestCase; - -/** - * SimpleRouteMatcherTest. - */ -class SimpleRouteMatcherTest extends TestCase -{ - public function testConstructor(): void - { - $factory = new RouteMatcher(new RouteCollection()); - - $this->assertInstanceOf(RouteMatcherInterface::class, $factory); - } - - /** - * @dataProvider routeCompileData - * - * @param array $variables - */ - public function testCompileRoute(string $path, array $variables): void - { - $collection = new RouteCollection(); - $collection->add($route = new Route('http://[{lang:[a-z]{2}}.]example.com/{foo}', ['FOO', 'BAR'])); - - $factory = new RouteMatcher($collection); - $route = $factory->matchRequest(new ServerRequest($route->getMethods()[0], $path)); - - $this->assertInstanceOf(Route::class, $route); - $this->assertEquals($variables, $route->getArguments()); - - $factory = $factory->getCompiler()->compile($route); - - $this->assertEquals('{^\/(?P[^\/]+)?$}u', $factory[0]); - $this->assertEquals('{^(?:(?P[a-z]{2})\.)?example\.com$}ui', $factory[1]); - $this->assertEquals(['foo' => null, 'lang' => null], $factory[2]); - } - - public function testSamePathOnMethodMatch(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->getRoute(); - $route2 = $collection->addRoute('/foo', ['POST'])->getRoute(); - $route3 = $collection->addRoute('/bar/{var}', Route::DEFAULT_METHODS)->getRoute(); - $route4 = $collection->addRoute('/bar/{var}', ['POST'])->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertSame($route1, $matcher->match('GET', new Uri('/foo'))); - $this->assertSame($route2, $matcher->match('POST', new Uri('/foo'))); - $this->assertSame($route3, $matcher->match('GET', new Uri('/bar/foo'))); - $this->assertSame($route4, $matcher->match('POST', new Uri('/bar/foo'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('/foo'))->getData()); - $this->assertEquals($route2->getData(), $serializedMatcher->match('POST', new Uri('/foo'))->getData()); - $this->assertEquals($route3->getData(), $serializedMatcher->match('GET', new Uri('/bar/foo'))->getData()); - - $this->expectExceptionMessage('Route with "/bar/foo" path is allowed on request method(s) [GET], "POST" is invalid.'); - $this->expectException(MethodNotAllowedException::class); - $serializedMatcher->match('POST', new Uri('/bar/foo')); - } - - public function testMatchSamePathWithInvalidMethod(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/foo', Route::DEFAULT_METHODS); - $collection->addRoute('/foo', ['POST']); - $matcher = new RouteMatcher($collection); - - $this->expectExceptionMessage('Route with "/foo" path is allowed on request method(s) [GET,POST], "PATCH" is invalid.'); - $this->expectException(MethodNotAllowedException::class); - $matcher->match('PATCH', new Uri('/foo')); - } - - public function testMatchSamePathWithInvalidMethodAndSerializedMatcher(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/foo', Route::DEFAULT_METHODS); - $collection->addRoute('/foo', ['POST']); - $matcher = \unserialize(\serialize(new RouteMatcher($collection))); - - $this->expectExceptionMessage('Route with "/foo" path is allowed on request method(s) [GET,POST], "PATCH" is invalid.'); - $this->expectException(MethodNotAllowedException::class); - $matcher->match('PATCH', new Uri('/foo')); - } - - public function testSamePathOnDomainMatch(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('localhost')->getRoute(); - $route2 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('biurad.com')->getRoute(); - $route3 = $collection->addRoute('/bar/{var}', Route::DEFAULT_METHODS)->domain('localhost')->getRoute(); - $route4 = $collection->addRoute('/bar/{var}', Route::DEFAULT_METHODS)->domain('biurad.com')->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertSame($route1, $matcher->match('GET', new Uri('//localhost/foo'))); - $this->assertSame($route2, $matcher->match('GET', new Uri('//biurad.com/foo'))); - $this->assertSame($route3, $matcher->match('GET', new Uri('//localhost/bar/foo'))); - $this->assertSame($route4, $matcher->match('GET', new Uri('//biurad.com/bar/foo'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('//localhost/foo'))->getData()); - $this->assertEquals($route2->getData(), $serializedMatcher->match('GET', new Uri('//biurad.com/foo'))->getData()); - $this->assertEquals($route3->getData(), $serializedMatcher->match('GET', new Uri('//localhost/bar/foo'))->getData()); - - $this->expectExceptionMessage('Route with "/bar/foo" path is not allowed on requested uri "//biurad.com/bar/foo" as uri host is invalid.'); - $this->expectException(UriHandlerException::class); - $serializedMatcher->match('GET', new Uri('//biurad.com/bar/foo')); - } - - public function testMatchSamePathWithInvalidDomain(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('localhost'); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('biurad.com'); - $matcher = new RouteMatcher($collection); - - $this->expectExceptionMessage('Route with "/foo" path is not allowed on requested uri "//localhost.com/foo" as uri host is invalid.'); - $this->expectException(UriHandlerException::class); - $matcher->match('GET', new Uri('//localhost.com/foo')); - } - - public function testMatchSamePathWithInvalidDomainAndSerializedMatcher(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('localhost'); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('biurad.com'); - $matcher = \unserialize(\serialize(new RouteMatcher($collection))); - - $this->expectExceptionMessage('Route with "/foo" path is not allowed on requested uri "//localhost.com/foo" as uri host is invalid.'); - $this->expectException(UriHandlerException::class); - $matcher->match('GET', new Uri('//localhost.com/foo')); - } - - public function testSamePathOnSchemeMatch(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->scheme('https')->getRoute(); - $route2 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->scheme('http')->getRoute(); - $route3 = $collection->addRoute('/bar/{var}', Route::DEFAULT_METHODS)->scheme('https')->getRoute(); - $route4 = $collection->addRoute('/bar/{var}', Route::DEFAULT_METHODS)->scheme('http')->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertSame($route1, $matcher->match('GET', new Uri('https://localhost/foo'))); - $this->assertSame($route2, $matcher->match('GET', new Uri('http://localhost/foo'))); - $this->assertSame($route3, $matcher->match('GET', new Uri('https://localhost/bar/foo'))); - $this->assertSame($route4, $matcher->match('GET', new Uri('http://localhost/bar/foo'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('https://localhost/foo'))->getData()); - $this->assertEquals($route2->getData(), $serializedMatcher->match('GET', new Uri('http://localhost/foo'))->getData()); - $this->assertEquals($route3->getData(), $serializedMatcher->match('GET', new Uri('https://localhost/bar/foo'))->getData()); - - $this->expectExceptionMessage('Route with "/bar/foo" path is not allowed on requested uri "http://localhost/bar/foo" with invalid scheme, supported scheme(s): [https].'); - $this->expectException(UriHandlerException::class); - $serializedMatcher->match('GET', new Uri('http://localhost/bar/foo')); - } - - public function testMatchSamePathWithInvalidScheme(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->scheme('https'); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->scheme('http'); - $matcher = new RouteMatcher($collection); - - $this->expectExceptionMessage('Route with "/foo" path is not allowed on requested uri "ftp://localhost.com/foo" with invalid scheme, supported scheme(s): [https, http].'); - $this->expectException(UriHandlerException::class); - $matcher->match('GET', new Uri('ftp://localhost.com/foo')); - } - - public function testMatchSamePathWithInvalidSchemeAndSerializedMatcher(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('localhost'); - $collection->addRoute('/foo', Route::DEFAULT_METHODS)->domain('biurad.com'); - $matcher = \unserialize(\serialize(new RouteMatcher($collection))); - - $this->expectExceptionMessage('Route with "/foo" path is not allowed on requested uri "//localhost.com/foo" as uri host is invalid.'); - $this->expectException(UriHandlerException::class); - $matcher->match('GET', new Uri('//localhost.com/foo')); - } - - public function testMatchingRouteWithEndingSlash(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('/foo/', Route::DEFAULT_METHODS)->getRoute(); - $route2 = $collection->addRoute('/bar@', Route::DEFAULT_METHODS)->getRoute(); - $route3 = $collection->addRoute('/foo/{var}/', Route::DEFAULT_METHODS)->getRoute(); - $route4 = $collection->addRoute('/bar/{var:[a-z]{3}}@', Route::DEFAULT_METHODS)->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertSame($route1, $matcher->match('GET', new Uri('/foo'))); - $this->assertSame($route1, $matcher->match('GET', new Uri('/foo/'))); - $this->assertSame($route2, $matcher->match('GET', new Uri('/bar'))); - $this->assertSame($route2, $matcher->match('GET', new Uri('/bar@'))); - $this->assertSame($route3, $matcher->match('GET', new Uri('/foo/bar'))); - $this->assertSame($route3, $matcher->match('GET', new Uri('/foo/bar/'))); - $this->assertSame($route4, $matcher->match('GET', new Uri('/bar/foo'))); - $this->assertSame($route4, $matcher->match('GET', new Uri('/bar/foo@'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('/foo'))->getData()); - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('/foo/'))->getData()); - $this->assertEquals($route2->getData(), $serializedMatcher->match('GET', new Uri('/bar'))->getData()); - $this->assertEquals($route2->getData(), $serializedMatcher->match('GET', new Uri('/bar@'))->getData()); - $this->assertEquals($route3->getData(), $serializedMatcher->match('GET', new Uri('/foo/bar'))->getData()); - $this->assertEquals($route3->getData(), $serializedMatcher->match('GET', new Uri('/foo/bar/'))->getData()); - $this->assertEquals($route4->getData(), $serializedMatcher->match('GET', new Uri('/bar/foo'))->getData()); - $this->assertEquals($route4->getData(), $serializedMatcher->match('GET', new Uri('/bar/foo@'))->getData()); - } - - public function testMatchingEndingSlashConflict(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->getRoute(); - $route2 = $collection->addRoute('/foo/', Route::DEFAULT_METHODS)->getRoute(); - $route3 = $collection->addRoute('/foo/', ['POST'])->getRoute(); - $route4 = $collection->addRoute('/bar/{var}', Route::DEFAULT_METHODS)->getRoute(); - $route5 = $collection->addRoute('/bar/{var}/', Route::DEFAULT_METHODS)->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertSame($route1, $matcher->match('GET', new Uri('/foo'))); - $this->assertSame($route2, $matcher->match('GET', new Uri('/foo/'))); - $this->assertSame($route3, $matcher->match('POST', new Uri('/foo/'))); - $this->assertSame($route4, $matcher->match('GET', new Uri('/bar/foo'))); - $this->assertSame($route5, $matcher->match('GET', new Uri('/bar/foo/'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('/foo'))->getData()); - $this->assertEquals($route2->getData(), $serializedMatcher->match('GET', new Uri('/foo/'))->getData()); - $this->assertEquals($route3->getData(), $serializedMatcher->match('POST', new Uri('/foo/'))->getData()); - $this->assertEquals($route4->getData(), $serializedMatcher->match('GET', new Uri('/bar/foo'))->getData()); - $this->assertNotEquals($route5->getData(), $serializedMatcher->match('GET', new Uri('/bar/foo/'))); - } - - public function testRouteMatchingError(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('/foo', Route::DEFAULT_METHODS)->getRoute(); - $route2 = $collection->addRoute('/bar/{var:[a-z]+}', Route::DEFAULT_METHODS)->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertSame($route1, $matcher->match('GET', new Uri('/foo'))); - $this->assertNull($matcher->match('GET', new Uri('/foo/'))); - $this->assertSame($route2, $matcher->match('GET', new Uri('/bar/foo'))); - $this->assertNull($matcher->match('GET', new Uri('/bar/foo/'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - - $this->assertEquals($route1->getData(), $serializedMatcher->match('GET', new Uri('/foo'))->getData()); - $this->assertNull($serializedMatcher->match('GET', new Uri('/foo/'))); - $this->assertEquals($route2->getData(), $serializedMatcher->match('GET', new Uri('/bar/foo'))->getData()); - $this->assertNull($serializedMatcher->match('GET', new Uri('/bar/foo/'))); - } - - public function testDuplicationOnDynamicRoutePattern(): void - { - $collection = new RouteCollection(); - $route1 = $collection->addRoute('[{locale:en|fr}]/admin/post/{id:int}/', Route::DEFAULT_METHODS)->getRoute(); - $collection->addRoute('[{locale:en|fr}]/admin/post/{id:int}/', Route::DEFAULT_METHODS)->getRoute(); - - $matcher = new RouteMatcher($collection); - $this->assertEquals($route1, $matcher->match('GET', new Uri('/admin/post/23'))); - - $serializedMatcher = \unserialize(\serialize($matcher)); - $this->assertInstanceOf(RouteMatcherInterface::class, $serializedMatcher); - $this->assertEquals($route1->argument('locale', 'en'), $serializedMatcher->match('GET', new Uri('en/admin/post/23'))); - } - - /** - * @dataProvider provideCompileData - * - * @param array $tokens - */ - public function testGenerateUri(string $regex, string $match, array $tokens, int $referenceType): void - { - $collection = new RouteCollection(); - $collection->addRoute($regex, ['FOO', 'BAR'])->bind('test'); - - $factory = new RouteMatcher($collection); - - $this->assertEquals($match, (string) $factory->generateUri('test', $tokens, $referenceType)); - } - - public function testGenerateUriNotFound(): void - { - $this->expectExceptionMessage('Unable to generate a URL for the named route "something" as such route does not exist.'); - $this->expectException(UrlGenerationException::class); - - $factory = new RouteMatcher(new RouteCollection()); - $factory->generateUri('something'); - } - - public function testGenerateUriWithDefaults(): void - { - $collection = new RouteCollection(); - $collection->addRoute('/{foo}', ['FOO', 'BAR'])->bind('test')->default('foo', 'fifty'); - - $factory = new RouteMatcher($collection); - - $this->assertEquals('/fifty', $factory->generateUri('test', [], GeneratedUri::NETWORK_PATH)); - } - - public function testRoutesData(): void - { - $collection = new RouteCollection(); - $routes = [new Route('/foo'), new Route('/bar'), new Route('baz')]; - $collection->routes($routes); - - $matcher = new RouteMatcher($collection); - $data = $matcher->getRoutes(); - - foreach ($data as $route) { - $this->assertInstanceOf(Route::class, $route); - } - - $this->assertCount(3, $data); - } - - public function testSerializedRoutesData(): void - { - $collection = new RouteCollection(); - $routes = [new Route('/foo'), new Route('/bar'), new Route('baz')]; - $collection->routes($routes); - - $matcher = \serialize(new RouteMatcher($collection)); - $data = ($matcher = \unserialize($matcher))->getRoutes(); - - foreach ($data as $route) { - $this->assertInstanceOf(Route::class, $route); - } - - $this->assertCount(3, $data); - $this->assertInstanceOf(RouteCompilerInterface::class, $matcher->getCompiler()); - } - - /** - * @return string[] - */ - public function routeCompileData(): array - { - return [ - ['http://en.example.com/english', ['lang' => 'en', 'foo' => 'english']], - ['http://example.com/locale', ['lang' => null, 'foo' => 'locale']], - ]; - } - - public function provideCompileData(): \Generator - { - yield 'Build route with variable' => [ - '/{foo}', - './two', - ['foo' => 'two'], - GeneratedUri::RELATIVE_PATH, - ]; - - yield 'Build route with variable and domain' => [ - 'http://[{lang:[a-z]{2}}.]example.com/{foo}', - 'http://example.com/cool', - ['foo' => 'cool'], - GeneratedUri::RELATIVE_PATH, - ]; - - yield 'Build route with variable and default' => [ - '/{foo=cool}', - './cool', - [], - GeneratedUri::RELATIVE_PATH, - ]; - - yield 'Build route with variable and override default' => [ - '/{foo=cool}', - './yeah', - ['foo' => 'yeah'], - GeneratedUri::RELATIVE_PATH, - ]; - - yield 'Build route with absolute path reference type' => [ - '/world', - '/world', - [], - GeneratedUri::ABSOLUTE_PATH, - ]; - - yield 'Build route with domain and network reference type' => [ - '//hello.com/world', - '//hello.com/world', - [], - GeneratedUri::NETWORK_PATH, - ]; - - yield 'Build route with domain, port 8080 and absolute url reference type' => [ - '//hello.com:8080/world', - '//hello.com:8080/world', - [], - GeneratedUri::ABSOLUTE_URL, - ]; - - yield 'Build route with domain, port 88 and absolute path reference type' => [ - '//hello.com:88/world', - '//hello.com:88/world', - [], - GeneratedUri::ABSOLUTE_URL, - ]; - } -} diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php new file mode 100644 index 00000000..81a0c11d --- /dev/null +++ b/tests/MiddlewareTest.php @@ -0,0 +1,238 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Flight\Routing\Exceptions\RouteNotFoundException; +use Flight\Routing\Handlers\FileHandler; +use Flight\Routing\Handlers\RouteHandler; +use Flight\Routing\Middlewares\PathMiddleware; +use Flight\Routing\Middlewares\UriRedirectMiddleware; +use Flight\Routing\Router; +use Flight\Routing\Tests\Fixtures; +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7\ServerRequest; +use Nyholm\Psr7\Uri; +use PHPUnit\Framework as t; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +dataset('paths_data', [ + // name => [$uriPath, $requestPath, $expectedPath, $status ] + 'root-without-prefix-tail_1' => ['/foo', '/foo', '', 200], + 'root-without-prefix-tail_2' => ['/foo', '/foo/', '/foo', 301], + 'root-without-prefix-tail_3' => ['/foo', '/foo@', '', 404], + 'root-without-prefix-tail_4' => ['/[{bar}]', '/', '', 200], + 'root-without-prefix-tail_5' => ['/[{bar}]', '/foo/', '/foo', 301], + 'root-without-prefix-tail_6' => ['/[{bar}]', '/foo', '', 200], + 'root-with-prefix-tail_1' => ['/foo/', '/foo/', '', 200], + 'root-with-prefix-tail_2' => ['/foo/', '/foo@', '', 404], + 'root-with-prefix-tail_3' => ['/foo/', '/foo', '/foo/', 301], + 'root-with-prefix-tail_4' => ['/[{bar}]/', '/', '', 200], + 'root-with-prefix-tail_5' => ['/[{bar}]/', '/foo', '/foo/', 301], + 'root-with-prefix-tail_6' => ['/[{bar}]/', '/foo/', '', 200], +]); + +dataset('redirects', function (): Generator { + yield 'Redirect string with symbols' => [ + ['/@come_here' => '/ch'], '/ch', + ]; + + yield 'Redirect string with format' => [ + ['/index.html' => '/home'], '/home', + ]; + + yield 'Redirect string with format reverse' => [ + ['/home' => '/index.html'], '/index.html', + ]; + + yield 'Redirect string with Uri instance' => [ + ['/sdjfdkgjdg' => new Uri('./cool')], '/cool', + ]; +}); + +test('if path middleware constructor is ok', function (): void { + $middleware = new PathMiddleware(); + $response = $middleware->process(new ServerRequest('GET', '/foo'), new Fixtures\BlankRequestHandler()); + + t\assertInstanceOf(ResponseInterface::class, $response); + t\assertEquals(200, $response->getStatusCode()); + t\assertFalse($response->hasHeader('Location')); +}); + +test('if path middleware process the right status code', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $router = Router::withCollection(); + $router->getCollection()->get('/foo', fn (ResponseFactoryInterface $f) => $f->createResponse()); + $router->pipe(new PathMiddleware()); + + $response = $router->process(new ServerRequest('GET', '/foo'), $handler); + t\assertInstanceOf(ResponseInterface::class, $response); + t\assertEquals(204, $response->getStatusCode()); + t\assertFalse($response->hasHeader('Location')); +}); + +test('if path middleware can process from a subfolder correctly', function (): void { + $subFolder = null; + $handler = new RouteHandler(new Psr17Factory()); + $router = Router::withCollection(); + $router->pipe(new PathMiddleware()); + $router->getCollection()->get('/foo/', function (ServerRequestInterface $req, ResponseFactoryInterface $f) use (&$subFolder) { + $subFolder = $req->getAttribute(PathMiddleware::SUB_FOLDER); + $res = $f->createResponse(); + $res->getBody()->write(\sprintf('Routing from subfolder %s as base root', $subFolder)); + + return $res; + }); + + $request = new ServerRequest(Router::METHOD_GET, '/build/foo', [], null, '1.1', ['PATH_INFO' => '/foo']); + $response = $router->process($request, $handler); + t\assertEquals('/build', $subFolder); + t\assertEquals(302, $response->getStatusCode()); + t\assertEquals('/foo/', $response->getHeaderLine('Location')); +}); + +test('if path middleware with expected 301', function (string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void { + $router = Router::withCollection(); + $router->pipe(new PathMiddleware(true)); + $router->getCollection()->get($uriPath, function (ResponseFactoryInterface $f): ResponseInterface { + $res = $f->createResponse()->withHeader('Content-Type', FileHandler::MIME_TYPE['html']); + $res->getBody()->write('Hello World'); + + return $res; + }); + + try { + $response = $router->process(new ServerRequest(Router::METHOD_GET, $requestPath), new RouteHandler(new Psr17Factory())); + } catch (RouteNotFoundException $e) { + t\assertEquals($expectsStatus, $e->getCode()); + + return; + } + + t\assertEquals($expectsStatus, $response->getStatusCode()); + t\assertEquals($expectedPath, $response->getHeaderLine('Location')); +})->with('paths_data'); + +test('if path middleware with expected 301 => 302', function (string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void { + $router = Router::withCollection(); + $router->pipe(new PathMiddleware()); + $router->getCollection()->get($uriPath, function (ResponseFactoryInterface $f): ResponseInterface { + $res = $f->createResponse()->withHeader('Content-Type', FileHandler::MIME_TYPE['html']); + $res->getBody()->write('Hello World'); + + return $res; + }); + + try { + $response = $router->process(new ServerRequest(Router::METHOD_GET, $requestPath), new RouteHandler(new Psr17Factory())); + } catch (RouteNotFoundException $e) { + t\assertEquals($expectsStatus, $e->getCode()); + + return; + } + + t\assertEquals(301 === $expectsStatus ? 302 : $expectsStatus, $response->getStatusCode()); + t\assertEquals($expectedPath, $response->getHeaderLine('Location')); +})->with('paths_data'); + +test('if path middleware with expected 301 => 307', function (string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void { + $router = Router::withCollection(); + $router->pipe(new PathMiddleware(false, true)); + $router->getCollection()->get($uriPath, function (ResponseFactoryInterface $f): ResponseInterface { + $res = $f->createResponse()->withHeader('Content-Type', FileHandler::MIME_TYPE['html']); + $res->getBody()->write('Hello World'); + + return $res; + }); + + try { + $response = $router->process(new ServerRequest(Router::METHOD_GET, $requestPath), new RouteHandler(new Psr17Factory())); + } catch (RouteNotFoundException $e) { + t\assertEquals($expectsStatus, $e->getCode()); + + return; + } + + t\assertEquals(301 === $expectsStatus ? 307 : $expectsStatus, $response->getStatusCode()); + t\assertEquals($expectedPath, $response->getHeaderLine('Location')); +})->with('paths_data'); + +test('if path middleware with expected 301 => 308', function (string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void { + $router = Router::withCollection(); + $router->pipe(new PathMiddleware(true, true)); + $router->getCollection()->get($uriPath, function (ResponseFactoryInterface $f): ResponseInterface { + $res = $f->createResponse()->withHeader('Content-Type', FileHandler::MIME_TYPE['html']); + $res->getBody()->write('Hello World'); + + return $res; + }); + + try { + $response = $router->process(new ServerRequest(Router::METHOD_GET, $requestPath), new RouteHandler(new Psr17Factory())); + } catch (RouteNotFoundException $e) { + t\assertEquals($expectsStatus, $e->getCode()); + + return; + } + + t\assertEquals(301 === $expectsStatus ? 308 : $expectsStatus, $response->getStatusCode()); + t\assertEquals($expectedPath, $response->getHeaderLine('Location')); +})->with('paths_data'); + +test('if uri-redirect middleware can process the right status code', function (array $redirects, string $expected): void { + $router = Router::withCollection(); + $router->pipe(new UriRedirectMiddleware($redirects)); + $router->getCollection()->get($expected, Fixtures\BlankRequestHandler::class); + + $res = $router->process(new ServerRequest(Router::METHOD_GET, $expected), new RouteHandler(new Psr17Factory())); + t\assertInstanceOf(ResponseInterface::class, $res); + t\assertSame(204, $res->getStatusCode()); +})->with('redirects'); + +test('if uri-redirect middleware can redirect old path to new as 301', function (array $redirects, string $expected): void { + $router = Router::withCollection(); + $router->pipe(new UriRedirectMiddleware($redirects)); + $router->getCollection()->get($expected, Fixtures\BlankRequestHandler::class); + + $actual = \key($redirects); + $res = $router->process(new ServerRequest(Router::METHOD_GET, $actual), new RouteHandler(new Psr17Factory())); + t\assertInstanceOf(ResponseInterface::class, $res); + t\assertSame(301, $res->getStatusCode()); + t\assertSame((string) $redirects[$actual], $res->getHeaderLine('Location')); +})->with('redirects'); + +test('if uri-redirect middleware can redirect old path to new as 308', function (array $redirects, string $expected): void { + $router = Router::withCollection(); + $router->pipe(new UriRedirectMiddleware($redirects, true)); + $router->getCollection()->get($expected, Fixtures\BlankRequestHandler::class); + + $actual = \key($redirects); + $res = $router->process(new ServerRequest(Router::METHOD_GET, $actual), new RouteHandler(new Psr17Factory())); + t\assertInstanceOf(ResponseInterface::class, $res); + t\assertSame(308, $res->getStatusCode()); + t\assertSame((string) $redirects[$actual], $res->getHeaderLine('Location')); +})->with('redirects'); + +test('if uri-redirect middleware can redirect a full path to new', function (): void { + $router = Router::withCollection(); + $router->pipe(new UriRedirectMiddleware(['/user/\d+' => '#/account/me'])); + $router->getCollection()->get('/account/me', Fixtures\BlankRequestHandler::class); + + $uri = new Uri('/user/23?page=settings#notification'); + $res = $router->process(new ServerRequest(Router::METHOD_GET, $uri), new RouteHandler(new Psr17Factory())); + t\assertInstanceOf(ResponseInterface::class, $res); + t\assertSame(301, $res->getStatusCode()); + t\assertSame('/account/me?page=settings#notification', $res->getHeaderLine('Location')); +}); diff --git a/tests/Middlewares/PathMiddlewareTest.php b/tests/Middlewares/PathMiddlewareTest.php deleted file mode 100644 index 7b3f8bdc..00000000 --- a/tests/Middlewares/PathMiddlewareTest.php +++ /dev/null @@ -1,210 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Middlewares; - -use Flight\Routing\Exceptions\RouteNotFoundException; -use Flight\Routing\Middlewares\PathMiddleware; -use Flight\Routing\Route; -use Flight\Routing\Router; -use Flight\Routing\Tests\BaseTestCase; -use Flight\Routing\Tests\Fixtures\BlankRequestHandler; -use Nyholm\Psr7\ServerRequest; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - -/** - * PathMiddlewareTest. - */ -class PathMiddlewareTest extends BaseTestCase -{ - public function testMiddleware(): void - { - $middleware = new PathMiddleware(); - $response = $middleware->process(new ServerRequest(Router::METHOD_GET, '/foo'), new BlankRequestHandler()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertFalse($response->hasHeader('Location')); - } - - public function testProcessStatus(): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware()); - $pipeline->addRoute(new Route('/foo', Router::METHOD_GET, [$this, 'handlePath'])); - - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, '/foo'), $this->getRequestHandler()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertFalse($response->hasHeader('Location')); - } - - public function testProcessOnSubFolder(): void - { - $subFolder = null; - $handler = function (ServerRequestInterface $request, ResponseFactoryInterface $factory) use (&$subFolder): ResponseInterface { - $subFolder = $request->getAttribute(PathMiddleware::SUB_FOLDER); - - return $factory->createResponse(); - }; - - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware()); - $pipeline->addRoute(new Route('/foo/', Route::DEFAULT_METHODS, $handler)); - - $request = new ServerRequest(Router::METHOD_GET, '/build/foo', [], null, '1.1', ['PATH_INFO' => '/foo']); - $response = $pipeline->process($request, $this->getRequestHandler()); - - - $this->assertEquals('/build', $subFolder); - $this->assertEquals(302, $response->getStatusCode()); - $this->assertEquals('/foo/', $response->getHeaderLine('Location')); - } - - /** - * @dataProvider pathCombinationsData - */ - public function testProcessWithPermanent(string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware(true)); - $pipeline->addRoute(new Route($uriPath, [Router::METHOD_GET, Router::METHOD_POST], [$this, 'handlePath'])); - - try { - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, $requestPath), $this->getRequestHandler()); - } catch (RouteNotFoundException $e) { - $this->assertEquals($expectsStatus, $e->getCode()); - - return; - } - - $this->assertEquals($expectsStatus, $response->getStatusCode()); - $this->assertEquals($expectedPath, $response->getHeaderLine('Location')); - } - - /** - * @dataProvider pathCombinationsData - */ - public function testProcessWithoutPermanent(string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware()); - $pipeline->addRoute(new Route($uriPath, [Router::METHOD_GET, Router::METHOD_POST], [$this, 'handlePath'])); - - try { - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, $requestPath), $this->getRequestHandler()); - } catch (RouteNotFoundException $e) { - $this->assertEquals($expectsStatus, $e->getCode()); - - return; - } - - $this->assertEquals(301 === $expectsStatus ? 302 : $expectsStatus, $response->getStatusCode()); - $this->assertEquals($expectedPath, $response->getHeaderLine('Location')); - } - - /** - * @dataProvider pathCombinationsData - */ - public function testProcessWithPermanentAndKeepMethod(string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware(true, true)); - $pipeline->addRoute(new Route($uriPath, [Router::METHOD_GET, Router::METHOD_POST], [$this, 'handlePath'])); - - try { - $response = $pipeline->process(new ServerRequest(Router::METHOD_POST, $requestPath), $this->getRequestHandler()); - } catch (RouteNotFoundException $e) { - $this->assertEquals($expectsStatus, $e->getCode()); - - return; - } - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(301 === $expectsStatus ? 308 : $expectsStatus, $response->getStatusCode()); - $this->assertEquals($expectedPath, $response->getHeaderLine('Location')); - } - - /** - * @dataProvider pathCombinationsData - */ - public function testProcessWithoutPermanentAndKeepMethod(string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware(false, false)); - $pipeline->addRoute(new Route($uriPath, [Router::METHOD_GET, Router::METHOD_POST], [$this, 'handlePath'])); - - try { - $response = $pipeline->process(new ServerRequest(Router::METHOD_POST, $requestPath), $this->getRequestHandler()); - } catch (RouteNotFoundException $e) { - $this->assertEquals($expectsStatus, $e->getCode()); - - return; - } - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(301 === $expectsStatus ? 302 : $expectsStatus, $response->getStatusCode()); - $this->assertEquals($expectedPath, $response->getHeaderLine('Location')); - } - - /** - * @dataProvider pathCombinationsData - */ - public function testProcessWithoutPermenantButKeepMethod(string $uriPath, string $requestPath, string $expectedPath, int $expectsStatus): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new PathMiddleware(false, true)); - $pipeline->addRoute(new Route($uriPath, [Router::METHOD_GET, Router::METHOD_POST], [$this, 'handlePath'])); - - try { - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, $requestPath), $this->getRequestHandler()); - } catch (RouteNotFoundException $e) { - $this->assertEquals($expectsStatus, $e->getCode()); - - return; - } - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(301 === $expectsStatus ? 307 : $expectsStatus, $response->getStatusCode()); - $this->assertEquals($expectedPath, $response->getHeaderLine('Location')); - } - - public function handlePath(ResponseFactoryInterface $responseFactory): ResponseInterface - { - return $responseFactory->createResponse(); - } - - /** - * @return array [$uriPath, $requestPath, $expectedPath, $permanent ] - 'root-without-prefix-tail_1' => ['/foo', '/foo', '', 200], - 'root-without-prefix-tail_2' => ['/foo', '/foo/', '/foo', 404], - 'root-without-prefix-tail_3' => ['/[{bar}]', '/', '', 200], - 'root-with-prefix-tail_1' => ['/foo/', '/foo/', '', 200], - 'root-with-prefix-tail_2' => ['/foo/', '/foo@', '', 404], - 'root-with-prefix-tail_3' => ['/foo/', '/foo', '/foo/', 301], - 'root-with-prefix-tail_4' => ['/[{bar}]/', '/', '', 200], - ]; - } -} diff --git a/tests/Middlewares/UriRedirectMiddlewareTest.php b/tests/Middlewares/UriRedirectMiddlewareTest.php deleted file mode 100644 index fda77438..00000000 --- a/tests/Middlewares/UriRedirectMiddlewareTest.php +++ /dev/null @@ -1,128 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests\Middlewares; - -use Flight\Routing\Middlewares\UriRedirectMiddleware; -use Flight\Routing\Route; -use Flight\Routing\Router; -use Flight\Routing\Tests\BaseTestCase; -use Flight\Routing\Tests\Fixtures\BlankRequestHandler; -use Nyholm\Psr7\ServerRequest; -use Nyholm\Psr7\Uri; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\UriInterface; - -/** - * UriRedirectMiddlewareTest. - */ -class UriRedirectMiddlewareTest extends BaseTestCase -{ - /** - * @dataProvider redirectionData - * - * @param array $redirects - */ - public function testProcess(array $redirects, string $expected): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new UriRedirectMiddleware($redirects)); - - $route = new Route($expected, Router::METHOD_GET, BlankRequestHandler::class); - $pipeline->addRoute($route); - - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, $expected), $this->getRequestHandler()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(200, $response->getStatusCode()); - } - - /** - * @dataProvider redirectionData - * - * @param array $redirects - */ - public function testProcessWithRedirect(array $redirects, string $expected): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new UriRedirectMiddleware($redirects)); - - $route = new Route($expected, Router::METHOD_GET, BlankRequestHandler::class); - $pipeline->addRoute($route); - - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, $actualPath = \key($redirects)), $this->getRequestHandler()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(301, $response->getStatusCode()); - $this->assertEquals($redirects[$actualPath], $response->getHeaderLine('Location')); - } - - /** - * @dataProvider redirectionData - * - * @param array $redirects - */ - public function testProcessWithRedirectAndKeepMethod(array $redirects, string $expected): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new UriRedirectMiddleware($redirects, true)); - - $route = new Route($expected, Router::METHOD_POST, BlankRequestHandler::class); - $pipeline->addRoute($route); - - $response = $pipeline->process(new ServerRequest(Router::METHOD_POST, $actualPath = \key($redirects)), $this->getRequestHandler()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(308, $response->getStatusCode()); - $this->assertEquals($redirects[$actualPath], $response->getHeaderLine('Location')); - } - - public function testProcessWithAllAttributes(): void - { - $pipeline = Router::withCollection(); - $pipeline->pipe(new UriRedirectMiddleware(['/user/\d+' => '#/account/me'])); - - $route = new Route('/account/me', Router::METHOD_GET, BlankRequestHandler::class); - $pipeline->addRoute($route); - - $uri = new Uri('/user/23?page=settings#notification'); - $response = $pipeline->process(new ServerRequest(Router::METHOD_GET, $uri), $this->getRequestHandler()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(301, $response->getStatusCode()); - $this->assertEquals('/account/me?page=settings#notification', $response->getHeaderLine('Location')); - } - - public function redirectionData(): \Generator - { - yield 'Redirect string with symbols' => [ - ['/@come_here' => '/ch'], '/ch', - ]; - - yield 'Redirect string with format' => [ - ['/index.html' => '/home'], '/home', - ]; - - yield 'Redirect string with format reverse' => [ - ['/home' => '/index.html'], '/index.html', - ]; - - yield 'Redirect string with Uri instance' => [ - ['/sdjfdkgjdg' => new Uri('./cool')], '/cool', - ]; - } -} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 00000000..4ca9c70d --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,118 @@ + + * @copyright 2019 Biurad Group (https://biurad.com/) + * @license https://opensource.org/licenses/BSD-3-Clause License + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Flight\Routing\Handlers\ResourceHandler; + +\spl_autoload_register(function (string $class): void { + match ($class) { + 'Flight\Routing\Tests\Fixtures\Annotation\Route\Valid\MultipleMethodRouteController' => require __DIR__.'/Fixtures/Annotation/Route/Valid/MultipleMethodRouteController.php', + 'Flight\Routing\Tests\Fixtures\BlankRequestHandler' => require __DIR__.'/Fixtures/BlankRequestHandler.php', + 'Flight\Routing\Tests\Fixtures\BlankRestful' => require __DIR__.'/Fixtures/BlankRestful.php', + 'Flight\Routing\Tests\Fixtures\Annotation\Route\Invalid\PathEmpty' => require __DIR__.'/Fixtures/Annotation/Route/Invalid/PathEmpty.php', + 'Flight\Routing\Tests\Fixtures\Annotation\Route\Invalid\MethodWithResource' => require __DIR__.'/Fixtures/Annotation/Route/Invalid/MethodWithResource.php', + 'Flight\Routing\Tests\Fixtures\Annotation\Route\Invalid\ClassGroupWithResource' => require __DIR__.'/Fixtures/Annotation/Route/Invalid/ClassGroupWithResource.php', + default => null, + }; +}); + +// uses(Tests\TestCase::class)->in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ +expect()->extend('toBeOne', fn () => $this->toBe(1)); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ +function debugFormat(mixed $value, $indent = ''): string +{ + switch (true) { + case \is_int($value) || \is_float($value): + return \var_export($value, true); + case [] === $value: + return '[]'; + case false === $value: + return 'false'; + case true === $value: + return 'true'; + case null === $value: + return 'null'; + case '' === $value: + return "''"; + case $value instanceof \UnitEnum: + return \ltrim(\var_export($value, true), '\\'); + } + $subIndent = $indent.' '; + + if (\is_string($value)) { + return \sprintf("'%s'", \addcslashes($value, "'\\")); + } + + if (\is_array($value)) { + $j = -1; + $code = ''; + + foreach ($value as $k => $v) { + $code .= $subIndent; + + if (!\is_int($k) || 1 !== $k - $j) { + $code .= debugFormat($k, $subIndent).' => '; + } + + if (\is_int($k) && $k > $j) { + $j = $k; + } + $code .= debugFormat($v, $subIndent).",\n"; + } + + return "[\n".$code.$indent.']'; + } + + if (\is_object($value)) { + if ($value instanceof ResourceHandler) { + return 'new ResourceHandler('.debugFormat($value(''), $indent).')'; + } + + if ($value instanceof \stdClass) { + return '(object) '.debugFormat((array) $value, $indent); + } + + if (!$value instanceof \Closure) { + return $value::class; + } + $ref = new \ReflectionFunction($value); + + if (0 === $ref->getNumberOfParameters()) { + return 'fn() => '.debugFormat($ref->invoke(), $indent); + } + } + + throw new \UnexpectedValueException(\sprintf('Cannot format value of type "%s".', \get_debug_type($value))); +} diff --git a/tests/RegexGeneratorTest.php b/tests/RegexGeneratorTest.php deleted file mode 100644 index 0e9a92a2..00000000 --- a/tests/RegexGeneratorTest.php +++ /dev/null @@ -1,190 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests; - -use Flight\Routing\Generator\RegexGenerator; -use PHPUnit\Framework\TestCase; - -class RegexGeneratorTest extends TestCase -{ - /** - * @dataProvider routeProvider - */ - public function testGrouping(array $routes, $expected): void - { - $collection = new RegexGenerator('/'); - - foreach ($routes as $route) { - [$path, $name] = $route; - $collection->addRoute($path, [$name]); - } - - $dumped = $this->dumpCollection($collection); - $this->assertEquals($expected, $dumped); - } - - public function routeProvider() - { - return [ - 'Simple - not nested' => [ - [ - ['/', 'root'], - ['/prefix/segment/', 'prefix_segment'], - ['/leading/segment/', 'leading_segment'], - ], - << [ - [ - ['/', 'root'], - ['/prefix/segment/aa', 'prefix_segment'], - ['/prefix/segment/bb', 'leading_segment'], - ], - << prefix_segment --> leading_segment -EOF - ], - 'Nested - contains item at intersection' => [ - [ - ['/', 'root'], - ['/prefix/segment/', 'prefix_segment'], - ['/prefix/segment/bb', 'leading_segment'], - ], - << prefix_segment --> leading_segment -EOF - ], - 'Simple one level nesting' => [ - [ - ['/', 'root'], - ['/group/segment/', 'nested_segment'], - ['/group/thing/', 'some_segment'], - ['/group/other/', 'other_segment'], - ], - << nested_segment --> some_segment --> other_segment -EOF - ], - 'Retain matching order with groups' => [ - [ - ['/group/aa/', 'aa'], - ['/group/bb/', 'bb'], - ['/group/cc/', 'cc'], - ['/(.*)', 'root'], - ['/group/dd/', 'dd'], - ['/group/ee/', 'ee'], - ['/group/ff/', 'ff'], - ], - << aa --> bb --> cc -root -/group/ --> dd --> ee --> ff -EOF - ], - 'Retain complex matching order with groups at base' => [ - [ - ['/aaa/111/', 'first_aaa'], - ['/prefixed/group/aa/', 'aa'], - ['/prefixed/group/bb/', 'bb'], - ['/prefixed/group/cc/', 'cc'], - ['/prefixed/(.*)', 'root'], - ['/prefixed/group/dd/', 'dd'], - ['/prefixed/group/ee/', 'ee'], - ['/prefixed/', 'parent'], - ['/prefixed/group/ff/', 'ff'], - ['/aaa/222/', 'second_aaa'], - ['/aaa/333/', 'third_aaa'], - ], - << first_aaa --> second_aaa --> third_aaa -/prefixed/ --> /prefixed/group/ --> -> aa --> -> bb --> -> cc --> root --> /prefixed/group/ --> -> dd --> -> ee --> -> ff --> parent -EOF - ], - - 'Group regardless of segments' => [ - [ - ['/aaa-111/', 'a1'], - ['/aaa-222/', 'a2'], - ['/aaa-333/', 'a3'], - ['/group-aa/', 'g1'], - ['/group-bb/', 'g2'], - ['/group-cc/', 'g3'], - ], - << a1 --> a2 --> a3 -/group- --> g1 --> g2 --> g3 -EOF - ], - ]; - } - - private function dumpCollection(RegexGenerator $collection, $prefix = '') - { - $lines = []; - - foreach ($collection->getRoutes() as $item) { - if ($item instanceof RegexGenerator) { - $lines[] = $prefix . $item->getPrefix(); - $lines[] = $this->dumpCollection($item, $prefix . '-> '); - } else { - $lines[] = $prefix . \implode(' ', $item); - } - } - - return \implode("\n", $lines); - } -} diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 3dbaa4e1..66c0eb4e 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -1,6 +1,4 @@ -assertNotInstanceOf(\Traversable::class, $collection->getRoutes()); - - $this->expectExceptionMessage('Cannot add a route to a frozen routes collection.'); - $this->expectException(\RuntimeException::class); - - $collection->addRoute('/hello', ['GET']); - } - - public function testAdd(): void - { - $collection = new RouteCollection(); - $collection->routes([new Route('/1'), new Route('/2'), new Route('/3')], true); - $collection = $this->getIterable($collection); - - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertEquals([ - 'handler' => null, - 'methods' => Route::DEFAULT_METHODS, - 'schemes' => [], - 'hosts' => [], - 'name' => null, - 'path' => '/1', - 'patterns' => [], - 'arguments' => [], - 'defaults' => [], - ], Fixtures\Helper::routesToArray([$route], true)); - - $collection->next(); - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertEquals([ - 'handler' => null, - 'methods' => Route::DEFAULT_METHODS, - 'schemes' => [], - 'hosts' => [], - 'name' => null, - 'path' => '/2', - 'patterns' => [], - 'arguments' => [], - 'defaults' => [], - ], Fixtures\Helper::routesToArray([$route], true)); - - $collection->next(); - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertEquals([ - 'handler' => null, - 'methods' => Route::DEFAULT_METHODS, - 'schemes' => [], - 'hosts' => [], - 'name' => null, - 'path' => '/3', - 'patterns' => [], - 'arguments' => [], - 'defaults' => [], - ], Fixtures\Helper::routesToArray([$route], true)); - - $this->assertCount(3, $collection); - } - - public function testAddRoute(): void - { - $collection = new RouteCollection(null, true); - $collection->addRoute('/foo', [Router::METHOD_GET])->bind('foo'); - $collection->addRoute('/bar', [Router::METHOD_GET]); - $collection = $this->getIterable($c = $collection); - - $this->assertInstanceOf(Route::class, $collection->current()); - $this->assertCount(2, $collection); - - $collection->next(); - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertEquals([ - 'handler' => null, - 'methods' => [Router::METHOD_GET], - 'schemes' => [], - 'hosts' => [], - 'name' => 'foo', - 'path' => '/foo', - 'patterns' => [], - 'arguments' => [], - 'defaults' => [], - ], Fixtures\Helper::routesToArray([$route], true)); - } - - public function testCannotOverriddenRoute(): void - { - $collection = new RouteCollection(); - $collection->add(new Route('/foo', Router::METHOD_GET)); - $collection->add(new Route('/foo1', Router::METHOD_GET)); - $collection->group('not_same', clone $collection); - $collection = $this->getIterable($collection); - - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertNull($route->getName()); - - $collection->next(); - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertNull($route->getName()); - - $collection->next(); - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertEquals('not_sameGET_foo', $route->getName()); - - $collection->next(); - $this->assertInstanceOf(Route::class, $route = $collection->current()); - $this->assertEquals('not_sameGET_foo1', $route->getName()); - - $this->assertCount(4, $collection); - } - - public function testRoutesSerialization(): void - { - $collection = new RouteCollection(); - - for ($i = 0; $i < 100; ++$i) { - $h = \substr(\md5((string) $i), 0, 6); - $collection->get('/' . $h . '/{a}/{b}/{c}/' . $h)->bind('_' . $i); - } - - $serialized = \serialize($collection); - - $this->assertCount(100, $collection->getRoutes()); - $this->assertCount(100, ($collection = \unserialize($serialized))->getRoutes()); - } - - /** - * @dataProvider populationProvider - */ - public function testDeepOverriddenRoute(bool $c1, bool $c2, array $expected): void - { - $collection = new RouteCollection(); - $collection->add(new Route('/foo', Router::METHOD_GET)); - - $collection1 = new RouteCollection(); - $collection1->add(new Route('/foo', Router::METHOD_GET)); - - $collection2 = new RouteCollection(); - $collection2->add(new Route('foo', Router::METHOD_GET)); - - $collection1->populate($collection2, $c2); - $collection->populate($collection1, $c1); - - $this->assertCount(3, $routes = $collection->getRoutes()); - $this->assertEquals( - $expected, - \array_map( - static function (Route $route): ?string { - return $route->getName(); - }, - $routes - ) - ); - } - - public function testUniqueGeneratedRouteNamesAmongMounts(): void - { - $controllers = new RouteCollection(); - - $controllers->group('', $rootA = new RouteCollection()); - $controllers->group('', $rootB = new RouteCollection()); - - $rootA->addRoute('/leaf-a', [])->end(); - $rootB->addRoute('/leaf_a', []); - - $this->assertCount(2, $routes = $controllers->getRoutes()); - $this->assertEquals(['_leaf_a', '_leaf_a_1'], \array_map( - static function (Route $route): string { - return $route->getName(); - }, - $routes - )); - } - - public function testSortingNestedGroupCollection(): void - { - $collection = new RouteCollection(); - - $this->expectExceptionObject(new \RuntimeException('Cannot sort routes in a nested collection.')); - $collection->group(null, new RouteCollection(null, true)); - } - - public function testLockedGroupCollection(): void - { - $collector = new RouteCollection(); - $collector->getRoutes(); - - $this->expectExceptionObject(new \RuntimeException('Cannot add a nested routes collection to a frozen routes collection.')); - $collector->group(''); - } - - public function testPopulatingAsGroupCollection(): void - { - $collector = new RouteCollection(); - $this->assertCount(0, $collector->getRoutes()); - - $collection = new RouteCollection(); - $collection->add(new Route('/foo', Router::METHOD_GET)); - - $this->expectExceptionObject(new \RuntimeException('Cannot add a nested routes collection to a frozen routes collection')); - $collector->populate($collection, true); - } - - public function testPrototypingOnRouteAndGroup(): void - { - $collection = new RouteCollection(); - $collection->add(Route::to('/hello'))->prototype([ - 'bind' => 'greeting', - 'method' => 'OPTIONS', - 'scheme' => ['http'], - ]); - - $group = $collection->group()->prototype([ - 'domain' => 'biurad.com', - 'method' => ['OPTIONS'], - ]); - $group->addRoute('/foo', ['GET']); - $group->end(); - - $this->assertEquals([ - [ - 'name' => 'greeting', - 'path' => '/hello', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD, Router::METHOD_OPTIONS], - 'handler' => null, - 'schemes' => ['http'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => 'GET_OPTIONS_foo', - 'path' => '/foo', - 'hosts' => ['biurad.com'], - 'methods' => [Router::METHOD_GET, Router::METHOD_OPTIONS], - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - ], Fixtures\Helper::routesToArray($collection->getRoutes())); - } - - public function testProxiedMethodsPrototyping(): void - { - $collection = new RouteCollection(); - $collection - ->namespace('App') - ->defaults(['hello' => 'world']) - ->asserts(['locale' => 'en|fr']) - ->arguments(['yes' => 'okay']) - ->group() - ->namespace('\\Controllers') - ->argument('foo', 'bar') - - ->get('/hello/{name}') - ->namespace('\\Greet') - ->prefix('/{locale}') - ->method('OPTIONS') - ->arguments(['name' => 'Divine']) - ->defaults(['data' => \stdClass::class]) - ->asserts(['name' => '\w+']) - ->run('\\phpinfo') - ->end() - ->end() - ->end(); - - $this->assertEquals([ - 'name' => 'GET_HEAD_OPTIONS_locale_hello_name', - 'path' => '/{locale}/hello/{name}', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD, Router::METHOD_OPTIONS], - 'handler' => 'App\Controllers\Greet\phpinfo', - 'schemes' => [], - 'defaults' => ['hello' => 'world', 'data' => \stdClass::class], - 'patterns' => ['locale' => 'en|fr', 'name' => '\w+'], - 'arguments' => ['yes' => 'okay', 'foo' => 'bar', 'name' => 'Divine'], - ], Fixtures\Helper::routesToArray($collection->getRoutes(), true)); - - try { - $collection->prototype(['bind' => 'foo']); - $this->fail('->prototype() method with an array key of bind is expected to throw an UnexpectedValueException error.'); - } catch (\UnexpectedValueException $e) { - $this->assertEquals('Binding the name "foo" is only supported on routes.', $e->getMessage()); - } - - try { - $collection->bind('foo'); - $this->fail('->bind() method called on non route is expected to throw an UnderflowException error.'); - } catch (\UnderflowException $e) { - $this->assertEquals('Binding the name "foo" is only supported on routes.', $e->getMessage()); - } - - try { - $collection->run(new Fixtures\InvokeController()); - $this->fail('->run() method called on non route is expected to throw an UnderflowException error.'); - } catch (\UnderflowException $e) { - $this->assertEquals('Binding a handler with type of "Flight\Routing\Tests\Fixtures\InvokeController", is only supported on routes.', $e->getMessage()); - } +use PHPUnit\Framework as t; +use Spiral\Attributes\AnnotationReader; +use Spiral\Attributes\AttributeReader; +use Spiral\Attributes\Composite\MergeReader; + +test('if the route collection is empty', function (): void { + $collection = new RouteCollection(); + $collection->group('empty'); + t\assertCount(0, $collection); +}); + +test('if the route collection is not empty', function (): void { + $collection = new RouteCollection(); + $collection->populate(new RouteCollection()); + $collection->get('/', fn (): string => 'Hello World'); + t\assertCount(1, $collection); +}); + +test('if route collection common methods works', function (): void { + $collection = new RouteCollection(); + $collection->get('/a', fn (): string => 'Hello World'); + $collection->post('/b', fn (): string => 'Hello World'); + $collection->put('/c', fn (): string => 'Hello World'); + $collection->patch('/d', fn (): string => 'Hello World'); + $collection->delete('/e', fn (): string => 'Hello World'); + $collection->options('/f', fn (): string => 'Hello World'); + $collection->any('/g', fn (): string => 'Hello World'); + $collection->resource('/h', 'HelloHandler'); + $collection->add('/i', handler: fn (): string => 'Hello World'); + $collection->group('a', function (RouteCollection $collection): void { + $collection->get('/j', fn (): string => 'Hello World'); + }); + + t\assertCount(10, $collection); + t\assertEquals(<<<'EOT' + [ + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/a', + 'path' => '/a', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/b', + 'path' => '/b', + 'methods' => [ + 'POST' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/c', + 'path' => '/c', + 'methods' => [ + 'PUT' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/d', + 'path' => '/d', + 'methods' => [ + 'PATCH' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/e', + 'path' => '/e', + 'methods' => [ + 'DELETE' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/f', + 'path' => '/f', + 'methods' => [ + 'OPTIONS' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/g', + 'path' => '/g', + 'methods' => [ + 'HEAD' => true, + 'GET' => true, + 'POST' => true, + 'PUT' => true, + 'PATCH' => true, + 'DELETE' => true, + 'PURGE' => true, + 'OPTIONS' => true, + 'TRACE' => true, + 'CONNECT' => true, + ], + ], + [ + 'handler' => new ResourceHandler([ + 'HelloHandler', + 'Action', + ]), + 'prefix' => '/h', + 'path' => '/h', + 'methods' => [ + 'HEAD' => true, + 'GET' => true, + 'POST' => true, + 'PUT' => true, + 'PATCH' => true, + 'DELETE' => true, + 'PURGE' => true, + 'OPTIONS' => true, + 'TRACE' => true, + 'CONNECT' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/i', + 'path' => '/i', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => fn() => 'Hello World', + 'prefix' => '/j', + 'path' => '/j', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'aGET_HEAD_j', + ], + ] + EOT, debugFormat($collection->getRoutes())); +}); + +test('if route collection routes can be accessed using the [] operator', function (): void { + $collection = new RouteCollection(); + $collection->add('hello', ['GET']); + $collection->add('hello1', ['GET']); + + t\assertTrue(isset($collection[1])); + t\assertSame('/hello1', $collection[1]['path']); + + unset($collection[1]); + t\assertNull($collection[1] ?? null); + t\assertSame('/hello', $collection[0]['path']); + + $collection[2] = ['path' => '/hello2']; +})->throws( + \BadMethodCallException::class, + 'The operator "[]" for new route, use the add() method instead.' +); + +test('if route collection routes are sorted', function (): void { + $collection = new RouteCollection(); + $collection->get('/a1'); + $collection->get('/c4'); + $collection->get('/d7'); + $collection->get('/c5'); + $collection->get('/b3'); + $collection->get('/b2'); + $collection->get('/{foo}'); + $collection->get('/c6'); + $collection->get('/f9'); + $collection->get('/e8'); + $collection->get('/foo/{bar}'); + $collection->sort(); + + t\assertSame([ + '/a1', + '/b2', + '/b3', + '/c4', + '/c5', + '/c6', + '/d7', + '/e8', + '/f9', + '/foo/{bar}', + '/{foo}', + ], \array_map(fn (array $v) => $v['path'], $collection->getRoutes())); +}); + +test('if route collection route prototyping works', function (): void { + $collection = new RouteCollection(); + $collection->add('world'); + $collection->method('CONNECT', 'PATCH'); + $collection->scheme('http'); + $collection->domain('https://example.com'); + $collection->bind('greet'); + $collection->run('HelloWorld'); + $collection->namespace('Demo\\'); + $collection->arguments(['hi' => 'Divine', 'code' => '233']); + $collection->defaults(['follow' => 'Me']); + $collection->placeholders(['number' => '\d+']); + $collection->piped('web'); + $collection->prefix('/hello'); + $collection->set('flight', 'routing'); + $collection->prototype(true); + $collection->set('data', ['hello', 'world']); + + t\assertEquals(<<<'EOT' + [ + 'handler' => 'Demo\\HelloWorld', + 'prefix' => '/hello/world', + 'path' => '/hello/world', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + 'CONNECT' => true, + 'PATCH' => true, + ], + 'schemes' => [ + 'http' => true, + 'https' => true, + ], + 'hosts' => [ + 'example.com' => true, + ], + 'name' => 'greet', + 'arguments' => [ + 'hi' => 'Divine', + 'code' => 233, + ], + 'defaults' => [ + 'follow' => 'Me', + ], + 'placeholders' => [ + 'number' => '\\d+', + ], + 'middlewares' => [ + 'web' => true, + ], + 'flight' => 'routing', + 'data' => [ + 'hello', + 'world', + ], + ] + EOT, debugFormat(\current($collection->getRoutes()))); +}); + +test('if route namespace on handler can be resolvable', function (): void { + $collection = new RouteCollection(); + $collection->add('/a', handler: 'HelloWorld')->namespace('Demo\\'); + $collection->add('/b')->namespace('Demo\\')->run('\\HelloWorld'); + $collection->add('/c', handler: ['HelloWorld', 'run'])->namespace('Demo\\'); + $collection->add('/d', handler: ['\\HelloWorld', 'run'])->namespace('Demo\\'); + $collection->add('/e', handler: new ResourceHandler('\\BlankRestful'))->namespace('Demo\\'); + $collection->add('/f', handler: new ResourceHandler('BlankRestful'))->namespace('Demo\\'); + + $routes = $collection->getRoutes(); + t\assertSame('Demo\\HelloWorld', $routes[0]['handler']); + t\assertSame('\\HelloWorld', $routes[1]['handler']); + t\assertSame(['Demo\\HelloWorld', 'run'], $routes[2]['handler']); + t\assertSame(['\\HelloWorld', 'run'], $routes[3]['handler']); + t\assertSame(['BlankRestful', 'Action'], $routes[4]['handler']('')); + t\assertSame(['Demo\\BlankRestful', 'Action'], $routes[5]['handler']('')); +}); + +test('if an array like route handler can be namespaced', function (): void { + $collection = new RouteCollection(); + $collection->add('/g', handler: ['1', 2, '3'])->namespace('Demo\\'); + $this->fail('Expected an exception to be thrown as route handler is invalid'); +})->throws( + InvalidControllerException::class, + 'Cannot use a non callable like array as route handler.' +); + +test('if route handler namespace ending slash can be omitted', function (): void { + $collection = new RouteCollection(); + $collection->add('/g', handler: 'HelloWorld')->namespace('Demo'); + $this->fail('Expected an exception to be thrown as route handler\'s namespace is invalid'); +})->throws( + InvalidControllerException::class, + 'Cannot set a route\'s handler namespace "Demo" without an ending "\".' +); + +test('if certain methods in the route collection class fails when route not set', function (): void { + $collection = new RouteCollection(); + + try { + $collection->bind('hello'); + $this->fail('Expected to throw an exception as a name cannot be set to an empty route'); + } catch (\InvalidArgumentException $e) { + t\assertEquals('Cannot use the "bind()" method if route not defined.', $e->getMessage()); } - public function testEmptyPrototype(): void - { - $collector = new RouteCollection(); - $collector - ->prefix('') - ->end(); - $collector->get('/foo'); - - $this->assertEquals('/foo', $collector->getRoutes()[0]->getPath()); - - $this->expectExceptionObject(new \RuntimeException('Prototyping "domain" route method failed as routes collection is frozen.')); - $collector->prototype(['domain' => 'biurad.com']); + try { + $collection->path('hello'); + $this->fail('Expected to throw an exception as a path cannot be set to an empty route'); + } catch (\InvalidArgumentException $e) { + t\assertEquals('Cannot use the "path()" method if route not defined.', $e->getMessage()); } - public function testRequestMethodAsCollectionMethod(): void - { - $collector = new RouteCollection(); - $collector->get('/get'); - $collector->post('/post'); - $collector->put('/put'); - $collector->patch('/patch'); - $collector->delete('/delete'); - $collector->options('/options'); - $collector->any('/any'); - $collector->resource('/resource', Fixtures\BlankRestful::class, 'user'); - - $routes = $this->getIterable($collector); - $routes->uasort(function (Route $a, Route $b): int { - return \strcmp($a->getPath(), $b->getPath()); - }); - - $this->assertEquals([ - [ - 'name' => null, - 'path' => '/any', - 'hosts' => [], - 'methods' => Router::HTTP_METHODS_STANDARD, - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/delete', - 'hosts' => [], - 'methods' => [Router::METHOD_DELETE], - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/get', - 'hosts' => [], - 'methods' => Route::DEFAULT_METHODS, - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/options', - 'hosts' => [], - 'methods' => [Router::METHOD_OPTIONS], - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/patch', - 'hosts' => [], - 'methods' => [Router::METHOD_PATCH], - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/post', - 'hosts' => [], - 'methods' => [Router::METHOD_POST], - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/put', - 'hosts' => [], - 'methods' => [Router::METHOD_PUT], - 'handler' => null, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - [ - 'name' => null, - 'path' => '/resource', - 'hosts' => [], - 'methods' => Router::HTTP_METHODS_STANDARD, - 'handler' => ResourceHandler::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], - ], Fixtures\Helper::routesToArray($routes)); + try { + $collection->prototype(['run' => 'phpinfo']); + $this->fail('Expected to throw an exception as a handler cannot be set to an empty route'); + } catch (\InvalidArgumentException $e) { + t\assertEquals('Cannot use the "run()" method if route not defined.', $e->getMessage()); } +}); + +test('if route path is not valid', function (): void { + $collection = new RouteCollection(); + $collection->add('//localhost'); + $this->fail('Expected to throw an exception as route path is not valid'); +})->throws( + UriHandlerException::class, + 'The route pattern "//localhost" is invalid as route path must be present in pattern.' +); + +test('if route path can resolve all accepted constraints', function (): void { + $collection = new RouteCollection(); + $collection->add('https://example.com/a/{b}*'); + $collection->add('//example.com/c/{d}*', handler: 'HelloWorld'); + $collection->add('/e/{f}*'); + + t\assertEquals(<<<'EOT' + [ + [ + 'handler' => [ + 'HelloWorld', + 'handle', + ], + 'schemes' => [ + 'https' => true, + ], + 'hosts' => [ + 'example.com' => true, + ], + 'prefix' => '/a', + 'path' => '/a/{b}', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => [ + 'HelloWorld', + 'handle', + ], + 'hosts' => [ + 'example.com' => true, + ], + 'prefix' => '/c', + 'path' => '/c/{d}', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => 'phpinfo', + 'prefix' => '/e', + 'path' => '/e/{f}', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + ] + EOT, debugFormat($collection->getRoutes())); +}); - public function testGroupWithInvalidController(): void - { - $this->expectException(\TypeError::class); +test('if route path can be prefixed', function (array|string $prefixes, string $path, string|array $expected): void { + $collection = new RouteCollection(); + $collection->add($path); - $collector = new RouteCollection(); - $collector->group('invalid', new Fixtures\BlankController()); + if (!\is_array($expected)) { + $expected = [$expected]; } - public function testMultipleSamePrototypeCallGroupPrototype(): void - { - $collector = new RouteCollection(); - $collector - ->prefix('/foo') - ->prefix('/bar'); - - $collector - ->scheme('http') - ->scheme('https'); - - $collector->get('/', Fixtures\BlankRequestHandler::class)->bind('home'); - - $this->assertEquals([ - 'name' => 'home', - 'path' => '/foo/bar/', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => ['http', 'https'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], Fixtures\Helper::routesToArray($collector->getRoutes(), true)); + foreach ((array) $prefixes as $i => $prefix) { + $collection->prefix($prefix); + t\assertSame($expected[$i], \current($collection->getRoutes())['path']); } - - public function testDeepGrouping(): void - { - $collector = new RouteCollection(); - $collector->get('/', new Fixtures\BlankRequestHandler())->bind('home'); - - $collector - ->group('api.') - ->prefix('/api') - - ->routes([ - Route::to('/', Route::DEFAULT_METHODS, new Fixtures\BlankRequestHandler())->bind('home'), - Route::to('/ping', Route::DEFAULT_METHODS, new Fixtures\BlankRequestHandler())->bind('ping'), - ]) - - ->group() - ->scheme('https', 'http') - ->method(Router::METHOD_CONNECT) - ->default('hello', 'world') - - ->head('hello', new Fixtures\BlankRequestHandler())->bind('hello')->argument('foo', 'hello')->end() - - ->method(Router::METHOD_OPTIONS)->piped('web') - ->end() - ->group() - ->prototype(['prefix' => '/v1', 'domain' => 'https://youtube.com']) - - ->group() - ->prefix('/section') - - ->post('/create', new Fixtures\BlankRequestHandler())->bind('section.create') - ->patch('/update/{id}', new Fixtures\BlankRequestHandler())->bind('section.update') - ->end() - ->group() - ->prefix('/product') - - ->post('/create', new Fixtures\BlankRequestHandler())->bind('product.create')->end() - ->patch('/update/{id}', new Fixtures\BlankRequestHandler())->bind('product.update')->end() - ->end() - ->end() - ->end() - ->get('/about-us', new Fixtures\BlankRequestHandler())->bind('about-us')->end(); - - $this->assertCount(9, $routes = $this->getIterable($collector)); - $routes->uasort(static function (Route $a, Route $b): int { - return \strcmp($a->getName(), $b->getName()); - }); - - $this->assertEquals(['web'], $routes[4]->getPiped()); - $routes = Fixtures\Helper::routesToArray($routes); - - $this->assertEquals([ - 'name' => 'home', +})->with([ + ['', '/bar', '/bar'], + ['/foo', '/bar', '/foo/bar'], + [['/c', '/b', '/a'], '/hello', ['/c/hello', '/b/c/hello', '/a/b/c/hello']], + [['/c.', 'b.', '/a.'], '/hello', ['/c.hello', '/b.c.hello', '/a.b.c.hello']], + ['/foo/', '/bar', '/foo/bar'], + ['/bar~', '/foo', '/bar~foo'], +]); + +test('if route internal data name can be overridden by the set method', function (): void { + $collection = new RouteCollection(); + $collection->set('name', 'Divine'); +})->throws( + \InvalidArgumentException::class, + 'Cannot replace the default "name" route binding.' +); + +test('if route collection can deep override route', function (bool $c1, bool $c2, array $expected): void { + $collection = new RouteCollection(); + $collection->add('/foo', ['GET']); + + $collection1 = new RouteCollection(); + $collection1->add('/foo', ['GET']); + + $collection2 = new RouteCollection(); + $collection2->add('foo', ['GET']); + + $collection1->populate($collection2, $c2); + $collection->populate($collection1, $c1); + + t\assertCount(3, $routes = $collection->getRoutes()); + t\assertEquals($expected, \array_map(fn (array $route) => $route['name'] ?? null, $routes)); +})->with([ + [true, true, [null, 'GET_foo', 'GET_foo']], + [false, true, [null, 2 => null, 3 => 'GET_foo']], + [true, false, [null, 'GET_foo', 'GET_foo_1']], + [false, false, [null, null, null]], +]); + +test('if unnamed routes can in a nested group can be named', function (): void { + $controllers = new RouteCollection(); + $controllers->group('', $rootA = new RouteCollection()); + $controllers->group('', $rootB = new RouteCollection()); + + $rootA->add('/leaf-a', []); + $rootB->add('/leaf_a', []); + $rootA->add('/leaf_a', ['GET']); + $rootB->add('/leaf_a', ['GET']); + + $this->assertCount(4, $routes = $controllers->getRoutes()); + $this->assertEquals( + ['_leaf_a', 'GET_leaf_a', '_leaf_a_1', 'GET_leaf_a_1'], + \array_map(fn (array $route) => $route['name'] ?? null, $routes) + ); +}); + +test('if route collections routes and groups can be prototyped', function (): void { + $collection = new RouteCollection(); + $collection->add('/hello')->prototype([ + 'bind' => 'greeting', + 'method' => 'OPTIONS', + 'scheme' => ['http'], + ]); + + $group = $collection->group(return: true)->prototype([ + 'domain' => 'biurad.com', + 'method' => ['OPTIONS'], + ]); + $group->add('/foo', ['GET']); + $group->end(); + + t\assertEquals(<<<'EOT' + [ + [ + 'handler' => null, + 'prefix' => '/hello', + 'path' => '/hello', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + 'OPTIONS' => true, + ], + 'name' => 'greeting', + 'schemes' => [ + 'http' => true, + ], + ], + [ + 'handler' => null, + 'prefix' => '/foo', + 'path' => '/foo', + 'hosts' => [ + 'biurad.com' => true, + ], + 'methods' => [ + 'OPTIONS' => true, + 'GET' => true, + ], + 'name' => 'OPTIONS_GET_foo', + ], + ] + EOT, debugFormat($collection->getRoutes())); +}); + +test('if simple route grouping is possible', function (): void { + $collection = new RouteCollection(); + $collection->namespace('Controller\\'); + $collection->defaults(['hello' => 'world']); + $collection->placeholders(['locale' => 'en|fr']); + $collection->scheme('http'); + $collection->piped('auth'); + $collection->group(return: true) + ->argument('name', 'Divine') + ->get('/hello/{name}', 'HelloWorld::handle') + ->namespace('Greet\\') + ->placeholder('name', '\w+') + ; + $collection->prefix('{locale}/'); + $collection->method('OPTIONS'); + $collection->namespace('App\\'); + $collection->argument('foo', 'bar'); + $collection->placeholder('bar', '\w+'); + $collection->scheme('https'); + $collection->piped('web'); + $collection->prototype(true) + ->domain('example.com') + ->defaults(['data' => ['hello' => 'world']]) + ->end() + ; + + t\assertEquals(<<<'EOT' + [ + 'handler' => 'App\\Greet\\Controller\\HelloWorld::handle', + 'prefix' => null, + 'path' => '/{locale}/hello/{name}', + 'defaults' => [ + 'hello' => 'world', + 'data' => [ + 'hello' => 'world', + ], + ], + 'placeholders' => [ + 'locale' => 'en|fr', + 'name' => '\\w+', + 'bar' => '\\w+', + ], + 'schemes' => [ + 'http' => true, + 'https' => true, + ], + 'middlewares' => [ + 'auth' => true, + 'web' => true, + ], + 'arguments' => [ + 'name' => 'Divine', + 'foo' => 'bar', + ], + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + 'OPTIONS' => true, + ], + 'hosts' => [ + 'example.com' => true, + ], + 'name' => 'GET_HEAD_OPTIONS_locale_hello_name', + ] + EOT, debugFormat(\current($collection->getRoutes()))); +}); + +test('if deep route grouping is possible', function (): void { + $collection = new RouteCollection(); + $collection->get('/', 'Home::index')->bind('home'); + + $nested = new RouteCollection(); + $nested->get('/', 'Home::indexApi'); + $nested->get('/ping', 'Home::ping')->bind('ping'); + + $collection->group('api.', return: true) + ->prefix('/api') + ->populate($nested) + ->group(null, static function (RouteCollection $collection): void { + $collection->scheme('https', 'http') + ->method('CONNECT') + ->set('something', 'different') + ->get('hello', 'Home::greet')->bind('hello')->argument('foo', 'hello')->end() + ->method('OPTIONS')->piped('web'); + }) + ->group(return: true) + ->prototype(['prefix' => '/v1', 'domain' => 'https://products.example.com']) + ->group(return: true) + ->prefix('/section') + ->post('/create', 'Home::createSection')->bind('section.create') + ->patch('/update/{id}', 'Home::sectionUpdate')->bind('section.update') + ->end() + ->group(return: true) + ->prefix('/product') + ->post('/create', 'Home::createProduct')->bind('product.create') + ->patch('/update/{id}', 'Home::productUpdate')->bind('product.update') + ->end() + ->end() + ->get('/about-us', 'Home::aboutUs')->bind('about-us')->sort(); + + t\assertCount(9, $collection); + t\assertEquals(['web' => true], ($routes = $collection->getRoutes())[2]['middlewares']); + t\assertEquals(<<<'EOT' + [ + [ + 'handler' => 'Home::index', + 'prefix' => null, 'path' => '/', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[8]); - - $this->assertEquals([ - 'name' => 'api.home', - 'path' => '/api/', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[2]); - - $this->assertEquals([ - 'name' => 'api.ping', - 'path' => '/api/ping', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[3]); - - $this->assertEquals([ - 'name' => 'api.hello', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'home', + ], + [ + 'handler' => 'Home::aboutUs', + 'prefix' => '/api/about-us', + 'path' => '/api/about-us', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'api.about-us', + ], + [ + 'handler' => 'Home::greet', + 'prefix' => '/api/hello', 'path' => '/api/hello', - 'hosts' => [], - 'methods' => [Router::METHOD_HEAD, Router::METHOD_CONNECT, Router::METHOD_OPTIONS], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => ['https', 'http'], - 'defaults' => ['hello' => 'world'], - 'patterns' => [], - 'arguments' => ['foo' => 'hello'], - ], $routes[1]); - - $this->assertEquals([ - 'name' => 'api.section.create', - 'path' => '/api/v1/section/create', - 'hosts' => ['youtube.com'], - 'methods' => [Router::METHOD_POST], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => ['https'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[6]); - - $this->assertEquals([ - 'name' => 'api.section.update', - 'path' => '/api/v1/section/update/{id}', - 'hosts' => ['youtube.com'], - 'methods' => [Router::METHOD_PATCH], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => ['https'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[7]); - - $this->assertEquals([ - 'name' => 'api.product.create', + 'schemes' => [ + 'https' => true, + 'http' => true, + ], + 'methods' => [ + 'CONNECT' => true, + 'GET' => true, + 'HEAD' => true, + 'OPTIONS' => true, + ], + 'something' => 'different', + 'name' => 'api.hello', + 'arguments' => [ + 'foo' => 'hello', + ], + 'middlewares' => [ + 'web' => true, + ], + ], + [ + 'handler' => 'Home::createProduct', + 'prefix' => '/api/v1/product/create', 'path' => '/api/v1/product/create', - 'hosts' => ['youtube.com'], - 'methods' => [Router::METHOD_POST], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => ['https'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[4]); - - $this->assertEquals([ - 'name' => 'api.product.update', + 'schemes' => [ + 'https' => true, + ], + 'hosts' => [ + 'products.example.com' => true, + ], + 'methods' => [ + 'POST' => true, + ], + 'name' => 'api.product.create', + ], + [ + 'handler' => 'Home::createSection', + 'prefix' => '/api/v1/section/create', + 'path' => '/api/v1/section/create', + 'schemes' => [ + 'https' => true, + ], + 'hosts' => [ + 'products.example.com' => true, + ], + 'methods' => [ + 'POST' => true, + ], + 'name' => 'api.section.create', + ], + [ + 'handler' => 'Home::indexApi', + 'prefix' => [ + '/api', + null, + ], + 'path' => '/api/', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'api.GET_HEAD_api_', + ], + [ + 'handler' => 'Home::ping', + 'prefix' => [ + '/api/ping', + '/ping', + ], + 'path' => '/api/ping', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'api.ping', + ], + [ + 'handler' => 'Home::productUpdate', + 'prefix' => '/api/v1/product/update', 'path' => '/api/v1/product/update/{id}', - 'hosts' => ['youtube.com'], - 'methods' => [Router::METHOD_PATCH], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => ['https'], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[5]); - - $this->assertEquals([ - 'name' => 'about-us', - 'path' => '/about-us', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => Fixtures\BlankRequestHandler::class, - 'schemes' => [], - 'defaults' => [], - 'patterns' => [], - 'arguments' => [], - ], $routes[0]); - } - - /** - * @dataProvider provideCollectionData - */ - public function testCollectionGroupingAndWithCache(bool $cached): void - { - $router = new Router(null, $cached ? self::$cacheFile : null); - $router->setCollection(static function (RouteCollection $mergedCollection): void { - // Collection without names - $demoCollection = new RouteCollection(); - $demoCollection->add(new Route('/admin/post/', Router::METHOD_POST)); - $demoCollection->add(new Route('/admin/post/new', Router::METHOD_POST)); - $demoCollection->add((new Route('/admin/post/{id}', Router::METHOD_POST))->assert('id', '\d+')); - $demoCollection->add((new Route('/admin/post/{id}/edit', Router::METHOD_PATCH))->assert('id', '\d+')); - $demoCollection->add((new Route('/admin/post/{id}/delete', Router::METHOD_DELETE))->assert('id', '\d+')); - $demoCollection->add(new Route('/blog/', Router::METHOD_GET)); - $demoCollection->add(new Route('/blog/rss.xml', Router::METHOD_GET)); - $demoCollection->add((new Route('/blog/page/{page}', Router::METHOD_GET))->assert('id', '\d+')); - $demoCollection->add((new Route('/blog/posts/{page}', Router::METHOD_GET))->assert('id', '\d+')); - $demoCollection->add((new Route('/blog/comments/{id}/new', Router::METHOD_GET))->assert('id', '\d+')); - $demoCollection->add(new Route('/blog/search', Router::METHOD_GET)); - $demoCollection->add(new Route('/login', Router::METHOD_POST)); - $demoCollection->add(new Route('/logout', Router::METHOD_POST)); - $demoCollection->add(new Route('/', Router::METHOD_GET))->end(); - $demoCollection->prefix('/{_locale}'); - $demoCollection->method(Router::METHOD_CONNECT); - $mergedCollection->group('demo.', $demoCollection)->default('_locale', 'en')->assert('_locale', 'en|fr'); - - $chunkedCollection = new RouteCollection(); - $chunkedCollection - ->domain('http://localhost') - ->scheme('https', 'http'); - - for ($i = 0; $i < 100; ++$i) { - $chunkedCollection->get('/chuck' . $i . '/{a}/{b}/{c}/')->bind('_' . $i); - } - $mergedCollection->group('chuck_', $chunkedCollection); - - $groupOptimisedCollection = new RouteCollection(); - $groupOptimisedCollection->addRoute('/a/11', [Router::METHOD_GET])->bind('a_first'); - $groupOptimisedCollection->addRoute('/a/22', [Router::METHOD_GET])->bind('a_second'); - $groupOptimisedCollection->addRoute('/a/333', [Router::METHOD_GET])->bind('a_third'); - $groupOptimisedCollection->addRoute('/{param}', [Router::METHOD_GET])->bind('a_wildcard'); - $groupOptimisedCollection->addRoute('/a/44/', [Router::METHOD_GET])->bind('a_fourth'); - $groupOptimisedCollection->addRoute('/a/55/', [Router::METHOD_GET])->bind('a_fifth'); - $groupOptimisedCollection->addRoute('/nested/{param}', [Router::METHOD_GET])->bind('nested_wildcard'); - $groupOptimisedCollection->addRoute('/nested/group/a/', [Router::METHOD_GET])->bind('nested_a'); - $groupOptimisedCollection->addRoute('/nested/group/b/', [Router::METHOD_GET])->bind('nested_b'); - $groupOptimisedCollection->addRoute('/nested/group/c/', [Router::METHOD_GET])->bind('nested_c'); - $groupOptimisedCollection->addRoute('a_sixth', [Router::METHOD_GET], '/a/66/', Fixtures\BlankController::class); - - $groupOptimisedCollection->addRoute('/slashed/group/', [Router::METHOD_GET])->bind('slashed_a'); - $groupOptimisedCollection->addRoute('/slashed/group/b/', [Router::METHOD_GET])->bind('slashed_b'); - $groupOptimisedCollection->addRoute('/slashed/group/c/', [Router::METHOD_GET])->bind('slashed_c'); - - $mergedCollection->group('', $groupOptimisedCollection); - }); - - $this->assertCount(128, $routes = $router->getMatcher()->getRoutes()); - \uasort($routes, static function (Route $a, Route $b): int { - return \strcmp($a->getName(), $b->getName()); - }); - - $this->assertEquals([ - 0 => 'GET_a_sixth', - 1 => 'a_fifth', - 2 => 'a_first', - 3 => 'a_fourth', - 4 => 'a_second', - 5 => 'a_third', - 6 => 'a_wildcard', - 7 => 'chuck__0', - 8 => 'chuck__1', - 9 => 'chuck__10', - 10 => 'chuck__11', - 11 => 'chuck__12', - 12 => 'chuck__13', - 13 => 'chuck__14', - 14 => 'chuck__15', - 15 => 'chuck__16', - 16 => 'chuck__17', - 17 => 'chuck__18', - 18 => 'chuck__19', - 19 => 'chuck__2', - 20 => 'chuck__20', - 21 => 'chuck__21', - 22 => 'chuck__22', - 23 => 'chuck__23', - 24 => 'chuck__24', - 25 => 'chuck__25', - 26 => 'chuck__26', - 27 => 'chuck__27', - 28 => 'chuck__28', - 29 => 'chuck__29', - 30 => 'chuck__3', - 31 => 'chuck__30', - 32 => 'chuck__31', - 33 => 'chuck__32', - 34 => 'chuck__33', - 35 => 'chuck__34', - 36 => 'chuck__35', - 37 => 'chuck__36', - 38 => 'chuck__37', - 39 => 'chuck__38', - 40 => 'chuck__39', - 41 => 'chuck__4', - 42 => 'chuck__40', - 43 => 'chuck__41', - 44 => 'chuck__42', - 45 => 'chuck__43', - 46 => 'chuck__44', - 47 => 'chuck__45', - 48 => 'chuck__46', - 49 => 'chuck__47', - 50 => 'chuck__48', - 51 => 'chuck__49', - 52 => 'chuck__5', - 53 => 'chuck__50', - 54 => 'chuck__51', - 55 => 'chuck__52', - 56 => 'chuck__53', - 57 => 'chuck__54', - 58 => 'chuck__55', - 59 => 'chuck__56', - 60 => 'chuck__57', - 61 => 'chuck__58', - 62 => 'chuck__59', - 63 => 'chuck__6', - 64 => 'chuck__60', - 65 => 'chuck__61', - 66 => 'chuck__62', - 67 => 'chuck__63', - 68 => 'chuck__64', - 69 => 'chuck__65', - 70 => 'chuck__66', - 71 => 'chuck__67', - 72 => 'chuck__68', - 73 => 'chuck__69', - 74 => 'chuck__7', - 75 => 'chuck__70', - 76 => 'chuck__71', - 77 => 'chuck__72', - 78 => 'chuck__73', - 79 => 'chuck__74', - 80 => 'chuck__75', - 81 => 'chuck__76', - 82 => 'chuck__77', - 83 => 'chuck__78', - 84 => 'chuck__79', - 85 => 'chuck__8', - 86 => 'chuck__80', - 87 => 'chuck__81', - 88 => 'chuck__82', - 89 => 'chuck__83', - 90 => 'chuck__84', - 91 => 'chuck__85', - 92 => 'chuck__86', - 93 => 'chuck__87', - 94 => 'chuck__88', - 95 => 'chuck__89', - 96 => 'chuck__9', - 97 => 'chuck__90', - 98 => 'chuck__91', - 99 => 'chuck__92', - 100 => 'chuck__93', - 101 => 'chuck__94', - 102 => 'chuck__95', - 103 => 'chuck__96', - 104 => 'chuck__97', - 105 => 'chuck__98', - 106 => 'chuck__99', - 107 => 'demo.DELETE_CONNECT_locale_admin_post_id_delete', - 108 => 'demo.GET_CONNECT_locale_', - 109 => 'demo.GET_CONNECT_locale_blog_', - 110 => 'demo.GET_CONNECT_locale_blog_comments_id_new', - 111 => 'demo.GET_CONNECT_locale_blog_page_page', - 112 => 'demo.GET_CONNECT_locale_blog_posts_page', - 113 => 'demo.GET_CONNECT_locale_blog_rss.xml', - 114 => 'demo.GET_CONNECT_locale_blog_search', - 115 => 'demo.PATCH_CONNECT_locale_admin_post_id_edit', - 116 => 'demo.POST_CONNECT_locale_admin_post_', - 117 => 'demo.POST_CONNECT_locale_admin_post_id', - 118 => 'demo.POST_CONNECT_locale_admin_post_new', - 119 => 'demo.POST_CONNECT_locale_login', - 120 => 'demo.POST_CONNECT_locale_logout', - 121 => 'nested_a', - 122 => 'nested_b', - 123 => 'nested_c', - 124 => 'nested_wildcard', - 125 => 'slashed_a', - 126 => 'slashed_b', - 127 => 'slashed_c', - ], Fixtures\Helper::routesToNames($routes)); - - $route1 = $router->matchRequest(new ServerRequest(Router::METHOD_GET, '/fr/blog')); - $route2 = $router->matchRequest(new ServerRequest(Router::METHOD_GET, 'http://localhost/chuck12/hello/1/2/')); - - $this->assertEquals([ - [ - 'name' => 'demo.GET_CONNECT_locale_blog_', - 'path' => '/{_locale}/blog/', - 'hosts' => [], - 'methods' => [Router::METHOD_GET, Router::METHOD_CONNECT], - 'handler' => null, - 'schemes' => [], - 'defaults' => ['_locale' => 'en'], - 'arguments' => ['_locale' => 'fr'], - 'patterns' => ['_locale' => 'en|fr'], - ], - [ - 'name' => 'chuck__12', - 'path' => '/chuck12/{a}/{b}/{c}/', - 'hosts' => ['localhost'], - 'methods' => [Router::METHOD_GET, Router::METHOD_HEAD], - 'handler' => null, - 'schemes' => ['http', 'https'], - 'defaults' => [], - 'arguments' => ['a' => 'hello', 'b' => 1, 'c' => 2], - 'patterns' => [], - ], - ], Fixtures\Helper::routesToArray([$route1, $route2])); - - $this->assertEquals($cached, $router->isCached()); - $this->assertEquals('/hello', (string) $router->generateUri('a_wildcard', ['param' => 'hello'])); - $this->assertInstanceOf(RouteCompiler::class, $router->getMatcher()->getCompiler()); - } - - /** - * @return array> - */ - public function provideCollectionData(): array - { - return [[false], [true], [true]]; - } - - /** - * @return array>int,array> - */ - public function populationProvider(): array - { - // [collection1, collection2, expect] - return [ - [true, true, [null, 'GET_foo', 'GET_foo']], - [false, true, [null, null, 'GET_foo']], - [true, false, [null, 'GET_foo', 'GET_foo_1']], - [false, false, [null, null, null]], - ]; - } - - /** - * Return Collections Routes as iterator. - * - * @return \ArrayIterator - */ - private function getIterable(RouteCollection $collection): \ArrayIterator - { - $routes = $collection->getRoutes(); - - return new \ArrayIterator($routes); - } -} + 'schemes' => [ + 'https' => true, + ], + 'hosts' => [ + 'products.example.com' => true, + ], + 'methods' => [ + 'PATCH' => true, + ], + 'name' => 'api.product.update', + ], + [ + 'handler' => 'Home::sectionUpdate', + 'prefix' => '/api/v1/section/update', + 'path' => '/api/v1/section/update/{id}', + 'schemes' => [ + 'https' => true, + ], + 'hosts' => [ + 'products.example.com' => true, + ], + 'methods' => [ + 'PATCH' => true, + ], + 'name' => 'api.section.update', + ], + ] + EOT, debugFormat($routes)); +}); + +test('if attribute route is resolvable', function (): void { + $params = [ + 'name' => 'foo', + 'path' => '/foo', + 'methods' => ['GET'], + ]; + $route = new Route($params['path'], $params['name'], $params['methods']); + + t\assertSame($params['name'], $route->name); + t\assertSame($params['path'], $route->path); + t\assertSame($params['methods'], $route->methods); + + // default property values... + t\assertSame([], $route->defaults); + t\assertSame([], $route->arguments); + t\assertSame([], $route->where); + t\assertSame([], $route->schemes); + t\assertSame([], $route->hosts); +}); + +test('if fetching of attribute/annotation routes from directories is possible', function (): void { + $reader = new AnnotationLoader(new MergeReader([new AnnotationReader(), new AttributeReader()])); + $reader->listener(new Listener()); + $reader->resource(...[ + __DIR__.'/../tests/Fixtures/Annotation/Route/Valid', + __DIR__.'/../tests/Fixtures/Annotation/Route/Containerable', + __DIR__.'/../tests/Fixtures/Annotation/Route/Attribute', + __DIR__.'/../tests/Fixtures/Annotation/Route/Abstracts', // Abstract should be excluded + ]); + t\assertCount(26, $routes = $reader->load(Listener::class)); + + $collection = new RouteCollection(); + $collection->populate($routes, true); + $names = \array_map(fn (array $v) => $v['name'] ?? null, $collection->getRoutes()); + \sort($names); + t\assertSame([ + 'GET_HEAD_get', + 'GET_HEAD_get_1', + 'GET_HEAD_testing_', + 'GET_POST_default', + 'POST_post', + 'PUT_put', + 'action', + 'attribute_GET_HEAD_defaults_localespecific_none', + 'attribute_specific_name', + 'class_group@CONNECT_GET_HEAD_get', + 'class_group@CONNECT_POST_post', + 'class_group@CONNECT_PUT_put', + 'do.action', + 'do.action_two', + 'english_locale', + 'foo', + 'french_locale', + 'hello_with_default', + 'hello_without_default', + 'home', + 'lol', + 'method_not_array', + 'ping', + 'sub-dir:bar', + 'sub-dir:foo', + 'user__restful', + ], $names); + + $routes->sort(); + t\assertEquals(<<<'EOT' + [ + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\DefaultNameController', + 'default', + ], + 'prefix' => '/default', + 'path' => '/default', + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MultipleClassRouteController', + 'default', + ], + 'prefix' => '/en/locale', + 'path' => '/en/locale', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'english_locale', + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Containerable\\FooRequestHandler', + 'prefix' => '/foo', + 'path' => '/foo', + 'methods' => [ + 'GET' => true, + ], + 'name' => 'foo', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MultipleClassRouteController', + 'default', + ], + 'prefix' => '/fr/locale', + 'path' => '/fr/locale', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'french_locale', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MultipleMethodRouteController', + 'default', + ], + 'prefix' => '/get', + 'path' => '/get', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\DefaultNameController', + 'default', + ], + 'prefix' => '/get', + 'path' => '/get', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\ClassGroupWithoutPath', + 'default', + ], + 'prefix' => '/get', + 'path' => '/get', + 'methods' => [ + 'CONNECT' => true, + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'class_group@CONNECT_GET_HEAD_get', + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\InvokableController', + 'prefix' => '/here', + 'path' => '/here', + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + 'name' => 'lol', + 'schemes' => [ + 'https' => true, + ], + 'arguments' => [ + 'hello' => 'world', + ], + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MethodsNotArray', + 'prefix' => '/method_not_array', + 'path' => '/method_not_array', + 'methods' => [ + 'GET' => true, + ], + 'name' => 'method_not_array', + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\PingRequestHandler', + 'prefix' => '/ping', + 'path' => '/ping', + 'methods' => [ + 'HEAD' => true, + 'GET' => true, + ], + 'name' => 'ping', + 'defaults' => [ + 'foo' => 'bar', + 'bar' => 'baz', + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MultipleMethodRouteController', + 'default', + ], + 'prefix' => '/post', + 'path' => '/post', + 'methods' => [ + 'POST' => true, + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\ClassGroupWithoutPath', + 'default', + ], + 'prefix' => '/post', + 'path' => '/post', + 'methods' => [ + 'CONNECT' => true, + 'POST' => true, + ], + 'name' => 'class_group@CONNECT_POST_post', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\RouteWithPrefixController', + 'action', + ], + 'prefix' => '/prefix/path', + 'path' => '/prefix/path', + 'hosts' => [ + 'biurad.com' => true, + ], + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + 'name' => 'do.action', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\RouteWithPrefixController', + 'actionTwo', + ], + 'prefix' => '/prefix/path_two', + 'path' => '/prefix/path_two', + 'hosts' => [ + 'biurad.com' => true, + ], + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + 'name' => 'do.action_two', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MultipleMethodRouteController', + 'default', + ], + 'prefix' => '/put', + 'path' => '/put', + 'methods' => [ + 'PUT' => true, + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\ClassGroupWithoutPath', + 'default', + ], + 'prefix' => '/put', + 'path' => '/put', + 'methods' => [ + 'CONNECT' => true, + 'PUT' => true, + ], + 'name' => 'class_group@CONNECT_PUT_put', + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\Subdir\\BarRequestHandler', + 'prefix' => '/sub-dir/bar', + 'path' => '/sub-dir/bar', + 'methods' => [ + 'HEAD' => true, + 'GET' => true, + ], + 'name' => 'sub-dir:bar', + 'defaults' => [ + 'foo' => 'bar', + 'bar' => 'baz', + ], + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\Subdir\\FooRequestHandler', + 'prefix' => '/sub-dir/foo', + 'path' => '/sub-dir/foo', + 'methods' => [ + 'HEAD' => true, + 'GET' => true, + ], + 'name' => 'sub-dir:foo', + 'defaults' => [ + 'foo' => 'bar', + 'bar' => 'baz', + ], + ], + [ + 'handler' => 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\HomeRequestHandler', + 'prefix' => null, + 'path' => '/', + 'methods' => [ + 'HEAD' => true, + 'GET' => true, + ], + 'name' => 'home', + 'schemes' => [ + 'https' => true, + ], + 'hosts' => [ + 'biurad.com' => true, + ], + 'defaults' => [ + 'foo' => 'bar', + 'bar' => 'baz', + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\DefaultValueController', + 'hello', + ], + 'prefix' => '/cool', + 'path' => '/cool/{name=}', + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + 'name' => 'hello_with_default', + 'placeholders' => [ + 'name' => '\\w+', + ], + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Attribute\\GlobalDefaultsClass', + 'withName', + ], + 'prefix' => '/defaults', + 'path' => '/defaults/{locale}specific-name', + 'placeholders' => [ + 'locale' => 'en|fr', + ], + 'defaults' => [ + 'foo' => 'bar', + ], + 'methods' => [ + 'GET' => true, + ], + 'name' => 'attribute_specific_name', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Attribute\\GlobalDefaultsClass', + 'noName', + ], + 'prefix' => '/defaults', + 'path' => '/defaults/{locale}specific-none', + 'placeholders' => [ + 'locale' => 'en|fr', + ], + 'defaults' => [ + 'foo' => 'bar', + ], + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'attribute_GET_HEAD_defaults_localespecific_none', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\DefaultValueController', + 'hello', + ], + 'prefix' => '/hello', + 'path' => '/hello/{name:\\w+}', + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + 'name' => 'hello_without_default', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MethodOnRoutePattern', + 'handleSomething', + ], + 'prefix' => 'testing', + 'path' => '/testing/', + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + ], + [ + 'handler' => new ResourceHandler([ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\RestfulController', + 'User', + ]), + 'prefix' => '/user', + 'path' => '/user/{id:\\d+}', + 'methods' => [ + 'GET' => true, + ], + 'name' => 'user__restful', + ], + [ + 'handler' => [ + 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\DefaultValueController', + 'action', + ], + 'prefix' => null, + 'path' => '/{default}/path', + 'methods' => [ + 'GET' => true, + 'POST' => true, + ], + 'name' => 'action', + ], + ] + EOT, debugFormat($routes->getRoutes())); +})->setRunTestInSeparateProcess(true); + +test('if route path from attribute route can be invalid', function (): void { + $reader = new AnnotationLoader(new AnnotationReader()); + $reader->listener(new Listener()); + $reader->resource('Flight\Routing\Tests\Fixtures\Annotation\Route\Invalid\PathEmpty'); + $reader->load(Listener::class); +})->throws(InvalidAnnotationException::class, 'Attributed method route path empty'); + +test('if annotated route from class method can be a restful type', function (): void { + $reader = new AnnotationLoader(new AnnotationReader()); + $reader->listener(new Listener()); + $reader->resource('Flight\Routing\Tests\Fixtures\Annotation\Route\Invalid\MethodWithResource'); + $reader->load(Listener::class); +})->throws(InvalidAnnotationException::class, 'Restful routing is only supported on attribute route classes.'); + +test('if annotated route from class with annotated methods can be a restful type', function (): void { + $reader = new AnnotationLoader(new AnnotationReader()); + $reader->listener(new Listener()); + $reader->resource('Flight\Routing\Tests\Fixtures\Annotation\Route\Invalid\ClassGroupWithResource'); + $reader->load(Listener::class); +})->throws(InvalidAnnotationException::class, 'Restful annotated class cannot contain annotated method(s).'); diff --git a/tests/RouteHandlerTest.php b/tests/RouteHandlerTest.php deleted file mode 100644 index 45704619..00000000 --- a/tests/RouteHandlerTest.php +++ /dev/null @@ -1,210 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests; - -use Flight\Routing\Exceptions\InvalidControllerException; -use Flight\Routing\Exceptions\RouteNotFoundException; -use Flight\Routing\Handlers\CallbackHandler; -use Flight\Routing\Handlers\RouteHandler; -use Flight\Routing\Route; -use Laminas\Stratigility\MiddlewarePipe; -use Nyholm\Psr7\Factory\Psr17Factory; -use Nyholm\Psr7\Response; -use Nyholm\Psr7\ServerRequest; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; - -/** - * RouteHandlerTest. - */ -class RouteHandlerTest extends TestCase -{ - public function testConstructor(): void - { - $factory = $this->getHandler('res', true); - - $this->assertInstanceOf(RequestHandlerInterface::class, $factory); - } - - public function testRouteNotFound(): void - { - $this->expectExceptionMessage('Unable to find the controller for path "/bar". The route is wrongly configured.'); - $this->expectException(RouteNotFoundException::class); - - $factory = new RouteHandler(new Psr17Factory()); - $factory->handle($this->serverCreator()); - } - - public function testOverrideRouteNotFound(): void - { - $request = $this->serverCreator(); - $handler = new RouteHandler(new Psr17Factory()); - - $pipe1 = (new MiddlewarePipe())->process($request->withAttribute(RouteHandler::OVERRIDE_HTTP_RESPONSE, true), $handler); - $pipe2 = (new MiddlewarePipe())->process($request->withAttribute(RouteHandler::OVERRIDE_HTTP_RESPONSE, $pipe1), $handler); - - $this->assertEquals($pipe1, $pipe2); - } - - public function testHandle(): void - { - $factory = $this->getHandler('resmsg', true); - $response = $factory->handle($this->serverCreator()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals('I am a [GET] method', (string) $response->getBody()); - $this->assertEquals( - 'text/html; charset=utf-8', - $response->getHeaderLine('Content-Type') - ); - } - - public function testHandleWithException(): void - { - $this->expectException(\RuntimeException::class); - - $handler = static function (ServerRequestInterface $request): ResponseInterface { - throw new \RuntimeException('An error occurred'); - }; - - $route = new Route('/bar', Route::DEFAULT_METHODS, $handler); - $factory = new RouteHandler(new Psr17Factory()); - $factory->handle($this->serverCreator()->withAttribute(Route::class, $route)); - } - - /** - * @dataProvider implicitHandle - * - * @param mixed $body - */ - public function testHandleResponse(string $contentType, $body): void - { - if (\is_array($body)) { - $body = \json_encode($json = $body); - } - - $handler = $this->getHandler($json ?? new Response(200, [], $body), true); - $response = $handler->handle($this->serverCreator()); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals($body, (string) $response->getBody()); - $this->assertEquals($contentType, $response->getHeaderLine('Content-Type')); - } - - public function testEchoHandleResponse(): void - { - $call = static function (ServerRequestInterface $request, ResponseFactoryInterface $response): void { - echo 'Hello World To Flight Routing'; - }; - - $route = new Route('/bar', Route::DEFAULT_METHODS, $call); - $response = (new RouteHandler(new Psr17Factory())) - ->handle($this->serverCreator()->withAttribute(Route::class, $route)); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals('Hello World To Flight Routing', (string) $response->getBody()); - } - - public function testInvalidRouteHandler(): void - { - $this->expectExceptionMessage('Route has an invalid handler type of "NULL".'); - $this->expectException(InvalidControllerException::class); - - $route = new Route('/bar', Route::DEFAULT_METHODS); - (new RouteHandler(new Psr17Factory()))->handle($this->serverCreator()->withAttribute(Route::class, $route)); - } - - public function testInvalidHandlerResponse(): void - { - $this->expectExceptionMessage('The route handler\'s content is not a valid PSR7 response body stream.'); - $this->expectException(InvalidControllerException::class); - - $call = static function (): bool { - return false; - }; - $route = new Route('/bar', Route::DEFAULT_METHODS, $call); - (new RouteHandler(new Psr17Factory()))->handle($this->serverCreator()->withAttribute(Route::class, $route)); - } - - public function implicitHandle(): \Generator - { - yield 'Plain Text:' => [ - 'text/plain; charset=utf-8', - 'Hello World', - ]; - - yield 'Html Text:' => [ - 'text/html; charset=utf-8', - 'Hello World', - ]; - - yield 'Xml Text as:' => [ - 'application/xml; charset=utf-8', - 'Hello World', - ]; - - yield 'Rss Text as:' => [ - 'application/rss+xml; charset=utf-8', - '', - ]; - - yield 'Svg Text:' => [ - 'image/svg+xml', - 'Hello World', - ]; - - yield 'Json Text:' => [ - 'application/json', - ['hello' => 'world'], - ]; - } - - /** - * @param mixed $output - */ - private function getHandler($output, bool $hasResponse = false): CallbackHandler - { - $response = (new Psr17Factory())->createResponse()->withHeader('Content-Type', 'text/html; charset=utf-8'); - - $call = static function (ServerRequestInterface $request) use ($response, $output) { - if ('resmsg' === $output) { - $response->getBody()->write(\sprintf('I am a [%s] method', $request->getMethod())); - $output = null; - } - - return $output ?? $response; - }; - - if ($hasResponse) { - $call = static function (ServerRequestInterface $request) use ($call): ResponseInterface { - return (new RouteHandler(new Psr17Factory())) - ->handle($request->withAttribute(Route::class, new Route('/bar', Route::DEFAULT_METHODS, $call))); - }; - } - - return new CallbackHandler($call); - } - - private function serverCreator(): ServerRequestInterface - { - return new ServerRequest('GET', '/bar'); - } -} diff --git a/tests/RouteInvokerTest.php b/tests/RouteInvokerTest.php deleted file mode 100644 index d4b7afe8..00000000 --- a/tests/RouteInvokerTest.php +++ /dev/null @@ -1,110 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests; - -use Flight\Routing\Handlers\RouteInvoker; -use Nyholm\Psr7\ServerRequest; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - -class RouteInvokerTest extends TestCase -{ - public function testConstructor(): void - { - $invoker = new RouteInvoker(); - $this->assertIsCallable($invoker); - } - - /** - * @dataProvider handlerProvider - * - * @param mixed $handler - * @param array $arguments - * @param mixed $expect - */ - public function testHandler($handler, array $arguments, $expect): void - { - $invoker = new RouteInvoker(); - $response = $invoker($handler, $arguments + [ServerRequestInterface::class => new ServerRequest('GET', '/foo')]); - - if (\is_string($expect) && (\class_exists($expect) || \interface_exists($expect))) { - $this->assertInstanceOf($expect, $response); - } else { - $this->assertEquals($expect, $response); - } - } - - public function handlerProvider(): \Generator - { - // [handler, arguments, expect] - yield 'closure callable with named parameter' => [ - static function (string $name): string { - return $name; - }, - ['name' => 'Flight Routing'], - 'Flight Routing', - ]; - - yield 'closure callable with mixed parameter' => [ - static function ($name) { - return $name; - }, - [], - null, - ]; - - yield 'closure callable with mixed parameter and default' => [ - static function ($name = 'Flight') { - return $name; - }, - [], - 'Flight', - ]; - - yield 'class object closure' => [ - new Fixtures\InvokeController(), - [], - ResponseInterface::class, - ]; - - yield 'class string closure' => [ - Fixtures\InvokeController::class, - [], - ResponseInterface::class, - ]; - - yield 'callable with a @ separator' => [ - Fixtures\BlankController::class . '@handle', - [], - ResponseInterface::class, - ]; - - yield 'class object' => [ - new Fixtures\BlankRequestHandler(), - [], - Fixtures\BlankRequestHandler::class, - ]; - - yield 'class string' => [ - Fixtures\BlankController::class, - [], - Fixtures\BlankController::class, - ]; - } -} diff --git a/tests/RouteTest.php b/tests/RouteTest.php deleted file mode 100644 index d07d3808..00000000 --- a/tests/RouteTest.php +++ /dev/null @@ -1,281 +0,0 @@ - - * @copyright 2019 Biurad Group (https://biurad.com/) - * @license https://opensource.org/licenses/BSD-3-Clause License - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flight\Routing\Tests; - -use Flight\Routing\Route; -use Flight\Routing\Exceptions\InvalidControllerException; -use Flight\Routing\Exceptions\UriHandlerException; -use Flight\Routing\Handlers\ResourceHandler; -use PHPUnit\Framework\TestCase; - -/** - * RouteTest. - */ -class RouteTest extends TestCase -{ - public function testConstructor(): void - { - $testRoute = new Route('/hello'); - $this->assertInstanceOf(Route::class, $testRoute); - $this->assertSame('/hello', $testRoute->getPath()); - - $testRoute = Route::to('hello'); - $this->assertInstanceOf(Route::class, $testRoute); - $this->assertSame('/hello', $testRoute->getPath()); - } - - public function testSetStateMethod(): void - { - $properties = [ - 'name' => 'baz', - 'path' => '/hello', - 'methods' => Route::DEFAULT_METHODS, - 'handler' => 'phpinfo', - 'defaults' => ['foo' => 'bar'], - ]; - - $testRoute = Route::__set_state($properties); - $this->assertEquals([ - 'name' => 'baz', - 'path' => '/hello', - 'methods' => Route::DEFAULT_METHODS, - 'schemes' => [], - 'hosts' => [], - 'handler' => 'phpinfo', - 'arguments' => [], - 'defaults' => ['foo' => 'bar'], - 'patterns' => [], - ], Fixtures\Helper::routesToArray([$testRoute], true)); - } - - public function testSetStateMethodWihInvalidKey(): void - { - $routeData = Route::__set_state(['path' => '/', 'foo' => 'bar']); - - $this->assertEquals([ - 'name' => null, - 'path' => '/', - 'methods' => [], - 'schemes' => [], - 'hosts' => [], - 'handler' => null, - 'arguments' => [], - 'patterns' => [], - 'defaults' => [], - ], Fixtures\Helper::routesToArray([$routeData], true)); - } - - public function testDomainRoute(): void - { - $dRoute1 = new Route('https://biurad.com/hi'); - $this->assertEquals('/hi', $dRoute1->getPath()); - $this->assertEquals(['https'], $dRoute1->getSchemes()); - $this->assertEquals(['biurad.com'], $dRoute1->getHosts()); - - $dRoute2 = new Route('//biurad.com/hi'); - $this->assertEquals('/hi', $dRoute2->getPath()); - $this->assertEmpty($dRoute2->getSchemes()); - $this->assertEquals(['biurad.com'], $dRoute2->getHosts()); - - $dRoute3 = new Route('/hi'); - $this->assertEquals('/hi', $dRoute3->getPath()); - $this->assertEmpty($dRoute3->getSchemes()); - $this->assertEmpty($dRoute3->getHosts()); - - $dRoute4 = Route::to('//biurad.com/')->path('//localhost/foo'); - $this->assertEquals('/foo', $dRoute4->getPath()); - $this->assertEquals(['biurad.com', 'localhost'], $dRoute4->getHosts()); - - $dRoute = Route::to('https://biurad.com/hi'); - $dRoute->scheme('https')->domain('https://greet.biurad.com', 'biurad.com'); - - $this->assertEquals(['https'], $dRoute->getSchemes()); - $this->assertEquals(['biurad.com', 'greet.biurad.com'], $dRoute->getHosts()); - } - - public function testRouteName(): void - { - $testRoute = new Route('/foo'); - $testRoute->bind('foo'); - - $this->assertEquals('foo', $testRoute->getName()); - } - - public function testRouteMethods(): void - { - $testRoute1 = new Route('/foo'); - $testRoute2 = new Route('foo', []); - $testRoute3 = new Route('foo', []); - $testRoute4 = Route::to('foo', [])->method(...['connect', 'get', 'get']); - - $this->assertEquals(Route::DEFAULT_METHODS, $testRoute1->getMethods()); - $this->assertEquals($testRoute2->getMethods(), $testRoute3->getMethods()); - $this->assertEquals(['CONNECT', 'GET'], $testRoute4->getMethods()); - } - - public function testRoutePath(): void - { - $staticRoute = new Route('/foo'); - $dynamicRoute = new Route('/hi/{baz}'); - $hostRoute = new Route('//localhost/bar'); - $schemeHostRoute = new Route('ws://localhost:8080/service'); - - $this->assertEquals('/foo', $staticRoute->getPath()); - $this->assertEquals('/hi/{baz}', $dynamicRoute->getPath()); - - $this->assertEquals('/bar', $hostRoute->getPath()); - $this->assertEquals(['localhost'], $hostRoute->getHosts()); - - $this->assertEquals('/service', $schemeHostRoute->getPath()); - $this->assertEquals(['localhost:8080'], $schemeHostRoute->getHosts()); - $this->assertEquals(['ws'], $schemeHostRoute->getSchemes()); - } - - public function testRouteHandler(): void - { - $functionRoute = new Route('/foo_1', Route::DEFAULT_METHODS, 'phpinfo'); - $closureRoute = new Route('/foo_2', Route::DEFAULT_METHODS, static function () { - return 'Hello'; - }); - $invokeRoute = new Route('/foo_3', Route::DEFAULT_METHODS, new Fixtures\InvokeController()); - $requestHandlerRoute = new Route('/foo_4', Route::DEFAULT_METHODS, new Fixtures\BlankRequestHandler()); - $patternRoute1 = new Route('/foo_5*'); - $patternRoute2 = new Route('/foo_6*', Route::DEFAULT_METHODS, Fixtures\BlankController::class); - - $this->assertIsCallable($functionRoute->getHandler()); - $this->assertIsCallable($closureRoute->getHandler()); - $this->assertIsCallable($invokeRoute->getHandler()); - $this->assertInstanceOf(Fixtures\InvokeController::class, $invokeRoute->getHandler()); - $this->assertInstanceOf(Fixtures\BlankRequestHandler::class, $requestHandlerRoute->getHandler()); - $this->assertEquals([Fixtures\BlankController::class, 'handle'], $patternRoute1->getHandler()); - $this->assertSame($patternRoute1->getHandler(), $patternRoute2->getHandler()); - } - - public function testRouteArguments(): void - { - $route = Route::to('/foo')->argument('number', '345')->arguments(['hello' => 'world']); - - $this->assertEquals(['number' => 345, 'hello' => 'world'], $route->getArguments()); - } - - public function testRouteNamespace(): void - { - $testRoute1 = Route::to('/foo')->run('\\BlankController')->namespace('Flight\Routing\Tests\Fixtures'); - $testRoute2 = Route::to('/foo')->run('\\Fixtures\BlankController')->namespace('Flight\Routing\Tests'); - $testRoute3 = Route::to('/foo')->run('Fixtures\BlankController')->namespace('Flight\Routing\Tests'); - $testRoute4 = Route::to('/foo')->run(new ResourceHandler('\\Fixtures\BlankRestful', 'user'))->namespace('Flight\Routing\Tests'); - $testRoute5 = Route::to('/foo')->namespace('Flight\Routing\Tests')->run('\\Fixtures\BlankController'); - $testRoute6 = Route::to('/foo')->namespace('Flight\Routing\Tests')->run('Fixtures\BlankController'); - - $this->assertSame($testRoute1->getHandler(), $testRoute2->getHandler()); - $this->assertSame($testRoute2->getHandler(), $testRoute5->getHandler()); - $this->assertEquals('Fixtures\BlankController', $testRoute3->getHandler()); - $this->assertEquals($testRoute3->getHandler(), $testRoute6->getHandler()); - $this->assertEquals([Fixtures\BlankRestful::class, 'getUser'], $testRoute4->getHandler()('GET')); - - $this->expectExceptionMessage('Namespace "Flight\Routing\Tests\" provided for routes must not end with a "\".'); - $this->expectException(InvalidControllerException::class); - - Route::to('/foo')->run('Fixtures\BlankController')->namespace('Flight\Routing\Tests\\'); - } - - /** - * @dataProvider providePrefixAndExpectedNewPath - */ - public function testRoutePrefix(string $path, string $prefix, string $expected): void - { - $testRoute = new Route($path); - $testRoute->prefix($prefix); - - $this->assertSame($expected, $testRoute->getPath()); - } - - public function testAllowedEmptyPath(): void - { - $route = new Route(''); - $this->assertEquals('/', $route->getPath()); - } - - public function testNotAllowedEmptyPathInHost(): void - { - $this->expectExceptionMessage('The route pattern "//localhost" is invalid as route path must be present in pattern.'); - $this->expectException(UriHandlerException::class); - - new Route('//localhost'); - } - - public function testNotAllowedEmptyPathInHostAndScheme(): void - { - $this->expectExceptionMessage('The route pattern "http://biurad.com" is invalid as route path must be present in pattern.'); - $this->expectException(UriHandlerException::class); - - new Route('http://biurad.com'); - } - - /** - * @dataProvider provideRouteAndExpectedRouteName - */ - public function testDefaultRouteNameGeneration(Route $route, string $prefix, string $expectedRouteName): void - { - $route->bind($route->generateRouteName($prefix)); - - $this->assertEquals($expectedRouteName, $route->getName()); - } - - public function testRoutePipeline(): void - { - $route = new Route('/foo'); - $route->piped('web'); - - $this->assertEquals(['web'], $route->getPiped()); - } - - /** - * @return string[] - */ - public function provideRouteAndExpectedRouteName(): array - { - return [ - [new Route('/Invalid%Symbols#Stripped', 'POST'), '', 'POST_InvalidSymbolsStripped'], - [new Route('/post/{id}', 'GET'), '', 'GET_post_id'], - [new Route('/colon:pipe|dashes-escaped', ''), '', '_colon_pipe_dashes_escaped'], - [new Route('/underscores_and.periods', ''), '', '_underscores_and.periods'], - [new Route('/post/{id}', 'GET'), 'prefix', 'GET_prefix_post_id'], - ]; - } - - /** - * @return string[] - */ - public function providePrefixAndExpectedNewPath(): array - { - return [ - ['/foo', '/bar', '/bar/foo'], - ['/foo', '/bar/', '/bar/foo'], - ['/foo', 'bar', '/bar/foo'], - ['foo', '/bar', '/bar/foo'], - ['foo', 'bar', '/bar/foo'], - ['@foo', 'bar', '/bar@foo'], - ['foo', '@bar', '/@bar/foo'], - ['~foo', '/bar~', '/bar~foo'], - ['/foo', '', '/foo'], - ['foo', '', '/foo'], - ['/foo', 'bar_', '/bar_foo'], - ]; - } -} diff --git a/tests/RouterTest.php b/tests/RouterTest.php index c9715bdc..5a0fc191 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -1,6 +1,4 @@ -assertInstanceOf(MiddlewareInterface::class, $router); - $this->assertInstanceOf(UrlGeneratorInterface::class, $router); - $this->assertInstanceOf(RouteMatcherInterface::class, $router); - - $this->assertInstanceOf(RouteMatcher::class, $router->getMatcher()); - } - - public function testAddRoute(): void - { - $routes = [new Route('/foo'), new Route('/bar'), new Route('/baz')]; - - $router = Router::withCollection(); - $router->addRoute(...$routes); - - $this->assertCount(3, $router->getMatcher()->getRoutes()); - } - - public function testMiddlewareOnRoute(): void - { - $router = Router::withCollection(); - $route = new Route('/phpinfo', Router::METHOD_GET, 'phpinfo'); - $request = new ServerRequest($route->getMethods()[0], $route->getPath()); - - $router->addRoute($route); - $router->pipe(new Fixtures\PhpInfoListener()); - - $response = $router->process($request, new RouteHandler(new Psr17Factory())); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); - } - - public function testAddRouteListenerWithException(): void - { - $router = Router::withCollection(); - $route = new Route('/phpinfo', Router::METHOD_GET, 'phpinfo'); - $request = new ServerRequest($route->getMethods()[0], $route->getPath()); - - $router->addRoute($route); - - try { - $this->assertInstanceOf(ResponseInterface::class, $router->process($request, new RouteHandler(new Psr17Factory()))); - } catch (NotEnoughParametersException $e) { - $this->assertEquals( - 'Unable to invoke the callable because no value was given for parameter 1 ($what)', - $e->getMessage() - ); - } - } - - public function testSetNamespace(): void - { - $router = Router::withCollection(); - $route = new Route('/foo', Router::METHOD_GET, '\\Fixtures\\BlankRequestHandler'); - - $router->addRoute($route->namespace('Flight\\Routing\\Tests')); - - $request = new ServerRequest($route->getMethods()[0], $route->getPath()); - $route = $router->matchRequest($request); - - $this->assertInstanceOf(Route::class, $route); - $this->assertInstanceOf(ResponseInterface::class, $router->process($request, new RouteHandler(new Psr17Factory()))); - } - - public function testGenerateUri(): void - { - $route = new Route('/foo'); - $path = '.' . $route->getPath(); - - $router = Router::withCollection(); - $router->addRoute($route->bind('hello'), Route::to('https://example.com/foo')->bind('world')); - - $this->assertSame($path, (string) $router->generateUri($route->getName(), [], GeneratedUri::RELATIVE_PATH)); - $this->assertSame('https://example.com:8080/foo', (string) $router->generateUri('world', [], GeneratedUri::ABSOLUTE_URL)->withPort(8080)); - - $this->expectException(UrlGenerationException::class); - (new GeneratedUri('/hello', GeneratedUri::ABSOLUTE_PATH))->withPort(-1); - } - - public function testGenerateUriWithDomain(): void - { - $route = new Route('/foo'); - $path = 'http://biurad.com' . $route->getPath(); - - $router = Router::withCollection(); - $router->addRoute($route->domain('http://biurad.com')->bind('hello')); - - $this->assertSame($path, (string) $router->generateUri($route->getName())); - } - - public function testGenerateUriWithQuery(): void - { - $route = new Route('/foo'); - $path = $route->getPath() . '?hello=world&first=1'; - - $router = Router::withCollection(); - $router->addRoute($route->bind('hello')); - - $this->assertSame( - $path, - (string) $router->generateUri($route->getName())->withQuery(['hello' => 'world', 'first' => 1]) - ); - } - - public function testGenerateUriException(): void - { - $router = Router::withCollection(); - - $this->expectExceptionMessage('Unable to generate a URL for the named route "none" as such route does not exist.'); - $this->expectException(UrlGenerationException::class); - - $router->generateUri('none'); - } - - public function testMatch(): void - { - $routes = [ - new Route('/path1'), - new Route('/path2'), - new Route('/path3'), - new Route('/path4'), - new Route('/path5'), - ]; - - $router = Router::withCollection(); - $router->addRoute(...$routes); - - $route = $router->match($routes[2]->getMethods()[1], new Uri($routes[2]->getPath())); - - $this->assertInstanceOf(Route::class, $route); - } - - public function testMatchForUnAllowedMethod(): void - { - $routes = [ - new Route('/path1', Router::METHOD_PATCH), - new Route('/path2', Router::METHOD_PATCH), - new Route('/path3', Router::METHOD_PATCH), - new Route('/path4', Router::METHOD_PATCH), - new Route('/path5', Router::METHOD_PATCH), - ]; - - $router = Router::withCollection(); - $router->addRoute(...$routes); - - // the given exception message should be tested through exceptions factory... - $this->expectExceptionObject(new MethodNotAllowedException([Router::METHOD_PATCH], '/path3', Router::METHOD_GET)); - - try { - $router->match('GET', new Uri($routes[2]->getPath())); - } catch (MethodNotAllowedException $e) { - $this->assertSame($routes[2]->getMethods(), $e->getAllowedMethods()); - - throw $e; - } - } - - public function testMatchForUndefinedRoute(): void - { - $router = new Router(); - $router->setCollection(static function (RouteCollection $collection): void { - $collection->routes([new Route('/foo'), new Route('/bar'), new Route('/baz')]); - }); - - $this->expectExceptionMessage('Unable to find the controller for path "/". The route is wrongly configured.'); - $this->expectException(RouteNotFoundException::class); - - $router->process(new ServerRequest(Router::METHOD_GET, '/'), new RouteHandler(new Psr17Factory())); - } - - public function testHandleResponse(): void - { - $router = Router::withCollection(); - $router->addRoute($route = new Route( - '/foo', - Router::METHOD_GET, - function (ResponseFactoryInterface $responseFactory): ResponseInterface { - ($response = $responseFactory->createResponse())->getBody()->write('I am a GET method'); - - return $response; - } - )); - - $response = $router->process(new ServerRequest(Router::METHOD_GET, $route->getPath()), new RouteHandler(new Psr17Factory())); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertSame('I am a GET method', (string) $response->getBody()); - } - - public function testHandleResponseOnSubDirectory(): void - { - $subPath = '/sub-directory'; - - $router = Router::withCollection(); - $router->addRoute($route = new Route( - '/foo', - Router::METHOD_GET, - static function (ResponseFactoryInterface $responseFactory): ResponseInterface { - ($response = $responseFactory->createResponse())->getBody()->write('I am a GET method'); - - return $response; - } - )); - - $response = $router->process( - new ServerRequest(Router::METHOD_GET, $subPath . $route->getPath(), [], null, '1.1', ['PATH_INFO' => $route->getPath()]), - new RouteHandler(new Psr17Factory()) - ); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertSame('I am a GET method', (string) $response->getBody()); - } - - public function testHandleResponseOnDirectory(): void - { - $subPath = '/directory'; - - $router = Router::withCollection(); - $router->addRoute($route = new Route( - '/foo', - Router::METHOD_GET, - function (ResponseFactoryInterface $responseFactory): ResponseInterface { - ($response = $responseFactory->createResponse())->getBody()->write('I am a GET method'); - - return $response; - } - )); - - $response = $router->process( - new ServerRequest(Router::METHOD_GET, $route->getPath(), [], null, '1.1', ['SCRIPT_NAME' => $subPath]), - new RouteHandler(new Psr17Factory()) - ); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertSame('I am a GET method', (string) $response->getBody()); - } - - public function testEmptyRequestHandler(): void - { - $middlewares = [ - new Fixtures\BlankMiddleware(), - new Fixtures\BlankMiddleware(), - ]; - - $pipeline = Router::withCollection(); - $pipeline->pipe(...$middlewares); - - $this->expectExceptionMessage('Unable to find the controller for path "test". The route is wrongly configured.'); - $this->expectException(RouteNotFoundException::class); - - $pipeline->process(new ServerRequest('GET', 'test'), new RouteHandler(new Psr17Factory())); - } - - /** - * @dataProvider hasParametersData - */ - public function testAddParameters(string $path, string $body): void - { - $route = new Route( - '/{cool}', - Router::METHOD_GET, - function ($cool, string $name): string { - return "My name is {$name} with id: {$cool}"; - } - ); - - $collection = new RouteCollection(); - $collection->add($route); - $collection->assert('cool', ['23', 'me'])->argument('name', 'Divine'); - - $router = Router::withCollection($collection); - - try { - $response = $router->process(new ServerRequest(Router::METHOD_GET, $path), new RouteHandler(new Psr17Factory())); - } catch (RouteNotFoundException $e) { - $this->assertEquals($e->getMessage(), 'Unable to find the controller for path "/none". The route is wrongly configured.'); - - return; +test('if method not found in matched route', function (): void { + $collection = new RouteCollection(); + $collection->add('/hello', ['POST']); + + $router = Router::withCollection($collection); + + try { + $router->match('GET', new Uri('/hello')); + $this->fail('Expected a method nor found exception to be thrown'); + } catch (MethodNotAllowedException $e) { + t\assertSame(['POST'], $e->getAllowedMethods()); + + throw $e; + } +})->throws( + MethodNotAllowedException::class, + 'Route with "/hello" path requires request method(s) [POST], "GET" is invalid.' +); + +test('if scheme not found in matched route', function (): void { + $collection = new RouteCollection(); + $collection->add('/hello', ['GET'])->scheme('ftp'); + + $router = Router::withCollection($collection); + $router->match('GET', new Uri('http://localhost/hello')); +})->throws( + UriHandlerException::class, + 'Route with "/hello" path requires request scheme(s) [ftp], "http" is invalid.' +); + +test('if host not found in matched route', function (): void { + $collection = new RouteCollection(); + $collection->add('/hello', ['GET'])->domain('mydomain.com'); + + $router = Router::withCollection($collection); + t\assertNull($router->match('GET', new Uri('//localhost/hello'))); +}); + +test('if route can match a static host', function (): void { + $collection = new RouteCollection(); + $collection->add('/world', ['GET'])->domain('hello.com'); + + $router = Router::withCollection($collection); + $route = $router->match('GET', new Uri('//hello.com/world')); + t\assertSame('hello.com', \array_key_first($route['hosts'])); + t\assertCount(0, $route['arguments'] ?? []); +}); + +test('if route can match a dynamic host', function (): void { + $collection = new RouteCollection(); + $collection->add('/world', ['GET'])->domain('hello.{tld}'); + + $router = Router::withCollection($collection); + $route = $router->match('GET', new Uri('//hello.ghana/world')); + t\assertSame('hello.{tld}', \array_key_first($route['hosts'])); + t\assertSame(['tld' => 'ghana'], $route['arguments'] ?? []); +}); + +test('if route cannot be found by name to generate a reversed uri', function (): void { + $router = Router::withCollection(); + $router->generateUri('hello'); +})->throws(UrlGenerationException::class, 'Route "hello" does not exist.'); + +test('if route handler can be intercepted by middlewares', function (): void { + $collection = new RouteCollection(); + $collection->add('/{name}', ['GET'], new CallbackHandler(fn (ServerRequestInterface $req): string => $req->getAttribute('hello')))->piped('guard'); + + $router = Router::withCollection($collection); + $router->pipe(middleware(function (ServerRequestInterface $req, RequestHandlerInterface $handler) { + t\assertIsArray($route = $req->getAttribute(Router::class)); + t\assertNotEmpty($route); + t\assertArrayHasKey('name', $hello = $route['arguments'] ?? []); + + return $handler->handle($req->withAttribute('hello', 'Hello '.$hello['name'])); + })); + $router->pipes('guard', middleware(function (ServerRequestInterface $req, RequestHandlerInterface $handler) { + $route = $req->getAttribute(Router::class); + + if ('divine' !== $route['arguments']['name']) { + throw new \RuntimeException('Expected name to be "divine".'); } - $this->assertSame($body, (string) $response->getBody()); - } - - public function testHandleWithMiddlewares(): void - { - $route = new Route('/foo', Route::DEFAULT_METHODS, 'phpinfo'); - - $middlewares = [ - new Fixtures\BlankMiddleware(), - new Fixtures\BlankMiddleware(), - middleware([new Fixtures\BlankMiddleware(), 'process']), - ]; - - $router = Router::withCollection(); - $router->addRoute($route); - $router->pipe(...$middlewares); - - $response = $router->process( - new ServerRequest($route->getMethods()[0], $route->getPath(), [], null, '1.1', ['Broken' => 'test']), - new RouteHandler(new Psr17Factory()) - ); - - $this->assertTrue($middlewares[0]->isRunned()); - $this->assertTrue($middlewares[1]->isRunned()); - $this->assertTrue($response->hasHeader('Middleware')); - $this->assertEquals('broken', $response->getHeaderLine('Middleware-Broken')); - } - - public function testHandleWithMiddlewareOnRoute(): void - { - $route1 = new Route('/foo'); - $route2 = Route::to('/bar', Router::METHOD_PURGE)->bind('test')->piped('ping'); - $handler = function (ServerRequestInterface $request, ResponseFactoryInterface $factory): ResponseInterface { - $this->assertArrayHasKey('Broken', $request->getServerParams()); - $this->assertInstanceOf(Route::class, $request->getAttribute(Route::class)); - - ($response = $factory->createResponse())->getBody()->write(\sprintf('I am a %s method', \strtoupper($request->getMethod()))); - - return $response; - }; - - $route1->run($handler); - $route2->run($handler); - - ($router = Router::withCollection())->addRoute($route1, $route2); - $router->pipe($middleware = new Fixtures\BlankMiddleware()); - $router->pipes('ping', new Fixtures\RouteMiddleware()); - - $handler = new RouteHandler(new Psr17Factory()); - $request1 = new ServerRequest($route1->getMethods()[0], $route1->getPath(), [], null, '1.1', ['Broken' => 'test']); - $request2 = new ServerRequest($route2->getMethods()[0], $route2->getPath(), [], null, '1.1', ['Broken' => 'test']); - - foreach ([$request1, $request2] as $request) { - $response = $router->process($request, $handler); - $method = $request->getMethod(); - - if ($response->hasHeader('NamedRoute')) { - $this->assertEquals($route2->getName(), $response->getHeaderLine('NamedRoute')); + return $handler->handle($req); + })); + + $response = $router->process(new ServerRequest(Router::METHOD_GET, '/hello'), $h = new RouteHandler(new Psr17Factory())); + t\assertSame('Hello divine', (string) $response->getBody()); + $router->process(new ServerRequest(Router::METHOD_GET, '/frank'), $h); +})->throws(\RuntimeException::class, 'Expected name to be "divine".'); + +test('if route cannot be found', function (): void { + $handler = new RouteHandler(new Psr17Factory()); + $handler->handle(new ServerRequest(Router::METHOD_GET, '/hello')); +})->throws( + RouteNotFoundException::class, + 'Unable to find the controller for path "/hello". The route is wrongly configured.' +); + +test('if route not found exception can be overridden', function (): void { + $handler = new RouteHandler($f = new Psr17Factory()); + $router = new Router(); + $router->pipe(middleware( + function (ServerRequestInterface $req, RequestHandlerInterface $handler) use ($f) { + if (null === $req->getAttribute(Router::class)) { + t\assertInstanceOf(Next::class, $handler); + $response = $f->createResponse('OPTIONS' === $req->getMethod() ? 200 : 204); + + if (false === $h = $req->getAttribute('override')) { + return $response; + } // This will break the middleware chain. + $req = $req->withAttribute(RouteHandler::OVERRIDE_NULL_ROUTE, $h ?? $response); } - $this->assertTrue($middleware->isRunned()); - $this->assertTrue($response->hasHeader('Middleware')); - $this->assertEquals('broken', $response->getHeaderLine('Middleware-Broken')); - $this->assertSame(\sprintf('I am a %s method', $method), (string) $response->getBody()); + return $handler->handle($req); } - } - - public function testHandleWithBrokenMiddleware(): void - { - $route = new Route('/foo'); - - $middlewares = [ - new Fixtures\BlankMiddleware(), - new Fixtures\BlankMiddleware(true), - new Fixtures\BlankMiddleware(), - ]; - - $router = Router::withCollection(); - $router->addRoute($route); - $router->pipe(...$middlewares); - - $router->process(new ServerRequest($route->getMethods()[0], $route->getPath()), new RouteHandler(new Psr17Factory())); - - $this->assertTrue($middlewares[0]->isRunned()); - $this->assertTrue($middlewares[1]->isRunned()); - $this->assertFalse($middlewares[2]->isRunned()); - } - - public function testHandleRouteHandlerAsResponse(): void - { - $route = new Route('/foo'); - $route->run(new Response(200, ['Response' => 'Controller'])); - - $router = new Router(); - $router->addRoute($route); - $response = $router->process(new ServerRequest($route->getMethods()[0], $route->getPath()), new RouteHandler(new Psr17Factory())); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals('Controller', $response->getHeaderLine('Response')); - } - - public function testHandleForUnAllowedMethod(): void - { - $routes = [new Route('/foo'), new Route('/baz', Router::METHOD_CONNECT, 'phpinfo'), new Route('/bar')]; - - $router = Router::withCollection(); - $router->addRoute(...$routes); - - // the given exception message should be tested through exceptions factory... - $this->expectException(MethodNotAllowedException::class); - - try { - $router->process(new ServerRequest(Router::METHOD_GET, $routes[1]->getPath()), new RouteHandler(new Psr17Factory())); - } catch (MethodNotAllowedException $e) { - $this->assertSame($routes[1]->getMethods(), $e->getAllowedMethods()); - - throw $e; + )); + + $req = new ServerRequest(Router::METHOD_GET, '/hello'); + $res1 = $router->process($req->withAttribute('override', false), $handler); + $res2 = $router->process($req->withAttribute('override', false)->withMethod('OPTIONS'), $handler); + $res3 = $router->process($req->withAttribute('override', true), $handler); + t\assertSame([204, 200, 200], [$res1->getStatusCode(), $res2->getStatusCode(), $res3->getStatusCode()]); +}); + +test('if router export method can work with closures, __set_state and resource handler', function (): void { + t\assertSame( + "[[], unserialize('O:11:\"ArrayObject\":4:{i:0;i:0;i:1;a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}i:2;a:0:{}i:3;N;}'), ". + "Flight\Routing\Handlers\ResourceHandler(['ShopHandler', 'User']), ". + "Flight\Routing\Tests\Fixtures\BlankRequestHandler::__set_state(['isDone' => false, 'attributes' => ['a' => [1, 2, 3]]]]", + Router::export([[], new \ArrayObject([1, 2, 3]), new ResourceHandler('ShopHandler', 'user'), new BlankRequestHandler(['a' => [1, 2, 3]])]) + ); + + Router::export(\Closure::bind(fn () => 'Failed', null)); + $this->fail('Expected an exception to be thrown as closure cannot be serialized'); +})->throws(\Exception::class, "Serialization of 'Closure' is not allowed"); + +test('if router can be resolvable', function (int $cache): void { + $file = __DIR__."/../tests/Fixtures/cached/{$cache}/compiled.php"; + $collection = static function (RouteCollection $mergedCollection): void { + $demoCollection = new RouteCollection(); + $demoCollection->add('/admin/post/', [Router::METHOD_POST]); + $demoCollection->add('/admin/post/new', [Router::METHOD_POST]); + $demoCollection->add('/admin/post/{id}', [Router::METHOD_POST])->placeholder('id', '\d+'); + $demoCollection->add('/admin/post/{id}/edit', [Router::METHOD_PATCH])->placeholder('id', '\d+'); + $demoCollection->add('/admin/post/{id}/delete', [Router::METHOD_DELETE])->placeholder('id', '\d+'); + $demoCollection->add('/blog/', [Router::METHOD_GET]); + $demoCollection->add('/blog/rss.xml', [Router::METHOD_GET]); + $demoCollection->add('/blog/page/{page}', [Router::METHOD_GET])->placeholder('id', '\d+'); + $demoCollection->add('/blog/posts/{page}', [Router::METHOD_GET])->placeholder('id', '\d+'); + $demoCollection->add('/blog/comments/{id}/new', [Router::METHOD_GET])->placeholder('id', '\d+'); + $demoCollection->add('/blog/search', [Router::METHOD_GET]); + $demoCollection->add('/login', [Router::METHOD_POST]); + $demoCollection->add('/logout', [Router::METHOD_POST]); + $demoCollection->add('/', [Router::METHOD_GET]); + $demoCollection->prototype(true)->prefix('/{_locale}/'); + $demoCollection->method(Router::METHOD_CONNECT); + $mergedCollection->group('demo.', $demoCollection)->default('_locale', 'en')->placeholder('_locale', 'en|fr'); + + $chunkedCollection = new RouteCollection(); + $chunkedCollection->domain('http://localhost')->scheme('https', 'http'); + + for ($i = 0; $i < 100; ++$i) { + $chunkedCollection->get('/chuck'.$i.'/{a}/{b}/{c}/')->bind('_'.$i); } - } - - public function testHandleForUndefinedRoute(): void - { - $routes = [new Route('/foo'), new Route('/bar'), new Route('/baz')]; - - $router = Router::withCollection(); - $router->addRoute(...$routes); - - $this->expectExceptionMessage('Unable to find the controller for path "/". The route is wrongly configured.'); - $this->expectException(RouteNotFoundException::class); - - $router->process(new ServerRequest($routes[0]->getMethods()[0], '/'), new RouteHandler(new Psr17Factory())); - } - - public function testHandleForUndefinedScheme(): void - { - $routes = [new Route('/foo'), new Route('/bar'), new Route('/baz')]; - $routes[0]->scheme('ftp'); - - $router = Router::withCollection(); - $router->addRoute(...$routes); - - $this->expectExceptionMessage('Route with "/foo" path is not allowed on requested uri "http://localost/foo" with invalid scheme, supported scheme(s): [ftp].'); - $this->expectException(UriHandlerException::class); - - $router->process(new ServerRequest($routes[0]->getMethods()[0], 'http://localost/foo'), new RouteHandler(new Psr17Factory())); - } - - public function testHandleDomainAndPort(): void - { - $route = new Route('/foo', Route::DEFAULT_METHODS, 'phpinfo'); - - $route->domain('localhost.com:8000'); - - $router = Router::withCollection(); - $router->addRoute($route); - - $requestPath = 'http://localhost.com:8000' . $route->getPath(); - $response = $router->process(new ServerRequest($route->getMethods()[0], $requestPath), new RouteHandler(new Psr17Factory())); - $this->assertInstanceOf(ResponseInterface::class, $response); - } - - public function testHandleForUndefinedDomain(): void - { - $route = new Route('/foo', Route::DEFAULT_METHODS, 'phpinfo'); - $route->domain('{foo}.biurad.com'); - - $router = Router::withCollection(); - $router->addRoute($route); - - $this->expectExceptionMessage('Route with "/foo" path is not allowed on requested uri "http://localhost.com/foo" as uri host is invalid.'); - $this->expectException(UriHandlerException::class); - - $requestPath = 'http://localhost.com' . $route->getPath(); - $router->process(new ServerRequest($route->getMethods()[0], $requestPath), new RouteHandler(new Psr17Factory())); - } - - /** - * @dataProvider handleNamespaceData - * - * @param string|string[] $controller - */ - public function testHandleWithNamespace(string $namespace, $controller): void - { - $route = new Route('/namespace', Router::METHOD_GET, $controller); - - $router = Router::withCollection(); - $router->addRoute($route->namespace($namespace)); - - $response = $router->matchRequest(new ServerRequest(Router::METHOD_GET, '/namespace')); - - $this->assertInstanceOf(Route::class, $response); - $this->assertSame($route, $response); - } - - /** - * @return string[] - */ - public function handleNamespaceData(): array - { - return [ - ['Flight\\Routing\\Tests\\Fixtures', '\\BlankController'], - ['Flight\\Routing\\Tests', '\\Fixtures\\BlankController'], - ['Flight\\Routing\\Tests', ['\\Fixtures\\BlankController', 'handle']], - ]; - } - - /** - * @dataProvider hasResourceData - * - * @param mixed $controller - */ - public function testHandleResource(string $method, $controller): void - { - $route = new Route('/user/{id:\d+}', Router::HTTP_METHODS_STANDARD, new ResourceHandler($controller, 'user')); - - $router = Router::withCollection(); - $router->addRoute($route); - - try { - $response = $router->process(new ServerRequest($method, '/user/23'), new RouteHandler(new Psr17Factory())); - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(\strtolower($method) . ' 23', (string) $response->getBody()); - } catch (MethodNotAllowedException $e) { - $this->assertEquals( - 'Route with "/user/23" path is allowed on request method(s) ' . - '[GET,POST,PUT,PATCH,DELETE,PURGE,OPTIONS,TRACE,CONNECT], "NONE" is invalid.', - $e->getMessage() - ); - } catch (InvalidControllerException $e) { - $this->assertEquals('Route has an invalid handler type of "array".', $e->getMessage()); + $mergedCollection->group('chuck_', $chunkedCollection); + + $groupOptimisedCollection = new RouteCollection(); + $groupOptimisedCollection->add('/a/11', [Router::METHOD_GET])->bind('a_first'); + $groupOptimisedCollection->add('/a/22', [Router::METHOD_GET])->bind('a_second'); + $groupOptimisedCollection->add('/a/333', [Router::METHOD_GET])->bind('a_third'); + $groupOptimisedCollection->add('/a/333/', [Router::METHOD_POST], (object) [2, 4])->bind('a_third_1'); + // $groupOptimisedCollection->add('/{param}', [Router::METHOD_GET])->bind('a_wildcard'); + $groupOptimisedCollection->add('/a/44/', [Router::METHOD_GET])->bind('a_fourth'); + $groupOptimisedCollection->add('/a/55/', [Router::METHOD_GET])->bind('a_fifth'); + $groupOptimisedCollection->add('/nested/{param}', [Router::METHOD_GET])->bind('nested_wildcard'); + $groupOptimisedCollection->add('/nested/group/a/', [Router::METHOD_GET])->bind('nested_a'); + $groupOptimisedCollection->add('/nested/group/b/', [Router::METHOD_GET])->bind('nested_b'); + $groupOptimisedCollection->add('/nested/group/c/', [Router::METHOD_GET])->bind('nested_c'); + $groupOptimisedCollection->add('/a/66/', [Router::METHOD_GET], 'phpinfo'); + + $groupOptimisedCollection->add('/slashed/group/', [Router::METHOD_GET])->bind('slashed_a'); + $groupOptimisedCollection->add('/slashed/group/b/', [Router::METHOD_GET])->bind('slashed_b'); + $groupOptimisedCollection->add('/slashed/group/c/', [Router::METHOD_GET])->bind('slashed_c'); + + $mergedCollection->group('', $groupOptimisedCollection); + $mergedCollection->sort(); + }; + + if ($cache <= 1) { + if (\file_exists($dir = __DIR__.'/../tests/Fixtures/cached/') && 0 === $cache) { + foreach ([ + $dir.'1/compiled.php', + $dir.'3/compiled.php', + $dir.'1', + $dir.'3', + $dir, + ] as $cached) { + \is_dir($cached) ? @\rmdir($cached) : @\unlink($cached); + } } - } - - /** - * @return string[] - */ - public function hasResourceData(): array - { - return [ - [Router::METHOD_GET, new Fixtures\BlankRestful()], - [Router::METHOD_POST, Fixtures\BlankRestful::class], - ['NONE', Fixtures\BlankRestful::class], - [Router::METHOD_DELETE, 'Fixtures\BlankRestful'], - ]; - } - - /** - * @dataProvider hasCollectionGroupData - */ - public function testHandleCollectionGrouping(string $expectedMethod, string $expectedUri): void - { - $collector = new RouteCollection(); - - $collector->group('api.', function (RouteCollection $group): void { - $group->get('/', new Fixtures\BlankRequestHandler()); - $group->get('/ping', new Fixtures\BlankRequestHandler()); - - $group->group('', function (RouteCollection $group): void { - $group->prefix('/v1')->domain('https://biurad.com'); - - $group->head('/hello/{me}', new Fixtures\BlankRequestHandler())->piped('hello'); - - $group->group('_lake_')->head('/ffffffff')->end(); - }); - })->prefix('/api'); - - $router = Router::withCollection($collector); - $router->pipes('hello', $middleware = new Fixtures\BlankMiddleware()); - - $response = $router->process(new ServerRequest($expectedMethod, $expectedUri), new RouteHandler(new Psr17Factory())); + $router = new Router(cache: 1 === $cache ? $file : null); + $router->setCollection($collection); + } else { + $collection($collection = new RouteCollection()); + $router = Router::withCollection($collection); + $router->setCompiler(new RouteCompiler()); - if ('https://biurad.com/api/v1/hello/23' === $expectedUri) { - $this->assertTrue($middleware->isRunned()); - $this->assertEquals('test', $response->getHeaderLine('Middleware')); + if (3 === $cache) { + $router->setCache($file); } - - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertEquals(200, $response->getStatusCode()); - } - - /** - * @return string[] - */ - public function hasCollectionGroupData(): array - { - return [ - [Router::METHOD_GET, '/api/'], - [Router::METHOD_GET, '/api/ping'], - [Router::METHOD_HEAD, 'https://biurad.com/api/v1/hello/23'], - ]; } - /** - * @return string[] - */ - public function hasParametersData(): array - { - return [ - ['/me', 'My name is Divine with id: me'], - ['/23', 'My name is Divine with id: 23'], - ['/none', 'My name is Divine with id: 23'], - ]; - } -} + $route1 = $router->match(Router::METHOD_GET, new Uri('/fr/blog')); + $route2 = $router->matchRequest(new ServerRequest(Router::METHOD_GET, 'http://localhost/chuck12/hello/1/2')); + $route3 = $router->matchRequest(new ServerRequest(Router::METHOD_GET, '/a/333')); + $route4 = $router->matchRequest(new ServerRequest(Router::METHOD_POST, '/a/333')); + $genRoute = $router->generateUri('chuck__12', ['a', 'b', 'c'])->withQuery(['h', 'a' => 'b']); + + t\assertCount(128, $router->getCollection()); + t\assertSame($router->match('GET', new Uri('/a/66/')), $router->match('GET', new Uri('/a/66'))); + t\assertSame('/chuck12/a/b/c/?0=h&a=b#yes', (string) $genRoute->withFragment('yes')); + t\assertSame('//example.com:8080/a/11', (string) $router->generateUri('a_first', [], GeneratedUri::ABSOLUTE_URL)->withPort(8080)); + t\assertNull($router->match('GET', new Uri('/None'))); + t\assertEquals(<<<'EOT' + [ + [ + 'handler' => null, + 'prefix' => null, + 'path' => '/{_locale}/blog/', + 'methods' => [ + 'GET' => true, + 'CONNECT' => true, + ], + 'defaults' => [ + '_locale' => 'en', + ], + 'placeholders' => [ + '_locale' => 'en|fr', + ], + 'name' => 'demo.GET_CONNECT_locale_blog_', + 'arguments' => [ + '_locale' => 'fr', + ], + ], + [ + 'handler' => null, + 'prefix' => '/chuck12', + 'path' => '/chuck12/{a}/{b}/{c}/', + 'schemes' => [ + 'https' => true, + 'http' => true, + ], + 'hosts' => [ + 'localhost' => true, + ], + 'methods' => [ + 'GET' => true, + 'HEAD' => true, + ], + 'name' => 'chuck__12', + 'arguments' => [ + 'a' => 'hello', + 'b' => '1', + 'c' => '2', + ], + ], + [ + 'handler' => null, + 'prefix' => '/a/333', + 'path' => '/a/333', + 'methods' => [ + 'GET' => true, + ], + 'name' => 'a_third', + ], + [ + 'handler' => (object) [ + 2, + 4, + ], + 'prefix' => '/a/333', + 'path' => '/a/333/', + 'methods' => [ + 'POST' => true, + ], + 'name' => 'a_third_1', + ], + ] + EOT, debugFormat([$route1, $route2, $route3, $route4])); +})->with([0, 1, 1, 2, 3, 3]); From f884a42c69437429638088a42bd1e1ca3d986397 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:47:34 +0000 Subject: [PATCH 25/40] Update composer.json --- composer.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 847cab23..f1edd68e 100644 --- a/composer.json +++ b/composer.json @@ -34,16 +34,14 @@ "phpunit/phpunit": "^9.5", "spiral/attributes": "^2.9", "squizlabs/php_codesniffer": "^3.6", - "symfony/var-exporter": "^5.4 || ^6.0", - "vimeo/psalm": "^4.13" + "vimeo/psalm": "^4.23" }, "suggest": { "biurad/annotations": "For annotation routing on classes and methods using Annotation/Listener class", "biurad/http-galaxy": "For handling router, an alternative is nyholm/psr7, slim/psr7 or laminas/laminas-diactoros", "divineniiquaye/php-invoker": "For auto-configuring route handler parameters as needed with or without PSR-11 support", - "divineniiquaye/rade-di": "For full support of PSR-11 and autowiring capabilities on routes (recommended for install).", - "laminas/laminas-httphandlerrunner": "For emitting response headers and body contents to browser", - "symfony/var-exporter": "For handling cached routes with better performance (recommended for install)." + "biurad/di": "For full support of PSR-11 and autowiring capabilities on routes (recommended for install).", + "laminas/laminas-httphandlerrunner": "For emitting response headers and body contents to browser" }, "autoload": { "psr-4": { From 03ad4c58fb81997d3cbe4f8dc9e243e4d3637068 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:48:00 +0000 Subject: [PATCH 26/40] Removed the upgrade 1.x md file --- UPGRADE-1.x.md | 152 ------------------------------------------------- 1 file changed, 152 deletions(-) delete mode 100644 UPGRADE-1.x.md diff --git a/UPGRADE-1.x.md b/UPGRADE-1.x.md deleted file mode 100644 index 60293fda..00000000 --- a/UPGRADE-1.x.md +++ /dev/null @@ -1,152 +0,0 @@ -# UPGRADE FROM `v1.0.0` TO `v1.4.0` - -* This upgrade comes with minimal dependencies of PHP 7.4. It's strongly recommended updating to new release. -* We've added, upgraded, and removed several packages this library depends on. -* Changes made to how routing is handled. - - _Before_ - - ```php - use Flight\Routing\{RouteCollector, Router}; - use Biurad\Http\Factory\GuzzleHttpPsr7Factory as Psr17Factory; - use Laminas\HttpHandlerRunner\Emitter\SapiStreamEmitter; - - $collector = new RouteCollector(); - $collector->get('phpinfo', '/phpinfo', 'phpinfo'); // Will create a phpinfo route. - - $factory = new Psr17Factory(); - $router = new Router($factory, $factory); - - $router->addRoute(...$collector->getCollection()); - - // Start the routing - (new SapiStreamEmitter())->emit($router->handle($factory::fromGlobalRequest())); - ``` - - _After_ - - ```php - use Flight\Routing\{Handlers\RouteHandler, RouteCollection, Router}; - use Biurad\Http\Factory\GuzzleHttpPsr7Factory as Psr17Factory; - use Laminas\HttpHandlerRunner\Emitter\SapiStreamEmitter; - - $router = new Router(); - $router->setCollection(static function (RouteCollection $collector): void { - $collector->get('/phpinfo', 'phpinfo'); // Will create a phpinfo route. - }); - - $psr17Factory = new Psr17Factory(); - $response = $router->process($psr17Factory->fromGlobalRequest(), new RouteHandler($psr17Factory)); - - // Start the routing - (new SapiStreamEmitter())->emit($response); - ``` - -* Changes made to how route grouping is handled. - - _Before_ - - ```php - use Flight\Routing\RouteCollector; - use Flight\Routing\Interfaces\RouteCollectorInterface; - - $collector = new RouteCollector(); - - $collector->group( - function (RouteCollectorInterface $route) { - // Define your routes using $route... - } - ); - ``` - - _After_ - - ```php - $collection = new RouteCollection(); - - // callable grouping - $group1 = function (RouteCollection $group) { - // Define your routes using $group... - }; - - // or collection grouping - $group2 = new RouteCollection(); - $group2->addRoute('/phpinfo', ['GET', 'HEAD'], 'phpinfo'); - - $collection->group('group_name', $group1); - $collection->group('group_name', $group2); - - //or dsl - $collection->group('group_name') - ->addRoute('/phpinfo', ['GET', 'HEAD'], 'phpinfo')->end() - // ... More can be added including nested grouping - ->end(); - ``` - - -# UPGRADE FROM `v0.5.x` TO `v1.0.0` - -Changes has been made to codebase which has affected how the library is meant to be used. - -* Changes made to how routing is handled. - - _Before_ - - ```php - use Flight\Routing\Publisher; - use Flight\Routing\RouteCollector as Router; - use BiuradPHP\Http\Factory\GuzzleHttpPsr7Factory as Psr17Factory; - - $router = new Router(new Psr17Factory()); - $router->get('/phpinfo', 'phpinfo'); // Will create a phpinfo route. - - // Start the routing - (new Publisher)->publish($router->handle(Psr17Factory::fromGlobalRequest())); - ``` - - _After_ - - ```php - use Flight\Routing\{RouteCollector, Router}; - use Biurad\Http\Factory\GuzzleHttpPsr7Factory as Psr17Factory; - use Laminas\HttpHandlerRunner\Emitter\SapiStreamEmitter; - - $collector = new RouteCollector(); - $collector->get('phpinfo', '/phpinfo', 'phpinfo'); // Will create a phpinfo route. - - $factory = new Psr17Factory(); - $router = new Router($factory, $factory); - - $router->addRoute(...$collector->getCollection()); - - // Start the routing - (new SapiStreamEmitter())->emit($router->handle($factory::fromGlobalRequest())); - ``` - -* Changes made to how route grouping is handled. - - _Before_ - - ```php - use Flight\Routing\Interfaces\RouterProxyInterface; - - $router->group( - [...], // Add your group attributes - function (RouterProxyInterface $route) { - // Define your routes using $route... - } - ); - ``` - - _After_ - - ```php - use Flight\Routing\Interfaces\RouteCollectorInterface; - - $collector->group( - function (RouteCollectorInterface $route) { - // Define your routes using $route... - } - ); - ``` - From 12a387c63e5bbfd2604fafb645d5a102da701cf2 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:48:24 +0000 Subject: [PATCH 27/40] Fixed coding standard issues --- src/Interfaces/ExceptionInterface.php | 4 +--- src/Middlewares/UriRedirectMiddleware.php | 14 +++++--------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/Interfaces/ExceptionInterface.php b/src/Interfaces/ExceptionInterface.php index cccd2b7f..a4be13b6 100644 --- a/src/Interfaces/ExceptionInterface.php +++ b/src/Interfaces/ExceptionInterface.php @@ -1,6 +1,4 @@ - */ - protected array $redirects; - - private bool $keepRequestMethod; - /** * @param array $redirects [from previous => to new] * @param bool $keepRequestMethod Whether redirect action should keep HTTP request method */ - public function __construct(array $redirects = [], bool $keepRequestMethod = false) + public function __construct(protected array $redirects = [], private bool $keepRequestMethod = false) { $this->redirects = $redirects; $this->keepRequestMethod = $keepRequestMethod; @@ -61,7 +56,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface if ('' === $redirectedUri = (string) ($this->redirects[$requestPath] ?? '')) { foreach ($this->redirects as $oldPath => $newPath) { - if (1 === \preg_match('#^' . $oldPath . '$#u', $requestPath)) { + if (1 === \preg_match('#^'.$oldPath.'$#u', $requestPath)) { $redirectedUri = $newPath; break; @@ -77,8 +72,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $redirectedUri = $uri->withPath(\substr($redirectedUri, 1)); } - return $handler->handle($request->withAttribute(RouteHandler::OVERRIDE_HTTP_RESPONSE, true)) + return $handler->handle($request->withAttribute(RouteHandler::OVERRIDE_NULL_ROUTE, true)) ->withStatus($this->keepRequestMethod ? 308 : 301) - ->withHeader('Location', (string) $redirectedUri); + ->withHeader('Location', (string) $redirectedUri) + ; } } From 8c5f7424b6809fcc0c5a69964b4d03d4086051ff Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:49:34 +0000 Subject: [PATCH 28/40] Added benchmarking comparison result file --- BENCHMARK.txt | 273 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 BENCHMARK.txt diff --git a/BENCHMARK.txt b/BENCHMARK.txt new file mode 100644 index 00000000..458254d8 --- /dev/null +++ b/BENCHMARK.txt @@ -0,0 +1,273 @@ ++--------------+------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| provider | key | value | ++--------------+------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| uname | os | Linux | +| uname | host | divineniiquaye-flightrouting-xfj1838tu2g | +| uname | release | 5.15.0-47-generic | +| uname | version | #51-Ubuntu SMP Thu Aug 11 07:51:15 UTC 2022 | +| uname | machine | x86_64 | +| php | xdebug | false | +| php | version | 8.1.9 | +| php | ini | /etc/php/8.1/cli/php.ini | +| php | extensions | Core, date, libxml, openssl, pcre, zlib, filter, hash, json, pcntl, Reflection, SPL, session, standard, sodium, mysqlnd, PDO, xml, bcmath, calendar, ctype, curl, dom, mbstring, FFI, fileinfo, ftp, gd, gettext, iconv, intl, exif, mysqli, pdo_mysql, pdo_pgsql, pdo_sqlite, pgsql, Phar, posix, readline, shmop, SimpleXML, sockets, sqlite3, sysvmsg, sysvsem, sysvshm, tokenizer, xmlreader, xmlwriter, xsl, zip, Zend OPcache | +| opcache | extension_loaded | true | +| opcache | enabled | true | +| unix-sysload | l1 | 1.14 | +| unix-sysload | l5 | 3.73 | +| unix-sysload | l15 | 4.48 | +| sampler | nothing | 0.0090599060058594 | +| sampler | md5 | 0.18787384033203 | +| sampler | file_rw | 0.51593780517578 | ++--------------+------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +PerformanceBench +================ + +Average iteration times by variant + +3.0ms │ █ █ +2.6ms │ ▁█ ▁█ +2.2ms │ ██ ██ +1.9ms │ ██ ██ +1.5ms │ ██ ██ █ +1.1ms │ ▁ ██ ██ ▇█ +748.8μs │ █ ▄ ██ ██ ██ +374.4μs │ ▄▆ ▁▁ ▁▆ ▁▁ ▁▁ ▁▁ ▄▆ ▁▁ ▄▆ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▄▆ ▁▁ ▂▃ ▁▁ ██ ▁▁ ██ ▁▁ ▂▃ ▁▁ ██ ██ ██ ▁▅ ▁▅ ▁▄ + └─────────────────────────────────────────────────────────────────────────────────────────── + 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 + +[█ ] [█ v1.6.4] + +1: benchStaticRoutes-n᠁ 2: benchStaticRoutes-c᠁ 3: benchStaticRoutes-n᠁ 4: benchStaticRoutes-c᠁ +5: benchStaticRoutes-n᠁ 6: benchStaticRoutes-c᠁ 7: benchStaticRoutes-n᠁ 8: benchStaticRoutes-c᠁ +9: benchDynamicRoutes-᠁ 10: benchDynamicRoutes-᠁ 11: benchDynamicRoutes-᠁ 12: benchDynamicRoutes-᠁ +13: benchDynamicRoutes-᠁ 14: benchDynamicRoutes-᠁ 15: benchDynamicRoutes-᠁ 16: benchDynamicRoutes-᠁ +17: benchOtherRoutes-no᠁ 18: benchOtherRoutes-ca᠁ 19: benchAll-not_cached᠁ 20: benchAll-cached,sta᠁ +21: benchAll-not_cached᠁ 22: benchAll-cached,dyn᠁ 23: benchAll-not_cached᠁ 24: benchAll-cached,oth᠁ +25: benchWithRouter-sta᠁ 26: benchWithRouter-dyn᠁ 27: benchWithRouter-oth᠁ 28: benchWithCache-stat᠁ +29: benchWithCache-dyna᠁ 30: benchWithCache-othe᠁ + +Memory by variant + +4.1mb │ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ +3.6mb │ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ +3.1mb │ ▄█ ▄█ ▄█ ▄█ ▄█ ▄█ ▄█ ▄█ ▄█ ▄█ ▅█ ▄█ ▄█ ▄█ ▄█ +2.6mb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +2.0mb │ ▄▄ ██ ▁▅ ██ ▅▅ ██ ▅▆ ██ ▄▄ ██ ▁ ██ ▄▄ ██ ▅▆ ██ ▁ ██ ▅▆ ██ ▅▆ ██ ▁ ██ ▇▆ ▇▆ ▁ ██ ██ ██ +1.5mb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ▅█ ██ ██ ██ ██ ██ ▅█ ██ ██ ██ ▅█ ██ ██ ██ +1.0mb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +510.1kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + └─────────────────────────────────────────────────────────────────────────────────────────── + 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 + +[█ ] [█ v1.6.4] + +1: benchStaticRoutes-n᠁ 2: benchStaticRoutes-c᠁ 3: benchStaticRoutes-n᠁ 4: benchStaticRoutes-c᠁ +5: benchStaticRoutes-n᠁ 6: benchStaticRoutes-c᠁ 7: benchStaticRoutes-n᠁ 8: benchStaticRoutes-c᠁ +9: benchDynamicRoutes-᠁ 10: benchDynamicRoutes-᠁ 11: benchDynamicRoutes-᠁ 12: benchDynamicRoutes-᠁ +13: benchDynamicRoutes-᠁ 14: benchDynamicRoutes-᠁ 15: benchDynamicRoutes-᠁ 16: benchDynamicRoutes-᠁ +17: benchOtherRoutes-no᠁ 18: benchOtherRoutes-ca᠁ 19: benchAll-not_cached᠁ 20: benchAll-cached,sta᠁ +21: benchAll-not_cached᠁ 22: benchAll-cached,dyn᠁ 23: benchAll-not_cached᠁ 24: benchAll-cached,oth᠁ +25: benchWithRouter-sta᠁ 26: benchWithRouter-dyn᠁ 27: benchWithRouter-oth᠁ 28: benchWithCache-stat᠁ +29: benchWithCache-dyna᠁ 30: benchWithCache-othe᠁ + + ++-----------------------------------------------------------------+---------+-----------+--------+----------+ +| subject | memory | mode | rstdev | stdev | ++-----------------------------------------------------------------+---------+-----------+--------+----------+ +| benchStaticRoutes (not_cached,first) | 1.747mb | 176.042μs | ±0.82% | 1.448μs | +| benchStaticRoutes (cached,first) | 2.796mb | 3.671μs | ±0.72% | 0.026μs | +| benchStaticRoutes (not_cached,middle) | 1.544mb | 1.531μs | ±1.80% | 0.028μs | +| benchStaticRoutes (cached,middle) | 2.797mb | 1.582μs | ±1.90% | 0.030μs | +| benchStaticRoutes (not_cached,last) | 1.803mb | 1.244μs | ±3.32% | 0.042μs | +| benchStaticRoutes (cached,last) | 2.797mb | 1.299μs | ±3.00% | 0.038μs | +| benchStaticRoutes (not_cached,invalid-method) | 1.839mb | 176.355μs | ±0.91% | 1.604μs | +| benchStaticRoutes (cached,invalid-method) | 2.796mb | 3.834μs | ±1.63% | 0.062μs | +| benchDynamicRoutes (not_cached,first) | 1.748mb | 175.468μs | ±0.69% | 1.210μs | +| benchDynamicRoutes (cached,first) | 2.797mb | 6.356μs | ±1.22% | 0.077μs | +| benchDynamicRoutes (not_cached,middle) | 1.518mb | 1.606μs | ±2.02% | 0.033μs | +| benchDynamicRoutes (cached,middle) | 2.798mb | 1.609μs | ±0.63% | 0.010μs | +| benchDynamicRoutes (not_cached,last) | 1.748mb | 1.242μs | ±2.60% | 0.033μs | +| benchDynamicRoutes (cached,last) | 2.797mb | 1.232μs | ±1.25% | 0.015μs | +| benchDynamicRoutes (not_cached,invalid-method) | 1.822mb | 182.853μs | ±0.82% | 1.506μs | +| benchDynamicRoutes (cached,invalid-method) | 2.796mb | 9.756μs | ±2.31% | 0.227μs | +| benchOtherRoutes (not_cached,non-existent) | 1.319mb | 59.182μs | ±1.61% | 0.957μs | +| benchOtherRoutes (cached,non-existent) | 2.795mb | 1.765μs | ±1.99% | 0.035μs | +| benchAll (not_cached,static(first,middle,last,invalid-method)) | 1.831mb | 359.099μs | ±0.40% | 1.443μs | +| benchAll (cached,static(first,middle,last,invalid-method)) | 2.802mb | 11.601μs | ±2.19% | 0.256μs | +| benchAll (not_cached,dynamic(first,middle,last,invalid-method)) | 1.833mb | 370.265μs | ±0.22% | 0.807μs | +| benchAll (cached,dynamic(first,middle,last,invalid-method)) | 2.806mb | 23.227μs | ±1.82% | 0.420μs | +| benchAll (not_cached,others(non-existent,...)) | 1.319mb | 59.193μs | ±1.62% | 0.964μs | +| benchAll (cached,others(non-existent,...)) | 2.796mb | 2.210μs | ±2.01% | 0.045μs | +| benchWithRouter (static(first,middle,last,invalid-method)) | 1.925mb | 2.246ms | ±0.50% | 11.237μs | +| benchWithRouter (dynamic(first,middle,last,invalid-method)) | 1.928mb | 2.271ms | ±2.02% | 46.229μs | +| benchWithRouter (others(non-existent,...)) | 1.318mb | 1.034ms | ±1.63% | 17.009μs | +| benchWithCache (static(first,middle,last,invalid-method)) | 2.801mb | 17.523μs | ±2.82% | 0.485μs | +| benchWithCache (dynamic(first,middle,last,invalid-method)) | 2.804mb | 41.193μs | ±2.93% | 1.229μs | +| benchWithCache (others(non-existent,...)) | 2.795mb | 4.500μs | ±2.25% | 0.100μs | ++-----------------------------------------------------------------+---------+-----------+--------+----------+ + +v1.6.4 ++-----------------------------------------------------------------+---------+-----------+--------+----------+ +| subject | memory | mode | rstdev | stdev | ++-----------------------------------------------------------------+---------+-----------+--------+----------+ +| benchStaticRoutes (not_cached,first) | 1.782mb | 249.409μs | ±1.76% | 4.417μs | +| benchStaticRoutes (cached,first) | 4.077mb | 1.862μs | ±2.11% | 0.040μs | +| benchStaticRoutes (not_cached,middle) | 1.839mb | 254.122μs | ±1.42% | 3.628μs | +| benchStaticRoutes (cached,middle) | 4.077mb | 5.579μs | ±2.29% | 0.127μs | +| benchStaticRoutes (not_cached,last) | 1.839mb | 1.378μs | ±1.16% | 0.016μs | +| benchStaticRoutes (cached,last) | 4.077mb | 1.340μs | ±2.54% | 0.034μs | +| benchStaticRoutes (not_cached,invalid-method) | 1.873mb | 253.452μs | ±1.62% | 4.153μs | +| benchStaticRoutes (cached,invalid-method) | 4.077mb | 4.039μs | ±0.70% | 0.028μs | +| benchDynamicRoutes (not_cached,first) | 1.782mb | 251.788μs | ±2.17% | 5.553μs | +| benchDynamicRoutes (cached,first) | 4.077mb | 5.991μs | ±2.52% | 0.149μs | +| benchDynamicRoutes (not_cached,middle) | 1.591mb | 1.722μs | ±2.04% | 0.035μs | +| benchDynamicRoutes (cached,middle) | 4.077mb | 1.685μs | ±1.98% | 0.034μs | +| benchDynamicRoutes (not_cached,last) | 1.782mb | 1.376μs | ±1.27% | 0.017μs | +| benchDynamicRoutes (cached,last) | 4.077mb | 1.387μs | ±2.21% | 0.030μs | +| benchDynamicRoutes (not_cached,invalid-method) | 1.856mb | 263.274μs | ±0.82% | 2.153μs | +| benchDynamicRoutes (cached,invalid-method) | 4.077mb | 9.764μs | ±2.87% | 0.279μs | +| benchOtherRoutes (not_cached,non-existent) | 1.586mb | 106.256μs | ±0.90% | 0.957μs | +| benchOtherRoutes (cached,non-existent) | 4.077mb | 1.788μs | ±1.84% | 0.033μs | +| benchAll (not_cached,static(first,middle,last,invalid-method)) | 1.861mb | 766.223μs | ±0.79% | 6.089μs | +| benchAll (cached,static(first,middle,last,invalid-method)) | 4.080mb | 15.364μs | ±2.68% | 0.419μs | +| benchAll (not_cached,dynamic(first,middle,last,invalid-method)) | 1.860mb | 533.440μs | ±2.05% | 10.821μs | +| benchAll (cached,dynamic(first,middle,last,invalid-method)) | 4.081mb | 23.307μs | ±1.85% | 0.435μs | +| benchAll (not_cached,others(non-existent,...)) | 1.587mb | 107.916μs | ±2.40% | 2.580μs | +| benchAll (cached,others(non-existent,...)) | 4.078mb | 2.224μs | ±1.05% | 0.023μs | +| benchWithRouter (static(first,middle,last,invalid-method)) | 1.859mb | 2.995ms | ±2.24% | 67.618μs | +| benchWithRouter (dynamic(first,middle,last,invalid-method)) | 1.858mb | 2.982ms | ±1.80% | 53.662μs | +| benchWithRouter (others(non-existent,...)) | 1.585mb | 1.493ms | ±3.00% | 44.450μs | +| benchWithCache (static(first,middle,last,invalid-method)) | 4.078mb | 198.636μs | ±2.22% | 4.424μs | +| benchWithCache (dynamic(first,middle,last,invalid-method)) | 4.078mb | 228.725μs | ±2.31% | 5.193μs | +| benchWithCache (others(non-existent,...)) | 4.076mb | 175.763μs | ±0.76% | 1.336μs | ++-----------------------------------------------------------------+---------+-----------+--------+----------+ + +RealExampleBench +================ + +Average iteration times by variant + +249.7μs │ █ +218.5μs │ ▅ ▁█ ▆ +187.3μs │ █ ██ █ +156.1μs │ ██ ██ ▆█ +124.8μs │ ██ ██ ██ +93.6μs │ ██ ██ ██ ▁ +62.4μs │ ▃▅ ▇ ██ ██ ██ ▃█ ▃ +31.2μs │ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▁▁ ▄▆ ▂▂ ▁▁ ▁▄ ▄▅ ▃▄ ▁▁ ▁▄ ▅▆ ▄▃ ▄▅ ▂▄ ▁▁ ▁▄ ▆▇ ▃▃ ██ ██ ▄▆ ▃█ ██ ██ ██ ▄▇ ██ ▅█ + └───────────────────────────────────────────────────────────────────────────────────────────────── + 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 + +[█ ] [█ v1.6.4] + +1: benchStaticRoutes-n᠁ 2: benchStaticRoutes-c᠁ 3: benchStaticRoutes-n᠁ 4: benchStaticRoutes-c᠁ +5: benchStaticRoutes-n᠁ 6: benchStaticRoutes-c᠁ 7: benchStaticRoutes-n᠁ 8: benchStaticRoutes-c᠁ +9: benchDynamicRoutes-᠁ 10: benchDynamicRoutes-᠁ 11: benchDynamicRoutes-᠁ 12: benchDynamicRoutes-᠁ +13: benchDynamicRoutes-᠁ 14: benchDynamicRoutes-᠁ 15: benchDynamicRoutes-᠁ 16: benchDynamicRoutes-᠁ +17: benchOtherRoutes-no᠁ 18: benchOtherRoutes-ca᠁ 19: benchOtherRoutes-no᠁ 20: benchOtherRoutes-ca᠁ +21: benchAll-not_cached᠁ 22: benchAll-cached,sta᠁ 23: benchAll-not_cached᠁ 24: benchAll-cached,dyn᠁ +25: benchAll-not_cached᠁ 26: benchAll-cached,oth᠁ 27: benchWithRouter-sta᠁ 28: benchWithRouter-dyn᠁ +29: benchWithRouter-oth᠁ 30: benchWithCache-stat᠁ 31: benchWithCache-dyna᠁ 32: benchWithCache-othe᠁ + + +Memory by variant + +1.3mb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +1.2mb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +991.3kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +826.1kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +660.9kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +495.7kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +330.4kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +165.2kb │ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ + └───────────────────────────────────────────────────────────────────────────────────────────────── + 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 + +[█ ] [█ v1.6.4] + +1: benchStaticRoutes-n᠁ 2: benchStaticRoutes-c᠁ 3: benchStaticRoutes-n᠁ 4: benchStaticRoutes-c᠁ +5: benchStaticRoutes-n᠁ 6: benchStaticRoutes-c᠁ 7: benchStaticRoutes-n᠁ 8: benchStaticRoutes-c᠁ +9: benchDynamicRoutes-᠁ 10: benchDynamicRoutes-᠁ 11: benchDynamicRoutes-᠁ 12: benchDynamicRoutes-᠁ +13: benchDynamicRoutes-᠁ 14: benchDynamicRoutes-᠁ 15: benchDynamicRoutes-᠁ 16: benchDynamicRoutes-᠁ +17: benchOtherRoutes-no᠁ 18: benchOtherRoutes-ca᠁ 19: benchOtherRoutes-no᠁ 20: benchOtherRoutes-ca᠁ +21: benchAll-not_cached᠁ 22: benchAll-cached,sta᠁ 23: benchAll-not_cached᠁ 24: benchAll-cached,dyn᠁ +25: benchAll-not_cached᠁ 26: benchAll-cached,oth᠁ 27: benchWithRouter-sta᠁ 28: benchWithRouter-dyn᠁ +29: benchWithRouter-oth᠁ 30: benchWithCache-stat᠁ 31: benchWithCache-dyna᠁ 32: benchWithCache-othe᠁ + + + ++-----------------------------------------------------------------+---------+-----------+--------+---------+ +| subject | memory | mode | rstdev | stdev | ++-----------------------------------------------------------------+---------+-----------+--------+---------+ +| benchStaticRoutes (not_cached,first) | 1.319mb | 1.183μs | ±2.94% | 0.034μs | +| benchStaticRoutes (cached,first) | 1.319mb | 1.137μs | ±2.04% | 0.023μs | +| benchStaticRoutes (not_cached,middle) | 1.319mb | 1.253μs | ±2.59% | 0.033μs | +| benchStaticRoutes (cached,middle) | 1.319mb | 1.247μs | ±1.19% | 0.015μs | +| benchStaticRoutes (not_cached,last) | 1.319mb | 1.251μs | ±1.00% | 0.013μs | +| benchStaticRoutes (cached,last) | 1.319mb | 1.280μs | ±0.94% | 0.012μs | +| benchStaticRoutes (not_cached,invalid-method) | 1.319mb | 14.596μs | ±1.87% | 0.273μs | +| benchStaticRoutes (cached,invalid-method) | 1.319mb | 3.946μs | ±2.75% | 0.109μs | +| benchDynamicRoutes (not_cached,first) | 1.319mb | 1.310μs | ±1.04% | 0.014μs | +| benchDynamicRoutes (cached,first) | 1.319mb | 1.290μs | ±2.43% | 0.032μs | +| benchDynamicRoutes (not_cached,middle) | 1.320mb | 13.850μs | ±2.30% | 0.322μs | +| benchDynamicRoutes (cached,middle) | 1.320mb | 8.570μs | ±3.24% | 0.271μs | +| benchDynamicRoutes (not_cached,last) | 1.319mb | 1.323μs | ±2.24% | 0.030μs | +| benchDynamicRoutes (cached,last) | 1.319mb | 1.323μs | ±2.08% | 0.028μs | +| benchDynamicRoutes (not_cached,invalid-method) | 1.319mb | 18.207μs | ±2.44% | 0.450μs | +| benchDynamicRoutes (cached,invalid-method) | 1.319mb | 12.332μs | ±1.29% | 0.159μs | +| benchOtherRoutes (not_cached,non-existent) | 1.319mb | 12.556μs | ±2.68% | 0.333μs | +| benchOtherRoutes (cached,non-existent) | 1.319mb | 6.291μs | ±2.17% | 0.139μs | +| benchOtherRoutes (not_cached,longest-route) | 1.320mb | 1.289μs | ±1.85% | 0.024μs | +| benchOtherRoutes (cached,longest-route) | 1.320mb | 1.277μs | ±1.53% | 0.020μs | +| benchAll (not_cached,static(first,middle,last,invalid-method)) | 1.320mb | 22.967μs | ±1.83% | 0.424μs | +| benchAll (cached,static(first,middle,last,invalid-method)) | 1.320mb | 8.591μs | ±1.38% | 0.120μs | +| benchAll (not_cached,dynamic(first,middle,last,invalid-method)) | 1.322mb | 40.733μs | ±1.58% | 0.647μs | +| benchAll (cached,dynamic(first,middle,last,invalid-method)) | 1.322mb | 29.327μs | ±1.95% | 0.565μs | +| benchAll (not_cached,others(non-existent,...)) | 1.320mb | 14.999μs | ±2.28% | 0.347μs | +| benchAll (cached,others(non-existent,...)) | 1.320mb | 9.844μs | ±2.43% | 0.243μs | +| benchWithRouter (static(first,middle,last,invalid-method)) | 1.320mb | 153.451μs | ±2.26% | 3.460μs | +| benchWithRouter (dynamic(first,middle,last,invalid-method)) | 1.321mb | 190.421μs | ±2.11% | 4.042μs | +| benchWithRouter (others(non-existent,...)) | 1.319mb | 146.774μs | ±2.00% | 2.961μs | +| benchWithCache (static(first,middle,last,invalid-method)) | 1.320mb | 14.903μs | ±1.25% | 0.187μs | +| benchWithCache (dynamic(first,middle,last,invalid-method)) | 1.321mb | 40.788μs | ±1.49% | 0.604μs | +| benchWithCache (others(non-existent,...)) | 1.319mb | 19.347μs | ±0.97% | 0.187μs | ++-----------------------------------------------------------------+---------+-----------+--------+---------+ + +v1.6.4 ++-----------------------------------------------------------------+---------+-----------+--------+---------+ +| subject | memory | mode | rstdev | stdev | ++-----------------------------------------------------------------+---------+-----------+--------+---------+ +| benchStaticRoutes (not_cached,first) | 1.321mb | 1.272μs | ±2.25% | 0.029μs | +| benchStaticRoutes (cached,first) | 1.321mb | 1.275μs | ±1.04% | 0.013μs | +| benchStaticRoutes (not_cached,middle) | 1.321mb | 1.368μs | ±2.31% | 0.032μs | +| benchStaticRoutes (cached,middle) | 1.321mb | 1.329μs | ±0.77% | 0.010μs | +| benchStaticRoutes (not_cached,last) | 1.321mb | 1.370μs | ±0.99% | 0.014μs | +| benchStaticRoutes (cached,last) | 1.321mb | 1.364μs | ±1.57% | 0.021μs | +| benchStaticRoutes (not_cached,invalid-method) | 1.321mb | 21.526μs | ±3.44% | 0.758μs | +| benchStaticRoutes (cached,invalid-method) | 1.321mb | 4.097μs | ±2.29% | 0.094μs | +| benchDynamicRoutes (not_cached,first) | 1.321mb | 1.406μs | ±1.52% | 0.021μs | +| benchDynamicRoutes (cached,first) | 1.321mb | 12.281μs | ±2.88% | 0.355μs | +| benchDynamicRoutes (not_cached,middle) | 1.321mb | 19.006μs | ±2.73% | 0.526μs | +| benchDynamicRoutes (cached,middle) | 1.321mb | 14.251μs | ±2.06% | 0.290μs | +| benchDynamicRoutes (not_cached,last) | 1.321mb | 1.403μs | ±2.78% | 0.038μs | +| benchDynamicRoutes (cached,last) | 1.321mb | 11.994μs | ±2.07% | 0.251μs | +| benchDynamicRoutes (not_cached,invalid-method) | 1.321mb | 22.565μs | ±2.80% | 0.628μs | +| benchDynamicRoutes (cached,invalid-method) | 1.321mb | 11.617μs | ±2.78% | 0.330μs | +| benchOtherRoutes (not_cached,non-existent) | 1.321mb | 16.581μs | ±2.43% | 0.404μs | +| benchOtherRoutes (cached,non-existent) | 1.321mb | 11.705μs | ±2.48% | 0.286μs | +| benchOtherRoutes (not_cached,longest-route) | 1.321mb | 1.520μs | ±1.49% | 0.022μs | +| benchOtherRoutes (cached,longest-route) | 1.321mb | 12.256μs | ±2.88% | 0.349μs | +| benchAll (not_cached,static(first,middle,last,invalid-method)) | 1.321mb | 27.065μs | ±2.35% | 0.644μs | +| benchAll (cached,static(first,middle,last,invalid-method)) | 1.321mb | 9.584μs | ±2.08% | 0.197μs | +| benchAll (not_cached,dynamic(first,middle,last,invalid-method)) | 1.322mb | 48.068μs | ±2.70% | 1.320μs | +| benchAll (cached,dynamic(first,middle,last,invalid-method)) | 1.322mb | 58.022μs | ±2.55% | 1.450μs | +| benchAll (not_cached,others(non-existent,...)) | 1.321mb | 20.408μs | ±2.42% | 0.499μs | +| benchAll (cached,others(non-existent,...)) | 1.321mb | 27.461μs | ±3.79% | 1.017μs | +| benchWithRouter (static(first,middle,last,invalid-method)) | 1.320mb | 206.080μs | ±1.30% | 2.697μs | +| benchWithRouter (dynamic(first,middle,last,invalid-method)) | 1.321mb | 249.682μs | ±2.22% | 5.616μs | +| benchWithRouter (others(non-existent,...)) | 1.320mb | 207.218μs | ±1.84% | 3.829μs | +| benchWithCache (static(first,middle,last,invalid-method)) | 1.320mb | 23.553μs | ±1.72% | 0.406μs | +| benchWithCache (dynamic(first,middle,last,invalid-method)) | 1.321mb | 65.983μs | ±2.85% | 1.917μs | +| benchWithCache (others(non-existent,...)) | 1.320mb | 39.758μs | ±0.31% | 0.122μs | ++-----------------------------------------------------------------+---------+-----------+--------+---------+ From 82c40d32544c010259a750ce91624925b98da798 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:50:27 +0000 Subject: [PATCH 29/40] Fixed psalm issues and raise error level to 7 --- psalm.xml.dist | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/psalm.xml.dist b/psalm.xml.dist index 4247c688..980c551a 100644 --- a/psalm.xml.dist +++ b/psalm.xml.dist @@ -3,7 +3,7 @@ xmlns="https://getpsalm.org/schema/config" name="Psalm for Flight Routing" useDocblockTypes="true" - errorLevel="4" + errorLevel="7" strictBinaryOperands="false" rememberPropertyAssignmentsAfterCall="true" checkForThrowsDocblock="false" @@ -15,17 +15,11 @@ > - - - - - - From ce4860f88084c5e02f2a7e60ae82c60adaaca5b0 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:53:38 +0000 Subject: [PATCH 30/40] Fixed phpstan issues --- phpstan.neon.dist | 6 ------ 1 file changed, 6 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f81b63f9..0b2e84b5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,14 +5,8 @@ parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false - excludePaths: - - %currentWorkingDirectory%/src/Handlers/RouteInvoker.php - ignoreErrors: - "#^Unsafe usage of new static\\(\\)|Expression on left side of \\?\\? is not nullable.$#" - - - message: "#^Method Flight\\\\Routing\\\\RouteCollection\\:\\:group\\(\\) should return \\$this\\(Flight\\\\Routing\\\\RouteCollection\\) but returns Flight\\\\Routing\\\\RouteCollection.$#" - path: src/RouteCollection.php - message: "#^Method Flight\\\\Routing\\\\RouteCollection\\:\\:end\\(\\) should return \\$this\\(Flight\\\\Routing\\\\RouteCollection\\) but returns Flight\\\\Routing\\\\RouteCollection.$#" path: src/Traits/PrototypeTrait.php From f5ad7a774795cc17c2ea4afdb515300b890d4632 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:54:11 +0000 Subject: [PATCH 31/40] Updated the phpunit configs --- phpunit.xml.dist | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index b2401c3d..139339e7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,17 +3,9 @@ xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" backupGlobals="false" - backupStaticAttributes="false" - beStrictAboutCoversAnnotation="true" - beStrictAboutOutputDuringTests="true" - beStrictAboutTestsThatDoNotTestAnything="false" - beStrictAboutTodoAnnotatedTests="true" colors="true" - verbose="true" - executionOrder="default" - processIsolation="false" - stopOnFailure="false" - stopOnError="false" + failOnRisky="true" + failOnWarning="true" > From 2fdbb8b0b22246ad129feadaf3b5774db1f8d875 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Wed, 7 Sep 2022 08:54:17 +0000 Subject: [PATCH 32/40] Update README.md --- README.md | 646 ++++++++++-------------------------------------------- 1 file changed, 110 insertions(+), 536 deletions(-) diff --git a/README.md b/README.md index 42713f51..53781228 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# The PHP HTTP Flight Router +# The PHP HTTP Flight Routing [![PHP Version](https://img.shields.io/packagist/php-v/divineniiquaye/flight-routing.svg?style=flat-square&colorB=%238892BF)](http://php.net) [![Latest Version](https://img.shields.io/packagist/v/divineniiquaye/flight-routing.svg?style=flat-square)](https://packagist.org/packages/divineniiquaye/flight-routing) @@ -14,484 +14,161 @@ --- -**divineniiquaye/flight-routing** is a HTTP router for [PHP] 7.4+ based on [PSR-7] and [PSR-15] with support for annotations, created by [Divine Niiquaye][@divineniiquaye]. This library helps create a human friendly urls (also more cool & prettier) while allows you to use any current trends of **`PHP Http Router`** implementation and fully meets developers' desires. +Flight routing is yet another high performance HTTP router for [PHP][1]. It is simple, easy to use, scalable and fast. This library depends on [PSR-7][2] for route match and support using [PSR-15][3] for intercepting route before being rendered. -[![Xcode](https://xscode.com/assets/promo-banner.svg)](https://xscode.com/divineniiquaye/flight-routing) +This library previous versions was inspired by [Sunrise Http Router][4], [Symfony Routing][5], [FastRoute][6] and now completely rewritten for better performance. ## 🏆 Features -- Basic routing (`GET`, `POST`, `PUT`, `PATCH`, `UPDATE`, `DELETE`) with support for custom multiple verbs. -- Regular Expression Constraints for parameters. -- Named routes. -- Generating named routes to [PSR-15] URL. -- Route groups. -- [PSR-15] Middleware (classes that intercepts before the route is rendered). -- Namespaces. -- Advanced route pattern syntax. -- Sub-domain routing and more. -- Restful Routing -- Custom matching strategy +- Supports all HTTP request methods (eg. `GET`, `POST` `DELETE`, etc). +- Regex Expression constraints for parameters. +- Reversing named routes paths to full URL with strict parameter checking. +- Route grouping and merging. +- Supports routes caching for performance. +- [PSR-15][3] Middleware (classes that intercepts before the route is rendered). +- Domain and sub-domain routing. +- Restful Routing. +- Supports PHP 8 attribute `#[Route]` and doctrine annotation `@Route` routing. +- Support custom matching strategy using custom route matcher class or compiler class. -## 📦 Installation & Basic Usage +## 📦 Installation -This project requires [PHP] 7.4 or higher. The recommended way to install, is via [Composer]. Simply run: +This project requires [PHP][1] 8.0 or higher. The recommended way to install, is via [Composer][7]. Simply run: ```bash $ composer require divineniiquaye/flight-routing ``` -First of all, you need to configure your web server to handle all the HTTP requests with a single PHP file like `index.php`. Here you can see required configurations for Apache HTTP Server and NGINX. +I recommend reading [my blog post][8] on setting up Apache, Nginx, IIS server configuration for your [PHP][1] project. -## Setting up Nginx: +## 📍 Quick Start -> If you are using Nginx please make sure that url-rewriting is enabled. +The default compiler accepts the following constraints in route pattern: -You can easily enable url-rewriting by adding the following configuration for the Nginx configuration-file for the demo-project. +- `{name}` - required placeholder. +- `{name=foo}` - placeholder with default value. +- `{name:regex}` - placeholder with regex definition. +- `{name:regex=foo}` - placeholder with regex definition and default value. +- `[{name}]` - optional placeholder. -```nginx -location / { - try_files $uri $uri/ /index.php?$query_string; -} -``` +A name of a placeholder variable is simply an acceptable PHP function/method parameter name expected to be unique, while the regex definition and default value can be any string (i.e [^/]+). -## Setting up Apache: +- `/foo/` - Matches **/foo/** or **/foo**. ending trailing slashes are striped before matching. +- `/user/{id}` - Matches **/user/bob**, **/user/1234** or **/user/23/**. +- `/user/{id:[^/]+}` - Same as the previous example. +- `/user[/{id}]` - Same as the previous example, but also match **/user** or **/user/**. +- `/user[/{id}]/` - Same as the previous example, ending trailing slashes are striped before matching. +- `/user/{id:[0-9a-fA-F]{1,8}}` - Only matches if the id parameter consists of 1 to 8 hex digits. +- `/files/{path:.*}` - Matches any URL starting with **/files/** and captures the rest of the path into the parameter **path**. +- `/[{lang:[a-z]{2}}[-{sublang}]/]{name}[/page-{page=0}]` - Matches **/cs/hello**, **/en-us/hello**, **/hello**, **/hello/page-12**, or **/ru/hello/page-23** -Nothing special is required for Apache to work. We've include the `.htaccess` file in the `public` folder. If rewriting is not working for you, please check that the `mod_rewrite` module (htaccess support) is enabled in the Apache configuration. +Route pattern accepts beginning with a `//domain.com` or `https://domain.com`. Route path also support adding controller (i.e `*`) directly at the end of the route path: -```htaccess - - Options -MultiViews - +- `*` - translates as a callable of BlogController class with method named indexAction. +- `*` - translates as a function, if a handler class is defined in route, then it turns to a callable. - - RewriteEngine On - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^(.*)$ index.php [QSA,L] - +Here is an example of how to use the library: - - - RedirectMatch 307 ^/$ /index.php/ - - -``` +```php +use Flight\Routing\{Router, RouteCollection}; -## Setting up IIS: - -On IIS you have to add some lines your `web.config` file. If rewriting is not working for you, please check that your IIS version have included the `url rewrite` module or download and install them from Microsoft web site. - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - +$router = new Router(); +$router->setCollection(static function (RouteCollection $routes) { + $routes->add('/blog/[{slug}]', handler: [BlogController::class, 'indexAction'])->bind('blog_show'); + //... You can add more routes here. +}); ``` -## Getting Started +Incase you'll prefer declaring your routes outside a closure scope, try this example: -This library uses any [PSR-7] implementation, for the purpose of this tutorial, we wil use [biurad-http-galaxy] library to provide [PSR-7] complaint request, stream and response objects to your controllers and middleware. +```php +use Flight\Routing\{Router, RouteCollection}; ->run this in command line if the package has not be added. +$routes = new RouteCollection(); +$routes->get('/blog/{slug}*', handler: BlogController::class)->bind('blog_show'); -```bash -composer require biurad/http-galaxy +$router = Router::withCollection($routes); ``` -Route supports adding a scheme, host, pattern and handler all in one. a scheme must end with **:**, while a domain must begin with **//** e.g. `http://biurad.com/blog/{slug}*`. Incase a class name or class object is passed into route's handler parameter, you can specify and callable method as `*` or just route to a function using same syntax as callable method. - -The default routes matchers behaves the same way as [Symfony Routing Component][] default's routes matchers. Two main differences are, Flight Routing is [PSR](http://www.php-fig.org/psr/) complaint and faster. - -For dispatching a route handler response to the browser, use an instance of `Laminas\HttpHandlerRunner\Emitter\EmitterInterface` to dispatch the router. +> NB: If caching is enabled, using the router's `setCollection()` method has much higher performance than using the `withCollection()` method. ->run this in command line if the package has not be added. - -```bash -composer require laminas/laminas-httphandlerrunner -``` +By default Flight routing does not ship a [PSR-7][2] http library nor a library to send response headers and body to the browser. If you'll like to install this libraries, I recommend installing either [biurad/http-galaxy][9] or [nyholm/psr7][10] and [laminas/laminas-httphandlerrunner][11]. ```php -use App\Controller\BlogController; -use Biurad\Http\Factory\NyholmPsr7Factory as Psr17Factory; -use Flight\Routing\{Route, RouteCollection, RouteMatch}; - -$routes = new RouteCollection(); -$routes->add(new Route('/blog/{slug}*', handler: BlogController::class))->bind('blog_show'); - -$psr17Factory = new Psr17Factory(); -$matcher = new RouteMatcher($routes); +$request = ... // A PSR-7 server request initialized from global request // Routing can match routes with incoming request -$route = $matcher->matchRequest($psr17Factory->fromGlobalRequest()); -// Should return a route class object, if request is made on a path like: /blog/lorem-ipsum +$route = $router->matchRequest($request); +// Should return an array, if request is made on a a configured route path (i.e /blog/lorem-ipsum) // Routing can also generate URLs for a given route -$url = $matcher->generateUri('blog_show', ['slug' => 'my-blog-post']); +$url = $router->generateUri('blog_show', ['slug' => 'my-blog-post']); // $url = '/blog/my-blog-post' if stringified else return a GeneratedUri class object - -``` - -To use the router class, Flight Routing has a default route request handler class to use with router. Also there are two ways of using router with collection: - -Router one #1: - -```php -$router = new Router(); - -$router->setCollection(static function (RouteCollection $routes): void { - $routes->add(new Route('/blog/{slug}*', handler: BlogController::class))->bind('blog_show'); -}); -``` - -Router two #2: - -```php -$router = Router::withCollection(); -$router->addRoute((new Route('/blog/{slug}*', handler: BlogController::class))->bind('blog_show')); ``` -Default way of dispatching Router's route to web browser: +In this example below, I'll assume you've installed [nyholm/psr-7][10] and [laminas/laminas-httphandlerrunner][11], So we can use [PSR-15][3] to intercept route before matching and [PSR-17][12] to render route response onto the browser: ```php -use App\Controller\BlogController; -use Biurad\Http\Factory\NyholmPsr7Factory as Psr17Factory; -use Flight\Routing\{Handlers\RouteHandler, RouteCollection, Router}; +use Flight\Routing\Handlers\RouteHandler; use Laminas\HttpHandlerRunner\Emitter\SapiStreamEmitter; $router->pipe(...); # Add PSR-15 middlewares ... $handlerResolver = ... // You can add your own route handler resolver else default is null +$responseFactory = ... // Add PSR-17 response factory +$request = ... // A PSR-7 server request initialized from global request // Default route handler, a custom request route handler can be used also. -$handler = new RouteHandler($psr17Factory = new Psr17Factory(), $handlerResolver); +$handler = new RouteHandler($responseFactory, $handlerResolver); // Match routes with incoming request and return a response -$response = $router->process($psr17Factory->fromGlobalRequest(), $handler); +$response = $router->process($request, $handler); // Send response to the browser ... (new SapiStreamEmitter())->emit($response); - ``` -> **NOTE**: Using the default request route handler class has many advantages, features like custom route handler resolver and auto-detection of PSR-7 response content type for plain, html, xml and svg contents are supported. - -> The Route class accepts handler types of `Psr\Http\Server\RequestHandlerInterface`, callable, invocable class object, class::method, or array of [class, method]. - -### Loading Annotated Routes - -This library is shipped with annotations support, check **Annotation** directory to find out more about collecting annotations into the routes collection's class. - -I suggests using [biurad-annotations] to use doctrine annotations and PHP 8 attributes on route classes. You can also create your own implementation to use load annotations using the `Flight\Routing\Annotation\Route` class. +To use [PHP][1] 8 attribute support, I highly recommend installing [biurad/annotations][13] and if for some reason you decide to use [doctrine/annotations][14] I recommend you install [spiral/attributes][15] to use either one or both. -run this in command line if the package has not be added. - -```bash -composer require biurad/annotations -``` +An example using annotations/attribute is: ```php use Biurad\Annotations\AnnotationLoader; -use Biurad\Http\Factory\NyholmPsr7Factory as Psr17Factory; +use Doctrine\Common\Annotations\AnnotationRegistry; use Flight\Routing\Annotation\Listener; -use Flight\Routing\RouteCollection; use Spiral\Attributes\{AnnotationReader, AttributeReader}; +use Spiral\Attributes\Composite\MergeReader; -// Setting a reader means spiral/attributes package must exist, if you're on PHP >= 8 a reader is not required. $reader = new AttributeReader(); -$loader = new AnnotationLoader($reader); - -$loader->listener(new Listener($routes = new RouteCollection())); -$loader->resource('src/Controller', 'src/Bundle/BundleName/Controller']); -$loader->build(); // Load and cache attributes found for reusability -$matcher = new RouteMatcher($routes); -// or -$router = new Router(); -$router->setCollection(static function (RouteCollection $routes) use ($loader): void { - $annotations = $loader->load('Flight\Routing\Annotation\Route'); +// If you only want to use PHP 8 attribute support, you can skip this step and set reader to null. +if (\class_exists(AnnotationRegistry::class)) $reader = new MergeReader([new AnnotationReader(), $reader]); - $routes->populate($annotations); - // or you can use grouping - $routes->group('', $annotations); -}); - -``` - -### Basic Routing - -This documentation for route pattern is based on [DefaultCompiler] class. Route pattern are path string with curly brace placeholders. Possible placeholder format are: - -- `{name}` - required placeholder. -- `{name=foo}` - placeholder with default value. -- `{name:regex}` - placeholder with regex definition. -- `{name:regex=foo}` - placeholder with regex definition and default value. -- `[{name}]` - optional placeholder. - -Variable placeholders may contain only word characters (latin letters, digits, and underscore) and must be unique within the pattern. For placeholders without an explicit regex, a variable placeholder matches any number of characters other than '/' (i.e [^/]+). - -> **NB:** Do not use digit for placeholder or it's value shouldn't be greater than 31 characters. - -Examples: - -- `/foo/` - Matches only if the path is exactly '/foo/'. There is no special treatment for trailing slashes, and patterns have to match the entire path, not just a prefix. -- `/user/{id}` - Matches '/user/bob' or '/user/1234!!!' but not '/user/' or '/user' or even '/user/bob/details'. -- `/user/{id:[^/]+}` - Same as the previous example. -- `/user[/{id}]` - Same as the previous example, but also match '/user'. -- `/user[/{id}]/` - Same as the previous example, but also match '/user/'. -- `/user/{id:[0-9a-fA-F]{1,8}}` - Only matches if the id parameter consists of 1 to 8 hex digits. -- `/files/{path:.*}` - Matches any URL starting with '/files/' and captures the rest of the path into the parameter 'path'. - -Below is a very basic example of setting up a route. First parameter is the url which the route should match - next parameter is a `Closure` or callback function that will be triggered once the route matches. - -```php -use Flight\Routing\{Route, RouteCollection}; - -$routes = new RouteCollection(); -$route = new Route('/', ['GET', 'HEAD'], fn () => 'Hello world'}); - -// Create a new route using $router. -$routes->add($route); -``` - -Here is an example of a complex route, flight routing can handle: - -```php -$routes->addRoute('/[{lang:[a-z]{2}}[-{sublang}]/]{name}[/page-{page=0}]'); - -// Accepted URLs: -// /cs/hello -// /en-us/hello -// /hello -// /hello/page-12 -// /ru/hello/page-12 -``` - -## Route Collection - -The route collection class contains all available routes, in the route collection class, there are 7 http request methods: [head, get, post, patch, put, options, delete]. - -Below is a basic example of how the route collection class can be used: - -```php -$collection = new RouteCollection(); - -// callable grouping -$group1 = function (RouteCollection $group) { - // Define your routes using $group... -}; - -// or collection grouping -$group2 = new RouteCollection(); -$group2->addRoute('/phpinfo', ['GET', 'HEAD'], 'phpinfo'); - -$collection->group('group_name', $group1); -$collection->group('group_name', $group2); - -//or dsl -$collection->group('group_name') - ->addRoute('/phpinfo', ['GET', 'HEAD'], 'phpinfo')->end() - // ... More can be added including nested grouping -->end(); -``` - -Sometimes you might need to create a route that accepts multiple HTTP-verbs. If you need to match all HTTP-verbs you can use the `any` method. - -```php -$routes->any('foo', fn () => 'Hello World'); -``` - -## Generating URLs From Named Routes - -URL generator tries to keep the URL as short as possible (while unique), so what can be omitted is not used. The behavior of generating urls from route depends on the respective parameters sequence given. - -Once you have assigned a name to a given route, you may use the route's name, its parameters and maybe add query, when generating URLs: - -```php -// Generating URLs... -$url = $router->generateUri('profile'); -``` - -If the named route defines parameters, you may pass the parameters as the second argument to the `url` function. The given parameters will automatically be inserted into the URL in their correct positions: - -```php -$collector->get('/user/{id}/profile', function ($id) { - // -})->bind('profile'); - -$url = $router->generateUri('profile', ['id' => 1]); // will produce "user/1/profile" -// or -$url = $router->generateUri('profile', [1]); // will produce "user/1/profile" -``` - -## Route Middlewares - -Router supports middleware, you can use it for different purposes like authentication, authorization, throttles and so forth. Middleware run before controllers and it can check and manipulate http requests and response.: - -Here you can see the request lifecycle considering some middleware: - -```text -Input --[Request]↦ Router ↦ Middleware 1 ↦ ... ↦ Middleware N ↦ Controller - ↧ -Output ↤[Response]- Router ↤ Middleware 1 ↤ ... ↤ Middleware N ↤ [Response] -``` - -We using [laminas-stratigility] to allow better and saver middleware usage. - -run this in command line if the package has not be added. - -```bash -composer require laminas/laminas-stratigility -``` - -To declare a middleware, you must implements Middleware `Psr\Http\Server\MiddlewareInterface` interface. - -Middleware must have a `process()` method that catches http request and a request handler (which runs the next middleware or the controller) and it returns a response at the end. Middleware can break the lifecycle and return a response itself or it can run the `$handler` implementing `Psr\Http\Server\RequestHandlerInterface` to continue lifecycle. - -For example see the following snippet. In this snippet, we will demonstrate how a middleware works: - -```php -use Flight\Routing\Route; -use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; - -$collector->get( - '/{param}', - function (ServerRequestInterface $request, ResponseInterface $response) { - return $request->getAttribute(Route::class)->getArguments(); - } -))->bind('watch'); -``` - -where `ParamWatcher` middleware is: - -```php -namespace Demo\Middleware; - - -use Demo\Exception\UnauthorizedException; -use Flight\Routing\Route; -use Psr\Http\Message\{ResponseInterface, ServerRequestInterface}; -use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface}; - -class ParamWatcher implements MiddlewareInterface -{ - /** - * {@inheritdoc} - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $arguments = $request->getAttribute(Route::class)->getArguments(); - - if ($arguments['param'] === 'forbidden') { - throw new UnauthorizedException(); - } - - return $handler->handle($request); - } -} -``` - -This route will trigger Unauthorized exception on `/forbidden`. - -> The default way of associating a middleware to a route is via the `Flight\Routing\Router::pipe` method. since route is present in server request attributes, you can create a middleware to work on only a particular named route(s). Again, you can add as many middlewares as you want. Middlewares can be implemented using closures but it doesn’t make sense to do so! - -## Multiple Routes - -Flight Routing supports **MRM (Multiple Routes Match)**. This increases SEO (search engine optimization) as it prevents multiple URLs to link to different content (without a proper redirect), the **MRM** feature is to serve static routes first, making other routes declared reachable. - -In order to enable this feature, you have to pass a true value to the second parameter of the route's collection main class. Route collection will return a list of routes starting with static routes eg: '/hello/world'. Make sure to avoid situations where dynamic route `/hello/{world}` matches a condition such as `/[{foo}/{bar}]` or `/[{foo}/{bar}]`. - -```php -use Flight\Routing\Route; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - -// this route will be trigger after static routes. -$collector->get( - '/{param}', - function (ServerRequestInterface $request, ResponseInterface $response) { - return $request->getAttribute(Route::class)->getArguments(); - } -)) - -// this route will be trigger first -$collector->get( - '/hello', - function (ServerRequestInterface $request, ResponseInterface $response) { - return $request->getAttribute(Route::class)->getArguments(); - } -)) -``` - -## Domain Routing - -Routes can be set to be used on domains or sub-domains. Example, you can point multiple domains or subdomain to the directory your project lives in or use `CNAME` rule. Flight routing will take care of the rest. - -Instead of a domain not found exception, except a null return using route matcher class and a route not found exception using router class. - -```php -use Flight\Routing\Interfaces\RouteCollection; - -// Domain -$collector->get('/', 'Controller::staticMethod')->domain('domain.com'); - -// Subdomain -$collector->get('/', 'function_handler')->domain('server2.domain.com'); - -// Subdomain regex pattern -$collector->get('/', ['Controller', 'method'])->domain('{accounts:.*}.domain.com'); +$loader = new AnnotationLoader($reader); +$loader->listener(new Listener(), 'my_routes'); +$loader->resource('src/Controller', 'src/Bundle/BundleName/Controller'); -$collector->group(function (RouteCollection $route) { - $route->get('/user/{id}', function ($id) { - // - }); -})->domain('account.myapp.com'); +$annotation = $loader->load('my_routes'); // Returns a Flight\Routing\RouteCollection class instance ``` -## RESTful Routing - -All of `Flight\Routing\Route` has a restful implementation, which specifies the method selection behavior. use `Flight\Routing\Handlers\ResourceHandler` class receiving the real handler, or use `Flight\Routing\RouteCollection::resource` method to automatically prefix all the methods in `Flight\Routing\Router::HTTP_METHODS_STANDARD` with HTTP verb. +You can add more listeners to the annotation loader class to have all your annotations/attributes loaded from one place. +Also use either the `populate()` route collection method or `group()` to merge annotation's route collection into default route collection, or just simple use the annotation's route collection as your default router route collection. -For example, we can use the following controller: +Finally, use a restful route, refer to this example below, using `Flight\Routing\RouteCollection::resource`, method means, route becomes available for all standard request methods `Flight\Routing\Router::HTTP_METHODS_STANDARD`: ```php namespace Demo\Controller; -class UserController -{ - public function getUser(int $id): string - { +class UserController { + public function getUser(int $id): string { return "get {$id}"; } - public function postUser(int $id): string - { + public function postUser(int $id): string { return "post {$id}"; } - public function deleteUser(int $id): string - { + public function deleteUser(int $id): string { return "delete {$id}"; } } @@ -500,155 +177,52 @@ class UserController Add route using `Flight\Routing\Handlers\ResourceHandler`: ```php -use Demo\UserController; use Flight\Routing\Handlers\ResourceHandler; -$route = new Route('/user/{id:\d+}', ['GET', 'POST'], new ResourceHandler(UserController::class, 'user')); - -// Using the `ResourceHandler` class means, the route is restful, the "user" passed into resource handler second parameter, is to be prefixed on class object method. Eg: getUser() which can be served on uri like /user/23 -``` - -Add route using `Flight\Routing\RouteCollection::resource`, doing this means, route becomes available for all standard request methods: - -```php -use Demo\UserController; - -$collector->resource('/user/{id:\d+}', UserController::class, 'user'); +$routes->add('/user/{id:\d+}', ['GET', 'POST'], new ResourceHandler(Demo\UserController::class, 'user')); ``` -> Invoking `/user/1` with different HTTP methods will call different controller methods. Note, you still need -> to specify the action name. - -## Custom Route Compiler - -If these offered route pattern do not fit your needs, you may create your own route compiler. Route matching is nothing more than an implementation of [RouteCompilerInterface](https://github.com/divineniiquaye/flight-routing/blob/master/src/Interfaces/RouteCompilerInterface.php). Your custom compiler must fit in the rules of the [DefaultCompiler]: - -```php -use Flight\Routing\Generator\GeneratedUri; -use Flight\Routing\Route; -use Flight\Routing\Interfaces\RouteCompilerInterface; - -class MyRouteCompiler implements RouteCompilerInterface -{ - /** - * {@inheritdoc} - */ - public function build(iterable $routes): array - { - // Create your own variables here ... - - foreach ($routes as $i => $route) { - [$pathRegex, $hostsRegex, $compiledVars] = $this->compile($route); - // Write your own implementation ... - } - - // If you using default route matcher, return must match. - // Else return your own set ... - } - - /** - * {@inheritdoc} - */ - public function compile(Route $route): array - { - if (!empty($hosts = $route->get('domain'))) { - $hostsRegex = ... // Compile host if supported else, return an empty array - } - - $pathRegex = ... // Compile path and return the regex excluding anything starting ^ and ending $. - $hostRegexs = ... // Compile route hosts and return the regex excluding anything starting ^ and ending $. - $variables = ... // A merged array from $hostsRegex and $pathRegex. - - return [$pathRegex, $hostRegexps, $variables]; // The results ... - } +As of Version 2.0, flight routing is very much stable and can be used in production, Feel free to contribute to the project, report bugs, request features and so on. - /** - * {@inheritdoc} - */ - public function generateUri(Route $route, array $parameters): GeneratedUri - { - // Same as compile method implementation or may differ, result should reverse the route and/or domain - // patterns into URI. - - return new GeneratedUri(...); // The results ... - } -} -``` +> Kindly take note of these before using: +> * The route collection class is declared as final and it's `getIterator` method returns a PHP SplFixedArray instance. +> * Avoid declaring the same pattern of dynamic route multiple times (eg. `/hello/{name}`), instead use static paths if you choose use same route path with multiple configurations. +> * Route handlers prefixed with a `\` (eg. `\HelloClass` or `['\HelloClass', 'handle']`) should be avoided if you choose to use a different resolver other the default hander's RouteInvoker class. +> * If you decide again to use a custom route's handler resolver, I recommend you use the static `resolveRoute` method from the default's route's RouteInvoker class. ## 📓 Documentation -For in-depth documentation before using this library.. Full documentation on advanced usage, configuration, and customization can be found at [docs.biurad.com][docs]. - -## ⏫ Upgrading - -Information on how to upgrade to newer versions of this library can be found in the [UPGRADE]. - -## 🏷️ Changelog - -[SemVer](http://semver.org/) is followed closely. Minor and patch releases should not introduce breaking changes to the codebase; See [CHANGELOG] for more information on what has changed recently. - -Any classes or methods marked `@internal` are not intended for use outside of this library and are subject to breaking changes at any time, so please avoid using them. - -## 🛠️ Maintenance & Support - -(This policy may change in the future and exceptions may be made on a case-by-case basis.) - -- A new **patch version released** (e.g. `1.0.10`, `1.1.6`) comes out roughly every month. It only contains bug fixes, so you can safely upgrade your applications. -- A new **minor version released** (e.g. `1.1`, `1.2`) comes out every six months: one in June and one in December. It contains bug fixes and new features, but it doesn’t include any breaking change, so you can safely upgrade your applications; -- A new **major version released** (e.g. `1.0`, `2.0`, `3.0`) comes out every two years. It can contain breaking changes, so you may need to do some changes in your applications before upgrading. - -When a **major** version is released, the number of minor versions is limited to five per branch (X.0, X.1, X.2, X.3 and X.4). The last minor version of a branch (e.g. 1.4, 2.4) is considered a **long-term support (LTS) version** with lasts for more that 2 years and the other ones cam last up to 8 months: - -**Get a professional support from [Biurad Lap][] after the active maintenance of a released version has ended**. - -## 🧪 Testing - -```bash -$ ./vendor/bin/phpunit -``` - -This will tests divineniiquaye/rade-di will run against PHP 7.4 version or higher. - -## 🏛️ Governance - -This project is primarily maintained by [Divine Niiquaye Ibok][@divineniiquaye]. Contributions are welcome 👷‍♀️! To contribute, please familiarize yourself with our [CONTRIBUTING] guidelines. - -To report a security vulnerability, please use the [Biurad Security](https://security.biurad.com). We will coordinate the fix and eventually commit the solution in this project. +In-depth documentation on how to use this library, kindly check out the [documentation][16] for this library. It is also recommended to browse through unit tests in the [tests](./tests/) directory. ## 🙌 Sponsors -Are you interested in sponsoring development of this project? Reach out and support us on [Patreon](https://www.patreon.com/biurad) or see for a list of ways to contribute. +If this library made it into your project, or you interested in supporting us, please consider [donating][17] to support future development. ## 👥 Credits & Acknowledgements -- [Divine Niiquaye Ibok][@divineniiquaye] -- [Anatoly Fenric][] -- [All Contributors][] - -Version 1.0 of this code was partly a referenced implementation of [Sunrise Http Router][] which is written, maintained and copyrighted by [Anatoly Fenric][]. Starting from version 2.0 a referenced implementation was taken from [Symfony Routing Component][]. +- [Divine Niiquaye Ibok][18] is the author this library. +- [All Contributors][19] who contributed to this project. ## 📄 License -The **divineniiquaye/flight-routing** library is copyright © [Divine Niiquaye Ibok](https://divinenii.com) and licensed for use under the [![Software License](https://img.shields.io/badge/License-BSD--3-brightgreen.svg?style=flat-square)](./LICENSE). - -[Composer]: https://getcomposer.org -[PHP]: https://php.net -[PSR-7]: http://www.php-fig.org/psr/psr-6/ -[PSR-11]: http://www.php-fig.org/psr/psr-11/ -[PSR-15]: http://www.php-fig.org/psr/psr-15/ -[@divineniiquaye]: https://github.com/divineniiquaye -[docs]: https://docs.biurad.com/flight-routing -[commit]: https://commits.biurad.com/flight-routing.git -[UPGRADE]: UPGRADE-1.x.md -[CHANGELOG]: CHANGELOG-1.x.md -[CONTRIBUTING]: ./.github/CONTRIBUTING.md -[All Contributors]: https://github.com/divineniiquaye/flight-routing/contributors -[Biurad Lap]: https://team.biurad.com -[email]: support@biurad.com -[message]: https://projects.biurad.com/message -[biurad-annotations]: https://github.com/biurad/annotations -[biurad-http-galaxy]: https://github.com/biurad/php-http-galaxy -[DefaultCompiler]: https://github.com/divineniiquaye/flight-routing/blob/master/src/RouteCompiler.php -[Anatoly Fenric]: https://anatoly.fenric.ru/ -[Sunrise Http Router]: https://github.com/sunrise-php/http-router -[Symfony Routing Component]: https://github.com/symfony/routing +Flight Routing is completely free and released under the [BSD 3 License](LICENSE). + +[1]: https://php.net +[2]: http://www.php-fig.org/psr/psr-7/ +[3]: http://www.php-fig.org/psr/psr-15/ +[4]: https://github.com/sunrise-php/http-router +[5]: https://github.com/symfony/routing +[6]: https://github.com/nikic/FastRoute +[7]: https://getcomposer.org +[8]: https://divinenii.com/blog/php-web_server_configuration +[9]: https://github.com/biurad/php-http-galaxy +[10]: https://github.com/nyholm/psr7 +[11]: https://github.com/laminas/laminas-httphandlerrunner +[12]: https://www.php-fig.org/psr/psr-17/ +[13]: https://github.com/biurad/php-annotations +[14]: https://github.com/doctrine/annotations +[15]: https://github.com/spiral/attributes +[16]: https://divinenii.com/courses/php-flight-routing/ +[17]: https://divinenii.com/sponser +[18]: https://github.com/divineniiquaye +[19]: https://github.com/divineniiquaye/flight-routing/contributors From 0c6fa4a7b42cdec7c4676ffa920380da8db57619 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Thu, 8 Sep 2022 08:54:35 +0000 Subject: [PATCH 33/40] Update CHANGELOG.md --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a026a6b..8be85276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,45 @@ CHANGELOG ========= +2.0 +=== + +* [BC BREAK] Removed the route class to use array instead of object +* [BC BREAK] Removed the route matcher class to use only the `Flight\Routing\Router` class for route matching +* [BC BREAK] Removed the `buildRoutes` method from the route collection class, use the `getRoutes` method directly +* [BC BREAK] Removed the `getRoute` method from the route collection class, use the `offGet` method instead +* [BC BREAK] Removed the `routes` method from the route collection class with no replacement +* [BC BREAK] Removed the `addRoute` method from the route collection class, use the `add` method instead +* [BC BREAK] Removed the `isCached` and `addRoute` methods from the default router class +* [BC BREAK] Removed classes, traits and class methods which are unnecessary or affects performance of routing +* [BC BREAK] Improved the route collection class to use array based routes instead of objects +* [BC BREAK] Improved how the default route handler handles array like callable handlers +* [BC BREAK] Replaced the route matcher implementation in the router class for compiler's implementation instead +* [BC BREAK] Replaced unmatched route host exception to instead return null and a route not found exception +* [BC BREAK] Renamed the `Flight\Routing\Generator\GeneratedUri` class to `Flight\Routing\RouteUri` +* Removed `symfony/var-exporter` library support from caching support, using PHP `var-export` function instead +* Added a new `FileHandler` handler class to return contents from a valid file as PSR-7 response +* Added new sets of requirements to the `Flight\Routing\RouteCompiler::SEGMENT_TYPES` constant +* Added a `offGet` method to the route collection class for finding route by it index number +* Added PHP `Countable` support to the route collection class, for faster routes count +* Added PHP `ArrayAccess` support to the route collection class for easier access to routes +* Added support for the default route compiler placeholder's default rule from `\w+` to `.*?` +* Added a static `export` method to the default router class to export php values in a well formatted way for caching +* Improved the route annotation's listener and attribute class for better performance +* Improved the default route matcher's `generateUri` method reversing a route path and strictly matching parameters +* Improved the default route matcher's class `Flight\Routing\Router::match()` method for better performance +* Improved the default route handler's class for easier extending of route handler and arguments rendering +* Improved the default route handler's class ability to detect content type of string +* Improved and fixed route namespacing issues +* Improved thrown exceptions messages for better debugging of errors +* Improved the sorting of routes in the route's collection class +* Improved the `README.md` doc file for better understanding on how to use this library +* Improved coding standard in making the codebase more readable +* Improved benchmarking scenarios for better performance comparison +* Improved performance tremendously, see [Benchmark Results](./BENCHMARK.txt) +* Updated all tests units rewritten with `pestphp/pest` for easier maintenance and improved benchmarking +* Updated minimum requirement for installing this library to PHP 8.0 + 1.6 === From 7f030515010c2536dbc1f10f62603f48e15efdfa Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Thu, 8 Sep 2022 08:57:06 +0000 Subject: [PATCH 34/40] Fixed minor issue with GitHub Action's test workflow --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 742e5688..56aea057 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: operating-system: [ubuntu-latest, windows-latest, macos-latest] - php-versions: ['7.4', '8.0', '8.1'] + php-versions: ['8.0', '8.1', '8.2'] runs-on: ${{ matrix.operating-system }} @@ -46,7 +46,7 @@ jobs: run: composer install --no-progress --optimize-autoloader - name: Run unit tests (PHPUnit) - run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover + run: vendor/bin/pest --coverage --coverage-clover=coverage.clover - name: "Upload coverage report to Codecov" uses: codecov/codecov-action@v2 From 019ae884af79de27d2a879d093e10dc31f8cba83 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Thu, 8 Sep 2022 09:16:49 +0000 Subject: [PATCH 35/40] Fixed minor issue exporting non-public property object value --- src/Traits/CacheTrait.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Traits/CacheTrait.php b/src/Traits/CacheTrait.php index b3737ff6..cc27aa13 100644 --- a/src/Traits/CacheTrait.php +++ b/src/Traits/CacheTrait.php @@ -75,6 +75,8 @@ public static function export(mixed $value, string $indent = ''): string if (\method_exists($value, '__set_state')) { return $value::class.'::__set_state('.self::export( \array_merge(...\array_map(function (\ReflectionProperty $v) use ($value): array { + $v->setAccessible(true); + return [$v->getName() => $v->getValue($value)]; }, (new \ReflectionObject($value))->getProperties())) ); From 4a4df90466b3d2287f1bcd3087dfa02c92762728 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Thu, 8 Sep 2022 09:17:41 +0000 Subject: [PATCH 36/40] Fixed minor issue failing test in GitHub CI --- tests/HandlersTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/HandlersTest.php b/tests/HandlersTest.php index 796969e2..d2def031 100644 --- a/tests/HandlersTest.php +++ b/tests/HandlersTest.php @@ -131,7 +131,7 @@ CSV; }]); t\assertInstanceOf(ResponseInterface::class, $res = $handler->handle($req)); - t\assertSame('text/csv', $res->getHeaderLine('Content-Type')); + t\assertTrue(\in_array($res->getHeaderLine('Content-Type'), ['text/csv', 'application/csv'], true)); t\assertSame(200, $res->getStatusCode()); }); From 071d835b7deff72299060d7d2e1481bfc13a2b59 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 10 Oct 2022 16:20:28 +0000 Subject: [PATCH 37/40] Updated GitHub CI workflow --- .github/workflows/tests.yml | 42 ++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 56aea057..62fe8366 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,25 +27,39 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: mbstring, xml, ctype, iconv, curl + extensions: mbstring, xml, ctype, iconv, curl, fileinfo coverage: xdebug tools: composer:v2 - name: Get composer cache directory - id: composercache + id: composer-cache run: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Cache composer dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: - path: ${{ steps.composercache.outputs.dir }} + path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - name: Install dependencies - run: composer install --no-progress --optimize-autoloader + run: composer install --no-progress --optimize-autoloader ${{ 8.2 == matrix.php-versions && '--ignore-platform-reqs' || '' }} - - name: Run unit tests (PHPUnit) + - name: Check coding standards (PHP_CodeSniffer) + run: vendor/bin/phpcs + + - name: Statically analyze code (Phpstan) + run: vendor/bin/phpstan analyse + + - name: Statically analyze code (Psalm) + run: vendor/bin/psalm --output-format=github --taint-analysis --shepherd --report=build/logs/psalm.sarif + + - name: "Upload security analysis results to GitHub" + uses: "github/codeql-action/upload-sarif@v2" + with: + sarif_file: "build/logs/psalm.sarif" + + - name: Run unit tests (Pest) run: vendor/bin/pest --coverage --coverage-clover=coverage.clover - name: "Upload coverage report to Codecov" @@ -60,22 +74,8 @@ jobs: env: CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} - - name: Check coding standards (PHP_CodeSniffer) - run: vendor/bin/phpcs - - - name: Statically analyze code (Phpstan) - run: vendor/bin/phpstan analyse - - - name: Statically analyze code (Psalm) - run: vendor/bin/psalm --output-format=github --taint-analysis --shepherd --report=build/logs/psalm.sarif - - - name: "Upload security analysis results to GitHub" - uses: "github/codeql-action/upload-sarif@v1" - with: - sarif_file: "build/logs/psalm.sarif" - - name: "Benchmark for Performance" if: matrix.operating-system != 'macos-latest' run: | - composer require phpbench/phpbench -W + composer require phpbench/phpbench -W --dev ${{ 8.2 == matrix.php-versions && '--ignore-platform-reqs' || '' }} vendor/bin/phpbench run --report=default -l none From 15374e9a21364c55ac72d4faaed2594cf78439f8 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 10 Oct 2022 19:51:31 +0000 Subject: [PATCH 38/40] Fixed minor issue caching routes --- src/Traits/CacheTrait.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Traits/CacheTrait.php b/src/Traits/CacheTrait.php index cc27aa13..d6ee1f48 100644 --- a/src/Traits/CacheTrait.php +++ b/src/Traits/CacheTrait.php @@ -152,7 +152,7 @@ protected function buildCache(RouteCollection $collection, bool $doCache): strin $this->optimized[1][1][$i] = $var; } \ksort($this->optimized[0], \SORT_NATURAL); - \ksort($dynamicRoutes, \SORT_NATURAL); + \uksort($dynamicRoutes, fn (string $a, string $b): int => \in_array('/', [$a, $b], true) ? \strcmp($b, $a) : \strcmp($a, $b)); foreach ($dynamicRoutes as $offset => $paths) { $numParts = \max(1, \round(($c = \count($paths)) / 30)); @@ -162,12 +162,6 @@ protected function buildCache(RouteCollection $collection, bool $doCache): strin } } - if (isset($this->optimized[1][0]['/'])) { - $last = $this->optimized[1][0]['/']; - unset($this->optimized[1][0]['/']); - $this->optimized[1][0]['/'] = $last; - } - return self::export([...$this->optimized, $doCache ? $collection : null]); } } From aca5d78bba6fa88584d1095192c849bf1a90a918 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 10 Oct 2022 20:35:41 +0000 Subject: [PATCH 39/40] Fixed minor issue failing tests in GitHub CI --- tests/RouteCollectionTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/RouteCollectionTest.php b/tests/RouteCollectionTest.php index 66c0eb4e..0151713d 100644 --- a/tests/RouteCollectionTest.php +++ b/tests/RouteCollectionTest.php @@ -815,6 +815,9 @@ ], $names); $routes->sort(); + $routes = $routes->getRoutes(); + $routes[4]['handler'][0] = 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\MultipleMethodRouteController'; + $routes[5]['handler'][0] = 'Flight\\Routing\\Tests\\Fixtures\\Annotation\\Route\\Valid\\DefaultNameController'; t\assertEquals(<<<'EOT' [ [ @@ -1173,7 +1176,7 @@ 'name' => 'action', ], ] - EOT, debugFormat($routes->getRoutes())); + EOT, debugFormat($routes)); })->setRunTestInSeparateProcess(true); test('if route path from attribute route can be invalid', function (): void { From 058addbc718022d948f8abbb12cff2caa22c0324 Mon Sep 17 00:00:00 2001 From: Divine Niiquaye Ibok Date: Mon, 10 Oct 2022 21:29:41 +0000 Subject: [PATCH 40/40] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 53781228..222767d2 100644 --- a/README.md +++ b/README.md @@ -185,10 +185,9 @@ $routes->add('/user/{id:\d+}', ['GET', 'POST'], new ResourceHandler(Demo\UserCon As of Version 2.0, flight routing is very much stable and can be used in production, Feel free to contribute to the project, report bugs, request features and so on. > Kindly take note of these before using: -> * The route collection class is declared as final and it's `getIterator` method returns a PHP SplFixedArray instance. > * Avoid declaring the same pattern of dynamic route multiple times (eg. `/hello/{name}`), instead use static paths if you choose use same route path with multiple configurations. > * Route handlers prefixed with a `\` (eg. `\HelloClass` or `['\HelloClass', 'handle']`) should be avoided if you choose to use a different resolver other the default hander's RouteInvoker class. -> * If you decide again to use a custom route's handler resolver, I recommend you use the static `resolveRoute` method from the default's route's RouteInvoker class. +> * If you decide again to use a custom route's handler resolver, I recommend you include the static `resolveRoute` method from the default's route's RouteInvoker class. ## 📓 Documentation