diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index 62c2af8..934a738 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -205,15 +205,53 @@ 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: +the dependency injection container like this: + +=== "Arrow functions (PHP 7.4+)" + + ```php title="public/index.php" + fn() => new Acme\Todo\HelloController(); + ]); + + + + $app = new FrameworkX\App($container); + + $app->get('/', Acme\Todo\HelloController::class); + $app->get('/users/{name}', Acme\Todo\UserController::class); + + $app->run(); + ``` + +=== "Closure" + + ```php title="public/index.php" + function () { + return new Acme\Todo\HelloController(); + } + ]); + + $app = new FrameworkX\App($container); + + $app->get('/', Acme\Todo\HelloController::class); + $app->get('/users/{name}', Acme\Todo\UserController::class); + + $app->run(); + ``` + +This can be useful in these cases: * Constructor parameter references an interface and you want to explicitly define an instance that implements this interface. @@ -222,6 +260,19 @@ the dependency injection container. This can be useful in these cases: * Constructor parameter references a class, but you want to inject a specific instance or subclass in place of a default class. +The configured container instance can be passed into the application like any +other middleware request handler. In most cases this means you create a single +`Container` instance with a number of factory methods and pass this instance as +the first argument to the `App`. + +### PSR-11 compatibility + +> ⚠️ **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! + 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`. diff --git a/docs/integrations/database.md b/docs/integrations/database.md index b829d3d..88b4e5e 100644 --- a/docs/integrations/database.md +++ b/docs/integrations/database.md @@ -379,23 +379,50 @@ acme/ > see [controller classes](../best-practices/controllers.md) for more details. The main entry point [registers a route](../api/app.md#routing) for our -controller and uses dependency injection (DI) to connect all classes: +controller and uses dependency injection (DI) or a +[DI container](../best-practices/controllers.md#container) to wire all classes: -```php title="public/index.php" -createLazyConnection($credentials); -$repository = new Acme\Todo\BookRepository($db); + require __DIR__ . '/../vendor/autoload.php'; -$app = new FrameworkX\App(); + $credentials = 'alice:secret@localhost/bookstore?idle=0.001'; + $db = (new React\MySQL\Factory())->createLazyConnection($credentials); + $repository = new Acme\Todo\BookRepository($db); -$app->get('/book/{isbn}, new Acme\Todo\BookLookupController($repository)); -$app->run(); -``` + + + $app = new FrameworkX\App(); + + $app->get('/book/{isbn}', new Acme\Todo\BookLookupController($repository)); + + $app->run(); + ``` + +=== "DI container" + + ```php title="public/index.php" + function () { + $credentials = 'alice:secret@localhost/bookstore?idle=0.001'; + return (new React\MySQL\Factory())->createLazyConnection($credentials); + } + ]); + + $app = new FrameworkX\App($container); + + $app->get('/book/{isbn}', Acme\Todo\BookLookupController::class); + + $app->run(); + ``` The main entity we're dealing with in this example is a plain PHP class which makes it super easy to write and to use in our code: diff --git a/src/App.php b/src/App.php index 2e88c4e..2a122ee 100644 --- a/src/App.php +++ b/src/App.php @@ -37,17 +37,18 @@ class App */ public function __construct(...$middleware) { - $container = new Container(); $errorHandler = new ErrorHandler(); - $this->router = new RouteHandler($container); + $container = new Container(); if ($middleware) { - $middleware = array_map( - function ($handler) use ($container) { - return is_callable($handler) ? $handler : $container->callable($handler); - }, - $middleware - ); + foreach ($middleware as $i => $handler) { + if ($handler instanceof Container) { + $container = $handler; + unset($middleware[$i]); + } elseif (!\is_callable($handler)) { + $middleware[$i] = $container->callable($handler); + } + } } // new MiddlewareHandler([$accessLogHandler, $errorHandler, ...$middleware, $routeHandler]) @@ -58,6 +59,7 @@ function ($handler) use ($container) { \array_unshift($middleware, new AccessLogHandler()); } + $this->router = new RouteHandler($container); $middleware[] = $this->router; $this->handler = new MiddlewareHandler($middleware); $this->sapi = new SapiHandler(); diff --git a/src/Container.php b/src/Container.php index d3f91a6..fe9c394 100644 --- a/src/Container.php +++ b/src/Container.php @@ -5,16 +5,39 @@ use Psr\Http\Message\ServerRequestInterface; /** - * @internal + * @final */ class Container { - /** @var array */ + /** @var array */ private $container; + /** @var array */ + public function __construct(array $map = []) + { + $this->container = $map; + } + + public function __invoke(ServerRequestInterface $request, callable $next = null) + { + if ($next === null) { + // You don't want to end up here. This only happens if you use the + // container as a final request handler instead of as a middleware. + // In this case, you should omit the container or add another final + // request handler behind the container in the middleware chain. + throw new \BadMethodCallException('Container should not be used as final request handler'); + } + + // If the container is used as a middleware, simply forward to the next + // request handler. As an additional optimization, the container would + // usually be filtered out from a middleware chain as this is a NO-OP. + return $next($request); + } + /** * @param class-string $class - * @return callable + * @return callable(ServerRequestInterface,?callable=null) + * @internal */ public function callable(string $class): callable { @@ -57,6 +80,10 @@ public function callable(string $class): callable private function load(string $name, int $depth = 64) { if (isset($this->container[$name])) { + if ($this->container[$name] instanceof \Closure) { + $this->container[$name] = ($this->container[$name])(); + } + return $this->container[$name]; } diff --git a/src/RouteHandler.php b/src/RouteHandler.php index 3dbc16e..5bfc021 100644 --- a/src/RouteHandler.php +++ b/src/RouteHandler.php @@ -43,16 +43,24 @@ public function __construct(Container $container = null) public function map(array $methods, string $route, $handler, ...$handlers): void { if ($handlers) { - $handler = new MiddlewareHandler(array_map( - function ($handler) { - return is_callable($handler) ? $handler : $this->container->callable($handler); - }, - array_merge([$handler], $handlers) - )); - } elseif (!is_callable($handler)) { - $handler = $this->container->callable($handler); + \array_unshift($handlers, $handler); + \end($handlers); + } else { + $handlers = [$handler]; } + $last = key($handlers); + $container = $this->container; + foreach ($handlers as $i => $handler) { + if ($handler instanceof Container && $i !== $last) { + $container = $handler; + unset($handlers[$i]); + } elseif (!\is_callable($handler)) { + $handlers[$i] = $container->callable($handler); + } + } + + $handler = \count($handlers) > 1 ? new MiddlewareHandler(array_values($handlers)) : \reset($handlers); $this->routeDispatcher = null; $this->routeCollector->addRoute($methods, $route, $handler); } diff --git a/tests/AppTest.php b/tests/AppTest.php index 6810be9..72efc37 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -4,6 +4,7 @@ use FrameworkX\AccessLogHandler; use FrameworkX\App; +use FrameworkX\Container; use FrameworkX\ErrorHandler; use FrameworkX\MiddlewareHandler; use FrameworkX\RouteHandler; @@ -55,6 +56,61 @@ public function testConstructWithMiddlewareAssignsGivenMiddleware() $this->assertInstanceOf(RouteHandler::class, $handlers[3]); } + public function testConstructWithContainerAssignsContainerForRouteHandlerOnly() + { + $container = new Container(); + $app = new App($container); + + $ref = new ReflectionProperty($app, 'handler'); + $ref->setAccessible(true); + $handler = $ref->getValue($app); + + $this->assertInstanceOf(MiddlewareHandler::class, $handler); + $ref = new ReflectionProperty($handler, 'handlers'); + $ref->setAccessible(true); + $handlers = $ref->getValue($handler); + + $this->assertCount(3, $handlers); + $this->assertInstanceOf(AccessLogHandler::class, $handlers[0]); + $this->assertInstanceOf(ErrorHandler::class, $handlers[1]); + $this->assertInstanceOf(RouteHandler::class, $handlers[2]); + + $routeHandler = $handlers[2]; + $ref = new ReflectionProperty($routeHandler, 'container'); + $ref->setAccessible(true); + $this->assertSame($container, $ref->getValue($routeHandler)); + } + + public function testConstructWithContainerAndMiddlewareClassNameAssignsCallableFromContainerAsMiddleware() + { + $middleware = function (ServerRequestInterface $request, callable $next) { }; + + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('callable')->with('stdClass')->willReturn($middleware); + + $app = new App($container, \stdClass::class); + + $ref = new ReflectionProperty($app, 'handler'); + $ref->setAccessible(true); + $handler = $ref->getValue($app); + + $this->assertInstanceOf(MiddlewareHandler::class, $handler); + $ref = new ReflectionProperty($handler, 'handlers'); + $ref->setAccessible(true); + $handlers = $ref->getValue($handler); + + $this->assertCount(4, $handlers); + $this->assertInstanceOf(AccessLogHandler::class, $handlers[0]); + $this->assertInstanceOf(ErrorHandler::class, $handlers[1]); + $this->assertSame($middleware, $handlers[2]); + $this->assertInstanceOf(RouteHandler::class, $handlers[3]); + + $routeHandler = $handlers[3]; + $ref = new ReflectionProperty($routeHandler, 'container'); + $ref->setAccessible(true); + $this->assertSame($container, $ref->getValue($routeHandler)); + } + public function testRunWillReportListeningAddressAndRunLoopWithSocketServer() { $socket = @stream_socket_server('127.0.0.1:8080'); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php new file mode 100644 index 0000000..94f3d35 --- /dev/null +++ b/tests/ContainerTest.php @@ -0,0 +1,156 @@ +callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCallableReturnsCallableForClassNameViaAutowiringWithConfigurationForDependency() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => (object)['name' => 'Alice'] + ]); + + $callable = $container->callable(get_class($controller)); + $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 testCallableReturnsCallableForClassNameViaAutowiringWithFactoryFunctionForDependency() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => function () { + return (object)['name' => 'Alice']; + } + ]); + + $callable = $container->callable(get_class($controller)); + $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 testCallableTwiceReturnsCallableForClassNameViaAutowiringWithFactoryFunctionForDependencyWillCallFactoryOnlyOnce() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class(new \stdClass()) { + private $data; + + public function __construct(\stdClass $data) + { + $this->data = $data; + } + + public function __invoke(ServerRequestInterface $request) + { + return new Response(200, [], json_encode($this->data)); + } + }; + + $container = new Container([ + \stdClass::class => function () { + static $called = 0; + return (object)['num' => ++$called]; + } + ]); + + $callable = $container->callable(get_class($controller)); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"num":1}', (string) $response->getBody()); + } + + public function testInvokeContainerAsMiddlewareReturnsFromNextRequestHandler() + { + $request = new ServerRequest('GET', 'http://example.com/'); + $response = new Response(200, [], ''); + + $container = new Container(); + $ret = $container($request, function () use ($response) { return $response; }); + + $this->assertSame($response, $ret); + } + + public function testInvokeContainerAsFinalRequestHandlerThrows() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $container = new Container(); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container should not be used as final request handler'); + $container($request); + } +} diff --git a/tests/RouteHandlerTest.php b/tests/RouteHandlerTest.php index b36c975..7d112bc 100644 --- a/tests/RouteHandlerTest.php +++ b/tests/RouteHandlerTest.php @@ -3,6 +3,7 @@ namespace FrameworkX\Tests; use FastRoute\RouteCollector; +use FrameworkX\Container; use FrameworkX\MiddlewareHandler; use FrameworkX\RouteHandler; use PHPUnit\Framework\TestCase; @@ -46,6 +47,60 @@ public function testMapRouteWithMiddlewareAndControllerAddsRouteWithMiddlewareHa $handler->map(['GET'], '/', $middleware, $controller); } + public function testMapRouteWithClassNameAddsRouteOnRouterWithControllerCallableFromContainer() + { + $controller = function () { }; + + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('callable')->with('stdClass')->willReturn($controller); + + $handler = new RouteHandler($container); + + $router = $this->createMock(RouteCollector::class); + $router->expects($this->once())->method('addRoute')->with(['GET'], '/', $controller); + + $ref = new \ReflectionProperty($handler, 'routeCollector'); + $ref->setAccessible(true); + $ref->setValue($handler, $router); + + $handler->map(['GET'], '/', \stdClass::class); + } + + public function testMapRouteWithContainerAndControllerAddsRouteOnRouterWithControllerOnly() + { + $controller = function () { }; + + $handler = new RouteHandler(); + + $router = $this->createMock(RouteCollector::class); + $router->expects($this->once())->method('addRoute')->with(['GET'], '/', $controller); + + $ref = new \ReflectionProperty($handler, 'routeCollector'); + $ref->setAccessible(true); + $ref->setValue($handler, $router); + + $handler->map(['GET'], '/', new Container(), $controller); + } + + public function testMapRouteWithContainerAndControllerClassNameAddsRouteOnRouterWithControllerCallableFromContainer() + { + $controller = function () { }; + + $container = $this->createMock(Container::class); + $container->expects($this->once())->method('callable')->with('stdClass')->willReturn($controller); + + $handler = new RouteHandler(); + + $router = $this->createMock(RouteCollector::class); + $router->expects($this->once())->method('addRoute')->with(['GET'], '/', $controller); + + $ref = new \ReflectionProperty($handler, 'routeCollector'); + $ref->setAccessible(true); + $ref->setValue($handler, $router); + + $handler->map(['GET'], '/', $container, \stdClass::class); + } + public function testHandleRequestWithProxyRequestReturnsResponseWithMessageThatProxyRequestsAreNotAllowed() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -271,4 +326,16 @@ public function testHandleRequestWithOptionsAsteriskRequestReturnsResponseFromMa $this->assertSame($response, $ret); } + + public function testHandleRequestWithContainerOnlyThrows() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $handler = new RouteHandler(); + $handler->map(['GET'], '/', new Container()); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Container should not be used as final request handler'); + $handler($request); + } }