diff --git a/docs/api/app.md b/docs/api/app.md index 32eef65..81a926b 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -111,9 +111,27 @@ you keep adding more controllers to a single application. For this reason, we recommend using [controller classes](../best-practices/controllers.md) for production use-cases like this: -```php title="public/index.php" -$app->get('/', new Acme\Todo\HelloController()); -``` +=== "Using controller instances" + + ```php title="public/index.php" + get('/', new Acme\Todo\HelloController()); + ``` + +=== "Using controller names" + + ```php title="public/index.php" + get('/', Acme\Todo\HelloController::class); + ``` + + ```php title="src/HelloController.php" get('/user', new DemoMiddleware(), new UserController()); -``` + // … + + $app->get('/user', new DemoMiddleware(), new UserController()); + ``` + +=== "Using middleware names" + + ```php title="public/index.php" + get('/user', DemoMiddleware::class, UserController::class); + ``` This highlights how middleware classes provide the exact same functionaly as using inline functions, yet provide a cleaner and more reusable structure. @@ -145,17 +161,31 @@ class UserController } ``` -```php -# public/index.php -get('/user', new AdminMiddleware(), new UserController()); -``` + $app->get('/user', new AdminMiddleware(), new UserController()); + ``` + +=== "Using middleware names" + + ```php title="public/index.php" + get('/user', AdminMiddleware::class, UserController::class); + ``` For example, an HTTP `GET` request for `/user` would first call the middleware handler which then modifies this request and passes the modified request to the next controller function. This is commonly used for HTTP authentication, login handling and session handling. @@ -210,16 +240,31 @@ class UserController } ``` -```php title="public/index.php" -get('/user', new ContentTypeMiddleware(), new UserController()); -``` + $app->get('/user', new ContentTypeMiddleware(), new UserController()); + ``` + +=== "Using middleware names" + + ```php title="public/index.php" + get('/user', ContentTypeMiddleware::class, UserController::class); + ``` For example, an HTTP `GET` request for `/user` would first call the middleware handler which passes on the request to the controller function and then modifies the response that is returned by the controller function. This is commonly used for cache handling and response body transformations (compression etc.). @@ -428,17 +473,33 @@ a response object synchronously: } ``` + -```php title="public/index.php" -get('/user', new AsyncContentTypeMiddleware(), new AsyncUserController()); -``` + // … + + $app->get('/user', new AsyncContentTypeMiddleware(), new AsyncUserController()); + ``` + +=== "Using middleware names" + + ```php title="public/index.php" + get('/user', AsyncContentTypeMiddleware::class, AsyncUserController::class); + ``` For example, an HTTP `GET` request for `/user` would first call the middleware handler which passes on the request to the controller function and then modifies the response that is returned by the controller function. This is commonly used for cache handling and response body transformations (compression etc.). @@ -456,18 +517,35 @@ This is commonly used for cache handling and response body transformations (comp Additionally, you can also add middleware to the [`App`](app.md) object itself to register a global middleware handler: -```php hl_lines="7" title="public/index.php" -get('/user', new UserController()); + $app = new FrameworkX\App(new AdminMiddleware()); -$app->run(); -``` + $app->get('/user', new UserController()); + + $app->run(); + ``` + +=== "Using middleware names" + + ```php hl_lines="6" title="public/index.php" + get('/user', UserController::class); + + $app->run(); + ``` Any global middleware handler will always be called for all registered routes and also any requests that can not be routed. diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 321a234..8a293e8 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -40,18 +40,37 @@ For real-world applications, we highly recommend structuring your application into individual controller classes. This way, we can break up the above definition into three even simpler files: -```php title="public/index.php" -get('/', new Acme\Todo\HelloController()); -$app->get('/users/{name}', new Acme\Todo\UserController()); + $app = new FrameworkX\App(); -$app->run(); -``` + $app->get('/', new Acme\Todo\HelloController()); + $app->get('/users/{name}', new Acme\Todo\UserController()); + + $app->run(); + ``` + +=== "Using controller names" + + ```php title="public/index.php" + get('/', Acme\Todo\HelloController::class); + $app->get('/users/{name}', Acme\Todo\UserController::class); + + $app->run(); + ``` + + ```php title="src/HelloController.php" router = new RouteHandler(); @@ -53,51 +53,103 @@ public function __construct(callable ...$middleware) $this->sapi = new SapiHandler(); } - public function get(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function get(string $route, $handler, ...$handlers): void { $this->map(['GET'], $route, $handler, ...$handlers); } - public function head(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function head(string $route, $handler, ...$handlers): void { $this->map(['HEAD'], $route, $handler, ...$handlers); } - public function post(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function post(string $route, $handler, ...$handlers): void { $this->map(['POST'], $route, $handler, ...$handlers); } - public function put(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function put(string $route, $handler, ...$handlers): void { $this->map(['PUT'], $route, $handler, ...$handlers); } - public function patch(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function patch(string $route, $handler, ...$handlers): void { $this->map(['PATCH'], $route, $handler, ...$handlers); } - public function delete(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function delete(string $route, $handler, ...$handlers): void { $this->map(['DELETE'], $route, $handler, ...$handlers); } - public function options(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function options(string $route, $handler, ...$handlers): void { $this->map(['OPTIONS'], $route, $handler, ...$handlers); } - public function any(string $route, callable $handler, callable ...$handlers): void + /** + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function any(string $route, $handler, ...$handlers): void { $this->map(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], $route, $handler, ...$handlers); } - public function map(array $methods, string $route, callable $handler, callable ...$handlers): void + /** + * + * @param string[] $methods + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function map(array $methods, string $route, $handler, ...$handlers): void { $this->router->map($methods, $route, $handler, ...$handlers); } + /** + * @param string $route + * @param string $target + * @param int $code + */ public function redirect(string $route, string $target, int $code = 302): void { $this->any($route, new RedirectHandler($target, $code)); diff --git a/src/RouteHandler.php b/src/RouteHandler.php index 155b9df..4beb892 100644 --- a/src/RouteHandler.php +++ b/src/RouteHandler.php @@ -30,10 +30,23 @@ public function __construct() $this->errorHandler = new ErrorHandler(); } - public function map(array $methods, string $route, callable $handler, callable ...$handlers): void + /** + * @param string[] $methods + * @param string $route + * @param callable|class-string $handler + * @param callable|class-string ...$handlers + */ + public function map(array $methods, string $route, $handler, ...$handlers): void { if ($handlers) { - $handler = new MiddlewareHandler(array_merge([$handler], $handlers)); + $handler = new MiddlewareHandler(array_map( + function ($handler) { + return is_callable($handler) ? $handler : self::callable($handler); + }, + array_merge([$handler], $handlers) + )); + } elseif (!is_callable($handler)) { + $handler = self::callable($handler); } $this->routeDispatcher = null; @@ -70,4 +83,43 @@ public function __invoke(ServerRequestInterface $request) return $handler($request); } } // @codeCoverageIgnore + + /** + * @param class-string $class + * @return callable + */ + private static function callable($class): callable + { + return function (ServerRequestInterface $request, callable $next = null) use ($class) { + // Check `$class` references a valid class name that can be autoloaded + if (!\class_exists($class, true)) { + throw new \BadMethodCallException('Unable to load request handler class "' . $class . '"'); + } + + // This initial version is intentionally limited to loading classes that require no arguments. + // A follow-up version will invoke a DI container here to load the appropriate hierarchy of arguments. + try { + $handler = new $class(); + } catch (\Throwable $e) { + throw new \BadMethodCallException( + 'Unable to instantiate request handler class "' . $class . '": ' . $e->getMessage(), + 0, + $e + ); + } + + // Check `$handler` references a class name that is callable, i.e. has an `__invoke()` method. + // This initial version is intentionally limited to checking the method name only. + // A follow-up version will likely use reflection to check request handler argument types. + if (!is_callable($handler)) { + throw new \BadMethodCallException('Unable to use request handler class "' . $class . '" because it has no "public function __invoke()"'); + } + + // invoke request handler as middleware handler or final controller + if ($next === null) { + return $handler($request); + } + return $handler($request, $next); + }; + } } diff --git a/tests/AppTest.php b/tests/AppTest.php index 1bc527d..d106e63 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -1046,6 +1046,116 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $this->assertStringContainsString("
Expected request handler to return Psr\Http\Message\ResponseInterface but got null.
The requested page failed to load, please try again later.
\n", (string) $response->getBody()); + $this->assertStringMatchesFormat("%aExpected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Unable to load request handler class \"UnknownClass\" in RouteHandler.php:%d.
The requested page failed to load, please try again later.
\n", (string) $response->getBody()); + $this->assertStringMatchesFormat("%aExpected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Unable to instantiate request handler class \"%s\": %s in RouteHandler.php:%d.
The requested page failed to load, please try again later.
\n", (string) $response->getBody()); + $this->assertStringMatchesFormat("%aExpected request handler to return Psr\Http\Message\ResponseInterface but got uncaught TypeError with message %s in AppTest.php:$line.
The requested page failed to load, please try again later.
\n", (string) $response->getBody()); + $this->assertStringMatchesFormat("%aExpected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Unable to use request handler class \"%s\" because it has no \"public function __invoke()\" in RouteHandler.php:%d.