Skip to content

Commit 774fa27

Browse files
authored
Merge pull request #97 from clue-labs/container-factory
Support dependency injection for factory functions in `Container` config
2 parents 94b5526 + a80e4aa commit 774fa27

3 files changed

Lines changed: 125 additions & 8 deletions

File tree

docs/best-practices/controllers.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,28 @@ name to a factory function that will be invoked when this class is first
270270
requested. The factory function is responsible for returning an instance that
271271
implements the given class name.
272272

273+
Factory functions used in the container configuration map may reference other
274+
classes that will automatically be injected from the container. This can be
275+
particularly useful when combining autowiring with some manual configuration
276+
like this:
277+
278+
```php title="public/index.php"
279+
<?php
280+
281+
require __DIR__ . '/../vendor/autoload.php';
282+
283+
$container = new FrameworkX\Container([
284+
Acme\Todo\UserController::class => function (React\Http\Browser $browser) {
285+
// example UserController class requires two arguments:
286+
// - first argument will be autowired based on class reference
287+
// - second argument expects some manual value
288+
return new Acme\Todo\UserController($browser, 42);
289+
}
290+
]);
291+
292+
// …
293+
```
294+
273295
The container configuration may also be used to map a class name to a different
274296
class name that implements the same interface, either by mapping between two
275297
class names or using a factory function that returns a class name. This is

src/Container.php

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ private function load(string $name, int $depth = 64)
9090
{
9191
if (isset($this->container[$name])) {
9292
if ($this->container[$name] instanceof \Closure) {
93-
$value = ($this->container[$name])();
93+
// build list of factory parameters based on parameter types
94+
$closure = new \ReflectionFunction($this->container[$name]);
95+
$params = $this->loadFunctionParams($closure, $depth);
96+
97+
// invoke factory with list of parameters
98+
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);
9499

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

129134
// build list of constructor parameters based on parameter types
130-
$params = [];
131135
$ctor = $class->getConstructor();
132-
assert($ctor === null || $ctor instanceof \ReflectionMethod);
133-
foreach ($ctor !== null ? $ctor->getParameters() : [] as $parameter) {
136+
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth);
137+
138+
// instantiate with list of parameters
139+
return $this->container[$name] = $params === [] ? new $name() : $class->newInstance(...$params);
140+
}
141+
142+
private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth): array
143+
{
144+
$params = [];
145+
foreach ($function->getParameters() as $parameter) {
134146
assert($parameter instanceof \ReflectionParameter);
135147

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

169-
$params[] = $this->load($type->getName(), --$depth);
181+
$params[] = $this->load($type->getName(), $depth - 1);
170182
}
171183

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

176187
private static function parameterError(\ReflectionParameter $parameter): string
177188
{
178-
return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . explode("\0", $parameter->getDeclaringClass()->getName())[0] . '::' . $parameter->getDeclaringFunction()->getName() . '()';
189+
$name = $parameter->getDeclaringFunction()->getShortName();
190+
if (!$parameter->getDeclaringFunction()->isClosure() && ($class = $parameter->getDeclaringClass()) !== null) {
191+
$name = explode("\0", $class->getName())[0] . '::' . $name;
192+
}
193+
194+
return 'Argument ' . ($parameter->getPosition() + 1) . ' ($' . $parameter->getName() . ') of ' . $name . '()';
179195
}
180196
}

tests/ContainerTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,40 @@ public function __invoke(ServerRequestInterface $request)
202202
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
203203
}
204204

205+
public function testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryWithClassDependency()
206+
{
207+
$request = new ServerRequest('GET', 'http://example.com/');
208+
209+
$controller = new class(new Response()) {
210+
private $response;
211+
212+
public function __construct(ResponseInterface $response)
213+
{
214+
$this->response = $response;
215+
}
216+
217+
public function __invoke()
218+
{
219+
return $this->response;
220+
}
221+
};
222+
223+
$container = new Container([
224+
ResponseInterface::class => function (\stdClass $dto) {
225+
return new Response(200, [], json_encode($dto));
226+
},
227+
\stdClass::class => function () { return (object)['name' => 'Alice']; }
228+
]);
229+
230+
$callable = $container->callable(get_class($controller));
231+
$this->assertInstanceOf(\Closure::class, $callable);
232+
233+
$response = $callable($request);
234+
$this->assertInstanceOf(ResponseInterface::class, $response);
235+
$this->assertEquals(200, $response->getStatusCode());
236+
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
237+
}
238+
205239
public function testCtorThrowsWhenMapContainsInvalidInteger()
206240
{
207241
$this->expectException(\BadMethodCallException::class);
@@ -242,6 +276,51 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidIn
242276
$callable($request);
243277
}
244278

279+
public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresInvalidClassName()
280+
{
281+
$request = new ServerRequest('GET', 'http://example.com/');
282+
283+
$container = new Container([
284+
\stdClass::class => function (self $instance) { return $instance; }
285+
]);
286+
287+
$callable = $container->callable(\stdClass::class);
288+
289+
$this->expectException(\BadMethodCallException::class);
290+
$this->expectExceptionMessage('Class self not found');
291+
$callable($request);
292+
}
293+
294+
public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUntypedArgument()
295+
{
296+
$request = new ServerRequest('GET', 'http://example.com/');
297+
298+
$container = new Container([
299+
\stdClass::class => function ($data) { return $data; }
300+
]);
301+
302+
$callable = $container->callable(\stdClass::class);
303+
304+
$this->expectException(\BadMethodCallException::class);
305+
$this->expectExceptionMessage('Argument 1 ($data) of {closure}() has no type');
306+
$callable($request);
307+
}
308+
309+
public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresRecursiveClass()
310+
{
311+
$request = new ServerRequest('GET', 'http://example.com/');
312+
313+
$container = new Container([
314+
\stdClass::class => function (\stdClass $data) { return $data; }
315+
]);
316+
317+
$callable = $container->callable(\stdClass::class);
318+
319+
$this->expectException(\BadMethodCallException::class);
320+
$this->expectExceptionMessage('Argument 1 ($data) of {closure}() is recursive');
321+
$callable($request);
322+
}
323+
245324
public function testCallableReturnsCallableThatThrowsWhenFactoryIsRecursive()
246325
{
247326
$request = new ServerRequest('GET', 'http://example.com/');

0 commit comments

Comments
 (0)