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 string variables in container configuration for factory functions #178

Merged
merged 4 commits into from
Jul 29, 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
33 changes: 32 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,37 @@ $container = new FrameworkX\Container([
// …
```

Factory functions used in the container configuration map may also reference
string variables defined in the container configuration. You may also use
factory functions that return string variables. 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, string $hostname) {
// example UserController class requires two string arguments
return new Acme\Todo\UserController($name, $hostname);
},
'name' => 'Acme',
'hostname' => fn(): string => gethostname()
]);

// …
```

> ℹ️ **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
97 changes: 76 additions & 21 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|string)|string>|ContainerInterface */
private $container;

/** @var array<class-string,callable():(object|class-string) | object | class-string>|ContainerInterface $loader */
/** @var array<string,callable():(object|string) | object | string>|ContainerInterface $loader */
public function __construct($loader = [])
{
if (!\is_array($loader) && !$loader instanceof ContainerInterface) {
Expand All @@ -23,11 +23,7 @@ public function __construct($loader = [])
}

foreach (($loader instanceof ContainerInterface ? [] : $loader) as $name => $value) {
if (\is_string($value)) {
$loader[$name] = static function () use ($value) {
return $value;
};
} elseif (!$value instanceof \Closure && !$value instanceof $name) {
if (!\is_string($value) && !$value instanceof \Closure && !$value instanceof $name) {
throw new \BadMethodCallException('Map for ' . $name . ' contains unexpected ' . (is_object($value) ? get_class($value) : gettype($value)));
}
}
Expand Down Expand Up @@ -67,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 @@ -102,7 +98,7 @@ public function getAccessLogHandler(): AccessLogHandler
return new AccessLogHandler();
}
}
return $this->load(AccessLogHandler::class);
return $this->loadObject(AccessLogHandler::class);
}

/** @internal */
Expand All @@ -115,21 +111,33 @@ 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 ($this->container[$name] instanceof \Closure) {
if (\is_string($this->container[$name])) {
if ($depth < 1) {
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$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)));
}

$this->container[$name] = $value;
} 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 @@ -139,14 +147,17 @@ private function load(string $name, int $depth = 64)
throw new \BadMethodCallException('Factory for ' . $name . ' is recursive');
}

$value = $this->load($value, $depth - 1);
} elseif (!$value instanceof $name) {
$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)));
}

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

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

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

Expand All @@ -170,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 @@ -206,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(), $depth);
continue;
}

// abort for other primitive types
if ($type->isBuiltin()) {
throw new \BadMethodCallException(self::parameterError($parameter) . ' expects unsupported type ' . $type->getName());
}
Expand All @@ -215,12 +235,47 @@ 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, int $depth): string
{
if (!isset($this->container[$name])) {
throw new \BadMethodCallException('Container variable $' . $name . ' is not defined');
}

if ($this->container[$name] instanceof \Closure) {
if ($depth < 1) {
throw new \BadMethodCallException('Container variable $' . $name . ' is recursive');
}

// build list of factory parameters based on parameter types
$closure = new \ReflectionFunction($this->container[$name]);
$params = $this->loadFunctionParams($closure, $depth - 1, true);

// invoke factory with list of parameters
$value = $params === [] ? ($this->container[$name])() : ($this->container[$name])(...$params);

if (!\is_string($value)) {
throw new \BadMethodCallException('Container variable $' . $name . ' expected type string from factory, but got ' . (\is_object($value) ? \get_class($value) : \gettype($value)));
}

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

$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
Loading