diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 190b4da..184fa62 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,7 +15,6 @@ jobs: - ubuntu-latest php: - - "8.1" - "8.2" - "8.3" diff --git a/.github/workflows/cs-tests.yml b/.github/workflows/cs-tests.yml index e8bbade..f9df1a0 100644 --- a/.github/workflows/cs-tests.yml +++ b/.github/workflows/cs-tests.yml @@ -15,7 +15,6 @@ jobs: - ubuntu-latest php: - - "8.1" - "8.2" - "8.3" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 6f7452d..1c328d6 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -15,7 +15,6 @@ jobs: - ubuntu-latest php: - - "8.1" - "8.2" - "8.3" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 7f5f333..e1ce1ac 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -15,7 +15,6 @@ jobs: - ubuntu-latest php: - - "8.1" - "8.2" - "8.3" diff --git a/OSSMETADATA b/OSSMETADATA index 6c7e106..b96d4a4 100644 --- a/OSSMETADATA +++ b/OSSMETADATA @@ -1 +1 @@ -osslifecycle=active \ No newline at end of file +osslifecycle=active diff --git a/README.md b/README.md index 0508e5e..7d1dac5 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # dot-annotated-services -DotKernel component used to create services through [Laminas Service Manager](https://github.com/laminas/laminas-servicemanager) and inject them with dependencies just using method annotations. It can also create services without the need to write factories. Annotation parsing can be cached, to improve performance. +DotKernel dependency injection service. This package can clean up your code, by getting rid of all the factories you write, sometimes just to inject a dependency or two. ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-annotated-services) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/4.1.7) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/5.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/network) [![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/stargazers) -[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/blob/4.0/LICENSE.md) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-annotated-services)](https://github.com/dotkernel/dot-annotated-services/blob/5.0/LICENSE.md) -[![Build Static](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml/badge.svg?branch=4.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml) +[![Build Static](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml/badge.svg?branch=5.0)](https://github.com/dotkernel/dot-annotated-services/actions/workflows/static-analysis.yml) [![codecov](https://codecov.io/gh/dotkernel/dot-annotated-services/graph/badge.svg?token=ZBZDEA3LY8)](https://codecov.io/gh/dotkernel/dot-annotated-services) [![SymfonyInsight](https://insight.symfony.com/projects/a0d7016e-fc3f-46b8-9b36-571ff060d744/big.svg)](https://insight.symfony.com/projects/a0d7016e-fc3f-46b8-9b36-571ff060d744) @@ -20,18 +20,22 @@ This package can clean up your code, by getting rid of all the factories you wri ## Installation -Run the following command in your project directory +Install `dot-annotated-services` by running the following command in your project directory: composer require dotkernel/dot-annotated-services -After installing, add the `ConfigProvider` class to your configuration aggregate. +After installing, register `dot-annotated-services` in your project by adding the below line to your configuration aggregate (usually: `config/config.php`): + + Dot\AnnotatedServices\ConfigProvider::class, + ## Usage ### Using the AnnotatedServiceFactory -You can register services in the service manager using the `AnnotatedServiceFactory` as below +You can register services in the service manager using `AnnotatedServiceFactory` as seen in the below example: + ```php return [ 'factories' => [ @@ -40,20 +44,20 @@ return [ ]; ``` + ### NOTE > You can use only the fully qualified class name as the service key -The next step is to annotate the service constructor or setters with the service names to inject +The next step is to add the `#[Inject]` attribute to the service constructor with the service FQCNs to inject: + ```php -use Dot\AnnotatedServices\Annotation\Inject; - -/** - * @Inject({ - * Dependency1::class, - * Dependency2::class, - * "config" - * }) - */ +use Dot\AnnotatedServices\Attribute\Inject; + +#[Inject( + Dependency1::class, + Dependency2::class, + "config", +)] public function __construct( protected Dependency1 $dep1, protected Dependency2 $dep2, @@ -62,105 +66,54 @@ public function __construct( } ``` -The annotation `@Inject` is telling the factory to inject the services between curly braces. +The `#[Inject]` attribute is telling `AnnotatedServiceFactory` to inject the services specified as parameters. Valid service names should be provided, as registered in the service manager. To inject an array value from the service manager, you can use dot notation as below + ```php -use Dot\AnnotatedServices\Annotation\Inject; +use Dot\AnnotatedServices\Attribute\Inject; -/** - * @Inject({"config.debug"}) - */ +#[Inject( + "config.debug", +)] ``` +which will inject `$container->get('config')['debug'];`. -which will inject `$container->get('config')['debug'];` ### NOTE -> Even if using dot annotation, the annotated factory will check first if a service name exists with that name +> Even if using dot notation, `AnnotatedServiceFactory` will check first if a service name exists with that name. -You can use the inject annotation on setters too, they will be called at creation time and injected with the configured dependencies. -### Using the AnnotatedRepositoryFactory -You can register doctrine repositories and inject them using the AnnotatedRepositoryFactory as below: +### Using the AttributedRepositoryFactory +You can register doctrine repositories and inject them using the `AttributedRepositoryFactory` as below: ```php return [ 'factories' => [ - ExampleRepository::class => AnnotatedRepositoryFactory::class, + ExampleRepository::class => AttributedRepositoryFactory::class, ], ]; ``` -The next step is to add the `@Entity` annotation in the repository class. +The next step is to add the `#[Entity]` attribute in the repository class. The `name` field has to be the fully qualified class name. Every repository should extend `Doctrine\ORM\EntityRepository`. ```php +use Api\App\Entity\Example; use Doctrine\ORM\EntityRepository; -use Dot\AnnotatedServices\Annotation\Entity; +use Dot\AnnotatedServices\Attribute\Entity; -/** - * @Entity(name="App\Entity\Example") - */ +#[Entity(name: Example::class)] class ExampleRepository extends EntityRepository { - -} -``` - - -### Using the abstract factory - -Using this approach, no service manager configuration is required. It uses the registered abstract factory to create annotated services. - -In order to tell the abstract factory which services are to be created, you need to annotate the service class with the `@Service` annotation. -```php -use Dot\AnnotatedServices\Annotation\Service; - -/* - * @Service - */ -class ServiceClass -{ - // configure injections as described in the previous section } ``` -And that's it, you don't need to configure the service manager with this class, creation will happen automatically. - - -## Cache annotations - -This package is built on top of `doctrine/annotation` and `doctrine/cache`. -In order to cache annotations, you should register a service factory at key `AbstractAnnotatedFactory::CACHE_SERVICE` that should return a valid `Doctrine\Common\Cache\Cache` cache driver. See [Cache Drivers](https://github.com/doctrine/cache/tree/master/lib/Doctrine/Common/Cache) for available implementations offered by doctrine. - -Below, we give an example, as defined in our frontend and admin starter applications -```php -return [ - 'annotations_cache_dir' => __DIR__ . '/../../data/cache/annotations', - 'dependencies' => [ - 'factories' => [ - // used by dot-annotated-services to cache annotations - // needs to return a cache instance from Doctrine\Common\Cache - AbstractAnnotatedFactory::CACHE_SERVICE => AnnotationsCacheFactory::class, - ] - ], -]; -``` - -```php -namespace Frontend\App\Factory; - -use Doctrine\Common\Cache\FilesystemCache; -use Psr\Container\ContainerInterface; - -class AnnotationsCacheFactory -{ - public function __invoke(ContainerInterface $container) - { - //change this to suite your caching needs - return new FilesystemCache($container->get('config')['annotations_cache_dir']); - } -} -``` +### NOTE +Starting from version `5.0` of `dot-annotated-services`: +- services can only be injected using the `#[Inject]` attribute (`@Inject` and `@Service` annotations are no longer supported) +- repository-entity relation can only be established using the `#[Entity]` attribute (`@Entity` annotation is no longer supported) +- dependencies injected via the`#[Entity]`/`#[Inject]` attributes are not cached +- injecting dependencies into property setters is no longer supported diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d90c421 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,41 @@ +# Security Policy + +## Supported Versions + + +| Version | Supported | PHP Version | +|---------|--------------------|------------------------------------------------------------------------------------------------------------------------| +| 5.x | :white_check_mark: | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/5.0.0) | +| 4.x | :white_check_mark: | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-annotated-services/4.0.0) | +| <= 3.x | :x: | | + + +## Reporting Potential Security Issues + +If you have encountered a potential security vulnerability in this project, +please report it to us at . We will work with you to +verify the vulnerability and patch it. + +When reporting issues, please provide the following information: + +- Component(s) affected +- A description indicating how to reproduce the issue +- A summary of the security vulnerability and impact + +We request that you contact us via the email address above and give the +project contributors a chance to resolve the vulnerability and issue a new +release prior to any public exposure; this helps protect the project's +users, and provides them with a chance to upgrade and/or update in order to +protect their applications. + + +## Policy + +If we verify a reported security vulnerability, our policy is: + +- We will patch the current release branch, as well as the immediate prior minor + release branch. + +- After patching the release branches, we will immediately issue new security + fix releases for each patched release branch. + diff --git a/composer.json b/composer.json index 6195b20..fd85127 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "dotkernel/dot-annotated-services", "type": "library", - "description": "DotKernel service creation component through laminas-servicemanager and annotations", + "description": "DotKernel dependency injection component using class attributes.", "license": "MIT", "homepage": "https://github.com/dotkernel/dot-annotated-services", "authors": [ @@ -11,23 +11,21 @@ } ], "keywords": [ - "annotations", - "services", - "factories", + "attribute", "container", - "laminas", - "mezzio", - "service-manager" + "dependency", + "di", + "factory", + "inject", + "service" ], "require": { - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", - "laminas/laminas-servicemanager": "^3.22.1", - "doctrine/annotations": "^1.14.3", - "doctrine/cache": "^1.12.1 || ^2.1.1", - "doctrine/orm" : "^2.17.3" + "php": "~8.2.0 || ~8.3.0", + "doctrine/orm": "^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0" }, "require-dev": { - "phpunit/phpunit": "^10.5.9", + "phpunit/phpunit": "^10.5", "vimeo/psalm": "^5.20", "laminas/laminas-coding-standard": "^2.5" }, diff --git a/phpunit.xml b/phpunit.xml index 65d03b8..ea7ff0c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,6 +3,7 @@ ./test + ./test/TestData diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..a4c80a0 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,8 @@ + + + + + class ($entityManager, $metadata) extends EntityRepository { + + + diff --git a/psalm.xml b/psalm.xml index 7272b57..3f3ebbf 100644 --- a/psalm.xml +++ b/psalm.xml @@ -7,9 +7,11 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + errorBaseline="psalm-baseline.xml" > + diff --git a/src/Annotation/Entity.php b/src/Annotation/Entity.php deleted file mode 100644 index c8c84f9..0000000 --- a/src/Annotation/Entity.php +++ /dev/null @@ -1,31 +0,0 @@ -name = $name; - } - - public function getName(): string - { - return $this->name; - } -} diff --git a/src/Annotation/Inject.php b/src/Annotation/Inject.php deleted file mode 100644 index cb13367..0000000 --- a/src/Annotation/Inject.php +++ /dev/null @@ -1,27 +0,0 @@ -services = $values['value'] ?? []; - } - - public function getServices(): array - { - return $this->services; - } -} diff --git a/src/Annotation/Service.php b/src/Annotation/Service.php deleted file mode 100644 index b378408..0000000 --- a/src/Annotation/Service.php +++ /dev/null @@ -1,17 +0,0 @@ -name = $name; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Attribute/Inject.php b/src/Attribute/Inject.php new file mode 100644 index 0000000..db30a21 --- /dev/null +++ b/src/Attribute/Inject.php @@ -0,0 +1,23 @@ +services = $services; + } + + public function getServices(): array + { + return $this->services; + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 51284a4..264f95a 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -4,23 +4,10 @@ namespace Dot\AnnotatedServices; -use Dot\AnnotatedServices\Factory\AnnotatedServiceAbstractFactory; - class ConfigProvider { public function __invoke(): array { - return [ - 'dependencies' => $this->getDependenciesConfig(), - ]; - } - - public function getDependenciesConfig(): array - { - return [ - 'abstract_factories' => [ - AnnotatedServiceAbstractFactory::class, - ], - ]; + return []; } } diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php index 0571bb7..700c73b 100644 --- a/src/Exception/ExceptionInterface.php +++ b/src/Exception/ExceptionInterface.php @@ -4,9 +4,6 @@ namespace Dot\AnnotatedServices\Exception; -/** - * Interface ExceptionInterface - */ interface ExceptionInterface { } diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index b55962d..c84f58d 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -6,4 +6,6 @@ class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface { + public const MESSAGE_MISSING_KEY = + 'The key "%s" provided in the dotted notation could not be found in the array service.'; } diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php index 72ea6c2..b082221 100644 --- a/src/Exception/RuntimeException.php +++ b/src/Exception/RuntimeException.php @@ -8,35 +8,32 @@ class RuntimeException extends \RuntimeException implements ExceptionInterface { + public const MESSAGE_ATTRIBUTE_NOT_FOUND = + 'You need to use the "%s" attribute on the "%s" class so that "%s" can create it.'; + public const MESSAGE_CLASS_NOT_FOUND = + 'Defined injectable "%s" could not be found in container or as a class.'; + public const MESSAGE_DOES_NOT_EXTEND = + 'Class "%s" must extend class "%s".'; + public const MESSAGE_RECURSIVE_INJECT = + 'Class "%s" can not be injected into itself.'; + public static function classNotFound(string $requestedName): self { - return new self(sprintf( - 'Defined injectable service "%s" could not be found in container or as a class.', - $requestedName - )); + return new self(sprintf(self::MESSAGE_CLASS_NOT_FOUND, $requestedName)); } - public static function doesNotExtend(string $class): self + public static function doesNotExtend(string $requestedName, string $class): self { - return new self(sprintf('Class has to extend "%s".', $class)); + return new self(sprintf(self::MESSAGE_DOES_NOT_EXTEND, $requestedName, $class)); } - public static function annotationNotFound(string $annotation, string $class, string $factory): self + public static function attributeNotFound(string $attribute, string $class, string $factory): self { - return new self(sprintf( - 'You need to use the "%s" annotation in "%s" class so that the "%s" can create it.', - $annotation, - $class, - $factory - )); + return new self(sprintf(self::MESSAGE_ATTRIBUTE_NOT_FOUND, $attribute, $class, $factory)); } - public static function invalidAnnotation(string $requestedName): self + public static function recursiveInject(string $requestedName): self { - return new self(sprintf( - 'Annotated factories can only be used with services that are identified by their FQCN. ' - . 'Provided "%s" service name is not a valid class.', - $requestedName - )); + return new self(sprintf(self::MESSAGE_RECURSIVE_INJECT, $requestedName)); } } diff --git a/src/Factory/AbstractAnnotatedFactory.php b/src/Factory/AbstractAnnotatedFactory.php deleted file mode 100644 index bc4d523..0000000 --- a/src/Factory/AbstractAnnotatedFactory.php +++ /dev/null @@ -1,49 +0,0 @@ -annotationReader = $annotationReader; - } - - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ - protected function createAnnotationReader(ContainerInterface $container): Reader - { - if ($this->annotationReader !== null) { - return $this->annotationReader; - } - - if (! $container->has(self::CACHE_SERVICE)) { - return $this->annotationReader = new AnnotationReader(); - } else { - /** @var CacheItemPoolInterface $cache */ - $cache = $container->get(self::CACHE_SERVICE); - $debug = false; - if ($container->has('config')) { - $config = $container->get('config'); - if (isset($config['debug'])) { - $debug = (bool) $config['debug']; - } - } - return $this->annotationReader = new PsrCachedReader(new AnnotationReader(), $cache, $debug); - } - } -} diff --git a/src/Factory/AnnotatedServiceAbstractFactory.php b/src/Factory/AnnotatedServiceAbstractFactory.php deleted file mode 100644 index c53b97b..0000000 --- a/src/Factory/AnnotatedServiceAbstractFactory.php +++ /dev/null @@ -1,50 +0,0 @@ -createAnnotationReader($container); - $refClass = new ReflectionClass($requestedName); - - $service = $annotationReader->getClassAnnotation($refClass, Service::class); - if ($service === null) { - return false; - } - - return true; - } - - /** - * @param string $requestedName - */ - public function __invoke(ContainerInterface $container, $requestedName, ?array $options = null): object - { - $factory = new AnnotatedServiceFactory(); - $factory->setAnnotationReader($this->createAnnotationReader($container)); - - return $factory->createObject($container, $requestedName); - } -} diff --git a/src/Factory/AnnotatedServiceFactory.php b/src/Factory/AnnotatedServiceFactory.php deleted file mode 100644 index a75c2ea..0000000 --- a/src/Factory/AnnotatedServiceFactory.php +++ /dev/null @@ -1,141 +0,0 @@ -createObject($container, $requestedName); - } - - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - * @throws ReflectionException - */ - public function createObject(ContainerInterface $container, string $requestedName): mixed - { - if (! class_exists($requestedName)) { - throw RuntimeException::classNotFound($requestedName); - } - - $service = null; - - $annotationReader = $this->createAnnotationReader($container); - $refClass = $this->getReflectionClass($requestedName); - $constructor = $refClass->getConstructor(); - - if ($constructor === null) { - $service = new $requestedName(); - } else { - $inject = $annotationReader->getMethodAnnotation($constructor, Inject::class); - if ($inject === null && $constructor->getNumberOfRequiredParameters() > 0) { - throw RuntimeException::annotationNotFound( - Inject::class, - $requestedName, - static::class - ); - } - - $services = []; - if ($inject) { - $services = $this->getServicesToInject($container, $inject); - } - - $service = new $requestedName(...$services); - } - - $methods = $refClass->getMethods(ReflectionMethod::IS_PUBLIC); - foreach ($methods as $method) { - $inject = $annotationReader->getMethodAnnotation($method, Inject::class); - if ($inject) { - $services = $this->getServicesToInject($container, $inject); - $method->invoke($service, ...$services); - } - } - - return $service; - } - - /** - * @throws ContainerExceptionInterface - * @throws NotFoundExceptionInterface - */ - protected function getServicesToInject(ContainerInterface $container, Inject $inject): array - { - $services = []; - foreach ($inject->getServices() as $serviceKey) { - $parts = explode('.', $serviceKey); - // Even when dots are found, try to find a service with the full name - // If it is not found, then assume dots are used to get part of an array service - if (count($parts) > 1 && ! $container->has($serviceKey)) { - $serviceKey = array_shift($parts); - } else { - $parts = []; - } - - if ($container->has($serviceKey)) { - $service = $container->get($serviceKey); - } elseif (class_exists($serviceKey)) { - $service = new $serviceKey(); - } else { - throw RuntimeException::classNotFound($serviceKey); - } - - $services[] = empty($parts) ? $service : $this->readKeysFromArray($parts, $service); - } - - return $services; - } - - protected function readKeysFromArray(array $keys, mixed $array): mixed - { - $key = array_shift($keys); - // When one of the provided keys is not found, throw an exception - if (! isset($array[$key])) { - throw new InvalidArgumentException(sprintf( - 'The key "%s" provided in the dotted notation could not be found in the array service', - $key - )); - } - $value = $array[$key]; - if (! empty($keys) && (is_array($value) || $value instanceof ArrayAccess)) { - $value = $this->readKeysFromArray($keys, $value); - } - return $value; - } - - /** - * @throws ReflectionException - */ - protected function getReflectionClass(string $requestedName): ReflectionClass - { - return new ReflectionClass($requestedName); - } -} diff --git a/src/Factory/AnnotatedRepositoryFactory.php b/src/Factory/AttributedRepositoryFactory.php similarity index 57% rename from src/Factory/AnnotatedRepositoryFactory.php rename to src/Factory/AttributedRepositoryFactory.php index 45fec3e..f80b2d5 100644 --- a/src/Factory/AnnotatedRepositoryFactory.php +++ b/src/Factory/AttributedRepositoryFactory.php @@ -6,23 +6,20 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; -use Dot\AnnotatedServices\Annotation\Entity; +use Dot\AnnotatedServices\Attribute\Entity; use Dot\AnnotatedServices\Exception\RuntimeException; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use ReflectionClass; -use ReflectionException; use function class_exists; -class AnnotatedRepositoryFactory extends AbstractAnnotatedFactory +class AttributedRepositoryFactory { /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface - * @throws ReflectionException - * @throws RuntimeException */ public function __invoke(ContainerInterface $container, string $requestedName): EntityRepository { @@ -32,7 +29,6 @@ public function __invoke(ContainerInterface $container, string $requestedName): /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface - * @throws ReflectionException */ public function createObject(ContainerInterface $container, string $requestedName): EntityRepository { @@ -42,16 +38,26 @@ public function createObject(ContainerInterface $container, string $requestedNam $reflectionClass = new ReflectionClass($requestedName); if (! $reflectionClass->isSubclassOf(EntityRepository::class)) { - throw RuntimeException::doesNotExtend(EntityRepository::class); + throw RuntimeException::doesNotExtend($requestedName, EntityRepository::class); } - $annotationReader = $this->createAnnotationReader($container); - $entity = $annotationReader->getClassAnnotation($reflectionClass, Entity::class); - if (! $entity) { - throw RuntimeException::annotationNotFound(Entity::class, $requestedName, static::class); + $entityAttribute = $this->findEntityAttribute($reflectionClass); + if (! $entityAttribute instanceof Entity) { + throw RuntimeException::attributeNotFound(Entity::class, $requestedName, static::class); } - $entityManager = $container->get(EntityManagerInterface::class); - return $entityManager->getRepository($entity->getName()); + return $container->get(EntityManagerInterface::class)->getRepository($entityAttribute->getName()); + } + + protected function findEntityAttribute(ReflectionClass $reflectionClass): ?Entity + { + $attributes = $reflectionClass->getAttributes(); + foreach ($attributes as $attribute) { + if ($attribute->getName() === Entity::class) { + return $attribute->newInstance(); + } + } + + return null; } } diff --git a/src/Factory/AttributedServiceFactory.php b/src/Factory/AttributedServiceFactory.php new file mode 100644 index 0000000..e51bdc4 --- /dev/null +++ b/src/Factory/AttributedServiceFactory.php @@ -0,0 +1,140 @@ +createObject($container, $requestedName); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function createObject(ContainerInterface $container, string $requestedName): mixed + { + if (! class_exists($requestedName)) { + throw RuntimeException::classNotFound($requestedName); + } + + $constructor = (new ReflectionClass($requestedName))->getConstructor(); + if ($constructor === null) { + return new $requestedName(); + } + + $injectAttribute = $this->findInjectAttribute($constructor); + if (! $injectAttribute instanceof Inject) { + throw RuntimeException::attributeNotFound(Inject::class, $requestedName, static::class); + } + + if (in_array($requestedName, $injectAttribute->getServices(), true)) { + throw RuntimeException::recursiveInject($requestedName); + } + + $services = $this->getServicesToInject($container, $injectAttribute->getServices()); + + return new $requestedName(...$services); + } + + protected function findInjectAttribute(ReflectionMethod $constructor): ?Inject + { + $attributes = $constructor->getAttributes(); + foreach ($attributes as $attribute) { + if ($attribute->getName() === Inject::class) { + return $attribute->newInstance(); + } + } + + return null; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + protected function getServicesToInject(ContainerInterface $container, array $parameters): array + { + $services = []; + + foreach ($parameters as $parameter) { + $services[] = $this->getServiceToInject($container, $parameter); + } + + return $services; + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + protected function getServiceToInject(ContainerInterface $container, string $serviceKey): mixed + { + $this->originalKey = $serviceKey; + + /** + * Even when dots are found, try to find a service with the full name. + * If it is not found, then assume dots are used to get part of an array service + */ + $parts = explode('.', $serviceKey); + if (count($parts) > 1 && ! $container->has($serviceKey)) { + $serviceKey = array_shift($parts); + } else { + $parts = []; + } + + if ($container->has($serviceKey)) { + $service = $container->get($serviceKey); + } elseif (class_exists($serviceKey)) { + $service = new $serviceKey(); + } else { + throw RuntimeException::classNotFound($serviceKey); + } + + return empty($parts) ? $service : $this->readKeysFromArray($parts, $service); + } + + protected function readKeysFromArray(array $keys, mixed $array): mixed + { + $key = array_shift($keys); + if (! isset($array[$key])) { + throw new InvalidArgumentException( + sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, $this->originalKey) + ); + } + + $value = $array[$key]; + if (! empty($keys) && (is_array($value) || $value instanceof ArrayAccess)) { + $value = $this->readKeysFromArray($keys, $value); + } + + return $value; + } +} diff --git a/test/AnnotatedRepositoryFactoryTest.php b/test/AnnotatedRepositoryFactoryTest.php deleted file mode 100644 index 3220350..0000000 --- a/test/AnnotatedRepositoryFactoryTest.php +++ /dev/null @@ -1,92 +0,0 @@ -container = $this->createMock(ContainerInterface::class); - $this->annotationReader = $this->createMock(Reader::class); - $this->subject = $this->createPartialMock(Subject::class, ['createAnnotationReader']); - } - - public function testThrowsExceptionClassNotFound() - { - $requestedName = 'TestRepository'; - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::classNotFound($requestedName)->getMessage()); - - $this->subject->__invoke($this->container, $requestedName); - } - - public function testThrowsExceptionClassNotExtendsEntityRepository() - { - $requestedName = 'TestRepository'; - - $this->getMockBuilder($requestedName)->getMock(); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::doesNotExtend(EntityRepository::class)->getMessage()); - $this->subject->__invoke($this->container, $requestedName); - } - - public function testCreateObjectThrowsExceptionAnnotationNotFound() - { - $repository = $this->createMock(EntityRepository::class); - $this->annotationReader->method('getClassAnnotation')->willReturn(null); - - $this->subject->method('createAnnotationReader')->willReturn($this->annotationReader); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::annotationNotFound( - Entity::class, - $repository::class, - get_class($this->subject) - )->getMessage()); - - $this->subject->__invoke($this->container, $repository::class); - } - - public function testCreateObjectReturnsEntityRepository() - { - $repository = $this->createMock(EntityRepository::class); - $annotation = new Entity('test'); - $entityManager = $this->createMock(EntityManagerInterface::class); - - $entityManager->method('getRepository')->willReturn($repository); - - $this->annotationReader->method('getClassAnnotation')->willReturn($annotation); - - $this->container->method('get') - ->with(EntityManagerInterface::class) - ->willReturn($entityManager); - - $this->subject - ->method('createAnnotationReader') - ->willReturn($this->annotationReader); - - $object = $this->subject->__invoke($this->container, $repository::class); - - $this->assertInstanceOf(EntityRepository::class, $object); - } -} diff --git a/test/AnnotatedServiceFactoryTest.php b/test/AnnotatedServiceFactoryTest.php deleted file mode 100644 index 9578f8a..0000000 --- a/test/AnnotatedServiceFactoryTest.php +++ /dev/null @@ -1,113 +0,0 @@ -container = $this->createMock(ContainerInterface::class); - $this->annotationReader = $this->createMock(Reader::class); - $this->subject = $this->createPartialMock(Subject::class, [ - 'createAnnotationReader', - 'getReflectionClass', - ]); - } - - public function testThrowsExceptionClassNotFound() - { - $requestedName = 'TestService'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::classNotFound($requestedName)->getMessage()); - - $this->subject->__invoke($this->container, $requestedName); - } - - public function testReturnServiceWithNoDependencies() - { - $requestedName = 'TestService'; - $this->getMockBuilder($requestedName)->allowMockingUnknownTypes()->getMock(); - $refClass = $this->createMock(ReflectionClass::class); - - $refClass->method('getConstructor')->willReturn(null); - $refClass->method('getMethods')->willReturn([]); - - $this->annotationReader->method('getMethodAnnotation')->willReturn(null); - $this->subject - ->method('createAnnotationReader') - ->willReturn($this->annotationReader); - $this->subject->method('getReflectionClass')->willReturn($refClass); - - $object = $this->subject->__invoke($this->container, $requestedName); - - $this->assertInstanceOf($requestedName, $object); - } - - public function testThrowsExceptionAnnotationNotFound() - { - $requestedName = 'TestService'; - $this->getMockBuilder($requestedName)->allowMockingUnknownTypes()->getMock(); - $refClass = $this->createMock(ReflectionClass::class); - $refConstructor = $this->createMock(ReflectionMethod::class); - - $refClass->method('getConstructor')->willReturn($refConstructor); - $refConstructor->method('getNumberOfRequiredParameters')->willReturn(100); - - $this->annotationReader->method('getMethodAnnotation')->willReturn(null); - $this->subject - ->method('createAnnotationReader') - ->willReturn($this->annotationReader); - $this->subject->method('getReflectionClass')->willReturn($refClass); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage(RuntimeException::annotationNotFound( - Inject::class, - $requestedName, - get_class($this->subject), - )->getMessage()); - - $this->subject->__invoke($this->container, $requestedName); - } - - public function testReturnService() - { - $requestedName = 'TestService'; - $this->getMockBuilder($requestedName)->allowMockingUnknownTypes()->getMock(); - $refClass = $this->createMock(ReflectionClass::class); - $refConstructor = $this->createMock(ReflectionMethod::class); - - $refClass->method('getConstructor')->willReturn($refConstructor); - $refClass->method('getMethods')->willReturn([]); - $refConstructor->method('getNumberOfRequiredParameters')->willReturn(1); - - $inject = new Inject(['test']); - $this->annotationReader->method('getMethodAnnotation')->willReturn($inject); - - $this->subject->method('createAnnotationReader')->willReturn($this->annotationReader); - $this->subject->method('getReflectionClass')->willReturn($refClass); - - $service = $this->subject->__invoke($this->container, $requestedName); - - $this->assertInstanceOf($requestedName, $service); - } -} diff --git a/test/ConfigProviderTest.php b/test/ConfigProviderTest.php index 536a1c8..11e0a8d 100644 --- a/test/ConfigProviderTest.php +++ b/test/ConfigProviderTest.php @@ -5,7 +5,6 @@ namespace DotTest\AnnotatedServices; use Dot\AnnotatedServices\ConfigProvider; -use Dot\AnnotatedServices\Factory\AnnotatedServiceAbstractFactory; use PHPUnit\Framework\TestCase; class ConfigProviderTest extends TestCase @@ -17,17 +16,8 @@ protected function setup(): void $this->config = (new ConfigProvider())(); } - public function testHasDependencies(): void + public function testConfigIsEmpty(): void { - $this->assertArrayHasKey('dependencies', $this->config); - } - - public function testDependenciesHasFactories(): void - { - $this->assertArrayHasKey('abstract_factories', $this->config['dependencies']); - $this->assertContainsEquals( - AnnotatedServiceAbstractFactory::class, - $this->config['dependencies']['abstract_factories'] - ); + $this->assertEmpty($this->config); } } diff --git a/test/Factory/AttributedRepositoryFactoryTest.php b/test/Factory/AttributedRepositoryFactoryTest.php new file mode 100644 index 0000000..20c0aaf --- /dev/null +++ b/test/Factory/AttributedRepositoryFactoryTest.php @@ -0,0 +1,119 @@ +createMock(ContainerInterface::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf(RuntimeException::MESSAGE_CLASS_NOT_FOUND, 'test') + ); + + (new AttributedRepositoryFactory())($container, 'test'); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfRepositoryDoesNotExtendEntityRepository(): void + { + $container = $this->createMock(ContainerInterface::class); + + $subject = new class { + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf(RuntimeException::MESSAGE_DOES_NOT_EXTEND, $subject::class, EntityRepository::class) + ); + + (new AttributedRepositoryFactory())($container, $subject::class); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfAttributeNotFound(): void + { + $container = $this->createMock(ContainerInterface::class); + $entityManager = $this->createMock(EntityManagerInterface::class); + + $entity = new class { + }; + $metadata = new ClassMetadata($entity::class); + $subject = new class ($entityManager, $metadata) extends EntityRepository { + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf( + RuntimeException::MESSAGE_ATTRIBUTE_NOT_FOUND, + Entity::class, + $subject::class, + AttributedRepositoryFactory::class + ) + ); + + (new AttributedRepositoryFactory())($container, $subject::class); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws Exception + */ + public function testWillCreateRepository(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $container = $this->createMock(ContainerInterface::class); + + $entity = new TestEntity(); + $metadata = new ClassMetadata($entity::class); + $subject = new TestRepository($entityManager, $metadata); + + $container + ->expects($this->once()) + ->method('get') + ->with(EntityManagerInterface::class) + ->willReturn($entityManager); + $entityManager + ->expects($this->once()) + ->method('getRepository') + ->with(TestEntity::class) + ->willReturn($subject); + + $repository = (new AttributedRepositoryFactory())($container, TestRepository::class); + $this->assertInstanceOf(TestRepository::class, $repository); + } +} diff --git a/test/Factory/AttributedServiceFactoryTest.php b/test/Factory/AttributedServiceFactoryTest.php new file mode 100644 index 0000000..d2e7b3b --- /dev/null +++ b/test/Factory/AttributedServiceFactoryTest.php @@ -0,0 +1,212 @@ +createMock(ContainerInterface::class); + + $subject = 'test'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf(RuntimeException::MESSAGE_CLASS_NOT_FOUND, $subject) + ); + + (new AttributedServiceFactory())($container, $subject); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfAttributeNotFound(): void + { + $container = $this->createMock(ContainerInterface::class); + + $subject = new class { + public function __construct() + { + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf( + RuntimeException::MESSAGE_ATTRIBUTE_NOT_FOUND, + Inject::class, + $subject::class, + AttributedServiceFactory::class + ) + ); + + (new AttributedServiceFactory())($container, $subject::class); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionOnRecursiveInjection(): void + { + $container = $this->createMock(ContainerInterface::class); + + $subject = new RecursionService(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf( + RuntimeException::MESSAGE_RECURSIVE_INJECT, + $subject::class + ) + ); + + (new AttributedServiceFactory())($container, $subject::class); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfDottedServiceNotFound(): void + { + $mapping = [ + 'config' => [ + 'uration' => [ + 'test' => [], + ], + ], + 'uration' => [ + 'test' => [], + ], + 'key' => [], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + function (string $key) use ($mapping): bool { + return array_key_exists($key, $mapping); + }, + ); + $container->expects($this->any())->method('get')->willReturnCallback( + function (string $key) use ($mapping): array { + return $mapping[$key] ?? []; + }, + ); + + $subject = new class + { + #[Inject('config.uration.key')] + public function __construct(array $config = []) + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf(InvalidArgumentException::MESSAGE_MISSING_KEY, 'config.uration.key') + ); + + (new AttributedServiceFactory())($container, $subject::class); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillThrowExceptionIfDependencyNotFound(): void + { + $container = $this->createMock(ContainerInterface::class); + + $subject = new class + { + #[Inject('test')] + public function __construct(mixed $test = null) + { + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + sprintf(RuntimeException::MESSAGE_CLASS_NOT_FOUND, 'test') + ); + + (new AttributedServiceFactory())($container, $subject::class); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillCreateServiceIfNoConstructor(): void + { + $container = $this->createMock(ContainerInterface::class); + + $subject = new class { + }; + + $service = (new AttributedServiceFactory())($container, $subject::class); + $this->assertInstanceOf($subject::class, $service); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testWillCreateService(): void + { + $mapping = [ + 'config' => [ + 'uration' => [], + ], + 'uration' => [], + ]; + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->any())->method('has')->willReturnCallback( + function (string $key) use ($mapping): bool { + return array_key_exists($key, $mapping); + }, + ); + $container->expects($this->any())->method('get')->willReturnCallback( + function (string $key) use ($mapping): array { + return $mapping[$key] ?? []; + }, + ); + + $subject = new ValidService(); + + $service = (new AttributedServiceFactory())($container, $subject::class); + $this->assertInstanceOf(ValidService::class, $service); + } +} diff --git a/test/TestData/Entity.php b/test/TestData/Entity.php new file mode 100644 index 0000000..a0352dd --- /dev/null +++ b/test/TestData/Entity.php @@ -0,0 +1,12 @@ + + */ +#[Entity(name: TestEntity::class)] +class Repository extends EntityRepository +{ +} diff --git a/test/TestData/ValidService.php b/test/TestData/ValidService.php new file mode 100644 index 0000000..8dc8f9a --- /dev/null +++ b/test/TestData/ValidService.php @@ -0,0 +1,19 @@ +