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 class name references (aliases) in Container configuration #96

Merged
merged 2 commits into from
Dec 13, 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
28 changes: 27 additions & 1 deletion docs/best-practices/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,9 +262,35 @@ This can be useful in these cases:

The configured container instance can be passed into the application like any
other middleware request handler. In most cases this means you create a single
`Container` instance with a number of factory methods and pass this instance as
`Container` instance with a number of factory functions and pass this instance as
the first argument to the `App`.

In its most common form, each entry in the container configuration maps a class
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.

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
particularly useful when implementing an interface.

```php title="public/index.php"
<?php

require __DIR__ . '/../vendor/autoload.php';

$container = new FrameworkX\Container([
React\Cache\CacheInterface::class => React\Cache\ArrayCache::class,
Psr\Http\Message\ResponseInterface::class => function () {
// returns class implementing interface from factory function
return React\Http\Message\Response::class;
}
]);

// …
```

### PSR-11 compatibility

> ⚠️ **Feature preview**
Expand Down
27 changes: 24 additions & 3 deletions src/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@
*/
class Container
{
/** @var array<class-string,object|callable():object> */
/** @var array<class-string,object|callable():(object|class-string)> */
private $container;

/** @var array<class-string,callable():object | object> */
/** @var array<class-string,callable():(object|class-string) | object | class-string> */
public function __construct(array $map = [])
{
foreach ($map as $name => $value) {
if (\is_string($value)) {
$map[$name] = static function () use ($value) {
return $value;
};
} elseif (!$value instanceof \Closure && !$value instanceof $name) {
throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}
}
$this->container = $map;
}

Expand Down Expand Up @@ -81,7 +90,19 @@ private function load(string $name, int $depth = 64)
{
if (isset($this->container[$name])) {
if ($this->container[$name] instanceof \Closure) {
$this->container[$name] = ($this->container[$name])();
$value = ($this->container[$name])();

if (\is_string($value)) {
if ($depth < 1) {
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$value = $this->load($value, $depth - 1);
} elseif (!$value instanceof $name) {
throw new \BadMethodCallException('Factory for ' . $name . ' returned unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}

$this->container[$name] = $value;
}

return $this->container[$name];
Expand Down
125 changes: 125 additions & 0 deletions tests/ContainerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,131 @@ public function __invoke(ServerRequestInterface $request)
$this->assertEquals('{"num":1}', (string) $response->getBody());
}

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

$dto = new class extends \stdClass { };
$dto->name = 'Alice';

$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 => get_class($dto),
get_class($dto) => $dto
]);

$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 testCallableReturnsCallableForClassNameWithSubclassMappedFromFactoryForDependency()
{
$request = new ServerRequest('GET', 'http://example.com/');

$dto = new class extends \stdClass { };
$dto->name = 'Alice';

$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 () use ($dto) { return get_class($dto); },
get_class($dto) => function () use ($dto) { return $dto; }
]);

$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);
$this->expectExceptionMessage('Map for stdClass contains unexpected integer');

new Container([
\stdClass::class => 42
]);
}

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

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

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

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

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

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

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

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Factory for stdClass returned unexpected integer');
$callable($request);
}

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

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

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

$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('Factory for stdClass is recursive');
$callable($request);
}

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