Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
62 changes: 62 additions & 0 deletions docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
<?php

require __DIR__ . '/../vendor/autoload.php';

$app = new FrameworkX\App();

// Using controller-method pair syntax
$app->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"
<?php

namespace Acme\Todo;

use Psr\Http\Message\ServerRequestInterface;
use React\Http\Message\Response;

class UserController
{
public function index()
{
return Response::plaintext(
"List of all users\n"
);
}

public function show(ServerRequestInterface $request)
{
return Response::plaintext(
"Hello " . $request->getAttribute('name') . "!\n"
);
}
}
```

You can also use an instantiated controller if you need to:

```php title="public/index.php"
<?php

require __DIR__ . '/../vendor/autoload.php';

$app = new FrameworkX\App();

$userController = new Acme\Todo\UserController();
$app->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
Expand Down
49 changes: 49 additions & 0 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
8 changes: 6 additions & 2 deletions src/Io/RouteHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);
}
Expand Down
56 changes: 56 additions & 0 deletions tests/AppTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'));
}
}
Loading