From e9b42da024a6b899b01fc044442e32ba8a28e38d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 23 Jun 2024 01:08:05 +0400 Subject: [PATCH] feat(router): added support for query (GET) parameters --- src/Handler/Router/Attribute/QueryParam.php | 22 ++++ src/Handler/Router/Router.php | 120 +++++++++++++++++--- tests/Unit/Handler/Router/RouterTest.php | 2 +- 3 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 src/Handler/Router/Attribute/QueryParam.php diff --git a/src/Handler/Router/Attribute/QueryParam.php b/src/Handler/Router/Attribute/QueryParam.php new file mode 100644 index 00000000..cbe005e0 --- /dev/null +++ b/src/Handler/Router/Attribute/QueryParam.php @@ -0,0 +1,22 @@ + $assertions * @throws AssertRouteFailed */ - public static function assert(array $routes, array $assertions): void + public static function assert(\ReflectionMethod $method, array $routes, array $assertions): void { $index = \array_fill_keys(\array_column(Method::cases(), 'value'), []); - $reflection = new \ReflectionMethod(self::class, 'doNothing'); foreach ($routes as $route) { $route instanceof RouteAttribute or throw new \InvalidArgumentException(\sprintf( 'Routes expected to be `%s` instances, `%s` given.', @@ -43,7 +43,7 @@ public static function assert(array $routes, array $assertions): void )); $index[$route->method->value][] = new RouteDto( - method: $reflection, + method: $method, route: $route, ); } @@ -52,6 +52,7 @@ public static function assert(array $routes, array $assertions): void /** @var list $fails */ $fails = []; + $mockMethod = new \ReflectionMethod(self::class, 'doNothing'); foreach ($assertions as $assertion) { $assertion instanceof AssertAttribute or throw new \InvalidArgumentException(\sprintf( 'Assertions expected to be `%s` instances, `%s` given.', @@ -60,7 +61,7 @@ public static function assert(array $routes, array $assertions): void )); try { - $handler = $self->match($assertion->method, $assertion->path); + $handler = $self->match($assertion->method, $assertion->path, $mockMethod); } catch (\Throwable $e) { throw new AssertRouteFailed(\sprintf( '> Failed to match route -> %s `%s`.', @@ -132,12 +133,19 @@ public static function new(string|object $classOrObject): self /** * Find a route for specified method and path. * + * @param \ReflectionMethod|null $mock Mock method to use instead of the real one. The real method will be used + * for arguments resolution. + * * @return null|callable(mixed...): mixed Returns null if no route matches * * @throws \Exception */ - public function match(Method $method, string $path): ?callable + public function match(Method $method, string $uri, ?\ReflectionMethod $mock = null): ?callable { + $components = \parse_url($uri); + $path = $components['path'] ?? ''; + $query = $components['query'] ?? ''; + foreach ($this->routes[$method->value] as $route) { $rr = $route->route; /** @psalm-suppress ArgumentTypeCoercion */ @@ -156,14 +164,23 @@ public function match(Method $method, string $path): ?callable continue; } + $get = []; + \parse_str($query, $get); + // Prepare callable $object = $this->object; return match(true) { \is_callable($match) => $match, - default => static fn(mixed ...$args): mixed => self::invoke( - $route->method, - $object, - \array_merge($args, \is_array($match) ? $match : []), + default => static fn(mixed ...$args): mixed => ($mock ?? $route->method)->invokeArgs( + ($mock ?? $route->method)->isStatic() + ? null + : $object, + self::resolveArguments( + $route->method, + $object, + \array_merge($args, \is_array($match) ? $match : []), + $get, + ) ), }; } @@ -237,12 +254,19 @@ private static function collectRoutes(string $class): array } /** - * Invoke a method with specified arguments. The arguments will be filtered by parameter names. + * Resolve arguments for the route method. + * + * @param array $args Arguments for the URI path + * @param array $params Query parameters (GET) * * @throws \Throwable */ - private static function invoke(\ReflectionMethod $method, ?object $object, array $args): mixed - { + private static function resolveArguments( + \ReflectionMethod $method, + ?object $object, + array $args, + array $params, + ): array { if ($method->isVariadic()) { $filteredArgs = $args; } else { @@ -250,6 +274,13 @@ private static function invoke(\ReflectionMethod $method, ?object $object, array $filteredArgs = []; foreach ($method->getParameters() as $param) { $name = $param->getName(); + + /** @var null|RouteQueryParam $queryParam */ + $queryParam = ($param->getAttributes(RouteQueryParam::class)[0] ?? null)?->newInstance(); + if ($queryParam !== null) { + $filteredArgs[$name] = self::convertQueryParam($param, $params); + continue; + } if (isset($args[$name])) { /** @psalm-suppress MixedAssignment */ $filteredArgs[$name] = $args[$name]; @@ -257,7 +288,70 @@ private static function invoke(\ReflectionMethod $method, ?object $object, array } } - return $method->invokeArgs($method->isStatic() ? null : $object, $filteredArgs); + return $filteredArgs; + } + + /** + * Convert query parameter to the specified type. + */ + public static function convertQueryParam( + \ReflectionParameter $param, + array $params, + ): mixed { + $name = $param->getName(); + $type = $param->getType(); + + $queryName = $queryParam->name ?? $name; + if (!isset($params[$queryName])) { + $param->isDefaultValueAvailable() or throw new \InvalidArgumentException(\sprintf( + 'Query parameter `%s` is required.', + $queryName, + )); + + return $param->getDefaultValue(); + } + + $value = $params[$queryName]; + if ($type === null) { + return $value; + } + + foreach (($type instanceof \ReflectionUnionType ? $type->getTypes() : [$type]) as $t) { + $typeString = \ltrim($t?->getName() ?? '', '?'); + switch (true) { + case $typeString === 'mixed': + return $value; + case $typeString === 'array': + return (array) $value; + case $typeString === 'int': + return (int) (\is_array($value) + ? throw new \InvalidArgumentException( + \sprintf( + 'Query parameter `%s` must be an integer, array given.', + $queryName, + ) + ) + : $value); + case $typeString === 'string': + return (string) (\is_array($value) + ? throw new \InvalidArgumentException( + \sprintf( + 'Query parameter `%s` must be a string, array given.', + $queryName, + ) + ) + : $value); + default: + continue 2; + } + } + + throw new \InvalidArgumentException(\sprintf( + 'Query parameter `%s` must be of type `%s`, `%s` given.', + $queryName, + $type, + \gettype($value), + )); } private static function doNothing(mixed ...$args): array diff --git a/tests/Unit/Handler/Router/RouterTest.php b/tests/Unit/Handler/Router/RouterTest.php index 70d59a6c..ef373cd5 100644 --- a/tests/Unit/Handler/Router/RouterTest.php +++ b/tests/Unit/Handler/Router/RouterTest.php @@ -61,7 +61,7 @@ public function testRouteAssertions(string $class): void $asserts, ); - Router::assert($routes, $asserts); + Router::assert($method, $routes, $asserts); self::assertTrue(true, (string) $method . ' passed'); } }