Skip to content

Commit

Permalink
Support string variables in container configuration for factories
Browse files Browse the repository at this point in the history
  • Loading branch information
clue committed Jul 27, 2022
1 parent f4b43d7 commit 9b1d235
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 19 deletions.
32 changes: 31 additions & 1 deletion docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ the dependency injection container like this:
require __DIR__ . '/../vendor/autoload.php';

$container = new FrameworkX\Container([
Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController();
Acme\Todo\HelloController::class => fn() => new Acme\Todo\HelloController()
]);


Expand Down Expand Up @@ -284,6 +284,36 @@ $container = new FrameworkX\Container([
// …
```

Factory functions used in the container configuration map may also reference
string variables defined in the container configuration. 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 (string $name) {
// example UserController class requires single string argument
return new Acme\Todo\UserController($name);
},
'name' => 'Acme'
]);

// …
```

> ℹ️ **Avoiding name conflicts**
>
> Note that class names and string variables share the same container
> configuration map and as such might be subject to name collisions as a single
> entry may only have a single value. For this reason, container variables will
> only be used for container functions by default. We highly recommend using
> namespaced class names like in the previous example. You may also want to make
> sure that container variables use unique names prefixed with your vendor name.
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
58 changes: 43 additions & 15 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
*/
class Container
{
/** @var array<class-string,object|callable():(object|class-string)>|ContainerInterface */
/** @var array<string,object|callable():(object|class-string)|string>|ContainerInterface */
private $container;

/** @var array<class-string,callable():(object|class-string) | object | class-string>|ContainerInterface $loader */
/** @var array<string,callable():(object|class-string) | object | string>|ContainerInterface $loader */
public function __construct($loader = [])
{
if (!\is_array($loader) && !$loader instanceof ContainerInterface) {
Expand Down Expand Up @@ -63,7 +63,7 @@ public function callable(string $class): callable
if ($this->container instanceof ContainerInterface) {
$handler = $this->container->get($class);
} else {
$handler = $this->load($class);
$handler = $this->loadObject($class);
}
} catch (\Throwable $e) {
throw new \BadMethodCallException(
Expand Down Expand Up @@ -98,7 +98,7 @@ public function getAccessLogHandler(): AccessLogHandler
return new AccessLogHandler();
}
}
return $this->load(AccessLogHandler::class);
return $this->loadObject(AccessLogHandler::class);
}

/** @internal */
Expand All @@ -111,23 +111,24 @@ public function getErrorHandler(): ErrorHandler
return new ErrorHandler();
}
}
return $this->load(ErrorHandler::class);
return $this->loadObject(ErrorHandler::class);
}

/**
* @param class-string $name
* @return object
* @throws \BadMethodCallException
* @template T
* @param class-string<T> $name
* @return T
* @throws \BadMethodCallException if object of type $name can not be loaded
*/
private function load(string $name, int $depth = 64)
private function loadObject(string $name, int $depth = 64) /*: object (PHP 7.2+) */
{
if (isset($this->container[$name])) {
if (\is_string($this->container[$name])) {
if ($depth < 1) {
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$value = $this->load($this->container[$name], $depth - 1);
$value = $this->loadObject($this->container[$name], $depth - 1);
if (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}
Expand All @@ -136,7 +137,7 @@ private function load(string $name, int $depth = 64)
} elseif ($this->container[$name] instanceof \Closure) {
// build list of factory parameters based on parameter types
$closure = new \ReflectionFunction($this->container[$name]);
$params = $this->loadFunctionParams($closure, $depth);
$params = $this->loadFunctionParams($closure, $depth, true);

// invoke factory with list of parameters
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);
Expand All @@ -146,7 +147,7 @@ private function load(string $name, int $depth = 64)
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$value = $this->load($value, $depth - 1);
$value = $this->loadObject($value, $depth - 1);
}
if (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
Expand All @@ -155,6 +156,8 @@ private function load(string $name, int $depth = 64)
$this->container[$name] = $value;
}

assert($this->container[$name] instanceof $name);

return $this->container[$name];
}

Expand All @@ -178,13 +181,14 @@ private function load(string $name, int $depth = 64)

// build list of constructor parameters based on parameter types
$ctor = $class->getConstructor();
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth);
$params = $ctor === null ? [] : $this->loadFunctionParams($ctor, $depth, false);

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

private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth): array
/** @throws \BadMethodCallException if either parameter can not be loaded */
private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $depth, bool $allowVariables): array
{
$params = [];
foreach ($function->getParameters() as $parameter) {
Expand Down Expand Up @@ -214,6 +218,14 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $
}

assert($type instanceof \ReflectionNamedType);

// load string variables from container
if ($allowVariables && $type->getName() === 'string') {
$params[] = $this->loadVariable($parameter->getName());
continue;
}

// abort for other primitive types
if ($type->isBuiltin()) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
}
Expand All @@ -223,12 +235,28 @@ private function loadFunctionParams(\ReflectionFunctionAbstract $function, int $
throw new \BadMethodCallException(self::parameterError($parameter) . ' is recursive');
}

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

return $params;
}

/** @throws \BadMethodCallException if $name is not a valid string variable */
private function loadVariable(string $name): string
{
if (!isset($this->container[$name])) {
throw new \BadMethodCallException('Container variable $' . $name . ' is not defined');
}

$value = $this->container[$name];
if (!\is_string($value)) {
throw new \BadMethodCallException('Container variable $' . $name . ' expected type string, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value)));
}

return $value;
}

/** @throws void */
private static function parameterError(\ReflectionParameter $parameter): string
{
$name = $parameter->getDeclaringFunction()->getShortName();
Expand Down
163 changes: 160 additions & 3 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public function __invoke(ServerRequestInterface $request)
$this->assertEquals('{"num":1}', (string) $response->getBody());
}

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

Expand Down Expand Up @@ -171,7 +171,7 @@ public function __invoke(ServerRequestInterface $request)
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
}

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

Expand Down Expand Up @@ -206,7 +206,7 @@ public function __invoke(ServerRequestInterface $request)
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
}

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

Expand Down Expand Up @@ -240,6 +240,163 @@ public function __invoke()
$this->assertEquals('{"name":"Alice"}', (string) $response->getBody());
}

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

$controller = new class(new \stdClass()) {
private $data;

public function __construct(\stdClass $data)
{
$this->data = $data;
}

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

$container = new Container([
\stdClass::class => function (string $username) {
return (object) ['name' => $username];
},
'username' => '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 testCallableReturnsCallableThatThrowsWhenFactoryReferencesUnknownVariable()
{
$request = new ServerRequest('GET', 'http://example.com/');

$controller = new class(new \stdClass()) {
private $data;

public function __construct(\stdClass $data)
{
$this->data = $data;
}

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

$container = new Container([
\stdClass::class => function (string $username) {
return (object) ['name' => $username];
}
]);

$callable = $container->callable(get_class($controller));

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Container variable $username is not defined');
$callable($request);
}

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

$controller = new class(new \stdClass()) {
private $data;

public function __construct(\stdClass $data)
{
$this->data = $data;
}

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

$container = new Container([
\stdClass::class => function (string $http) {
return (object) ['name' => $http];
},
'http' => function () {
return 'http/3';
}
]);

$callable = $container->callable(get_class($controller));

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Container variable $http expected type string, but got Closure');
$callable($request);
}

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

$controller = new class(new \stdClass()) {
private $data;

public function __construct(\stdClass $data)
{
$this->data = $data;
}

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

$container = new Container([
\stdClass::class => 'Yes'
]);

$callable = $container->callable(get_class($controller));

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

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

$controller = new class('Alice') {
private $data;

public function __construct(string $name)
{
$this->data = $name;
}

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

$container = new Container([
'name' => 'Alice'
]);

$callable = $container->callable(get_class($controller));

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Argument 1 ($name) of class@anonymous::__construct() expects unsupported type string');
$callable($request);
}

public function testCtorThrowsWhenMapContainsInvalidInteger()
{
$this->expectException(\BadMethodCallException::class);
Expand Down

0 comments on commit 9b1d235

Please sign in to comment.