Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion features/graphql/mutation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."
3 changes: 2 additions & 1 deletion src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
<argument type="service" id="api_platform.data_persister" />
<argument type="service" id="serializer" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="ignore" />
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="null" />
<argument type="service" id="api_platform.validator" on-invalid="null" />
</service>

<service id="api_platform.graphql.resolver.item" class="ApiPlatform\Core\GraphQl\Resolver\ItemResolver" public="false">
Expand Down
11 changes: 8 additions & 3 deletions src/Bridge/Symfony/Bundle/Resources/config/validator.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

<services>
<service id="api_platform.validator" class="ApiPlatform\Core\Bridge\Symfony\Validator\Validator">
<argument type="service" id="validator" />
<argument type="service" id="service_container" />
</service>
<service id="ApiPlatform\Core\Validator\ValidatorInterface" alias="api_platform.validator" />

<service id="api_platform.metadata.property.metadata_factory.validator" class="ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory" decorates="api_platform.metadata.property.metadata_factory" decoration-priority="20" public="false">
<argument type="service" id="validator" />
<argument type="service" id="api_platform.metadata.property.metadata_factory.validator.inner" />
</service>

<service id="api_platform.listener.view.validate" class="ApiPlatform\Core\Bridge\Symfony\Validator\EventListener\ValidateListener">
<argument type="service" id="validator" />
<service id="api_platform.listener.view.validate" class="ApiPlatform\Core\Validator\EventListener\ValidateListener">
<argument type="service" id="api_platform.validator" />
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
<argument type="service" id="service_container" />

<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="64" />
</service>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,8 @@
/**
* Validates data.
*
* @deprecated
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class ValidateListener
Expand All @@ -34,6 +37,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;
Expand Down
26 changes: 22 additions & 4 deletions src/Bridge/Symfony/Validator/Exception/ValidationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,23 @@

namespace ApiPlatform\Core\Bridge\Symfony\Validator\Exception;

use ApiPlatform\Core\Validator\Exception\ValidationException as BaseValidationException;
use Symfony\Component\Validator\ConstraintViolationListInterface;

/**
* Thrown when a validation error occurs.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
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);
}

/**
Expand All @@ -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;
}
}
63 changes: 63 additions & 0 deletions src/Bridge/Symfony/Validator/Validator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 <dunglas@gmail.com>
*/
class Validator implements ValidatorInterface
{
private $validator;
private $container;

public function __construct(SymfonyValidatorInterface $validator, ContainerInterface $container = null)
{
$this->validator = $validator;
$this->container = $container;
}

/**
* {@inheritdoc}
*/
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);
}
}
}
28 changes: 27 additions & 1 deletion src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to warn the user if there is no validator?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No (we would just make this property not nullable otherwise), it allows to create micro-frameworks on top of API Platform and it mimics the behavior of the ValidateListener (which is optional).

}

$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);
}
}
}
5 changes: 1 addition & 4 deletions src/Serializer/AbstractConstraintViolationListNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
67 changes: 67 additions & 0 deletions src/Validator/EventListener/ValidateListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 <dunglas@gmail.com>
*/
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dunglas consider validating controller argument data instead of controller result. We cann't put some logic inside controller now because request data is not validated yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure to understand, the validation occurs after the execution of the controller.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need to validate the data before executing your logic, just inject ValidatorInterface in your controller and call it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's related to this: #1590

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, @alanpoulain is right. IMHO validating input data before controller execution is much logical.

@dunglas dunglas Dec 26, 2017

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing this would be a big BC break and will provide less flexibility. IMO the data must be valid before being persisted, but after the custom logic has been executed.

$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]);
}
}
25 changes: 25 additions & 0 deletions src/Validator/Exception/ValidationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 <dunglas@gmail.com>
*/
class ValidationException extends RuntimeException
{
}
Loading