Skip to content

Commit

Permalink
feat(router): added support for query (GET) parameters
Browse files Browse the repository at this point in the history
  • Loading branch information
roxblnfk committed Jun 22, 2024
1 parent ab22057 commit e9b42da
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 14 deletions.
22 changes: 22 additions & 0 deletions src/Handler/Router/Attribute/QueryParam.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Buggregator\Trap\Handler\Router\Attribute;

/**
* Request query parameter.
*
* @internal
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
final class QueryParam
{
/**
* @param string|null $name Query parameter name. If not provided, the parameter name from
* the method signature is used.
*/
public function __construct(
public readonly ?string $name = null,
) {}
}
120 changes: 107 additions & 13 deletions src/Handler/Router/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Buggregator\Trap\Handler\Router;

use Buggregator\Trap\Handler\Router\Attribute\AssertRoute as AssertAttribute;
use Buggregator\Trap\Handler\Router\Attribute\QueryParam as RouteQueryParam;
use Buggregator\Trap\Handler\Router\Attribute\Route as RouteAttribute;
use Buggregator\Trap\Handler\Router\Exception\AssertRouteFailed;

Expand All @@ -31,10 +32,9 @@ private function __construct(
* @param array<AssertAttribute> $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.',
Expand All @@ -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,
);
}
Expand All @@ -52,6 +52,7 @@ public static function assert(array $routes, array $assertions): void

/** @var list<non-empty-string> $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.',
Expand All @@ -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`.',
Expand Down Expand Up @@ -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 */
Expand All @@ -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 : []),

Check failure on line 181 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedArgumentTypeCoercion

src/Handler/Router/Router.php:181:25: MixedArgumentTypeCoercion: Argument 3 of Buggregator\Trap\Handler\Router\Router::resolveArguments expects array<non-empty-string, mixed>, but parent type array<array-key, mixed|string> provided (see https://psalm.dev/194)
$get,
)
),
};
}
Expand Down Expand Up @@ -237,27 +254,104 @@ 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<non-empty-string, mixed> $args Arguments for the URI path
* @param array<array-key, mixed> $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 {
/** @var array<non-empty-string, mixed> $filteredArgs Filter args */
$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);

Check failure on line 281 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedAssignment

src/Handler/Router/Router.php:281:21: MixedAssignment: Unable to determine the type of this assignment (see https://psalm.dev/032)
continue;
}
if (isset($args[$name])) {
/** @psalm-suppress MixedAssignment */
$filteredArgs[$name] = $args[$name];
}
}
}

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;

Check failure on line 304 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedAssignment

src/Handler/Router/Router.php:304:9: MixedAssignment: Unable to determine the type that $queryName is being assigned to (see https://psalm.dev/032)

Check failure on line 304 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

UndefinedVariable

src/Handler/Router/Router.php:304:22: UndefinedVariable: Cannot find referenced variable $queryParam (see https://psalm.dev/024)

Check failure on line 304 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedPropertyFetch

src/Handler/Router/Router.php:304:22: MixedPropertyFetch: Cannot fetch property on mixed var $queryParam (see https://psalm.dev/034)
if (!isset($params[$queryName])) {

Check failure on line 305 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedArrayTypeCoercion

src/Handler/Router/Router.php:305:20: MixedArrayTypeCoercion: Coercion from array offset type 'mixed|non-empty-string' to the expected type 'array-key' (see https://psalm.dev/195)

Check failure on line 305 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedArrayOffset

src/Handler/Router/Router.php:305:20: MixedArrayOffset: Cannot access value on variable $params using mixed offset (see https://psalm.dev/031)
$param->isDefaultValueAvailable() or throw new \InvalidArgumentException(\sprintf(
'Query parameter `%s` is required.',
$queryName,

Check failure on line 308 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedArgument

src/Handler/Router/Router.php:308:17: MixedArgument: Argument 2 of sprintf cannot be mixed|non-empty-string, expecting float|int|object{__tostring()}|string (see https://psalm.dev/030)
));

return $param->getDefaultValue();
}

$value = $params[$queryName];

Check failure on line 314 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedAssignment

src/Handler/Router/Router.php:314:9: MixedAssignment: Unable to determine the type that $value is being assigned to (see https://psalm.dev/032)
if ($type === null) {
return $value;
}

foreach (($type instanceof \ReflectionUnionType ? $type->getTypes() : [$type]) as $t) {
$typeString = \ltrim($t?->getName() ?? '', '?');

Check failure on line 320 in src/Handler/Router/Router.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

DocblockTypeContradiction

src/Handler/Router/Router.php:320:34: DocblockTypeContradiction: ReflectionNamedType|ReflectionType does not contain null (see https://psalm.dev/155)
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
Expand Down
2 changes: 1 addition & 1 deletion tests/Unit/Handler/Router/RouterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Expand Down

0 comments on commit e9b42da

Please sign in to comment.