diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 8a293e8..62c2af8 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -1,4 +1,6 @@ -# Controller classes to structure your app +# Controller classes + +## First steps When starting with X, it's often easiest to start with simple closure definitions like suggested in the [quickstart guide](../getting-started/quickstart.md). @@ -113,6 +115,8 @@ class UserController } ``` +## Composer autoloading + Doesn't look too complex, right? Now, we only need to tell Composer's autoloader about our vendor namespace `Acme\Todo` in the `src/` folder. Make sure to include the following lines in your `composer.json` file: @@ -142,7 +146,7 @@ assured this is the only time you have to worry about this, new classes can simply be added without having to run Composer again. Again, let's see our web application still works by using your favorite -webbrowser or command line tool: +web browser or command-line tool: ```bash $ curl http://localhost:8080/ @@ -150,3 +154,75 @@ Hello wörld! ``` If everything works as expected, we can continue with writing our first tests to automate this. + +## Container + +X has a powerful, built-in dependency injection container (DI container or DIC). +It allows you to automatically create request handler classes and their +dependencies with zero configuration for most common use cases. + +> ℹ️ **Dependency Injection (DI)** +> +> Dependency injection (DI) is a technique in which an object receives other +> objects that it depends on, rather than creating these dependencies within its +> class. In its most basic form, this means creating all required object +> dependencies upfront and manually injecting them into the controller class. +> This can be done manually or you can use the optional container which does +> this for you. + +### Autowiring + +To use autowiring, simply pass in the class name of your request handler classes +like this: + +```php title="public/index.php" +get('/', Acme\Todo\HelloController::class); +$app->get('/users/{name}', Acme\Todo\UserController::class); + +$app->run(); +``` + +X will automatically take care of instantiating the required request handler +classes and their dependencies when a request comes in. This autowiring feature +covers most common use cases: + +* Names always reference existing class names. +* Class names need to be loadable through the autoloader. See + [composer autoloading](#composer-autoloading) above. +* Each class may or may not have a constructor. +* If the constructor has an optional argument, it will be omitted. +* If the constructor has a nullable argument, it will be given a `null` value. +* If the constructor references another class, it will load this class next. + +This covers most common use cases where the request handler class uses a +constructor with type definitions to explicitly reference other classes. + +### Container configuration + +> ⚠️ **Feature preview** +> +> This is a feature preview, i.e. it might not have made it into the current beta. +> Give feedback to help us prioritize. +> We also welcome [contributors](../getting-started/community.md) to help out! + +Autowiring should cover most common use cases with zero configuration. If you +want to have more control over this behavior, you may also explicitly configure +the dependency injection container. This can be useful in these cases: + +* Constructor parameter references an interface and you want to explicitly + define an instance that implements this interface. +* Constructor parameter has a primitive type (scalars such as `int` or `string` + etc.) or has no type at all and you want to explicitly bind a given value. +* Constructor parameter references a class, but you want to inject a specific + instance or subclass in place of a default class. + +In the future, we will also allow you to pass in a custom +[PSR-11: Container interface](https://www.php-fig.org/psr/psr-11/) implementing +the well-established `Psr\Container\ContainerInterface`. +We love standards and interoperability. diff --git a/src/RouteHandler.php b/src/RouteHandler.php index 4beb892..526d8d3 100644 --- a/src/RouteHandler.php +++ b/src/RouteHandler.php @@ -24,6 +24,9 @@ class RouteHandler /** @var ErrorHandler */ private $errorHandler; + /** @var array */ + private static $container = []; + public function __construct() { $this->routeCollector = new RouteCollector(new RouteParser(), new RouteGenerator()); @@ -92,17 +95,15 @@ 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 . '"'); + if (!\class_exists($class, true) && !interface_exists($class, false) && !trait_exists($class, false)) { + throw new \BadMethodCallException('Request handler class ' . $class . ' not found'); } - // 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(); + $handler = self::load($class); } catch (\Throwable $e) { throw new \BadMethodCallException( - 'Unable to instantiate request handler class "' . $class . '": ' . $e->getMessage(), + 'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(), 0, $e ); @@ -112,7 +113,7 @@ private static function callable($class): callable // 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()"'); + throw new \BadMethodCallException('Request handler class "' . $class . '" has no public __invoke() method'); } // invoke request handler as middleware handler or final controller @@ -122,4 +123,80 @@ private static function callable($class): callable return $handler($request, $next); }; } + + private static function load(string $name, int $depth = 64) + { + if (isset(self::$container[$name])) { + return self::$container[$name]; + } + + // Check `$name` references a valid class name that can be autoloaded + if (!\class_exists($name, true) && !interface_exists($name, false) && !trait_exists($name, false)) { + throw new \BadMethodCallException('Class ' . $name . ' not found'); + } + + $class = new \ReflectionClass($name); + if (!$class->isInstantiable()) { + $modifier = 'class'; + if ($class->isInterface()) { + $modifier = 'interface'; + } elseif ($class->isAbstract()) { + $modifier = 'abstract class'; + } elseif ($class->isTrait()) { + $modifier = 'trait'; + } + throw new \BadMethodCallException('Cannot instantiate ' . $modifier . ' '. $name); + } + + // build list of constructor parameters based on parameter types + $params = []; + $ctor = $class->getConstructor(); + assert($ctor === null || $ctor instanceof \ReflectionMethod); + foreach ($ctor !== null ? $ctor->getParameters() : [] as $parameter) { + assert($parameter instanceof \ReflectionParameter); + + // stop building parameters when encountering first optional parameter + if ($parameter->isOptional()) { + break; + } + + // ensure parameter is typed + $type = $parameter->getType(); + if ($type === null) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type'); + } + + // if allowed, use null value without injecting any instances + assert($type instanceof \ReflectionType); + if ($type->allowsNull()) { + $params[] = null; + continue; + } + + // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type); // @codeCoverageIgnore + } + + assert($type instanceof \ReflectionNamedType); + if ($type->isBuiltin()) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName()); + } + + // abort for unreasonably deep nesting or recursive types + if ($depth < 1) { + throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive'); + } + + $params[] = self::load($type->getName(), --$depth); + } + + // instantiate with list of parameters + return self::$container[$name] = $params === [] ? new $name() : $class->newInstance(...$params); + } + + private static function parameterError(\ReflectionParameter $parameter): string + { + return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . explode("\0", $parameter->getDeclaringClass()->getName())[0] . '::' . $parameter->getDeclaringFunction()->getName() . '()'; + } } diff --git a/tests/AppTest.php b/tests/AppTest.php index d106e63..c553f4f 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -8,10 +8,21 @@ use FrameworkX\MiddlewareHandler; use FrameworkX\RouteHandler; use FrameworkX\SapiHandler; +use FrameworkX\Tests\Fixtures\InvalidAbstract; +use FrameworkX\Tests\Fixtures\InvalidConstructorInt; +use FrameworkX\Tests\Fixtures\InvalidConstructorIntersection; +use FrameworkX\Tests\Fixtures\InvalidConstructorPrivate; +use FrameworkX\Tests\Fixtures\InvalidConstructorProtected; +use FrameworkX\Tests\Fixtures\InvalidConstructorSelf; +use FrameworkX\Tests\Fixtures\InvalidConstructorUnion; +use FrameworkX\Tests\Fixtures\InvalidConstructorUnknown; +use FrameworkX\Tests\Fixtures\InvalidConstructorUntyped; +use FrameworkX\Tests\Fixtures\InvalidInterface; +use FrameworkX\Tests\Fixtures\InvalidTrait; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use React\EventLoop\LoopInterface; +use React\EventLoop\Loop; use React\Http\Message\Response; use React\Http\Message\ServerRequest; use React\Promise\Promise; @@ -20,7 +31,6 @@ use ReflectionProperty; use function React\Promise\reject; use function React\Promise\resolve; -use React\EventLoop\Loop; class AppTest extends TestCase { @@ -1050,7 +1060,6 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp { $app = $this->createAppWithoutLogger(); - $line = __LINE__ + 2; $app->get('/users', 'UnknownClass'); $request = new ServerRequest('GET', 'http://localhost/users'); @@ -1068,19 +1077,81 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); - $this->assertStringMatchesFormat("%a

Expected 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.

\n%a", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class UnknownClass not found in RouteHandler.php:%d.

\n%a", (string) $response->getBody()); } - public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresConstructorParameter() + public function provideInvalidClasses() { - $app = $this->createAppWithoutLogger(); + yield [ + InvalidConstructorPrivate::class, + 'Cannot instantiate class ' . addslashes(InvalidConstructorPrivate::class) + ]; + + yield [ + InvalidConstructorProtected::class, + 'Cannot instantiate class ' . addslashes(InvalidConstructorProtected::class) + ]; + + yield [ + InvalidAbstract::class, + 'Cannot instantiate abstract class ' . addslashes(InvalidAbstract::class) + ]; + + yield [ + InvalidInterface::class, + 'Cannot instantiate interface ' . addslashes(InvalidInterface::class) + ]; + + yield [ + InvalidTrait::class, + 'Cannot instantiate trait ' . addslashes(InvalidTrait::class) + ]; + + yield [ + InvalidConstructorUntyped::class, + 'Argument 1 ($value) of %s::__construct() has no type' + ]; + + yield [ + InvalidConstructorInt::class, + 'Argument 1 ($value) of %s::__construct() expects unsupported type int' + ]; + + if (PHP_VERSION_ID >= 80000) { + yield [ + InvalidConstructorUnion::class, + 'Argument 1 ($value) of %s::__construct() expects unsupported type int|float' + ]; + } - $controller = new class(42) { - public function __construct(int $value) { } - }; + if (PHP_VERSION_ID >= 80100) { + yield [ + InvalidConstructorIntersection::class, + 'Argument 1 ($value) of %s::__construct() expects unsupported type Traversable&ArrayAccess' + ]; + } - $line = __LINE__ + 2; - $app->get('/users', get_class($controller)); + yield [ + InvalidConstructorUnknown::class, + 'Class UnknownClass not found' + ]; + + yield [ + InvalidConstructorSelf::class, + 'Argument 1 ($value) of %s::__construct() is recursive' + ]; + } + + /** + * @dataProvider provideInvalidClasses + * @param class-string $class + * @param string $error + */ + public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassIsInvalid(string $class, string $error) + { + $app = $this->createAppWithoutLogger(); + + $app->get('/users', $class); $request = new ServerRequest('GET', 'http://localhost/users'); @@ -1097,7 +1168,7 @@ public function __construct(int $value) { } $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); - $this->assertStringMatchesFormat("%a

Expected 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.

\n%a", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class " . addslashes($class) . " failed to load: $error in RouteHandler.php:%d.

\n%a", (string) $response->getBody()); } public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResponseWhenHandlerClassRequiresUnexpectedCallableParameter() @@ -1135,7 +1206,6 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $controller = new class { }; - $line = __LINE__ + 2; $app->get('/users', get_class($controller)); $request = new ServerRequest('GET', 'http://localhost/users'); @@ -1153,7 +1223,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); - $this->assertStringMatchesFormat("%a

Expected 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.

\n%a", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught BadMethodCallException with message Request handler class %s has no public __invoke() method in RouteHandler.php:%d.

\n%a", (string) $response->getBody()); } public function testHandleRequestWithMatchingRouteReturnsPromiseWhichFulfillsWithInternalServerErrorResponseWhenHandlerReturnsPromiseWhichFulfillsWithWrongValue() diff --git a/tests/Fixtures/InvalidAbstract.php b/tests/Fixtures/InvalidAbstract.php new file mode 100644 index 0000000..07de445 --- /dev/null +++ b/tests/Fixtures/InvalidAbstract.php @@ -0,0 +1,7 @@ +assertSame($response, $ret); } + public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandlerClassNameWithOptionalConstructor() + { + $request = new ServerRequest('GET', 'http://example.com/'); + $response = new Response(200, [], ''); + + $controller = new class { + public static $response; + public function __construct(int $value = null) { + } + public function __invoke() { + return self::$response; + } + }; + $controller::$response = $response; + + $handler = new RouteHandler(); + $handler->map(['GET'], '/', get_class($controller)); + + $ret = $handler($request); + + $this->assertSame($response, $ret); + } + + public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandlerClassNameWithNullableConstructor() + { + $request = new ServerRequest('GET', 'http://example.com/'); + $response = new Response(200, [], ''); + + $controller = new class(null) { + public static $response; + public function __construct(?int $value) { + } + public function __invoke() { + return self::$response; + } + }; + $controller::$response = $response; + + $handler = new RouteHandler(); + $handler->map(['GET'], '/', get_class($controller)); + + $ret = $handler($request); + + $this->assertSame($response, $ret); + } + + public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandlerClassNameWithRequiredResponseInConstructor() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new Response(500)) { + public static $response; + public function __construct(Response $response) { + self::$response = $response; + } + public function __invoke() { + return self::$response; + } + }; + + $handler = new RouteHandler(); + $handler->map(['GET'], '/', get_class($controller)); + + $ret = $handler($request); + + $this->assertSame($controller::$response, $ret); + } + public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandlerWithClassNameMiddleware() { $request = new ServerRequest('GET', 'http://example.com/'); $response = new Response(200, [], ''); - $middleware = new class{ + $middleware = new class { public function __invoke(ServerRequestInterface $request, callable $next) { return $next($request); } @@ -156,6 +224,27 @@ public function __invoke(ServerRequestInterface $request, callable $next) { $this->assertSame($response, $ret); } + public function testHandleRequestTwiceWithGetRequestCallsSameHandlerInstanceFromMatchingHandlerClassName() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + private $called = 0; + public function __invoke() { + return ++$this->called; + } + }; + + $handler = new RouteHandler(); + $handler->map(['GET'], '/', get_class($controller)); + + $ret = $handler($request); + $this->assertEquals(1, $ret); + + $ret = $handler($request); + $this->assertEquals(2, $ret); + } + public function testHandleRequestWithGetRequestWithHttpUrlInPathReturnsResponseFromMatchingHandler() { $request = new ServerRequest('GET', 'http://example.com/http://localhost/');