Skip to content

Commit

Permalink
Merge pull request #97 from clue-labs/container-factory
Browse files Browse the repository at this point in the history
Support dependency injection for factory functions in `Container` config
  • Loading branch information
SimonFrings authored Dec 20, 2021
2 parents 94b5526 + a80e4aa commit 774fa27
Show file tree
Hide file tree
Showing 3 changed files with 125 additions and 8 deletions.
22 changes: 22 additions & 0 deletions docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,28 @@ name to a factory function that will be invoked when this class is first
requested. The factory function is responsible for returning an instance that
implements the given class name.

Factory functions used in the container configuration map may reference other
classes that will automatically be injected from the container. This can be
particularly useful when combining autowiring with some manual configuration
like this:

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

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

$container = new FrameworkX\Container([
Acme\Todo\UserController::class => function (React\Http\Browser $browser) {
// example UserController class requires two arguments:
// - first argument will be autowired based on class reference
// - second argument expects some manual value
return new Acme\Todo\UserController($browser, 42);
}
]);

// …
```

The container configuration may also be used to map a class name to a different
class name that implements the same interface, either by mapping between two
class names or using a factory function that returns a class name. This is
Expand Down
32 changes: 24 additions & 8 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ private function load(string $name, int $depth = 64)
{
if (isset($this->container[$name])) {
if ($this->container[$name] instanceof \Closure) {
$value = ($this->container[$name])();
// build list of factory parameters based on parameter types
$closure = new \ReflectionFunction($this->container[$name]);
$params = $this->loadFunctionParams($closure, $depth);

// invoke factory with list of parameters
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);

if (\is_string($value)) {
if ($depth < 1) {
Expand Down Expand Up @@ -127,10 +132,17 @@ private function load(string $name, int $depth = 64)
}

// 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) {
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth);

// instantiate with list of parameters
return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
}

private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth): array
{
$params = [];
foreach ($function->getParameters() as $parameter) {
assert($parameter instanceof \ReflectionParameter);

// stop building parameters when encountering first optional parameter
Expand Down Expand Up @@ -166,15 +178,19 @@ private function load(string $name, int $depth = 64)
throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive');
}

$params[] = $this->load($type->getName(), --$depth);
$params[] = $this->load($type->getName(), $depth - 1);
}

// instantiate with list of parameters
return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
return $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() . '()';
$name = $parameter->getDeclaringFunction()->getShortName();
if (!$parameter->getDeclaringFunction()->isClosure() && ($class = $parameter->getDeclaringClass()) !== null) {
$name = explode("\0", $class->getName())[0] . '::' . $name;
}

return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . $name . '()';
}
}
79 changes: 79 additions & 0 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,40 @@ public function __invoke(ServerRequestInterface $request)
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
}

public function testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryWithClassDependency()
{
$request = new ServerRequest('GET', 'http://example.com/');

$controller = new class(new Response()) {
private $response;

public function __construct(ResponseInterface $response)
{
$this->response = $response;
}

public function __invoke()
{
return $this->response;
}
};

$container = new Container([
ResponseInterface::class => function (\stdClass $dto) {
return new Response(200, [], json_encode($dto));
},
\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 testCtorThrowsWhenMapContainsInvalidInteger()
{
$this->expectException(\BadMethodCallException::class);
Expand Down Expand Up @@ -242,6 +276,51 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidIn
$callable($request);
}

public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresInvalidClassName()
{
$request = new ServerRequest('GET', 'http://example.com/');

$container = new Container([
\stdClass::class => function (self $instance) { return $instance; }
]);

$callable = $container->callable(\stdClass::class);

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Class self not found');
$callable($request);
}

public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUntypedArgument()
{
$request = new ServerRequest('GET', 'http://example.com/');

$container = new Container([
\stdClass::class => function ($data) { return $data; }
]);

$callable = $container->callable(\stdClass::class);

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Argument 1 ($data) of {closure}() has no type');
$callable($request);
}

public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresRecursiveClass()
{
$request = new ServerRequest('GET', 'http://example.com/');

$container = new Container([
\stdClass::class => function (\stdClass $data) { return $data; }
]);

$callable = $container->callable(\stdClass::class);

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Argument 1 ($data) of {closure}() is recursive');
$callable($request);
}

public function testCallableReturnsCallableThatThrowsWhenFactoryIsRecursive()
{
$request = new ServerRequest('GET', 'http://example.com/');
Expand Down

0 comments on commit 774fa27

Please sign in to comment.