Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support dependency injection for factory functions in Container config #97

Merged
merged 1 commit into from
Dec 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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