From 5c7ef6847fc6819f0861e56a8b621b6f6e8f7806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 30 Jun 2022 09:45:44 +0200 Subject: [PATCH] Support PSR-11 container interface by using DI container as adapter --- composer.json | 1 + docs/best-practices/controllers.md | 53 +++++++++++++++++++++++------- src/Container.php | 27 ++++++++++----- tests/ContainerTest.php | 53 ++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 09420da..f02277f 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "phpunit/phpunit": "^9.5 || ^7.5", + "psr/container": "^2 || ^1", "react/async": "^4@dev || ^3@dev" }, "autoload": { diff --git a/docs/best-practices/controllers.md b/docs/best-practices/controllers.md index eed6ce0..f0e190b 100644 --- a/docs/best-practices/controllers.md +++ b/docs/best-practices/controllers.md @@ -305,15 +305,46 @@ $container = new FrameworkX\Container([ // … ``` -### PSR-11 compatibility +### PSR-11: Container interface -> ⚠️ **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`. -We love standards and interoperability. +X has a powerful, built-in dependency injection container (DI container or DIC) +that has a strong focus on simplicity and should cover most common use cases. +Sometimes, you might need a little more control over this and may want to use a +custom container implementation instead. + +We love standards and interoperability, that's why we support the +[PSR-11: Container interface](https://www.php-fig.org/psr/psr-11/). This is a +common interface that is used by most DI containers in PHP. In the following +example, we're using [PHP-DI](https://php-di.org/), but you may likewise use any +other implementation of this interface: + +```bash +composer require php-di/php-di +``` + +In order to use an external DI container, you first have to instantiate your +custom container as per its documentation. If this instance implements the +`Psr\Container\ContainerInterface`, you can then pass it into the X container that +acts as an adapter for the application like this: + +```php title="public/index.php" +... +// $container = $builder->build(); +$container = new DI\Container(); + +$app = new FrameworkX\App(new FrameworkX\Container($container)); + +$app->get('/', Acme\Todo\HelloController::class); +$app->get('/users/{name}', Acme\Todo\UserController::class); + +$app->run(); +``` + +We expect most applications to work just fine with the built-in DI container. +If you need to use a custom container, the above logic should work with any of the +[PSR-11 container implementations](https://packagist.org/providers/psr/container-implementation). diff --git a/src/Container.php b/src/Container.php index fb93a02..fe48297 100644 --- a/src/Container.php +++ b/src/Container.php @@ -2,6 +2,7 @@ namespace FrameworkX; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; /** @@ -9,22 +10,28 @@ */ class Container { - /** @var array */ + /** @var array|ContainerInterface */ private $container; - /** @var array */ - public function __construct(array $map = []) + /** @var array|ContainerInterface $loader */ + public function __construct($loader = []) { - foreach ($map as $name => $value) { + if (!\is_array($loader) && !$loader instanceof ContainerInterface) { + throw new \TypeError( + 'Argument #1 ($loader) must be of type array|Psr\Container\ContainerInterface, ' . (\is_object($loader) ? get_class($loader) : gettype($loader)) . ' given' + ); + } + + foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) { if (\is_string($value)) { - $map[$name] = static function () use ($value) { + $loader[$name] = static function () use ($value) { return $value; }; } elseif (!$value instanceof \Closure && !$value instanceof $name) { throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value))); } } - $this->container = $map; + $this->container = $loader; } public function __invoke(ServerRequestInterface $request, callable $next = null) @@ -52,12 +59,16 @@ public function callable(string $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) && !interface_exists($class, false) && !trait_exists($class, false)) { + 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 { - $handler = $this->load($class); + if ($this->container instanceof ContainerInterface) { + $handler = $this->container->get($class); + } else { + $handler = $this->load($class); + } } catch (\Throwable $e) { throw new \BadMethodCallException( 'Request handler class ' . $class . ' failed to load: ' . $e->getMessage(), diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index cf46189..4c8e666 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -4,6 +4,8 @@ use FrameworkX\Container; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use React\Http\Message\Response; @@ -336,6 +338,50 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryIsRecursive() $callable($request); } + public function testCallableReturnsCallableForClassNameViaPsrContainer() + { + $request = new ServerRequest('GET', 'http://example.com/'); + + $controller = new class { + public function __invoke(ServerRequestInterface $request) + { + 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); + + $container = new Container($psr); + + $callable = $container->callable(get_class($controller)); + $this->assertInstanceOf(\Closure::class, $callable); + + $response = $callable($request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidClassNameViaPsrContainer() + { + $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); + + $container = new Container($psr); + + $callable = $container->callable('FooBar'); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('Request handler class FooBar failed to load: Unable to load class'); + $callable($request); + } + public function testInvokeContainerAsMiddlewareReturnsFromNextRequestHandler() { $request = new ServerRequest('GET', 'http://example.com/'); @@ -357,4 +403,11 @@ public function testInvokeContainerAsFinalRequestHandlerThrows() $this->expectExceptionMessage('Container should not be used as final request handler'); $container($request); } + + public function testCtorWithInvalidValueThrows() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('Argument #1 ($loader) must be of type array|Psr\Container\ContainerInterface, stdClass given'); + new Container((object) []); + } }