diff --git a/src/LocalData/AbstractLocalDataEventSubscriber.php b/src/LocalData/AbstractLocalDataEventSubscriber.php index e35e035e936c97809c081582984814521e9f772b..e309f6645730a3e14f69594f3cd2e64405add987 100644 --- a/src/LocalData/AbstractLocalDataEventSubscriber.php +++ b/src/LocalData/AbstractLocalDataEventSubscriber.php @@ -22,14 +22,16 @@ use Symfony\Contracts\EventDispatcher\Event; abstract class AbstractLocalDataEventSubscriber extends AbstractAuthorizationService implements EventSubscriberInterface { protected const ROOT_CONFIG_NODE = 'local_data_mapping'; - protected const SOURCE_ATTRIBUTES_CONFIG_NODE = 'source_attributes'; + protected const SOURCE_ATTRIBUTE_CONFIG_NODE = 'source_attribute'; protected const LOCAL_DATA_ATTRIBUTE_CONFIG_NODE = 'local_data_attribute'; protected const AUTHORIZATION_EXPRESSION_CONFIG_NODE = 'authorization_expression'; + protected const ALLOW_LOCAL_QUERY_CONFIG_NODE = 'allow_query'; protected const DEFAULT_VALUE_ATTRIBUTE_CONFIG_NODE = 'default_value'; protected const DEFAULT_VALUES_ATTRIBUTE_CONFIG_NODE = 'default_values'; - private const SOURCE_ATTRIBUTES_KEY = 'source'; + private const SOURCE_ATTRIBUTE_KEY = 'source'; private const DEFAULT_VALUE_KEY = 'default'; + private const QUERYABLE_KEY = 'queryable'; /* * WORKAROUND: could not find a way to determine whether a Symfony config array node was NOT specified since it provides an empty @@ -58,7 +60,8 @@ abstract class AbstractLocalDataEventSubscriber extends AbstractAuthorizationSer } $attributeMapEntry = []; - $attributeMapEntry[self::SOURCE_ATTRIBUTES_KEY] = $configMappingEntry[self::SOURCE_ATTRIBUTES_CONFIG_NODE]; + $attributeMapEntry[self::SOURCE_ATTRIBUTE_KEY] = $configMappingEntry[self::SOURCE_ATTRIBUTE_CONFIG_NODE]; + $attributeMapEntry[self::QUERYABLE_KEY] = $configMappingEntry[self::ALLOW_LOCAL_QUERY_CONFIG_NODE]; $defaultValue = $configMappingEntry[self::DEFAULT_VALUE_ATTRIBUTE_CONFIG_NODE] ?? null; if ($defaultValue === null) { @@ -97,33 +100,30 @@ abstract class AbstractLocalDataEventSubscriber extends AbstractAuthorizationSer public function onEvent(Event $event) { if ($event instanceof LocalDataPreEvent) { - foreach ($event->getPendingQueryParametersIn() as $localDataAttributeName => $localDataAttributeValue) { - if (($attributeMapEntry = $this->attributeMapping[$localDataAttributeName] ?? null) !== null) { + $localQueryParameters = []; + foreach ($event->getPendingQueryParameters() as $localDataAttributeName => $localDataAttributeValue) { + if (($attributeMapEntry = $this->attributeMapping[$localDataAttributeName] ?? null) !== null && + $attributeMapEntry[self::QUERYABLE_KEY] === true) { if (!$this->isGranted($localDataAttributeName)) { throw ApiError::withDetails(Response::HTTP_UNAUTHORIZED, sprintf('access to local data attribute \'%s\' denied', $localDataAttributeName)); } - $sourceAttributeName = $attributeMapEntry[self::SOURCE_ATTRIBUTES_KEY][0]; - $event->addQueryParameterOut($sourceAttributeName, $localDataAttributeValue); - $event->acknowledgeQueryParameterIn($localDataAttributeName); + $sourceAttributeName = $attributeMapEntry[self::SOURCE_ATTRIBUTE_KEY]; + $localQueryParameters[$sourceAttributeName] = $localDataAttributeValue; + $event->acknowledgeQueryParameter($localDataAttributeName); } } - $this->onPreEvent($event); + $this->onPreEvent($event, $localQueryParameters); } elseif ($event instanceof LocalDataPostEvent) { foreach ($event->getPendingRequestedAttributes() as $localDataAttributeName) { if (($attributeMapEntry = $this->attributeMapping[$localDataAttributeName] ?? null) !== null) { if (!$this->isGranted($localDataAttributeName)) { throw ApiError::withDetails(Response::HTTP_UNAUTHORIZED, sprintf('access to local data attribute \'%s\' denied', $localDataAttributeName)); } - $attributeValue = null; - foreach ($attributeMapEntry[self::SOURCE_ATTRIBUTES_KEY] as $sourceAttributeName) { - if (($value = $event->getSourceData()[$sourceAttributeName] ?? null) !== null) { - $attributeValue = $value; - break; - } - } + $attributeValue = $event->getSourceData()[$attributeMapEntry[self::SOURCE_ATTRIBUTE_KEY]] ?? null; $attributeValue = $attributeValue ?? $attributeMapEntry[self::DEFAULT_VALUE_KEY] ?? null; + if ($attributeValue !== null) { $event->setLocalDataAttribute($localDataAttributeName, $attributeValue); } else { @@ -153,20 +153,23 @@ abstract class AbstractLocalDataEventSubscriber extends AbstractAuthorizationSer ->scalarNode(self::LOCAL_DATA_ATTRIBUTE_CONFIG_NODE) ->info('The name of the local data attribute.') ->end() - ->arrayNode(self::SOURCE_ATTRIBUTES_CONFIG_NODE) - ->info('The list of source attributes to map to the local data attribute ordered by preferred usage. If an attribute is not found, the next attribute in the list is used.') - ->scalarPrototype()->end() + ->scalarNode(self::SOURCE_ATTRIBUTE_CONFIG_NODE) + ->info('The source attribute to map to the local data attribute. If the source attribute is not found, the default value is used.') ->end() ->scalarNode(self::AUTHORIZATION_EXPRESSION_CONFIG_NODE) ->defaultValue('false') ->info('A boolean expression evaluable by the Symfony Expression Language determining whether the current user may request read the local data attribute.') ->end() + ->booleanNode(self::ALLOW_LOCAL_QUERY_CONFIG_NODE) + ->defaultValue('false') + ->info('Indicates whether the local data attribute can be used in local queries.') + ->end() ->scalarNode(self::DEFAULT_VALUE_ATTRIBUTE_CONFIG_NODE) - ->info('The default value for scalar (i.e. non-array) attributes. If none is specified, an exception is thrown in case none of the source attributes is found.') + ->info('The default value for scalar (i.e. non-array) attributes. If none is specified, an exception is thrown in case the source attribute is not found.') ->end() ->arrayNode(self::DEFAULT_VALUES_ATTRIBUTE_CONFIG_NODE) ->defaultValue(self::ARRAY_VALUE_NOT_SPECIFIED) - ->info('The default value for array type attributes. If none is specified, an exception is thrown in case none of the source attributes is found.') + ->info('The default value for array type attributes. If none is specified, an exception is thrown in case the source attribute is not found.') ->scalarPrototype()->end() ->end() ->end() @@ -179,7 +182,7 @@ abstract class AbstractLocalDataEventSubscriber extends AbstractAuthorizationSer throw new \RuntimeException(sprintf('child classes must implement the \'%s\' method', __METHOD__)); } - protected function onPreEvent(LocalDataPreEvent $preEvent) + protected function onPreEvent(LocalDataPreEvent $preEvent, array $localQueryParameters) { } diff --git a/src/LocalData/LocalDataEventDispatcher.php b/src/LocalData/LocalDataEventDispatcher.php index 6635d57b32fbc8078ff1f2d4d623d6b0a24f412b..93f7d9238890db8e6bb3dc6eede4103b3d6f1570 100644 --- a/src/LocalData/LocalDataEventDispatcher.php +++ b/src/LocalData/LocalDataEventDispatcher.php @@ -83,10 +83,10 @@ class LocalDataEventDispatcher public function dispatch(Event $event, string $eventName = null): void { if ($event instanceof LocalDataPreEvent) { - $event->initQueryParametersIn($this->queryParameters); + $event->initQueryParameters($this->queryParameters); $this->eventDispatcher->dispatch($event, $eventName); - $pendingAttributes = $event->getPendingQueryParametersIn(); + $pendingAttributes = $event->getPendingQueryParameters(); if (count($pendingAttributes) !== 0) { throw ApiError::withDetails(Response::HTTP_BAD_REQUEST, sprintf("the following local query attributes were not acknowledged for resource '%s': %s", $this->uniqueEntityName, implode(', ', array_keys($pendingAttributes)))); } diff --git a/src/LocalData/LocalDataPreEvent.php b/src/LocalData/LocalDataPreEvent.php index 9fad9e6088d3df62a89e21b8834a631328240dc7..d548930d9ba8f3c219af16d8775b3589fbb01b87 100644 --- a/src/LocalData/LocalDataPreEvent.php +++ b/src/LocalData/LocalDataPreEvent.php @@ -11,45 +11,37 @@ class LocalDataPreEvent extends Event /** @var string[] */ private $queryParametersIn; - /** @var string[] */ - private $queryParametersOut; + /** @var array */ + private $options; - public function __construct() + public function __construct(array $options) { $this->queryParametersIn = []; - $this->queryParametersOut = []; - } - - /** - * @deprecated Use getQueryParametersOut - */ - public function getQueryParameters(): array - { - return $this->queryParametersOut; + $this->options = $options; } - public function initQueryParametersIn(array $queryParametersIn): void + public function initQueryParameters(array $queryParametersIn): void { $this->queryParametersIn = $queryParametersIn; } - public function getPendingQueryParametersIn(): array + public function getPendingQueryParameters(): array { return $this->queryParametersIn; } - public function acknowledgeQueryParameterIn(string $queryParameterName): void + public function acknowledgeQueryParameter(string $queryParameterName): void { unset($this->queryParametersIn[$queryParameterName]); } - public function addQueryParameterOut(string $queryParameterName, string $queryParameterValue): void + public function getOptions(): array { - $this->queryParametersOut[$queryParameterName] = $queryParameterValue; + return $this->options; } - public function getQueryParametersOut(): array + public function setOptions(array $options): void { - return $this->queryParametersOut; + $this->options = $options; } } diff --git a/tests/LocalData/LocalDataTest.php b/tests/LocalData/LocalDataTest.php index 29fd03e7497d46f4cfcca6d71470f60af7f7f031..06f1d1652ae4fa547ac5337fac78c3c7bf1a86a5 100644 --- a/tests/LocalData/LocalDataTest.php +++ b/tests/LocalData/LocalDataTest.php @@ -73,18 +73,6 @@ class LocalDataTest extends TestCase $this->assertEquals([0], $testEntity->getLocalDataValue($localDataAttributeName)); } - public function testLocalDataMappingFallback() - { - // first source attribute specified in config is present in source data -> return first source attribute value - $localDataAttributeName = 'attribute_2'; - $testEntity = $this->getTestEntity($localDataAttributeName, ['src_attribute_2_1' => 'value_2_1', 'src_attribute_2_2' => 'value_2_2']); - $this->assertEquals('value_2_1', $testEntity->getLocalDataValue($localDataAttributeName)); - - // first source attribute specified in config is not present in source data, however second attribute is preset -> return second source attribute value - $testEntity = $this->getTestEntity($localDataAttributeName, ['src_attribute_2_2' => 'value_2_2']); - $this->assertEquals('value_2_2', $testEntity->getLocalDataValue($localDataAttributeName)); - } - public function testLocalDataMappingAccessDenied() { // authorization expression of attribute evaluates to false -> deny access @@ -106,15 +94,15 @@ class LocalDataTest extends TestCase $options[LocalData::QUERY_PARAMETER_NAME] = $localDataAttributeName.':value_1'; $this->localDataEventDispatcher->onNewOperation($options); - $preEvent = new TestEntityPreEvent(); + $preEvent = new TestEntityPreEvent($options); $this->localDataEventDispatcher->dispatch($preEvent); - $filters = $preEvent->getQueryParametersOut(); - $this->assertArrayHasKey('src_attribute_1', $filters); - $this->assertEquals('value_1', $filters['src_attribute_1']); + $options = $preEvent->getOptions(); + $this->assertArrayHasKey('src_attribute_1', $options); + $this->assertEquals('value_1', $options['src_attribute_1']); } - public function testLocalDataQueryAttributeUnacknowledged() + public function testLocalDataQueryAttributeUnacknowledgedNotConfigure() { // 'attribute_4' has no configured source attribute. // Throw bad request error because no event subscriber acknowledged local query parameter 'attribute_4'. @@ -125,7 +113,25 @@ class LocalDataTest extends TestCase try { $this->localDataEventDispatcher->onNewOperation($options); - $preEvent = new TestEntityPreEvent(); + $preEvent = new TestEntityPreEvent($options); + $this->localDataEventDispatcher->dispatch($preEvent); + } catch (ApiError $exception) { + $this->assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode()); + } + } + + public function testLocalDataQueryAttributeUnacknowledgedNotQueryable() + { + // 'attribute_2' is configured 'allow_query': false (default value) + // Throw bad request error because no event subscriber acknowledged local query parameter 'attribute_2'. + $localDataAttributeName = 'attribute_2'; + + $options = []; + $options[LocalData::QUERY_PARAMETER_NAME] = $localDataAttributeName.':value_2'; + + try { + $this->localDataEventDispatcher->onNewOperation($options); + $preEvent = new TestEntityPreEvent($options); $this->localDataEventDispatcher->dispatch($preEvent); } catch (ApiError $exception) { $this->assertEquals(Response::HTTP_BAD_REQUEST, $exception->getStatusCode()); @@ -142,7 +148,7 @@ class LocalDataTest extends TestCase try { $this->localDataEventDispatcher->onNewOperation($options); - $preEvent = new TestEntityPreEvent(); + $preEvent = new TestEntityPreEvent($options); $this->localDataEventDispatcher->dispatch($preEvent); } catch (ApiError $exception) { $this->assertEquals(Response::HTTP_UNAUTHORIZED, $exception->getStatusCode()); @@ -168,24 +174,28 @@ class LocalDataTest extends TestCase $config['local_data_mapping'] = [ [ 'local_data_attribute' => 'attribute_1', - 'source_attributes' => ['src_attribute_1'], + 'source_attribute' => 'src_attribute_1', 'authorization_expression' => 'true', + 'allow_query' => true, 'default_value' => 0, ], [ 'local_data_attribute' => 'attribute_2', - 'source_attributes' => ['src_attribute_2_1', 'src_attribute_2_2'], + 'source_attribute' => 'src_attribute_2_1', 'authorization_expression' => 'true', + 'allow_query' => false, ], [ 'local_data_attribute' => 'attribute_3', - 'source_attributes' => ['src_attribute_3'], + 'source_attribute' => 'src_attribute_3', 'authorization_expression' => 'false', + 'allow_query' => true, ], [ 'local_data_attribute' => 'array_attribute_1', - 'source_attributes' => ['array_src_attribute_1'], + 'source_attribute' => 'array_src_attribute_1', 'authorization_expression' => 'true', + 'allow_query' => false, 'default_values' => [0], ], ]; diff --git a/tests/LocalData/TestEntityLocalDataEventSubscriber.php b/tests/LocalData/TestEntityLocalDataEventSubscriber.php index a85a1d84d6a154592e4147fb2097abc1753c36e8..5263feb2847f00ffc7bbdb964aa71fc684cdf237 100644 --- a/tests/LocalData/TestEntityLocalDataEventSubscriber.php +++ b/tests/LocalData/TestEntityLocalDataEventSubscriber.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Dbp\Relay\CoreBundle\Tests\LocalData; use Dbp\Relay\CoreBundle\LocalData\AbstractLocalDataPostEventSubscriber; +use Dbp\Relay\CoreBundle\LocalData\LocalDataPreEvent; class TestEntityLocalDataEventSubscriber extends AbstractLocalDataPostEventSubscriber { @@ -15,4 +16,9 @@ class TestEntityLocalDataEventSubscriber extends AbstractLocalDataPostEventSubsc TestEntityPreEvent::class, ]; } + + protected function onPreEvent(LocalDataPreEvent $preEvent, array $localQueryParameters) + { + $preEvent->setOptions($localQueryParameters); + } }