diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 3744783..2e85042 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -107,6 +107,68 @@ class UserController } ``` +## Using controller methods + +In addition to invokable controllers, X also supports specifying a controller class and method pair like this: + +```php title="public/index.php" +get('/users', [Acme\Todo\UserController::class, 'index']); +$app->get('/users/{name}', [Acme\Todo\UserController::class, 'show']); + +$app->run(); +``` + +This allows you to group related actions into controller classes: + +```php title="src/UserController.php" +getAttribute('name') . "!\n" + ); + } +} +``` + +You can also use an instantiated controller if you need to: + +```php title="public/index.php" +get('/users', [$userController, 'index']); +$app->get('/users/{name}', [$userController, 'show']); + +$app->run(); +``` + ## Composer autoloading Doesn't look too complex, right? Now, we only need to tell Composer's autoloader diff --git a/src/Container.php b/src/Container.php index 3ea15fd..45f348a 100644 --- a/src/Container.php +++ b/src/Container.php @@ -99,6 +99,55 @@ public function callable(string $class): callable }; } + /** + * @param class-string|object $class + * @param string $method + * @return callable(ServerRequestInterface,?callable=null) + * @internal + */ + public function callableMethod($class, string $method): callable + { + return function (ServerRequestInterface $request, ?callable $next = null) use ($class, $method) { + // Get a controller instance - either use the object directly or instantiate from class name + if (is_object($class)) { + $handler = $class; + } else { + // Check if class exists and is valid + if (\is_array($this->container) && !\class_exists($class, true) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new \BadMethodCallException('Request handler class ' . $class . ' not found'); + } + + try { + if ($this->container instanceof ContainerInterface) { + $handler = $this->container->get($class); + } else { + $handler = $this->loadObject($class); + } + } catch (\Throwable $e) { + throw new \BadMethodCallException( + 'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(), + 0, + $e + ); + } + } + + // Ensure $handler is an object at this point + assert(is_object($handler)); + + // Check if method exists on the controller + if (!method_exists($handler, $method)) { + throw new \BadMethodCallException('Request handler class "' . (is_object($class) ? get_class($class) : $class) . '" has no public ' . $method . '() method'); + } + + // invoke controller method as middleware handler or final controller + if ($next === null) { + return $handler->$method($request); + } + return $handler->$method($request, $next); + }; + } + /** @internal */ public function getEnv(string $name): ?string { diff --git a/src/Io/RouteHandler.php b/src/Io/RouteHandler.php index 2bd988b..4053f82 100644 --- a/src/Io/RouteHandler.php +++ b/src/Io/RouteHandler.php @@ -40,8 +40,8 @@ public function __construct(?Container $container = null) /** * @param string[] $methods * @param string $route - * @param callable|class-string $handler - * @param callable|class-string ...$handlers + * @param callable|class-string|array{0:class-string|object,1:string} $handler + * @param callable|class-string|array{0:class-string|object,1:string} ...$handlers */ public function map(array $methods, string $route, $handler, ...$handlers): void { @@ -60,6 +60,10 @@ public function map(array $methods, string $route, $handler, ...$handlers): void unset($handlers[$i]); } elseif ($handler instanceof AccessLogHandler || $handler === AccessLogHandler::class) { throw new \TypeError('AccessLogHandler may currently only be passed as a global middleware'); + } elseif (\is_array($handler)) { + if (count($handler) === 2) { + $handlers[$i] = $container->callableMethod($handler[0], $handler[1]); + } } elseif (!\is_callable($handler)) { $handlers[$i] = $container->callable($handler); } diff --git a/tests/AppTest.php b/tests/AppTest.php index b27919f..e3c2f29 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -20,6 +20,7 @@ use FrameworkX\Tests\Fixtures\InvalidConstructorUntyped; use FrameworkX\Tests\Fixtures\InvalidInterface; use FrameworkX\Tests\Fixtures\InvalidTrait; +use FrameworkX\Tests\Fixtures\MethodController; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -1687,4 +1688,59 @@ private function createAppWithoutLogger(callable ...$middleware): App ...$middleware ); } + + public function testControllerMethodPairAsRouteHandler(): void + { + $app = $this->createAppWithoutLogger(); + + $app->get('/index', [MethodController::class, 'index']); + $app->get('/show/{id}', [MethodController::class, 'show']); + + $request = new ServerRequest('GET', '/index'); + $response = $app($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('index', (string)$response->getBody()); + + $request = new ServerRequest('GET', '/show/123'); + $response = $app($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('show 123', (string)$response->getBody()); + } + + public function testControllerInstanceMethodPairAsRouteHandler(): void + { + $app = $this->createAppWithoutLogger(); + $controller = new MethodController(); + $app->get('/index', [$controller, 'index']); + $app->get('/show/{id}', [$controller, 'show']); + + $request = new ServerRequest('GET', '/index'); + $response = $app($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('index', (string)$response->getBody()); + + $request = new ServerRequest('GET', '/show/123'); + $response = $app($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('show 123', (string)$response->getBody()); + } + + public function testControllerMethodPairAsMiddleware(): void + { + $app = $this->createAppWithoutLogger(); + $app->get('/middleware', [MethodController::class, 'middleware'], function () { + return Response::plaintext('middleware'); + }); + + $request = new ServerRequest('GET', '/middleware'); + $response = $app($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('middleware', (string)$response->getBody()); + $this->assertSame(['value'], $response->getHeader('X-Method-Controller')); + } } diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 31aabb4..ccacc22 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -1889,6 +1889,190 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidCl $callable($request); } + public function testCallableMethodReturnsCallableForClassNameViaAutowiring(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + public function method(ServerRequestInterface $request): Response + { + return new Response(200); + } + }; + + $container = new Container(); + + $callable = $container->callableMethod(get_class($controller), 'method'); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCallableMethodReturnsCallableForClassInstanceDirectly(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + public function method(ServerRequestInterface $request): Response + { + return new Response(200); + } + }; + + $container = new Container(); + + $callable = $container->callableMethod($controller, 'method'); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCallableMethodReturnsCallableForClassNameViaAutowiringWithConfigurationForDependency(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new \stdClass()) { + /** @var \stdClass */ + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function method(ServerRequestInterface $request): Response + { + return new Response(200, [], (string) json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => (object)['name' => 'Alice'] + ]); + + $callable = $container->callableMethod(get_class($controller), 'method'); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"name":"Alice"}', (string) $response->getBody()); + } + + public function testCallableMethodReturnsCallableForClassNameViaPsrContainer(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + public function method(ServerRequestInterface $request): Response + { + return new Response(200); + } + }; + + $psr = $this->createMock(ContainerInterface::class); + $psr->expects($this->never())->method('has'); + $psr->expects($this->once())->method('get')->with(get_class($controller))->willReturn($controller); + + assert($psr instanceof ContainerInterface); + $container = new Container($psr); + + $callable = $container->callableMethod(get_class($controller), 'method'); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCallableMethodReturnsCallableWithMiddlewareSupport(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + public function method(ServerRequestInterface $request, callable $next): Response + { + $response = $next($request); + return $response->withHeader('X-Controller', 'true'); + } + }; + + $container = new Container(); + + $callable = $container->callableMethod(get_class($controller), 'method'); + $this->assertInstanceOf(\Closure::class, $callable); + + $next = function (ServerRequestInterface $request) { + return new Response(200); + }; + + $response = $callable($request, $next); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(['true'], $response->getHeader('X-Controller')); + } + + public function testCallableMethodThrowsWhenMethodDoesNotExist(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + public function method(ServerRequestInterface $request): Response + { + return new Response(200); + } + }; + + $container = new Container(); + + $callable = $container->callableMethod(get_class($controller), 'nonExistentMethod'); + $this->assertInstanceOf(\Closure::class, $callable); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('has no public nonExistentMethod() method'); + $callable($request); + } + + + public function testCallableMethodThrowsWhenClassDoesNotExist(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container(); + + $callable = $container->callableMethod('NonExistingClass', 'method'); // @phpstan-ignore-line + $this->assertInstanceOf(\Closure::class, $callable); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Request handler class NonExistingClass not found'); + $callable($request); + } + + public function testCallableMethodThrowsWhenClassFailsToLoad(): void + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $exception = new class('Unable to load class') extends \RuntimeException implements NotFoundExceptionInterface { }; + + $psr = $this->createMock(ContainerInterface::class); + $psr->expects($this->never())->method('has'); + $psr->expects($this->once())->method('get')->with('FooBar')->willThrowException($exception); + + assert($psr instanceof ContainerInterface); + $container = new Container($psr); + + $callable = $container->callableMethod('FooBar', 'method'); // @phpstan-ignore-line + $this->assertInstanceOf(\Closure::class, $callable); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Request handler class FooBar failed to load: Unable to load class'); + $callable($request); + } + public function testGetEnvReturnsNullWhenEnvironmentDoesNotExist(): void { $container = new Container([]); diff --git a/tests/Fixtures/MethodController.php b/tests/Fixtures/MethodController.php new file mode 100644 index 0000000..32942d5 --- /dev/null +++ b/tests/Fixtures/MethodController.php @@ -0,0 +1,27 @@ +getAttribute('id')); + } + + public function middleware(ServerRequestInterface $request, callable $next): ResponseInterface + { + $response = $next($request); + + return $response->withHeader('X-Method-Controller', 'value'); + } +} \ No newline at end of file