From f9d28d08818860998f20aa49a056a85c3f41ab4e Mon Sep 17 00:00:00 2001 From: Andrej Rypo Date: Wed, 8 Jul 2020 15:58:03 +0200 Subject: [PATCH] WireInvoker & ArgInspector: toolkit for automatic wiring --- examples/WireHelper.php | 73 ++++ readme.md | 324 +++++++++++++----- src/ArgInspector.php | 207 +++++++++++ src/InvokableProvider.php | 18 +- src/WireInvoker.php | 199 +++++++++++ tests/ArgumentInspectorTest.php | 187 ++++++++++ tests/AssertsErrors.php | 66 ++++ tests/AutomaticResolutionTest.php | 71 ---- tests/InvokableProviderTest.php | 3 + tests/WireGenieTest.php | 2 +- tests/WireInvokerTest.php | 254 ++++++++++++++ tests/WireLimiterTest.php | 4 +- ...inerProvider.php => testHelperClasses.php} | 57 +++ 13 files changed, 1289 insertions(+), 176 deletions(-) create mode 100644 examples/WireHelper.php create mode 100644 src/ArgInspector.php create mode 100644 src/WireInvoker.php create mode 100644 tests/ArgumentInspectorTest.php create mode 100644 tests/AssertsErrors.php delete mode 100644 tests/AutomaticResolutionTest.php create mode 100644 tests/WireInvokerTest.php rename tests/{ContainerProvider.php => testHelperClasses.php} (54%) diff --git a/examples/WireHelper.php b/examples/WireHelper.php new file mode 100644 index 0000000..baa361c --- /dev/null +++ b/examples/WireHelper.php @@ -0,0 +1,73 @@ +wiredCall($anyFactoryFunction); + * + * @author Andrej Rypák (dakujem) + */ +final class WireHelper +{ + /** @var WireGenie */ + private $genie; + + public function __construct(WireGenie $genie) + { + $this->genie = $genie; + } + + /** + * Invokes a callable resolving its type-hinted arguments, + * filling in the unresolved arguments from the static argument pool. + * Returns the callable's return value. + * Reading "wire" tags is enabled. + * + * @param callable $code + * @param mixed ...$staticArguments + * @return mixed the callable's return value + */ + public function wiredCall(callable $code, ...$staticArguments) + { + return WireInvoker::employ( + $this->genie + )->invoke($code, ...$staticArguments); + } + + /** + * Creates an instance of requested class, resolving its type-hinted constructor arguments, + * filling in the unresolved arguments from the static argument pool. + * Returns the constructed class instance. + * Reading "wire" tags is enabled. + * + * @param callable $code + * @param mixed ...$staticArguments + * @return mixed the constructed class instance + */ + public function wiredConstruct(string $className, ...$staticArguments) + { + return WireInvoker::employ( + $this->genie + )->construct($className, ...$staticArguments); + } + + /** + * Invokes a callable resolving explicitly given dependencies to call arguments. + * Returns the callable's return value. + * + * @param callable $code + * @param string[] ...$dependencies list of service names + * @return mixed the callable's return value + */ + public function wiredExplicitCall(callable $code, ...$dependencies) + { + return $this->genie->provide(...$dependencies)->invoke($code); + } +} diff --git a/readme.md b/readme.md index 319fe7e..61b8bae 100644 --- a/readme.md +++ b/readme.md @@ -5,51 +5,113 @@ > 💿 `composer require dakujem/wire-genie` -Allows to fetch multiple dependencies from a DI container -and provide them as arguments to callables. +Allows to easily +- fetch multiple dependencies from a service container +and provide them as arguments to _callables_ +- automatically detect parameter types of a _callable_ and wire respective dependencies to invoke it +- automatically detect _constructor_ parameters of a _class_ and wire respective dependencies to construct it > Disclaimer 🤚 > -> Depending on actual use, this might be breaking IoC +> Improper use of this package might break established IoC principles > and degrade your dependency injection container to a service locator, -> so use it with caution. -> -> But then again, if you can `get` from your container, you can use Wire Genie. +> so use the package with caution. +The main purposes of the package are to provide a limited means of wiring services +without directly exposing a service container, +and to help wire services automatically at runtime. + +> Note 💡 +> +> This approach solves an edge case in certain implementations where dependency injection +> boilerplate can not be avoided or reduced in a different way. +> +> Normally you want to wire your dependencies when building your app's service container. -The main purpose is to provide limited means of wiring services -without exposing the service container itself. ## How it works -Wire genie fetches specified dependencies from a container -and passes them to a callable, returning the result. + +[`WireGenie`](src/WireGenie.php) is rather simple, +it fetches specified dependencies from a container +and passes them to a callable, invoking it and returning the result. ```php +$wireGenie = new WireGenie($container); + $factory = function( Dependency $dep1, OtherDependency $dep2, ... ){ // create stuff... return new Service($dep1, $dep2, ... ); }; -// create a provider specifying dependencies +// create a provider, explicitly specifying dependencies $provider = $wireGenie->provide( Dependency::class, OtherDependency::class, ... ); // invoke the factory using the provider $service = $provider->invoke($factory); ``` -Note that _how_ one specifies the dependencies depends on the container he uses.\ -It might be just string keys, class names or interfaces.\ -Wire Genie calls PSR-11's `ContainerInterface::get()` and `ContainerInterface::has()` under the hood. +With [`WireInvoker`](src/WireInvoker.php) it is possible to omit specifying +the dependencies and use **automatic dependency wiring**: +```php +// invoke the factory without specifying dependencies, using an automatic provider +$service = WireInvoker::employ($wireGenie)->invoke($factory); +``` + +`WireInvoker` will detect type-hinted parameter types or tag-hinted identifiers +at runtime and then provide dependencies to the callable. + + +### Note on service containers and conventions + +Note that _how_ services in the container are accessed depends on the conventions used.\ +Services might be accessed by plain string keys, class names or interface names. + +`WireGenie` simply calls methods of [PSR-11 Container](https://www.php-fig.org/psr/psr-11/) +`ContainerInterface::get()` and `ContainerInterface::has()` under the hood, +there is no other "magic". + +Consider a basic service container ([Sleeve](https://github.com/dakujem/sleeve)) and the different conventions: +```php +$sleeve = new Sleeve(); +// using a plain string identifier +$sleeve->set('genie', function (Sleeve $container) { + return new WireGenie($container); +}); +// using a class name identifier +$sleeve->set(WireGenie::class, function (Sleeve $container) { + return new WireGenie($container); +}); + +// using a plain string identifier +$sleeve->set('self', $sleeve); +// using an interface name identifier +$sleeve->set(ContainerInterface::class, $sleeve); +``` + +The services can be accessed by calling either +```php +$sleeve->get('genie'); +$sleeve->get(WireGenie::class); + +$sleeve->get('self'); +$sleeve->get(ContainerInterface::class); +``` + +Different service containers expose services differently. +Some offer both conventions, some offer only one.\ +It is important to understand how _your_ container exposes the services to fully leverage `WireGenie` and `WireInvoker`. + +## Basic usage -## Usage +> Note: In the following example, services are accessed using plain string keys. ```php // Use any PSR-11 compatible container you like. $container = AppContainerPopulator::populate(new Sleeve()); -// Give Wire Genie full access to your DI container, -$genie = new WireGenie($container); +// Give Wire Genie full access to your service container, +$genie = new WireGenie($serviceContainer); // or give it access to limited services only. // (classes implementing RepositoryInterface in this example) @@ -74,24 +136,17 @@ $complexService = $genie->provide('myService', 'my-other-service')->invoke($fact $repoGenie->provide('my-system-service'); ``` -> 💡 -> -> This approach solves an edge case in certain implementations where dependency injection -> boilerplate can not be avoided or reduced in a different way. -> -> Normally you want to wire your dependencies when building your app's DI container. - -You now have means to allow a service -on-demand access to services of a certain type without injecting them all.\ -This particular use-case breaks IoC, though. +You now have means to allow a part of your application +on-demand access to a group of services of a certain type without injecting them all.\ +This particular use-case breaks IoC if misused, though. ```php // using $repoGenie from the previous snippet -new RepositoryUser($repoGenie); +new RepositoryConsumer($repoGenie); -// inside RepositoryUser +// ... then inside RepositoryConsumer $repoGenie->provide( - ThisRepo::class, // or 'this' ✳ - ThatRepo::class // or 'that' ✳ + 'this', + 'that' )->invoke(function( ThisRepository $r1, ThatRepository $r2 @@ -99,18 +154,145 @@ $repoGenie->provide( // do stuff with the repos... }); ``` -> ✳ the actual keys depend on the container in use -> and the way the services are registered in it.\ -> These identifiers are bare strings without their own semantics. -> They may or may not be related to the actual instances that are fetched from the container. In use cases like the one above, it is important to limit access -to certain services only to keep your app layers in good shape. +to certain services only, to keep your app layers in good shape. + + +## Automatic dependency resolution + +If you find the explicit way of `WireGenie` too verbose or insufficient, +Wire Genie package comes with the `WireInvoker` class +that enables automatic resolution of callable arguments. + + +### Type-hinted service identifiers + +Using `WireInvoker`, it possible to omit explicitly specifying the dependencies: +```php +WireInvoker::employ($wireGenie)->invoke(function( Dependency $dep1, OtherDependency $dep2 ){ + return new Service($dep1, $dep2); +}); +``` + +The automatic resolver will detect parameter types using type hints +and make sure that `Dependency::class` and `OtherDependency::class` +are fetched from the container.\ +This works, when the services are accessible using their class names. + + +### Tag-hinted service identifiers + +In case services are accessible by plain string identifiers +(naming conventions unrelated to the actual type-hinted class names), +or the type-hint differs from how the service is accessible, +doc-comments and "wire tags" can be used: +```php +/** + * @param $dep1 [wire:my-identifier] + * \__________________/ + * the whole "wire tag" + * + * @param $dep2 [wire:other-identifier] + * \______________/ + * service identifier + */ +$factory = function( Dependency $dep1, OtherDependency $dep2 ){ + return new Service($dep1, $dep2); +}; +WireInvoker::employ($wireGenie)->invoke($factory); +``` +In this case, services registered as `my-identifier` and `other-identifier` are fetched from the container. + +> Tip 💡 +> +> An empty wire tag `[wire:]` (including the colon at the end) +> can be used to indicate that a service should _not_ be wired.\ +> Useful when you want to pass custom objects to a call. + + +### Filling in for unresolved parameters + +When a callable requires passing arguments that are not resolved by the service container, +it is possible to provide them as a static argument pool: +```php +// scalars can not be resolved from the container using reflection by default +$func = function( Dependency $dep1, int $size, OtherDependency $dep2, bool $cool ){ + return $cool ? new Service($dep1, $dep2) : new OtherService($size, $dep1, $dep2); +}; +// but there is a tool for that too: +WireInvoker::employ($wireGenie)->invoke($func, 42, true); // cool, right? +``` +Values from the static argument pool will be used one by one to fill in for unresolvable parameters. + + +### When to use + +Note that `WireInvoker` resolves the dependencies at the moment of calling its `invoke`/`construct` methods, once per each call.\ +This is contrary to `WireGenie::provide*()` methods, +that resolve the dependencies at the moment of their call and only once, +regardless of how many callables are invoked by the provider returned by the methods. + + +Automatic argument resolution is useful for: +- async job execution + - supplying dependencies after a job is deserialized from a queue +- method dependency injection + - for controller methods, where dependencies differ between the handler methods +- generic factories that create instances with varying dependencies + +> Note that using reflection might have negative performance impact +> if used heavily. + + +## Integration + +As with many other third-party libraries, +you should consider wrapping code using Wire Genie into a helper class +with methods like the following one +(see [`WireHelper`](examples/WireHelper.php) for full example): +```php +/** + * Invokes a callable resolving its type-hinted arguments, + * filling in the unresolved arguments from the static argument pool. + * Returns the callable's return value. + * Reading "wire" tags is enabled. + */ +public function wiredCall(callable $code, ...$staticArguments) +{ + return WireInvoker::employ( + $this->wireGenie + )->invoke($code, ...$staticArguments); +} +``` + +This adds a tiny layer for flexibility, +in case you decide to tweak the way you wire dependencies later on. + + +## Advanced +### Implementing custom logic around `WireInvoker`'s core -### Example pseudocode +It is possible to configure every aspect of `WireInvoker`.\ +Pass callables to its constructor to configure +how services are wired to invoked callables or created instances. -More in-depth +For exmaple, if every service was accessed by its class name, +except the backslashes `\` were replaced by dots '.' and in lower case, +you could implement the following to invoke `$target` callable: +```php +$proxy = function(string $identifier, ContainerInterface $container) { + $key = str_replace('\\', '.', strtolower($identifier)); // alter the service key + return $container->has($key) ? $container->get($key) : null; +}; +new WireInvoker(null, $proxy); // using custom proxy +``` + + +### Example pseudocode for `WireGenie` + +An example with in-depth code comments: ```php // Given a factory function like the following one: $factoryFunction = function( /*...dependencies...*/ ){ @@ -118,7 +300,7 @@ $factoryFunction = function( /*...dependencies...*/ ){ return new Service( /*...dependencies...*/ ); }; -// Give access to full DI container +// Give access to full service container // or use WireLimiter to limit access to certain services only. $genie = new WireGenie( $serviceContainer ); @@ -133,69 +315,23 @@ $service = $invokableProvider->invoke($factoryFunction); $service = $invokableProvider($factoryFunction); ``` -Shorthand syntax: + +### Shorthand syntax + +As hinted in the example above, +the provider instances returned by `WireGenie`'s methods are _callable_ themselves, +the following syntax may be used: ```php +// the two lines below are equivalent $genie->provide( ... )($factoryFunction); $genie->provide( ... )->invoke($factoryFunction); +// the two lines below are equivalent $genie->provide( ... )(function( ... ){ ... }); $genie->provide( ... )->invoke(function( ... ){ ... }); ``` - -## Automatic dependency resolution - -You might consider implementing reflection-based automatic dependency resolution. - -> Note that using reflection might have negative performance impact -> if used heavily. - -This code would work for closures, provided the type-hinted class names are -equal to the identifiers of services in the DI container, -i.e. the container will fetch correct instances if called with class name -argument like this `$container->get(ClassName::class)`: -```php -final class ArgumentReflector -{ - public static function types(Closure $closure): array - { - $rf = new ReflectionFunction($closure); - return array_map(function (ReflectionParameter $rp): string { - $type = ($rp->getClass())->name ?? null; - if ($type === null) { - throw new RuntimeException( - sprintf( - 'Unable to reflect type of parameter "%s".', - $rp->getName() - ) - ); - } - return $type; - }, $rf->getParameters()); - } -} - -// Implement a method like this: -function wireAndExecute(Closure $closure) -{ - $genie = new WireGenie( $this->container ); // get a Wire Genie instance - return - $genie - ->provide(...ArgumentReflector::types($closure)) - ->invoke($closure); -} - -// Then use it to call closures without explicitly specifying the dependencies: -$result = $foo->wireAndExecute(function(DepOne $d1, DepTwo $d2){ - // do or create stuff - return $d1->foo() + $d2->bar(); -}); - -// The PSR-11 container will be asked to -// ::get(DepOne::class) -// ::get(DepTwo::class) -// instances. -``` +The shorthand syntax may also be used with `WireInvoker`, which itself is _callable_. ## Contributing diff --git a/src/ArgInspector.php b/src/ArgInspector.php new file mode 100644 index 0000000..966b5f4 --- /dev/null +++ b/src/ArgInspector.php @@ -0,0 +1,207 @@ + + */ +final class ArgInspector +{ + /** + * Returns a reflection-based detector that detects parameter types. + * Optionally may use other detection for individual parameters, like "wire tag" detection. + * + * Usage: + * new WireInvoker($container, ArgInspector::typeDetector(ArgInspector::tagReader())) + * + * @param callable|null $paramDetector optional detector used for individual parameters + * @return callable + */ + public static function typeDetector(?callable $paramDetector = null): callable + { + return function (?FunctionRef $reflection) use ($paramDetector): array { + return $reflection !== null ? static::detectTypes($reflection, $paramDetector) : []; + }; + } + + /** + * Returns a reflection-based detector that only detects "wire tags". + * + * Usage: + * new WireInvoker($container, ArgInspector::tagDetector()) + * + * @param string $tag defaults to "wire"; only alphanumeric characters should be used; case insensitive + * @return callable + */ + public static function tagDetector(string $tag = null): callable + { + return static::typeDetector(static::tagReader($tag, false)); + } + + /** + * Returns a callable to be used as $detector argument to the `ArgInspector::detectTypes()` call. + * @see ArgInspector::detectTypes() + * + * The callable will collect "wire tags" of parameter annotations, `@ param`. + * If no tag is present, it will return type-hinted class name by default. + * + * A "wire tag" is in the following form, where `` is replaced by the actual service identifier: + * [wire:] + * By default a "wire tag" looks like the following: + * @.param Foobar $foo description [wire:my_service_identifier] + * \__________________________/ + * the whole wire tag + * + * @.param Foobar $foo description [wire:my_service_identifier] + * \___________________/ + * service identifier + * Usage: + * $types = ArgInspector::detectTypes(new ReflectionFunction($func), ArgInspector::tagReader()); + * + * @param string $tag defaults to "wire"; only alphanumeric characters should be used; case insensitive + * @param bool $defaultToTypeHint whether or not to return type-hinted class names when a tag is not present + * @return callable + */ + public static function tagReader(string $tag = null, bool $defaultToTypeHint = true): callable + { + $annotations = null; // Cache used for subsequent calls... + $reflectionInstance = null; // ...with the same reflection instance. + return function ( + ParamRef $param, + FunctionRef $reflection + ) use ($tag, &$annotations, &$reflectionInstance, $defaultToTypeHint): ?string { + if ($annotations === null || $reflection !== $reflectionInstance) { + $reflectionInstance = $reflection; + $annotations = static::parseWireTags($reflection, $tag); + } + $annotation = $annotations[$param->getName()] ?? ($defaultToTypeHint ? static::typeHintOf($param) : null); + // omit empty annotations - empty wire tag indicates "no wiring" + return $annotation !== '' ? $annotation : null; + }; + } + + /** + * Returns an array of type-hinted argument class names by default. + * + * If a custom $detector is passed, it is used for each of the parameters instead. + * + * Usage: + * $types = ArgInspector::types(new ReflectionFunction($func)); + * $types = ArgInspector::types(new ReflectionMethod($object, $methodName)); + * $types = ArgInspector::types(new ReflectionMethod('Namespace\Object::method')); + * + * @param FunctionRef $reflection + * @param callable|null $paramDetector called for each parameter, if provided + * @param bool $removeTrailingNullValues null values at the end of the returned array will be omitted by default + * @return string[] the array may contain null values + */ + public static function detectTypes( + FunctionRef $reflection, + ?callable $paramDetector = null, + bool $removeTrailingNullValues = true + ): array { + $types = array_map(function (ParamRef $parameter) use ($reflection, $paramDetector): ?string { + return $paramDetector !== null ? + call_user_func($paramDetector, $parameter, $reflection) : + static::typeHintOf($parameter); + }, $reflection->getParameters()); + + // remove trailing null values (helps with variadic parameters and so on) + while ($removeTrailingNullValues && count($types) > 0 && end($types) === null) { + array_pop($types); + } + return $types; + } + + /** + * Returns a reflection of a callable or a reflection of a class name (if a constructor is present). + * + * @param callable|string $target a callable or a class name + * @return FunctionRef|null + * @throws ReflectionException + */ + public static function reflectionOf($target): ?FunctionRef + { + return is_string($target) && class_exists($target) ? + static::reflectionOfConstructor($target) : + static::reflectionOfCallable($target); + } + + /** + * Return a reflection of a callable for type detection or other uses. + * + * @param callable $callable any valid callable (closure, invokable object, string, array) + * @return FunctionRef + * @throws ReflectionException + */ + public static function reflectionOfCallable(callable $callable): FunctionRef + { + if ($callable instanceof Closure) { + return new ReflectionFunction($callable); + } + if (is_string($callable)) { + $pcs = explode('::', $callable); + return count($pcs) > 1 ? new ReflectionMethod($pcs[0], $pcs[1]) : new ReflectionFunction($callable); + } + if (!is_array($callable)) { + $callable = [$callable, '__invoke']; + } + return new ReflectionMethod($callable[0], $callable[1]); + } + + /** + * Return a reflection of the constructor of the class for type detection or other uses. + * If the class has no constructor, null is returned. + * + * @param string $className a class name + * @return FunctionRef|null + * @throws ReflectionException + */ + public static function reflectionOfConstructor(string $className): ?FunctionRef + { + return (new ReflectionClass($className))->getConstructor(); + } + + private static function typeHintOf(ParamRef $parameter): ?string + { + $typeHintedClass = $parameter->getClass(); + return $typeHintedClass !== null ? $typeHintedClass->getName() : null; + } + + /** + * @internal + */ + public static function parseWireTags(FunctionRef $reflection, string $tag = null): array + { + $annotations = []; + $dc = $reflection->getDocComment(); + if ($dc !== false && trim($dc) !== '') { + $m = []; + // modifiers: m - multiline; i - case insensitive + $regexp = '#@param\W+(.*?\W+)?\$([a-z0-9_]+)(.+?\[' . ($tag ?? 'wire') . ':(.*?)\])?.*?$#mi'; + // $\__________/ \___/ + // [2] parameter name [4] service identifier + preg_match_all($regexp, $dc, $m); + foreach ($m[2] as $i => $name) { + // [param_name => tag_value] map + $annotations[$name] = $m[3][$i] !== '' ? trim($m[4][$i]) : null; // only when a tag is present + } + } + return $annotations; + } +} diff --git a/src/InvokableProvider.php b/src/InvokableProvider.php index 728c5f5..052c45e 100644 --- a/src/InvokableProvider.php +++ b/src/InvokableProvider.php @@ -5,7 +5,10 @@ namespace Dakujem; /** - * A callable provider returned by WireGenie::provide method. + * A static provider returned by WireGenie::provide method(s). + * The instances are _callable_. + * + * All the call arguments will have been resolved at the moment of creating the instance. * * Usage: * $invokableProvider = (new WireGenie( ... ))->provide( ... ); @@ -15,7 +18,7 @@ * * @author Andrej Rypák (dakujem) */ -class InvokableProvider +class InvokableProvider implements Invoker { use PredictableAccess; @@ -36,20 +39,19 @@ public function __construct(...$callArgs) * @param callable $target callable to be invoked * @return mixed result of the $target callable invocation */ - public function __invoke(callable $target) + public function invoke(callable $target) { - return $this->invoke($target); + return call_user_func($target, ...$this->callArgs); } /** - * Invokes the callable $target with arguments passed to the constructor of the provider. - * Returns the result of the call. + * This provider instances are also callable. * * @param callable $target callable to be invoked * @return mixed result of the $target callable invocation */ - public function invoke(callable $target) + public function __invoke(callable $target) { - return call_user_func($target, ...$this->callArgs); + return $this->invoke($target); } } diff --git a/src/WireInvoker.php b/src/WireInvoker.php new file mode 100644 index 0000000..149b286 --- /dev/null +++ b/src/WireInvoker.php @@ -0,0 +1,199 @@ + + */ +final class WireInvoker implements Invoker, Constructor +{ + use PredictableAccess; + + /** + * A callable that allows to customize the way service identifiers are detected. + * @var callable function(ReflectionFunctionAbstract $reflection): string[] + */ + private $detector; + + /** + * A callable that allows to customize the way services are fetched from a container. + * @var callable function(string $identifier, ContainerInterface $container): service + */ + private $serviceProvider; + + /** + * A callable that allows to customize the way a function reflection is acquired. + * @var callable function($target): FunctionReflectionAbstract + */ + private $reflector; + + /** + * Construct an instance of WireInvoker. Really? Yup! + * + * Detector, reflector and service proxy work as a pipeline to provide a service for a target's parameter: + * $service = $serviceProvider( $detector( $reflector( $target ) ) ) + * + * In theory, the whole pipeline can be altered not to work with reflections, + * there are no restriction to return types of the three callables, except for the detector. + * + * @param ContainerInterface $container service container + * @param callable|null $detector a callable used for identifier detection; + * takes the result of $reflector, MUST return an array of service identifiers; + * function(ReflectionFunctionAbstract $reflection): string[] + * @param callable|null $serviceProxy a callable that takes a service identifier and a container instance + * and SHOULD return the requested service; + * function(string $identifier, ContainerInterface $container): service + * @param callable|null $reflector a callable used to get the reflection of the target being invoked or constructed; + * SHOULD return a reflection of the function or constructor that will be invoked; + * function($target): FunctionReflectionAbstract + */ + public function __construct( + ContainerInterface $container, + ?callable $detector = null, + ?callable $serviceProxy = null, + ?callable $reflector = null + ) { + $this->detector = $detector; + $this->reflector = $reflector; + $this->serviceProvider = $serviceProxy === null ? function ($id) use ($container) { + return $container->has($id) ? $container->get($id) : null; + } : function ($id) use ($container, $serviceProxy) { + return call_user_func($serviceProxy, $id, $container); + }; + } + + /** + * Create an instance of WireInvoker using a WireGenie instance's container. + * Other parameters are the same as for the constructor. + * @see WireInvoker::__construct() + * + * @param WireGenie $wireGenie + * @param callable|null $detector + * @param callable|null $serviceProxy + * @param callable|null $reflector + * @return static + */ + public static function employ( + WireGenie $wireGenie, + ?callable $detector = null, + ?callable $serviceProxy = null, + ?callable $reflector = null + ): self { + $worker = function (ContainerInterface $container) use ($detector, $serviceProxy, $reflector) { + return new static($container); + }; + return $wireGenie->exposeContainer($worker); + } + + /** + * Invokes the callable $target with automatically resolved arguments. + * Unresolved arguments are filled in from the static argument pool. + * Returns the result of the call. + * + * @param callable $target callable to be invoked + * @param mixed ...$staticArguments static argument pool + * @return mixed result of the $target callable invocation + */ + public function invoke(callable $target, ...$staticArguments) + { + $args = $this->resolveArguments($target, ...$staticArguments); + return call_user_func($target, ...$args); + } + + /** + * Constructs the requested object with automatically resolved arguments. + * Unresolved arguments are filled in from the static argument pool. + * Returns the constructed instance. + * + * @param string $target target class name + * @param mixed ...$staticArguments static argument pool + * @return mixed the constructed class instance + */ + public function construct(string $target, ...$staticArguments) + { + $args = $this->resolveArguments($target, ...$staticArguments); + return new $target(...$args); + } + + /** + * This provider instances are also callable. + * + * @param callable|string $target callable to be invoked or a name of a class to be constructed. + * @param mixed ...$staticArguments static argument pool + * @return mixed result of the $target callable invocation or an instance of the requested class + */ + public function __invoke($target, ...$staticArguments) + { + return is_string($target) && class_exists($target) ? + $this->construct($target, ...$staticArguments) : + $this->invoke($target, ...$staticArguments); + } + + /** + * Works sort of as a pipeline: + * $target -> $reflector -> $detector -> serviceProvider => service + * or $serviceProvider($detector($reflector($target))) + * + * @param callable|string $target a callable to be invoked or a name of a class to be constructed + * @param mixed ...$staticArguments static arguments to fill in for parameters where identifier can not be detected + * @return iterable + */ + private function resolveArguments($target, ...$staticArguments): iterable + { + $reflection = call_user_func($this->reflector ?? ArgInspector::class . '::reflectionOf', $target); + $identifiers = call_user_func($this->detector ?? ArgInspector::typeDetector(ArgInspector::tagReader()), $reflection); + if (count($identifiers) > 0) { + return static::resolveServicesFillingInStaticArguments( + $identifiers, + $this->serviceProvider, + $staticArguments + ); + } + return $staticArguments; + } + + /** + * A helper method. + * For each service identifier calls the service provider. + * If an identifier is `null`, one of the static arguments is used instead. + * + * The resulting array might be a mix of services fetched from the service container via the provider + * and other values passed in as static arguments. + * + * @param array $identifiers array of (nullable string) service identifiers + * @param callable $serviceProvider returns requested services; function(string $identifier): object + * @param array $staticArguments + * @return array + */ + public static function resolveServicesFillingInStaticArguments( + array $identifiers, + callable $serviceProvider, + array $staticArguments + ): array { + $services = []; + if (count($identifiers) > 0) { + $services = array_map(function ($identifier) use ($serviceProvider, &$staticArguments) { + if ($identifier !== null) { + return call_user_func($serviceProvider, $identifier); + } + if (count($staticArguments) > 0) { + return array_shift($staticArguments); + } + // when no static argument is present for an identifier, return null + return null; + }, $identifiers); + } + // merge with the rest of the static arguments + return array_merge(array_values($services), array_values($staticArguments)); + } +} diff --git a/tests/ArgumentInspectorTest.php b/tests/ArgumentInspectorTest.php new file mode 100644 index 0000000..3f1cb5e --- /dev/null +++ b/tests/ArgumentInspectorTest.php @@ -0,0 +1,187 @@ +assertSame([Foo::class, Bar::class], $types); + } + + public function testDetectTypesWithAScalar(): void + { + $closure = function (Foo $foo, int $qux, Bar $bar, $wham) { + }; + $reflection = new ReflectionFunction($closure); + $types = ArgInspector::detectTypes($reflection); + $this->assertSame([Foo::class, null, Bar::class], $types); // trailing null trimmed + } + + public function testDetectTypesWithoutParameters(): void + { + $closure = function () { + }; + $reflection = new ReflectionFunction($closure); + $this->assertSame([], ArgInspector::detectTypes($reflection)); + } + + public function testDetectTypesUsingTagReader(): void + { + $closure = function (Foo $foo, int $qux, Bar $bar, $wham) { + }; + $reflection = new ReflectionFunction($closure); + $types = ArgInspector::detectTypes($reflection, ArgInspector::tagReader()); + $this->assertSame([Foo::class, null, Bar::class], $types); // trailing null trimmed + } + + public function testDetectTypesUsingTagReaderWithTags(): void + { + /** + * @param Foo $foo [wire] + * @param int $qux [notreally:nothing-sorry] + * @param Bar $bar [wire:overridden] + * @param $wham [wire:My\Name\Space\Wham] + * @param $ham [wire:redundant] + */ + $closure = function (Foo $foo, int $qux, Bar $bar, $wham) { + }; + $reflection = new ReflectionFunction($closure); + $types = ArgInspector::detectTypes($reflection, ArgInspector::tagReader()); + $this->assertSame([Foo::class, null, 'overridden', \My\Name\Space\Wham::class], $types); + } + + public function testEmptyWireTagIndicatesNoWiring(): void + { + /** + * @param Foo $foo [wire:] + * @param int $qux + * @param Bar $bar [wire:] + * @param $wham + */ + $closure = function (Foo $foo, int $qux, Bar $bar, $wham) { + }; + $reflection = new ReflectionFunction($closure); + $types = ArgInspector::detectTypes($reflection, ArgInspector::tagReader()); + $this->assertSame([], $types); // trailing null values trimmed, all in this case + } + + public function testTypeDetector(): void + { + $reflection = new ReflectionMethod($this, 'otherMethod'); + $detector = ArgInspector::typeDetector(); + $this->assertSame([Foo::class, null, Bar::class], $detector($reflection)); // trailing null is trimmed ! + } + + public function testTypeDetectorWithTags(): void + { + $reflection = new ReflectionMethod($this, 'otherMethod'); + $detector = ArgInspector::typeDetector(ArgInspector::tagReader()); + $this->assertSame([Foo::class, null, 'overridden', \My\Name\Space\Wham::class], $detector($reflection)); + } + + public function testTagDetector(): void + { + $reflection = new ReflectionMethod($this, 'otherMethod'); + $detector = ArgInspector::tagDetector(); + $this->assertSame([null, null, 'overridden', \My\Name\Space\Wham::class], $detector($reflection)); + } + + public function testReflectionOfCallables() + { + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfCallable(function () { + })); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfCallable(new class { + public function __invoke() + { + } + })); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfCallable('\fopen')); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfCallable([$this, 'methodFoo'])); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfCallable(self::class . '::methodBar')); + } + + public function testReflectionOfConstructors() + { + $this->assertSame(null, ArgInspector::reflectionOfConstructor(NoConstructor::class)); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfConstructor(HasConstructor::class)); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOfConstructor(InheritsConstructor::class)); + } + + public function testReflectionOf() + { + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOf('\fopen')); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOf([$this, 'methodFoo'])); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOf(self::class . '::methodBar')); + $this->assertSame(null, ArgInspector::reflectionOf(NoConstructor::class)); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOf(HasConstructor::class)); + $this->assertInstanceOf(ReflectionFunctionAbstract::class, ArgInspector::reflectionOf(InheritsConstructor::class)); + } + + public function testTagParsing() + { + /** + * @param $foo [wire:foobar] + * @param Barbar $bAr [wire:whatever-it-is-you-like] + * @param Foo|Bar|null $qux [wire:1234] + * @param this is invalid $wham [wire:Name\Space\Wham] + * @param $facepalm [other:tag-prece:ding] [wire:Dakujem\Tests\Foo] [and:another] + * @param $empty [wire:] + * @param self $notag + */ + $callable = function () { + }; + $annotations = ArgInspector::parseWireTags(ArgInspector::reflectionOfCallable($callable)); + $this->assertSame([ + 'foo' => 'foobar', + 'bAr' => 'whatever-it-is-you-like', + 'qux' => '1234', + 'wham' => 'Name\Space\Wham', + 'facepalm' => 'Dakujem\Tests\Foo', + 'empty' => '', + 'notag' => null, + ], $annotations); + } + + /** + * @param Foo $foo [wire:genie] + * @param int $theAnswer [wire:Dakujem\Tests\Bar] + */ + public function methodFoo(Foo $foo, int $theAnswer) + { + } + + public static function methodBar(Foo $foo, int $theAnswer) + { + } + + /** + * @param Foo $foo [wire] + * @param int $qux [notreally:nothing-sorry] + * @param Bar $bar [wire:overridden] + * @param $wham [wire:My\Name\Space\Wham] + * @param $ham [wire:redundant] + */ + private function otherMethod(Foo $foo, int $qux, Bar $bar, $wham) + { + } +} diff --git a/tests/AssertsErrors.php b/tests/AssertsErrors.php new file mode 100644 index 0000000..467dad2 --- /dev/null +++ b/tests/AssertsErrors.php @@ -0,0 +1,66 @@ + + */ +trait AssertsErrors +{ + /** + * Assert an error of a given type is thrown in the provided callable. + * Can also check for a specific exception code and/or message. + * + * This assertion method fills in the hole in PHPUnit, + * that is, it replaces the need for using expectException. + * + * @link https://gist.github.com/VladaHejda/8826707 [source] + * + * @param callable $callable + * @param string $expectedException class name of the expected exception + * @param null|int $expectedCode + * @param null|string $expectedMessage + */ + protected function assertException( + callable $callable, + $expectedException = 'Exception', + $expectedMessage = null, + $expectedCode = null + ) { + $expectedException = ltrim((string)$expectedException, '\\'); + if (!class_exists($expectedException) && !interface_exists($expectedException)) { + $this->fail(sprintf('An exception of type "%s" does not exist.', $expectedException)); + } + try { + call_user_func($callable); + } catch (\Exception $e) { + $class = get_class($e); + $message = $e->getMessage(); + $code = $e->getCode(); + $errorMessage = 'Failed asserting the class of exception'; + if ($message && $code) { + $errorMessage .= sprintf(' (message was %s, code was %d)', $message, $code); + } elseif ($code) { + $errorMessage .= sprintf(' (code was %d)', $code); + } + $errorMessage .= '.'; + $this->assertInstanceOf($expectedException, $e, $errorMessage); + if ($expectedMessage !== null) { + $this->assertSame($expectedMessage, $message, sprintf('Failed asserting the message of thrown %s.', $class)); + } + if ($expectedCode !== null) { + $this->assertEquals($expectedCode, $code, sprintf('Failed asserting code of thrown %s.', $class)); + } + return; + } + $errorMessage = 'Failed asserting that exception'; + if (strtolower($expectedException) !== 'exception') { + $errorMessage .= sprintf(' of type %s', $expectedException); + } + $errorMessage .= ' was thrown.'; + $this->fail($errorMessage); + } +} + diff --git a/tests/AutomaticResolutionTest.php b/tests/AutomaticResolutionTest.php deleted file mode 100644 index a4d008f..0000000 --- a/tests/AutomaticResolutionTest.php +++ /dev/null @@ -1,71 +0,0 @@ -getClass())->name ?? null; - if ($type === null) { - throw new RuntimeException(sprintf('Unable to reflect type of parameter "%s".', $rp->getName())); - } - return $type; - }, $rf->getParameters()); - } -} - -/** - * AutomaticResolutionTest - */ -final class AutomaticResolutionTest extends TestCase -{ - private function wireAndExecute(Closure $closure) - { - $genie = new WireGenie(ContainerProvider::createContainer()); - return $genie->provide(...ArgumentReflector::types($closure))->invoke($closure); - } - - public function testCorrectResolution(): void - { - $run = false; - $this->wireAndExecute(function (?WireGenie $wg = null, ?Error $e = null) use (&$run) { - $run = true; - $this->assertSame(WireGenie::class, get_class($wg)); - $this->assertSame(Error::class, get_class($e)); - }); - $this->assertTrue($run); - } - - public function testFailedResolution(): void - { - $run = false; - $this->wireAndExecute(function (?WireLimiter $foo = null) use (&$run) { - $run = true; - $this->assertSame(null, $foo); - }); - $this->assertTrue($run); - } - - public function testFailedReflection(): void - { - $this->expectException(RuntimeException::class); - $this->wireAndExecute(function (int $foo = null) { - }); - } -} diff --git a/tests/InvokableProviderTest.php b/tests/InvokableProviderTest.php index bacc633..e5b1200 100644 --- a/tests/InvokableProviderTest.php +++ b/tests/InvokableProviderTest.php @@ -9,6 +9,9 @@ use ReflectionClass; use stdClass; +/** + * @internal test + */ final class InvokableProviderTest extends TestCase { public function testCallable(): void diff --git a/tests/WireGenieTest.php b/tests/WireGenieTest.php index 35b310f..12b6a7d 100644 --- a/tests/WireGenieTest.php +++ b/tests/WireGenieTest.php @@ -12,7 +12,7 @@ use Psr\Container\NotFoundExceptionInterface; /** - * WireGenieTest + * @internal test */ final class WireGenieTest extends TestCase { diff --git a/tests/WireInvokerTest.php b/tests/WireInvokerTest.php new file mode 100644 index 0000000..094bc36 --- /dev/null +++ b/tests/WireInvokerTest.php @@ -0,0 +1,254 @@ +assertSame([ + 'foo', + 1, + 'bar', + 2, + 'wham', + 3, + 42, + 'foobar', + ], $arguments); + + $staticArguments = ['foobar']; + $arguments = WireInvoker::resolveServicesFillingInStaticArguments($identifiers, $provider, $staticArguments); + $this->assertSame([ + 'foo', + 'foobar', + 'bar', + null, + 'wham', + ], $arguments); + } + + public function testInvokerFillsInArguments() + { + $invoker = new WireInvoker(ContainerProvider::createContainer()); + $func = function () { + return func_get_args(); + }; + $this->assertSame([], $invoker->invoke($func)); + $this->assertSame([42], $invoker->invoke($func, 42)); + $this->assertSame(['foo'], $invoker->invoke($func, 'foo')); + } + + public function testInvokerInvokesAnyCallableTypeAndFillsInUnresolvedArguments() + { + $invoker = new WireInvoker(ContainerProvider::createContainer()); + + $func = function (Foo $foo, int $theAnswer) { + return [$foo, $theAnswer]; + }; + $invokable = new class { + public function __invoke(Foo $foo, int $theAnswer) + { + return [$foo, $theAnswer]; + } + }; + + $check = function ($args) { + $this->assertInstanceOf(Foo::class, $args[0]); + $this->assertSame(42, $args[1]); + }; + + $rv = $invoker->invoke($func, 42); + $check($rv); + $rv = $invoker->invoke($invokable, 42); + $check($rv); + $rv = $invoker->invoke([$this, 'methodFoo'], 42); + $check($rv); + $rv = $invoker->invoke('\sleep', 0); + $this->assertSame(0, $rv); // sleep returns 0 on success + $rv = $invoker->invoke(self::class . '::methodBar', 42); + $check($rv); + } + + public function testInvokerReadsTagsByDefault() + { + $invoker = new WireInvoker(ContainerProvider::createContainer()); + // tags should be read by default + $rv = $invoker->invoke([$this, 'methodTagOverride'], 42); + $this->assertCount(3, $rv); + $this->assertInstanceOf(Baz::class, $rv[0]); + $this->assertInstanceOf(WireGenie::class, $rv[1]); + $this->assertSame(42, $rv[2]); + } + + public function testAutomaticResolutionCanBeOverridden() + { + $invoker = new WireInvoker($sleeve = ContainerProvider::createContainer()); + $func = function (Bar $bar) { + return func_get_args(); + }; + // normally resolves to Bar instance + $this->assertSame([$sleeve->get(Bar::class)], $invoker->invoke($func)); + + /** + * @param Bar $bar [wire:] <-- empty tag indicates no wiring + * @return array + */ + $funcOverridden = function (Bar $bar) { + return func_get_args(); + }; + $baz = $sleeve->get(Baz::class); + // but here we turn the detection off and provide our own instance (of Baz) + $this->assertSame([$baz], $invoker->invoke($funcOverridden, $baz)); + } + + public function testConstructor() + { + $invoker = new WireInvoker($sleeve = ContainerProvider::createContainer()); + $rv = $invoker->construct(WeepingWillow::class); + $this->assertInstanceOf(WeepingWillow::class, $rv); + $this->assertSame([], $rv->args); + + $rv = $invoker->construct(HollowWillow::class); + $this->assertInstanceOf(HollowWillow::class, $rv); + $this->assertSame([$sleeve->get(Foo::class)], $rv->args); + } + + public function testInvalidInvocation1() + { + $invoker = new WireInvoker(ContainerProvider::createContainer()); + + // passes ok + $invoker->invoke([$this, 'methodFoo'], 42); + + $this->expectErrorMessage('Too few arguments to function Dakujem\Tests\WireInvokerTest::methodFoo(), 1 passed and exactly 2 expected'); + + // type error, missing argument + $invoker->invoke([$this, 'methodFoo']); + } + + public function testInvalidInvocation2() + { + $invoker = new WireInvoker(ContainerProvider::createContainer()); + + $func = function (Foo $foo, int $theAnswer) { + return [$foo, $theAnswer]; + }; + $func2 = function (Foo $foo, int $theAnswer = null) { + return [$foo, $theAnswer]; + }; + + // passes ok + $invoker->invoke($func, 42); + $invoker->invoke($func2); + + $this->expectErrorMessage('Too few arguments to function Dakujem\Tests\WireInvokerTest::Dakujem\Tests\{closure}(), 1 passed and exactly 2 expected'); + + // type error, missing argument + $invoker->invoke($func); + } + + public function testInvokerUsesCustomCallables() + { + $sleeve = ContainerProvider::createContainer(); + $detectorCalled = 0; + $detector = function (ReflectionFunctionAbstract $ref) use (&$detectorCalled) { + $detectorCalled += 1; + return ArgInspector::detectTypes($ref); // no tag reader + }; + $proxyCalled = 0; + $proxy = function ($id, ContainerInterface $container) use (&$proxyCalled) { + $proxyCalled += 1; + return $container->get($id); + }; + $reflectorCalled = 0; + $reflector = function ($target) use (&$reflectorCalled) { + $reflectorCalled += 1; + return ArgInspector::reflectionOf($target); + }; + $invoker = new WireInvoker($sleeve, $detector, $proxy, $reflector); + [$bar, $fourtyTwo] = $invoker->invoke([$this, 'methodTagOverride'], 42); + $this->assertSame(1, $reflectorCalled); + $this->assertSame(1, $detectorCalled); + $this->assertSame(1, $proxyCalled); + $this->assertSame($sleeve->get(Bar::class), $bar); + $this->assertSame(42, $fourtyTwo); + } + + public function testInvokerUsesCustomCallablesWithTagReader() + { + $sleeve = ContainerProvider::createContainer(); + $detectorCalled = 0; + $detector = function (ReflectionFunctionAbstract $ref) use (&$detectorCalled) { + $detectorCalled += 1; + return ArgInspector::detectTypes($ref, ArgInspector::tagReader()); // added tag reader + }; + $proxyCalled = 0; + $proxy = function ($id, ContainerInterface $container) use (&$proxyCalled) { + $proxyCalled += 1; + return $container->get($id); + }; + $reflectorCalled = 0; + $reflector = function ($target) use (&$reflectorCalled) { + $reflectorCalled += 1; + return ArgInspector::reflectionOf($target); + }; + $invoker = new WireInvoker($sleeve, $detector, $proxy, $reflector); + [$baz, $genie, $fourtyTwo, $foo] = $invoker->invoke([$this, 'methodTagOverride'], 42, 'foobar'); + $this->assertSame(1, $reflectorCalled); + $this->assertSame(1, $detectorCalled); + $this->assertSame(2, $proxyCalled); + $this->assertSame($sleeve->get(Baz::class), $baz); // Baz, not Bar ! + $this->assertSame($sleeve->get('genie'), $genie); + $this->assertSame(42, $fourtyTwo); // rest arguments trail + $this->assertSame('foobar', $foo); // rest arguments trail + } + + public function methodFoo(Foo $foo, int $theAnswer): array + { + return func_get_args(); + } + + public static function methodBar(Foo $foo, int $theAnswer): array + { + return func_get_args(); + } + + /** + * @param Bar $bar [wire:Dakujem\Tests\Baz] + * @param mixed $theAnswer [wire:genie] + */ + public function methodTagOverride(Bar $bar, $theAnswer): array + { + return func_get_args(); + } +} diff --git a/tests/WireLimiterTest.php b/tests/WireLimiterTest.php index 247310d..1d9b3b5 100644 --- a/tests/WireLimiterTest.php +++ b/tests/WireLimiterTest.php @@ -10,10 +10,10 @@ use Psr\Container\ContainerExceptionInterface; use Throwable; -require_once 'ContainerProvider.php'; +require_once 'testHelperClasses.php'; /** - * WireLimiterTest + * @internal test */ final class WireLimiterTest extends TestCase { diff --git a/tests/ContainerProvider.php b/tests/testHelperClasses.php similarity index 54% rename from tests/ContainerProvider.php rename to tests/testHelperClasses.php index 23aafeb..4fc19cc 100644 --- a/tests/ContainerProvider.php +++ b/tests/testHelperClasses.php @@ -34,6 +34,63 @@ public static function createContainer(): ContainerInterface return new Error('Hey! I\'m a new error. Nice to meet you.'); })); + $sleeve->set(Foo::class, function () { + return new Foo; + }); + + $sleeve->set(Bar::class, function () { + return new Bar; + }); + $sleeve->set(Baz::class, function () { + return new Baz; + }); + return $sleeve; } } + +class Foo +{ +} + +class Bar +{ +} + +class Baz extends Bar +{ +} + +class NoConstructor +{ +} + +class HasConstructor +{ + public function __construct() + { + } +} + +class InheritsConstructor extends HasConstructor +{ + +} + +class WeepingWillow +{ + public $args; + + public function __construct(...$args) + { + $this->args = $args; + } +} + +class HollowWillow extends WeepingWillow +{ + public function __construct(Foo $foo) + { + parent::__construct($foo); + } +}