diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f9ea038..fc28b27 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,7 +20,7 @@ jobs: - php: '8.0' upload_coverage: true has_unique_phpspec_tests: true - container: ghcr.io/articus/phpdbg-coveralls:${{ matrix.php }}_2.4.3_2021-10-23 + container: ghcr.io/articus/phpdbg-coveralls:${{ matrix.php }}_2.5.1_2021-12-05 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/docs/attributing.md b/docs/attributing.md index 99e2db2..4619541 100644 --- a/docs/attributing.md +++ b/docs/attributing.md @@ -248,9 +248,10 @@ class Handler ``` ## Build-in attributes -Library provides two attributes out of the box: +Library provides three attributes out of the box: - `IdentifiableValueLoad` that uses [Data Transfer library](https://github.com/Articus/DataTransfer) to load some value by its identifier stored in request attribute +- `IdentifiableValueListLoad` that uses [Data Transfer library](https://github.com/Articus/DataTransfer) to load list of values by their identifiers stored in request - `Transfer` that uses [Data Transfer library](https://github.com/Articus/DataTransfer) to construct DTO and fill it with request data only if this data is valid. ### `IdentifiableValueLoad` usage @@ -279,7 +280,7 @@ class LoaderFactory return $result; } ] - ]) + ]); } } ``` @@ -329,6 +330,101 @@ class Handler For details see available options: `Articus\PathHandler\Attribute\Options\IdentifiableValueLoad`. +### `IdentifiableValueListLoad` usage + +Add `Articus\DataTransfer\IdentifiableValueLoader` service inside your container, for example with a factory like: + +```PHP +namespace My; + +use Articus\DataTransfer\IdentifiableValueLoader; +use Psr\Container\ContainerInterface; + +class LoaderFactory +{ + public function __invoke(ContainerInterface $container) + { + return new IdentifiableValueLoader([ + "entity_type" => [ + static function (EntityClass $value) { + return $value->getId();//...or any other ay to get id from EntityClass instance + }, + static function (array $ids) { + /** @var EntityClass[] $result */ + $result = [] + // load EntityClass instances for specified ids from database, external service, etc... + return $result; + } + ] + ]); + } +} +``` + +Then provide custom callable service inside your container for emitting identifiers from request, for example `entity_id_emitter` with a factory like: + +```PHP +namespace My; + +use Psr\Container\ContainerInterface; +use Psr\Http\Message\ServerRequestInterface as Request; + +class EmitterFactory +{ + public function __invoke(ContainerInterface $container) + { + return static function (string $type, Request $request)/*: array */ + { + return $request->getAttribute('attr_with_array_of_ids'); + }; + } +} +``` + +And then just add attribute to your handler: + +```PHP +namespace My; + +use Articus\PathHandler\Annotation as PHA; +use Psr\Http\Message\ServerRequestInterface; + +/** + * @PHA\Route(pattern="/entities") + * @PHA\Attribute(name="IdentifiableValueListLoad", options={"id_emitter":"entity_id_emitter","type":"entity_type","list_attr":"entity_list"}) + */ +class Handler +{ + /** + * @PHA\Get() + */ + public function handleGet(ServerRequestInterface $request) + { + /** @var EntityClass[] $entities */ + $entities = $request->getAttribute('entity_list');//This attribute will store list of loaded identifiable values + } +} +``` +```PHP +namespace My; + +use Articus\PathHandler\PhpAttribute as PHA; +use Psr\Http\Message\ServerRequestInterface; + +#[PHA\Route("/entity/{entity_id:[1-9][0-9]*}")] +#[PHA\Attribute("IdentifiableValueLoad", ["id_emitter" => "entity_id_emitter", "type" => "entity_type", "list_attr" => "entity_list"]) +class Handler +{ + #[PHA\Get()] + public function handleGet(ServerRequestInterface $request) + { + /** @var EntityClass[] $entities */ + $entities = $request->getAttribute('entity_list');//This attribute will store list of loaded identifiable values + } +} +``` + +For details see available options: `Articus\PathHandler\Attribute\Options\IdentifiableValueListLoad`. ### `Transfer` usage diff --git a/spec/Articus/PathHandler/Attribute/Factory/IdentifiableValueListLoadSpec.php b/spec/Articus/PathHandler/Attribute/Factory/IdentifiableValueListLoadSpec.php new file mode 100644 index 0000000..58285dd --- /dev/null +++ b/spec/Articus/PathHandler/Attribute/Factory/IdentifiableValueListLoadSpec.php @@ -0,0 +1,48 @@ + $type, + 'identifierEmitter' => 'test_emitter', + ]; + $emitter = static function (string $type, Request $request) + { + return $request->getAttribute('ids'); + }; + + $container->get(IdentifiableValueLoader::class)->shouldBeCalledOnce()->willReturn($loader); + $container->get('test_emitter')->shouldBeCalledOnce()->willReturn($emitter); + $loader->wishMultiple($type, $ids)->shouldBeCalledOnce(); + $loader->get($type, $ids[0])->shouldBeCalledOnce()->willReturn($values[0]); + $loader->get($type, $ids[1])->shouldBeCalledOnce()->willReturn($values[1]); + $loader->get($type, $ids[2])->shouldBeCalledOnce()->willReturn($values[2]); + $in->getAttribute('ids')->shouldBeCalledOnce()->willReturn($ids); + $in->withAttribute('list', $values)->shouldBeCalledOnce()->willReturn($out); + + /** @var Subject $wrapper */ + $wrapper = $this->__invoke($container, 'test', $options); + $wrapper->shouldBeAnInstanceOf(PH\Attribute\IdentifiableValueListLoad::class); + $wrapper->callOnWrappedObject('__invoke', [$in])->shouldBe($out); + } +} diff --git a/spec/Articus/PathHandler/Attribute/IdentifiableValueListLoadSpec.php b/spec/Articus/PathHandler/Attribute/IdentifiableValueListLoadSpec.php new file mode 100644 index 0000000..8e97612 --- /dev/null +++ b/spec/Articus/PathHandler/Attribute/IdentifiableValueListLoadSpec.php @@ -0,0 +1,145 @@ + $id) + { + $tuple = yield; + if ($tuple !== [$index, $id, $values[$index]]) + { + throw new \LogicException('Invalid tuple'); + } + } + return $list; + }; + $receiverFactoryArgAttrs = []; + $listAttr = 'test_list_attr'; + + $loader->wishMultiple($type, $ids)->shouldBeCalledOnce(); + $loader->get($type, $ids[0])->shouldBeCalledOnce()->willReturn($values[0]); + $loader->get($type, $ids[1])->shouldBeCalledOnce()->willReturn($values[1]); + $loader->get($type, $ids[2])->shouldBeCalledOnce()->willReturn($values[2]); + $emitter->__invoke($type, $in)->shouldBeCalledOnce()->willReturn($ids); + $receiverFactory->__invoke($type, $in)->shouldBeCalledOnce()->willReturn($receiver()); + $in->withAttribute($listAttr, $list)->shouldBeCalledOnce()->willReturn($out); + + $this->beConstructedWith($loader, $type, $emitter, $emitterArgAttrs, $receiverFactory, $receiverFactoryArgAttrs, $listAttr); + $this->__invoke($in)->shouldBe($out); + } + + public function it_loads_and_stores_values_if_emitter_and_receiver_have_arg_attrs( + IdentifiableValueLoader $loader, + Invokable $emitter, + Invokable $receiverFactory, + $list, + Request $in, + Request $out + ) + { + $type = 'test_type'; + $ids = [123, 456, 789]; + $values = ['abc', 'def', 'ghi']; + $emitterArgAttrs = ['test_e_arg_attr1', 'test_e_arg_attr2']; + $emitterArgs = ['test_e_arg1', 'test_e_arg2']; + $receiver = static function () use ($ids, $values, &$list) + { + foreach ($ids as $index => $id) + { + $tuple = yield; + if ($tuple !== [$index, $id, $values[$index]]) + { + throw new \LogicException('Invalid tuple'); + } + } + return $list; + }; + $receiverFactoryArgAttrs = ['test_rf_arg_attr1', 'test_rf_arg_attr2']; + $receiverFactoryArgs = ['test_rf_arg1', 'test_rf_arg2']; + $listAttr = 'test_list_attr'; + + $loader->wishMultiple($type, $ids)->shouldBeCalledOnce(); + $loader->get($type, $ids[0])->shouldBeCalledOnce()->willReturn($values[0]); + $loader->get($type, $ids[1])->shouldBeCalledOnce()->willReturn($values[1]); + $loader->get($type, $ids[2])->shouldBeCalledOnce()->willReturn($values[2]); + $emitter->__invoke($type, $emitterArgs[0], $emitterArgs[1])->shouldBeCalledOnce()->willReturn($ids); + $receiverFactory->__invoke($type, $receiverFactoryArgs[0], $receiverFactoryArgs[1])->shouldBeCalledOnce()->willReturn($receiver()); + $in->getAttribute($emitterArgAttrs[0])->shouldBeCalledOnce()->willReturn($emitterArgs[0]); + $in->getAttribute($emitterArgAttrs[1])->shouldBeCalledOnce()->willReturn($emitterArgs[1]); + $in->getAttribute($receiverFactoryArgAttrs[0])->shouldBeCalledOnce()->willReturn($receiverFactoryArgs[0]); + $in->getAttribute($receiverFactoryArgAttrs[1])->shouldBeCalledOnce()->willReturn($receiverFactoryArgs[1]); + $in->withAttribute($listAttr, $list)->shouldBeCalledOnce()->willReturn($out); + + $this->beConstructedWith($loader, $type, $emitter, $emitterArgAttrs, $receiverFactory, $receiverFactoryArgAttrs, $listAttr); + $this->__invoke($in)->shouldBe($out); + } + + public function it_throws_if_there_is_no_value_for_one_of_ids( + IdentifiableValueLoader $loader, + Invokable $emitter, + Invokable $receiverFactory, + $list, + Request $in, + Request $out + ) + { + $type = 'test_type'; + $ids = [12, 34, 56, 78]; + $values = ['abc', null, 'def', null]; + $emitterArgAttrs = []; + $receiver = static function () use ($ids, $values, &$list) + { + foreach ($ids as $index => $id) + { + if ($values[$index] !== null) + { + $tuple = yield; + if ($tuple !== [$index, $id, $values[$index]]) + { + throw new \LogicException('Invalid tuple'); + } + } + } + return $list; + }; + $receiverFactoryArgAttrs = []; + $listAttr = 'test_list_attr'; + $error = new PH\Exception\UnprocessableEntity([ + 'unknownIdentifiers' => 'Unknown identifier(s): 34, 78' + ]); + + $loader->wishMultiple($type, $ids)->shouldBeCalledOnce(); + $loader->get($type, $ids[0])->shouldBeCalledOnce()->willReturn($values[0]); + $loader->get($type, $ids[1])->shouldBeCalledOnce()->willReturn($values[1]); + $loader->get($type, $ids[2])->shouldBeCalledOnce()->willReturn($values[2]); + $loader->get($type, $ids[3])->shouldBeCalledOnce()->willReturn($values[3]); + $emitter->__invoke($type, $in)->shouldBeCalledOnce()->willReturn($ids); + $receiverFactory->__invoke($type, $in)->shouldBeCalledOnce()->willReturn($receiver()); + + $this->beConstructedWith($loader, $type, $emitter, $emitterArgAttrs, $receiverFactory, $receiverFactoryArgAttrs, $listAttr); + $this->shouldThrow($error)->during('__invoke', [$in]); + } +} diff --git a/spec/Articus/PathHandler/Attribute/Options/IdentifiableValueListLoadSpec.php b/spec/Articus/PathHandler/Attribute/Options/IdentifiableValueListLoadSpec.php new file mode 100644 index 0000000..0aff3c0 --- /dev/null +++ b/spec/Articus/PathHandler/Attribute/Options/IdentifiableValueListLoadSpec.php @@ -0,0 +1,84 @@ + 'test_type', + 'identifierEmitter' => 'test_emitter_attr', + 'identifierEmitterArgAttrs' => ['test_ieaa_1', 'test_ieaa_2'], + 'valueReceiverFactory' => 'test_receiver_factory', + 'valueReceiverFactoryArgAttrs' => ['test_vrfaa_1', 'test_vrfaa_2'], + 'valueListAttr' => 'test_list', + ]; + $this->beConstructedWith($options); + $this->shouldBeAnInstanceOf(PH\Attribute\Options\IdentifiableValueListLoad::class); + } + + public function it_constructs_with_snake_case_option_names() + { + $options = [ + 'type' => 'test_type', + 'identifier_emitter' => 'test_emitter_attr', + 'identifier_emitter_arg_attrs' => ['test_ieaa_1', 'test_ieaa_2'], + 'value_receiver_factory' => 'test_receiver_factory', + 'value_receiver_factory_arg_attrs' => ['test_vrfaa_1', 'test_vrfaa_2'], + 'value_list_attr' => 'test_list', + ]; + $this->beConstructedWith($options); + $this->shouldBeAnInstanceOf(PH\Attribute\Options\IdentifiableValueListLoad::class); + } + + public function it_constructs_with_camel_case_option_aliases() + { + $options = [ + 'type' => 'test_type', + 'idEmitter' => 'test_emitter_attr', + 'idEmitterArgAttrs' => ['test_ieaa_1', 'test_ieaa_2'], + 'valueReceiverFactory' => 'test_receiver_factory', + 'valueReceiverFactoryArgAttrs' => ['test_vrfaa_1', 'test_vrfaa_2'], + 'listAttr' => 'test_list', + ]; + $this->beConstructedWith($options); + $this->shouldBeAnInstanceOf(PH\Attribute\Options\IdentifiableValueListLoad::class); + } + + public function it_constructs_with_snake_case_option_aliases() + { + $options = [ + 'type' => 'test_type', + 'id_emitter' => 'test_emitter_attr', + 'id_emitter_arg_attrs' => ['test_ieaa_1', 'test_ieaa_2'], + 'value_receiver_factory' => 'test_receiver_factory', + 'value_receiver_factory_arg_attrs' => ['test_vrfaa_1', 'test_vrfaa_2'], + 'list_attr' => 'test_list', + ]; + $this->beConstructedWith($options); + $this->shouldBeAnInstanceOf(PH\Attribute\Options\IdentifiableValueListLoad::class); + } + + public function it_throws_it_there_is_no_type_option() + { + $options = []; + $exception = new \LogicException('Option "type" is not set'); + $this->beConstructedWith($options); + $this->shouldThrow($exception)->duringInstantiation(); + } + + public function it_throws_it_there_is_no_id_emitter_option() + { + $options = [ + 'type' => 'test_type', + ]; + $exception = new \LogicException('Option "identifierEmitter" is not set'); + $this->beConstructedWith($options); + $this->shouldThrow($exception)->duringInstantiation(); + } +} diff --git a/src/Articus/PathHandler/Attribute/Factory/IdentifiableValueListLoad.php b/src/Articus/PathHandler/Attribute/Factory/IdentifiableValueListLoad.php new file mode 100644 index 0000000..3937a53 --- /dev/null +++ b/src/Articus/PathHandler/Attribute/Factory/IdentifiableValueListLoad.php @@ -0,0 +1,50 @@ +valueReceiverFactory === null) ? self::$defaultValueReceiverFactory : $container->get($options->valueReceiverFactory); + $result = new PH\Attribute\IdentifiableValueListLoad( + $container->get(IdentifiableValueLoader::class), + $options->type, + $container->get($options->identifierEmitter), + $options->identifierEmitterArgAttrs, + $valueReceiverFactory, + $options->valueReceiverFactoryArgAttrs, + $options->valueListAttr + ); + return $result; + } +} diff --git a/src/Articus/PathHandler/Attribute/IdentifiableValueListLoad.php b/src/Articus/PathHandler/Attribute/IdentifiableValueListLoad.php new file mode 100644 index 0000000..8e87629 --- /dev/null +++ b/src/Articus/PathHandler/Attribute/IdentifiableValueListLoad.php @@ -0,0 +1,142 @@ + + */ + protected $identifierEmitter; + + /** + * @var string[] + */ + protected $identifierEmitterArgAttrs; + + /** + * @var callable(string, mixed...): \Generator + */ + protected $valueReceiverFactory; + + /** + * @var string[] + */ + protected $valueReceiverFactoryArgAttrs; + + /** + * @var string + */ + protected $valueListAttr; + + /** + * @param IdentifiableValueLoader $loader + * @param string $type + * @param callable $identifierEmitter + * @param string[] $identifierEmitterArgAttrs + * @param callable $valueReceiverFactory + * @param string[] $valueReceiverFactoryArgAttrs + * @param string $valueListAttr + */ + public function __construct( + IdentifiableValueLoader $loader, + string $type, + callable $identifierEmitter, + array $identifierEmitterArgAttrs, + callable $valueReceiverFactory, + array $valueReceiverFactoryArgAttrs, + string $valueListAttr + ) + { + $this->loader = $loader; + $this->type = $type; + $this->identifierEmitter = $identifierEmitter; + $this->identifierEmitterArgAttrs = $identifierEmitterArgAttrs; + $this->valueReceiverFactory = $valueReceiverFactory; + $this->valueReceiverFactoryArgAttrs = $valueReceiverFactoryArgAttrs; + $this->valueListAttr = $valueListAttr; + } + + /** + * @param Request $request + * @return Request + * @throws Exception\UnprocessableEntity + */ + public function __invoke(Request $request): Request + { + $ids = $this->getIdentifiers($request); + $unknownIds = []; + $valueReceiver = $this->getValueReceiver($request); + + $this->loader->wishMultiple($this->type, $ids); + foreach ($ids as $idIndex => $id) + { + $value = $this->loader->get($this->type, $id); + if ($value === null) + { + $unknownIds[$idIndex] = $id; + } + else + { + $valueReceiver->send([$idIndex, $id, $value]); + } + } + $valueReceiver->send(null); + + if (!empty($unknownIds)) + { + throw new Exception\UnprocessableEntity([ + 'unknownIdentifiers' => \sprintf('Unknown identifier(s): %s', \implode(', ', $unknownIds)) + ]); + } + + return $request->withAttribute($this->valueListAttr, $valueReceiver->getReturn()); + } + + protected function getIdentifiers(Request $request): array + { + $emitterArgs = [$request]; + if (!empty($this->identifierEmitterArgAttrs)) + { + $emitterArgs = []; + foreach ($this->identifierEmitterArgAttrs as $emitterArgAttr) + { + $emitterArgs[] = $request->getAttribute($emitterArgAttr); + } + } + return ($this->identifierEmitter)($this->type, ...$emitterArgs); + } + + protected function getValueReceiver(Request $request): \Generator + { + $receiverArgs = [$request]; + if (!empty($this->valueReceiverFactoryArgAttrs)) + { + $receiverArgs = []; + foreach ($this->valueReceiverFactoryArgAttrs as $receiverArgAttr) + { + $receiverArgs[] = $request->getAttribute($receiverArgAttr); + } + } + return ($this->valueReceiverFactory)($this->type, ...$receiverArgs); + } +} diff --git a/src/Articus/PathHandler/Attribute/Options/IdentifiableValueListLoad.php b/src/Articus/PathHandler/Attribute/Options/IdentifiableValueListLoad.php new file mode 100644 index 0000000..fb970f2 --- /dev/null +++ b/src/Articus/PathHandler/Attribute/Options/IdentifiableValueListLoad.php @@ -0,0 +1,98 @@ + is expected. + * @var string + */ + public $identifierEmitter; + + /** + * Names of request attributes that should be passed to identifier emitter. + * If it is empty whole request is passed. + * @var string[] + */ + public $identifierEmitterArgAttrs = []; + + /** + * Name of the service that should be used to instanciate "value receiver". + * Service is invoked with type name and either request object or specified request attributes values, + * so callable(string, mixed...): Generator> is expected. + * The generator instanciated with this service ("value receiver") is expected + * to receive tuples ("identifier index", "identifier", "value corresponding to identifier") until null is sent + * and to return value list that should be stored in request. + * @var string|null + */ + public $valueReceiverFactory = null; + + /** + * Names of request attributes that should be passed to value receiver factory. + * If it is empty whole request is passed. + * @var string[] + */ + public $valueReceiverFactoryArgAttrs = []; + + /** + * Name of the request attribute to store value list + * @var string + */ + public $valueListAttr = 'list'; + + public function __construct(iterable $options) + { + foreach ($options as $key => $value) + { + switch ($key) + { + case 'type': + $this->type = $value; + break; + case 'identifierEmitter': + case 'identifier_emitter': + case 'idEmitter': + case 'id_emitter': + $this->identifierEmitter = $value; + break; + case 'identifierEmitterArgAttrs': + case 'identifier_emitter_arg_attrs': + case 'idEmitterArgAttrs': + case 'id_emitter_arg_attrs': + $this->identifierEmitterArgAttrs = $value; + break; + case 'valueReceiverFactory': + case 'value_receiver_factory': + $this->valueReceiverFactory = $value; + break; + case 'valueReceiverFactoryArgAttrs': + case 'value_receiver_factory_arg_attrs': + $this->valueReceiverFactoryArgAttrs = $value; + break; + case 'valueListAttr': + case 'value_list_attr': + case 'listAttr': + case 'list_attr': + $this->valueListAttr = $value; + break; + } + } + switch (true) + { + case ($this->type === null): + throw new \LogicException('Option "type" is not set'); + case ($this->identifierEmitter === null): + throw new \LogicException('Option "identifierEmitter" is not set'); + } + } +} diff --git a/src/Articus/PathHandler/Attribute/PluginManager.php b/src/Articus/PathHandler/Attribute/PluginManager.php index 8d2db92..25d8ca6 100644 --- a/src/Articus/PathHandler/Attribute/PluginManager.php +++ b/src/Articus/PathHandler/Attribute/PluginManager.php @@ -14,14 +14,19 @@ class PluginManager extends AbstractPluginManager protected $factories = [ IdentifiableValueLoad::class => Factory\IdentifiableValueLoad::class, + IdentifiableValueListLoad::class => Factory\IdentifiableValueListLoad::class, Transfer::class => Factory\Transfer::class, ]; protected $aliases = [ 'IdentifiableValueLoad' => IdentifiableValueLoad::class, 'identifiableValueLoad' => IdentifiableValueLoad::class, + 'IdentifiableValueListLoad' => IdentifiableValueListLoad::class, + 'identifiableValueListLoad' => IdentifiableValueListLoad::class, 'LoadById' => IdentifiableValueLoad::class, 'loadById' => IdentifiableValueLoad::class, + 'LoadByIds' => IdentifiableValueListLoad::class, + 'loadByIds' => IdentifiableValueListLoad::class, 'Transfer' => Transfer::class, 'transfer' => Transfer::class, ];