diff --git a/packages/router/src/Exceptions/ControllerActionDoesNotExist.php b/packages/router/src/Exceptions/ControllerActionDoesNotExist.php new file mode 100644 index 000000000..2603dad93 --- /dev/null +++ b/packages/router/src/Exceptions/ControllerActionDoesNotExist.php @@ -0,0 +1,20 @@ + */ public array $matchingRegexes = [], + /** @var array */ + public array $handlerIndex = [], + /** @var class-string<\Tempest\Router\ResponseProcessor>[] */ public array $responseProcessors = [], @@ -37,6 +40,7 @@ public function apply(RouteConfig $newConfig): void $this->staticRoutes = $newConfig->staticRoutes; $this->dynamicRoutes = $newConfig->dynamicRoutes; $this->matchingRegexes = $newConfig->matchingRegexes; + $this->handlerIndex = $newConfig->handlerIndex; } public function addResponseProcessor(string $responseProcessor): void diff --git a/packages/router/src/RouteDiscovery.php b/packages/router/src/RouteDiscovery.php index 931519cde..f9556bd20 100644 --- a/packages/router/src/RouteDiscovery.php +++ b/packages/router/src/RouteDiscovery.php @@ -27,8 +27,8 @@ public function discover(DiscoveryLocation $location, ClassReflector $class): vo foreach ($routeAttributes as $routeAttribute) { $decorators = [ - ...$method->getDeclaringClass()->getAttributes(RouteDecorator::class), ...$method->getAttributes(RouteDecorator::class), + ...$method->getDeclaringClass()->getAttributes(RouteDecorator::class), ]; $route = DiscoveredRoute::fromRoute($routeAttribute, $decorators, $method); diff --git a/packages/router/src/Routing/Construction/RouteConfigurator.php b/packages/router/src/Routing/Construction/RouteConfigurator.php index 0c7d65861..c6d74b308 100644 --- a/packages/router/src/Routing/Construction/RouteConfigurator.php +++ b/packages/router/src/Routing/Construction/RouteConfigurator.php @@ -22,6 +22,9 @@ final class RouteConfigurator /** @var array> */ private array $dynamicRoutes = []; + /** @var array */ + private array $handlerIndex = []; + private bool $isDirty = false; private RoutingTree $routingTree; @@ -35,6 +38,10 @@ public function addRoute(DiscoveredRoute $route): void { $this->isDirty = true; + $handler = $route->handler->getDeclaringClass()->getName() . '::' . $route->handler->getName(); + $this->handlerIndex[$handler] ??= []; + $this->handlerIndex[$handler][] = $route->uri; + if ($route->isDynamic) { $this->addDynamicRoute($route); } else { @@ -76,6 +83,7 @@ public function toRouteConfig(): RouteConfig $this->staticRoutes, $this->dynamicRoutes, $this->routingTree->toMatchingRegexes(), + $this->handlerIndex, ); } diff --git a/packages/router/src/UriGenerator.php b/packages/router/src/UriGenerator.php index 195a78846..1d1cc92ae 100644 --- a/packages/router/src/UriGenerator.php +++ b/packages/router/src/UriGenerator.php @@ -13,7 +13,8 @@ use Tempest\Http\Request; use Tempest\Reflection\ClassReflector; use Tempest\Reflection\MethodReflector; -use Tempest\Router\Exceptions\ControllerMethodHadNoRouteAttribute; +use Tempest\Router\Exceptions\ControllerActionDoesNotExist; +use Tempest\Router\Exceptions\ControllerMethodHadNoRoute; use Tempest\Router\Routing\Construction\DiscoveredRoute; use Tempest\Support\Arr; use Tempest\Support\Regex; @@ -25,6 +26,7 @@ final class UriGenerator { public function __construct( private AppConfig $appConfig, + private RouteConfig $routeConfig, private Signer $signer, private Container $container, ) {} @@ -178,7 +180,7 @@ public function isCurrentUri(array|string|MethodReflector $action, mixed ...$par $matchedRoute = $this->container->get(MatchedRoute::class); $candidateUri = $this->createUri($action, ...[...$matchedRoute->params, ...$params]); - $currentUri = $this->createUri([$matchedRoute->route->handler->getDeclaringClass(), $matchedRoute->route->handler->getName()]); + $currentUri = $this->createUri([$matchedRoute->route->handler->getDeclaringClass()->getName(), $matchedRoute->route->handler->getName()]); foreach ($matchedRoute->params as $key => $value) { if ($value instanceof BackedEnum) { @@ -206,14 +208,20 @@ private function normalizeActionToUri(array|string|MethodReflector $action): str [$controllerClass, $controllerMethod] = is_array($action) ? $action : [$action, '__invoke']; - $routeAttribute = new ClassReflector($controllerClass) - ->getMethod($controllerMethod) - ->getAttribute(Route::class); + $routes = array_unique($this->routeConfig->handlerIndex[$controllerClass . '::' . $controllerMethod] ?? []); - if ($routeAttribute === null) { - throw new ControllerMethodHadNoRouteAttribute($controllerClass, $controllerMethod); + if ($routes === []) { + if (! class_exists($controllerClass)) { + throw ControllerActionDoesNotExist::controllerNotFound($controllerClass, $controllerMethod); + } + + if (! method_exists($controllerClass, $controllerMethod)) { + throw ControllerActionDoesNotExist::actionNotFound($controllerClass, $controllerMethod); + } + + throw new ControllerMethodHadNoRoute($controllerClass, $controllerMethod); } - return Str\ensure_starts_with($routeAttribute->uri, '/'); + return Str\ensure_starts_with($routes[0], '/'); } } diff --git a/rector.php b/rector.php index 3e383346f..76d45de6c 100644 --- a/rector.php +++ b/rector.php @@ -5,13 +5,11 @@ use Rector\Arguments\Rector\ClassMethod\ArgumentAdderRector; use Rector\Caching\ValueObject\Storage\FileCacheStorage; use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector; -use Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector; use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\PropertyProperty\RemoveNullPropertyInitializationRector; use Rector\Php70\Rector\StaticCall\StaticCallOnNonStaticToInstanceCallRector; use Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector; use Rector\Php74\Rector\Property\RestoreDefaultNullToNullableTypePropertyRector; -use Rector\Php74\Rector\Ternary\ParenthesizeNestedTernaryRector; use Rector\Php81\Rector\Array_\FirstClassCallableRector; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; @@ -21,8 +19,8 @@ use Rector\Php84\Rector\Param\ExplicitNullableParamTypeRector; use Rector\Privatization\Rector\ClassMethod\PrivatizeFinalClassMethodRector; use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector; +use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector; -use Rector\TypeDeclaration\Rector\Closure\ClosureReturnTypeRector; use Rector\TypeDeclaration\Rector\Empty_\EmptyOnNullableObjectToInstanceOfRector; return RectorConfig::configure() @@ -54,10 +52,10 @@ RestoreDefaultNullToNullableTypePropertyRector::class, ReturnNeverTypeRector::class, StaticCallOnNonStaticToInstanceCallRector::class, - ClosureReturnTypeRector::class, EncapsedStringsToSprintfRector::class, AddArrowFunctionReturnTypeRector::class, PrivatizeFinalClassMethodRector::class, + NarrowObjectReturnTypeRector::class, ]) ->withParallel(300, 10, 10) ->withPreparedSets( diff --git a/tests/Fixtures/Controllers/ControllerWithRepeatedRoutes.php b/tests/Fixtures/Controllers/ControllerWithRepeatedRoutes.php index 13797c517..367853db2 100644 --- a/tests/Fixtures/Controllers/ControllerWithRepeatedRoutes.php +++ b/tests/Fixtures/Controllers/ControllerWithRepeatedRoutes.php @@ -17,6 +17,7 @@ #[Get('/repeated/d')] #[Post('/repeated/e')] #[Post('/repeated/f')] + #[Get('/repeated/f')] public function __invoke(): Response { return new Ok(); diff --git a/tests/Fixtures/Controllers/PrefixController.php b/tests/Fixtures/Controllers/PrefixController.php index e61e6c8dc..7b7b8baa9 100644 --- a/tests/Fixtures/Controllers/PrefixController.php +++ b/tests/Fixtures/Controllers/PrefixController.php @@ -9,7 +9,7 @@ #[Prefix('/prefix')] final class PrefixController { - #[Get('/endpoint')] + #[Prefix('/method'), Get('/endpoint')] public function __invoke(): Ok { return new Ok(); diff --git a/tests/Integration/Route/RouterTest.php b/tests/Integration/Route/RouterTest.php index b97c7d544..a4478c43a 100644 --- a/tests/Integration/Route/RouterTest.php +++ b/tests/Integration/Route/RouterTest.php @@ -272,7 +272,7 @@ public function test_stateless_decorator(): void public function test_prefix_decorator(): void { $this->http - ->get('/prefix/endpoint') + ->get('/prefix/method/endpoint') ->assertOk(); } diff --git a/tests/Integration/Route/UriGeneratorTest.php b/tests/Integration/Route/UriGeneratorTest.php index 125e45d83..1f673a2c1 100644 --- a/tests/Integration/Route/UriGeneratorTest.php +++ b/tests/Integration/Route/UriGeneratorTest.php @@ -4,15 +4,16 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; -use ReflectionException; use Tempest\Core\AppConfig; use Tempest\Database\PrimaryKey; use Tempest\DateTime\Duration; use Tempest\Http\GenericRequest; use Tempest\Http\Method; +use Tempest\Router\Exceptions\ControllerActionDoesNotExist; use Tempest\Router\UriGenerator; use Tests\Tempest\Fixtures\Controllers\ControllerWithEnumBinding; use Tests\Tempest\Fixtures\Controllers\EnumForController; +use Tests\Tempest\Fixtures\Controllers\PrefixController; use Tests\Tempest\Fixtures\Controllers\TestController; use Tests\Tempest\Fixtures\Controllers\UriGeneratorController; use Tests\Tempest\Fixtures\Modules\Books\BookController; @@ -56,11 +57,21 @@ public function uri_functions(): void #[Test] public function uri_generation_with_invalid_fqcn(): void { - $this->expectException(ReflectionException::class); + $this->expectException(ControllerActionDoesNotExist::class); + $this->expectExceptionMessage('The controller class `Tests\Tempest\Fixtures\Controllers\TestControllerInvalid` does not exist.'); $this->generator->createUri(TestController::class . 'Invalid'); } + #[Test] + public function uri_generation_with_invalid_method(): void + { + $this->expectException(ControllerActionDoesNotExist::class); + $this->expectExceptionMessage('The method `invalid()` does not exist in controller class `Tests\Tempest\Fixtures\Controllers\TestController`.'); + + $this->generator->createUri([TestController::class, 'invalid']); + } + #[Test] public function uri_generation_with_query_param(): void { @@ -249,4 +260,35 @@ public function cannot_add_custom_signature(): void signature: 'uwu', ); } + + #[Test] + public function generates_uri_with_prefix_decorator(): void + { + $this->assertSame( + '/prefix/method/endpoint', + $this->generator->createUri(PrefixController::class), + ); + + $this->assertSame( + '/prefix/method/endpoint', + uri(PrefixController::class), + ); + } + + #[Test] + public function is_current_uri_with_prefix_decorator(): void + { + $this->http->get('/prefix/method/endpoint')->assertOk(); + + $this->assertTrue($this->generator->isCurrentUri(PrefixController::class)); + } + + #[Test] + public function uri_to_controller_with_multiple_routes(): void + { + $this->assertSame( + '/repeated/a', + $this->generator->createUri('/repeated/a'), + ); + } }