From 27d38a115f9c0cd80f946108e52ff53bd730066a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sun, 24 Jan 2021 18:18:56 +0100 Subject: [PATCH 01/41] feat: rename master brand to main --- README.md | 7 +++---- composer.json | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1fcbdb4704e..1a1f9f86358 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,10 @@ It natively supports popular open formats including [JSON for Linked Data (JSON- Build a working and fully-featured CRUD API in minutes. Leverage the awesome features of the tool to develop complex and high performance API-first projects. Extend or override everything you want. -[![GitHub Actions](https://github.com/api-platform/core/workflows/CI/badge.svg?branch=master)](https://github.com/api-platform/core/actions?query=workflow%3ACI+branch%3Amaster) -[![AppVeyor](https://ci.appveyor.com/api/projects/status/grwuyprts3wdqx5l/branch/master?svg=true)](https://ci.appveyor.com/project/dunglas/dunglasapibundle/branch/master) -[![Codecov](https://codecov.io/gh/api-platform/core/branch/master/graph/badge.svg)](https://codecov.io/gh/api-platform/core/branch/master) +[![GitHub Actions](https://github.com/api-platform/core/workflows/CI/badge.svg?branch=main)](https://github.com/api-platform/core/actions?query=workflow%3ACI+branch%3Amain) +[![Codecov](https://codecov.io/gh/api-platform/core/branch/main/graph/badge.svg)](https://codecov.io/gh/api-platform/core/branch/main) [![SymfonyInsight](https://insight.symfony.com/projects/92d78899-946c-4282-89a3-ac92344f9a93/mini.svg)](https://insight.symfony.com/projects/92d78899-946c-4282-89a3-ac92344f9a93) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/api-platform/core/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/api-platform/core/?branch=master) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/api-platform/core/badges/quality-score.png?b=main)](https://scrutinizer-ci.com/g/api-platform/core/?branch=main) ## Documentation diff --git a/composer.json b/composer.json index 83d4a7dc37f..a4a2e4fa763 100644 --- a/composer.json +++ b/composer.json @@ -124,7 +124,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.6.x-dev" + "dev-main": "2.7.x-dev" }, "symfony": { "require": "^3.4 || ^4.4 || ^5.1" From 41842c430e1eaf23b18d72a7cbc4744324347be4 Mon Sep 17 00:00:00 2001 From: GrimpEreau <47628187+GrimpEreau@users.noreply.github.com> Date: Sat, 30 Jan 2021 09:48:50 +0100 Subject: [PATCH 02/41] Fix: allow to disable OpenAPI, Swagger UI (#3968) * fix: load swagger even when conf is false * fix: change aliases and definitions for swagger disabled in tests * fix: manage necessary services loading * fix typo --- .../DependencyInjection/ApiPlatformExtension.php | 11 ++++++++--- src/Bridge/Symfony/Bundle/Resources/config/api.xml | 2 +- .../DependencyInjection/ApiPlatformExtensionTest.php | 10 +++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index b6b84eb7387..8110b5bb07c 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -375,13 +375,18 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array { $container->setParameter('api_platform.swagger.versions', $config['swagger']['versions']); - if (empty($config['swagger']['versions'])) { - return; + if (!$config['enable_swagger'] && $config['enable_swagger_ui']) { + throw new RuntimeException('You can not enable the Swagger UI without enabling Swagger, fix this by enabling swagger via the configuration "enable_swagger: true".'); } $loader->load('json_schema.xml'); - $loader->load('swagger.xml'); + + if (!$config['enable_swagger']) { + return; + } + $loader->load('openapi.xml'); + $loader->load('swagger.xml'); $loader->load('swagger-ui.xml'); if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 3dac225ca80..4742de28199 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -242,7 +242,7 @@ %api_platform.version% null %api_platform.swagger.versions% - + diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index a9b1ae66bc5..aea8afcb566 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1240,6 +1240,9 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.http_cache.listener.response.configure', 'api_platform.http_cache.purger.varnish_client', 'api_platform.http_cache.purger.varnish', + 'api_platform.json_schema.json_schema_generate_command', + 'api_platform.json_schema.type_factory', + 'api_platform.json_schema.schema_factory', 'api_platform.listener.view.validate', 'api_platform.listener.view.validate_query_parameters', 'api_platform.mercure.listener.response.add_link_header', @@ -1305,9 +1308,6 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions[] = 'api_platform.swagger.command.swagger_command'; $definitions[] = 'api_platform.swagger.normalizer.api_gateway'; $definitions[] = 'api_platform.swagger.normalizer.documentation'; - $definitions[] = 'api_platform.json_schema.type_factory'; - $definitions[] = 'api_platform.json_schema.schema_factory'; - $definitions[] = 'api_platform.json_schema.json_schema_generate_command'; $definitions[] = 'api_platform.openapi.options'; $definitions[] = 'api_platform.openapi.normalizer'; $definitions[] = 'api_platform.openapi.normalizer.api_gateway'; @@ -1363,6 +1363,8 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo NumericFilter::class => 'api_platform.doctrine.orm.numeric_filter', ExistsFilter::class => 'api_platform.doctrine.orm.exists_filter', GraphQlSerializerContextBuilderInterface::class => 'api_platform.graphql.serializer.context_builder', + TypeFactoryInterface::class => 'api_platform.json_schema.type_factory', + SchemaFactoryInterface::class => 'api_platform.json_schema.schema_factory', ]; if (\in_array('odm', $doctrineIntegrationsToLoad, true)) { @@ -1383,8 +1385,6 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo // Only when swagger is enabled if ($hasSwagger) { $aliases += [ - TypeFactoryInterface::class => 'api_platform.json_schema.type_factory', - SchemaFactoryInterface::class => 'api_platform.json_schema.schema_factory', Options::class => 'api_platform.openapi.options', OpenApiNormalizer::class => 'api_platform.openapi.normalizer', OpenApiFactoryInterface::class => 'api_platform.openapi.factory', From d4abacbcf8f6757d6ae985a60ba42a308fe17b0d Mon Sep 17 00:00:00 2001 From: CheapHasz Date: Thu, 11 Feb 2021 11:26:33 +0100 Subject: [PATCH 03/41] Add mongodb date_immutable filter support (#3940) --- .github/workflows/ci.yml | 1 - CHANGELOG.md | 4 ++ composer.json | 4 +- features/doctrine/date_filter.feature | 1 - .../Doctrine/MongoDbOdm/Filter/DateFilter.php | 1 + .../MongoDbOdm/Filter/SearchFilter.php | 1 + .../PropertyInfo/DoctrineExtractor.php | 2 + tests/Behat/DoctrineContext.php | 11 +++- .../PropertyInfo/DoctrineExtractorTest.php | 2 + .../PropertyInfo/Fixtures/DoctrineDummy.php | 5 ++ .../Document/DummyImmutableDate.php | 53 +++++++++++++++++++ tests/Fixtures/app/config/config_mongodb.yml | 4 ++ 12 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/DummyImmutableDate.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d268056ee9..ab21915d65f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -522,7 +522,6 @@ jobs: - name: Update project dependencies run: | composer update --no-interaction --no-progress --ansi - composer why doctrine/reflection # - name: Require Symfony Uid # run: composer require symfony/uid --dev --no-interaction --no-progress --ansi - name: Install PHPUnit diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4e2ed20f5..5145a3c46e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2.7.0 + +* MongoDB: `date_immutable` support (#3940) + ## 2.6.1 * Fix defaults when using attributes (#3978) diff --git a/composer.json b/composer.json index a4a2e4fa763..93f236ea7ad 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "doctrine/common": "^2.11 || ^3.0", "doctrine/data-fixtures": "^1.2.2", "doctrine/doctrine-bundle": "^1.12 || ^2.0", - "doctrine/mongodb-odm": "^2.0", + "doctrine/mongodb-odm": "^2.1", "doctrine/mongodb-odm-bundle": "^4.0", "doctrine/orm": "^2.6.4 || ^3.0", "elasticsearch/elasticsearch": "^6.0 || ^7.0", @@ -86,7 +86,7 @@ }, "conflict": { "doctrine/common": "<2.7", - "doctrine/mongodb-odm": "<2.0", + "doctrine/mongodb-odm": "<2.1", "doctrine/persistence": "<1.3" }, "suggest": { diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature index 067fbb14745..7d8d1a906f8 100644 --- a/features/doctrine/date_filter.feature +++ b/features/doctrine/date_filter.feature @@ -506,7 +506,6 @@ Feature: Date filter on collections And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-02T00:00:00+00:00" And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - @!mongodb @createSchema Scenario: Get collection filtered by date that is an immutable date variant Given there are 30 dummyimmutabledate objects with dummyDate diff --git a/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php b/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php index 275a153e369..8904bf570b7 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php @@ -34,6 +34,7 @@ class DateFilter extends AbstractFilter implements DateFilterInterface public const DOCTRINE_DATE_TYPES = [ MongoDbType::DATE => true, + MongoDbType::DATE_IMMUTABLE => true, ]; /** diff --git a/src/Bridge/Doctrine/MongoDbOdm/Filter/SearchFilter.php b/src/Bridge/Doctrine/MongoDbOdm/Filter/SearchFilter.php index 91cb23dc3cf..b75b2b310ea 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Filter/SearchFilter.php @@ -202,6 +202,7 @@ protected function getType(string $doctrineType): string case MongoDbType::BOOLEAN: return 'bool'; case MongoDbType::DATE: + case MongoDbType::DATE_IMMUTABLE: return \DateTimeInterface::class; case MongoDbType::FLOAT: return 'float'; diff --git a/src/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractor.php b/src/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractor.php index 3325392b5da..6538bce9cf0 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractor.php +++ b/src/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractor.php @@ -92,6 +92,8 @@ public function getTypes($class, $property, array $context = []) switch ($typeOfField) { case MongoDbType::DATE: return [new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, 'DateTime')]; + case MongoDbType::DATE_IMMUTABLE: + return [new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, 'DateTimeImmutable')]; case MongoDbType::HASH: return [new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true)]; case MongoDbType::COLLECTION: diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 3d5754c4f34..db2405e5daa 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -45,6 +45,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoOutputSameClass as DummyDtoOutputSameClassDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyOffer as DummyOfferDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyProduct as DummyProductDocument; @@ -1270,7 +1271,7 @@ public function thereAreDummyImmutableDateObjectsWithDummyDate(int $nb) { for ($i = 1; $i <= $nb; ++$i) { $date = new \DateTimeImmutable(sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - $dummy = new DummyImmutableDate(); + $dummy = $this->buildDummyImmutableDate(); $dummy->dummyDate = $date; $this->manager->persist($dummy); @@ -1801,6 +1802,14 @@ private function buildDummyDate() return $this->isOrm() ? new DummyDate() : new DummyDateDocument(); } + /** + * @return DummyImmutableDate|DummyImmutableDateDocument + */ + private function buildDummyImmutableDate() + { + return $this->isOrm() ? new DummyImmutableDate() : new DummyImmutableDateDocument(); + } + /** * @return DummyDifferentGraphQlSerializationGroup|DummyDifferentGraphQlSerializationGroupDocument */ diff --git a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php index 36505c4dc9c..1638b933ea9 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/DoctrineExtractorTest.php @@ -52,6 +52,7 @@ public function testGetProperties(): void 'binUuidRfc4122', 'timestamp', 'date', + 'dateImmutable', 'float', 'bool', 'boolean', @@ -141,6 +142,7 @@ public function typesProvider(): array ['binUuidRfc4122', [new Type(Type::BUILTIN_TYPE_STRING)]], ['timestamp', [new Type(Type::BUILTIN_TYPE_STRING)]], ['date', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTime')]], + ['dateImmutable', [new Type(Type::BUILTIN_TYPE_OBJECT, false, 'DateTimeImmutable')]], ['float', [new Type(Type::BUILTIN_TYPE_FLOAT)]], ['bool', [new Type(Type::BUILTIN_TYPE_BOOL)]], ['boolean', [new Type(Type::BUILTIN_TYPE_BOOL)]], diff --git a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php index 64a34ed4f65..e54438a4190 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/PropertyInfo/Fixtures/DoctrineDummy.php @@ -91,6 +91,11 @@ class DoctrineDummy */ private $date; + /** + * @Field(type="date_immutable") + */ + private $dateImmutable; + /** * @Field(type="float") */ diff --git a/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php b/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php new file mode 100644 index 00000000000..6bc16ea97c4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy Date Immutable. + * + * @ApiResource(attributes={ + * "filters"={"my_dummy_immutable_date.mongodb.date"} + * }) + * + * @ODM\Document + */ +class DummyImmutableDate +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @var \DateTimeImmutable The dummy date + * + * @ODM\Field(type="date_immutable") + */ + public $dummyDate; + + /** + * Get id. + * + * @return int + */ + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_mongodb.yml b/tests/Fixtures/app/config/config_mongodb.yml index 15565a806d3..747bd44724e 100644 --- a/tests/Fixtures/app/config/config_mongodb.yml +++ b/tests/Fixtures/app/config/config_mongodb.yml @@ -60,6 +60,10 @@ services: parent: 'api_platform.doctrine_mongodb.odm.date_filter' arguments: [ { 'dummyDate': ~ } ] tags: [ { name: 'api_platform.filter', id: 'my_dummy_date.mongodb.date' } ] + app.my_dummy_immutable_date_resource.mongodb.date_filter: + parent: 'api_platform.doctrine_mongodb.odm.date_filter' + arguments: [ { 'dummyDate': ~ } ] + tags: [ { name: 'api_platform.filter', id: 'my_dummy_immutable_date.mongodb.date' } ] app.related_dummy_to_friend_resource.mongodb.search_filter: parent: 'api_platform.doctrine_mongodb.odm.search_filter' arguments: [ { 'name': 'ipartial', 'description': 'ipartial' } ] From e68be3bb78b0644df04e748bbf1ce005665aa293 Mon Sep 17 00:00:00 2001 From: Baptiste Leduc Date: Tue, 16 Feb 2021 15:53:43 +0100 Subject: [PATCH 04/41] [DoctrineBridge][UX] Better Doctrine exceptions (#3965) --- CHANGELOG.md | 1 + .../Common/Util/IdentifierManagerTrait.php | 18 +++++++- src/Bridge/Doctrine/Orm/ItemDataProvider.php | 4 +- .../Doctrine/Orm/SubresourceDataProvider.php | 4 +- .../Bundle/Resources/config/doctrine_orm.xml | 2 + .../Util/IdentifierManagerTraitTest.php | 37 +++++++++------- .../Doctrine/Orm/ItemDataProviderTest.php | 30 +++++++------ .../Orm/SubresourceDataProviderTest.php | 42 ++++++++++--------- 8 files changed, 86 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0714fcb2e39..5a645b4e61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* Doctrine: Better exception to find which resource is linked to an exception (#3965) * MongoDB: `date_immutable` support (#3940) ## 2.6.3 diff --git a/src/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php b/src/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php index f33eca72ffa..d164fce4a7e 100644 --- a/src/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php +++ b/src/Bridge/Doctrine/Common/Util/IdentifierManagerTrait.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Exception\InvalidIdentifierException; use ApiPlatform\Core\Exception\PropertyNotFoundException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\ODM\MongoDB\DocumentManager; @@ -29,6 +30,7 @@ trait IdentifierManagerTrait { private $propertyNameCollectionFactory; private $propertyMetadataFactory; + private $resourceMetadataFactory; /** * Transform and check the identifier, composite or not. @@ -74,7 +76,13 @@ private function normalizeIdentifiers($id, ObjectManager $manager, string $resou $identifier = null === $identifiersMap ? $identifierValues[$i] ?? null : $identifiersMap[$propertyName] ?? null; if (null === $identifier) { - throw new PropertyNotFoundException(sprintf('Invalid identifier "%s", "%s" was not found.', $id, $propertyName)); + $exceptionMessage = sprintf('Invalid identifier "%s", "%s" was not found', $id, $propertyName); + if ($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $exceptionMessage .= sprintf(' for resource "%s"', $resourceMetadata->getShortName()); + } + + throw new PropertyNotFoundException($exceptionMessage.'.'); } $doctrineTypeName = $doctrineClassMetadata->getTypeOfField($propertyName); @@ -87,7 +95,13 @@ private function normalizeIdentifiers($id, ObjectManager $manager, string $resou $identifier = MongoDbType::getType($doctrineTypeName)->convertToPHPValue($identifier); } } catch (ConversionException $e) { - throw new InvalidIdentifierException(sprintf('Invalid value "%s" provided for an identifier.', $propertyName), $e->getCode(), $e); + $exceptionMessage = sprintf('Invalid value "%s" provided for an identifier', $propertyName); + if ($this->resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $exceptionMessage .= sprintf(' for resource "%s"', $resourceMetadata->getShortName()); + } + + throw new InvalidIdentifierException($exceptionMessage.'.', $e->getCode(), $e); } $identifiers[$propertyName] = $identifier; diff --git a/src/Bridge/Doctrine/Orm/ItemDataProvider.php b/src/Bridge/Doctrine/Orm/ItemDataProvider.php index 9535b162427..e36cd8c79c8 100644 --- a/src/Bridge/Doctrine/Orm/ItemDataProvider.php +++ b/src/Bridge/Doctrine/Orm/ItemDataProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -45,11 +46,12 @@ class ItemDataProvider implements DenormalizedIdentifiersAwareItemDataProviderIn /** * @param QueryItemExtensionInterface[] $itemExtensions */ - public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $itemExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $itemExtensions = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->managerRegistry = $managerRegistry; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->itemExtensions = $itemExtensions; } diff --git a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php index e3571d9880b..264cf65d84c 100644 --- a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php @@ -26,6 +26,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\QueryBuilder; @@ -48,11 +49,12 @@ final class SubresourceDataProvider implements SubresourceDataProviderInterface * @param QueryCollectionExtensionInterface[] $collectionExtensions * @param QueryItemExtensionInterface[] $itemExtensions */ - public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->managerRegistry = $managerRegistry; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->collectionExtensions = $collectionExtensions; $this->itemExtensions = $itemExtensions; } diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml index f60b7d564af..e18d751f2b6 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -25,6 +25,7 @@ + @@ -33,6 +34,7 @@ + diff --git a/tests/Bridge/Doctrine/Common/Util/IdentifierManagerTraitTest.php b/tests/Bridge/Doctrine/Common/Util/IdentifierManagerTraitTest.php index 99c9d3d5abc..6e8fe0b97d9 100644 --- a/tests/Bridge/Doctrine/Common/Util/IdentifierManagerTraitTest.php +++ b/tests/Bridge/Doctrine/Common/Util/IdentifierManagerTraitTest.php @@ -20,6 +20,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -39,17 +41,18 @@ class IdentifierManagerTraitTest extends TestCase { use ProphecyTrait; - private function getIdentifierManagerTraitImpl(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory) + private function getIdentifierManagerTraitImpl(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory) { - return new class($propertyNameCollectionFactory, $propertyMetadataFactory) { + return new class($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory) { use IdentifierManagerTrait { IdentifierManagerTrait::normalizeIdentifiers as public; } - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory) { $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->resourceMetadataFactory = $resourceMetadataFactory; } }; } @@ -59,7 +62,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName */ public function testSingleIdentifier() { - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); $objectManager = $this->getEntityManager(Dummy::class, [ @@ -68,7 +71,7 @@ public function testSingleIdentifier() ], ]); - $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory); + $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory); $this->assertEquals($identifierManager->normalizeIdentifiers(1, $objectManager, Dummy::class), ['id' => 1]); } @@ -79,7 +82,7 @@ public function testSingleIdentifier() */ public function testSingleDocumentIdentifier() { - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(DummyDocument::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(DummyDocument::class, [ 'id', ]); $objectManager = $this->getDocumentManager(DummyDocument::class, [ @@ -88,7 +91,7 @@ public function testSingleDocumentIdentifier() ], ]); - $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory); + $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory); $this->assertEquals($identifierManager->normalizeIdentifiers(1, $objectManager, DummyDocument::class), ['id' => 1]); } @@ -98,7 +101,7 @@ public function testSingleDocumentIdentifier() */ public function testCompositeIdentifier() { - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', 'idb', ]); @@ -111,7 +114,7 @@ public function testCompositeIdentifier() ], ]); - $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory); + $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory); $this->assertEquals($identifierManager->normalizeIdentifiers('ida=1;idb=2', $objectManager, Dummy::class), ['ida' => 1, 'idb' => 2]); } @@ -122,9 +125,9 @@ public function testCompositeIdentifier() public function testInvalidIdentifier() { $this->expectException(PropertyNotFoundException::class); - $this->expectExceptionMessage('Invalid identifier "idbad=1;idb=2", "ida" was not found.'); + $this->expectExceptionMessage('Invalid identifier "idbad=1;idb=2", "ida" was not found for resource "dummy".'); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', 'idb', ]); @@ -137,7 +140,7 @@ public function testInvalidIdentifier() ], ]); - $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory); + $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory); $identifierManager->normalizeIdentifiers('idbad=1;idb=2', $objectManager, Dummy::class); } @@ -149,6 +152,7 @@ private function getMetadataFactories(string $resourceClass, array $identifiers) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $nameCollection = ['foobar']; @@ -164,8 +168,9 @@ private function getMetadataFactories(string $resourceClass, array $identifiers) $propertyMetadataFactoryProphecy->create($resourceClass, 'foobar')->willReturn(new PropertyMetadata()); $propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection($nameCollection)); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata('dummy')); - return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()]; + return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal()]; } /** @@ -218,9 +223,9 @@ public function testInvalidIdentifierConversion() DBALType::addType('uuid', UuidType::class); $this->expectException(InvalidIdentifierException::class); - $this->expectExceptionMessage('Invalid value "ida" provided for an identifier.'); + $this->expectExceptionMessage('Invalid value "ida" provided for an identifier for resource "dummy".'); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', ]); $objectManager = $this->getEntityManager(Dummy::class, [ @@ -229,7 +234,7 @@ public function testInvalidIdentifierConversion() ], ]); - $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory); + $identifierManager = $this->getIdentifierManagerTraitImpl($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory); $identifierManager->normalizeIdentifiers('notanuuid', $objectManager, Dummy::class); } diff --git a/tests/Bridge/Doctrine/Orm/ItemDataProviderTest.php b/tests/Bridge/Doctrine/Orm/ItemDataProviderTest.php index e356de369ae..a17dd694fdb 100644 --- a/tests/Bridge/Doctrine/Orm/ItemDataProviderTest.php +++ b/tests/Bridge/Doctrine/Orm/ItemDataProviderTest.php @@ -24,6 +24,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\DBAL\Connection; @@ -69,7 +71,7 @@ public function testGetItemSingleIdentifier() $queryBuilder = $queryBuilderProphecy->reveal(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, [ @@ -81,7 +83,7 @@ public function testGetItemSingleIdentifier() $extensionProphecy = $this->prophesize(QueryItemExtensionInterface::class); $extensionProphecy->applyToItem($queryBuilder, Argument::type(QueryNameGeneratorInterface::class), Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()], $resourceMetadataFactory); $this->assertEquals([], $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -109,7 +111,7 @@ public function testGetItemDoubleIdentifier() $queryBuilder = $queryBuilderProphecy->reveal(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', 'idb', ]); @@ -126,7 +128,7 @@ public function testGetItemDoubleIdentifier() $extensionProphecy = $this->prophesize(QueryItemExtensionInterface::class); $extensionProphecy->applyToItem($queryBuilder, Argument::type(QueryNameGeneratorInterface::class), Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()], $resourceMetadataFactory); $this->assertEquals([], $dataProvider->getItem(Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)); } @@ -138,7 +140,7 @@ public function testGetItemWrongCompositeIdentifier() { $this->expectException(PropertyNotFoundException::class); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', 'idb', ]); @@ -151,7 +153,7 @@ public function testGetItemWrongCompositeIdentifier() ], ], $this->prophesize(QueryBuilder::class)->reveal()); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [], $resourceMetadataFactory); $dataProvider->getItem(Dummy::class, 'ida=1;', 'foo'); } @@ -171,7 +173,7 @@ public function testQueryResultExtension() $queryBuilder = $queryBuilderProphecy->reveal(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, [ @@ -186,7 +188,7 @@ public function testQueryResultExtension() $extensionProphecy->supportsResult(Dummy::class, 'foo', $context)->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($queryBuilder, Dummy::class, 'foo', $context)->willReturn([])->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()], $resourceMetadataFactory); $this->assertEquals([], $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -198,11 +200,11 @@ public function testUnsupportedClass() $extensionProphecy = $this->prophesize(QueryItemExtensionInterface::class); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); - $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()], $resourceMetadataFactory); $this->assertFalse($dataProvider->supports(Dummy::class, 'foo')); } @@ -233,11 +235,11 @@ public function testCannotCreateQueryBuilder() $extensionProphecy = $this->prophesize(QueryItemExtensionInterface::class); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); - $itemDataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $itemDataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()], $resourceMetadataFactory); $itemDataProvider->getItem(Dummy::class, ['id' => 1234], null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); } @@ -248,6 +250,7 @@ private function getMetadataFactories(string $resourceClass, array $identifiers) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $nameCollection = ['foobar']; @@ -263,8 +266,9 @@ private function getMetadataFactories(string $resourceClass, array $identifiers) $propertyMetadataFactoryProphecy->create($resourceClass, 'foobar')->willReturn(new PropertyMetadata()); $propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection($nameCollection)); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata('dummy')); - return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()]; + return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal()]; } /** diff --git a/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php b/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php index d9e49037bef..cf8791f18ce 100644 --- a/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php +++ b/tests/Bridge/Doctrine/Orm/SubresourceDataProviderTest.php @@ -23,6 +23,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; @@ -67,6 +69,7 @@ private function getMetadataProphecies(array $resourceClassesIdentifiers) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); foreach ($resourceClassesIdentifiers as $resourceClass => $identifiers) { $nameCollection = ['foobar']; @@ -81,11 +84,12 @@ private function getMetadataProphecies(array $resourceClassesIdentifiers) //random property to prevent the use of non-identifiers metadata while looping $propertyMetadataFactoryProphecy->create($resourceClass, 'foobar')->willReturn(new PropertyMetadata()); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata('dummy')); $propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection($nameCollection)); } - return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()]; + return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal()]; } private function getManagerRegistryProphecy(QueryBuilder $queryBuilder, array $identifiers, string $resourceClass) @@ -112,11 +116,11 @@ public function testNotASubresource() $this->expectExceptionMessage('The given resource class is not a subresource.'); $identifiers = ['id']; - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); $queryBuilder = $this->prophesize(QueryBuilder::class)->reveal(); $managerRegistry = $this->getManagerRegistryProphecy($queryBuilder, $identifiers, Dummy::class); - $dataProvider = new SubresourceDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, []); + $dataProvider = new SubresourceDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -170,9 +174,9 @@ public function testGetSubresource() $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $context = ['property' => 'relatedDummies', 'identifiers' => ['id' => [Dummy::class, 'id']], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -264,9 +268,9 @@ public function testGetSubSubresourceItem() $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $context = ['property' => 'thirdLevel', 'identifiers' => ['id' => [Dummy::class, 'id'], 'relatedDummies' => [RelatedDummy::class, 'id']], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -320,9 +324,9 @@ public function testGetSubresourceOneToOneOwningRelation() $managerRegistryProphecy->getManagerForClass(RelatedOwningDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $context = ['property' => 'ownedDummy', 'identifiers' => ['id' => [Dummy::class, 'id']], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -375,14 +379,14 @@ public function testQueryResultExtension() $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); $extensionProphecy = $this->prophesize(QueryResultCollectionExtensionInterface::class); $extensionProphecy->applyToCollection($queryBuilder, Argument::type(QueryNameGeneratorInterface::class), RelatedDummy::class, null, Argument::type('array'))->shouldBeCalled(); $extensionProphecy->supportsResult(RelatedDummy::class, null, Argument::type('array'))->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($queryBuilder, RelatedDummy::class, null, Argument::type('array'))->willReturn([])->shouldBeCalled(); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()], [], $resourceMetadataFactory); $context = ['property' => 'relatedDummies', 'identifiers' => ['id' => [Dummy::class, 'id']], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -403,9 +407,9 @@ public function testCannotCreateQueryBuilder() $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -417,9 +421,9 @@ public function testThrowResourceClassNotSupportedException() $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn(null)->shouldBeCalled(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -514,9 +518,9 @@ public function testGetSubSubresourceItemLegacy() $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false]; @@ -604,9 +608,9 @@ public function testGetSubresourceCollectionItem() $rDummyManagerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [], [], $resourceMetadataFactory); $context = ['property' => 'id', 'identifiers' => ['id' => [Dummy::class, 'id', true], 'relatedDummies' => [RelatedDummy::class, 'id', true]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; From 959f0829747eb733f496b5ddc6508b433b4f6792 Mon Sep 17 00:00:00 2001 From: Minh Vuong <38932626+vuongxuongminh@users.noreply.github.com> Date: Tue, 16 Feb 2021 22:16:19 +0700 Subject: [PATCH 05/41] Allow mixed type value when apply to DateFilter (#3870) --- CHANGELOG.md | 1 + .../Doctrine/Common/Filter/DateFilterTrait.php | 14 ++++++++++++++ .../Doctrine/MongoDbOdm/Filter/DateFilter.php | 8 +++++++- src/Bridge/Doctrine/Orm/Filter/DateFilter.php | 8 +++++++- .../Doctrine/Common/Filter/DateFilterTestTrait.php | 1 + 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a645b4e61b..129c0dadcf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.7.0 * Doctrine: Better exception to find which resource is linked to an exception (#3965) +* Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) * MongoDB: `date_immutable` support (#3940) ## 2.6.3 diff --git a/src/Bridge/Doctrine/Common/Filter/DateFilterTrait.php b/src/Bridge/Doctrine/Common/Filter/DateFilterTrait.php index 82e5db681f4..fbbd3bf7f2e 100644 --- a/src/Bridge/Doctrine/Common/Filter/DateFilterTrait.php +++ b/src/Bridge/Doctrine/Common/Filter/DateFilterTrait.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Doctrine\Common\Filter; use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Exception\InvalidArgumentException; /** * Trait for filtering the collection by date intervals. @@ -79,4 +80,17 @@ protected function getFilterDescription(string $property, string $period): array ], ]; } + + private function normalizeValue($value, string $operator): ?string + { + if (false === \is_string($value)) { + $this->getLogger()->notice('Invalid filter ignored', [ + 'exception' => new InvalidArgumentException(sprintf('Invalid value for "[%s]", expected string', $operator)), + ]); + + return null; + } + + return $value; + } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php b/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php index 8904bf570b7..6c6abf3ccf7 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Filter/DateFilter.php @@ -108,8 +108,14 @@ protected function filterProperty(string $property, $values, Builder $aggregatio /** * Adds the match stage according to the chosen null management. */ - private function addMatch(Builder $aggregationBuilder, string $field, string $operator, string $value, string $nullManagement = null): void + private function addMatch(Builder $aggregationBuilder, string $field, string $operator, $value, string $nullManagement = null): void { + $value = $this->normalizeValue($value, $operator); + + if (null === $value) { + return; + } + try { $value = new \DateTime($value); } catch (\Exception $e) { diff --git a/src/Bridge/Doctrine/Orm/Filter/DateFilter.php b/src/Bridge/Doctrine/Orm/Filter/DateFilter.php index 0b619be243e..9970eadf3b4 100644 --- a/src/Bridge/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/DateFilter.php @@ -128,9 +128,15 @@ protected function filterProperty(string $property, $values, QueryBuilder $query * * @param string|DBALType $type */ - protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, string $value, string $nullManagement = null, $type = null) + protected function addWhere(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, string $operator, $value, string $nullManagement = null, $type = null) { $type = (string) $type; + $value = $this->normalizeValue($value, $operator); + + if (null === $value) { + return; + } + try { $value = false === strpos($type, '_immutable') ? new \DateTime($value) : new \DateTimeImmutable($value); } catch (\Exception $e) { diff --git a/tests/Bridge/Doctrine/Common/Filter/DateFilterTestTrait.php b/tests/Bridge/Doctrine/Common/Filter/DateFilterTestTrait.php index 490960d9bb8..7d680d26d54 100644 --- a/tests/Bridge/Doctrine/Common/Filter/DateFilterTestTrait.php +++ b/tests/Bridge/Doctrine/Common/Filter/DateFilterTestTrait.php @@ -220,6 +220,7 @@ private function provideApplyTestArguments(): array [ 'dummyDate' => [ 'after' => '1932iur123ufqe', + 'before' => [], ], ], ], From 3557329f15da2ae3d61cc328a28e45929697d89d Mon Sep 17 00:00:00 2001 From: Toby Griffiths Date: Wed, 17 Feb 2021 17:51:36 +0000 Subject: [PATCH 06/41] Add traversable paginator (#3783) --- CHANGELOG.md | 1 + src/DataProvider/TraversablePaginator.php | 90 +++++++++++++++++++ .../DataProvider/TraversablePaginatorTest.php | 56 ++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/DataProvider/TraversablePaginator.php create mode 100644 tests/DataProvider/TraversablePaginatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 129c0dadcf7..f2ee9d43f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Doctrine: Better exception to find which resource is linked to an exception (#3965) * Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) * MongoDB: `date_immutable` support (#3940) +* Add `TraversablePaginator` (#3783) ## 2.6.3 diff --git a/src/DataProvider/TraversablePaginator.php b/src/DataProvider/TraversablePaginator.php new file mode 100644 index 00000000000..91635bc1fb4 --- /dev/null +++ b/src/DataProvider/TraversablePaginator.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\DataProvider; + +final class TraversablePaginator implements \IteratorAggregate, PaginatorInterface +{ + private $traversable; + private $currentPage; + private $itemsPerPage; + private $totalItems; + + public function __construct(\Traversable $iterator, float $currentPage, float $itemsPerPage, float $totalItems) + { + $this->traversable = $iterator; + $this->currentPage = $currentPage; + $this->itemsPerPage = $itemsPerPage; + $this->totalItems = $totalItems; + } + + /** + * {@inheritdoc} + */ + public function getCurrentPage(): float + { + return $this->currentPage; + } + + /** + * {@inheritdoc} + */ + public function getLastPage(): float + { + if (0. >= $this->itemsPerPage) { + return 1.; + } + + return max(ceil($this->totalItems / $this->itemsPerPage) ?: 1., 1.); + } + + /** + * {@inheritdoc} + */ + public function getItemsPerPage(): float + { + return $this->itemsPerPage; + } + + /** + * {@inheritdoc} + */ + public function getTotalItems(): float + { + return $this->totalItems; + } + + /** + * {@inheritdoc} + */ + public function count(): int + { + if ($this->getCurrentPage() < $this->getLastPage()) { + return (int) ceil($this->itemsPerPage); + } + + if (0. >= $this->itemsPerPage) { + return (int) ceil($this->totalItems); + } + + return $this->totalItems % $this->itemsPerPage; + } + + /** + * {@inheritdoc} + */ + public function getIterator(): \Traversable + { + return $this->traversable; + } +} diff --git a/tests/DataProvider/TraversablePaginatorTest.php b/tests/DataProvider/TraversablePaginatorTest.php new file mode 100644 index 00000000000..ac96262a102 --- /dev/null +++ b/tests/DataProvider/TraversablePaginatorTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\DataProvider; + +use ApiPlatform\Core\DataProvider\TraversablePaginator; +use ArrayIterator; +use PHPUnit\Framework\TestCase; + +class TraversablePaginatorTest extends TestCase +{ + /** + * @dataProvider initializeProvider + */ + public function testInitialize( + array $results, + float $currentPage, + float $perPage, + float $totalItems, + float $lastPage, + float $currentItems + ): void { + $traversable = new ArrayIterator($results); + + $paginator = new TraversablePaginator($traversable, $currentPage, $perPage, $totalItems); + + self::assertEquals($totalItems, $paginator->getTotalItems()); + self::assertEquals($currentPage, $paginator->getCurrentPage()); + self::assertEquals($lastPage, $paginator->getLastPage()); + self::assertEquals($perPage, $paginator->getItemsPerPage()); + self::assertEquals($currentItems, $paginator->count()); + + self::assertSame($results, iterator_to_array($paginator)); + } + + public function initializeProvider(): array + { + return [ + 'First of three pages of 3 items each' => [[0, 1, 2, 3, 4, 5, 6], 1, 3, 7, 3, 3], + 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 2, 3, 5, 2, 2], + 'Empty results' => [[], 1, 2, 0, 1, 0], + '0 items per page' => [[0, 1, 2, 3], 1, 0, 4, 1, 4], + 'Total items less than items per page' => [[0, 1, 2], 1, 4, 3, 1, 3], + ]; + } +} From f3492dcf18d6b2ee5b292959cb5943ad0a75e9d0 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 18 Dec 2020 13:57:20 +0100 Subject: [PATCH 07/41] Resource definition --- docs/adr/0000-subresources-definition.md | 2 +- docs/adr/0002-resource-definition.md | 208 +++++++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0002-resource-definition.md diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md index 72cccb17e03..062711279fe 100644 --- a/docs/adr/0000-subresources-definition.md +++ b/docs/adr/0000-subresources-definition.md @@ -1,6 +1,6 @@ # Subresource Definition -* Status: proposed +* Status: superseded by [0002-resource-definition](0002-resource-definition.md)] * Deciders: @dunglas, @vincentchalamon, @soyuka, @GregoireHebert, @Deuchnord ## Context and Problem Statement diff --git a/docs/adr/0002-resource-definition.md b/docs/adr/0002-resource-definition.md new file mode 100644 index 00000000000..7cdfd846950 --- /dev/null +++ b/docs/adr/0002-resource-definition.md @@ -0,0 +1,208 @@ +# Resource definition + +* Status: proposed +* Deciders: @dunglas @soyuka @vincentchalamon @GregoireHebert + +## Context and Problem Statement + +The API Platform `@ApiResource` annotation was initially created to represent a Resource as defined in [Roy Fiedling's dissertation about REST](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm#sec_5_2_1_1) in corelation with [RFC 7231 about HTTP Semantics](https://httpwg.org/specs/rfc7231.html#resources). This annotation brings some confusion as it mixes concepts of resources and operations. Here we discussed how we could revamp API Platform's resource definition using PHP8 attributes, beeing as close as we can to Roy Fiedling's thesis vocabulary. + +## Considered Options + +* Declare multiple ApiResource on a PHP Class [see Subresources definition](./0000-subresources-definition.md) +* Declare operations in conjunction with resources using two attributes: `Resource` and `Operation` +* Use HTTP Verb to represent operations with a syntax sugar for collections (`CGET`?) + +## Decision Outcome + +As Roy Fiedling's thesis states: + +> REST uses a resource identifier to identify the particular resource involved in an interaction between components. REST connectors provide a generic interface for accessing and manipulating the value set of a resource, regardless of how the membership function is defined or the type of software that is handling the request. + +In API Platform, this resource identifier is also named [IRI (Internationalized Resource Identifiers)](https://tools.ietf.org/html/rfc3987). Following these recommandation, applied to PHP, we came up with the following [PHP 8 attributes](https://www.php.net/manual/en/language.attributes.php): + +```php + + + + Code + + + Operations + + + + +
+#[Get]
+class User {}
+            
+ + +
  • GET /users/{id}
+ + + + +
+#[GetCollection]
+class User {}
+            
+ + +
  • GET /users
+ + + + +
+#[Get("/users")]
+class User {}
+            
+ + +
  • GET /users
+ + + + +
+#[Post("/users")]
+#[Patch("/users/{id}")]
+class User {}
+            
+ + +
  • POST /users
  • +
  • PATCH /users/{id}
  • + + + + +
    +// See these as aliases to the `/users/{id}` Resource:
    +#[Get("/companies/{companyId}/users/{id}")]
    +#[Delete("/companies/{companyId}/users/{id}")]
    +class User {}
    +            
    + + +
    • GET /companies/{companyId}/users/{id}
    • +
    • DELETE /companies/{companyId}/users/{id}
    • + + + + +To link these operations with identifiers, refer to [Resource Identifiers decision record](./0001-resource-identifiers), for example: + +```php + [Company::class, "id"], "id" => [User::class, "id"]]])] +class User { + #[ApiProperty(identifier=true)] + public $id; + public Company $company; +} +``` + +The `Resource` attribute could be used to set defaults properties on operations: + +```php + Date: Sat, 20 Feb 2021 13:46:44 +0100 Subject: [PATCH 08/41] test: fix mongodb deprecation --- tests/Fixtures/TestBundle/Document/DummyImmutableDate.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php b/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php index 6bc16ea97c4..d9893b94a3e 100644 --- a/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php +++ b/tests/Fixtures/TestBundle/Document/DummyImmutableDate.php @@ -30,7 +30,7 @@ class DummyImmutableDate /** * @var int The id * - * @ODM\Id(strategy="INCREMENT", type="integer") + * @ODM\Id(strategy="INCREMENT", type="int") */ private $id; From 930c930debe0f14fd7c6a0a46034d9e858f17846 Mon Sep 17 00:00:00 2001 From: Chernosov Denis Date: Tue, 23 Feb 2021 13:34:33 +0400 Subject: [PATCH 09/41] Add `swagger_ui_extra_configuration` to Swagger configuration (#3731) --- CHANGELOG.md | 3 ++- .../Symfony/Bundle/Action/SwaggerUiAction.php | 5 ++++- .../DependencyInjection/ApiPlatformExtension.php | 1 + .../Bundle/DependencyInjection/Configuration.php | 16 ++++++++++++++++ .../Bundle/Resources/config/swagger-ui.xml | 2 ++ .../Bundle/Resources/public/init-swagger-ui.js | 4 ++-- .../Symfony/Bundle/SwaggerUi/SwaggerUiAction.php | 1 + .../Bundle/SwaggerUi/SwaggerUiContext.php | 9 ++++++++- .../Bundle/Action/SwaggerUiActionTest.php | 3 +++ .../ApiPlatformExtensionTest.php | 2 ++ .../DependencyInjection/ConfigurationTest.php | 2 ++ .../Bundle/SwaggerUi/SwaggerUiActionTest.php | 3 +++ 12 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f1944990e..e0bd71c6aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ * Doctrine: Better exception to find which resource is linked to an exception (#3965) * Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) * MongoDB: `date_immutable` support (#3940) -* Add `TraversablePaginator` (#3783) +* DataProvider: Add `TraversablePaginator` (#3783) +* Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) ## 2.6.3 diff --git a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php index d84a1b2214a..5a03d2baffd 100644 --- a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php @@ -62,12 +62,13 @@ final class SwaggerUiAction private $swaggerVersions; private $swaggerUiAction; private $assetPackage; + private $swaggerUiExtraConfiguration; /** * @param int[] $swaggerVersions * @param mixed|null $assetPackage */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null, $assetPackage = null) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null, $assetPackage = null, array $swaggerUiExtraConfiguration = []) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -93,6 +94,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; $this->swaggerVersions = $swaggerVersions; $this->swaggerUiAction = $swaggerUiAction; + $this->swaggerUiExtraConfiguration = $swaggerUiExtraConfiguration; $this->assetPackage = $assetPackage; if (null === $this->swaggerUiAction) { @@ -157,6 +159,7 @@ private function getContext(Request $request, Documentation $documentation): arr $swaggerData = [ 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), 'spec' => $this->normalizer->normalize($documentation, 'json', $swaggerContext), + 'extraConfiguration' => $this->swaggerUiExtraConfiguration, ]; $swaggerData['oauth'] = [ diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index c330c90905f..8eee94a3990 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -397,6 +397,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_swagger_ui', $config['enable_swagger_ui']); $container->setParameter('api_platform.enable_re_doc', $config['enable_re_doc']); $container->setParameter('api_platform.swagger.api_keys', $config['swagger']['api_keys']); + $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?? $config['swagger']['swagger_ui_extra_configuration']); if (true === $config['openapi']['backward_compatibility_layer']) { $container->getDefinition('api_platform.swagger.normalizer.documentation')->addArgument($container->getDefinition('api_platform.openapi.normalizer')); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index de9a01c80a8..d14ec4533ab 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -339,6 +339,14 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->end() ->end() ->end() + ->variableNode('swagger_ui_extra_configuration') + ->defaultValue([]) + ->validate() + ->ifTrue(static function ($v) { return false === \is_array($v); }) + ->thenInvalid('The swagger_ui_extra_configuration parameter must be an array.') + ->end() + ->info('To pass extra configuration to Swagger UI, like docExpansion or filter.') + ->end() ->end() ->end() ->end(); @@ -492,6 +500,14 @@ private function addOpenApiSection(ArrayNodeDefinition $rootNode): void ->scalarNode('url')->defaultNull()->info('URL to the license used for the API. MUST be in the format of a URL.')->end() ->end() ->end() + ->variableNode('swagger_ui_extra_configuration') + ->defaultValue([]) + ->validate() + ->ifTrue(static function ($v) { return false === \is_array($v); }) + ->thenInvalid('The swagger_ui_extra_configuration parameter must be an array.') + ->end() + ->info('To pass extra configuration to Swagger UI, like docExpansion or filter.') + ->end() ->end() ->end() ->end(); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml index c56d3a3440c..f882312a68d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml @@ -38,6 +38,7 @@ %api_platform.swagger.versions% %api_platform.asset_package% + %api_platform.swagger_ui.extra_configuration% @@ -48,6 +49,7 @@ %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% %api_platform.asset_package% + %api_platform.swagger_ui.extra_configuration% diff --git a/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js b/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js index 95f2b63ed5e..f248f92bbc2 100644 --- a/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js +++ b/src/Bridge/Symfony/Bundle/Resources/public/init-swagger-ui.js @@ -41,7 +41,7 @@ window.onload = function() { }).observe(document, {childList: true, subtree: true}); const data = JSON.parse(document.getElementById('swagger-data').innerText); - const ui = SwaggerUIBundle({ + const ui = SwaggerUIBundle(Object.assign({ spec: data.spec, dom_id: '#swagger-ui', validatorUrl: null, @@ -54,7 +54,7 @@ window.onload = function() { SwaggerUIBundle.plugins.DownloadUrl, ], layout: 'StandaloneLayout', - }); + }, data.extraConfiguration)); if (data.oauth.enabled) { ui.initOAuth({ diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php index 81bf60ebb39..c88c89993e1 100644 --- a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -86,6 +86,7 @@ public function __invoke(Request $request) 'clientId' => $this->oauthClientId, 'clientSecret' => $this->oauthClientSecret, ], + 'extraConfiguration' => $this->swaggerUiContext->getExtraConfiguration(), ]; if ($request->isMethodSafe() && null !== $resourceClass = $request->attributes->get('_api_resource_class')) { diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php index 29aaf205600..d37f51fcfbe 100644 --- a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php @@ -22,8 +22,9 @@ final class SwaggerUiContext private $graphiQlEnabled; private $graphQlPlaygroundEnabled; private $assetPackage; + private $extraConfiguration; - public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = true, bool $reDocEnabled = false, bool $graphQlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, $assetPackage = null) + public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = true, bool $reDocEnabled = false, bool $graphQlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, $assetPackage = null, array $extraConfiguration = []) { $this->swaggerUiEnabled = $swaggerUiEnabled; $this->showWebby = $showWebby; @@ -32,6 +33,7 @@ public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = tr $this->graphiQlEnabled = $graphiQlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; $this->assetPackage = $assetPackage; + $this->extraConfiguration = $extraConfiguration; } public function isSwaggerUiEnabled(): bool @@ -68,4 +70,9 @@ public function getAssetPackage(): ?string { return $this->assetPackage; } + + public function getExtraConfiguration(): array + { + return $this->extraConfiguration; + } } diff --git a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php index ae1c6d3391d..ef9e674cc24 100644 --- a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php @@ -89,6 +89,7 @@ public function getInvokeParameters() 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, + 'extraConfiguration' => [], 'oauth' => [ 'enabled' => false, 'clientId' => '', @@ -123,6 +124,7 @@ public function getInvokeParameters() 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, + 'extraConfiguration' => [], 'oauth' => [ 'enabled' => false, 'clientId' => '', @@ -180,6 +182,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, + 'extraConfiguration' => [], 'oauth' => [ 'enabled' => false, 'clientId' => '', diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 38f379dd726..0b000383d13 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -244,6 +244,7 @@ public function testSetNameConverter() $containerBuilderProphecy->hasParameter('kernel.debug')->willReturn(true); $containerBuilderProphecy->getParameter('kernel.debug')->willReturn(false); $containerBuilderProphecy->setAlias('api_platform.name_converter', $nameConverterId)->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.swagger_ui.extra_configuration', [])->shouldBeCalled(); $containerBuilder = $containerBuilderProphecy->reveal(); @@ -1172,6 +1173,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo if ($hasSwagger) { $parameters['api_platform.swagger.versions'] = [2, 3]; $parameters['api_platform.swagger.api_keys'] = []; + $parameters['api_platform.swagger_ui.extra_configuration'] = []; } else { $parameters['api_platform.swagger.versions'] = []; } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 1df2208bb80..4f544652f20 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -150,6 +150,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'swagger' => [ 'versions' => [2, 3], 'api_keys' => [], + 'swagger_ui_extra_configuration' => [], ], 'eager_loading' => [ 'enabled' => true, @@ -219,6 +220,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'url' => null, ], 'backward_compatibility_layer' => true, + 'swagger_ui_extra_configuration' => [], ], ], $config); } diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php index 126a3b8bc10..d00df4ba2ec 100644 --- a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -102,6 +102,7 @@ public function getInvokeParameters() 'authorizationUrl' => '', 'scopes' => [], ], + 'extraConfiguration' => [], 'shortName' => 'F', 'operationId' => 'getFCollection', 'id' => null, @@ -136,6 +137,7 @@ public function getInvokeParameters() 'authorizationUrl' => '', 'scopes' => [], ], + 'extraConfiguration' => [], 'shortName' => 'F', 'operationId' => 'getFItem', 'id' => null, @@ -188,6 +190,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'authorizationUrl' => '', 'scopes' => [], ], + 'extraConfiguration' => [], ], ])->shouldBeCalled()->willReturn(''); From aa61122cccd0072386f919751a96520285fe3892 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 1 Mar 2021 16:39:22 +0100 Subject: [PATCH 10/41] fix: unit tests for MongoDB --- .../MongoDbOdm/ItemDataProviderTest.php | 49 +++++++------------ 1 file changed, 18 insertions(+), 31 deletions(-) diff --git a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php index b38d3cd1073..8bce94b8b21 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php @@ -47,18 +47,6 @@ class ItemDataProviderTest extends TestCase { use ProphecyTrait; - private $resourceMetadataFactoryProphecy; - - /** - * {@inheritdoc} - */ - protected function setUp(): void - { - parent::setUp(); - - $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - } - public function testGetItemSingleIdentifier() { $context = ['foo' => 'bar', 'fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -77,17 +65,15 @@ public function testGetItemSingleIdentifier() $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); - $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); - $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -115,7 +101,8 @@ public function testGetItemWithExecuteOptions() ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); - $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata( 'Dummy', null, null, @@ -125,7 +112,7 @@ public function testGetItemWithExecuteOptions() $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $resourceMetadataFactory->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -148,19 +135,17 @@ public function testGetItemDoubleIdentifier() $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', 'idb', ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); - $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); - $context = [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)); } @@ -172,7 +157,7 @@ public function testGetItemWrongCompositeIdentifier() { $this->expectException(PropertyNotFoundException::class); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'ida', 'idb', ]); @@ -185,7 +170,7 @@ public function testGetItemWrongCompositeIdentifier() ], ]); - $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new ItemDataProvider($managerRegistry, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getItem(Dummy::class, 'ida=1;', 'foo'); } @@ -199,7 +184,7 @@ public function testAggregationResultExtension() $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); @@ -210,7 +195,7 @@ public function testAggregationResultExtension() $extensionProphecy->supportsResult(Dummy::class, 'foo', $context)->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, Dummy::class, 'foo', $context)->willReturn([])->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals([], $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -222,11 +207,11 @@ public function testUnsupportedClass() $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); - $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertFalse($dataProvider->supports(Dummy::class, 'foo')); } @@ -245,11 +230,11 @@ public function testCannotCreateAggregationBuilder() $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); - [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + [$propertyNameCollectionFactory, $propertyMetadataFactory, $resourceMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ 'id', ]); - (new ItemDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]))->getItem(Dummy::class, 'foo', null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); + (new ItemDataProvider($managerRegistryProphecy->reveal(), $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]))->getItem(Dummy::class, 'foo', null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); } /** @@ -259,6 +244,7 @@ private function getMetadataFactories(string $resourceClass, array $identifiers) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $nameCollection = ['foobar']; @@ -274,8 +260,9 @@ private function getMetadataFactories(string $resourceClass, array $identifiers) $propertyMetadataFactoryProphecy->create($resourceClass, 'foobar')->willReturn(new PropertyMetadata()); $propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection($nameCollection)); + $resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata('dummy')); - return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()]; + return [$propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal()]; } /** From a3f3d7c7a5a64d70ab313bfb236a71dbe2ace67f Mon Sep 17 00:00:00 2001 From: Florian Moser Date: Tue, 2 Mar 2021 10:32:21 +0100 Subject: [PATCH 11/41] Add nulls_always_first and nulls_always_last to nulls_comparison (#4103) --- CHANGELOG.md | 1 + .../Common/Filter/OrderFilterInterface.php | 10 +++ .../Common/Filter/OrderFilterTestTrait.php | 56 ++++++++++++++++ .../MongoDbOdm/Filter/OrderFilterTest.php | 64 +++++++++++++++++++ .../Doctrine/Orm/Filter/OrderFilterTest.php | 20 ++++++ 5 files changed, 151 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 148362352a0..88ad8b83d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * MongoDB: `date_immutable` support (#3940) * DataProvider: Add `TraversablePaginator` (#3783) * Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) +* OrderFilter: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` (#4103) ## 2.6.3 diff --git a/src/Bridge/Doctrine/Common/Filter/OrderFilterInterface.php b/src/Bridge/Doctrine/Common/Filter/OrderFilterInterface.php index 4edbeaad129..d348c10bd02 100644 --- a/src/Bridge/Doctrine/Common/Filter/OrderFilterInterface.php +++ b/src/Bridge/Doctrine/Common/Filter/OrderFilterInterface.php @@ -26,6 +26,8 @@ interface OrderFilterInterface public const DIRECTION_DESC = 'DESC'; public const NULLS_SMALLEST = 'nulls_smallest'; public const NULLS_LARGEST = 'nulls_largest'; + public const NULLS_ALWAYS_FIRST = 'nulls_always_first'; + public const NULLS_ALWAYS_LAST = 'nulls_always_last'; public const NULLS_DIRECTION_MAP = [ self::NULLS_SMALLEST => [ 'ASC' => 'ASC', @@ -35,5 +37,13 @@ interface OrderFilterInterface 'ASC' => 'DESC', 'DESC' => 'ASC', ], + self::NULLS_ALWAYS_FIRST => [ + 'ASC' => 'ASC', + 'DESC' => 'ASC', + ], + self::NULLS_ALWAYS_LAST => [ + 'ASC' => 'DESC', + 'DESC' => 'DESC', + ], ]; } diff --git a/tests/Bridge/Doctrine/Common/Filter/OrderFilterTestTrait.php b/tests/Bridge/Doctrine/Common/Filter/OrderFilterTestTrait.php index f9b4e6f6507..3e1e9464459 100644 --- a/tests/Bridge/Doctrine/Common/Filter/OrderFilterTestTrait.php +++ b/tests/Bridge/Doctrine/Common/Filter/OrderFilterTestTrait.php @@ -232,6 +232,62 @@ private function provideApplyTestArguments(): array ], ], ], + 'nulls_always_first (asc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_first', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_first (desc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_first', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'desc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_last (asc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_last', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'asc', + 'name' => 'desc', + ], + ], + ], + 'nulls_always_last (desc)' => [ + [ + 'dummyDate' => [ + 'nulls_comparison' => 'nulls_always_last', + ], + 'name' => null, + ], + [ + 'order' => [ + 'dummyDate' => 'desc', + 'name' => 'desc', + ], + ], + ], 'not having order should not throw a deprecation (select unchanged)' => [ [ 'id' => null, diff --git a/tests/Bridge/Doctrine/MongoDbOdm/Filter/OrderFilterTest.php b/tests/Bridge/Doctrine/MongoDbOdm/Filter/OrderFilterTest.php index bdddb3877fa..9279532f8e6 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/Filter/OrderFilterTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/Filter/OrderFilterTest.php @@ -436,6 +436,70 @@ public function provideApplyTestData(): array ], $orderFilterFactory, ], + 'nulls_always_first (asc)' => [ + [ + [ + '$sort' => [ + 'dummyDate' => 1, + ], + ], + [ + '$sort' => [ + 'dummyDate' => 1, + 'name' => -1, + ], + ], + ], + $orderFilterFactory, + ], + 'nulls_always_first (desc)' => [ + [ + [ + '$sort' => [ + 'dummyDate' => -1, + ], + ], + [ + '$sort' => [ + 'dummyDate' => -1, + 'name' => -1, + ], + ], + ], + $orderFilterFactory, + ], + 'nulls_always_last (asc)' => [ + [ + [ + '$sort' => [ + 'dummyDate' => 1, + ], + ], + [ + '$sort' => [ + 'dummyDate' => 1, + 'name' => -1, + ], + ], + ], + $orderFilterFactory, + ], + 'nulls_always_last (desc)' => [ + [ + [ + '$sort' => [ + 'dummyDate' => -1, + ], + ], + [ + '$sort' => [ + 'dummyDate' => -1, + 'name' => -1, + ], + ], + ], + $orderFilterFactory, + ], 'not having order should not throw a deprecation (select unchanged)' => [ [], $orderFilterFactory, diff --git a/tests/Bridge/Doctrine/Orm/Filter/OrderFilterTest.php b/tests/Bridge/Doctrine/Orm/Filter/OrderFilterTest.php index 77417ba1714..fa5c16cdc3c 100644 --- a/tests/Bridge/Doctrine/Orm/Filter/OrderFilterTest.php +++ b/tests/Bridge/Doctrine/Orm/Filter/OrderFilterTest.php @@ -265,6 +265,26 @@ public function provideApplyTestData(): array null, $orderFilterFactory, ], + 'nulls_always_first (asc)' => [ + sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate ASC, o.name DESC', Dummy::class), + null, + $orderFilterFactory, + ], + 'nulls_always_first (desc)' => [ + sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank ASC, o.dummyDate DESC, o.name DESC', Dummy::class), + null, + $orderFilterFactory, + ], + 'nulls_always_last (asc)' => [ + sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate ASC, o.name DESC', Dummy::class), + null, + $orderFilterFactory, + ], + 'nulls_always_last (desc)' => [ + sprintf('SELECT o, CASE WHEN o.dummyDate IS NULL THEN 0 ELSE 1 END AS HIDDEN _o_dummyDate_null_rank FROM %s o ORDER BY _o_dummyDate_null_rank DESC, o.dummyDate DESC, o.name DESC', Dummy::class), + null, + $orderFilterFactory, + ], 'not having order should not throw a deprecation (select unchanged)' => [ sprintf('SELECT o FROM %s o', Dummy::class), null, From 3a8329f09ded4d42e807ebe92006921efebbfe98 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 2 Mar 2021 11:31:45 +0100 Subject: [PATCH 12/41] Add every pagination options in PaginationOptions (#4065) * Add every pagination options in PaginationOptions * compat php 7.1 :'( --- .../Bundle/Resources/config/data_provider.xml | 5 +++ src/DataProvider/PaginationOptions.php | 42 ++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml index a838e119689..33a513bfad4 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml @@ -41,6 +41,11 @@ %api_platform.collection.pagination.items_per_page_parameter_name% %api_platform.collection.pagination.client_enabled% %api_platform.collection.pagination.enabled_parameter_name% + %api_platform.collection.pagination.items_per_page% + %api_platform.collection.pagination.maximum_items_per_page% + %api_platform.collection.pagination.partial% + %api_platform.collection.pagination.client_partial% + %api_platform.collection.pagination.partial_parameter_name% diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php index 1db7646943f..3239383b6a0 100644 --- a/src/DataProvider/PaginationOptions.php +++ b/src/DataProvider/PaginationOptions.php @@ -21,8 +21,13 @@ final class PaginationOptions private $itemsPerPageParameterName; private $paginationClientEnabled; private $paginationClientEnabledParameterName; + private $itemsPerPage; + private $maximumItemsPerPage; + private $partialPaginationEnabled; + private $clientPartialPaginationEnabled; + private $partialPaginationParameterName; - public function __construct(bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination') + public function __construct(bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination', int $itemsPerPage = 30, int $maximumItemsPerPage = null, bool $partialPaginationEnabled = false, bool $clientPartialPaginationEnabled = false, string $partialPaginationParameterName = 'partial') { $this->paginationEnabled = $paginationEnabled; $this->paginationPageParameterName = $paginationPageParameterName; @@ -30,6 +35,11 @@ public function __construct(bool $paginationEnabled = true, string $paginationPa $this->itemsPerPageParameterName = $itemsPerPageParameterName; $this->paginationClientEnabled = $paginationClientEnabled; $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName; + $this->itemsPerPage = $itemsPerPage; + $this->maximumItemsPerPage = $maximumItemsPerPage; + $this->partialPaginationEnabled = $partialPaginationEnabled; + $this->clientPartialPaginationEnabled = $clientPartialPaginationEnabled; + $this->partialPaginationParameterName = $partialPaginationParameterName; } public function isPaginationEnabled(): bool @@ -57,8 +67,38 @@ public function getPaginationClientEnabled(): bool return $this->paginationClientEnabled; } + public function isPaginationClientEnabled(): bool + { + return $this->paginationClientEnabled; + } + public function getPaginationClientEnabledParameterName(): string { return $this->paginationClientEnabledParameterName; } + + public function getItemsPerPage(): int + { + return $this->itemsPerPage; + } + + public function getMaximumItemsPerPage(): int + { + return $this->maximumItemsPerPage; + } + + public function isPartialPaginationEnabled(): bool + { + return $this->partialPaginationEnabled; + } + + public function isClientPartialPaginationEnabled(): bool + { + return $this->clientPartialPaginationEnabled; + } + + public function getPartialPaginationParameterName(): string + { + return $this->partialPaginationParameterName; + } } From 6f88fb4e37904041bbae1c605e7fefab0b5a28e6 Mon Sep 17 00:00:00 2001 From: Joe Bennett Date: Wed, 3 Mar 2021 02:48:52 +1000 Subject: [PATCH 13/41] #3577 moved content negotiation to before firewall (#3599) --- CHANGELOG.md | 3 +- features/filter/filter_validation.feature | 5 +- features/main/content_negotiation.feature | 20 ++++++-- .../Symfony/Bundle/Resources/config/api.xml | 4 +- src/EventListener/AddFormatListener.php | 10 ++-- .../Security/AuthenticationEntryPoint.php | 49 +++++++++++++++++++ tests/Fixtures/app/AppKernel.php | 1 + tests/Fixtures/app/config/config_common.yml | 4 ++ 8 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Security/AuthenticationEntryPoint.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ad8b83d63..24336e30781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,13 @@ ## 2.7.0 +* **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599) * Doctrine: Better exception to find which resource is linked to an exception (#3965) * Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) +* Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103) * MongoDB: `date_immutable` support (#3940) * DataProvider: Add `TraversablePaginator` (#3783) * Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) -* OrderFilter: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` (#4103) ## 2.6.3 diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index 23b63bcff09..69648bbb8ff 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -1,5 +1,8 @@ Feature: Validate filters based upon filter description + Background: + Given I add "Accept" header equal to "application/json" + @createSchema Scenario: Required filter should not throw an error if set When I am on "/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=" @@ -13,7 +16,7 @@ Feature: Validate filters based upon filter description Scenario: Required filter should throw an error if not set When I am on "/filter_validators" Then the response status code should be 400 - Then the JSON node "detail" should match '/^Query parameter "required" is required\nQuery parameter "required-allow-empty" is required$/' + And the JSON node "detail" should match '/^Query parameter "required" is required\nQuery parameter "required-allow-empty" is required$/' Scenario: Required filter should not throw an error if set When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo" diff --git a/features/main/content_negotiation.feature b/features/main/content_negotiation.feature index f6f6593c448..7f22db3b396 100644 --- a/features/main/content_negotiation.feature +++ b/features/main/content_negotiation.feature @@ -22,7 +22,7 @@ Feature: Content Negotiation support 1XML! """ - Scenario: Retrieve a collection in XML + Scenario: Retrieve a collection in XML When I add "Accept" header equal to "text/xml" And I send a "GET" request to "/dummies" Then the response status code should be 200 @@ -34,7 +34,7 @@ Feature: Content Negotiation support 1XML! """ - Scenario: Retrieve a collection in XML using the .xml URL + Scenario: Retrieve a collection in XML using the .xml URL When I send a "GET" request to "/dummies.xml" Then the response status code should be 200 And the header "Content-Type" should be equal to "application/xml; charset=utf-8" @@ -45,7 +45,7 @@ Feature: Content Negotiation support 1XML! """ - Scenario: Retrieve a collection in JSON + Scenario: Retrieve a collection in JSON When I add "Accept" header equal to "application/json" And I send a "GET" request to "/dummies" Then the response status code should be 200 @@ -155,3 +155,17 @@ Feature: Content Negotiation support id,name 1,Kevin """ + + Scenario: Get a security response in JSON + Given there are 1 SecuredDummy objects + And I add "Accept" header equal to "application/json" + When I send a "GET" request to "/secured_dummies" + Then the response status code should be 401 + And the header "Content-Type" should be equal to "application/json" + And the response should be in JSON + And the JSON should be equal to: + """ + { + "message": "Authentication Required" + } + """ diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index cb4bf137755..9182d01a4c7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -150,15 +150,15 @@ - %api_platform.formats% - + + diff --git a/src/EventListener/AddFormatListener.php b/src/EventListener/AddFormatListener.php index 840b0f663d6..d5471c24ad3 100644 --- a/src/EventListener/AddFormatListener.php +++ b/src/EventListener/AddFormatListener.php @@ -65,11 +65,11 @@ public function __construct(Negotiator $negotiator, $resourceMetadataFactory, ar public function onKernelRequest(RequestEvent $event): void { $request = $event->getRequest(); - if ( - !($request->attributes->has('_api_resource_class') - || $request->attributes->getBoolean('_api_respond', false) - || $request->attributes->getBoolean('_graphql', false)) - ) { + if (!( + $request->attributes->has('_api_resource_class') + || $request->attributes->getBoolean('_api_respond', false) + || $request->attributes->getBoolean('_graphql', false) + )) { return; } diff --git a/tests/Fixtures/TestBundle/Security/AuthenticationEntryPoint.php b/tests/Fixtures/TestBundle/Security/AuthenticationEntryPoint.php new file mode 100644 index 00000000000..cbc88c0890a --- /dev/null +++ b/tests/Fixtures/TestBundle/Security/AuthenticationEntryPoint.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Security; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface; + +final class AuthenticationEntryPoint implements AuthenticationEntryPointInterface +{ + private $router; + + public function __construct(RouterInterface $router) + { + $this->router = $router; + } + + public function start(Request $request, AuthenticationException $authException = null): Response + { + if ('html' === $request->getRequestFormat()) { + return new RedirectResponse($this->router->generate('api_doc', [], UrlGeneratorInterface::ABSOLUTE_URL)); + } + if ('json' === $request->getRequestFormat()) { + return new JsonResponse( + ['message' => 'Authentication Required'], + Response::HTTP_UNAUTHORIZED, + ['WWW-Authenticate' => 'Bearer realm="example"'] + ); + } + + return new Response('', Response::HTTP_UNAUTHORIZED); + } +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 128d952fd6a..5251e4eef7d 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -168,6 +168,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'http_basic' => null, 'anonymous' => null, 'stateless' => true, + 'entry_point' => 'app.security.authentication_entrypoint', ], ], 'access_control' => [ diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 69db4cfb002..aa230d66027 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -359,3 +359,7 @@ services: tags: - { name: 'api_platform.data_transformer' } + app.security.authentication_entrypoint: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Security\AuthenticationEntryPoint' + arguments: + $router: '@router' From 7d9533723523d3a0c14c7c7d0c767fc5efd0435b Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Tue, 9 Mar 2021 10:11:19 +0100 Subject: [PATCH 14/41] Add @final to ORM filters (#4109) --- CHANGELOG.md | 1 + src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php | 2 ++ src/Bridge/Doctrine/Orm/Filter/DateFilter.php | 2 ++ src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php | 4 +++- src/Bridge/Doctrine/Orm/Filter/NumericFilter.php | 2 ++ src/Bridge/Doctrine/Orm/Filter/OrderFilter.php | 2 ++ src/Bridge/Doctrine/Orm/Filter/RangeFilter.php | 2 ++ src/Bridge/Doctrine/Orm/Filter/SearchFilter.php | 2 ++ 8 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47df3d53557..aa00b184c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.7.0 * **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599) +* **BC**: Use `@final` annotation in ORM filters (#4109) * Doctrine: Better exception to find which resource is linked to an exception (#3965) * Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) * Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103) diff --git a/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php b/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php index 3203f18b976..8c136c2c155 100644 --- a/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/BooleanFilter.php @@ -29,6 +29,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @final */ class BooleanFilter extends AbstractContextAwareFilter { diff --git a/src/Bridge/Doctrine/Orm/Filter/DateFilter.php b/src/Bridge/Doctrine/Orm/Filter/DateFilter.php index 9970eadf3b4..b6010170f90 100644 --- a/src/Bridge/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/DateFilter.php @@ -25,6 +25,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @final */ class DateFilter extends AbstractContextAwareFilter implements DateFilterInterface { diff --git a/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php b/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php index 209adbef92a..24f86d31a35 100644 --- a/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/ExistsFilter.php @@ -37,6 +37,8 @@ * Interpretation: filter products which have a brand * * @author Teoh Han Hui + * + * @final */ class ExistsFilter extends AbstractContextAwareFilter implements ExistsFilterInterface { @@ -74,7 +76,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB if (\func_num_args() > 6) { $context = func_get_arg(6); } else { - if (__CLASS__ !== static::class) { + if (__CLASS__ !== static::class) { /** @phpstan-ignore-line The class was not final before */ $r = new \ReflectionMethod($this, __FUNCTION__); if (__CLASS__ !== $r->getDeclaringClass()->getName()) { @trigger_error(sprintf('Method %s() will have a seventh `$context` argument in version API Platform 3.0. Not defining it is deprecated since API Platform 2.5.', __FUNCTION__), \E_USER_DEPRECATED); diff --git a/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php b/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php index eb5f0a692e7..1cbf3e2c507 100644 --- a/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/NumericFilter.php @@ -28,6 +28,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @final */ class NumericFilter extends AbstractContextAwareFilter { diff --git a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php index e9829396abc..7eecd52d7b1 100644 --- a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php @@ -36,6 +36,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @final */ class OrderFilter extends AbstractContextAwareFilter implements OrderFilterInterface { diff --git a/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php b/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php index 394c65f9bc1..ead733e20e2 100644 --- a/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/RangeFilter.php @@ -22,6 +22,8 @@ * Filters the collection by range. * * @author Lee Siong Chan + * + * @final */ class RangeFilter extends AbstractContextAwareFilter implements RangeFilterInterface { diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index 858406d421e..4ce6cd4ab75 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -35,6 +35,8 @@ * Filter the collection by given properties. * * @author Kévin Dunglas + * + * @final */ class SearchFilter extends AbstractContextAwareFilter implements SearchFilterInterface { From 4717bd5643d7761707229ed8705ae6824248f7e3 Mon Sep 17 00:00:00 2001 From: Julien Falque Date: Tue, 9 Mar 2021 11:00:23 +0100 Subject: [PATCH 15/41] Allow defining exception_to_status per operation (#3519) --- CHANGELOG.md | 1 + src/Action/ExceptionAction.php | 35 +++- src/Annotation/ApiResource.php | 5 +- .../Symfony/Bundle/Resources/config/api.xml | 1 + tests/Action/ExceptionActionTest.php | 150 ++++++++++++++++++ tests/Annotation/ApiResourceTest.php | 6 + 6 files changed, 195 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa00b184c1b..e304f6de76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599) * **BC**: Use `@final` annotation in ORM filters (#4109) +* Allow defining `exception_to_status` per operation (#3519) * Doctrine: Better exception to find which resource is linked to an exception (#3965) * Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) * Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103) diff --git a/src/Action/ExceptionAction.php b/src/Action/ExceptionAction.php index 8476c21bb9b..7f227e9eafd 100644 --- a/src/Action/ExceptionAction.php +++ b/src/Action/ExceptionAction.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Core\Action; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ErrorFormatGuesser; +use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\HttpFoundation\Request; @@ -31,16 +33,18 @@ final class ExceptionAction private $serializer; private $errorFormats; private $exceptionToStatus; + private $resourceMetadataFactory; /** * @param array $errorFormats A list of enabled error formats * @param array $exceptionToStatus A list of exceptions mapped to their HTTP status code */ - public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = []) + public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = [], ?ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->serializer = $serializer; $this->errorFormats = $errorFormats; $this->exceptionToStatus = $exceptionToStatus; + $this->resourceMetadataFactory = $resourceMetadataFactory; } /** @@ -53,7 +57,12 @@ public function __invoke($exception, Request $request): Response $exceptionClass = $exception->getClass(); $statusCode = $exception->getStatusCode(); - foreach ($this->exceptionToStatus as $class => $status) { + $exceptionToStatus = array_merge( + $this->exceptionToStatus, + $this->getOperationExceptionToStatus($request) + ); + + foreach ($exceptionToStatus as $class => $status) { if (is_a($exceptionClass, $class, true)) { $statusCode = $status; @@ -69,4 +78,26 @@ public function __invoke($exception, Request $request): Response return new Response($this->serializer->serialize($exception, $format['key'], ['statusCode' => $statusCode]), $statusCode, $headers); } + + private function getOperationExceptionToStatus(Request $request): array + { + $attributes = RequestAttributesExtractor::extractAttributes($request); + + if ([] === $attributes || null === $this->resourceMetadataFactory) { + return []; + } + + $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); + $operationExceptionToStatus = $resourceMetadata->getOperationAttribute($attributes, 'exception_to_status', [], false); + $resourceExceptionToStatus = $resourceMetadata->getAttribute('exception_to_status', []); + + if (!\is_array($operationExceptionToStatus) || !\is_array($resourceExceptionToStatus)) { + throw new \LogicException('"exception_to_status" attribute should be an array.'); + } + + return array_merge( + $resourceExceptionToStatus, + $operationExceptionToStatus + ); + } } diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index a591557e10b..d553287f836 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -70,6 +70,7 @@ * @Attribute("swaggerContext", type="array"), * @Attribute("urlGenerationStrategy", type="int"), * @Attribute("validationGroups", type="mixed"), + * @Attribute("exceptionToStatus", type="array"), * ) */ #[\Attribute(\Attribute::TARGET_CLASS)] @@ -170,6 +171,7 @@ final class ApiResource * @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups * @param int $urlGenerationStrategy + * @param array $exceptionToStatus https://api-platform.com/docs/core/errors/#fine-grained-configuration * * @throws InvalidArgumentException */ @@ -219,7 +221,8 @@ public function __construct( ?array $swaggerContext = null, ?array $validationGroups = null, ?int $urlGenerationStrategy = null, - ?bool $compositeIdentifier = null + ?bool $compositeIdentifier = null, + ?array $exceptionToStatus = null ) { if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 9182d01a4c7..1f4cf3a1dcb 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -249,6 +249,7 @@ %api_platform.error_formats% %api_platform.exception_to_status% + diff --git a/tests/Action/ExceptionActionTest.php b/tests/Action/ExceptionActionTest.php index 6d7a7164958..24911e160a1 100644 --- a/tests/Action/ExceptionActionTest.php +++ b/tests/Action/ExceptionActionTest.php @@ -15,7 +15,10 @@ use ApiPlatform\Core\Action\ExceptionAction; use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\ProphecyTrait; +use DomainException; use PHPUnit\Framework\TestCase; use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException; use Symfony\Component\ErrorHandler\Exception\FlattenException; @@ -57,6 +60,153 @@ public function testActionWithCatchableException() $this->assertTrue($response->headers->contains('X-Frame-Options', 'deny')); } + /** + * @dataProvider provideOperationExceptionToStatusCases + */ + public function testActionWithOperationExceptionToStatus( + array $globalExceptionToStatus, + ?array $resourceExceptionToStatus, + ?array $operationExceptionToStatus, + int $expectedStatusCode + ) { + $exception = new DomainException(); + $flattenException = FlattenException::create($exception); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($flattenException, 'jsonproblem', ['statusCode' => $expectedStatusCode])->willReturn(); + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactory->create('Foo')->willReturn(new ResourceMetadata( + 'Foo', + null, + null, + [ + 'operation' => null !== $operationExceptionToStatus ? ['exception_to_status' => $operationExceptionToStatus] : [], + ], + null, + null !== $resourceExceptionToStatus ? ['exception_to_status' => $resourceExceptionToStatus] : [] + )); + + $exceptionAction = new ExceptionAction( + $serializer->reveal(), + [ + 'jsonproblem' => ['application/problem+json'], + 'jsonld' => ['application/ld+json'], + ], + $globalExceptionToStatus, + $resourceMetadataFactory->reveal() + ); + + $request = new Request(); + $request->setFormat('jsonproblem', 'application/problem+json'); + $request->attributes->replace([ + '_api_resource_class' => 'Foo', + '_api_item_operation_name' => 'operation', + ]); + + $response = $exceptionAction($flattenException, $request); + + $this->assertSame('', $response->getContent()); + $this->assertSame($expectedStatusCode, $response->getStatusCode()); + $this->assertTrue($response->headers->contains('Content-Type', 'application/problem+json; charset=utf-8')); + $this->assertTrue($response->headers->contains('X-Content-Type-Options', 'nosniff')); + $this->assertTrue($response->headers->contains('X-Frame-Options', 'deny')); + } + + public function provideOperationExceptionToStatusCases() + { + yield 'no mapping' => [ + [], + null, + null, + 500, + ]; + + yield 'on global attributes' => [ + [DomainException::class => 100], + null, + null, + 100, + ]; + + yield 'on global attributes with empty resource and operation attributes' => [ + [DomainException::class => 100], + [], + [], + 100, + ]; + + yield 'on global attributes and resource attributes' => [ + [DomainException::class => 100], + [DomainException::class => 200], + null, + 200, + ]; + + yield 'on global attributes and resource attributes with empty operation attributes' => [ + [DomainException::class => 100], + [DomainException::class => 200], + [], + 200, + ]; + + yield 'on global attributes and operation attributes' => [ + [DomainException::class => 100], + null, + [DomainException::class => 300], + 300, + ]; + + yield 'on global attributes and operation attributes with empty resource attributes' => [ + [DomainException::class => 100], + [], + [DomainException::class => 300], + 300, + ]; + + yield 'on global, resource and operation attributes' => [ + [DomainException::class => 100], + [DomainException::class => 200], + [DomainException::class => 300], + 300, + ]; + + yield 'on resource attributes' => [ + [], + [DomainException::class => 200], + null, + 200, + ]; + + yield 'on resource attributes with empty operation attributes' => [ + [], + [DomainException::class => 200], + [], + 200, + ]; + + yield 'on resource and operation attributes' => [ + [], + [DomainException::class => 200], + [DomainException::class => 300], + 300, + ]; + + yield 'on operation attributes' => [ + [], + null, + [DomainException::class => 300], + 300, + ]; + + yield 'on operation attributes with empty resource attributes' => [ + [], + [], + [DomainException::class => 300], + 300, + ]; + } + public function testActionWithUncatchableException() { $serializerException = $this->prophesize(ExceptionInterface::class); diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 8835ee6e9ca..078f2dd19d7 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -159,6 +159,9 @@ public function testConstructAttribute() hydraContext: ['hydra' => 'foo'], paginationViaCursor: ['foo'], stateless: true, + exceptionToStatus: [ + \DomainException::class => 400, + ], ); PHP ); @@ -208,6 +211,9 @@ public function testConstructAttribute() 'pagination_via_cursor' => ['foo'], 'stateless' => true, 'composite_identifier' => null, + 'exception_to_status' => [ + \DomainException::class => 400, + ], ], $resource->attributes); } From 7ba0628089d2239afeebb562d0589411e569854c Mon Sep 17 00:00:00 2001 From: Andreas Schacht Date: Wed, 17 Mar 2021 17:24:20 +0100 Subject: [PATCH 16/41] JSON:API add inclusion of resources from path (#3288) Co-authored-by: Alan Poulain --- CHANGELOG.md | 1 + .../related-resouces-inclusion.feature | 691 ++++++++++++++++++ src/JsonApi/Serializer/ItemNormalizer.php | 66 +- tests/Behat/DoctrineContext.php | 47 ++ 4 files changed, 797 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e304f6de76d..8fff37221a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103) * MongoDB: `date_immutable` support (#3940) * DataProvider: Add `TraversablePaginator` (#3783) +* JSON:API: Support inclusion of resources from path (#3288) * Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) ## 2.6.3 diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index d2850d7ea49..a7d07dbf3b5 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -391,6 +391,480 @@ Feature: JSON API Inclusion of Related Resources } """ + @createSchema + Scenario: Request inclusion of resources from path + Given there is a dummy object with a fourth level relation + When I send a "GET" request to "/dummies/1?include=relatedDummy.thirdLevel.fourthLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + }, + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + ] + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "Hello", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + }, + "relationships": { + "fourthLevel": { + "data": { + "type": "FourthLevel", + "id": "/fourth_levels/1" + } + } + } + }, + { + "id": "/fourth_levels/1", + "type": "FourthLevel", + "attributes": { + "_id": 1, + "level": 4 + } + } + ] + } + """ + + @createSchema + Scenario: Request inclusion of resources from path with collection + Given there is a dummy object with 3 relatedDummies and their thirdLevel + When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "RelatedDummy #1", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" + } + } + } + }, + { + "id": "/third_levels/2", + "type": "ThirdLevel", + "attributes": { + "_id": 2, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "_id": 3, + "name": "RelatedDummy #3", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/3" + } + } + } + }, + { + "id": "/third_levels/3", + "type": "ThirdLevel", + "attributes": { + "_id": 3, + "level": 3, + "test": true + } + } + ] + } + """ + + @createSchema + Scenario: Do not include the requested resource + Given there is a RelatedOwningDummy object with OneToOne relation + When I send a "GET" request to "/dummies/1?include=relatedOwningDummy.ownedDummy" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null, + "_id": 1, + "name": "plop", + "alias": null, + "foo": null + }, + "relationships": { + "relatedOwningDummy": { + "data": { + "type": "RelatedOwningDummy", + "id": "/related_owning_dummies/1" + } + } + } + }, + "included": [ + { + "id": "/related_owning_dummies/1", + "type": "RelatedOwningDummy", + "attributes": { + "name": null, + "_id": 1 + }, + "relationships": { + "ownedDummy": { + "data": { + "type": "Dummy", + "id": "/dummies/1" + } + } + } + } + ] + } + """ + + @createSchema + Scenario: Do not include resources multiple times + Given there is a dummy object with 3 relatedDummies with same thirdLevel + When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be equal to: + """ + { + "data": { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy with relations", + "alias": null, + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummies": { + "data": [ + { + "type": "RelatedDummy", + "id": "/related_dummies/1" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/2" + }, + { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + ] + } + } + }, + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "RelatedDummy #1", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "_id": 3, + "name": "RelatedDummy #3", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + } + ] + } + """ + + @createSchema Scenario: Request inclusion of a related resources on collection Given there are 3 dummy property objects @@ -674,3 +1148,220 @@ Feature: JSON API Inclusion of Related Resources }] } """ + + @createSchema + Scenario: Request inclusion from path of resource with relation + Given there are 3 dummy objects with relatedDummy and its thirdLevel + When I send a "GET" request to "/dummies?include=relatedDummy.thirdLevel" + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be valid according to the JSON API schema + And the JSON should be equal to: + """ + { + "links": { + "self": "/dummies?include=relatedDummy.thirdLevel" + }, + "meta": { + "totalItems": 3, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "/dummies/1", + "type": "Dummy", + "attributes": { + "_id": 1, + "name": "Dummy #1", + "alias": "Alias #2", + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/1" + } + } + } + }, + { + "id": "/dummies/2", + "type": "Dummy", + "attributes": { + "_id": 2, + "name": "Dummy #2", + "alias": "Alias #1", + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/2" + } + } + } + }, + { + "id": "/dummies/3", + "type": "Dummy", + "attributes": { + "_id": 3, + "name": "Dummy #3", + "alias": "Alias #0", + "foo": null, + "description": null, + "dummy": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "jsonData": [], + "arrayData": [], + "name_converted": null + }, + "relationships": { + "relatedDummy": { + "data": { + "type": "RelatedDummy", + "id": "/related_dummies/3" + } + } + } + } + ], + "included": [ + { + "id": "/related_dummies/1", + "type": "RelatedDummy", + "attributes": { + "_id": 1, + "name": "RelatedDummy #1", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/1" + } + } + } + }, + { + "id": "/third_levels/1", + "type": "ThirdLevel", + "attributes": { + "_id": 1, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/2", + "type": "RelatedDummy", + "attributes": { + "_id": 2, + "name": "RelatedDummy #2", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/2" + } + } + } + }, + { + "id": "/third_levels/2", + "type": "ThirdLevel", + "attributes": { + "_id": 2, + "level": 3, + "test": true + } + }, + { + "id": "/related_dummies/3", + "type": "RelatedDummy", + "attributes": { + "_id": 3, + "name": "RelatedDummy #3", + "symfony": "symfony", + "dummyDate": null, + "dummyBoolean": null, + "embeddedDummy": { + "dummyName": null, + "dummyBoolean": null, + "dummyDate": null, + "dummyFloat": null, + "dummyPrice": null, + "symfony": null + }, + "age": null + }, + "relationships": { + "thirdLevel": { + "data": { + "type": "ThirdLevel", + "id": "/third_levels/3" + } + } + } + }, + { + "id": "/third_levels/3", + "type": "ThirdLevel", + "attributes": { + "_id": 3, + "level": 3, + "test": true + } + } + ] + } + """ diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 2beb8cab51a..bf588d8c02f 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -94,6 +94,10 @@ public function normalize($object, $format = null, array $context = []) $allRelationshipsData = $this->getComponents($object, $format, $context)['relationships']; $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($object, $format, $populatedRelationContext, $allRelationshipsData); + + // Do not include primary resources + $context['api_included_resources'] = [$context['iri']]; + $includedResourcesData = $this->getRelatedResources($object, $format, $context, $allRelationshipsData); $resourceData = [ @@ -370,32 +374,78 @@ private function getRelatedResources($object, ?string $format, array $context, a $included = []; foreach ($relationships as $relationshipDataArray) { - if (!\in_array($relationshipDataArray['name'], $context['api_included'], true)) { + $relationshipName = $relationshipDataArray['name']; + + if (!$this->shouldIncludeRelation($relationshipName, $context)) { continue; } - $relationshipName = $relationshipDataArray['name']; $relationContext = $context; + $relationContext['api_included'] = $this->getIncludedNestedResources($relationshipName, $context); + $attributeValue = $this->getAttributeValue($object, $relationshipName, $format, $relationContext); if (!$attributeValue) { continue; } + // Many to many relationship + $attributeValues = $attributeValue; // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $included[] = $attributeValue['data']; - - continue; + $attributeValues = [$attributeValue]; } - // Many to many relationship - foreach ($attributeValue as $attributeValueElement) { + + foreach ($attributeValues as $attributeValueElement) { if (isset($attributeValueElement['data'])) { - $included[] = $attributeValueElement['data']; + $this->addIncluded($attributeValueElement['data'], $included, $context); + if (isset($attributeValueElement['included']) && \is_array($attributeValueElement['included'])) { + foreach ($attributeValueElement['included'] as $include) { + $this->addIncluded($include, $included, $context); + } + } } } } return $included; } + + /** + * Add data to included array if it's not already included. + */ + private function addIncluded(array $data, array &$included, array &$context): void + { + if (isset($data['id']) && !\in_array($data['id'], $context['api_included_resources'], true)) { + $included[] = $data; + // Track already included resources + $context['api_included_resources'][] = $data['id']; + } + } + + /** + * Figures out if the relationship is in the api_included hash or has included nested resources (path). + */ + private function shouldIncludeRelation(string $relationshipName, array $context): bool + { + $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; + + return \in_array($normalizedName, $context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; + } + + /** + * Returns the names of the nested resources from a path relationship. + */ + private function getIncludedNestedResources(string $relationshipName, array $context): array + { + $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; + + $filtered = array_filter($context['api_included'] ?? [], static function (string $included) use ($normalizedName) { + return 0 === strpos($included, $normalizedName.'.'); + }); + + return array_map(static function (string $nested) { + return substr($nested, strpos($nested, '.') + 1); + }, $filtered); + } } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index b2f7ae5066d..d10431be6a2 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -617,6 +617,53 @@ public function thereAreDummyObjectsWithRelatedDummyAndItsThirdLevel(int $nb) $this->manager->flush(); } + /** + * @Given there is a dummy object with :nb relatedDummies and their thirdLevel + */ + public function thereIsADummyObjectWithRelatedDummiesAndTheirThirdLevel(int $nb) + { + $dummy = $this->buildDummy(); + $dummy->setName('Dummy with relations'); + + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = $this->buildThirdLevel(); + + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + + $dummy->addRelatedDummy($relatedDummy); + + $this->manager->persist($thirdLevel); + $this->manager->persist($relatedDummy); + } + $this->manager->persist($dummy); + $this->manager->flush(); + } + + /** + * @Given there is a dummy object with :nb relatedDummies with same thirdLevel + */ + public function thereIsADummyObjectWithRelatedDummiesWithSameThirdLevel(int $nb) + { + $dummy = $this->buildDummy(); + $dummy->setName('Dummy with relations'); + $thirdLevel = $this->buildThirdLevel(); + + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + + $dummy->addRelatedDummy($relatedDummy); + + $this->manager->persist($relatedDummy); + } + $this->manager->persist($thirdLevel); + $this->manager->persist($dummy); + $this->manager->flush(); + } + /** * @Given there are :nb dummy objects with embeddedDummy */ From 9c348d2b4fdc55b55f202a91878e2f4dd8d6cf99 Mon Sep 17 00:00:00 2001 From: xavren Date: Mon, 22 Mar 2021 18:18:19 +0100 Subject: [PATCH 17/41] Add global config for nulls order comparison (#3117) --- CHANGELOG.md | 1 + src/Bridge/Doctrine/Orm/Filter/OrderFilter.php | 7 +++++-- .../Bundle/DependencyInjection/ApiPlatformExtension.php | 1 + .../Symfony/Bundle/DependencyInjection/Configuration.php | 2 ++ .../Symfony/Bundle/Resources/config/doctrine_orm.xml | 1 + .../DependencyInjection/ApiPlatformExtensionTest.php | 1 + .../Bundle/DependencyInjection/ConfigurationTest.php | 1 + 7 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d113ffdb4..4d782466bd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Doctrine: Better exception to find which resource is linked to an exception (#3965) * Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870) * Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103) +* Doctrine: Add a global `order_nulls_comparison` configuration (#3117) * MongoDB: `date_immutable` support (#3940) * DataProvider: Add `TraversablePaginator` (#3783) * JSON:API: Support inclusion of resources from path (#3288) diff --git a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php index c1643ea8cdd..cde61b93368 100644 --- a/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/OrderFilter.php @@ -43,7 +43,9 @@ class OrderFilter extends AbstractContextAwareFilter implements OrderFilterInter { use OrderFilterTrait; - public function __construct(ManagerRegistry $managerRegistry, ?RequestStack $requestStack = null, string $orderParameterName = 'order', LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null) + private $orderNullsComparison; + + public function __construct(ManagerRegistry $managerRegistry, ?RequestStack $requestStack = null, string $orderParameterName = 'order', LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null, ?string $orderNullsComparison = null) { if (null !== $properties) { $properties = array_map(static function ($propertyOptions) { @@ -61,6 +63,7 @@ public function __construct(ManagerRegistry $managerRegistry, ?RequestStack $req parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter); $this->orderParameterName = $orderParameterName; + $this->orderNullsComparison = $orderNullsComparison; } /** @@ -104,7 +107,7 @@ protected function filterProperty(string $property, $direction, QueryBuilder $qu [$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::LEFT_JOIN); } - if (null !== $nullsComparison = $this->properties[$property]['nulls_comparison'] ?? null) { + if (null !== $nullsComparison = $this->properties[$property]['nulls_comparison'] ?? $this->orderNullsComparison) { $nullsDirection = self::NULLS_DIRECTION_MAP[$nullsComparison][$direction]; $nullRankHiddenField = sprintf('_%s_%s_null_rank', $alias, str_replace('.', '_', $field)); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index faa80f5c069..fe910c407a6 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -185,6 +185,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.collection.exists_parameter_name', $config['collection']['exists_parameter_name']); $container->setParameter('api_platform.collection.order', $config['defaults']['order'] ?? $config['collection']['order']); $container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']); + $container->setParameter('api_platform.collection.order_nulls_comparison', $config['collection']['order_nulls_comparison']); $container->setParameter('api_platform.collection.pagination.enabled', $config['defaults']['pagination_enabled'] ?? $this->isConfigEnabled($container, $config['collection']['pagination'])); $container->setParameter('api_platform.collection.pagination.partial', $config['defaults']['pagination_partial'] ?? $config['collection']['pagination']['partial']); $container->setParameter('api_platform.collection.pagination.client_enabled', $config['defaults']['pagination_client_enabled'] ?? $config['collection']['pagination']['client_enabled']); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index a7689079fe5..46c20c9ca51 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Bridge\Doctrine\Common\Filter\OrderFilterInterface; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; @@ -132,6 +133,7 @@ public function getConfigTreeBuilder() ->scalarNode('exists_parameter_name')->defaultValue('exists')->cannotBeEmpty()->info('The name of the query parameter to filter on nullable field values.')->end() ->scalarNode('order')->defaultValue('ASC')->info('The default order of results.')->end() // Default ORDER is required for postgresql and mysql >= 5.7 when using LIMIT/OFFSET request ->scalarNode('order_parameter_name')->defaultValue('order')->cannotBeEmpty()->info('The name of the query parameter to order results.')->end() + ->enumNode('order_nulls_comparison')->defaultNull()->values(array_merge(array_keys(OrderFilterInterface::NULLS_DIRECTION_MAP), [null]))->info('The nulls comparison strategy.')->end() ->arrayNode('pagination') ->canBeDisabled() ->addDefaultsIfNotSet() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml index ec3b07b4c50..86d1d6b31a8 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -68,6 +68,7 @@ %api_platform.collection.order_parameter_name% + %api_platform.collection.order_nulls_comparison% diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d08d64d7a39..4a9c065f3b9 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -883,6 +883,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.collection.exists_parameter_name' => 'exists', 'api_platform.collection.order' => 'ASC', 'api_platform.collection.order_parameter_name' => 'order', + 'api_platform.collection.order_nulls_comparison' => null, 'api_platform.description' => 'description', 'api_platform.error_formats' => ['jsonproblem' => ['application/problem+json'], 'jsonld' => ['application/ld+json']], 'api_platform.formats' => null === $configuration ? ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']] : $this->getFormatsFromConfiguration($configuration['api_platform']['formats']) ?? [], diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index adf0d3a4098..a5cabd4c39f 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -162,6 +162,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'exists_parameter_name' => 'exists', 'order' => 'ASC', 'order_parameter_name' => 'order', + 'order_nulls_comparison' => null, 'pagination' => [ 'enabled' => true, 'partial' => false, From 38775b453629c7ff6f12150ba6e365efdd586679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Fri, 26 Mar 2021 12:02:20 +0200 Subject: [PATCH 18/41] Add support for generating property schema with Unique restriction (#4159) --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 4 ++ .../PropertySchemaUniqueRestriction.php | 40 +++++++++++++ .../ApiPlatformExtensionTest.php | 1 + .../PropertySchemaUniqueRestrictionTest.php | 57 +++++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 28 +++++++++ tests/Fixtures/DummyUniqueValidatedEntity.php | 26 +++++++++ 7 files changed, 157 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestrictionTest.php create mode 100644 tests/Fixtures/DummyUniqueValidatedEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d782466bd6..77701f7b230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema with Unique restriction (#4159) * **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599) * **BC**: Use `@final` annotation in ORM filters (#4109) * Allow defining `exception_to_status` per operation (#3519) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index 77455124011..ab4a914c91a 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -34,6 +34,10 @@ + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestriction.php new file mode 100644 index 00000000000..8a7a0359669 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestriction.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Unique; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaUniqueRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return ['uniqueItems' => true]; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Unique; + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 4a9c065f3b9..362d4da9d28 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1339,6 +1339,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.metadata.property_schema.one_of_restriction', 'api_platform.metadata.property_schema.regex_restriction', 'api_platform.metadata.property_schema.format_restriction', + 'api_platform.metadata.property_schema.unique_restriction', 'api_platform.metadata.property.metadata_factory.yaml', 'api_platform.metadata.property.name_collection_factory.yaml', 'api_platform.metadata.resource.filter_metadata_factory.annotation', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestrictionTest.php new file mode 100644 index 00000000000..43f8f8b49f0 --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaUniqueRestrictionTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaUniqueRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Positive; +use Symfony\Component\Validator\Constraints\Unique; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaUniqueRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaUniqueRestriction; + + protected function setUp(): void + { + $this->propertySchemaUniqueRestriction = new PropertySchemaUniqueRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaUniqueRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported' => [new Unique(), new PropertyMetadata(), true]; + + yield 'not supported' => [new Positive(), new PropertyMetadata(), false]; + } + + public function testCreate(): void + { + self::assertSame(['uniqueItems' => true], $this->propertySchemaUniqueRestriction->create(new Unique(), new PropertyMetadata())); + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index f7c59d07666..3b8118a0456 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -17,12 +17,14 @@ use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaOneOfRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaUniqueRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity; use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyUniqueValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\AnnotationReader; @@ -406,4 +408,30 @@ public function testCreateWithAtLeastOneOfConstraint(): void ['minLength' => 10], ], $schema['oneOf']); } + + public function testCreateWithPropertyUniqueRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyUniqueValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyUniqueValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyUniqueValidatedEntity::class, 'dummyItems', [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaUniqueRestriction()] + ); + + $schema = $validationPropertyMetadataFactory->create(DummyUniqueValidatedEntity::class, 'dummyItems')->getSchema(); + + $this->assertSame(['uniqueItems' => true], $schema); + } } diff --git a/tests/Fixtures/DummyUniqueValidatedEntity.php b/tests/Fixtures/DummyUniqueValidatedEntity.php new file mode 100644 index 00000000000..bfb23be28c6 --- /dev/null +++ b/tests/Fixtures/DummyUniqueValidatedEntity.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyUniqueValidatedEntity +{ + /** + * @var string[] + * + * @Assert\Unique + */ + public $dummyItems; +} From 359b3738b6b03902c869c02c8baa84c56db1b78a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Fri, 26 Mar 2021 16:13:06 +0200 Subject: [PATCH 19/41] Add support for generating property schema with Range restriction (#4158) --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 4 ++ .../PropertySchemaRangeRestriction.php | 51 +++++++++++++ .../ApiPlatformExtensionTest.php | 1 + .../PropertySchemaRangeRestrictionTest.php | 72 +++++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 39 ++++++++++ tests/Fixtures/DummyRangeValidatedEntity.php | 61 ++++++++++++++++ 7 files changed, 229 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php create mode 100644 tests/Fixtures/DummyRangeValidatedEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 77701f7b230..87e7cf1b47b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema with Range restriction (#4158) * JSON Schema: Add support for generating property schema with Unique restriction (#4159) * **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599) * **BC**: Use `@final` annotation in ORM filters (#4109) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index ab4a914c91a..5752356b0fc 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -26,6 +26,10 @@ + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php new file mode 100644 index 00000000000..00a1ca5875d --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Range; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaRangeRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $restriction = []; + + if (isset($constraint->min) && is_numeric($constraint->min)) { + $restriction['minimum'] = $constraint->min; + } + + if (isset($constraint->max) && is_numeric($constraint->max)) { + $restriction['maximum'] = $constraint->max; + } + + return $restriction; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Range && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 362d4da9d28..9cb3d8e4367 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1337,6 +1337,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.metadata.property.metadata_factory.validator', 'api_platform.metadata.property_schema.length_restriction', 'api_platform.metadata.property_schema.one_of_restriction', + 'api_platform.metadata.property_schema.range_restriction', 'api_platform.metadata.property_schema.regex_restriction', 'api_platform.metadata.property_schema.format_restriction', 'api_platform.metadata.property_schema.unique_restriction', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php new file mode 100644 index 00000000000..3891d6c1d60 --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRangeRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\Range; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaRangeRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaRangeRestriction; + + protected function setUp(): void + { + $this->propertySchemaRangeRestriction = new PropertySchemaRangeRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaRangeRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported int' => [new Range(['min' => 1, 'max' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'supported float' => [new Range(['min' => 1, 'max' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), true]; + + yield 'not supported constraint' => [new Length(['min' => 1]), new PropertyMetadata(), false]; + yield 'not supported type' => [new Range(['min' => 1]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), false]; + } + + /** + * @dataProvider createProvider + */ + public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaRangeRestriction->create($constraint, $propertyMetadata)); + } + + public function createProvider(): \Generator + { + yield 'int min' => [new Range(['min' => 1]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['minimum' => 1]]; + yield 'int max' => [new Range(['max' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['maximum' => 10]]; + + yield 'float min' => [new Range(['min' => 1.5]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['minimum' => 1.5]]; + yield 'float max' => [new Range(['max' => 10.5]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['maximum' => 10.5]]; + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 3b8118a0456..02f2c440a19 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaOneOfRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRangeRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaUniqueRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory; @@ -23,6 +24,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyRangeValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyUniqueValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity; @@ -434,4 +436,41 @@ public function testCreateWithPropertyUniqueRestriction(): void $this->assertSame(['uniqueItems' => true], $schema); } + + /** + * @dataProvider provideRangeConstraintCases + */ + public function testCreateWithRangeConstraint(Type $type, string $property, array $expectedSchema): void + { + $validatorClassMetadata = new ClassMetadata(DummyRangeValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyRangeValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyRangeValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata($type) + )->shouldBeCalled(); + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaRangeRestriction()] + ); + $schema = $validationPropertyMetadataFactory->create(DummyRangeValidatedEntity::class, $property)->getSchema(); + + $this->assertSame($expectedSchema, $schema); + } + + public function provideRangeConstraintCases(): \Generator + { + yield 'min int' => ['type' => new Type(Type::BUILTIN_TYPE_INT), 'property' => 'dummyIntMin', 'expectedSchema' => ['minimum' => 1]]; + yield 'max int' => ['type' => new Type(Type::BUILTIN_TYPE_INT), 'property' => 'dummyIntMax', 'expectedSchema' => ['maximum' => 10]]; + yield 'min/max int' => ['type' => new Type(Type::BUILTIN_TYPE_INT), 'property' => 'dummyIntMinMax', 'expectedSchema' => ['minimum' => 1, 'maximum' => 10]]; + yield 'min float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMin', 'expectedSchema' => ['minimum' => 1.5]]; + yield 'max float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMax', 'expectedSchema' => ['maximum' => 10.5]]; + yield 'min/max float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]]; + } } diff --git a/tests/Fixtures/DummyRangeValidatedEntity.php b/tests/Fixtures/DummyRangeValidatedEntity.php new file mode 100644 index 00000000000..5a2a9a0cc5a --- /dev/null +++ b/tests/Fixtures/DummyRangeValidatedEntity.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyRangeValidatedEntity +{ + /** + * @var int + * + * @Assert\Range(min=1) + */ + public $dummyIntMin; + + /** + * @var int + * + * @Assert\Range(max=10) + */ + public $dummyIntMax; + + /** + * @var int + * + * @Assert\Range(min=1, max=10) + */ + public $dummyIntMinMax; + + /** + * @var float + * + * @Assert\Range(min=1.5) + */ + public $dummyFloatMin; + + /** + * @var float + * + * @Assert\Range(max=10.5) + */ + public $dummyFloatMax; + + /** + * @var float + * + * @Assert\Range(min=1.5, max=10.5) + */ + public $dummyFloatMinMax; +} From b7a980e25e7b3aed9fa9584bef913bc856d373b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Fri, 26 Mar 2021 19:47:39 +0200 Subject: [PATCH 20/41] Add support for generating property schema with Choice restriction (#4162) --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 4 + .../PropertySchemaChoiceRestriction.php | 74 +++++++++++++++ .../ApiPlatformExtensionTest.php | 1 + .../PropertySchemaChoiceRestrictionTest.php | 94 +++++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 41 ++++++++ tests/Fixtures/DummyValidatedChoiceEntity.php | 73 ++++++++++++++ 7 files changed, 288 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php create mode 100644 tests/Fixtures/DummyValidatedChoiceEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e7cf1b47b..224ea87c7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema with Choice restriction (#4162) * JSON Schema: Add support for generating property schema with Range restriction (#4158) * JSON Schema: Add support for generating property schema with Unique restriction (#4159) * **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index 5752356b0fc..5243f45dfc5 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -17,6 +17,10 @@ + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php new file mode 100644 index 00000000000..c12630177a5 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Choice; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaChoiceRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + * + * @param Choice $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $choices = []; + + if (\is_callable($choices = $constraint->callback)) { + $choices = $choices(); + } elseif (\is_array($constraint->choices)) { + $choices = $constraint->choices; + } + + if (!$choices) { + return []; + } + + $restriction = []; + + if (!$constraint->multiple) { + $restriction['enum'] = $choices; + + return $restriction; + } + + $restriction['type'] = 'array'; + $restriction['items'] = ['type' => Type::BUILTIN_TYPE_STRING === $propertyMetadata->getType()->getBuiltinType() ? 'string' : 'number', 'enum' => $choices]; + + if (null !== $constraint->min) { + $restriction['minItems'] = $constraint->min; + } + + if (null !== $constraint->max) { + $restriction['maxItems'] = $constraint->max; + } + + return $restriction; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Choice && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 9cb3d8e4367..9708ac6022c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1335,6 +1335,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.metadata.extractor.yaml', 'api_platform.metadata.property.metadata_factory.annotation', 'api_platform.metadata.property.metadata_factory.validator', + 'api_platform.metadata.property_schema.choice_restriction', 'api_platform.metadata.property_schema.length_restriction', 'api_platform.metadata.property_schema.one_of_restriction', 'api_platform.metadata.property_schema.range_restriction', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php new file mode 100644 index 00000000000..36fc8aca64f --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Positive; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaChoiceRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaChoiceRestriction; + + protected function setUp(): void + { + $this->propertySchemaChoiceRestriction = new PropertySchemaChoiceRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported' => [new Choice(['choices' => ['a', 'b']]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), true]; + + yield 'not supported constraint' => [new Positive(), new PropertyMetadata(), false]; + yield 'not supported type' => [new Choice(['choices' => [new \stdClass(), new \stdClass()]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT)), false]; + } + + /** + * @dataProvider createProvider + */ + public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->create($constraint, $propertyMetadata)); + } + + public function createProvider(): \Generator + { + yield 'single string choice' => [new Choice(['choices' => ['a', 'b']]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['enum' => ['a', 'b']]]; + yield 'multi string choice' => [new Choice(['choices' => ['a', 'b'], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]]; + yield 'multi string choice min' => [new Choice(['choices' => ['a', 'b'], 'multiple' => true, 'min' => 2]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']], 'minItems' => 2]]; + yield 'multi string choice max' => [new Choice(['choices' => ['a', 'b', 'c', 'd'], 'multiple' => true, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]]; + yield 'multi string choice min/max' => [new Choice(['choices' => ['a', 'b', 'c', 'd'], 'multiple' => true, 'min' => 2, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]]; + + yield 'single int choice' => [new Choice(['choices' => [1, 2]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['enum' => [1, 2]]]; + yield 'multi int choice' => [new Choice(['choices' => [1, 2], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]]]]; + yield 'multi int choice min' => [new Choice(['choices' => [1, 2], 'multiple' => true, 'min' => 2]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]], 'minItems' => 2]]; + yield 'multi int choice max' => [new Choice(['choices' => [1, 2, 3, 4], 'multiple' => true, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'maxItems' => 4]]; + yield 'multi int choice min/max' => [new Choice(['choices' => [1, 2, 3, 4], 'multiple' => true, 'min' => 2, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'minItems' => 2, 'maxItems' => 4]]; + + yield 'single float choice' => [new Choice(['choices' => [1.1, 2.2]]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['enum' => [1.1, 2.2]]]; + yield 'multi float choice' => [new Choice(['choices' => [1.1, 2.2], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]]]]; + yield 'multi float choice min' => [new Choice(['choices' => [1.1, 2.2], 'multiple' => true, 'min' => 2]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]], 'minItems' => 2]]; + yield 'multi float choice max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]]; + yield 'multi float choice min/max' => [new Choice(['choices' => [1.1, 2.2, 3.3, 4.4], 'multiple' => true, 'min' => 2, 'max' => 4]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]]; + + yield 'single choice callback' => [new Choice(['callback' => [ChoiceCallback::class, 'getChoices']]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['enum' => ['a', 'b', 'c', 'd']]]; + yield 'multi choice callback' => [new Choice(['callback' => [ChoiceCallback::class, 'getChoices'], 'multiple' => true]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]]; + } +} + +final class ChoiceCallback +{ + public static function getChoices(): array + { + return ['a', 'b', 'c', 'd']; + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 02f2c440a19..b7cfa83f6a2 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaOneOfRestriction; @@ -27,6 +28,7 @@ use ApiPlatform\Core\Tests\Fixtures\DummyRangeValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyUniqueValidatedEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyValidatedChoiceEntity; use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\AnnotationReader; @@ -473,4 +475,43 @@ public function provideRangeConstraintCases(): \Generator yield 'max float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMax', 'expectedSchema' => ['maximum' => 10.5]]; yield 'min/max float' => ['type' => new Type(Type::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]]; } + + /** + * @dataProvider provideChoiceConstraintCases + */ + public function testCreateWithPropertyChoiceRestriction(PropertyMetadata $propertyMetadata, string $property, array $expectedSchema): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedChoiceEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedChoiceEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property, [])->willReturn( + $propertyMetadata + )->shouldBeCalled(); + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaChoiceRestriction()] + ); + + $schema = $validationPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property)->getSchema(); + + $this->assertSame($expectedSchema, $schema); + } + + public function provideChoiceConstraintCases(): \Generator + { + yield 'single choice' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummySingleChoice', 'expectedSchema' => ['enum' => ['a', 'b']]]; + yield 'single choice callback' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummySingleChoiceCallback', 'expectedSchema' => ['enum' => ['a', 'b', 'c', 'd']]]; + yield 'multi choice' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoice', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]]; + yield 'multi choice callback' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceCallback', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]]; + yield 'multi choice min' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMin', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2]]; + yield 'multi choice max' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]]; + yield 'multi choice min/max' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMinMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]]; + } } diff --git a/tests/Fixtures/DummyValidatedChoiceEntity.php b/tests/Fixtures/DummyValidatedChoiceEntity.php new file mode 100644 index 00000000000..8c41c04ed1e --- /dev/null +++ b/tests/Fixtures/DummyValidatedChoiceEntity.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyValidatedChoiceEntity +{ + /** + * @var string + * + * @Assert\Choice(choices={"a", "b"}) + */ + public $dummySingleChoice; + + /** + * @var string + * + * @Assert\Choice(callback={DummyValidatedChoiceEntity::class, "getChoices"}) + */ + public $dummySingleChoiceCallback; + + /** + * @var string[] + * + * @Assert\Choice(choices={"a", "b"}, multiple=true) + */ + public $dummyMultiChoice; + + /** + * @var string[] + * + * @Assert\Choice(callback={DummyValidatedChoiceEntity::class, "getChoices"}, multiple=true) + */ + public $dummyMultiChoiceCallback; + + /** + * @var string[] + * + * @Assert\Choice(choices={"a", "b", "c", "d"}, multiple=true, min=2) + */ + public $dummyMultiChoiceMin; + + /** + * @var string[] + * + * @Assert\Choice(choices={"a", "b", "c", "d"}, multiple=true, max=4) + */ + public $dummyMultiChoiceMax; + + /** + * @var string[] + * + * @Assert\Choice(choices={"a", "b", "c", "d"}, multiple=true, min=2, max=4) + */ + public $dummyMultiChoiceMinMax; + + public static function getChoices(): array + { + return ['a', 'b', 'c', 'd']; + } +} From 02e701fc4b8ed1e306af85967863faafaa8ff547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Tue, 30 Mar 2021 12:59:28 +0300 Subject: [PATCH 21/41] Add an option to disable query parameter validation (#4165) --- CHANGELOG.md | 1 + src/Annotation/ApiResource.php | 81 ++++++++++--------- .../ApiPlatformExtension.php | 6 ++ .../DependencyInjection/Configuration.php | 1 + .../Bundle/Resources/config/validator.xml | 1 + .../QueryParameterValidateListener.php | 11 ++- tests/Annotation/ApiResourceTest.php | 4 + .../ApiPlatformExtensionTest.php | 1 + .../DependencyInjection/ConfigurationTest.php | 1 + .../QueryParameterValidateListenerTest.php | 66 +++++++++++++-- 10 files changed, 127 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224ea87c7ba..1f0f35f742d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* Validator: Add an option to disable query parameter validation (#4165) * JSON Schema: Add support for generating property schema with Choice restriction (#4162) * JSON Schema: Add support for generating property schema with Range restriction (#4158) * JSON Schema: Add support for generating property schema with Unique restriction (#4159) diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index d553287f836..fe089850457 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -71,6 +71,7 @@ * @Attribute("urlGenerationStrategy", type="int"), * @Attribute("validationGroups", type="mixed"), * @Attribute("exceptionToStatus", type="array"), + * @Attribute("queryParameterValidationEnabled", type="bool") * ) */ #[\Attribute(\Attribute::TARGET_CLASS)] @@ -132,46 +133,47 @@ final class ApiResource /** * @param string $description - * @param array $collectionOperations https://api-platform.com/docs/core/operations - * @param array $graphql https://api-platform.com/docs/core/graphql - * @param array $itemOperations https://api-platform.com/docs/core/operations - * @param array $subresourceOperations https://api-platform.com/docs/core/subresources - * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers - * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties - * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ - * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial - * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager - * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation - * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters - * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra - * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @param bool|array $mercure https://api-platform.com/docs/core/mercure - * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus - * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @param array $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts - * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order - * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation - * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 - * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 - * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 - * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination - * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource - * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator - * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page - * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page - * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination - * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations - * @param string $security https://api-platform.com/docs/core/security - * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message - * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization - * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param array $collectionOperations https://api-platform.com/docs/core/operations + * @param array $graphql https://api-platform.com/docs/core/graphql + * @param array $itemOperations https://api-platform.com/docs/core/operations + * @param array $subresourceOperations https://api-platform.com/docs/core/subresources + * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers + * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ + * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial + * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager + * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation + * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters + * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra + * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool|array $mercure https://api-platform.com/docs/core/mercure + * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus + * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param array $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts + * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order + * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 + * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 + * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 + * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination + * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource + * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator + * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page + * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page + * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination + * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations + * @param string $security https://api-platform.com/docs/core/security + * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message * @param bool $stateless - * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed - * @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts - * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups + * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed + * @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts + * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups * @param int $urlGenerationStrategy - * @param array $exceptionToStatus https://api-platform.com/docs/core/errors/#fine-grained-configuration + * @param array $exceptionToStatus https://api-platform.com/docs/core/errors/#fine-grained-configuration + * @param bool $queryParameterValidationEnabled * * @throws InvalidArgumentException */ @@ -222,7 +224,8 @@ public function __construct( ?array $validationGroups = null, ?int $urlGenerationStrategy = null, ?bool $compositeIdentifier = null, - ?array $exceptionToStatus = null + ?array $exceptionToStatus = null, + ?bool $queryParameterValidationEnabled = null ) { if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index fe910c407a6..3359c0a00fb 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -624,6 +624,12 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr } $container->setParameter('api_platform.validator.serialize_payload_fields', $config['validator']['serialize_payload_fields']); + $container->setParameter('api_platform.validator.query_parameter_validation', $config['validator']['query_parameter_validation']); + + if (!$config['validator']['query_parameter_validation']) { + $container->removeDefinition('api_platform.listener.view.validate_query_parameters'); + $container->removeDefinition('api_platform.validator.query_parameter_validator'); + } } private function registerDataCollectorConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 46c20c9ca51..6380d02c74a 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -100,6 +100,7 @@ public function getConfigTreeBuilder() ->addDefaultsIfNotSet() ->children() ->variableNode('serialize_payload_fields')->defaultValue([])->info('Set to null to serialize all payload fields when a validation error is thrown, or set the fields you want to include explicitly.')->end() + ->booleanNode('query_parameter_validation')->defaultValue(true)->end() ->end() ->end() ->arrayNode('eager_loading') diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index 5243f45dfc5..dfcb9af797c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -60,6 +60,7 @@ + %api_platform.validator.query_parameter_validation% diff --git a/src/EventListener/QueryParameterValidateListener.php b/src/EventListener/QueryParameterValidateListener.php index e9334c1583c..534094113cd 100644 --- a/src/EventListener/QueryParameterValidateListener.php +++ b/src/EventListener/QueryParameterValidateListener.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Filter\QueryParameterValidator; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ToggleableOperationAttributeTrait; use ApiPlatform\Core\Util\RequestAttributesExtractor; use ApiPlatform\Core\Util\RequestParser; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -26,14 +27,21 @@ */ final class QueryParameterValidateListener { + use ToggleableOperationAttributeTrait; + + public const OPERATION_ATTRIBUTE_KEY = 'query_parameter_validate'; + private $resourceMetadataFactory; private $queryParameterValidator; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, QueryParameterValidator $queryParameterValidator) + private $enabled; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, QueryParameterValidator $queryParameterValidator, bool $enabled = true) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->queryParameterValidator = $queryParameterValidator; + $this->enabled = $enabled; } public function onKernelRequest(RequestEvent $event) @@ -45,6 +53,7 @@ public function onKernelRequest(RequestEvent $event) || !isset($attributes['collection_operation_name']) || !($operationName = $attributes['collection_operation_name']) || 'GET' !== $request->getMethod() + || $this->isOperationAttributeDisabled($attributes, self::OPERATION_ATTRIBUTE_KEY, !$this->enabled) ) { return; } diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index 078f2dd19d7..02a266b057d 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -66,6 +66,7 @@ public function testConstruct() 'validationGroups' => ['foo', 'bar'], 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', 'urlGenerationStrategy' => UrlGeneratorInterface::ABS_PATH, + 'queryParameterValidationEnabled' => false, ]); $this->assertSame('shortName', $resource->shortName); @@ -107,6 +108,7 @@ public function testConstruct() 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']], 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', 'url_generation_strategy' => 1, + 'query_parameter_validation_enabled' => false, ], $resource->attributes); } @@ -162,6 +164,7 @@ public function testConstructAttribute() exceptionToStatus: [ \DomainException::class => 400, ], + queryParameterValidationEnabled: false, ); PHP ); @@ -214,6 +217,7 @@ public function testConstructAttribute() 'exception_to_status' => [ \DomainException::class => 400, ], + 'query_parameter_validation_enabled' => false, ], $resource->attributes); } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 9708ac6022c..d28a0ca2b2e 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1227,6 +1227,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.graphql.graphql_playground.enabled' => true, 'api_platform.resource_class_directories' => Argument::type('array'), 'api_platform.validator.serialize_payload_fields' => [], + 'api_platform.validator.query_parameter_validation' => true, 'api_platform.elasticsearch.enabled' => false, 'api_platform.asset_package' => null, 'api_platform.defaults' => ['attributes' => []], diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index a5cabd4c39f..71352749b39 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -105,6 +105,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'path_segment_name_generator' => 'api_platform.path_segment_name_generator.underscore', 'validator' => [ 'serialize_payload_fields' => [], + 'query_parameter_validation' => true, ], 'name_converter' => null, 'enable_fos_user' => false, diff --git a/tests/EventListener/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php index c77773bcb3d..7f84d846cc6 100644 --- a/tests/EventListener/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -29,7 +30,7 @@ class QueryParameterValidateListenerTest extends TestCase use ProphecyTrait; private $testedInstance; - private $queryParameterValidor; + private $queryParameterValidator; /** * unsafe method should not use filter validations. @@ -49,6 +50,59 @@ public function testOnKernelRequestWithUnsafeMethod() ); } + public function testDoNotValidateWhenDisabledGlobally(): void + { + $resourceMetadata = new ResourceMetadata('Dummy', null, null, [], [ + 'get' => [], + ]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn($resourceMetadata); + + $request = new Request([], [], ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get']); + + $eventProphecy = $this->prophesize(RequestEvent::class); + $eventProphecy->getRequest()->willReturn($request); + + $queryParameterValidator = $this->prophesize(QueryParameterValidator::class); + $queryParameterValidator->validateFilters(Argument::cetera())->shouldNotBeCalled(); + + $listener = new QueryParameterValidateListener( + $resourceMetadataFactoryProphecy->reveal(), + $queryParameterValidator->reveal(), + false + ); + + $listener->onKernelRequest($eventProphecy->reveal()); + } + + public function testDoNotValidateWhenDisabledInOperationAttribute(): void + { + $resourceMetadata = new ResourceMetadata('Dummy', null, null, [], [ + 'get' => [ + 'query_parameter_validate' => false, + ], + ]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn($resourceMetadata); + + $request = new Request([], [], ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get']); + + $eventProphecy = $this->prophesize(RequestEvent::class); + $eventProphecy->getRequest()->willReturn($request); + + $queryParameterValidator = $this->prophesize(QueryParameterValidator::class); + $queryParameterValidator->validateFilters(Argument::cetera())->shouldNotBeCalled(); + + $listener = new QueryParameterValidateListener( + $resourceMetadataFactoryProphecy->reveal(), + $queryParameterValidator->reveal() + ); + + $listener->onKernelRequest($eventProphecy->reveal()); + } + /** * If the tested filter is non-existent, then nothing should append. */ @@ -62,7 +116,7 @@ public function testOnKernelRequestWithWrongFilter() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->queryParameterValidor->validateFilters(Dummy::class, ['some_inexistent_filter'], [])->shouldBeCalled(); + $this->queryParameterValidator->validateFilters(Dummy::class, ['some_inexistent_filter'], [])->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -82,7 +136,7 @@ public function testOnKernelRequestWithRequiredFilterNotSet() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->queryParameterValidor + $this->queryParameterValidator ->validateFilters(Dummy::class, ['some_filter'], []) ->shouldBeCalled() ->willThrow(new FilterValidationException(['Query parameter "required" is required'])); @@ -111,7 +165,7 @@ public function testOnKernelRequestWithRequiredFilter() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->queryParameterValidor + $this->queryParameterValidator ->validateFilters(Dummy::class, ['some_filter'], ['required' => 'foo']) ->shouldBeCalled(); @@ -132,11 +186,11 @@ private function setUpWithFilters(array $filters = []) ]) ); - $this->queryParameterValidor = $this->prophesize(QueryParameterValidator::class); + $this->queryParameterValidator = $this->prophesize(QueryParameterValidator::class); $this->testedInstance = new QueryParameterValidateListener( $resourceMetadataFactoryProphecy->reveal(), - $this->queryParameterValidor->reveal() + $this->queryParameterValidator->reveal() ); } } From 6b02f156979c5aa345719a999939bead568c6d6c Mon Sep 17 00:00:00 2001 From: Guilliam Xavier Date: Wed, 31 Mar 2021 17:18:21 +0200 Subject: [PATCH 22/41] Do not overwrite variable with unknown value (#4179) --- .../Property/Restriction/PropertySchemaChoiceRestriction.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php index c12630177a5..712517df7f7 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php @@ -32,8 +32,8 @@ public function create(Constraint $constraint, PropertyMetadata $propertyMetadat { $choices = []; - if (\is_callable($choices = $constraint->callback)) { - $choices = $choices(); + if (\is_callable($constraint->callback)) { + $choices = ($constraint->callback)(); } elseif (\is_array($constraint->choices)) { $choices = $constraint->choices; } From 28020b4aa95c2601ae2efcf8a16fe65cb890f68b Mon Sep 17 00:00:00 2001 From: David Bennett Date: Thu, 1 Apr 2021 09:44:31 +0100 Subject: [PATCH 23/41] GraphQL association field security fixes + @ApiProperty security support (#4143) --- CHANGELOG.md | 2 + features/graphql/authorization.feature | 241 ++++++++++++++++++ .../Bundle/Resources/config/graphql.xml | 1 + .../Factory/CollectionResolverFactory.php | 28 +- src/GraphQl/Serializer/ItemNormalizer.php | 5 +- src/GraphQl/Type/FieldsBuilder.php | 10 +- tests/Behat/DoctrineContext.php | 44 ++++ .../Document/RelatedSecuredDummy.php | 53 ++++ .../TestBundle/Document/SecuredDummy.php | 125 ++++++++- .../TestBundle/Entity/RelatedSecuredDummy.php | 55 ++++ .../TestBundle/Entity/SecuredDummy.php | 135 +++++++++- .../Factory/CollectionResolverFactoryTest.php | 75 +++++- 12 files changed, 751 insertions(+), 23 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/RelatedSecuredDummy.php create mode 100644 tests/Fixtures/TestBundle/Entity/RelatedSecuredDummy.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 34be4540b37..4947887fb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ * DataProvider: Add `TraversablePaginator` (#3783) * JSON:API: Support inclusion of resources from path (#3288) * Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) +* GraphQL: Support `ApiProperty` security (#4143) +* GraphQL: **BC** Fix security on association collection properties. The collection resource `item_query` security is no longer used. `ApiProperty` security can now be used to secure collection (or any other) properties. (#4143) ## 2.6.4 diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature index 165b0131c20..3844387d884 100644 --- a/features/graphql/authorization.feature +++ b/features/graphql/authorization.feature @@ -21,6 +21,7 @@ Feature: Authorization checking And the JSON node "errors[0].extensions.status" should be equal to 403 And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.securedDummy" should be null Scenario: An anonymous user tries to retrieve a secured collection Given there are 1 SecuredDummy objects @@ -43,6 +44,7 @@ Feature: Authorization checking And the JSON node "errors[0].extensions.status" should be equal to 403 And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.securedDummies" should be null Scenario: An admin can retrieve a secured collection When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" @@ -62,6 +64,7 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummies" should exist And the JSON node "data.securedDummies" should not be null Scenario: An anonymous user cannot retrieve a secured collection @@ -86,6 +89,7 @@ Feature: Authorization checking And the JSON node "errors[0].extensions.status" should be equal to 403 And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.securedDummies" should be null Scenario: An anonymous user tries to create a resource they are not allowed to When I send the following GraphQL request: @@ -105,6 +109,206 @@ Feature: Authorization checking And the JSON node "errors[0].extensions.status" should be equal to 403 And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy." + And the JSON node "data.createSecuredDummy" should be null + + @createSchema + Scenario: An admin can access a secured collection relation + Given there are 1 SecuredDummy objects owned by admin with related dummies + When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { + edges { + node { + id + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.relatedDummies" should have 1 element + + Scenario: An admin can access a secured relation + When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + relatedDummy { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.relatedDummy" should exist + And the JSON node "data.securedDummy.relatedDummy" should not be null + + @createSchema + Scenario: A user can't access a secured collection relation + Given there are 1 SecuredDummy objects owned by dunglas with related dummies + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { + edges { + node { + id + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.relatedDummies" should be null + + Scenario: A user can't access a secured relation + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + relatedDummy { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.relatedDummy" should be null + + Scenario: A user can't access a secured relation resource directly + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + relatedSecuredDummy(id: "/related_secured_dummies/1") { + id + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user + And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.relatedSecuredDummy" should be null + + Scenario: A user can't access a secured relation resource collection directly + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + relatedSecuredDummies { + edges { + node { + id + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user + And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.relatedSecuredDummies" should be null + + Scenario: A user can access a secured collection relation + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + relatedSecuredDummies { + edges { + node { + id + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.relatedSecuredDummies" should have 1 element + + Scenario: A user can access a secured relation + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + relatedSecuredDummy { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.relatedSecuredDummy" should exist + And the JSON node "data.securedDummy.relatedSecuredDummy" should not be null + + Scenario: A user can access a non-secured collection relation + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + publicRelatedSecuredDummies { + edges { + node { + id + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.publicRelatedSecuredDummies" should have 1 element + + Scenario: A user can access a non-secured relation + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + When I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/1") { + publicRelatedSecuredDummy { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should exist + And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should not be null @createSchema Scenario: An admin can create a secured resource @@ -162,6 +366,7 @@ Feature: Authorization checking And the JSON node "errors[0].extensions.status" should be equal to 403 And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.securedDummy" should be null Scenario: A user can retrieve an item they owns When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" @@ -179,6 +384,41 @@ Feature: Authorization checking And the header "Content-Type" should be equal to "application/json" And the JSON node "data.securedDummy.owner" should be equal to the string "dunglas" + Scenario: An admin can see a secured admin-only property on an object they don't own + When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/2") { + owner + title + adminOnlyProperty + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.adminOnlyProperty" should exist + And the JSON node "data.securedDummy.adminOnlyProperty" should not be null + + Scenario: A user can't see a secured admin-only property on an object they own + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/2") { + owner + title + adminOnlyProperty + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.adminOnlyProperty" should be null + Scenario: A user can't assign to themself an item they doesn't own When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" And I send the following GraphQL request: @@ -199,6 +439,7 @@ Feature: Authorization checking And the JSON node "errors[0].extensions.status" should be equal to 403 And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." + And the JSON node "data.updateSecuredDummy" should be null Scenario: A user can update an item they owns and transfer it When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index d9400932005..8d679d5c6f6 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -220,6 +220,7 @@ null + diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index 8fff34f5643..a123ce5334a 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -59,7 +59,8 @@ public function __construct(ReadStageInterface $readStage, SecurityStageInterfac public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?string $operationName = null): callable { return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operationName) { - if (null === $resourceClass || null === $rootClass) { + // If authorization has failed for a relation field (e.g. via ApiProperty security), the field is not present in the source: null can be returned directly to ensure the collection isn't in the response. + if (null === $resourceClass || null === $rootClass || (null !== $source && !\array_key_exists($info->fieldName, $source))) { return null; } @@ -87,17 +88,20 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul $collection = $queryResolver($collection, $resolverContext); } - ($this->securityStage)($resourceClass, $operationName, $resolverContext + [ - 'extra_variables' => [ - 'object' => $collection, - ], - ]); - ($this->securityPostDenormalizeStage)($resourceClass, $operationName, $resolverContext + [ - 'extra_variables' => [ - 'object' => $collection, - 'previous_object' => $this->clone($collection), - ], - ]); + // Only perform security stage on the top-level query + if (null === $source) { + ($this->securityStage)($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $collection, + ], + ]); + ($this->securityPostDenormalizeStage)($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $collection, + 'previous_object' => $this->clone($collection), + ], + ]); + } return ($this->serializeStage)($collection, $resourceClass, $operationName, $resolverContext); }; diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index e7c5e6c8a0c..d36c1190f7e 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\ItemNormalizer as BaseItemNormalizer; use ApiPlatform\Core\Util\ClassInfoTrait; use Psr\Log\LoggerInterface; @@ -45,9 +46,9 @@ final class ItemNormalizer extends BaseItemNormalizer private $identifiersExtractor; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, IdentifiersExtractorInterface $identifiersExtractor, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, $logger ?: new NullLogger(), $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, $logger ?: new NullLogger(), $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); $this->identifiersExtractor = $identifiersExtractor; } diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index aa462454c73..0c91908e834 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -226,7 +226,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta continue; } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', null), $propertyType, $resourceClass, $input, $queryName, $mutationName, $subscriptionName, $depth)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', null), $propertyType, $resourceClass, $input, $queryName, $mutationName, $subscriptionName, $depth, null !== $propertyMetadata->getAttribute('security'))) { $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; } } @@ -260,7 +260,7 @@ public function resolveResourceArgs(array $args, string $operationName, string $ * * @see http://webonyx.github.io/graphql-php/type-system/object-types/ */ - private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth = 0): ?array + private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth = 0, bool $forceNullable = false): ?array { try { if ( @@ -272,7 +272,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $resourceClass = $type->getClassName(); } - if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass ?? '', $rootResource, $property, $depth)) { + if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable)) { return null; } @@ -476,7 +476,7 @@ private function convertFilterArgsToTypes(array $args): array * * @throws InvalidTypeException */ - private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) + private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false) { $graphqlType = $this->typeConverter->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass, $rootResource, $property, $depth); @@ -498,7 +498,7 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin return $this->pagination->isGraphQlEnabled($resourceClass, $operationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operationName) : GraphQLType::listOf($graphqlType); } - return !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName) + return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName) ? $graphqlType : GraphQLType::nonNull($graphqlType); } diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 6aab69787f9..1f237b3f2bd 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -76,6 +76,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy as RelatedOwningDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedToDummyFriend as RelatedToDummyFriendDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelationEmbedder as RelationEmbedderDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; @@ -150,6 +151,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SecuredDummy; @@ -1035,6 +1037,40 @@ public function thereAreSecuredDummyObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb SecuredDummy objects owned by :ownedby with related dummies + */ + public function thereAreSecuredDummyObjectsOwnedByWithRelatedDummies(int $nb, string $ownedby) + { + for ($i = 1; $i <= $nb; ++$i) { + $securedDummy = $this->buildSecuredDummy(); + $securedDummy->setTitle("#$i"); + $securedDummy->setDescription("Hello #$i"); + $securedDummy->setOwner($ownedby); + + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy'); + $this->manager->persist($relatedDummy); + + $relatedSecuredDummy = $this->buildRelatedSecureDummy(); + $this->manager->persist($relatedSecuredDummy); + + $publicRelatedSecuredDummy = $this->buildRelatedSecureDummy(); + $this->manager->persist($publicRelatedSecuredDummy); + + $securedDummy->addRelatedDummy($relatedDummy); + $securedDummy->setRelatedDummy($relatedDummy); + $securedDummy->addRelatedSecuredDummy($relatedSecuredDummy); + $securedDummy->setRelatedSecuredDummy($relatedSecuredDummy); + $securedDummy->addPublicRelatedSecuredDummy($publicRelatedSecuredDummy); + $securedDummy->setPublicRelatedSecuredDummy($publicRelatedSecuredDummy); + + $this->manager->persist($securedDummy); + } + + $this->manager->flush(); + } + /** * @Given there is a RelationEmbedder object */ @@ -2170,6 +2206,14 @@ private function buildSecuredDummy() return $this->isOrm() ? new SecuredDummy() : new SecuredDummyDocument(); } + /** + * @return RelatedSecuredDummy|RelatedSecuredDummyDocument + */ + private function buildRelatedSecureDummy() + { + return $this->isOrm() ? new RelatedSecuredDummy() : new RelatedSecuredDummyDocument(); + } + /** * @return ThirdLevel|ThirdLevelDocument */ diff --git a/tests/Fixtures/TestBundle/Document/RelatedSecuredDummy.php b/tests/Fixtures/TestBundle/Document/RelatedSecuredDummy.php new file mode 100644 index 00000000000..6e3b9f104a1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/RelatedSecuredDummy.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource( + * attributes={"security"="is_granted('ROLE_ADMIN')"}, + * collectionOperations={ + * "get"={"security"="is_granted('ROLE_ADMIN')"}, + * }, + * itemOperations={ + * "get"={"security"="is_granted('ROLE_ADMIN')"}, + * }, + * graphql={ + * "item_query"={"security"="is_granted('ROLE_ADMIN')"}, + * "collection_query"={"security"="is_granted('ROLE_ADMIN')"}, + * } + * ) + * @ODM\Document + */ +class RelatedSecuredDummy +{ + /** + * @var int + * + * @ODM\Id(strategy="INCREMENT", type="int") + */ + private $id; + + public function getId() + { + return $this->id; + } + + public function setId($id) + { + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummy.php b/tests/Fixtures/TestBundle/Document/SecuredDummy.php index 58c787110ca..277b1220066 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummy.php @@ -15,6 +15,8 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Symfony\Component\Validator\Constraints as Assert; @@ -40,7 +42,7 @@ * "put"={"security_post_denormalize"="is_granted('ROLE_USER') and previous_object.getOwner() == user"}, * }, * graphql={ - * "item_query"={"security"="is_granted('ROLE_USER') and object.getOwner() == user"}, + * "item_query"={"security"="is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and object.getOwner() == user)"}, * "collection_query"={"security"="is_granted('ROLE_ADMIN')"}, * "delete"={}, * "update"={"security_post_denormalize"="is_granted('ROLE_USER') and previous_object.getOwner() == user"}, @@ -89,6 +91,67 @@ class SecuredDummy */ private $owner; + /** + * @var Collection Several dummies + * + * @ODM\ReferenceMany(targetDocument=RelatedDummy::class, storeAs="id", nullable=true) + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + public $relatedDummies; + + /** + * @var RelatedDummy + * + * @ODM\ReferenceOne(targetDocument=RelatedDummy::class, storeAs="id", nullable=true) + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + protected $relatedDummy; + + /** + * A collection of dummies that only users can access. The security on RelatedSecuredDummy shouldn't be run. + * + * @var Collection Several dummies + * + * @ODM\ReferenceMany(targetDocument=RelatedSecuredDummy::class, storeAs="id", nullable=true) + * @ApiProperty(security="is_granted('ROLE_USER')") + */ + public $relatedSecuredDummies; + + /** + * A dummy that only users can access. The security on RelatedSecuredDummy shouldn't be run. + * + * @var RelatedSecuredDummy + * + * @ODM\ReferenceOne(targetDocument=RelatedSecuredDummy::class, storeAs="id", nullable=true) + * @ApiProperty(security="is_granted('ROLE_USER')") + */ + protected $relatedSecuredDummy; + + /** + * Collection of dummies that anyone can access. There is no @ApiProperty security, and the security on RelatedSecuredDummy shouldn't be run. + * + * @var Collection Several dummies + * + * @ODM\ReferenceMany(targetDocument=RelatedSecuredDummy::class, storeAs="id", nullable=true) + */ + public $publicRelatedSecuredDummies; + + /** + * A dummy that anyone can access. There is no @ApiProperty security, and the security on RelatedSecuredDummy shouldn't be run. + * + * @var RelatedSecuredDummy + * + * @ODM\ReferenceOne(targetDocument=RelatedSecuredDummy::class, storeAs="id", nullable=true) + */ + protected $publicRelatedSecuredDummy; + + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + $this->relatedSecuredDummies = new ArrayCollection(); + $this->publicRelatedSecuredDummies = new ArrayCollection(); + } + public function getId(): int { return $this->id; @@ -133,4 +196,64 @@ public function setOwner(string $owner) { $this->owner = $owner; } + + public function addRelatedDummy(RelatedDummy $relatedDummy) + { + $this->relatedDummies->add($relatedDummy); + } + + public function getRelatedDummies() + { + return $this->relatedDummies; + } + + public function getRelatedDummy() + { + return $this->relatedDummy; + } + + public function setRelatedDummy(RelatedDummy $relatedDummy) + { + $this->relatedDummy = $relatedDummy; + } + + public function addRelatedSecuredDummy(RelatedSecuredDummy $relatedSecuredDummy) + { + $this->relatedSecuredDummies->add($relatedSecuredDummy); + } + + public function getRelatedSecuredDummies() + { + return $this->relatedSecuredDummies; + } + + public function getRelatedSecuredDummy() + { + return $this->relatedSecuredDummy; + } + + public function setRelatedSecuredDummy(RelatedSecuredDummy $relatedSecuredDummy) + { + $this->relatedSecuredDummy = $relatedSecuredDummy; + } + + public function addPublicRelatedSecuredDummy(RelatedSecuredDummy $publicRelatedSecuredDummy) + { + $this->publicRelatedSecuredDummies->add($publicRelatedSecuredDummy); + } + + public function getPublicRelatedSecuredDummies() + { + return $this->publicRelatedSecuredDummies; + } + + public function getPublicRelatedSecuredDummy() + { + return $this->publicRelatedSecuredDummy; + } + + public function setPublicRelatedSecuredDummy(RelatedSecuredDummy $publicRelatedSecuredDummy) + { + $this->publicRelatedSecuredDummy = $publicRelatedSecuredDummy; + } } diff --git a/tests/Fixtures/TestBundle/Entity/RelatedSecuredDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedSecuredDummy.php new file mode 100644 index 00000000000..9bcb8822699 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/RelatedSecuredDummy.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource( + * attributes={"security"="is_granted('ROLE_ADMIN')"}, + * collectionOperations={ + * "get"={"security"="is_granted('ROLE_ADMIN')"}, + * }, + * itemOperations={ + * "get"={"security"="is_granted('ROLE_ADMIN')"}, + * }, + * graphql={ + * "item_query"={"security"="is_granted('ROLE_ADMIN')"}, + * "collection_query"={"security"="is_granted('ROLE_ADMIN')"}, + * } + * ) + * @ORM\Entity + */ +class RelatedSecuredDummy +{ + /** + * @var int + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + public function getId() + { + return $this->id; + } + + public function setId($id) + { + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php index 19ffc2cc816..3a35ccf6ada 100644 --- a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php @@ -15,6 +15,8 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -39,7 +41,7 @@ * "put"={"security_post_denormalize"="is_granted('ROLE_USER') and previous_object.getOwner() == user"}, * }, * graphql={ - * "item_query"={"security"="is_granted('ROLE_USER') and object.getOwner() == user"}, + * "item_query"={"security"="is_granted('ROLE_ADMIN') or (is_granted('ROLE_USER') and object.getOwner() == user)"}, * "collection_query"={"security"="is_granted('ROLE_ADMIN')"}, * "delete"={}, * "update"={"security_post_denormalize"="is_granted('ROLE_USER') and previous_object.getOwner() == user"}, @@ -90,6 +92,77 @@ class SecuredDummy */ private $owner; + /** + * A collection of dummies that only admins can access. + * + * @var Collection Several dummies + * + * @ORM\ManyToMany(targetEntity="RelatedDummy") + * @ORM\JoinTable(name="secured_dummy_related_dummy") + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + public $relatedDummies; + + /** + * A dummy that only admins can access. + * + * @var RelatedDummy + * + * @ORM\ManyToOne(targetEntity="RelatedDummy") + * @ORM\JoinColumn(name="related_dummy_id") + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + protected $relatedDummy; + + /** + * A collection of dummies that only users can access. The security on RelatedSecuredDummy shouldn't be run. + * + * @var Collection Several dummies + * + * @ORM\ManyToMany(targetEntity="RelatedSecuredDummy") + * @ORM\JoinTable(name="secured_dummy_related_secured_dummy") + * @ApiProperty(security="is_granted('ROLE_USER')") + */ + public $relatedSecuredDummies; + + /** + * A dummy that only users can access. The security on RelatedSecuredDummy shouldn't be run. + * + * @var RelatedSecuredDummy + * + * @ORM\ManyToOne(targetEntity="RelatedSecuredDummy") + * @ORM\JoinColumn(name="related_secured_dummy_id") + * @ApiProperty(security="is_granted('ROLE_USER')") + */ + protected $relatedSecuredDummy; + + /** + * Collection of dummies that anyone can access. There is no @ApiProperty security, and the security on RelatedSecuredDummy shouldn't be run. + * + * @var Collection Several dummies + * + * @ORM\ManyToMany(targetEntity="RelatedSecuredDummy") + * @ORM\JoinTable(name="secured_dummy_public_related_secured_dummy") + */ + public $publicRelatedSecuredDummies; + + /** + * A dummy that anyone can access. There is no @ApiProperty security, and the security on RelatedSecuredDummy shouldn't be run. + * + * @var RelatedSecuredDummy + * + * @ORM\ManyToOne(targetEntity="RelatedSecuredDummy") + * @ORM\JoinColumn(name="public_related_secured_dummy_id") + */ + protected $publicRelatedSecuredDummy; + + public function __construct() + { + $this->relatedDummies = new ArrayCollection(); + $this->relatedSecuredDummies = new ArrayCollection(); + $this->publicRelatedSecuredDummies = new ArrayCollection(); + } + public function getId(): int { return $this->id; @@ -134,4 +207,64 @@ public function setOwner(string $owner) { $this->owner = $owner; } + + public function addRelatedDummy(RelatedDummy $relatedDummy) + { + $this->relatedDummies->add($relatedDummy); + } + + public function getRelatedDummies() + { + return $this->relatedDummies; + } + + public function getRelatedDummy() + { + return $this->relatedDummy; + } + + public function setRelatedDummy(RelatedDummy $relatedDummy) + { + $this->relatedDummy = $relatedDummy; + } + + public function addRelatedSecuredDummy(RelatedSecuredDummy $relatedSecuredDummy) + { + $this->relatedSecuredDummies->add($relatedSecuredDummy); + } + + public function getRelatedSecuredDummies() + { + return $this->relatedSecuredDummies; + } + + public function getRelatedSecuredDummy() + { + return $this->relatedSecuredDummy; + } + + public function setRelatedSecuredDummy(RelatedSecuredDummy $relatedSecuredDummy) + { + $this->relatedSecuredDummy = $relatedSecuredDummy; + } + + public function addPublicRelatedSecuredDummy(RelatedSecuredDummy $publicRelatedSecuredDummy) + { + $this->publicRelatedSecuredDummies->add($publicRelatedSecuredDummy); + } + + public function getPublicRelatedSecuredDummies() + { + return $this->publicRelatedSecuredDummies; + } + + public function getPublicRelatedSecuredDummy() + { + return $this->publicRelatedSecuredDummy; + } + + public function setPublicRelatedSecuredDummy(RelatedSecuredDummy $publicRelatedSecuredDummy) + { + $this->publicRelatedSecuredDummy = $publicRelatedSecuredDummy; + } } diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php index a4d464f6770..6b77394486d 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php @@ -70,6 +70,47 @@ protected function setUp(): void } public function testResolve(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'collection_query'; + $source = ['testField' => 0]; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $info->fieldName = 'testField'; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; + + $request = new Request(); + $attributesParameterBagProphecy = $this->prophesize(ParameterBag::class); + $attributesParameterBagProphecy->get('_graphql_collections_args', [])->willReturn(['collection_args']); + $attributesParameterBagProphecy->set('_graphql_collections_args', [$resourceClass => $args, 'collection_args'])->shouldBeCalled(); + $request->attributes = $attributesParameterBagProphecy->reveal(); + $this->requestStackProphecy->getCurrentRequest()->willReturn($request); + + $readStageCollection = [new \stdClass()]; + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); + + $this->securityStageProphecy->__invoke($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $readStageCollection, + ], + ])->shouldNotBeCalled(); + $this->securityPostDenormalizeStageProphecy->__invoke($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $readStageCollection, + 'previous_object' => $readStageCollection, + ], + ])->shouldNotBeCalled(); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageCollection, $resourceClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($serializeStageData); + + $this->assertSame($serializeStageData, ($this->collectionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveFieldNotInSource(): void { $resourceClass = 'stdClass'; $rootClass = 'rootClass'; @@ -77,6 +118,36 @@ public function testResolve(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); + $info->fieldName = 'testField'; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; + + $readStageCollection = [new \stdClass()]; + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldNotBeCalled(); + + $this->securityStageProphecy->__invoke($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $readStageCollection, + ], + ])->shouldNotBeCalled(); + $this->securityPostDenormalizeStageProphecy->__invoke($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $readStageCollection, + 'previous_object' => $readStageCollection, + ], + ])->shouldNotBeCalled(); + + // Null should be returned if the field isn't in the source - as its lack of presence will be due to @ApiProperty security stripping unauthorized fields + $this->assertNull(($this->collectionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNullSource(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'collection_query'; + $source = null; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $request = new Request(); @@ -138,7 +209,7 @@ public function testResolveBadReadStageCollection(): void $resourceClass = 'stdClass'; $rootClass = 'rootClass'; $operationName = 'collection_query'; - $source = ['source']; + $source = null; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; @@ -157,7 +228,7 @@ public function testResolveCustom(): void $resourceClass = 'stdClass'; $rootClass = 'rootClass'; $operationName = 'collection_query'; - $source = ['source']; + $source = null; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; From 69229fb8a43d920c4b4013e159596ae9d20a49a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Thu, 1 Apr 2021 11:52:53 +0300 Subject: [PATCH 24/41] Handle Compound validator constraints when generating property metadata (#4180) --- CHANGELOG.md | 1 + .../ValidatorPropertyMetadataFactory.php | 3 +- .../ValidatorPropertyMetadataFactoryTest.php | 33 +++++++++++++++++++ .../Fixtures/DummyCompoundValidatedEntity.php | 26 +++++++++++++++ .../Constraint/DummyCompoundRequirements.php | 32 ++++++++++++++++++ 5 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/DummyCompoundValidatedEntity.php create mode 100644 tests/Fixtures/TestBundle/Validator/Constraint/DummyCompoundRequirements.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4947887fb2c..4dba8849985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Manage Compound constraint when generating property metadata (#4180) * Validator: Add an option to disable query parameter validation (#4165) * JSON Schema: Add support for generating property schema with Choice restriction (#4162) * JSON Schema: Add support for generating property schema with Range restriction (#4158) diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php b/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php index 1f1706597a6..9e8babef41d 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php @@ -19,6 +19,7 @@ use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Bic; use Symfony\Component\Validator\Constraints\CardScheme; +use Symfony\Component\Validator\Constraints\Compound; use Symfony\Component\Validator\Constraints\Currency; use Symfony\Component\Validator\Constraints\Date; use Symfony\Component\Validator\Constraints\DateTime; @@ -171,7 +172,7 @@ private function getPropertyConstraints( } foreach ($validatorPropertyMetadata->findConstraints($validationGroup) as $propertyConstraint) { - if ($propertyConstraint instanceof Sequentially) { + if ($propertyConstraint instanceof Sequentially || $propertyConstraint instanceof Compound) { $constraints[] = $propertyConstraint->getNestedContraints(); } else { $constraints[] = [$propertyConstraint]; diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index b7cfa83f6a2..389789c426c 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -24,6 +24,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyCompoundValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity; use ApiPlatform\Core\Tests\Fixtures\DummyRangeValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity; @@ -35,6 +36,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Constraints\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\Compound; use Symfony\Component\Validator\Constraints\Sequentially; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; @@ -378,6 +380,37 @@ public function testCreateWithSequentiallyConstraint(): void $this->assertArrayHasKey('pattern', $schema); } + public function testCreateWithCompoundConstraint(): void + { + if (!class_exists(Compound::class)) { + $this->markTestSkipped(); + } + + $validatorClassMetadata = new ClassMetadata(DummyCompoundValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyCompoundValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyCompoundValidatedEntity::class, 'dummy', [])->willReturn( + new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)) + )->shouldBeCalled(); + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaLengthRestriction(), new PropertySchemaRegexRestriction()] + ); + $schema = $validationPropertyMetadataFactory->create(DummyCompoundValidatedEntity::class, 'dummy')->getSchema(); + + $this->assertNotNull($schema); + $this->assertArrayHasKey('minLength', $schema); + $this->assertArrayHasKey('maxLength', $schema); + $this->assertArrayHasKey('pattern', $schema); + } + public function testCreateWithAtLeastOneOfConstraint(): void { if (!class_exists(AtLeastOneOf::class)) { diff --git a/tests/Fixtures/DummyCompoundValidatedEntity.php b/tests/Fixtures/DummyCompoundValidatedEntity.php new file mode 100644 index 00000000000..40e72cf5155 --- /dev/null +++ b/tests/Fixtures/DummyCompoundValidatedEntity.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Validator\Constraint\DummyCompoundRequirements; + +class DummyCompoundValidatedEntity +{ + /** + * @var string + * + * @DummyCompoundRequirements + */ + public $dummy; +} diff --git a/tests/Fixtures/TestBundle/Validator/Constraint/DummyCompoundRequirements.php b/tests/Fixtures/TestBundle/Validator/Constraint/DummyCompoundRequirements.php new file mode 100644 index 00000000000..cb70c8f4d1d --- /dev/null +++ b/tests/Fixtures/TestBundle/Validator/Constraint/DummyCompoundRequirements.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Validator\Constraint; + +use Symfony\Component\Validator\Constraints\Compound; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\Regex; + +/** + * @Annotation + */ +final class DummyCompoundRequirements extends Compound +{ + public function getConstraints(array $options): array + { + return [ + new Length(['min' => 1, 'max' => 32]), + new Regex(['pattern' => '/^[a-z]$/']), + ]; + } +} From 80fa20ed2397f7913e3abe0cd17869f281920823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Tue, 6 Apr 2021 11:44:22 +0300 Subject: [PATCH 25/41] Add support for generating property schema with Count restriction (#4186) --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 4 ++ .../PropertySchemaCountRestriction.php | 52 +++++++++++++++ .../ApiPlatformExtensionTest.php | 1 + .../PropertySchemaCountRestrictionTest.php | 66 +++++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 37 +++++++++++ tests/Fixtures/DummyCountValidatedEntity.php | 40 +++++++++++ 7 files changed, 201 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestrictionTest.php create mode 100644 tests/Fixtures/DummyCountValidatedEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dba8849985..6db4ecef9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema with Count restriction (#4186) * JSON Schema: Manage Compound constraint when generating property metadata (#4180) * Validator: Add an option to disable query parameter validation (#4165) * JSON Schema: Add support for generating property schema with Choice restriction (#4162) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index dfcb9af797c..165eee08e50 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -21,6 +21,10 @@ + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestriction.php new file mode 100644 index 00000000000..d09f5835b0b --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestriction.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Count; + +/** + * @author Tomas Norkūnas + */ +class PropertySchemaCountRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + * + * @param Count $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $restriction = []; + + if (null !== $constraint->min) { + $restriction['minItems'] = $constraint->min; + } + + if (null !== $constraint->max) { + $restriction['maxItems'] = $constraint->max; + } + + return $restriction; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Count; + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d28a0ca2b2e..4414d7b3b8e 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1337,6 +1337,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.metadata.property.metadata_factory.annotation', 'api_platform.metadata.property.metadata_factory.validator', 'api_platform.metadata.property_schema.choice_restriction', + 'api_platform.metadata.property_schema.count_restriction', 'api_platform.metadata.property_schema.length_restriction', 'api_platform.metadata.property_schema.one_of_restriction', 'api_platform.metadata.property_schema.range_restriction', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestrictionTest.php new file mode 100644 index 00000000000..d9b6fd2806c --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCountRestrictionTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Count; +use Symfony\Component\Validator\Constraints\Positive; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaCountRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaCountRestriction; + + protected function setUp(): void + { + $this->propertySchemaCountRestriction = new PropertySchemaCountRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaCountRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported' => [new Count(['min' => 1]), new PropertyMetadata(), true]; + yield 'not supported' => [new Positive(), new PropertyMetadata(), false]; + } + + /** + * @dataProvider createProvider + */ + public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaCountRestriction->create($constraint, $propertyMetadata)); + } + + public function createProvider(): \Generator + { + yield 'min items' => [new Count(['min' => 1]), new PropertyMetadata(), ['minItems' => 1]]; + yield 'max items' => [new Count(['max' => 10]), new PropertyMetadata(), ['maxItems' => 10]]; + yield 'min/max items' => [new Count(['min' => 1, 'max' => 10]), new PropertyMetadata(), ['minItems' => 1, 'maxItems' => 10]]; + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 389789c426c..fb4c484c895 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaOneOfRestriction; @@ -25,6 +26,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyCompoundValidatedEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyCountValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity; use ApiPlatform\Core\Tests\Fixtures\DummyRangeValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity; @@ -547,4 +549,39 @@ public function provideChoiceConstraintCases(): \Generator yield 'multi choice max' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]]; yield 'multi choice min/max' => ['propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)), 'property' => 'dummyMultiChoiceMinMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]]; } + + /** + * @dataProvider provideCountConstraintCases + */ + public function testCreateWithPropertyCountRestriction(string $property, array $expectedSchema): void + { + $validatorClassMetadata = new ClassMetadata(DummyCountValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyCountValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyCountValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaCountRestriction()] + ); + + $schema = $validationPropertyMetadataFactory->create(DummyCountValidatedEntity::class, $property)->getSchema(); + + $this->assertSame($expectedSchema, $schema); + } + + public function provideCountConstraintCases(): \Generator + { + yield 'min' => ['property' => 'dummyMin', 'expectedSchema' => ['minItems' => 1]]; + yield 'max' => ['property' => 'dummyMax', 'expectedSchema' => ['maxItems' => 10]]; + yield 'min/max' => ['property' => 'dummyMinMax', 'expectedSchema' => ['minItems' => 1, 'maxItems' => 10]]; + } } diff --git a/tests/Fixtures/DummyCountValidatedEntity.php b/tests/Fixtures/DummyCountValidatedEntity.php new file mode 100644 index 00000000000..90febc3bfc2 --- /dev/null +++ b/tests/Fixtures/DummyCountValidatedEntity.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyCountValidatedEntity +{ + /** + * @var array + * + * @Assert\Count(min=1) + */ + public $dummyMin; + + /** + * @var array + * + * @Assert\Count(max=10) + */ + public $dummyMax; + + /** + * @var array + * + * @Assert\Count(min=1, max=10) + */ + public $dummyMinMax; +} From c7e18e351b6db2722fbbd48a9b82dafef46b4b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Wed, 7 Apr 2021 15:29:02 +0300 Subject: [PATCH 26/41] Add support for generating property schema format for Url and Hostname (#4185) --- CHANGELOG.md | 1 + .../Restriction/PropertySchemaFormat.php | 12 ++- .../Restriction/PropertySchemaFormatTest.php | 86 +++++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 59 ++++++++----- tests/Fixtures/DummyValidatedEntity.php | 7 ++ .../Fixtures/DummyValidatedHostnameEntity.php | 26 ++++++ tests/Fixtures/DummyValidatedUlidEntity.php | 26 ++++++ 7 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormatTest.php create mode 100644 tests/Fixtures/DummyValidatedHostnameEntity.php create mode 100644 tests/Fixtures/DummyValidatedUlidEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db4ecef9a3..72a3f3fa28a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema format for Url and Hostname (#4185) * JSON Schema: Add support for generating property schema with Count restriction (#4186) * JSON Schema: Manage Compound constraint when generating property metadata (#4180) * Validator: Add an option to disable query parameter validation (#4165) diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php index c6cec232b6f..b77c9642045 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php @@ -16,8 +16,10 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\Hostname; use Symfony\Component\Validator\Constraints\Ip; use Symfony\Component\Validator\Constraints\Ulid; +use Symfony\Component\Validator\Constraints\Url; use Symfony\Component\Validator\Constraints\Uuid; /** @@ -36,6 +38,14 @@ public function create(Constraint $constraint, PropertyMetadata $propertyMetadat return ['format' => 'email']; } + if ($constraint instanceof Url) { + return ['format' => 'uri']; + } + + if ($constraint instanceof Hostname) { + return ['format' => 'hostname']; + } + if ($constraint instanceof Uuid) { return ['format' => 'uuid']; } @@ -62,6 +72,6 @@ public function supports(Constraint $constraint, PropertyMetadata $propertyMetad { $schema = $propertyMetadata->getSchema(); - return empty($schema['format']) && ($constraint instanceof Email || $constraint instanceof Uuid || $constraint instanceof Ulid || $constraint instanceof Ip); + return empty($schema['format']) && ($constraint instanceof Email || $constraint instanceof Url || $constraint instanceof Hostname || $constraint instanceof Uuid || $constraint instanceof Ulid || $constraint instanceof Ip); } } diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormatTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormatTest.php new file mode 100644 index 00000000000..f8c67873c8c --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormatTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\Hostname; +use Symfony\Component\Validator\Constraints\Ip; +use Symfony\Component\Validator\Constraints\Positive; +use Symfony\Component\Validator\Constraints\Ulid; +use Symfony\Component\Validator\Constraints\Url; +use Symfony\Component\Validator\Constraints\Uuid; + +final class PropertySchemaFormatTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaFormatRestriction; + + protected function setUp(): void + { + $this->propertySchemaFormatRestriction = new PropertySchemaFormat(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaFormatRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'email' => [new Email(), new PropertyMetadata(), true]; + yield 'url' => [new Url(), new PropertyMetadata(), true]; + if (class_exists(Hostname::class)) { + yield 'hostname' => [new Hostname(), new PropertyMetadata(), true]; + } + yield 'uuid' => [new Uuid(), new PropertyMetadata(), true]; + if (class_exists(Ulid::class)) { + yield 'ulid' => [new Ulid(), new PropertyMetadata(), true]; + } + yield 'ip' => [new Ip(), new PropertyMetadata(), true]; + yield 'not supported' => [new Positive(), new PropertyMetadata(), false]; + } + + /** + * @dataProvider createProvider + */ + public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaFormatRestriction->create($constraint, $propertyMetadata)); + } + + public function createProvider(): \Generator + { + yield 'email' => [new Email(), new PropertyMetadata(), ['format' => 'email']]; + yield 'url' => [new Url(), new PropertyMetadata(), ['format' => 'uri']]; + if (class_exists(Hostname::class)) { + yield 'hostname' => [new Hostname(), new PropertyMetadata(), ['format' => 'hostname']]; + } + yield 'uuid' => [new Uuid(), new PropertyMetadata(), ['format' => 'uuid']]; + if (class_exists(Ulid::class)) { + yield 'ulid' => [new Ulid(), new PropertyMetadata(), ['format' => 'ulid']]; + } + yield 'ipv4' => [new Ip(['version' => '4']), new PropertyMetadata(), ['format' => 'ipv4']]; + yield 'ipv6' => [new Ip(['version' => '6']), new PropertyMetadata(), ['format' => 'ipv6']]; + yield 'not supported' => [new Positive(), new PropertyMetadata(), []]; + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index fb4c484c895..ec831cd4982 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -33,13 +33,17 @@ use ApiPlatform\Core\Tests\Fixtures\DummyUniqueValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyValidatedChoiceEntity; use ApiPlatform\Core\Tests\Fixtures\DummyValidatedEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyValidatedHostnameEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyValidatedUlidEntity; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Constraints\AtLeastOneOf; use Symfony\Component\Validator\Constraints\Compound; +use Symfony\Component\Validator\Constraints\Hostname; use Symfony\Component\Validator\Constraints\Sequentially; +use Symfony\Component\Validator\Constraints\Ulid; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; @@ -318,36 +322,45 @@ public function testCreateWithPropertyRegexRestriction(): void $this->assertEquals('dummy', $schema['pattern']); } - public function testCreateWithPropertyFormatRestriction(): void + /** + * @dataProvider providePropertySchemaFormatCases + */ + public function testCreateWithPropertyFormatRestriction(string $property, string $class, array $expectedSchema): void { - $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + $validatorClassMetadata = new ClassMetadata($class); (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + $validatorMetadataFactory->getMetadataFor($class) ->willReturn($validatorClassMetadata) ->shouldBeCalled(); - $formats = [ - 'dummyEmail' => 'email', - 'dummyUuid' => 'uuid', - 'dummyIpv4' => 'ipv4', - 'dummyIpv6' => 'ipv6', - ]; - foreach ($formats as $property => $format) { - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( - new PropertyMetadata() - )->shouldBeCalled(); - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [new PropertySchemaFormat()] - ); - $schema = $validationPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); - $this->assertNotNull($schema); - $this->assertArrayHasKey('format', $schema); - $this->assertEquals($format, $schema['format']); + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create($class, $property, [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaFormat()] + ); + $schema = $validationPropertyMetadataFactory->create($class, $property)->getSchema(); + + $this->assertSame($expectedSchema, $schema); + } + + public function providePropertySchemaFormatCases(): \Generator + { + yield ['dummyEmail', DummyValidatedEntity::class, ['format' => 'email']]; + yield ['dummyUuid', DummyValidatedEntity::class, ['format' => 'uuid']]; + yield ['dummyIpv4', DummyValidatedEntity::class, ['format' => 'ipv4']]; + yield ['dummyIpv6', DummyValidatedEntity::class, ['format' => 'ipv6']]; + yield ['dummyUrl', DummyValidatedEntity::class, ['format' => 'uri']]; + if (class_exists(Ulid::class)) { + yield ['dummyUlid', DummyValidatedUlidEntity::class, ['format' => 'ulid']]; + } + if (class_exists(Hostname::class)) { + yield ['dummyHostname', DummyValidatedHostnameEntity::class, ['format' => 'hostname']]; } } diff --git a/tests/Fixtures/DummyValidatedEntity.php b/tests/Fixtures/DummyValidatedEntity.php index 3668c7ff51b..1e287372193 100644 --- a/tests/Fixtures/DummyValidatedEntity.php +++ b/tests/Fixtures/DummyValidatedEntity.php @@ -77,4 +77,11 @@ class DummyValidatedEntity * @Assert\NotNull(groups={"dummy"}) */ public $dummyGroup; + + /** + * @var string A dummy url + * + * @Assert\Url + */ + public $dummyUrl; } diff --git a/tests/Fixtures/DummyValidatedHostnameEntity.php b/tests/Fixtures/DummyValidatedHostnameEntity.php new file mode 100644 index 00000000000..dcf0306dbde --- /dev/null +++ b/tests/Fixtures/DummyValidatedHostnameEntity.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyValidatedHostnameEntity +{ + /** + * @var string + * + * @Assert\Hostname + */ + public $dummyHostname; +} diff --git a/tests/Fixtures/DummyValidatedUlidEntity.php b/tests/Fixtures/DummyValidatedUlidEntity.php new file mode 100644 index 00000000000..0600249eae0 --- /dev/null +++ b/tests/Fixtures/DummyValidatedUlidEntity.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyValidatedUlidEntity +{ + /** + * @var string + * + * @Assert\Ulid + */ + public $dummyUlid; +} From f355595f6c521aa50ed5f64fb1c4d454debcfd3d Mon Sep 17 00:00:00 2001 From: Vitaliy Date: Mon, 12 Apr 2021 11:15:23 +0300 Subject: [PATCH 27/41] FIX: ApiPlatform Extension extra configuration - merging arrays (#4193) --- .../ApiPlatformExtension.php | 5 ++++- .../ApiPlatformExtensionTest.php | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index db08be5a470..09af447af4c 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -398,7 +398,10 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_swagger_ui', $config['enable_swagger_ui']); $container->setParameter('api_platform.enable_re_doc', $config['enable_re_doc']); $container->setParameter('api_platform.swagger.api_keys', $config['swagger']['api_keys']); - $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?? $config['swagger']['swagger_ui_extra_configuration']); + if ($config['openapi']['swagger_ui_extra_configuration'] && $config['swagger']['swagger_ui_extra_configuration']) { + throw new RuntimeException('You can not set "swagger_ui_extra_configuration" twice - in "openapi" and "swagger" section.'); + } + $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?: $config['swagger']['swagger_ui_extra_configuration']); if (true === $config['openapi']['backward_compatibility_layer']) { $container->getDefinition('api_platform.swagger.normalizer.documentation')->addArgument($container->getDefinition('api_platform.openapi.normalizer')); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 4414d7b3b8e..ab1ce1ef375 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -859,6 +859,27 @@ static function (string $path): string { $this->extension->load($config, $containerBuilderProphecy->reveal()); } + public function testSwaggerUiExtraConfigurationMerging() + { + $containerBuilderProphecy = $this->getBaseContainerBuilderProphecy(); + $containerBuilderProphecy->setParameter('api_platform.swagger_ui.extra_configuration', [])->shouldNotBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.swagger_ui.extra_configuration', ['someParameter' => 'someValue'])->shouldBeCalled(); + $containerBuilder = $containerBuilderProphecy->reveal(); + + $config = self::DEFAULT_CONFIG; + $config['api_platform']['openapi']['swagger_ui_extra_configuration'] = []; + $config['api_platform']['swagger']['swagger_ui_extra_configuration'] = ['someParameter' => 'someValue']; + + $this->extension->load($config, $containerBuilder); + + $config['api_platform']['openapi']['swagger_ui_extra_configuration'] = ['someParameter' => 'someValue']; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('You can not set "swagger_ui_extra_configuration" twice - in "openapi" and "swagger" section.'); + + $this->extension->load($config, $containerBuilder); + } + private function getPartialContainerBuilderProphecy($configuration = null) { $parameterBag = new EnvPlaceholderParameterBag(); From d62e86e9cfa2a7a4eba3029053ff65f43a10e1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Nork=C5=ABnas?= Date: Tue, 13 Apr 2021 11:33:53 +0300 Subject: [PATCH 28/41] Add support for generating property schema with Collection restriction (#4182) --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 5 + .../PropertySchemaCollectionRestriction.php | 96 ++++++++++++++ .../ApiPlatformExtensionTest.php | 1 + ...ropertySchemaCollectionRestrictionTest.php | 121 ++++++++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 65 ++++++++++ .../DummyCollectionValidatedEntity.php | 50 ++++++++ 7 files changed, 339 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php create mode 100644 tests/Fixtures/DummyCollectionValidatedEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d086b5bb5..ea73b393bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema with Collection restriction (#4182) * JSON Schema: Add support for generating property schema format for Url and Hostname (#4185) * JSON Schema: Add support for generating property schema with Count restriction (#4186) * JSON Schema: Manage Compound constraint when generating property metadata (#4180) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index 165eee08e50..d05525f5e6c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -21,6 +21,11 @@ + + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php new file mode 100644 index 00000000000..cf5336820a4 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestriction.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Collection; +use Symfony\Component\Validator\Constraints\Optional; +use Symfony\Component\Validator\Constraints\Required; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaCollectionRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * @var iterable + */ + private $restrictionsMetadata; + + /** + * @param iterable $restrictionsMetadata + */ + public function __construct(iterable $restrictionsMetadata = []) + { + $this->restrictionsMetadata = $restrictionsMetadata; + } + + /** + * {@inheritdoc} + * + * @param Collection $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $restriction = [ + 'type' => 'object', + 'properties' => [], + 'additionalProperties' => $constraint->allowExtraFields, + ]; + $required = []; + + foreach ($constraint->fields as $field => $baseConstraint) { + /** @var Required|Optional $baseConstraint */ + if ($baseConstraint instanceof Required) { + $required[] = $field; + } + + $restriction['properties'][$field] = $this->mergeConstraintRestrictions($baseConstraint, $propertyMetadata); + } + + if ($required) { + $restriction['required'] = $required; + } + + return $restriction; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Collection; + } + + /** + * @param Required|Optional $constraint + */ + private function mergeConstraintRestrictions(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $propertyRestrictions = []; + $nestedConstraints = method_exists($constraint, 'getNestedContraints') ? $constraint->getNestedContraints() : $constraint->constraints; + + foreach ($nestedConstraints as $nestedConstraint) { + foreach ($this->restrictionsMetadata as $restrictionMetadata) { + if ($restrictionMetadata->supports($nestedConstraint, $propertyMetadata) && !empty($nestedConstraintRestriction = $restrictionMetadata->create($nestedConstraint, $propertyMetadata))) { + $propertyRestrictions[] = $nestedConstraintRestriction; + } + } + } + + return array_merge([], ...$propertyRestrictions); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index ab1ce1ef375..1f5b861ee64 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1358,6 +1358,7 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.metadata.property.metadata_factory.annotation', 'api_platform.metadata.property.metadata_factory.validator', 'api_platform.metadata.property_schema.choice_restriction', + 'api_platform.metadata.property_schema.collection_restriction', 'api_platform.metadata.property_schema.count_restriction', 'api_platform.metadata.property_schema.length_restriction', 'api_platform.metadata.property_schema.one_of_restriction', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php new file mode 100644 index 00000000000..80842e2dd9a --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Collection; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Optional; +use Symfony\Component\Validator\Constraints\Positive; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Required; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaCollectionRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaCollectionRestriction; + + protected function setUp(): void + { + $this->propertySchemaCollectionRestriction = new PropertySchemaCollectionRestriction([ + new PropertySchemaLengthRestriction(), + new PropertySchemaRegexRestriction(), + new PropertySchemaFormat(), + new PropertySchemaCollectionRestriction(), + ]); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaCollectionRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported' => [new Collection(['fields' => []]), new PropertyMetadata(), true]; + + yield 'not supported' => [new Positive(), new PropertyMetadata(), false]; + } + + /** + * @dataProvider createProvider + */ + public function testCreate(Constraint $constraint, PropertyMetadata $propertyMetadata, array $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaCollectionRestriction->create($constraint, $propertyMetadata)); + } + + public function createProvider(): \Generator + { + yield 'empty' => [new Collection(['fields' => []]), new PropertyMetadata(), ['type' => 'object', 'properties' => [], 'additionalProperties' => false]]; + + yield 'with fields' => [ + new Collection([ + 'allowExtraFields' => true, + 'fields' => [ + 'name' => new Required([ + new NotBlank(), + ]), + 'email' => [ + new NotNull(), + new Length(['min' => 2, 'max' => 255]), + new Email(['mode' => Email::VALIDATION_MODE_LOOSE]), + ], + 'phone' => new Optional([ + new \Symfony\Component\Validator\Constraints\Type(['type' => 'string']), + new Regex(['pattern' => '/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/']), + ]), + 'age' => new Optional([ + new \Symfony\Component\Validator\Constraints\Type(['type' => 'int']), + ]), + 'social' => new Collection([ + 'fields' => [ + 'githubUsername' => new NotNull(), + ], + ]), + ], + ]), + new PropertyMetadata(), + [ + 'type' => 'object', + 'properties' => [ + 'name' => [], + 'email' => ['format' => 'email'], + 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], + 'age' => [], + 'social' => ['type' => 'object', 'properties' => ['githubUsername' => []], 'additionalProperties' => false, 'required' => ['githubUsername']], + ], + 'additionalProperties' => true, + 'required' => ['name', 'email', 'social'], + ], + ]; + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 570bcd695f0..769b8b523ab 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; @@ -25,6 +26,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyAtLeastOneOfValidatedEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyCollectionValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyCompoundValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyCountValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity; @@ -581,4 +583,67 @@ public function provideCountConstraintCases(): \Generator yield 'max' => ['property' => 'dummyMax', 'expectedSchema' => ['maxItems' => 10]]; yield 'min/max' => ['property' => 'dummyMinMax', 'expectedSchema' => ['minItems' => 1, 'maxItems' => 10]]; } + + public function testCreateWithPropertyCollectionRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyCollectionValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyCollectionValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyCollectionValidatedEntity::class, 'dummyData', [])->willReturn( + new PropertyMetadata(new Type(Type::BUILTIN_TYPE_ARRAY)) + )->shouldBeCalled(); + + $lengthRestriction = new PropertySchemaLengthRestriction(); + $regexRestriction = new PropertySchemaRegexRestriction(); + $formatRestriction = new PropertySchemaFormat(); + $restrictionsMetadata = [ + $lengthRestriction, + $regexRestriction, + $formatRestriction, + new PropertySchemaCollectionRestriction([ + $lengthRestriction, + $regexRestriction, + $formatRestriction, + new PropertySchemaCollectionRestriction([ + $lengthRestriction, + $regexRestriction, + $formatRestriction, + ]), + ]), + ]; + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + $restrictionsMetadata + ); + + $schema = $validationPropertyMetadataFactory->create(DummyCollectionValidatedEntity::class, 'dummyData')->getSchema(); + + $this->assertSame([ + 'type' => 'object', + 'properties' => [ + 'name' => [], + 'email' => ['format' => 'email'], + 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], + 'age' => [], + 'social' => [ + 'type' => 'object', + 'properties' => [ + 'githubUsername' => [], + ], + 'additionalProperties' => false, + 'required' => ['githubUsername'], + ], + ], + 'additionalProperties' => true, + 'required' => ['name', 'email', 'social'], + ], $schema); + } } diff --git a/tests/Fixtures/DummyCollectionValidatedEntity.php b/tests/Fixtures/DummyCollectionValidatedEntity.php new file mode 100644 index 00000000000..93902217521 --- /dev/null +++ b/tests/Fixtures/DummyCollectionValidatedEntity.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyCollectionValidatedEntity +{ + /** + * @var array + * + * @Assert\Collection( + * allowExtraFields=true, + * fields={ + * "name"=@Assert\Required({ + * @Assert\NotBlank + * }), + * "email"={ + * @Assert\NotNull, + * @Assert\Length(min=2, max=255), + * @Assert\Email(mode=Assert\Email::VALIDATION_MODE_LOOSE) + * }, + * "phone"=@Assert\Optional({ + * @Assert\Type(type="string"), + * @Assert\Regex(pattern="/^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\.\/0-9]*$/") + * }), + * "age"=@Assert\Optional({ + * @Assert\Type(type="int") + * }), + * "social"=@Assert\Collection( + * fields={ + * "githubUsername"=@Assert\NotNull + * } + * ) + * } + * ) + */ + public $dummyData; +} From 6a72c6e4fb30062e040771db59903f621fee7687 Mon Sep 17 00:00:00 2001 From: Tomas Date: Fri, 16 Apr 2021 15:26:52 +0300 Subject: [PATCH 29/41] Add support for generating property schema with numeric constraint restrictions --- CHANGELOG.md | 1 + .../Bundle/Resources/config/validator.xml | 16 ++++ ...rtySchemaGreaterThanOrEqualRestriction.php | 45 ++++++++++ .../PropertySchemaGreaterThanRestriction.php | 46 ++++++++++ ...opertySchemaLessThanOrEqualRestriction.php | 45 ++++++++++ .../PropertySchemaLessThanRestriction.php | 46 ++++++++++ .../ApiPlatformExtensionTest.php | 4 + ...chemaGreaterThanOrEqualRestrictionTest.php | 61 +++++++++++++ ...opertySchemaGreaterThanRestrictionTest.php | 65 ++++++++++++++ ...tySchemaLessThanOrEqualRestrictionTest.php | 61 +++++++++++++ .../PropertySchemaLessThanRestrictionTest.php | 64 +++++++++++++ .../ValidatorPropertyMetadataFactoryTest.php | 89 +++++++++++++++++++ .../Fixtures/DummyNumericValidatedEntity.php | 75 ++++++++++++++++ 13 files changed, 618 insertions(+) create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php create mode 100644 src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php create mode 100644 tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php create mode 100644 tests/Fixtures/DummyNumericValidatedEntity.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ea73b393bdc..c6be371153e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.7.0 +* JSON Schema: Add support for generating property schema with numeric constraint restrictions (#4225) * JSON Schema: Add support for generating property schema with Collection restriction (#4182) * JSON Schema: Add support for generating property schema format for Url and Hostname (#4185) * JSON Schema: Add support for generating property schema with Count restriction (#4186) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index d05525f5e6c..8e0a2c3f22b 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -30,10 +30,26 @@ + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php new file mode 100644 index 00000000000..4380b8c140b --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaGreaterThanOrEqualRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + * + * @param GreaterThanOrEqual $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return [ + 'minimum' => $constraint->value, + ]; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof GreaterThanOrEqual && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php new file mode 100644 index 00000000000..044df1cf7ce --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\GreaterThan; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaGreaterThanRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + * + * @param GreaterThan $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return [ + 'minimum' => $constraint->value, + 'exclusiveMinimum' => true, + ]; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof GreaterThan && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php new file mode 100644 index 00000000000..592eb8b479e --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\LessThanOrEqual; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaLessThanOrEqualRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + * + * @param LessThanOrEqual $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return [ + 'maximum' => $constraint->value, + ]; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof LessThanOrEqual && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php new file mode 100644 index 00000000000..cf5c8cca153 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\LessThan; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaLessThanRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + * + * @param LessThan $constraint + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return [ + 'maximum' => $constraint->value, + 'exclusiveMaximum' => true, + ]; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof LessThan && is_numeric($constraint->value) && null !== ($type = $propertyMetadata->getType()) && \in_array($type->getBuiltinType(), [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT], true); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 1f5b861ee64..2d9e3f85ec2 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1360,7 +1360,11 @@ private function getBaseContainerBuilderProphecyWithoutDefaultMetadataLoading(ar 'api_platform.metadata.property_schema.choice_restriction', 'api_platform.metadata.property_schema.collection_restriction', 'api_platform.metadata.property_schema.count_restriction', + 'api_platform.metadata.property_schema.greater_than_or_equal_restriction', + 'api_platform.metadata.property_schema.greater_than_restriction', 'api_platform.metadata.property_schema.length_restriction', + 'api_platform.metadata.property_schema.less_than_or_equal_restriction', + 'api_platform.metadata.property_schema.less_than_restriction', 'api_platform.metadata.property_schema.one_of_restriction', 'api_platform.metadata.property_schema.range_restriction', 'api_platform.metadata.property_schema.regex_restriction', diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php new file mode 100644 index 00000000000..517155ffd4b --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanOrEqualRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; +use Symfony\Component\Validator\Constraints\Positive; +use Symfony\Component\Validator\Constraints\PositiveOrZero; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaGreaterThanOrEqualRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaGreaterThanOrEqualRestriction; + + protected function setUp(): void + { + $this->propertySchemaGreaterThanOrEqualRestriction = new PropertySchemaGreaterThanOrEqualRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaGreaterThanOrEqualRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported int' => [new GreaterThanOrEqual(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'supported float' => [new GreaterThanOrEqual(['value' => 10.99]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), true]; + yield 'supported positive or zero' => [new PositiveOrZero(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'not supported positive' => [new Positive(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + yield 'not supported property path' => [new GreaterThanOrEqual(['propertyPath' => 'greaterThanMe']), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + } + + public function testCreate(): void + { + self::assertSame(['minimum' => 10], $this->propertySchemaGreaterThanOrEqualRestriction->create(new GreaterThanOrEqual(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)))); + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php new file mode 100644 index 00000000000..e19669ef2aa --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\GreaterThan; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; +use Symfony\Component\Validator\Constraints\Positive; +use Symfony\Component\Validator\Constraints\PositiveOrZero; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaGreaterThanRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaGreaterThanRestriction; + + protected function setUp(): void + { + $this->propertySchemaGreaterThanRestriction = new PropertySchemaGreaterThanRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaGreaterThanRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported int' => [new GreaterThan(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'supported float' => [new GreaterThan(['value' => 10.99]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), true]; + yield 'supported positive' => [new Positive(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'not supported positive or zero' => [new PositiveOrZero(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + yield 'not supported property path' => [new GreaterThan(['propertyPath' => 'greaterThanMe']), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + } + + public function testCreate(): void + { + self::assertSame([ + 'minimum' => 10, + 'exclusiveMinimum' => true, + ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThanOrEqual(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)))); + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php new file mode 100644 index 00000000000..ea97bb04587 --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanOrEqualRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\LessThanOrEqual; +use Symfony\Component\Validator\Constraints\Negative; +use Symfony\Component\Validator\Constraints\NegativeOrZero; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaLessThanOrEqualRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaLessThanOrEqualRestriction; + + protected function setUp(): void + { + $this->propertySchemaLessThanOrEqualRestriction = new PropertySchemaLessThanOrEqualRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaLessThanOrEqualRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported int' => [new LessThanOrEqual(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'supported float' => [new LessThanOrEqual(['value' => 10.99]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), true]; + yield 'supported negative or zero' => [new NegativeOrZero(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'not supported negative' => [new Negative(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + yield 'not supported property path' => [new LessThanOrEqual(['propertyPath' => 'greaterThanMe']), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + } + + public function testCreate(): void + { + self::assertSame(['maximum' => 10], $this->propertySchemaLessThanOrEqualRestriction->create(new LessThanOrEqual(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)))); + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php new file mode 100644 index 00000000000..c65d855e3f9 --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanRestriction; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\LessThan; +use Symfony\Component\Validator\Constraints\Negative; +use Symfony\Component\Validator\Constraints\NegativeOrZero; + +/** + * @author Tomas Norkūnas + */ +final class PropertySchemaLessThanRestrictionTest extends TestCase +{ + use ProphecyTrait; + + private $propertySchemaLessThanRestriction; + + protected function setUp(): void + { + $this->propertySchemaLessThanRestriction = new PropertySchemaLessThanRestriction(); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(Constraint $constraint, PropertyMetadata $propertyMetadata, bool $expectedResult): void + { + self::assertSame($expectedResult, $this->propertySchemaLessThanRestriction->supports($constraint, $propertyMetadata)); + } + + public function supportsProvider(): \Generator + { + yield 'supported int' => [new LessThan(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'supported float' => [new LessThan(['value' => 10.99]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), true]; + yield 'supported negative' => [new Negative(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), true]; + yield 'not supported negative or zero' => [new NegativeOrZero(), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + yield 'not supported property path' => [new LessThan(['propertyPath' => 'greaterThanMe']), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), false]; + } + + public function testCreate(): void + { + self::assertSame([ + 'maximum' => 10, + 'exclusiveMaximum' => true, + ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10]), new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)))); + } +} diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 769b8b523ab..95622b7d699 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -17,7 +17,11 @@ use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCollectionRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanOrEqualRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanOrEqualRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaOneOfRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRangeRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction; @@ -30,6 +34,7 @@ use ApiPlatform\Core\Tests\Fixtures\DummyCompoundValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyCountValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyIriWithValidationEntity; +use ApiPlatform\Core\Tests\Fixtures\DummyNumericValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyRangeValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummySequentiallyValidatedEntity; use ApiPlatform\Core\Tests\Fixtures\DummyUniqueValidatedEntity; @@ -646,4 +651,88 @@ public function testCreateWithPropertyCollectionRestriction(): void 'required' => ['name', 'email', 'social'], ], $schema); } + + /** + * @dataProvider provideNumericConstraintCases + */ + public function testCreateWithPropertyNumericRestriction(PropertyMetadata $propertyMetadata, string $property, array $expectedSchema): void + { + $validatorClassMetadata = new ClassMetadata(DummyNumericValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyNumericValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyNumericValidatedEntity::class, $property, [])->willReturn( + $propertyMetadata + )->shouldBeCalled(); + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), + [ + new PropertySchemaGreaterThanOrEqualRestriction(), + new PropertySchemaGreaterThanRestriction(), + new PropertySchemaLessThanOrEqualRestriction(), + new PropertySchemaLessThanRestriction(), + ] + ); + + $schema = $validationPropertyMetadataFactory->create(DummyNumericValidatedEntity::class, $property)->getSchema(); + + $this->assertSame($expectedSchema, $schema); + } + + public function provideNumericConstraintCases(): \Generator + { + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), + 'property' => 'greaterThanMe', + 'expectedSchema' => ['minimum' => 10, 'exclusiveMinimum' => true], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), + 'property' => 'greaterThanOrEqualToMe', + 'expectedSchema' => ['minimum' => 10.99], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), + 'property' => 'lessThanMe', + 'expectedSchema' => ['maximum' => 99, 'exclusiveMaximum' => true], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_FLOAT)), + 'property' => 'lessThanOrEqualToMe', + 'expectedSchema' => ['maximum' => 99.33], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), + 'property' => 'positive', + 'expectedSchema' => ['minimum' => 0, 'exclusiveMinimum' => true], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), + 'property' => 'positiveOrZero', + 'expectedSchema' => ['minimum' => 0], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), + 'property' => 'negative', + 'expectedSchema' => ['maximum' => 0, 'exclusiveMaximum' => true], + ]; + + yield [ + 'propertyMetadata' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT)), + 'property' => 'negativeOrZero', + 'expectedSchema' => ['maximum' => 0], + ]; + } } diff --git a/tests/Fixtures/DummyNumericValidatedEntity.php b/tests/Fixtures/DummyNumericValidatedEntity.php new file mode 100644 index 00000000000..83e09ff4dbc --- /dev/null +++ b/tests/Fixtures/DummyNumericValidatedEntity.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures; + +use Symfony\Component\Validator\Constraints as Assert; + +class DummyNumericValidatedEntity +{ + /** + * @var int + * + * @Assert\GreaterThan(value=10) + */ + public $greaterThanMe; + + /** + * @var float + * + * @Assert\GreaterThanOrEqual(value=10.99) + */ + public $greaterThanOrEqualToMe; + + /** + * @var int + * + * @Assert\LessThan(value=99) + */ + public $lessThanMe; + + /** + * @var float + * + * @Assert\LessThanOrEqual(value=99.33) + */ + public $lessThanOrEqualToMe; + + /** + * @var int + * + * @Assert\Positive + */ + public $positive; + + /** + * @var int + * + * @Assert\PositiveOrZero + */ + public $positiveOrZero; + + /** + * @var int + * + * @Assert\Negative + */ + public $negative; + + /** + * @var int + * + * @Assert\NegativeOrZero + */ + public $negativeOrZero; +} From e4e6d0cb632b6918961cfafce7b97b95221dcd60 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon Date: Fri, 26 Mar 2021 15:56:47 +0100 Subject: [PATCH 30/41] Fix #2686: deprecate allow_plain_identifiers option --- CHANGELOG.md | 1 + features/json/relation.feature | 6 +- features/main/relation.feature | 4 +- .../ApiPlatformExtension.php | 1 + .../DependencyInjection/Configuration.php | 6 +- .../Symfony/Bundle/Resources/config/api.xml | 1 + .../Bundle/Resources/config/graphql.xml | 1 + src/Serializer/AbstractItemNormalizer.php | 10 +++ .../ApiPlatformExtensionTest.php | 1 + .../DummyPlainIdentifierDenormalizer.php | 71 +++++++++++++++++++ ...elatedDummyPlainIdentifierDenormalizer.php | 65 +++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 15 +++- .../Serializer/AbstractItemNormalizerTest.php | 13 ++++ 13 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Serializer/Denormalizer/DummyPlainIdentifierDenormalizer.php create mode 100644 tests/Fixtures/TestBundle/Serializer/Denormalizer/RelatedDummyPlainIdentifierDenormalizer.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ea73b393bdc..fafd4311f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ * Swagger UI: Add `swagger_ui_extra_configuration` to Swagger / OpenAPI configuration (#3731) * GraphQL: Support `ApiProperty` security (#4143) * GraphQL: **BC** Fix security on association collection properties. The collection resource `item_query` security is no longer used. `ApiProperty` security can now be used to secure collection (or any other) properties. (#4143) +* Deprecate `allow_plain_identifiers` option (#4167) ## 2.6.4 diff --git a/features/json/relation.feature b/features/json/relation.feature index 7c39dc75f71..f76b9096b36 100644 --- a/features/json/relation.feature +++ b/features/json/relation.feature @@ -120,7 +120,7 @@ Feature: JSON relations support } """ - Scenario: Update an embedded relation using plain identifiers + Scenario: Update an embedded relation When I add "Content-Type" header equal to "application/json" And I send a "PUT" request to "/relation_embedders/1" with body: """ @@ -151,7 +151,8 @@ Feature: JSON relations support } """ - Scenario: Create a related dummy with a relation + # TODO: to remove in 3.0 + Scenario: Create a related dummy with a relation using plain identifiers When I add "Content-Type" header equal to "application/json" And I send a "POST" request to "/related_dummies" with body: """ @@ -184,6 +185,7 @@ Feature: JSON relations support } """ + # TODO: to remove in 3.0 Scenario: Passing a (valid) plain identifier on a relation When I add "Content-Type" header equal to "application/json" And I send a "POST" request to "/dummies" with body: diff --git a/features/main/relation.feature b/features/main/relation.feature index 8c268ed8417..dd4a65c0a99 100644 --- a/features/main/relation.feature +++ b/features/main/relation.feature @@ -497,13 +497,13 @@ Feature: Relations support And I send a "POST" request to "/relation_embedders" with body: """ { - "related": "certainly not an iri and not a plain identifier" + "related": "certainly not an IRI" } """ Then the response status code should be 400 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:description" should contain 'Invalid IRI "certainly not an iri and not a plain identifier".' + And the JSON node "hydra:description" should contain 'Invalid IRI "certainly not an IRI".' Scenario: Passing an invalid type to a relation When I add "Content-Type" header equal to "application/ld+json" diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 09af447af4c..5d7100bafec 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -177,6 +177,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.formats', $formats); $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); + // TODO: to remove in 3.0 $container->setParameter('api_platform.allow_plain_identifiers', $config['allow_plain_identifiers']); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 6380d02c74a..962cc16beca 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -95,7 +95,11 @@ public function getConfigTreeBuilder() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() ->scalarNode('asset_package')->defaultNull()->info('Specify an asset package name to use.')->end() ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() - ->booleanNode('allow_plain_identifiers')->defaultFalse()->info('Allow plain identifiers, for example "id" instead of "@id" when denormalizing a relation.')->end() + ->booleanNode('allow_plain_identifiers') + ->defaultFalse() + ->info('Allow plain identifiers, for example "id" instead of "@id" when denormalizing a relation.') + ->setDeprecated(...$this->buildDeprecationArgs('2.7', 'The use of `allow_plain_identifiers` has been deprecated in 2.7 and will be removed in 3.0.')) + ->end() ->arrayNode('validator') ->addDefaultsIfNotSet() ->children() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 1f4cf3a1dcb..fc1bf40e797 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -111,6 +111,7 @@ + %api_platform.allow_plain_identifiers% null diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 8d679d5c6f6..4e23af26195 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -216,6 +216,7 @@ + %api_platform.allow_plain_identifiers% null diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 3e41e9da00d..8ab7b0e116a 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -88,7 +88,12 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->resourceClassResolver = $resourceClassResolver; $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); $this->itemDataProvider = $itemDataProvider; + + if (true === $allowPlainIdentifiers) { + @trigger_error(sprintf('Allowing plain identifiers as argument of "%s" is deprecated since API Platform 2.7 and will not be possible anymore in API Platform 3.', self::class), \E_USER_DEPRECATED); + } $this->allowPlainIdentifiers = $allowPlainIdentifiers; + $this->dataTransformers = $dataTransformers; $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceAccessChecker = $resourceAccessChecker; @@ -851,6 +856,11 @@ private function setValue($object, string $attributeName, $value) } } + /** + * TODO: to remove in 3.0. + * + * @deprecated since 2.7 + */ private function supportsPlainIdentifiers(): bool { return $this->allowPlainIdentifiers && null !== $this->itemDataProvider; diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 1f5b861ee64..6b11bbcce05 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -918,6 +918,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.title' => 'title', 'api_platform.version' => 'version', 'api_platform.show_webby' => true, + // TODO: to remove in 3.0 'api_platform.allow_plain_identifiers' => false, 'api_platform.eager_loading.enabled' => Argument::type('bool'), 'api_platform.eager_loading.max_joins' => 30, diff --git a/tests/Fixtures/TestBundle/Serializer/Denormalizer/DummyPlainIdentifierDenormalizer.php b/tests/Fixtures/TestBundle/Serializer/Denormalizer/DummyPlainIdentifierDenormalizer.php new file mode 100644 index 00000000000..0f072e9655e --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/Denormalizer/DummyPlainIdentifierDenormalizer.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Denormalizer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy as DummyEntity; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy as RelatedDummyEntity; +use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; + +/** + * BC to keep allow_plain_identifiers working in 2.7 tests + * TODO: to remove in 3.0. + * + * @author Vincent Chalamon + */ +class DummyPlainIdentifierDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + private $iriConverter; + + public function __construct(IriConverterInterface $iriConverter) + { + $this->iriConverter = $iriConverter; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + $relatedDummyClass = DummyEntity::class === $class ? RelatedDummyEntity::class : RelatedDummyDocument::class; + if (!empty($data['relatedDummy'])) { + $data['relatedDummy'] = $this->iriConverter->getItemIriFromResourceClass($relatedDummyClass, ['id' => $data['relatedDummy']]); + } + + if (!empty($data['relatedDummies'])) { + foreach ($data['relatedDummies'] as $k => $v) { + $data['relatedDummies'][$k] = $this->iriConverter->getItemIriFromResourceClass($relatedDummyClass, ['id' => $v]); + } + } + + return $this->denormalizer->denormalize($data, $class, $format, $context + [__CLASS__ => true]); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool + { + return 'json' === $format + && (is_a($type, DummyEntity::class, true) || is_a($type, DummyDocument::class, true)) + && ('1' === ($data['relatedDummy'] ?? null) || ['1'] === ($data['relatedDummies'] ?? null)) + && !isset($context[__CLASS__]); + } +} diff --git a/tests/Fixtures/TestBundle/Serializer/Denormalizer/RelatedDummyPlainIdentifierDenormalizer.php b/tests/Fixtures/TestBundle/Serializer/Denormalizer/RelatedDummyPlainIdentifierDenormalizer.php new file mode 100644 index 00000000000..079a89edb13 --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/Denormalizer/RelatedDummyPlainIdentifierDenormalizer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Denormalizer; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy as RelatedDummyEntity; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel as ThirdLevelEntity; +use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; + +/** + * BC to keep allow_plain_identifiers working in 2.7 tests + * TODO: to remove in 3.0. + * + * @author Vincent Chalamon + */ +class RelatedDummyPlainIdentifierDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + private $iriConverter; + + public function __construct(IriConverterInterface $iriConverter) + { + $this->iriConverter = $iriConverter; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + $data['thirdLevel'] = $this->iriConverter->getItemIriFromResourceClass( + RelatedDummyEntity::class === $class ? ThirdLevelEntity::class : ThirdLevelDocument::class, + ['id' => $data['thirdLevel']] + ); + + return $this->denormalizer->denormalize($data, $class, $format, $context + [__CLASS__ => true]); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null, array $context = []): bool + { + return 'json' === $format + && (is_a($type, RelatedDummyEntity::class, true) || is_a($type, RelatedDummyDocument::class, true)) + && '1' === ($data['thirdLevel'] ?? null) + && !isset($context[__CLASS__]); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 20e9dadb1f8..c5b565d06f8 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -25,7 +25,6 @@ api_platform: description: | This is a test API. Made with love - allow_plain_identifiers: true formats: jsonld: ['application/ld+json'] jsonhal: ['application/hal+json'] @@ -118,6 +117,20 @@ services: tags: - { name: 'serializer.normalizer' } + app.serializer.denormalizer.related_dummy_plain_identifier: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Denormalizer\RelatedDummyPlainIdentifierDenormalizer' + public: false + arguments: ['@api_platform.iri_converter'] + tags: + - { name: 'serializer.normalizer' } + + app.serializer.denormalizer.dummy_plain_identifier: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Denormalizer\DummyPlainIdentifierDenormalizer' + public: false + arguments: ['@api_platform.iri_converter'] + tags: + - { name: 'serializer.normalizer' } + app.name_converter: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter' diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 47af10747d4..09592c80316 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -942,6 +942,11 @@ public function testChildInheritedProperty(): void ], $normalizer->normalize($dummy, null, ['resource_class' => DummyTableInheritance::class, 'resources' => []])); } + /** + * TODO: to remove in 3.0. + * + * @group legacy + */ public function testDenormalizeRelationWithPlainId() { $data = [ @@ -995,6 +1000,11 @@ public function testDenormalizeRelationWithPlainId() $propertyAccessorProphecy->setValue($actual, 'relatedDummy', $relatedDummy)->shouldHaveBeenCalled(); } + /** + * TODO: to remove in 3.0. + * + * @group legacy + */ public function testDenormalizeRelationWithPlainIdNotFound() { $this->expectException(ItemNotFoundException::class); @@ -1054,6 +1064,9 @@ public function testDenormalizeRelationWithPlainIdNotFound() $normalizer->denormalize($data, Dummy::class, 'jsonld'); } + /** + * TODO: to remove in 3.0. + */ public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNotAllowed() { $this->expectException(UnexpectedValueException::class); From 4b491b660419fa3254c84c701ab0a4ea8e7c26be Mon Sep 17 00:00:00 2001 From: Baptiste Meyer Date: Mon, 26 Apr 2021 08:38:25 +0200 Subject: [PATCH 31/41] Add the ability to customize multiple status codes based on the validation exception (#4017) --- .../ValidationExceptionListener.php | 4 +- ...ntViolationListAwareExceptionInterface.php | 28 +++++++++++ .../Exception/ValidationException.php | 5 +- .../ValidationExceptionListenerTest.php | 49 +++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 src/Bridge/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php diff --git a/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php b/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php index d0da61f1d2d..82ebfe35f37 100644 --- a/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php +++ b/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Validator\EventListener; -use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface; use ApiPlatform\Core\Util\ErrorFormatGuesser; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; @@ -43,7 +43,7 @@ public function __construct(SerializerInterface $serializer, array $errorFormats public function onKernelException(ExceptionEvent $event): void { $exception = method_exists($event, 'getThrowable') ? $event->getThrowable() : $event->getException(); // @phpstan-ignore-line - if (!$exception instanceof ValidationException) { + if (!$exception instanceof ConstraintViolationListAwareExceptionInterface) { return; } $exceptionClass = \get_class($exception); diff --git a/src/Bridge/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php b/src/Bridge/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php new file mode 100644 index 00000000000..cc47928de0b --- /dev/null +++ b/src/Bridge/Symfony/Validator/Exception/ConstraintViolationListAwareExceptionInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Exception; + +use ApiPlatform\Core\Exception\ExceptionInterface; +use Symfony\Component\Validator\ConstraintViolationListInterface; + +/** + * An exception which has a constraint violation list. + */ +interface ConstraintViolationListAwareExceptionInterface extends ExceptionInterface +{ + /** + * Gets constraint violations related to this exception. + */ + public function getConstraintViolationList(): ConstraintViolationListInterface; +} diff --git a/src/Bridge/Symfony/Validator/Exception/ValidationException.php b/src/Bridge/Symfony/Validator/Exception/ValidationException.php index 02af2a21629..b3a1ca6372f 100644 --- a/src/Bridge/Symfony/Validator/Exception/ValidationException.php +++ b/src/Bridge/Symfony/Validator/Exception/ValidationException.php @@ -21,7 +21,7 @@ * * @author Kévin Dunglas */ -final class ValidationException extends BaseValidationException +final class ValidationException extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface { private $constraintViolationList; @@ -32,9 +32,6 @@ public function __construct(ConstraintViolationListInterface $constraintViolatio parent::__construct($message ?: $this->__toString(), $code, $previous); } - /** - * Gets constraint violations related to this exception. - */ public function getConstraintViolationList(): ConstraintViolationListInterface { return $this->constraintViolationList; diff --git a/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php b/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php index b1128b477c0..af84cd340c0 100644 --- a/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php +++ b/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php @@ -14,8 +14,10 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\EventListener; use ApiPlatform\Core\Bridge\Symfony\Validator\EventListener\ValidationExceptionListener; +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ConstraintViolationListAwareExceptionInterface; use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; use ApiPlatform\Core\Tests\ProphecyTrait; +use ApiPlatform\Core\Validator\Exception\ValidationException as BaseValidationException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -23,6 +25,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\ConstraintViolationListInterface; /** * @author Kévin Dunglas @@ -62,4 +65,50 @@ public function testValidationException() $this->assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); $this->assertSame('deny', $response->headers->get('X-Frame-Options')); } + + public function testOnKernelValidationExceptionWithCustomStatus(): void + { + $serializedConstraintViolationList = '{"foo": "bar"}'; + $constraintViolationList = new ConstraintViolationList([]); + $exception = new class($constraintViolationList) extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface { + private $constraintViolationList; + + public function __construct(ConstraintViolationListInterface $constraintViolationList, $message = '', $code = 0, \Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->constraintViolationList = $constraintViolationList; + } + + public function getConstraintViolationList(): ConstraintViolationListInterface + { + return $this->constraintViolationList; + } + }; + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($constraintViolationList, 'hydra')->willReturn($serializedConstraintViolationList)->shouldBeCalledOnce(); + + $exceptionEvent = new ExceptionEvent( + $this->prophesize(HttpKernelInterface::class)->reveal(), + new Request(), + HttpKernelInterface::MASTER_REQUEST, + $exception + ); + + (new ValidationExceptionListener( + $serializerProphecy->reveal(), + ['hydra' => ['application/ld+json']], + [\get_class($exception) => Response::HTTP_BAD_REQUEST] + ))->onKernelException($exceptionEvent); + + $response = $exceptionEvent->getResponse(); + + self::assertInstanceOf(Response::class, $response); + self::assertSame($serializedConstraintViolationList, $response->getContent()); + self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + self::assertSame('application/ld+json; charset=utf-8', $response->headers->get('Content-Type')); + self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); + self::assertSame('deny', $response->headers->get('X-Frame-Options')); + } } From 1b39c9a908213e0f08342e139165bfaa13480a57 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 26 Apr 2021 08:43:53 +0200 Subject: [PATCH 32/41] chore: Missing changelog validation exception (#4017) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8182b68747e..f63c3850fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ * GraphQL: Support `ApiProperty` security (#4143) * GraphQL: **BC** Fix security on association collection properties. The collection resource `item_query` security is no longer used. `ApiProperty` security can now be used to secure collection (or any other) properties. (#4143) * Deprecate `allow_plain_identifiers` option (#4167) +* Exception: Add the ability to customize multiple status codes based on the validation exception (#4017) ## 2.6.5 From af4c8395a53a52ce0815af9adfefa8fc17d2256b Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 26 Apr 2021 09:27:02 +0200 Subject: [PATCH 33/41] test(Validation): wrong phone regex --- .../Restriction/PropertySchemaCollectionRestrictionTest.php | 2 +- .../Metadata/Property/ValidatorPropertyMetadataFactoryTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php index 80842e2dd9a..c776dd3bb26 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php @@ -109,7 +109,7 @@ public function createProvider(): \Generator 'properties' => [ 'name' => [], 'email' => ['format' => 'email'], - 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], + 'phone' => ['pattern' => '^([+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], 'age' => [], 'social' => ['type' => 'object', 'properties' => ['githubUsername' => []], 'additionalProperties' => false, 'required' => ['githubUsername']], ], diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 4bffe8231cb..da18b63e49f 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -636,7 +636,7 @@ public function testCreateWithPropertyCollectionRestriction(): void 'properties' => [ 'name' => [], 'email' => ['format' => 'email'], - 'phone' => ['pattern' => '^(?:[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], + 'phone' => ['pattern' => '^([+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], 'age' => [], 'social' => [ 'type' => 'object', From a625fe7211f877613ca1e971bbe17f7895401acb Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 Apr 2021 17:19:27 +0200 Subject: [PATCH 34/41] GraphQL: Fix GraphQL fetching with Elasticsearch --- CHANGELOG.md | 1 + behat.yml.dist | 1 + features/elasticsearch/graphql.feature | 99 +++++++++ .../DataProvider/SubresourceDataProvider.php | 148 +++++++++++++ .../Bundle/Resources/config/elasticsearch.xml | 14 ++ .../SubresourceDataProviderTest.php | 204 ++++++++++++++++++ .../ApiPlatformExtensionTest.php | 1 + 7 files changed, 468 insertions(+) create mode 100644 features/elasticsearch/graphql.feature create mode 100644 src/Bridge/Elasticsearch/DataProvider/SubresourceDataProvider.php create mode 100644 tests/Bridge/Elasticsearch/DataProvider/SubresourceDataProviderTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f63c3850fa5..421ba1612da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ * GraphQL: **BC** Fix security on association collection properties. The collection resource `item_query` security is no longer used. `ApiProperty` security can now be used to secure collection (or any other) properties. (#4143) * Deprecate `allow_plain_identifiers` option (#4167) * Exception: Add the ability to customize multiple status codes based on the validation exception (#4017) +* GraphQL: Fix graphql fetching with Elasticsearch (#4217) ## 2.6.5 diff --git a/behat.yml.dist b/behat.yml.dist index 54d22dd1014..3970282b6b1 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -84,6 +84,7 @@ elasticsearch: contexts: - 'ApiPlatform\Core\Tests\Behat\CommandContext' - 'ApiPlatform\Core\Tests\Behat\ElasticsearchContext' + - 'ApiPlatform\Core\Tests\Behat\GraphqlContext' - 'ApiPlatform\Core\Tests\Behat\JsonContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' diff --git a/features/elasticsearch/graphql.feature b/features/elasticsearch/graphql.feature new file mode 100644 index 00000000000..ad198c113e5 --- /dev/null +++ b/features/elasticsearch/graphql.feature @@ -0,0 +1,99 @@ +Feature: GraphQL query support + + @elasticsearch + Scenario: Execute a GraphQL query on an ElasticSearch model with SubResources + When I send the following GraphQL request: + """ + query { + users { + edges { + node { + id, + gender, + tweets { + edges { + node { + id, + message, + } + } + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be equal to: + """ + { + "data": { + "users": { + "edges": [ + { + "node": { + "id": "/users/116b83f8-6c32-48d8-8e28-c5c247532d3f", + "gender": "male", + "tweets": { + "edges": [ + { + "node": { + "id": "/tweets/89601e1c-3ef2-4ef7-bca2-7511d38611c6", + "message": "Great day in any endur... During the Himalayas were very talented skimo racer junior podiums, top 10." + } + }, + { + "node": { + "id": "/tweets/9da70727-d656-42d9-876a-1be6321f171b", + "message": "During the path and his Summits Of My Life project. Next Wednesday, Kilian Jornet..." + } + }, + { + "node": { + "id": "/tweets/f36a0026-0635-4865-86a6-5adb21d94d64", + "message": "The north summit, Store Vengetind Thanks for t... These Top 10 Women of a fk... Francois is the field which." + } + } + ] + } + } + }, + { + "node": { + "id": "/users/15fce6f1-18fd-4ef6-acab-7e6a3333ec7f", + "gender": "male", + "tweets": { + "edges": [ + { + "node": { + "id": "/tweets/0cfe3d33-6116-416b-8c50-3b8319331998", + "message": "Thanks! Fun day with Next up one of our 2018 cover: One look into what races we'll be running that they!" + } + } + ] + } + } + }, + { + "node": { + "id": "/users/6a457188-d1ba-45e3-8509-81e5c66a5297", + "gender": "female", + "tweets": { + "edges": [ + { + "node": { + "id": "/tweets/9de3308c-6f82-4a57-a33c-4e3cd5d5a3f6", + "message": "In case you do! A humble beginning to traverse... Im so now until you can't tell how strong she run at!" + } + } + ] + } + } + } + ] + } + } + } + """ diff --git a/src/Bridge/Elasticsearch/DataProvider/SubresourceDataProvider.php b/src/Bridge/Elasticsearch/DataProvider/SubresourceDataProvider.php new file mode 100644 index 00000000000..b3a598a713e --- /dev/null +++ b/src/Bridge/Elasticsearch/DataProvider/SubresourceDataProvider.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Elasticsearch\DataProvider; + +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\NonUniqueIdentifierException; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; +use ApiPlatform\Core\DataProvider\Pagination; +use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; +use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\Exception\ResourceClassNotFoundException; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use Elasticsearch\Client; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +final class SubresourceDataProvider implements SubresourceDataProviderInterface, RestrictedDataProviderInterface +{ + private $client; + private $documentMetadataFactory; + private $identifierExtractor; + private $denormalizer; + private $pagination; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $collectionExtensions; + + public function __construct(Client $client, DocumentMetadataFactoryInterface $documentMetadataFactory, IdentifierExtractorInterface $identifierExtractor, DenormalizerInterface $denormalizer, Pagination $pagination, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = []) + { + $this->client = $client; + $this->documentMetadataFactory = $documentMetadataFactory; + $this->identifierExtractor = $identifierExtractor; + $this->denormalizer = $denormalizer; + $this->pagination = $pagination; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->collectionExtensions = $collectionExtensions; + } + + /** + * {@inheritdoc} + */ + public function supports(string $resourceClass, ?string $operationName = null, array $context = []): bool + { + try { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if (false === $resourceMetadata->getCollectionOperationAttribute($operationName, 'elasticsearch', true, true)) { + return false; + } + } catch (ResourceClassNotFoundException $e) { + return false; + } + + try { + $this->documentMetadataFactory->create($resourceClass); + } catch (IndexNotFoundException $e) { + return false; + } + + // Elasticsearch does not support subresources as items (yet) + if (false === ($context['collection'] ?? false)) { + return false; + } + + try { + $this->identifierExtractor->getIdentifierFromResourceClass($resourceClass); + } catch (NonUniqueIdentifierException $e) { + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName = null) + { + $documentMetadata = $this->documentMetadataFactory->create($resourceClass); + $body = []; + + foreach ($this->collectionExtensions as $collectionExtension) { + $body = $collectionExtension->applyToCollection($body, $resourceClass, $operationName, $context); + } + + // Elasticsearch subresources only works with single identifiers + $idKey = key($context['identifiers']); + [$relationClass, $identiferProperty] = $context['identifiers'][$idKey]; + + $propertyName = null; + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + $type = $propertyMetadata->getType(); + + if ($type && $type->getClassName() === $relationClass) { + $propertyName = $property; + break; + } + } + + if (!$propertyName) { + throw new RuntimeException('The property has not been found for this relation.'); + } + + $relationQuery = ['match' => [$propertyName.'.'.$identiferProperty => $identifiers[$idKey]]]; + + if (!isset($body['query']) && !isset($body['aggs'])) { + $body['query'] = $relationQuery; + } else { + $body['query']['constant_score']['filter']['bool']['must'][] = $relationQuery; + } + + $limit = $body['size'] = $body['size'] ?? $this->pagination->getLimit($resourceClass, $operationName, $context); + $offset = $body['from'] = $body['from'] ?? $this->pagination->getOffset($resourceClass, $operationName, $context); + + $documents = $this->client->search([ + 'index' => $documentMetadata->getIndex(), + 'type' => $documentMetadata->getType(), + 'body' => $body, + ]); + + return new Paginator( + $this->denormalizer, + $documents, + $resourceClass, + $limit, + $offset, + $context + ); + } +} diff --git a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml index 3f1a23bb9a1..5b158573b05 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml @@ -82,6 +82,20 @@ + + + + + + + + + + + + + + diff --git a/tests/Bridge/Elasticsearch/DataProvider/SubresourceDataProviderTest.php b/tests/Bridge/Elasticsearch/DataProvider/SubresourceDataProviderTest.php new file mode 100644 index 00000000000..07ff5e3a743 --- /dev/null +++ b/tests/Bridge/Elasticsearch/DataProvider/SubresourceDataProviderTest.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider; + +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\RequestBodySearchCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Paginator; +use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\SubresourceDataProvider; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException; +use ApiPlatform\Core\Bridge\Elasticsearch\Exception\NonUniqueIdentifierException; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; +use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; +use ApiPlatform\Core\DataProvider\Pagination; +use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\Exception\ResourceClassNotFoundException; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Core\Tests\ProphecyTrait; +use Elasticsearch\Client; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class SubresourceDataProviderTest extends TestCase +{ + use ProphecyTrait; + + public function testConstruct() + { + self::assertInstanceOf( + SubresourceDataProviderInterface::class, + new SubresourceDataProvider( + $this->prophesize(Client::class)->reveal(), + $this->prophesize(DocumentMetadataFactoryInterface::class)->reveal(), + $this->prophesize(IdentifierExtractorInterface::class)->reveal(), + $this->prophesize(DenormalizerInterface::class)->reveal(), + new Pagination($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal()), + $this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal() + ) + ); + } + + public function testSupports() + { + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + $documentMetadataFactoryProphecy->create(Dummy::class)->willThrow(new IndexNotFoundException())->shouldBeCalled(); + $documentMetadataFactoryProphecy->create(CompositeRelation::class)->willReturn(new DocumentMetadata('composite_relation'))->shouldBeCalled(); + + $identifierExtractorProphecy = $this->prophesize(IdentifierExtractorInterface::class); + $identifierExtractorProphecy->getIdentifierFromResourceClass(Foo::class)->willReturn('id')->shouldBeCalled(); + $identifierExtractorProphecy->getIdentifierFromResourceClass(CompositeRelation::class)->willThrow(new NonUniqueIdentifierException())->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->shouldBeCalled()->willReturn(new ResourceMetadata()); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->shouldBeCalled()->willReturn((new ResourceMetadata())->withAttributes(['elasticsearch' => false])); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadata()); + $resourceMetadataFactoryProphecy->create(CompositeRelation::class)->shouldBeCalled()->willReturn(new ResourceMetadata()); + $resourceMetadataFactoryProphecy->create(DummyCarColor::class)->shouldBeCalled()->willThrow(new ResourceClassNotFoundException()); + + $subresourceDataProvider = new SubresourceDataProvider( + $this->prophesize(Client::class)->reveal(), + $documentMetadataFactoryProphecy->reveal(), + $identifierExtractorProphecy->reveal(), + $this->prophesize(DenormalizerInterface::class)->reveal(), + new Pagination($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal()), + $resourceMetadataFactoryProphecy->reveal(), + $this->prophesize(PropertyNameCollectionFactoryInterface::class)->reveal(), + $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal() + ); + + self::assertTrue($subresourceDataProvider->supports(Foo::class, null, ['collection' => true])); + self::assertFalse($subresourceDataProvider->supports(Dummy::class, null, ['collection' => true])); + self::assertFalse($subresourceDataProvider->supports(CompositeRelation::class, null, ['collection' => true])); + self::assertFalse($subresourceDataProvider->supports(DummyCar::class, null, ['collection' => true])); + self::assertFalse($subresourceDataProvider->supports(DummyCarColor::class, null, ['collection' => true])); + self::assertFalse($subresourceDataProvider->supports(Foo::class, null, ['collection' => false])); + } + + public function testGetSubresource() + { + $context = [ + 'groups' => ['custom'], + 'identifiers' => ['id' => ['BarRelationClass', 'barId']], + ]; + + $documentMetadataFactoryProphecy = $this->prophesize(DocumentMetadataFactoryInterface::class); + $documentMetadataFactoryProphecy->create(Foo::class)->willReturn(new DocumentMetadata('foo'))->shouldBeCalled(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadata()); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Foo::class)->willReturn(new PropertyNameCollection(['id', 'bar', 'nanme'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Foo::class, 'id')->willReturn(new PropertyMetadata()); + $propertyMetadataFactoryProphecy->create(Foo::class, 'bar')->willReturn(new PropertyMetadata(new Type('object', true, 'BarRelationClass'))); + $propertyMetadataFactoryProphecy->create(Foo::class, 'name')->willReturn(new PropertyMetadata()); + + $documents = [ + 'took' => 15, + 'time_out' => false, + '_shards' => [ + 'total' => 5, + 'successful' => 5, + 'skipped' => 0, + 'failed' => 0, + ], + 'hits' => [ + 'total' => 4, + 'max_score' => 1, + 'hits' => [ + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '1', + '_score' => 1, + '_source' => [ + 'id' => 1, + 'name' => 'Kilian', + 'bar' => 'Jornet', + ], + ], + [ + '_index' => 'foo', + '_type' => '_doc', + '_id' => '2', + '_score' => 1, + '_source' => [ + 'id' => 2, + 'name' => 'François', + 'bar' => 'D\'Haene', + ], + ], + ], + ], + ]; + + $clientProphecy = $this->prophesize(Client::class); + $clientProphecy + ->search( + Argument::allOf( + Argument::withEntry('index', 'foo'), + Argument::withEntry('type', DocumentMetadata::DEFAULT_TYPE), + Argument::withEntry('body', Argument::allOf( + Argument::withEntry('size', 2), + Argument::withEntry('from', 0), + Argument::withEntry('query', Argument::allOf( + Argument::withEntry('match', Argument::allOf(Argument::withEntry('bar.barId', 1))), + Argument::size(1) + )), + Argument::size(3) + )), + Argument::size(3) + ) + ) + ->willReturn($documents) + ->shouldBeCalled(); + + $requestBodySearchCollectionExtensionProphecy = $this->prophesize(RequestBodySearchCollectionExtensionInterface::class); + $requestBodySearchCollectionExtensionProphecy->applyToCollection([], Foo::class, 'get', $context)->willReturn([])->shouldBeCalled(); + + $subresourceDataProvider = new SubresourceDataProvider( + $clientProphecy->reveal(), + $documentMetadataFactoryProphecy->reveal(), + $this->prophesize(IdentifierExtractorInterface::class)->reveal(), + $denormalizer = $this->prophesize(DenormalizerInterface::class)->reveal(), + new Pagination($resourceMetadataFactoryProphecy->reveal(), ['items_per_page' => 2]), + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + [$requestBodySearchCollectionExtensionProphecy->reveal()] + ); + + self::assertEquals( + new Paginator($denormalizer, $documents, Foo::class, 2, 0, $context), + $subresourceDataProvider->getSubresource(Foo::class, ['id' => 1], $context, 'get') + ); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index f516934f5c4..616c7d8c979 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -675,6 +675,7 @@ public function testEnableElasticsearch() $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.term_filter', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.order_filter', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.match_filter', Argument::type(Definition::class))->shouldBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.subresource_data_provider', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setAlias('api_platform.elasticsearch.metadata.document.metadata_factory', 'api_platform.elasticsearch.metadata.document.metadata_factory.configured')->shouldBeCalled(); $containerBuilderProphecy->setAlias(DocumentMetadataFactoryInterface::class, 'api_platform.elasticsearch.metadata.document.metadata_factory')->shouldBeCalled(); $containerBuilderProphecy->setAlias(IdentifierExtractorInterface::class, 'api_platform.elasticsearch.identifier_extractor')->shouldBeCalled(); From 1cee253f71e936958a613bfb7bef8fa9a3e5f570 Mon Sep 17 00:00:00 2001 From: meyerbaptiste Date: Mon, 25 Jan 2021 16:49:33 +0100 Subject: [PATCH 35/41] Fix the denormalization of \DateTime(Immutable) for Elasticsearch documents --- .../DataProvider/ItemDataProvider.php | 4 +- .../Elasticsearch/DataProvider/Paginator.php | 4 +- .../Serializer/DocumentNormalizer.php | 98 ++++++++++++ .../Serializer/ItemNormalizer.php | 91 +++++------ .../Bundle/Resources/config/elasticsearch.xml | 10 +- .../DataProvider/ItemDataProviderTest.php | 4 +- .../DataProvider/PaginatorTest.php | 4 +- .../Serializer/DocumentNormalizerTest.php | 98 ++++++++++++ .../Serializer/ItemNormalizerTest.php | 143 ++++++++++++------ .../ApiPlatformExtensionTest.php | 1 + .../Elasticsearch/Mappings/tweet.json | 62 ++++---- .../Fixtures/Elasticsearch/Mappings/user.json | 68 ++++----- tests/Fixtures/Elasticsearch/Model/Tweet.php | 4 +- 13 files changed, 407 insertions(+), 184 deletions(-) create mode 100644 src/Bridge/Elasticsearch/Serializer/DocumentNormalizer.php create mode 100644 tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php diff --git a/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php b/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php index 9d3fcb1c75e..5f6cb58bb7a 100644 --- a/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php +++ b/src/Bridge/Elasticsearch/DataProvider/ItemDataProvider.php @@ -17,7 +17,7 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Exception\IndexNotFoundException; use ApiPlatform\Core\Bridge\Elasticsearch\Exception\NonUniqueIdentifierException; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; -use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; @@ -101,7 +101,7 @@ public function getItem(string $resourceClass, $id, ?string $operationName = nul return null; } - $item = $this->denormalizer->denormalize($document, $resourceClass, ItemNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true]); + $item = $this->denormalizer->denormalize($document, $resourceClass, DocumentNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true]); if (!\is_object($item) && null !== $item) { throw new \UnexpectedValueException('Expected item to be an object or null.'); } diff --git a/src/Bridge/Elasticsearch/DataProvider/Paginator.php b/src/Bridge/Elasticsearch/DataProvider/Paginator.php index e14608ca843..5561ced9b4e 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Paginator.php +++ b/src/Bridge/Elasticsearch/DataProvider/Paginator.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\Bridge\Elasticsearch\DataProvider; -use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Core\DataProvider\PaginatorInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -115,7 +115,7 @@ public function getIterator(): \Traversable $object = $this->denormalizer->denormalize( $document, $this->resourceClass, - ItemNormalizer::FORMAT, + DocumentNormalizer::FORMAT, $denormalizationContext ); diff --git a/src/Bridge/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Bridge/Elasticsearch/Serializer/DocumentNormalizer.php new file mode 100644 index 00000000000..8f08852314a --- /dev/null +++ b/src/Bridge/Elasticsearch/Serializer/DocumentNormalizer.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Elasticsearch\Serializer; + +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; + +/** + * Document denormalizer for Elasticsearch. + * + * @experimental + * + * @author Baptiste Meyer + */ +final class DocumentNormalizer extends ObjectNormalizer +{ + public const FORMAT = 'elasticsearch'; + + private $identifierExtractor; + + public function __construct(IdentifierExtractorInterface $identifierExtractor, ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, callable $objectClassResolver = null, array $defaultContext = []) + { + parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); + + $this->identifierExtractor = $identifierExtractor; + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + if (\is_string($data['_id'] ?? null) && \is_array($data['_source'] ?? null)) { + $data = $this->populateIdentifier($data, $class)['_source']; + } + + return parent::denormalize($data, $class, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format + return self::FORMAT === $format; + } + + /** + * {@inheritdoc} + * + * @throws LogicException + */ + public function normalize($object, $format = null, array $context = []) + { + throw new LogicException(sprintf('%s is a write-only format.', self::FORMAT)); + } + + /** + * Populates the resource identifier with the document identifier if not present in the original JSON document. + */ + private function populateIdentifier(array $data, string $class): array + { + $identifier = $this->identifierExtractor->getIdentifierFromResourceClass($class); + $identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT); + + if (!isset($data['_source'][$identifier])) { + $data['_source'][$identifier] = $data['_id']; + } + + return $data; + } +} diff --git a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php index c5bc46ffedf..730af7fa4dc 100644 --- a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php @@ -13,86 +13,67 @@ namespace ApiPlatform\Core\Bridge\Elasticsearch\Serializer; -use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; -use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; /** - * Item denormalizer for Elasticsearch. + * Item normalizer decorator that prevents {@see \ApiPlatform\Core\Serializer\ItemNormalizer} + * from taking over for the {@see DocumentNormalizer::FORMAT} format because of priorities. * * @experimental - * - * @author Baptiste Meyer */ -final class ItemNormalizer extends ObjectNormalizer +final class ItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface { - public const FORMAT = 'elasticsearch'; - - private $identifierExtractor; + private $decorated; - public function __construct(IdentifierExtractorInterface $identifierExtractor, ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, callable $objectClassResolver = null, array $defaultContext = []) + public function __construct(NormalizerInterface $decorated) { - parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); + if (!$decorated instanceof DenormalizerInterface) { + throw new InvalidArgumentException(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); + } + + if (!$decorated instanceof SerializerAwareInterface) { + throw new InvalidArgumentException(sprintf('The decorated normalizer must be an instance of "%s".', SerializerAwareInterface::class)); + } + + if (!$decorated instanceof CacheableSupportsMethodInterface) { + throw new InvalidArgumentException(sprintf('The decorated normalizer must be an instance of "%s".', CacheableSupportsMethodInterface::class)); + } - $this->identifierExtractor = $identifierExtractor; + $this->decorated = $decorated; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, $type, $format = null): bool + public function hasCacheableSupportsMethod(): bool { - return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format); + return $this->decorated->hasCacheableSupportsMethod(); } - /** - * {@inheritdoc} - */ - public function denormalize($data, $class, $format = null, array $context = []) + public function denormalize($data, $type, $format = null, array $context = []) { - if (\is_string($data['_id'] ?? null) && \is_array($data['_source'] ?? null)) { - $data = $this->populateIdentifier($data, $class)['_source']; - } - - return parent::denormalize($data, $class, $format, $context); + return $this->decorated->denormalize($data, $type, $format, $context); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, $format = null): bool + public function supportsDenormalization($data, $type, $format = null) { - // prevent the use of lower priority normalizers (e.g. serializer.normalizer.object) for this format - return self::FORMAT === $format; + return DocumentNormalizer::FORMAT !== $format && $this->decorated->supportsDenormalization($data, $type, $format); } - /** - * {@inheritdoc} - * - * @throws LogicException - */ public function normalize($object, $format = null, array $context = []) { - throw new LogicException(sprintf('%s is a write-only format.', self::FORMAT)); + return $this->decorated->normalize($object, $format, $context); } - /** - * Populates the resource identifier with the document identifier if not present in the original JSON document. - */ - private function populateIdentifier(array $data, string $class): array + public function supportsNormalization($data, $format = null) { - $identifier = $this->identifierExtractor->getIdentifierFromResourceClass($class); - $identifier = null === $this->nameConverter ? $identifier : $this->nameConverter->normalize($identifier, $class, self::FORMAT); - - if (!isset($data['_source'][$identifier])) { - $data['_source'][$identifier] = $data['_id']; - } + return DocumentNormalizer::FORMAT !== $format && $this->decorated->supportsNormalization($data, $format); + } - return $data; + public function setSerializer(SerializerInterface $serializer) + { + $this->decorated->setSerializer($serializer); } } diff --git a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml index 5b158573b05..a36b677db62 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/elasticsearch.xml @@ -48,7 +48,11 @@ - + + + + + @@ -56,8 +60,8 @@ - - + + diff --git a/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php b/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php index a89545551da..975674638fd 100644 --- a/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php +++ b/tests/Bridge/Elasticsearch/DataProvider/ItemDataProviderTest.php @@ -19,7 +19,7 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Exception\NonUniqueIdentifierException; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; -use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; @@ -116,7 +116,7 @@ public function testGetItem() $clientProphecy->get(['index' => 'foo', 'type' => DocumentMetadata::DEFAULT_TYPE, 'id' => '1'])->willReturn($document)->shouldBeCalled(); $denormalizerProphecy = $this->prophesize(DenormalizerInterface::class); - $denormalizerProphecy->denormalize($document, Foo::class, ItemNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true])->willReturn($foo)->shouldBeCalled(); + $denormalizerProphecy->denormalize($document, Foo::class, DocumentNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true])->willReturn($foo)->shouldBeCalled(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); diff --git a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php index e30237b5a53..3dc9017d051 100644 --- a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php +++ b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\DataProvider; use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Paginator; -use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -178,7 +178,7 @@ private function getPaginator(int $limit = self::OFFSET, int $offset = self::OFF foreach ($documents['hits']['hits'] as $document) { $denormalizerProphecy - ->denormalize($document, Foo::class, ItemNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true, 'groups' => ['custom']]) + ->denormalize($document, Foo::class, DocumentNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true, 'groups' => ['custom']]) ->willReturn($this->denormalizeFoo($document['_source'])); } diff --git a/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php b/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php new file mode 100644 index 00000000000..403f77892bd --- /dev/null +++ b/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\Serializer; + +use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class DocumentNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testConstruct() + { + $itemNormalizer = new DocumentNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); + + self::assertInstanceOf(DenormalizerInterface::class, $itemNormalizer); + self::assertInstanceOf(NormalizerInterface::class, $itemNormalizer); + } + + public function testSupportsDenormalization() + { + $document = [ + '_index' => 'test', + '_type' => '_doc', + '_id' => '1', + '_version' => 1, + 'found' => true, + '_source' => [ + 'id' => 1, + 'name' => 'Caroline', + 'bar' => 'Chaverot', + ], + ]; + + $itemNormalizer = new DocumentNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); + + self::assertTrue($itemNormalizer->supportsDenormalization($document, Foo::class, DocumentNormalizer::FORMAT)); + self::assertFalse($itemNormalizer->supportsDenormalization($document, Foo::class, 'text/coffee')); + } + + public function testDenormalize() + { + $document = [ + '_index' => 'test', + '_type' => '_doc', + '_id' => '1', + '_version' => 1, + 'found' => true, + '_source' => [ + 'name' => 'Caroline', + 'bar' => 'Chaverot', + ], + ]; + + $identifierExtractorProphecy = $this->prophesize(IdentifierExtractorInterface::class); + $identifierExtractorProphecy->getIdentifierFromResourceClass(Foo::class)->willReturn('id')->shouldBeCalled(); + + $normalizer = new DocumentNormalizer($identifierExtractorProphecy->reveal()); + + $expectedFoo = new Foo(); + $expectedFoo->setName('Caroline'); + $expectedFoo->setBar('Chaverot'); + + self::assertEquals($expectedFoo, $normalizer->denormalize($document, Foo::class, DocumentNormalizer::FORMAT)); + } + + public function testSupportsNormalization() + { + $itemNormalizer = new DocumentNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); + + self::assertTrue($itemNormalizer->supportsNormalization(new Foo(), DocumentNormalizer::FORMAT)); + } + + public function testNormalize() + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('%s is a write-only format.', DocumentNormalizer::FORMAT)); + + (new DocumentNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()))->normalize(new Foo(), DocumentNormalizer::FORMAT); + } +} diff --git a/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php b/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php index 910a4adc9a2..6a389a24acb 100644 --- a/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php +++ b/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php @@ -13,86 +13,131 @@ namespace ApiPlatform\Core\Tests\Bridge\Elasticsearch\Serializer; -use ApiPlatform\Core\Bridge\Elasticsearch\Api\IdentifierExtractorInterface; +use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; -use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; -use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; -class ItemNormalizerTest extends TestCase +final class ItemNormalizerTest extends TestCase { use ProphecyTrait; - public function testConstruct() + private $normalizerProphecy; + private $itemNormalizer; + + protected function setUp(): void { - $itemNormalizer = new ItemNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); + $this->itemNormalizer = new ItemNormalizer( + ( + $this->normalizerProphecy = $this + ->prophesize(NormalizerInterface::class) + ->willImplement(DenormalizerInterface::class) + ->willImplement(SerializerAwareInterface::class) + ->willImplement(CacheableSupportsMethodInterface::class) + )->reveal() + ); + } + + /** @dataProvider decoratedNormalizerProvider */ + public function testConstruct(NormalizerInterface $normalizer, ?string $exception): void + { + if (null !== $exception) { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($exception); + } + + $itemNormalizer = new ItemNormalizer($normalizer); - self::assertInstanceOf(DenormalizerInterface::class, $itemNormalizer); self::assertInstanceOf(NormalizerInterface::class, $itemNormalizer); + self::assertInstanceOf(DenormalizerInterface::class, $itemNormalizer); + self::assertInstanceOf(SerializerAwareInterface::class, $itemNormalizer); + self::assertInstanceOf(CacheableSupportsMethodInterface::class, $itemNormalizer); } - public function testSupportsDenormalization() + public function testHasCacheableSupportsMethod(): void { - $document = [ - '_index' => 'test', - '_type' => '_doc', - '_id' => '1', - '_version' => 1, - 'found' => true, - '_source' => [ - 'id' => 1, - 'name' => 'Caroline', - 'bar' => 'Chaverot', - ], - ]; + $this->normalizerProphecy->hasCacheableSupportsMethod()->willReturn(true)->shouldBeCalledOnce(); + + self::assertTrue($this->itemNormalizer->hasCacheableSupportsMethod()); + } - $itemNormalizer = new ItemNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); + public function testDenormalize(): void + { + $this->normalizerProphecy->denormalize('foo', 'string', 'json', ['groups' => 'foo'])->willReturn('foo')->shouldBeCalledOnce(); - self::assertTrue($itemNormalizer->supportsDenormalization($document, Foo::class, ItemNormalizer::FORMAT)); - self::assertFalse($itemNormalizer->supportsDenormalization($document, Foo::class, 'text/coffee')); + self::assertSame('foo', $this->itemNormalizer->denormalize('foo', 'string', 'json', ['groups' => 'foo'])); } - public function testDenormalize() + public function testSupportsDenormalization(): void { - $document = [ - '_index' => 'test', - '_type' => '_doc', - '_id' => '1', - '_version' => 1, - 'found' => true, - '_source' => [ - 'name' => 'Caroline', - 'bar' => 'Chaverot', - ], - ]; + $this->normalizerProphecy->supportsDenormalization('foo', 'string', 'json')->willReturn(true)->shouldBeCalledOnce(); + $this->normalizerProphecy->supportsDenormalization('foo', 'string', DocumentNormalizer::FORMAT)->shouldNotBeCalled(); - $identifierExtractorProphecy = $this->prophesize(IdentifierExtractorInterface::class); - $identifierExtractorProphecy->getIdentifierFromResourceClass(Foo::class)->willReturn('id')->shouldBeCalled(); + self::assertTrue($this->itemNormalizer->supportsDenormalization('foo', 'string', 'json')); + self::assertFalse($this->itemNormalizer->supportsDenormalization('foo', 'string', DocumentNormalizer::FORMAT)); + } + + public function testNormalize(): void + { + $this->normalizerProphecy->normalize($object = (object) ['foo'], 'json', ['groups' => 'foo'])->willReturn(['foo'])->shouldBeCalledOnce(); - $normalizer = new ItemNormalizer($identifierExtractorProphecy->reveal()); + self::assertSame(['foo'], $this->itemNormalizer->normalize($object, 'json', ['groups' => 'foo'])); + } - $expectedFoo = new Foo(); - $expectedFoo->setName('Caroline'); - $expectedFoo->setBar('Chaverot'); + public function testSupportsNormalization(): void + { + $this->normalizerProphecy->supportsNormalization($object = (object) ['foo'], 'json')->willReturn(true)->shouldBeCalledOnce(); + $this->normalizerProphecy->supportsNormalization($object, DocumentNormalizer::FORMAT)->shouldNotBeCalled(); - self::assertEquals($expectedFoo, $normalizer->denormalize($document, Foo::class, ItemNormalizer::FORMAT)); + self::assertTrue($this->itemNormalizer->supportsNormalization($object, 'json')); + self::assertFalse($this->itemNormalizer->supportsNormalization($object, DocumentNormalizer::FORMAT)); } - public function testSupportsNormalization() + public function testSetSerializer(): void { - $itemNormalizer = new ItemNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()); + $this->normalizerProphecy->setSerializer($serializer = $this->prophesize(SerializerInterface::class)->reveal())->shouldBeCalledOnce(); - self::assertTrue($itemNormalizer->supportsNormalization(new Foo(), ItemNormalizer::FORMAT)); + $this->itemNormalizer->setSerializer($serializer); } - public function testNormalize() + public function decoratedNormalizerProvider(): iterable { - $this->expectException(LogicException::class); - $this->expectExceptionMessage(sprintf('%s is a write-only format.', ItemNormalizer::FORMAT)); + yield [ + $this->prophesize(NormalizerInterface::class)->reveal(), + sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class), + ]; - (new ItemNormalizer($this->prophesize(IdentifierExtractorInterface::class)->reveal()))->normalize(new Foo(), ItemNormalizer::FORMAT); + yield [ + $this + ->prophesize(NormalizerInterface::class) + ->willImplement(DenormalizerInterface::class) + ->reveal(), + sprintf('The decorated normalizer must be an instance of "%s".', SerializerAwareInterface::class), + ]; + + yield [ + $this + ->prophesize(NormalizerInterface::class) + ->willImplement(DenormalizerInterface::class) + ->willImplement(SerializerAwareInterface::class) + ->reveal(), + sprintf('The decorated normalizer must be an instance of "%s".', CacheableSupportsMethodInterface::class), + ]; + + yield [ + $this + ->prophesize(NormalizerInterface::class) + ->willImplement(DenormalizerInterface::class) + ->willImplement(SerializerAwareInterface::class) + ->willImplement(CacheableSupportsMethodInterface::class) + ->reveal(), + null, + ]; } } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 616c7d8c979..7d6745049a7 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -664,6 +664,7 @@ public function testEnableElasticsearch() $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.metadata.document.metadata_factory.cached', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.identifier_extractor', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.name_converter.inner_fields', Argument::type(Definition::class))->shouldBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.normalizer.document', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.normalizer.item', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.item_data_provider', Argument::type(Definition::class))->shouldBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.elasticsearch.collection_data_provider', Argument::type(Definition::class))->shouldBeCalled(); diff --git a/tests/Fixtures/Elasticsearch/Mappings/tweet.json b/tests/Fixtures/Elasticsearch/Mappings/tweet.json index 9a5ab8bfc04..32ef0fb4a11 100644 --- a/tests/Fixtures/Elasticsearch/Mappings/tweet.json +++ b/tests/Fixtures/Elasticsearch/Mappings/tweet.json @@ -1,39 +1,37 @@ { "mappings": { - "_doc": { - "properties": { - "id": { - "type": "keyword" - }, - "author": { - "properties": { - "id": { - "type": "keyword" - }, - "gender": { - "type": "keyword" - }, - "age": { - "type": "integer" - }, - "firstName": { - "type": "text" - }, - "lastName": { - "type": "text" - } + "properties": { + "id": { + "type": "keyword" + }, + "author": { + "properties": { + "id": { + "type": "keyword" }, - "dynamic": "strict" - }, - "date": { - "type": "date", - "format": "yyyy-MM-dd HH:mm:ss" + "gender": { + "type": "keyword" + }, + "age": { + "type": "integer" + }, + "firstName": { + "type": "text" + }, + "lastName": { + "type": "text" + } }, - "message": { - "type": "text" - } + "dynamic": "strict" + }, + "date": { + "type": "date", + "format": "yyyy-MM-dd HH:mm:ss" }, - "dynamic": "strict" - } + "message": { + "type": "text" + } + }, + "dynamic": "strict" } } diff --git a/tests/Fixtures/Elasticsearch/Mappings/user.json b/tests/Fixtures/Elasticsearch/Mappings/user.json index 200297ea008..8cad8f89a2a 100644 --- a/tests/Fixtures/Elasticsearch/Mappings/user.json +++ b/tests/Fixtures/Elasticsearch/Mappings/user.json @@ -1,40 +1,38 @@ { "mappings": { - "_doc": { - "properties": { - "id": { - "type": "keyword" - }, - "gender": { - "type": "keyword" - }, - "age": { - "type": "integer" - }, - "firstName": { - "type": "text" - }, - "lastName": { - "type": "text" - }, - "tweets": { - "type": "nested", - "properties": { - "id": { - "type": "keyword" - }, - "date": { - "type": "date", - "format": "yyyy-MM-dd HH:mm:ss" - }, - "message": { - "type": "text" - } - }, - "dynamic": "strict" - } + "properties": { + "id": { + "type": "keyword" + }, + "gender": { + "type": "keyword" }, - "dynamic": "strict" - } + "age": { + "type": "integer" + }, + "firstName": { + "type": "text" + }, + "lastName": { + "type": "text" + }, + "tweets": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "date": { + "type": "date", + "format": "yyyy-MM-dd HH:mm:ss" + }, + "message": { + "type": "text" + } + }, + "dynamic": "strict" + } + }, + "dynamic": "strict" } } diff --git a/tests/Fixtures/Elasticsearch/Model/Tweet.php b/tests/Fixtures/Elasticsearch/Model/Tweet.php index 3abef0c55b4..8239d3c0109 100644 --- a/tests/Fixtures/Elasticsearch/Model/Tweet.php +++ b/tests/Fixtures/Elasticsearch/Model/Tweet.php @@ -73,12 +73,12 @@ public function setAuthor(User $author): void $this->author = $author; } - public function getDate(): ?\DateTimeInterface + public function getDate(): ?\DateTimeImmutable { return $this->date; } - public function setDate(\DateTimeInterface $date): void + public function setDate(\DateTimeImmutable $date): void { $this->date = $date; } From 3bf91d8806440ae721493f49bed044aecd29b4c1 Mon Sep 17 00:00:00 2001 From: meyerbaptiste Date: Tue, 26 Jan 2021 11:29:22 +0100 Subject: [PATCH 36/41] Fix PHPStan and tests --- features/elasticsearch/read.feature | 1 + .../Serializer/ItemNormalizer.php | 56 ++++++++++--- .../Serializer/DocumentNormalizerTest.php | 2 +- .../Serializer/ItemNormalizerTest.php | 81 ++++++++----------- .../Fixtures/Elasticsearch/Fixtures/user.json | 12 +++ .../Elasticsearch/Mappings/tweet.json | 62 +++++++------- .../Fixtures/Elasticsearch/Mappings/user.json | 72 +++++++++-------- tests/Fixtures/Elasticsearch/Model/User.php | 15 ++++ 8 files changed, 178 insertions(+), 123 deletions(-) diff --git a/features/elasticsearch/read.feature b/features/elasticsearch/read.feature index 2f7c679ce62..5d4177aaee9 100644 --- a/features/elasticsearch/read.feature +++ b/features/elasticsearch/read.feature @@ -20,6 +20,7 @@ Feature: Retrieve from Elasticsearch "age": 31, "firstName": "Kilian", "lastName": "Jornet", + "registeredAt": "2009-09-01T00:00:00+00:00", "tweets": [ { "@id": "/tweets/f36a0026-0635-4865-86a6-5adb21d94d64", diff --git a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php index 730af7fa4dc..f38d19a562d 100644 --- a/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Bridge/Elasticsearch/Serializer/ItemNormalizer.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\Bridge\Elasticsearch\Serializer; -use ApiPlatform\Core\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -32,48 +32,78 @@ final class ItemNormalizer implements NormalizerInterface, DenormalizerInterface public function __construct(NormalizerInterface $decorated) { - if (!$decorated instanceof DenormalizerInterface) { - throw new InvalidArgumentException(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); - } - - if (!$decorated instanceof SerializerAwareInterface) { - throw new InvalidArgumentException(sprintf('The decorated normalizer must be an instance of "%s".', SerializerAwareInterface::class)); - } - - if (!$decorated instanceof CacheableSupportsMethodInterface) { - throw new InvalidArgumentException(sprintf('The decorated normalizer must be an instance of "%s".', CacheableSupportsMethodInterface::class)); - } - $this->decorated = $decorated; } + /** + * {@inheritdoc} + * + * @throws LogicException + */ public function hasCacheableSupportsMethod(): bool { + if (!$this->decorated instanceof CacheableSupportsMethodInterface) { + throw new LogicException(sprintf('The decorated normalizer must be an instance of "%s".', CacheableSupportsMethodInterface::class)); + } + return $this->decorated->hasCacheableSupportsMethod(); } + /** + * {@inheritdoc} + * + * @throws LogicException + */ public function denormalize($data, $type, $format = null, array $context = []) { + if (!$this->decorated instanceof DenormalizerInterface) { + throw new LogicException(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); + } + return $this->decorated->denormalize($data, $type, $format, $context); } + /** + * {@inheritdoc} + * + * @throws LogicException + */ public function supportsDenormalization($data, $type, $format = null) { + if (!$this->decorated instanceof DenormalizerInterface) { + throw new LogicException(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); + } + return DocumentNormalizer::FORMAT !== $format && $this->decorated->supportsDenormalization($data, $type, $format); } + /** + * {@inheritdoc} + */ public function normalize($object, $format = null, array $context = []) { return $this->decorated->normalize($object, $format, $context); } + /** + * {@inheritdoc} + */ public function supportsNormalization($data, $format = null) { return DocumentNormalizer::FORMAT !== $format && $this->decorated->supportsNormalization($data, $format); } + /** + * {@inheritdoc} + * + * @throws LogicException + */ public function setSerializer(SerializerInterface $serializer) { + if (!$this->decorated instanceof SerializerAwareInterface) { + throw new LogicException(sprintf('The decorated normalizer must be an instance of "%s".', SerializerAwareInterface::class)); + } + $this->decorated->setSerializer($serializer); } } diff --git a/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php b/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php index 403f77892bd..e38f1edf422 100644 --- a/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php +++ b/tests/Bridge/Elasticsearch/Serializer/DocumentNormalizerTest.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -class DocumentNormalizerTest extends TestCase +final class DocumentNormalizerTest extends TestCase { use ProphecyTrait; diff --git a/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php b/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php index 6a389a24acb..89d259b5932 100644 --- a/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php +++ b/tests/Bridge/Elasticsearch/Serializer/ItemNormalizerTest.php @@ -15,9 +15,9 @@ use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Core\Bridge\Elasticsearch\Serializer\ItemNormalizer; -use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -44,20 +44,12 @@ protected function setUp(): void ); } - /** @dataProvider decoratedNormalizerProvider */ - public function testConstruct(NormalizerInterface $normalizer, ?string $exception): void + public function testConstruct(): void { - if (null !== $exception) { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($exception); - } - - $itemNormalizer = new ItemNormalizer($normalizer); - - self::assertInstanceOf(NormalizerInterface::class, $itemNormalizer); - self::assertInstanceOf(DenormalizerInterface::class, $itemNormalizer); - self::assertInstanceOf(SerializerAwareInterface::class, $itemNormalizer); - self::assertInstanceOf(CacheableSupportsMethodInterface::class, $itemNormalizer); + self::assertInstanceOf(NormalizerInterface::class, $this->itemNormalizer); + self::assertInstanceOf(DenormalizerInterface::class, $this->itemNormalizer); + self::assertInstanceOf(SerializerAwareInterface::class, $this->itemNormalizer); + self::assertInstanceOf(CacheableSupportsMethodInterface::class, $this->itemNormalizer); } public function testHasCacheableSupportsMethod(): void @@ -106,38 +98,35 @@ public function testSetSerializer(): void $this->itemNormalizer->setSerializer($serializer); } - public function decoratedNormalizerProvider(): iterable + public function testHasCacheableSupportsMethodWithDecoratedNormalizerNotAnInstanceOfCacheableSupportsMethodInterface(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The decorated normalizer must be an instance of "%s".', CacheableSupportsMethodInterface::class)); + + (new ItemNormalizer($this->prophesize(NormalizerInterface::class)->reveal()))->hasCacheableSupportsMethod(); + } + + public function testDenormalizeWithDecoratedNormalizerNotAnInstanceOfDenormalizerInterface(): void { - yield [ - $this->prophesize(NormalizerInterface::class)->reveal(), - sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class), - ]; - - yield [ - $this - ->prophesize(NormalizerInterface::class) - ->willImplement(DenormalizerInterface::class) - ->reveal(), - sprintf('The decorated normalizer must be an instance of "%s".', SerializerAwareInterface::class), - ]; - - yield [ - $this - ->prophesize(NormalizerInterface::class) - ->willImplement(DenormalizerInterface::class) - ->willImplement(SerializerAwareInterface::class) - ->reveal(), - sprintf('The decorated normalizer must be an instance of "%s".', CacheableSupportsMethodInterface::class), - ]; - - yield [ - $this - ->prophesize(NormalizerInterface::class) - ->willImplement(DenormalizerInterface::class) - ->willImplement(SerializerAwareInterface::class) - ->willImplement(CacheableSupportsMethodInterface::class) - ->reveal(), - null, - ]; + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); + + (new ItemNormalizer($this->prophesize(NormalizerInterface::class)->reveal()))->denormalize('foo', 'string'); + } + + public function testSupportsDenormalizationWithDecoratedNormalizerNotAnInstanceOfDenormalizerInterface(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The decorated normalizer must be an instance of "%s".', DenormalizerInterface::class)); + + (new ItemNormalizer($this->prophesize(NormalizerInterface::class)->reveal()))->supportsDenormalization('foo', 'string'); + } + + public function testSetSerializerWithDecoratedNormalizerNotAnInstanceOfSerializerAwareInterface(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The decorated normalizer must be an instance of "%s".', SerializerAwareInterface::class)); + + (new ItemNormalizer($this->prophesize(NormalizerInterface::class)->reveal()))->setSerializer($this->prophesize(SerializerInterface::class)->reveal()); } } diff --git a/tests/Fixtures/Elasticsearch/Fixtures/user.json b/tests/Fixtures/Elasticsearch/Fixtures/user.json index aa83cd99326..a13d72c1fe9 100644 --- a/tests/Fixtures/Elasticsearch/Fixtures/user.json +++ b/tests/Fixtures/Elasticsearch/Fixtures/user.json @@ -5,6 +5,7 @@ "age": 31, "firstName": "Kilian", "lastName": "Jornet", + "registeredAt": "2009-09-01", "tweets": [ { "id": "f36a0026-0635-4865-86a6-5adb21d94d64", @@ -29,6 +30,7 @@ "age": 32, "firstName": "Francois", "lastName": "D'Haene", + "registeredAt": "2014-08-01", "tweets": [ { "id": "5bc245d7-df50-4e2d-b26b-823e73372183", @@ -48,6 +50,7 @@ "age": 30, "firstName": "Xavier", "lastName": "Thevenard", + "registeredAt": "2013-09-02", "tweets": [ { "id": "86c41446-31d6-48a4-96e7-73e84ea283d3", @@ -62,6 +65,7 @@ "age": 35, "firstName": "Anton", "lastName": "Krupicka", + "registeredAt": "2014-07-01", "tweets": [ { "id": "ce73f15a-8c96-46fe-8999-392460feb61b", @@ -81,6 +85,7 @@ "age": 28, "firstName": "Jim", "lastName": "Walmsley", + "registeredAt": "2011-07-01", "tweets": [ { "id": "0cfe3d33-6116-416b-8c50-3b8319331998", @@ -95,6 +100,7 @@ "age": 30, "firstName": "Zach", "lastName": "Miller", + "registeredAt": "2017-03-01", "tweets": [ { "id": "1c9e0545-1b37-4a9a-83e0-30400d0b354e", @@ -119,6 +125,7 @@ "age": 42, "firstName": "Caroline", "lastName": "Chaverot", + "registeredAt": "2013-11-01", "tweets": [ { "id": "6d82a76c-8ba2-4e78-9ab3-6a456e4470c3", @@ -138,6 +145,7 @@ "age": 42, "firstName": "Nuria", "lastName": "Picas", + "registeredAt": "2012-12-01", "tweets": [ { "id": "dcaef1db-225d-442b-960e-5de6984a44be", @@ -157,6 +165,7 @@ "age": 32, "firstName": "Emelie", "lastName": "Forsberg", + "registeredAt": "2012-07-01", "tweets": [ { "id": "6947ce52-c85d-4786-a587-3966a936842b", @@ -176,6 +185,7 @@ "age": 37, "firstName": "Anna", "lastName": "Frost", + "registeredAt": "2010-07-01", "tweets": [ { "id": "9de3308c-6f82-4a57-a33c-4e3cd5d5a3f6", @@ -195,6 +205,7 @@ "age": 34, "firstName": "Rory", "lastName": "Bosio", + "registeredAt": "2013-09-05", "tweets": [] }, { @@ -203,6 +214,7 @@ "age": 29, "firstName": "Ruth", "lastName": "Croft", + "registeredAt": "2015-02-01", "tweets": [ { "id": "3a1d02fa-2347-41ff-80ef-ed9b9c0efea9", diff --git a/tests/Fixtures/Elasticsearch/Mappings/tweet.json b/tests/Fixtures/Elasticsearch/Mappings/tweet.json index 32ef0fb4a11..9a5ab8bfc04 100644 --- a/tests/Fixtures/Elasticsearch/Mappings/tweet.json +++ b/tests/Fixtures/Elasticsearch/Mappings/tweet.json @@ -1,37 +1,39 @@ { "mappings": { - "properties": { - "id": { - "type": "keyword" - }, - "author": { - "properties": { - "id": { - "type": "keyword" - }, - "gender": { - "type": "keyword" - }, - "age": { - "type": "integer" - }, - "firstName": { - "type": "text" + "_doc": { + "properties": { + "id": { + "type": "keyword" + }, + "author": { + "properties": { + "id": { + "type": "keyword" + }, + "gender": { + "type": "keyword" + }, + "age": { + "type": "integer" + }, + "firstName": { + "type": "text" + }, + "lastName": { + "type": "text" + } }, - "lastName": { - "type": "text" - } + "dynamic": "strict" }, - "dynamic": "strict" - }, - "date": { - "type": "date", - "format": "yyyy-MM-dd HH:mm:ss" + "date": { + "type": "date", + "format": "yyyy-MM-dd HH:mm:ss" + }, + "message": { + "type": "text" + } }, - "message": { - "type": "text" - } - }, - "dynamic": "strict" + "dynamic": "strict" + } } } diff --git a/tests/Fixtures/Elasticsearch/Mappings/user.json b/tests/Fixtures/Elasticsearch/Mappings/user.json index 8cad8f89a2a..0d83e99fa58 100644 --- a/tests/Fixtures/Elasticsearch/Mappings/user.json +++ b/tests/Fixtures/Elasticsearch/Mappings/user.json @@ -1,38 +1,44 @@ { "mappings": { - "properties": { - "id": { - "type": "keyword" - }, - "gender": { - "type": "keyword" - }, - "age": { - "type": "integer" - }, - "firstName": { - "type": "text" - }, - "lastName": { - "type": "text" - }, - "tweets": { - "type": "nested", - "properties": { - "id": { - "type": "keyword" - }, - "date": { - "type": "date", - "format": "yyyy-MM-dd HH:mm:ss" - }, - "message": { - "type": "text" - } + "_doc": { + "properties": { + "id": { + "type": "keyword" }, - "dynamic": "strict" - } - }, - "dynamic": "strict" + "gender": { + "type": "keyword" + }, + "age": { + "type": "integer" + }, + "firstName": { + "type": "text" + }, + "lastName": { + "type": "text" + }, + "registeredAt": { + "type": "date", + "format": "yyyy-MM-dd" + }, + "tweets": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "date": { + "type": "date", + "format": "yyyy-MM-dd HH:mm:ss" + }, + "message": { + "type": "text" + } + }, + "dynamic": "strict" + } + }, + "dynamic": "strict" + } } } diff --git a/tests/Fixtures/Elasticsearch/Model/User.php b/tests/Fixtures/Elasticsearch/Model/User.php index 44b1fd28112..027b33f1827 100644 --- a/tests/Fixtures/Elasticsearch/Model/User.php +++ b/tests/Fixtures/Elasticsearch/Model/User.php @@ -56,6 +56,11 @@ class User */ private $lastName; + /** + * @Groups({"user:read"}) + */ + private $registeredAt; + /** * @Groups({"user:read"}) */ @@ -111,6 +116,16 @@ public function setLastName(string $lastName): void $this->lastName = $lastName; } + public function getRegisteredAt(): ?\DateTimeInterface + { + return $this->registeredAt; + } + + public function setRegisteredAt(\DateTimeInterface $registeredAt): void + { + $this->registeredAt = $registeredAt; + } + public function getTweets(): array { return $this->tweets; From de5988dacc871a7c1565aa8dea8c30102984cff9 Mon Sep 17 00:00:00 2001 From: David Bennett Date: Thu, 6 May 2021 17:09:52 +0100 Subject: [PATCH 37/41] feat: add `security_post_denormalize` in `ApiProperty` (#4184) --- CHANGELOG.md | 2 + features/authorization/deny.feature | 30 ++ features/graphql/authorization.feature | 123 +++++++ src/Annotation/ApiProperty.php | 5 +- src/Serializer/AbstractItemNormalizer.php | 50 ++- tests/Annotation/ApiPropertyTest.php | 4 + .../TestBundle/Document/SecuredDummy.php | 21 ++ .../TestBundle/Entity/SecuredDummy.php | 21 ++ .../Serializer/AbstractItemNormalizerTest.php | 306 ++++++++++++++++++ tests/Serializer/ItemNormalizerTest.php | 1 + 10 files changed, 560 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81907cea9b..ef9fddde27b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.7.0 +* Security: **BC** Fix `ApiProperty` `security` attribute expression being passed a class string for the `object` variable on updates/creates - null is now passed instead if the object is not available (#4184) +* Security: `ApiProperty` now supports a `security_post_denormalize` attribute, which provides access to the `object` variable for the object being updated/created and `previous_object` for the object before it was updated (#4184) * JSON Schema: Add support for generating property schema with numeric constraint restrictions (#4225) * JSON Schema: Add support for generating property schema with Collection restriction (#4182) * JSON Schema: Add support for generating property schema format for Url and Hostname (#4185) diff --git a/features/authorization/deny.feature b/features/authorization/deny.feature index 54d63181f1f..d6c4e540a20 100644 --- a/features/authorization/deny.feature +++ b/features/authorization/deny.feature @@ -78,6 +78,22 @@ Feature: Authorization checking And I send a "GET" request to "/secured_dummies/2" Then the response status code should be 200 + Scenario: A user can see a secured owner-only property on an object they own + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies/2" + Then the response status code should be 200 + And the JSON node "ownerOnlyProperty" should exist + And the JSON node "ownerOnlyProperty" should not be null + + Scenario: An admin can't see a secured owner-only property on objects they don't own + When I add "Accept" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should not contain "ownerOnlyProperty" + Scenario: A user can't assign to themself an item they doesn't own When I add "Accept" header equal to "application/ld+json" And I add "Content-Type" header equal to "application/ld+json" @@ -151,3 +167,17 @@ Feature: Authorization checking Then the response status code should be 200 And the response should contain "adminOnlyProperty" And the JSON node "hydra:member[2].adminOnlyProperty" should be equal to the string "Is it safe?" + + Scenario: An user can update an owner-only secured property on an object they own + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "PUT" request to "/secured_dummies/3" with body: + """ + { + "ownerOnlyProperty": "updated" + } + """ + Then the response status code should be 200 + And the response should contain "ownerOnlyProperty" + And the JSON node "ownerOnlyProperty" should be equal to the string "updated" diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature index 3844387d884..a9c16a7131b 100644 --- a/features/graphql/authorization.feature +++ b/features/graphql/authorization.feature @@ -349,6 +349,69 @@ Feature: Authorization checking And the header "Content-Type" should be equal to "application/json" And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "dunglas" + Scenario: An admin can create a secured resource with an owner-only property if they will be the owner + When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + mutation { + createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { + securedDummy { + ownerOnlyProperty + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "it works" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + { + securedDummies { + edges { + node { + ownerOnlyProperty + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.securedDummies.edges[2].node.ownerOnlyProperty" should be equal to "it works" + + Scenario: An admin can't create a secured resource with an owner-only property if they won't be the owner + When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + mutation { + createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { + securedDummy { + ownerOnlyProperty + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should exist + And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be null + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/4") { + ownerOnlyProperty + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to the string "" + Scenario: A user cannot retrieve an item they doesn't own When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" And I send the following GraphQL request: @@ -419,6 +482,66 @@ Feature: Authorization checking And the header "Content-Type" should be equal to "application/json" And the JSON node "data.securedDummy.adminOnlyProperty" should be null + Scenario: A user can see a secured owner-only property on an object they own + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/2") { + ownerOnlyProperty + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.ownerOnlyProperty" should exist + And the JSON node "data.securedDummy.ownerOnlyProperty" should not be null + + Scenario: A user can update a secured owner-only property on an object they own + When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/2", ownerOnlyProperty: "updated"}) { + securedDummy { + ownerOnlyProperty + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.updateSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "updated" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/2") { + ownerOnlyProperty + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to the string "updated" + + Scenario: An admin can't see a secured owner-only property on an object they don't own + When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send the following GraphQL request: + """ + { + securedDummy(id: "/secured_dummies/2") { + ownerOnlyProperty + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.securedDummy.ownerOnlyProperty" should be null + Scenario: A user can't assign to themself an item they doesn't own When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" And I send the following GraphQL request: diff --git a/src/Annotation/ApiProperty.php b/src/Annotation/ApiProperty.php index 3140eae218f..a61629624f4 100644 --- a/src/Annotation/ApiProperty.php +++ b/src/Annotation/ApiProperty.php @@ -30,6 +30,7 @@ * @Attribute("jsonldContext", type="array"), * @Attribute("push", type="bool"), * @Attribute("security", type="string"), + * @Attribute("securityPostDenormalize", type="string"), * @Attribute("swaggerContext", type="array") * ) */ @@ -112,6 +113,7 @@ final class ApiProperty * @param bool $push * @param string $security * @param array $swaggerContext + * @param string $securityPostDenormalize * * @throws InvalidArgumentException */ @@ -136,7 +138,8 @@ public function __construct( ?array $openapiContext = null, ?bool $push = null, ?string $security = null, - ?array $swaggerContext = null + ?array $swaggerContext = null, + ?string $securityPostDenormalize = null ) { if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 8ab7b0e116a..be441f9ca53 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -254,7 +254,22 @@ public function denormalize($data, $class, $format = null, array $context = []) return $item; } - return parent::denormalize($data, $resourceClass, $format, $context); + $previousObject = null !== $objectToPopulate ? clone $objectToPopulate : null; + $object = parent::denormalize($data, $resourceClass, $format, $context); + + // Revert attributes that aren't allowed to be changed after a post-denormalize check + foreach (array_keys($data) as $attribute) { + if (!$this->canAccessAttributePostDenormalize($object, $previousObject, $attribute, $context)) { + if (null !== $previousObject) { + $this->setValue($object, $attribute, $this->propertyAccessor->getValue($previousObject, $attribute)); + } else { + $propertyMetaData = $this->propertyMetadataFactory->create($resourceClass, $attribute, $this->getFactoryOptions($context)); + $this->setValue($object, $attribute, $propertyMetaData->getDefault()); + } + } + } + + return $object; } /** @@ -390,12 +405,43 @@ protected function isAllowedAttribute($classOrObject, $attribute, $format = null return false; } + return $this->canAccessAttribute(\is_object($classOrObject) ? $classOrObject : null, $attribute, $context); + } + + /** + * Check if access to the attribute is granted. + * + * @param object $object + */ + protected function canAccessAttribute($object, string $attribute, array $context = []): bool + { $options = $this->getFactoryOptions($context); $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); $security = $propertyMetadata->getAttribute('security'); if ($this->resourceAccessChecker && $security) { return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [ - 'object' => $classOrObject, + 'object' => $object, + ]); + } + + return true; + } + + /** + * Check if access to the attribute is granted. + * + * @param object $object + * @param object|null $previousObject + */ + protected function canAccessAttributePostDenormalize($object, $previousObject, string $attribute, array $context = []): bool + { + $options = $this->getFactoryOptions($context); + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); + $security = $propertyMetadata->getAttribute('security_post_denormalize'); + if ($this->resourceAccessChecker && $security) { + return $this->resourceAccessChecker->isGranted($context['resource_class'], $security, [ + 'object' => $object, + 'previous_object' => $previousObject, ]); } diff --git a/tests/Annotation/ApiPropertyTest.php b/tests/Annotation/ApiPropertyTest.php index f4c1ec790c0..9946e49ed73 100644 --- a/tests/Annotation/ApiPropertyTest.php +++ b/tests/Annotation/ApiPropertyTest.php @@ -53,6 +53,7 @@ public function testConstruct() 'fetchEager' => false, 'jsonldContext' => ['foo' => 'bar'], 'security' => 'is_granted(\'ROLE_ADMIN\')', + 'securityPostDenormalize' => 'is_granted(\'VIEW\', object)', 'swaggerContext' => ['foo' => 'baz'], 'openapiContext' => ['foo' => 'baz'], 'push' => true, @@ -64,6 +65,7 @@ public function testConstruct() 'fetch_eager' => false, 'jsonld_context' => ['foo' => 'bar'], 'security' => 'is_granted(\'ROLE_ADMIN\')', + 'security_post_denormalize' => 'is_granted(\'VIEW\', object)', 'swagger_context' => ['foo' => 'baz'], 'openapi_context' => ['foo' => 'baz'], 'push' => true, @@ -83,6 +85,7 @@ public function testConstructAttribute() fetchEager: false, jsonldContext: ['foo' => 'bar'], security: 'is_granted(\'ROLE_ADMIN\')', + securityPostDenormalize: 'is_granted(\'VIEW\', object)', swaggerContext: ['foo' => 'baz'], openapiContext: ['foo' => 'baz'], push: true, @@ -97,6 +100,7 @@ public function testConstructAttribute() 'fetch_eager' => false, 'jsonld_context' => ['foo' => 'bar'], 'security' => 'is_granted(\'ROLE_ADMIN\')', + 'security_post_denormalize' => 'is_granted(\'VIEW\', object)', 'swagger_context' => ['foo' => 'baz'], 'openapi_context' => ['foo' => 'baz'], 'push' => true, diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummy.php b/tests/Fixtures/TestBundle/Document/SecuredDummy.php index 277b1220066..1356529c0ec 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummy.php @@ -83,6 +83,17 @@ class SecuredDummy */ private $adminOnlyProperty = ''; + /** + * @var string Secret property, only readable/writable by owners + * + * @ODM\Field + * @ApiProperty( + * security="object == null or object.getOwner() == user", + * securityPostDenormalize="object.getOwner() == user", + * ) + */ + private $ownerOnlyProperty = ''; + /** * @var string The owner * @@ -187,6 +198,16 @@ public function setAdminOnlyProperty(?string $adminOnlyProperty) $this->adminOnlyProperty = $adminOnlyProperty; } + public function getOwnerOnlyProperty(): ?string + { + return $this->ownerOnlyProperty; + } + + public function setOwnerOnlyProperty(?string $ownerOnlyProperty) + { + $this->ownerOnlyProperty = $ownerOnlyProperty; + } + public function getOwner(): string { return $this->owner; diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php index 3a35ccf6ada..3af569999dc 100644 --- a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php @@ -84,6 +84,17 @@ class SecuredDummy */ private $adminOnlyProperty = ''; + /** + * @var string Secret property, only readable/writable by owners + * + * @ORM\Column + * @ApiProperty( + * security="object == null or object.getOwner() == user", + * securityPostDenormalize="object.getOwner() == user", + * ) + */ + private $ownerOnlyProperty = ''; + /** * @var string The owner * @@ -198,6 +209,16 @@ public function setAdminOnlyProperty(?string $adminOnlyProperty) $this->adminOnlyProperty = $adminOnlyProperty; } + public function getOwnerOnlyProperty(): ?string + { + return $this->ownerOnlyProperty; + } + + public function setOwnerOnlyProperty(?string $ownerOnlyProperty) + { + $this->ownerOnlyProperty = $ownerOnlyProperty; + } + public function getOwner(): string { return $this->owner; diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 09592c80316..9d259dd4808 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -266,6 +266,310 @@ public function testNormalizeWithSecuredProperty() ])); } + public function testDenormalizeWithSecuredProperty() + { + $data = [ + 'title' => 'foo', + 'adminOnlyProperty' => 'secret', + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', false, true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, true, null, null, null, null, null, null, ['security' => 'is_granted(\'ROLE_ADMIN\')'])); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'is_granted(\'ROLE_ADMIN\')', + ['object' => null] + )->willReturn(false); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $actual = $normalizer->denormalize($data, SecuredDummy::class); + + $this->assertInstanceOf(SecuredDummy::class, $actual); + + $propertyAccessorProphecy->setValue($actual, 'title', 'foo')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'adminOnlyProperty', 'secret')->shouldNotHaveBeenCalled(); + } + + public function testDenormalizeCreateWithDeniedPostDenormalizeSecuredProperty() + { + $data = [ + 'title' => 'foo', + 'ownerOnlyProperty' => 'should not be set', + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'ownerOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', false, true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, true, null, null, null, null, null, null, ['security_post_denormalize' => 'false'], null, null, '')); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'false', + Argument::any() + )->willReturn(false); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $actual = $normalizer->denormalize($data, SecuredDummy::class); + + $this->assertInstanceOf(SecuredDummy::class, $actual); + + $propertyAccessorProphecy->setValue($actual, 'title', 'foo')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'ownerOnlyProperty', 'should not be set')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'ownerOnlyProperty', '')->shouldHaveBeenCalled(); + } + + public function testDenormalizeUpdateWithSecuredProperty() + { + $dummy = new SecuredDummy(); + + $data = [ + 'title' => 'foo', + 'ownerOnlyProperty' => 'secret', + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'ownerOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', false, true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, true, null, null, null, null, null, null, ['security' => 'true'])); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'true', + ['object' => null] + )->willReturn(true); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'true', + ['object' => $dummy] + )->willReturn(true); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $context = [AbstractItemNormalizer::OBJECT_TO_POPULATE => $dummy]; + $actual = $normalizer->denormalize($data, SecuredDummy::class, null, $context); + + $this->assertInstanceOf(SecuredDummy::class, $actual); + + $propertyAccessorProphecy->setValue($actual, 'title', 'foo')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'ownerOnlyProperty', 'secret')->shouldHaveBeenCalled(); + } + + public function testDenormalizeUpdateWithDeniedSecuredProperty() + { + $dummy = new SecuredDummy(); + $dummy->setOwnerOnlyProperty('secret'); + + $data = [ + 'title' => 'foo', + 'ownerOnlyProperty' => 'should not be set', + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'ownerOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', false, true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, true, null, null, null, null, null, null, ['security' => 'false'])); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'false', + ['object' => null] + )->willReturn(false); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'false', + ['object' => $dummy] + )->willReturn(false); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $context = [AbstractItemNormalizer::OBJECT_TO_POPULATE => $dummy]; + $actual = $normalizer->denormalize($data, SecuredDummy::class, null, $context); + + $this->assertInstanceOf(SecuredDummy::class, $actual); + + $propertyAccessorProphecy->setValue($actual, 'title', 'foo')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'ownerOnlyProperty', 'should not be set')->shouldNotHaveBeenCalled(); + } + + public function testDenormalizeUpdateWithDeniedPostDenormalizeSecuredProperty() + { + $dummy = new SecuredDummy(); + $dummy->setOwnerOnlyProperty('secret'); + + $data = [ + 'title' => 'foo', + 'ownerOnlyProperty' => 'should not be set', + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'ownerOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', false, true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, true, null, null, null, null, null, null, ['security_post_denormalize' => 'false'])); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'ownerOnlyProperty')->willReturn('secret'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, SecuredDummy::class)->willReturn(SecuredDummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted( + SecuredDummy::class, + 'false', + ['object' => $dummy, 'previous_object' => $dummy] + )->willReturn(false); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $context = [AbstractItemNormalizer::OBJECT_TO_POPULATE => $dummy]; + $actual = $normalizer->denormalize($data, SecuredDummy::class, null, $context); + + $this->assertInstanceOf(SecuredDummy::class, $actual); + + $propertyAccessorProphecy->setValue($actual, 'title', 'foo')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'ownerOnlyProperty', 'should not be set')->shouldHaveBeenCalled(); + $propertyAccessorProphecy->setValue($actual, 'ownerOnlyProperty', 'secret')->shouldHaveBeenCalled(); + } + public function testNormalizeReadableLinks() { $relatedDummy = new RelatedDummy(); @@ -787,6 +1091,8 @@ public function testDenormalizeBadKeyType() $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['relatedDummies'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', false, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class), '', false, true, false, false)); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', [])->willReturn( new PropertyMetadata( new Type( diff --git a/tests/Serializer/ItemNormalizerTest.php b/tests/Serializer/ItemNormalizerTest.php index 7a097aae786..9a63baea547 100644 --- a/tests/Serializer/ItemNormalizerTest.php +++ b/tests/Serializer/ItemNormalizerTest.php @@ -164,6 +164,7 @@ public function testDenormalizeWithIri() $propertyMetadata = new PropertyMetadata(null, null, true, true); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', [])->willReturn($propertyMetadata)->shouldBeCalled(); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadata)->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); From ad7353f1c004bb70de5ee66e14cb52caa8b71f63 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 17 May 2021 21:48:21 +0200 Subject: [PATCH 38/41] chore: fix phpstan issues --- .../DataProvider/Extension/AbstractFilterExtension.php | 2 +- src/GraphQl/Action/EntrypointAction.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bridge/Elasticsearch/DataProvider/Extension/AbstractFilterExtension.php b/src/Bridge/Elasticsearch/DataProvider/Extension/AbstractFilterExtension.php index 58e04664880..fe6ec92dc46 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Extension/AbstractFilterExtension.php +++ b/src/Bridge/Elasticsearch/DataProvider/Extension/AbstractFilterExtension.php @@ -51,7 +51,7 @@ public function applyToCollection(array $requestBody, string $resourceClass, ?st foreach ($resourceFilters as $filterId) { if ($this->filterLocator->has($filterId) && is_a($filter = $this->filterLocator->get($filterId), $this->getFilterInterface(), true)) { - $clauseBody = $filter->apply($clauseBody, $resourceClass, $operationName, $context); + $clauseBody = $filter->apply($clauseBody, $resourceClass, $operationName, $context); // @phpstan-ignore-line } } diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index 926236a8735..b14ab4d52e5 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -214,10 +214,10 @@ private function applyMapToVariables(array $map, array $variables, array $files) */ private function decodeVariables(string $variables): array { - if (!\is_array($variables = json_decode($variables, true))) { + if (!\is_array($decoded = json_decode($variables, true))) { throw new BadRequestHttpException('GraphQL variables are not valid JSON.'); } - return $variables; + return $decoded; } } From cd198d20b9bcb39ad9f5b0771368afac089e48d1 Mon Sep 17 00:00:00 2001 From: Vincent Date: Fri, 21 May 2021 10:21:01 +0200 Subject: [PATCH 39/41] fix(ApiTestCase): consitency with WebTestCase replace example.com by localhost #4242 (#4285) --- src/Bridge/Symfony/Bundle/Test/Client.php | 2 +- tests/Bridge/Symfony/Bundle/Test/ApiTestCaseTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 68071863333..a3a22899951 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -44,7 +44,7 @@ final class Client implements HttpClientInterface 'headers' => ['accept' => ['application/ld+json']], 'body' => '', 'json' => null, - 'base_uri' => 'http://example.com', + 'base_uri' => 'http://localhost', 'extra' => [], ]; diff --git a/tests/Bridge/Symfony/Bundle/Test/ApiTestCaseTest.php b/tests/Bridge/Symfony/Bundle/Test/ApiTestCaseTest.php index 12fa9c00296..6cf8bc4331d 100644 --- a/tests/Bridge/Symfony/Bundle/Test/ApiTestCaseTest.php +++ b/tests/Bridge/Symfony/Bundle/Test/ApiTestCaseTest.php @@ -71,7 +71,7 @@ public function testAssertJsonEquals(): void self::createClient()->request('GET', '/contexts/Address'); $this->assertJsonEquals([ '@context' => [ - '@vocab' => 'http://example.com/docs.jsonld#', + '@vocab' => 'http://localhost/docs.jsonld#', 'hydra' => 'http://www.w3.org/ns/hydra/core#', 'name' => 'Address/name', ], @@ -84,7 +84,7 @@ public function testAssertJsonEqualsWithJsonObjectString(): void $this->assertJsonEquals(<< Date: Sat, 22 May 2021 20:04:03 +0200 Subject: [PATCH 40/41] Fixes filter when filtering with an int Fixes the notice: app.NOTICE: Invalid filter ignored {"exception":"[object] (ApiPlatform\\Core\\Exception\\InvalidArgumentException(code: 0): At least one value is required, multiple values should be in \"status[]=firstvalue&status[]=secondvalue\" format at /vendor/api-platform/core/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php:146)"} [] Should fix #4196 and also #2190 --- src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php b/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php index 327ef594da8..ebcd963ae90 100644 --- a/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php +++ b/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php @@ -136,7 +136,7 @@ protected function getIdFromValue(string $value) protected function normalizeValues(array $values, string $property): ?array { foreach ($values as $key => $value) { - if (!\is_int($key) || !\is_string($value)) { + if (!\is_int($key)) { unset($values[$key]); } } From dd109146e29b134431b90be28d62f3f02de6d4ef Mon Sep 17 00:00:00 2001 From: Julio Montoya Date: Sun, 23 May 2021 09:13:40 +0200 Subject: [PATCH 41/41] Skip arrays --- src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php b/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php index ebcd963ae90..65711227ac9 100644 --- a/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php +++ b/src/Bridge/Doctrine/Common/Filter/SearchFilterTrait.php @@ -136,7 +136,7 @@ protected function getIdFromValue(string $value) protected function normalizeValues(array $values, string $property): ?array { foreach ($values as $key => $value) { - if (!\is_int($key)) { + if (!\is_int($key) || is_array($value)) { unset($values[$key]); } }