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 untyped and mixed arguments for container factory #182

Merged
merged 1 commit into from
Jul 30, 2022
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
45 changes: 25 additions & 20 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,16 +209,8 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $
*/
private function loadParameter(\ReflectionParameter $parameter, int $depth, bool $allowVariables) /*: mixed (PHP 8.0+) */
{
// ensure parameter is typed
$type = $parameter->getType();
if ($type === null) {
if ($parameter->isDefaultValueAvailable()) {
return $parameter->getDefaultValue();
}
throw new \BadMethodCallException(self::parameterError($parameter) . ' has no type');
}

$hasDefault = $parameter->isDefaultValueAvailable() || $parameter->allowsNull();
$hasDefault = $parameter->isDefaultValueAvailable() || ((!$type instanceof \ReflectionNamedType || $type->getName() !== 'mixed') && $parameter->allowsNull());

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

assert($type instanceof \ReflectionNamedType);

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

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

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

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

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

// skip type checks and allow all values if expected type is undefined or mixed (PHP 8+)
if ($type === 'mixed') {
return $value;
}

if (
(\is_object($value) && !$value instanceof $type) ||
(!\is_object($value) && !\in_array($type, ['string', 'int', 'float', 'bool'])) ||
Expand Down
199 changes: 196 additions & 3 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ public function __invoke(ServerRequestInterface $request)
$this->assertEquals('42', (string) $response->getBody());
}

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

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

/**
* @requires PHP 8
*/
public function testCallableReturnsCallableForMixedWithStringDefaultViaAutowiringWillDefaultToStringValue()
{
$request = new ServerRequest('GET', 'http://example.com/');

$controller = new class(null) {
private $data = false;

#[PHP8] public function __construct(mixed $data = 'empty') { $this->data = $data; }

public function __invoke(ServerRequestInterface $request)
{
return new Response(200, [], json_encode($this->data));
}
};

$container = new Container([]);

$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('"empty"', (string) $response->getBody());
}

public function testCallableReturnsCallableForClassNameViaAutowiringWithFactoryFunctionForDependency()
{
$request = new ServerRequest('GET', 'http://example.com/');
Expand Down Expand Up @@ -517,6 +546,152 @@ public function __invoke()
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
}

public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariable()
{
$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 ($data) {
return new Response(200, [], json_encode($data));
},
'data' => (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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresUntypedContainerVariableWithFactory()
{
$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 ($data) {
return new Response(200, [], json_encode($data));
},
'data' => 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());
}

/**
* @requires PHP 8
*/
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariable()
{
$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 (mixed $data) {
return new Response(200, [], json_encode($data));
},
'data' => (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());
}

/**
* @requires PHP 8
*/
public function testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresMixedContainerVariableWithFactory()
{
$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 (mixed $data) {
return new Response(200, [], json_encode($data));
},
'data' => 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 testCallableReturnsCallableForClassNameWithDependencyMappedWithFactoryThatRequiresNullableContainerVariables()
{
$request = new ServerRequest('GET', 'http://example.com/');
Expand Down Expand Up @@ -1284,13 +1459,31 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUntypedA
$request = new ServerRequest('GET', 'http://example.com/');

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

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

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

/**
* @requires PHP 8
*/
public function testCallableReturnsCallableThatThrowsWhenFactoryRequiresUndefinedMixedArgument()
{
$request = new ServerRequest('GET', 'http://example.com/');

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

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

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

Expand Down