From 6311b3db671676fc1ade73db2b5dc1022878314d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 25 Dec 2017 22:21:24 +0100 Subject: [PATCH 1/5] Add validation for GraphQL mutations --- features/graphql/mutation.feature | 16 ++++- .../Bundle/Resources/config/graphql.xml | 3 +- .../Bundle/Resources/config/validator.xml | 11 ++- .../EventListener/ValidateListener.php | 3 + .../Exception/ValidationException.php | 26 +++++-- src/Bridge/Symfony/Validator/Validator.php | 66 ++++++++++++++++++ .../Factory/ItemMutationResolverFactory.php | 28 +++++++- ...tractConstraintViolationListNormalizer.php | 5 +- .../EventListener/ValidateListener.php | 67 +++++++++++++++++++ .../Exception/ValidationException.php | 25 +++++++ src/Validator/ValidatorInterface.php | 34 ++++++++++ .../ApiPlatformExtensionTest.php | 5 ++ .../EventListener/ValidateListenerTest.php | 4 ++ 13 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 src/Bridge/Symfony/Validator/Validator.php create mode 100644 src/Validator/EventListener/ValidateListener.php create mode 100644 src/Validator/Exception/ValidationException.php create mode 100644 src/Validator/ValidatorInterface.php diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index c4c0e66e766..162d9285365 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -114,7 +114,6 @@ Feature: GraphQL mutation support And the JSON node "data.updateFoo.clientMutationId" should be equal to "myId" @createSchema - @dropSchema Scenario: Modify an item with composite identifiers through a mutation Given there are Composite identifier objects When I send the following GraphQL request: @@ -133,3 +132,18 @@ Feature: GraphQL mutation support And the JSON node "data.updateCompositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=2" And the JSON node "data.updateCompositeRelation.value" should be equal to "Modified value." And the JSON node "data.updateCompositeRelation.clientMutationId" should be equal to "myId" + + @dropSchema + Scenario: Trigger a validation error + When I send the following GraphQL request: + """ + mutation { + createDummy(input: {_id: 12, name: "", clientMutationId: "myId"}) { + clientMutationId + } + } + """ + 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/json" + And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index e14b9709f05..3447a31acef 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -25,7 +25,8 @@ - + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index c45ff9c4b5e..8fcace8dc56 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -5,15 +5,20 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + + + + + - - + + - diff --git a/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php b/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php index dcd453c706b..5d699c62ec6 100644 --- a/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php +++ b/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; +use ApiPlatform\Core\Validator\EventListener\ValidateListener as MainValidateListener; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; @@ -34,6 +35,8 @@ final class ValidateListener public function __construct(ValidatorInterface $validator, ResourceMetadataFactoryInterface $resourceMetadataFactory, ContainerInterface $container = null) { + @trigger_error(sprintf('Using "%s" is deprecated since API Platform 2.2 and will not be possible anymore in API Platform 3. Use "%s" instead.', __CLASS__, MainValidateListener::class), E_USER_DEPRECATED); + $this->validator = $validator; $this->resourceMetadataFactory = $resourceMetadataFactory; $this->container = $container; diff --git a/src/Bridge/Symfony/Validator/Exception/ValidationException.php b/src/Bridge/Symfony/Validator/Exception/ValidationException.php index c0819e16f7a..8d7d4ee0b4b 100644 --- a/src/Bridge/Symfony/Validator/Exception/ValidationException.php +++ b/src/Bridge/Symfony/Validator/Exception/ValidationException.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Validator\Exception; +use ApiPlatform\Core\Validator\Exception\ValidationException as BaseValidationException; use Symfony\Component\Validator\ConstraintViolationListInterface; /** @@ -20,15 +21,15 @@ * * @author Kévin Dunglas */ -final class ValidationException extends \RuntimeException +final class ValidationException extends BaseValidationException { private $constraintViolationList; - public function __construct(ConstraintViolationListInterface $constraintViolationList, $message = '', $code = 0, \Exception $previous = null) + public function __construct(ConstraintViolationListInterface $constraintViolationList, string $message = '', int $code = 0, \Exception $previous = null) { - parent::__construct($message, $code, $previous); - $this->constraintViolationList = $constraintViolationList; + + parent::__construct($message ?: $this->__toString(), $code, $previous); } /** @@ -40,4 +41,21 @@ public function getConstraintViolationList() { return $this->constraintViolationList; } + + public function __toString(): string + { + $message = ''; + foreach ($this->constraintViolationList as $violation) { + if ('' !== $message) { + $message .= "\n"; + } + if ($propertyPath = $violation->getPropertyPath()) { + $message .= "$propertyPath: "; + } + + $message .= $violation->getMessage(); + } + + return $message; + } } diff --git a/src/Bridge/Symfony/Validator/Validator.php b/src/Bridge/Symfony/Validator/Validator.php new file mode 100644 index 00000000000..8df2b439aa4 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Validator.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\Bridge\Symfony\Validator; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\Validator\ValidatorInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface as SymfonyValidatorInterface; + +/** + * Validates an item using the Symfony validator component. + * + * @author Kévin Dunglas + */ +class Validator implements ValidatorInterface +{ + private $validator; + private $container; + + public function __construct(SymfonyValidatorInterface $validator, ContainerInterface $container = null) + { + $this->validator = $validator; + $this->container = $container; + } + + /** + * Validates an item. + * + * @param object $data + * @param array $context + */ + public function validate($data, array $context = []) + { + if (null !== $validationGroups = $context['groups'] ?? null) { + if ( + $this->container && + \is_string($validationGroups) && + $this->container->has($validationGroups) && + ($service = $this->container->get($validationGroups)) && + \is_callable($service) + ) { + $validationGroups = $service($data); + } elseif (\is_callable($validationGroups)) { + $validationGroups = $validationGroups($data); + } + + $validationGroups = (array) $validationGroups; + } + + $violations = $this->validator->validate($data, null, $validationGroups); + if (0 !== \count($violations)) { + throw new ValidationException($violations); + } + } +} diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index b5ee66c9293..982f5e6d940 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -20,7 +20,10 @@ use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; +use ApiPlatform\Core\Validator\Exception\ValidationException; +use ApiPlatform\Core\Validator\ValidatorInterface; use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -42,8 +45,9 @@ final class ItemMutationResolverFactory implements ResolverFactoryInterface private $normalizer; private $resourceMetadataFactory; private $resourceAccessChecker; + private $validator; - public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null) + public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, ValidatorInterface $validator = null) { if (!$normalizer instanceof DenormalizerInterface) { throw new InvalidArgumentException(sprintf('The normalizer must implements the "%s" interface', DenormalizerInterface::class)); @@ -54,6 +58,7 @@ public function __construct(IriConverterInterface $iriConverter, DataPersisterIn $this->normalizer = $normalizer; $this->resourceMetadataFactory = $resourceMetadataFactory; $this->resourceAccessChecker = $resourceAccessChecker; + $this->validator = $validator; } public function __invoke(string $resourceClass = null, string $rootClass = null, string $operationName = null): callable @@ -78,6 +83,7 @@ public function __invoke(string $resourceClass = null, string $rootClass = null, case 'update': $context = null === $item ? ['resource_class' => $resourceClass] : ['resource_class' => $resourceClass, 'object_to_populate' => $item]; $item = $this->normalizer->denormalize($args['input'], $resourceClass, ItemNormalizer::FORMAT, $context); + $this->validate($item, $info, $resourceMetadata, $operationName); $this->dataPersister->persist($item); return $this->normalizer->normalize( @@ -98,4 +104,24 @@ public function __invoke(string $resourceClass = null, string $rootClass = null, return $data; }; } + + /** + * @param object $item + * + * @throws Error + */ + private function validate($item, ResolveInfo $info, ResourceMetadata $resourceMetadata, string $operationName = null) + { + if (null === $this->validator) { + return; + } + + $validationGroups = $resourceMetadata->getGraphqlAttribute($operationName, 'validation_groups', null, true); + + try { + $this->validator->validate($item, ['groups' => $validationGroups]); + } catch (ValidationException $e) { + throw Error::createLocatedError($e->getMessage(), $info->fieldNodes, $info->path); + } + } } diff --git a/src/Serializer/AbstractConstraintViolationListNormalizer.php b/src/Serializer/AbstractConstraintViolationListNormalizer.php index 6253e78f6bd..9f8549366d7 100644 --- a/src/Serializer/AbstractConstraintViolationListNormalizer.php +++ b/src/Serializer/AbstractConstraintViolationListNormalizer.php @@ -57,10 +57,7 @@ protected function getMessagesAndViolations(ConstraintViolationListInterface $co } $violations[] = $violationData; - - $propertyPath = $violation->getPropertyPath(); - $prefix = $propertyPath ? sprintf('%s: ', $propertyPath) : ''; - $messages[] = "$prefix{$violation->getMessage()}"; + $messages[] = ($violationData['propertyPath'] ? "{$violationData['propertyPath']}: " : '').$violationData['message']; } return [$messages, $violations]; diff --git a/src/Validator/EventListener/ValidateListener.php b/src/Validator/EventListener/ValidateListener.php new file mode 100644 index 00000000000..a1d7accc46c --- /dev/null +++ b/src/Validator/EventListener/ValidateListener.php @@ -0,0 +1,67 @@ + + * + * 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\Validator\EventListener; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; +use ApiPlatform\Core\Validator\ValidatorInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; + +/** + * Validates data. + * + * @author Kévin Dunglas + */ +final class ValidateListener +{ + private $validator; + private $resourceMetadataFactory; + + public function __construct(ValidatorInterface $validator, ResourceMetadataFactoryInterface $resourceMetadataFactory) + { + $this->validator = $validator; + $this->resourceMetadataFactory = $resourceMetadataFactory; + } + + /** + * Validates data returned by the controller if applicable. + * + * @throws ValidationException + */ + public function onKernelView(GetResponseForControllerResultEvent $event) + { + $request = $event->getRequest(); + if ( + $request->isMethodSafe(false) + || $request->isMethod(Request::METHOD_DELETE) + || !($attributes = RequestAttributesExtractor::extractAttributes($request)) + || !$attributes['receive'] + ) { + return; + } + + $data = $event->getControllerResult(); + $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); + + if (isset($attributes['collection_operation_name'])) { + $validationGroups = $resourceMetadata->getCollectionOperationAttribute($attributes['collection_operation_name'], 'validation_groups', null, true); + } else { + $validationGroups = $resourceMetadata->getItemOperationAttribute($attributes['item_operation_name'], 'validation_groups', null, true); + } + + $this->validator->validate($data, ['groups' => $validationGroups]); + } +} diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php new file mode 100644 index 00000000000..13c4777eaf4 --- /dev/null +++ b/src/Validator/Exception/ValidationException.php @@ -0,0 +1,25 @@ + + * + * 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\Validator\Exception; + +use ApiPlatform\Core\Exception\RuntimeException; + +/** + * Thrown when a validation error occurs. + * + * @author Kévin Dunglas + */ +class ValidationException extends RuntimeException +{ +} diff --git a/src/Validator/ValidatorInterface.php b/src/Validator/ValidatorInterface.php new file mode 100644 index 00000000000..dda66f46942 --- /dev/null +++ b/src/Validator/ValidatorInterface.php @@ -0,0 +1,34 @@ + + * + * 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\Validator; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; + +/** + * Validates an item using the Symfony validator component. + * + * @author Kévin Dunglas + */ +interface ValidatorInterface +{ + /** + * Validates an item. + * + * @param object $data + * @param array $contexy + * + * @throws ValidationException + */ + public function validate($data, array $context = []); +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 84f1db10117..72b061a0b37 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -35,6 +35,7 @@ use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Tests\Fixtures\TestBundle\TestBundle; +use ApiPlatform\Core\Validator\ValidatorInterface; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use FOS\UserBundle\FOSUserBundle; use Nelmio\ApiDocBundle\NelmioApiDocBundle; @@ -480,6 +481,7 @@ private function getPartialContainerBuilderProphecy($test = false) 'api_platform.subresource_operation_factory', 'api_platform.subresource_operation_factory.cached', 'api_platform.serializer_locator', + 'api_platform.validator', ]; foreach ($definitions as $definition) { @@ -514,6 +516,7 @@ private function getPartialContainerBuilderProphecy($test = false) ResourceMetadataFactoryInterface::class => 'api_platform.metadata.resource.metadata_factory', PropertyNameCollectionFactoryInterface::class => 'api_platform.metadata.property.name_collection_factory', PropertyMetadataFactoryInterface::class => 'api_platform.metadata.property.metadata_factory', + ValidatorInterface::class => 'api_platform.validator', ]; foreach ($aliases as $alias => $service) { @@ -640,6 +643,7 @@ private function getBaseContainerBuilderProphecy() 'api_platform.http_cache.purger.varnish', 'api_platform.http_cache.purger.varnish_client', 'api_platform.http_cache.listener.response.add_tags', + 'api_platform.validator', ]; foreach ($definitions as $definition) { @@ -653,6 +657,7 @@ private function getBaseContainerBuilderProphecy() FilterEagerLoadingExtension::class => 'api_platform.doctrine.orm.query_extension.filter_eager_loading', PaginationExtension::class => 'api_platform.doctrine.orm.query_extension.pagination', OrderExtension::class => 'api_platform.doctrine.orm.query_extension.order', + ValidatorInterface::class => 'api_platform.validator', ]; foreach ($aliases as $alias => $service) { diff --git a/tests/Bridge/Symfony/Validator/EventListener/ValidateListenerTest.php b/tests/Bridge/Symfony/Validator/EventListener/ValidateListenerTest.php index 19d46ff347d..e94f3e28c95 100644 --- a/tests/Bridge/Symfony/Validator/EventListener/ValidateListenerTest.php +++ b/tests/Bridge/Symfony/Validator/EventListener/ValidateListenerTest.php @@ -28,6 +28,8 @@ /** * @author Samuel ROZE + * + * @group legacy */ class ValidateListenerTest extends TestCase { @@ -157,6 +159,8 @@ public function testThrowsValidationExceptionWithViolationsFound() $expectedValidationGroups = ['a', 'b', 'c']; $violationsProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $violationsProphecy->rewind()->shouldBeCalled(); + $violationsProphecy->valid()->shouldBeCalled(); $violationsProphecy->count()->willReturn(1)->shouldBeCalled(); $violations = $violationsProphecy->reveal(); From 5d2f5e039d1649935f09d8d15766e79b3ca50dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 26 Dec 2017 00:14:53 +0100 Subject: [PATCH 2/5] Add unit tests --- .../Exception/ValidationExceptionTest.php | 52 +++++++ .../Symfony/Validator/ValidatorTest.php | 116 ++++++++++++++++ .../EventListener/ValidateListenerTest.php | 129 ++++++++++++++++++ 3 files changed, 297 insertions(+) create mode 100644 tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php create mode 100644 tests/Bridge/Symfony/Validator/ValidatorTest.php create mode 100644 tests/Validator/EventListener/ValidateListenerTest.php diff --git a/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php b/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php new file mode 100644 index 00000000000..eb61142219d --- /dev/null +++ b/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.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); +/* + * This file is part of the API Platform project. + * + * (c) Kévin Dunglas + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Exception; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Validator\Exception\ValidationException as MainValidationException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * @author Kévin Dunglas + */ +class ValidationExceptionTest extends TestCase +{ + public function testToString() + { + $e = new ValidationException(new ConstraintViolationList([ + new ConstraintViolation('message 1', null, [], '', '', 'invalid'), + new ConstraintViolation('message 2', null, [], '', 'foo', 'invalid'), + ])); + $this->assertInstanceOf(MainValidationException::class, $e); + $this->assertInstanceOf(RuntimeException::class, $e); + $this->assertInstanceOf(\RuntimeException::class, $e); + + $this->assertEquals(<<__toString()); + } +} diff --git a/tests/Bridge/Symfony/Validator/ValidatorTest.php b/tests/Bridge/Symfony/Validator/ValidatorTest.php new file mode 100644 index 00000000000..6c034a4a33c --- /dev/null +++ b/tests/Bridge/Symfony/Validator/ValidatorTest.php @@ -0,0 +1,116 @@ + + * + * 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; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Validator; +use ApiPlatform\Core\Tests\Fixtures\DummyEntity; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\Validator\ConstraintViolationListInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface as SymfonyValidatorInterface; + +/** + * @author Kévin Dunglas + */ +class ValidatorTest extends TestCase +{ + public function testValid() + { + $data = new DummyEntity(); + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, null)->willReturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); + $symfonyValidator = $symfonyValidatorProphecy->reveal(); + + $validator = new Validator($symfonyValidator); + $validator->validate(new DummyEntity()); + } + + /** + * @expectedException \ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException + */ + public function testInvalid() + { + $data = new DummyEntity(); + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $constraintViolationListProphecy->rewind()->shouldBeCalled(); + $constraintViolationListProphecy->valid()->shouldBeCalled(); + $constraintViolationListProphecy->count()->willReturn(2)->shouldBeCalled(); + + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, null)->willReturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); + $symfonyValidator = $symfonyValidatorProphecy->reveal(); + + $validator = new Validator($symfonyValidator); + $validator->validate(new DummyEntity()); + } + + public function testGetGroupsFromCallable() + { + $data = new DummyEntity(); + $expectedValidationGroups = ['a', 'b', 'c']; + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, $expectedValidationGroups)->willReturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); + $symfonyValidator = $symfonyValidatorProphecy->reveal(); + + $validator = new Validator($symfonyValidator); + $validator->validate(new DummyEntity(), ['groups' => function ($data) use ($expectedValidationGroups): array { + return $data instanceof DummyEntity ? $expectedValidationGroups : []; + }]); + } + + public function testGetGroupsFromService() + { + $data = new DummyEntity(); + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); + $symfonyValidator = $symfonyValidatorProphecy->reveal(); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('groups_builder')->willReturn(true)->shouldBeCalled(); + $containerProphecy->get('groups_builder')->willReturn(new class() { + public function __invoke($data): array + { + return $data instanceof DummyEntity ? ['a', 'b', 'c'] : []; + } + } + )->shouldBeCalled(); + + $validator = new Validator($symfonyValidator, $containerProphecy->reveal()); + $validator->validate(new DummyEntity(), ['groups' => 'groups_builder']); + } + + public function testValidatorWithScalarGroup() + { + $data = new DummyEntity(); + $expectedValidationGroups = ['foo']; + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, $expectedValidationGroups)->willreturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); + $symfonyValidator = $symfonyValidatorProphecy->reveal(); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('foo')->willReturn(false)->shouldBeCalled(); + + $validator = new Validator($symfonyValidator, $containerProphecy->reveal()); + $validator->validate(new DummyEntity(), ['groups' => 'foo']); + } +} diff --git a/tests/Validator/EventListener/ValidateListenerTest.php b/tests/Validator/EventListener/ValidateListenerTest.php new file mode 100644 index 00000000000..dc27e068a2e --- /dev/null +++ b/tests/Validator/EventListener/ValidateListenerTest.php @@ -0,0 +1,129 @@ + + * + * 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\Validator\EventListener; + +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\DummyEntity; +use ApiPlatform\Core\Validator\EventListener\ValidateListener; +use ApiPlatform\Core\Validator\Exception\ValidationException; +use ApiPlatform\Core\Validator\ValidatorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +/** + * @author Samuel ROZE + * @author Kévin Dunglas + * + * @group legacy + */ +class ValidateListenerTest extends TestCase +{ + public function testNotAnApiPlatformRequest() + { + $validatorProphecy = $this->prophesize(ValidatorInterface::class); + $validatorProphecy->validate()->shouldNotBeCalled(); + $validator = $validatorProphecy->reveal(); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create()->shouldNotBeCalled(); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + + $request = new Request(); + $request->setMethod('POST'); + + $event = $this->prophesize(GetResponseForControllerResultEvent::class); + $event->getRequest()->willReturn($request)->shouldBeCalled(); + + $listener = new ValidateListener($validator, $resourceMetadataFactory); + $listener->onKernelView($event->reveal()); + } + + public function testValidatorIsCalled() + { + $data = new DummyEntity(); + $expectedValidationGroups = ['a', 'b', 'c']; + + $validatorProphecy = $this->prophesize(ValidatorInterface::class); + $validatorProphecy->validate($data, ['groups' => $expectedValidationGroups])->shouldBeCalled(); + $validator = $validatorProphecy->reveal(); + + list($resourceMetadataFactory, $event) = $this->createEventObject($expectedValidationGroups, $data); + + $validationViewListener = new ValidateListener($validator, $resourceMetadataFactory); + $validationViewListener->onKernelView($event); + } + + public function testDoNotCallWhenReceiveFlagIsFalse() + { + $data = new DummyEntity(); + $expectedValidationGroups = ['a', 'b', 'c']; + + $validatorProphecy = $this->prophesize(ValidatorInterface::class); + $validatorProphecy->validate($data, ['groups' => $expectedValidationGroups])->shouldNotBeCalled(); + $validator = $validatorProphecy->reveal(); + + list($resourceMetadataFactory, $event) = $this->createEventObject($expectedValidationGroups, $data, false); + + $validationViewListener = new ValidateListener($validator, $resourceMetadataFactory); + $validationViewListener->onKernelView($event); + } + + /** + * @expectedException \ApiPlatform\Core\Validator\Exception\ValidationException + */ + public function testThrowsValidationExceptionWithViolationsFound() + { + $data = new DummyEntity(); + $expectedValidationGroups = ['a', 'b', 'c']; + + $validatorProphecy = $this->prophesize(ValidatorInterface::class); + $validatorProphecy->validate($data, ['groups' => $expectedValidationGroups])->willThrow(new ValidationException())->shouldBeCalled(); + $validator = $validatorProphecy->reveal(); + + list($resourceMetadataFactory, $event) = $this->createEventObject($expectedValidationGroups, $data); + + $validationViewListener = new ValidateListener($validator, $resourceMetadataFactory); + $validationViewListener->onKernelView($event); + } + + private function createEventObject($expectedValidationGroups, $data, bool $receive = true): array + { + $resourceMetadata = new ResourceMetadata(null, null, null, [ + 'create' => ['validation_groups' => $expectedValidationGroups], + ]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + if ($receive) { + $resourceMetadataFactoryProphecy->create(DummyEntity::class)->willReturn($resourceMetadata)->shouldBeCalled(); + } + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + + $kernel = $this->prophesize(HttpKernelInterface::class)->reveal(); + $request = new Request([], [], [ + '_api_resource_class' => DummyEntity::class, + '_api_item_operation_name' => 'create', + '_api_format' => 'json', + '_api_mime_type' => 'application/json', + '_api_receive' => $receive, + ]); + + $request->setMethod(Request::METHOD_POST); + $event = new GetResponseForControllerResultEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST, $data); + + return [$resourceMetadataFactory, $event]; + } +} From 709ae0a677a2865e36cb55e4a7676b6967fefe3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 26 Dec 2017 00:16:18 +0100 Subject: [PATCH 3/5] Add missing deprecated annot --- src/Bridge/Symfony/Validator/EventListener/ValidateListener.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php b/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php index 5d699c62ec6..32f1273dcd7 100644 --- a/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php +++ b/src/Bridge/Symfony/Validator/EventListener/ValidateListener.php @@ -25,6 +25,8 @@ /** * Validates data. * + * @deprecated + * * @author Kévin Dunglas */ final class ValidateListener From 0877afc943fc5c78123dc60f0f6fd02a64800d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 26 Dec 2017 09:58:16 +0100 Subject: [PATCH 4/5] Fix PHPStan --- .../Symfony/Validator/Exception/ValidationExceptionTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php b/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php index eb61142219d..44436e5d16d 100644 --- a/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php +++ b/tests/Bridge/Symfony/Validator/Exception/ValidationExceptionTest.php @@ -36,8 +36,8 @@ class ValidationExceptionTest extends TestCase public function testToString() { $e = new ValidationException(new ConstraintViolationList([ - new ConstraintViolation('message 1', null, [], '', '', 'invalid'), - new ConstraintViolation('message 2', null, [], '', 'foo', 'invalid'), + new ConstraintViolation('message 1', '', [], '', '', 'invalid'), + new ConstraintViolation('message 2', '', [], '', 'foo', 'invalid'), ])); $this->assertInstanceOf(MainValidationException::class, $e); $this->assertInstanceOf(RuntimeException::class, $e); From f1922fc4c7647d6a4cf41d96ec54656c9d7de056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Tue, 26 Dec 2017 10:52:13 +0100 Subject: [PATCH 5/5] Fix some comments --- src/Bridge/Symfony/Validator/Validator.php | 5 +---- src/Validator/ValidatorInterface.php | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Bridge/Symfony/Validator/Validator.php b/src/Bridge/Symfony/Validator/Validator.php index 8df2b439aa4..405d7727101 100644 --- a/src/Bridge/Symfony/Validator/Validator.php +++ b/src/Bridge/Symfony/Validator/Validator.php @@ -35,10 +35,7 @@ public function __construct(SymfonyValidatorInterface $validator, ContainerInter } /** - * Validates an item. - * - * @param object $data - * @param array $context + * {@inheritdoc} */ public function validate($data, array $context = []) { diff --git a/src/Validator/ValidatorInterface.php b/src/Validator/ValidatorInterface.php index dda66f46942..0bba8ff649b 100644 --- a/src/Validator/ValidatorInterface.php +++ b/src/Validator/ValidatorInterface.php @@ -26,7 +26,6 @@ interface ValidatorInterface * Validates an item. * * @param object $data - * @param array $contexy * * @throws ValidationException */