Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/router/src/Exceptions/ControllerActionDoesNotExist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Tempest\Router\Exceptions;

use Exception;

final class ControllerActionDoesNotExist extends Exception implements RouterException
{
public static function controllerNotFound(string $controller): self
{
return new self("The controller class `{$controller}` does not exist.");
}

public static function actionNotFound(string $controller, string $method): self
{
return new self("The method `{$method}()` does not exist in controller class `{$controller}`.");
}
}
15 changes: 15 additions & 0 deletions packages/router/src/Exceptions/ControllerMethodHadNoRoute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Router\Exceptions;

use Exception;

final class ControllerMethodHadNoRoute extends Exception implements RouterException
{
public function __construct(string $controllerClass, string $controllerMethod)
{
parent::__construct("No route found for `{$controllerClass}::{$controllerMethod}()`. Did you forget to add a `Route` attribute?");
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Tempest\Router\Exceptions;

use Exception;
use Tempest\Support\Arr;

final class ControllerMethodHasMultipleRoutes extends Exception implements RouterException
{
/** @param string[] $routes */
public function __construct(string $controllerClass, string $controllerMethod, array $routes)
{
parent::__construct(vsprintf(
format: "Controller method `%s::%s()` has multiple different routes: \"%s\". Please use the route path directly.",
values: [
$controllerClass,
$controllerMethod,
Arr\join($routes),
],
));
}
}
4 changes: 4 additions & 0 deletions packages/router/src/RouteConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public function __construct(
/** @var array<string,\Tempest\Router\Routing\Matching\MatchingRegex> */
public array $matchingRegexes = [],

/** @var array<string,string[]> */
public array $handlerIndex = [],

/** @var class-string<\Tempest\Router\ResponseProcessor>[] */
public array $responseProcessors = [],

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ final class RouteConfigurator
/** @var array<string,array<string,\Tempest\Router\Routing\Construction\DiscoveredRoute>> */
private array $dynamicRoutes = [];

/** @var array<string,string[]> */
private array $handlerIndex = [];

private bool $isDirty = false;

private RoutingTree $routingTree;
Expand All @@ -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 {
Expand Down Expand Up @@ -76,6 +83,7 @@ public function toRouteConfig(): RouteConfig
$this->staticRoutes,
$this->dynamicRoutes,
$this->routingTree->toMatchingRegexes(),
$this->handlerIndex,
);
}

Expand Down
29 changes: 21 additions & 8 deletions packages/router/src/UriGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
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\Exceptions\ControllerMethodHasMultipleRoutes;
use Tempest\Router\Routing\Construction\DiscoveredRoute;
use Tempest\Support\Arr;
use Tempest\Support\Regex;
Expand All @@ -25,6 +27,7 @@ final class UriGenerator
{
public function __construct(
private AppConfig $appConfig,
private RouteConfig $routeConfig,
private Signer $signer,
private Container $container,
) {}
Expand Down Expand Up @@ -178,7 +181,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) {
Expand Down Expand Up @@ -206,14 +209,24 @@ 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);
}

if (count($routes) > 1) {
throw new ControllerMethodHasMultipleRoutes($controllerClass, $controllerMethod, $routes);
}

return Str\ensure_starts_with($routeAttribute->uri, '/');
return Str\ensure_starts_with($routes[0], '/');
}
}
6 changes: 2 additions & 4 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#[Get('/repeated/d')]
#[Post('/repeated/e')]
#[Post('/repeated/f')]
#[Get('/repeated/f')]
public function __invoke(): Response
{
return new Ok();
Expand Down
57 changes: 55 additions & 2 deletions tests/Integration/Route/UriGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@

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\Exceptions\ControllerMethodHasMultipleRoutes;
use Tempest\Router\UriGenerator;
use Tests\Tempest\Fixtures\Controllers\ControllerWithEnumBinding;
use Tests\Tempest\Fixtures\Controllers\ControllerWithRepeatedRoutes;
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;
Expand Down Expand Up @@ -56,11 +59,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
{
Expand Down Expand Up @@ -249,4 +262,44 @@ public function cannot_add_custom_signature(): void
signature: 'uwu',
);
}

#[Test]
public function generates_uri_with_prefix_decorator(): void
{
$this->assertSame(
'/prefix/endpoint',
$this->generator->createUri(PrefixController::class),
);

$this->assertSame(
'/prefix/endpoint',
uri(PrefixController::class),
);
}

#[Test]
public function is_current_uri_with_prefix_decorator(): void
{
$this->http->get('/prefix/endpoint')->assertOk();

$this->assertTrue($this->generator->isCurrentUri(PrefixController::class));
}

#[Test]
public function controller_with_multiple_routes(): void
{
$this->expectException(ControllerMethodHasMultipleRoutes::class);
$this->expectExceptionMessage('Controller method `' . ControllerWithRepeatedRoutes::class . '::__invoke()` has multiple different routes');

$this->generator->createUri(ControllerWithRepeatedRoutes::class);
}

#[Test]
public function uri_to_controller_with_multiple_routes(): void
{
$this->assertSame(
'/repeated/a',
$this->generator->createUri('/repeated/a'),
);
}
}