Maison  >  Article  >  développement back-end  >  Un moyen simple de valider les DTO à l'aide des attributs Symfony

Un moyen simple de valider les DTO à l'aide des attributs Symfony

Susan Sarandon
Susan Sarandonoriginal
2024-09-21 20:16:02316parcourir

An easy way to validate DTO

Introduction

Les DTO sont des objets simples qui encapsulent des attributs de données sans contenir aucune logique métier. Ils sont souvent utilisés pour regrouper des données provenant de plusieurs sources en un seul objet, ce qui facilite leur gestion et leur transmission. En utilisant les DTO, les développeurs peuvent réduire le nombre d'appels de méthodes, améliorer les performances et simplifier la gestion des données, en particulier dans les systèmes distribués ou les API.

A titre d'exemple, nous pouvons utiliser des DTO pour mapper les données reçues via une requête HTTP. Ces DTO conserveraient dans leurs propriétés les valeurs de charge utile reçues et nous pourrions les utiliser dans l'application, par exemple, en créant un objet d'entité de doctrine prêt à être conservé dans la base de données à partir des données contenues dans le DTO. Comme les données DTO seraient déjà validées, cela peut réduire la probabilité de générer des erreurs lors de la persistance de la base de données.

Les attributs MapQueryString et MapRequestPayload

Les attributs MapQueryString et MapRequestPayload nous permettent de mapper respectivement la chaîne de requête reçue et les paramètres de charge utile. Voyons un exemple des deux.

Un exemple MapQueryString

Imaginons que nous ayons une route Symfony qui puisse recevoir les paramètres suivants dans la chaîne de requête :

  • de : Une date de début obligatoire
  • à : Un rendez-vous obligatoire
  • âge : Un âge facultatif

Sur la base des paramètres ci-dessus, nous souhaitons les mapper au dto suivant :

readonly class QueryInputDto {
   public function __construct(
      #[Assert\Datetime(message: 'From must be a valid datetime')]
      #[Assert\NotBlank(message: 'From date cannot be empty')]
      public string $from,
      #[Assert\Datetime(message: 'To must be a valid datetime')]
      #[Assert\NotBlank(message: 'To date cannot be empty')]
      public string $to,
      public ?int $age = null 
   ){}
}

Pour les mapper, il suffit d'utiliser l'attribut MapQueryString de cette manière :

#[Route('/my-get-route', name: 'my_route_name', methods: ['GET'])]
public function myAction(#[MapQueryString] QueryInputDTO $queryInputDto) 
{
   // your code
}

Comme vous pouvez le voir, lorsque symfony détecte que l'argument $queryInputDto a été marqué avec l'attribut #[MapQueryString], il mappe automatiquement les paramètres de la chaîne de requête reçus dans cet argument qui est une instance du Classe QueryInputDTO.

Un exemple MapRequestPayload

Dans ce cas, imaginons que nous ayons une route Symfony qui reçoit les données requises pour enregistrer un nouvel utilisateur dans la charge utile de la requête JSON. Ces paramètres sont les suivants :

  • nom : obligatoire
  • email : obligatoire
  • date de naissance (dob) : obligatoire

Sur la base des paramètres ci-dessus, nous souhaitons les mapper au dto suivant :

readonly class PayloadInputDto {
    public function __construct(
       #[Assert\NotBlank(message: 'Name cannot be empty')] 
       public string $name,
       #[Assert\NotBlank(message: 'Email cannot be empty')]
       #[Assert\Email(message: 'Email must be a valid email')]
       public string $email,
       #[Assert\NotBlank(message: 'From date cannot be empty')]
       #[Assert\Date(message: 'Dob must be a valid date')]
       public ?string $dob = null 
    ){}
 }

Pour les cartographier, il suffit d'utiliser l'attribut MapRequestPayload de cette manière :

#[Route('/my-post-route', name: 'my_post_route', methods: ['POST'])]
public function myAction(#[MapRequestPayload] PayloadInputDTO $payloadInputDto) 
{
   // your code
}

Comme nous l'avons vu dans la section précédente, lorsque symfony détecte que l'argument $payloadInputDto a été marqué avec l'attribut #[MapRequestPayload], il mappe automatiquement les paramètres de charge utile reçus dans cet argument qui est une instance de la classe PayloadInputDTO.

MapRequestPayload fonctionne à la fois pour les charges utiles JSON et les charges utiles codées par URL de formulaire.

Gestion des erreurs de validation DTO

Si la validation échoue pendant le processus de mappage (par exemple, l'e-mail obligatoire n'a pas été envoyé), Symfony renvoie une exception 422 Unprocessable Content. Si vous souhaitez détecter ce type d'exceptions et renvoyer les erreurs de validation comme, par exemple, json au client, vous pouvez créer un abonné à l'événement et continuer à écouter l'événement KernelException.

class KernelSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::EXCEPTION => 'onException'
        ];
    }

    public function onException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        if($exception instanceof UnprocessableEntityHttpException) {
            $previous = $exception->getPrevious();
            if($previous instanceof ValidationFailedException) {
                $errors = [];
                foreach($previous->getViolations() as $violation) {
                    $errors[] = [
                        'path' => $violation->getPropertyPath(),
                        'error' => $violation->getMessage() 
                    ];
                }

                $event->setResponse(new JsonResponse($errors));
            }
        }
    }
}

Une fois la méthode onException déclenchée, elle vérifie si l'exception d'événement est une instance de UnprocessableEntityHttpException. Si tel est le cas, il vérifie également si l'erreur non traitable provient d'un échec de validation vérifiant si l'exception précédente est une instance de la classe ValidationFailedException. Si tel est le cas, il stocke toutes les erreurs de violation dans un tableau (uniquement le chemin de la propriété comme clé et le message de violation comme erreur), crée une réponse JSON à partir de ces erreurs et définit la nouvelle réponse à l'événement.

L'image suivante montre la réponse JSON pour une requête qui échoue car l'e-mail n'a pas été envoyé :

@baseUrl = http://127.0.0.1:8000

POST {{baseUrl}}/my-post-route
Content-Type: application/json

{
    "name" : "Peter Parker",
    "email" : "",
    "dob" : "1990-06-28"
}

-------------------------------------------------------------
HTTP/1.1 422 Unprocessable Entity
Cache-Control: no-cache, private
Content-Type: application/json
Date: Fri, 20 Sep 2024 16:44:20 GMT
Host: 127.0.0.1:8000
X-Powered-By: PHP/8.2.23
X-Robots-Tag: noindex
Transfer-Encoding: chunked

[
  {
    "path": "email",
    "error": "Email cannot be empty"
  }
]

La demande d'image ci-dessus a été générée à l'aide de fichiers http.

Création de votre résolveur personnalisé

Imaginons que nous ayons des routes qui reçoivent les paramètres de la chaîne de requête dans un tableau nommé "f". Quelque chose comme ça :

/my-get-route?f[from]=2024-08-20 16:24:08&f[to]=&f[age]=14

Nous pourrions créer un résolveur personnalisé pour vérifier ce tableau dans la requête, puis valider les données. Codons-le.

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class CustomQsValueResolver implements ValueResolverInterface, EventSubscriberInterface
{
    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly SerializerInterface $serializer
    ){}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments',
        ];
    }

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        $attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null;

        if (!$attribute) {
            return [];
        }

        if ($argument->isVariadic()) {
            throw new \LogicException(sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName()));
        }

        $attribute->metadata = $argument;
        return [$attribute];
    }

    public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
    {
        $arguments = $event->getArguments();
        $request  = $event->getRequest();

        foreach ($arguments as $i => $argument) {
            if($argument instanceof MapQueryString ) {
                $qs = $request->get('f', []);
                if(count($qs) > 0) {
                    $object = $this->serializer->denormalize($qs, $argument->metadata->getType());
                    $violations = $this->validator->validate($object);
                    if($violations->count() > 0) {
                        $validationFailedException = new ValidationFailedException(null, $violations);
                        throw new UnprocessableEntityHttpException('Unale to process received data', $validationFailedException);
                    }

                    $arguments[$i] = $object;
                }
            }

        }

        $event->setArguments($arguments);
    }
}

The CustomQsValueResolver implements the ValueResolverInterface and the EventSubscriberInterface, allowing it to resolve arguments for controller actions and listen to specific events in the Symfony event system. In this case, the resolver listens to the Kernel CONTROLLER_ARGUMENTS event.
Let's analyze it step by step:

The constructor

The constructor takes two dependencies: The Validator service for validating objects and a the Serializer service for denormalizing data into objects.

The getSubscribedEvents method

The getSubscribedEvents method returns an array mapping the KernelEvents::CONTROLLER_ARGUMENTS symfony event to the onKernelControllerArguments method. This means that when the CONTROLLER_ARGUMENTS event is triggered (always a controller is reached), the onKernelControllerArguments method will be called.

The resolve method

The resolve method is responsible for resolving the value of an argument based on the request and its metadata.

  • It checks if the argument has the MapQueryString attribute. If not, it returns an empty array.
  • If the argument is variadic, that is, it can accept a variable number of arguments, it throws a LogicException, indicating that mapping variadic arguments is not supported.
  • If the attribute is found, it sets the metadata property of the attribute and returns it as a php iterable.

The onKernelControllerArguments method

The onKernelControllerArguments method is called when the CONTROLLER_ARGUMENTS event is triggered.

  • It retrieves the current arguments and the request from the event.
  • It iterates over the arguments, checking for arguments flagged as MapQueryString
  • If found, it retrieves the query string parameters holded by the "f" array using $request->get('f', []).
  • If there are parameters, it denormalizes them into an object of the type specified in the argument's metadata (The Dto class).
  • It then validates the object using the validator. If there are validation violations, it throws an UnprocessableEntityHttpException which wraps a ValidationFailedException with the validation errors.
  • If validation passes, it replaces the original argument with the newly created object.

Using the resolver in the controller

To instruct the MapQueryString attribute to use our recently created resolver instead of the default one, we must specify it with the attribute resolver value. Let's see how to do it:

#[Route('/my-get-route', name: 'my_route_name', methods: ['GET'])]
public function myAction(#[MapQueryString(resolver: CustomQsValueResolver::class)] QueryInputDTO $queryInputDto) 
{
   // your code
}

Conclusion

In this article, we have analized how symfony makes our lives easier by making common application tasks very simple, such as receiving and validating data from an API. To do that, it offers us the MapQueryString and MapRequestPayload attributes. In addition, it also offers us the possibility of creating our custom mapping resolvers for cases that require specific needs.

If you like my content and enjoy reading it and you are interested in learning more about PHP, you can read my ebook about how to create an operation-oriented API using PHP and the Symfony Framework. You can find it here: Building an Operation-Oriented Api using PHP and the Symfony Framework: A step-by-step guide

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn
Article précédent:. Numéros lexicographiquesArticle suivant:. Numéros lexicographiques