Skip to content

Commit 871cc04

Browse files
committed
Support untyped and mixed arguments for container factory
1 parent 9fe083d commit 871cc04

2 files changed

Lines changed: 221 additions & 23 deletions

File tree

src/Container.php

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,8 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $
209209
*/
210210
private function loadParameter(\ReflectionParameter $parameter, int $depth, bool $allowVariables) /*: mixed (PHP 8.0+) */
211211
{
212-
// ensure parameter is typed
213212
$type = $parameter->getType();
214-
if ($type === null) {
215-
if ($parameter->isDefaultValueAvailable()) {
216-
return $parameter->getDefaultValue();
217-
}
218-
throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type');
219-
}
220-
221-
$hasDefault = $parameter->isDefaultValueAvailable() || $parameter->allowsNull();
213+
$hasDefault = $parameter->isDefaultValueAvailable() || ((!$type instanceof \ReflectionNamedType || $type->getName() !== 'mixed') && $parameter->allowsNull());
222214

223215
// abort for union types (PHP 8.0+) and intersection types (PHP 8.1+)
224216
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart
@@ -228,26 +220,34 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool
228220
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type);
229221
} // @codeCoverageIgnoreEnd
230222

231-
assert($type instanceof \ReflectionNamedType);
232-
233223
// load container variables if parameter name is known
224+
assert($type === null || $type instanceof \ReflectionNamedType);
234225
if ($allowVariables && isset($this->container[$parameter->getName()])) {
235-
return $this->loadVariable($parameter->getName(), $type->getName(), $depth);
226+
return $this->loadVariable($parameter->getName(), $type === null ? 'mixed' : $type->getName(), $depth);
236227
}
237228

238-
// use null for nullable arguments if not already loaded above
239-
if ($hasDefault && !isset($this->container[$type->getName()])) {
240-
return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
229+
// abort if parameter is untyped and not explicitly defined by container variable
230+
if ($type === null) {
231+
assert($parameter->allowsNull());
232+
if ($parameter->isDefaultValueAvailable()) {
233+
return $parameter->getDefaultValue();
234+
}
235+
throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type');
241236
}
242237

243-
// abort if required container variable is not defined
244-
if ($allowVariables && \in_array($type->getName(), ['string', 'int', 'float', 'bool'])) {
245-
throw new \BadMethodCallException(self::parameterError($parameter) . ' is not defined');
238+
// use default/nullable argument if not loadable as container variable or by type
239+
assert($type instanceof \ReflectionNamedType);
240+
if ($hasDefault && ($type->isBuiltin() || !isset($this->container[$type->getName()]))) {
241+
return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
246242
}
247243

248-
// abort for other primitive types (array etc.)
244+
// abort if required container variable is not defined or for any other primitive types (array etc.)
249245
if ($type->isBuiltin()) {
250-
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
246+
if ($allowVariables) {
247+
throw new \BadMethodCallException(self::parameterError($parameter) . ' is not defined');
248+
} else {
249+
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
250+
}
251251
}
252252

253253
// abort for unreasonably deep nesting or recursive types
@@ -287,6 +287,11 @@ private function loadVariable(string $name, string $type, int $depth) /*: object
287287
$value = $this->container[$name];
288288
assert(\is_object($value) || \is_scalar($value));
289289

290+
// skip type checks and allow all values if expected type is undefined or mixed (PHP 8+)
291+
if ($type === 'mixed') {
292+
return $value;
293+
}
294+
290295
if (
291296
(\is_object($value) && !$value instanceof $type) ||
292297
(!\is_object($value) && !\in_array($type, ['string', 'int', 'float', 'bool'])) ||

tests/ContainerTest.php

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ public function __invoke(ServerRequestInterface $request)
245245
$this->assertEquals('42', (string) $response->getBody());
246246
}
247247

248-
public function testCallableReturnsCallableForUndefaultWithStringDefaultViaAutowiringWillDefaultToStringValue()
248+
public function testCallableReturnsCallableForUntypedWithStringDefaultViaAutowiringWillDefaultToStringValue()
249249
{
250250
$request = new ServerRequest('GET', 'http://example.com/');
251251

@@ -274,6 +274,35 @@ public function __invoke(ServerRequestInterface $request)
274274
$this->assertEquals('"empty"', (string) $response->getBody());
275275
}
276276

277+
/**
278+
* @requires PHP 8
279+
*/
280+
public function testCallableReturnsCallableForMixedWithStringDefaultViaAutowiringWillDefaultToStringValue()
281+
{
282+
$request = new ServerRequest('GET', 'http://example.com/');
283+
284+
$controller = new class(null) {
285+
private $data = false;
286+
287+
#[PHP8] public function __construct(mixed $data = 'empty') { $this->data = $data; }
288+
289+
public function __invoke(ServerRequestInterface $request)
290+
{
291+
return new Response(200, [], json_encode($this->data));
292+
}
293+
};
294+
295+
$container = new Container([]);
296+
297+
$callable = $container->callable(get_class($controller));
298+
$this->assertInstanceOf(\Closure::class, $callable);
299+
300+
$response = $callable($request);
301+
$this->assertInstanceOf(ResponseInterface::class, $response);
302+
$this->assertEquals(200, $response->getStatusCode());
303+
$this->assertEquals('"empty"', (string) $response->getBody());
304+
}
305+
277306
public function testCallableReturnsCallableForClassNameViaAutowiringWithFactoryFunctionForDependency()
278307
{
279308
$request = new ServerRequest('GET', 'http://example.com/');
@@ -517,6 +546,152 @@ public function __invoke()
517546
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
518547
}
519548

549+
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariable()
550+
{
551+
$request = new ServerRequest('GET', 'http://example.com/');
552+
553+
$controller = new class(new Response()) {
554+
private $response;
555+
556+
public function __construct(ResponseInterface $response)
557+
{
558+
$this->response = $response;
559+
}
560+
561+
public function __invoke()
562+
{
563+
return $this->response;
564+
}
565+
};
566+
567+
$container = new Container([
568+
ResponseInterface::class => function ($data) {
569+
return new Response(200, [], json_encode($data));
570+
},
571+
'data' => (object) ['name' => 'Alice']
572+
]);
573+
574+
$callable = $container->callable(get_class($controller));
575+
$this->assertInstanceOf(\Closure::class, $callable);
576+
577+
$response = $callable($request);
578+
$this->assertInstanceOf(ResponseInterface::class, $response);
579+
$this->assertEquals(200, $response->getStatusCode());
580+
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
581+
}
582+
583+
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariableWithFactory()
584+
{
585+
$request = new ServerRequest('GET', 'http://example.com/');
586+
587+
$controller = new class(new Response()) {
588+
private $response;
589+
590+
public function __construct(ResponseInterface $response)
591+
{
592+
$this->response = $response;
593+
}
594+
595+
public function __invoke()
596+
{
597+
return $this->response;
598+
}
599+
};
600+
601+
$container = new Container([
602+
ResponseInterface::class => function ($data) {
603+
return new Response(200, [], json_encode($data));
604+
},
605+
'data' => function () {
606+
return (object) ['name' => 'Alice'];
607+
}
608+
]);
609+
610+
$callable = $container->callable(get_class($controller));
611+
$this->assertInstanceOf(\Closure::class, $callable);
612+
613+
$response = $callable($request);
614+
$this->assertInstanceOf(ResponseInterface::class, $response);
615+
$this->assertEquals(200, $response->getStatusCode());
616+
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
617+
}
618+
619+
/**
620+
* @requires PHP 8
621+
*/
622+
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariable()
623+
{
624+
$request = new ServerRequest('GET', 'http://example.com/');
625+
626+
$controller = new class(new Response()) {
627+
private $response;
628+
629+
public function __construct(ResponseInterface $response)
630+
{
631+
$this->response = $response;
632+
}
633+
634+
public function __invoke()
635+
{
636+
return $this->response;
637+
}
638+
};
639+
640+
$container = new Container([
641+
ResponseInterface::class => function (mixed $data) {
642+
return new Response(200, [], json_encode($data));
643+
},
644+
'data' => (object) ['name' => 'Alice']
645+
]);
646+
647+
$callable = $container->callable(get_class($controller));
648+
$this->assertInstanceOf(\Closure::class, $callable);
649+
650+
$response = $callable($request);
651+
$this->assertInstanceOf(ResponseInterface::class, $response);
652+
$this->assertEquals(200, $response->getStatusCode());
653+
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
654+
}
655+
656+
/**
657+
* @requires PHP 8
658+
*/
659+
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariableWithFactory()
660+
{
661+
$request = new ServerRequest('GET', 'http://example.com/');
662+
663+
$controller = new class(new Response()) {
664+
private $response;
665+
666+
public function __construct(ResponseInterface $response)
667+
{
668+
$this->response = $response;
669+
}
670+
671+
public function __invoke()
672+
{
673+
return $this->response;
674+
}
675+
};
676+
677+
$container = new Container([
678+
ResponseInterface::class => function (mixed $data) {
679+
return new Response(200, [], json_encode($data));
680+
},
681+
'data' => function () {
682+
return (object) ['name' => 'Alice'];
683+
}
684+
]);
685+
686+
$callable = $container->callable(get_class($controller));
687+
$this->assertInstanceOf(\Closure::class, $callable);
688+
689+
$response = $callable($request);
690+
$this->assertInstanceOf(ResponseInterface::class, $response);
691+
$this->assertEquals(200, $response->getStatusCode());
692+
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
693+
}
694+
520695
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableContainerVariables()
521696
{
522697
$request = new ServerRequest('GET', 'http://example.com/');
@@ -1284,13 +1459,31 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUntypedA
12841459
$request = new ServerRequest('GET', 'http://example.com/');
12851460

12861461
$container = new Container([
1287-
\stdClass::class => function ($data) { return $data; }
1462+
\stdClass::class => function ($undefined) { return $undefined; }
1463+
]);
1464+
1465+
$callable = $container->callable(\stdClass::class);
1466+
1467+
$this->expectException(\BadMethodCallException::class);
1468+
$this->expectExceptionMessage('Argument 1 ($undefined) of {closure}() has no type');
1469+
$callable($request);
1470+
}
1471+
1472+
/**
1473+
* @requires PHP 8
1474+
*/
1475+
public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUndefinedMixedArgument()
1476+
{
1477+
$request = new ServerRequest('GET', 'http://example.com/');
1478+
1479+
$container = new Container([
1480+
\stdClass::class => function (mixed $undefined) { return $undefined; }
12881481
]);
12891482

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

12921485
$this->expectException(\BadMethodCallException::class);
1293-
$this->expectExceptionMessage('Argument 1 ($data) of {closure}() has no type');
1486+
$this->expectExceptionMessage('Argument 1 ($undefined) of {closure}() is not defined');
12941487
$callable($request);
12951488
}
12961489

0 commit comments

Comments
 (0)