Utilisation du pattern DTO pour la validation de données en Symfony

Retour

Introduction

Never trust the user input, si tu veux suivre cet adage avec ton API alors il est important de valider les données que tu reçois. En effet, il se pourrait que le corps de la requête ne soit pas correct et ça, nous, on en veut pas 🤮 Tu pourrais valider les champs un par un en utilisant $request directement comme tableau, mais ça aussi on en veut pas 😀 Alors comment on fait ma poule ? Je vais te montrer à travers cet article ma méthode. Elle pourra te sembler excessive (à vous de juger), mais je pense que c'est plus dans l'esprit de Symfony.

Posons les bases

Pour l’exemple, je vais utiliser deux entités :

  • Car
  • Moto

celles-ci implémentent une interface :

interface VehicleInterface
{
    public function getBrand(): string;
    public function getType(): string;
}

Et pour la création de l’une ou l’autre, je vais utiliser une factory :

class VehicleFactory
{
    /**
     * @throws Exception
     */
    public static function createVehicle(string $type): Car|Moto
    {
        return match ($type) {
            'car' => new Car(),
            'moto' => new Moto(),
            default => throw new Exception("$type is not a supported type"),
        };
    }
}

Partant de là, pour créer un véhicule il suffirait donc de faire ceci :

$vehicle = VehicleFactory::createVehicle($data['type']);
$vehicle->setBrand($data['brand']);

Ecrivons notre API Controller

Tout d'abord, je vais désérialiser la requête vers un type VehicleDTO. Créons donc cette classe :

class VehicleDTO implements VehicleInterface
{
    #[Assert\NotBlank(message: 'Type cannot be blank')]
    private string $type;
    #[Assert\NotBlank(message: 'Brand cannot be blank')]
    private string $brand;

    public function getType(): string
    {
        return $this->type;
    }

    public function setType(string $type): self
    {
        $this->type = $type;
        return $this;
    }

    public function getBrand(): string
    {
        return $this->brand;
    }

    public function setBrand(string $brand): self
    {
        $this->brand = $brand;
        return $this;
    }
}

Maintenant je peux m’en servir dans mon controller :

#[Route('/api', name: 'app_api', methods: ['POST'])]
public function index(Request $request): JsonResponse
{
    $vehicleDTO = $this->serializer->deserialize($request->getContent(), VehicleDTO::class, 'json');
    return new JsonResponse();
}

Tu l’auras remarqué, dans la classe DTO j’ai déjà ajouté les asserts afin de valider les propriétés dont nous avons besoin pour la création de notre véhicule. Ne reste alors plus qu’à utiliser la validation du composant Validator :

$errors = $this->validator->validate($vehicleDTO);
if (count($errors) > 0) {
    $messages = [];
    foreach ($errors as $error) {
        $messages[] = $error->getMessage();
    }
    return $this->json($messages, Response::HTTP_BAD_REQUEST);
}

Si je n'ai pas d'erreurs, il ne me reste plus qu'à créer mon véhicule et à l'enregistrer. Pour que le contrôleur reste plus lisible et surtout pour limiter ses responsabilités, je vais créer un VehicleManager qui s'occupera de ces tâches :

class VehicleManager
{
    public function __construct(private readonly EntityManagerInterface $entityManager) {}

    /**
     * @throws Exception
     */
    public function createVehicle(VehicleDTO $vehicleDTO): Moto|Car
    {
        // code
    }
}

Dans mon manager, pour passer de ma classe VehicleDTO à une classe Moto ou Car, je vais créer une classe VehicleDataTransformer :

class VehicleDataTransformer implements DataTransformerInterface
{
    /**
     * @param VehicleInterface $value
     * @return VehicleDTO
     */
    public function transform($value): VehicleDTO
    {
        $vehicleDTO = new VehicleDTO();
        $vehicleDTO->setType($value->getType())
            ->setBrand($value->getBrand());

        return $vehicleDTO;
    }

    /**
     * @throws \\Exception
     */
    public function reverseTransform($value)
    {
        $vehicle = VehicleFactory::createVehicle($value->getType());
        $vehicle->setBrand($value->getBrand());

        return $vehicle;
    }
}

Bon, OK, peut-être que c'est too much d'implémenter DataTransformerInterface, car il y a peu de chances que nous convertissions souvent l'entité en DTO, ou du moins rarement. Cependant, de cette manière, nous gagnons en lisibilité et en limitant les responsabilités à chaque classe.

Maintenant que nous avons tout ce qu’il nous faut pour notre manager, il ne reste plus qu’en s’en servir :

public function createVehicle(VehicleDTO $vehicleDTO): Moto|Car
{
  $transformer = new VehicleDataTransformer();
  $vehicle = $transformer->reverseTransform($vehicleDTO);

  $this->entityManager->persist($vehicle);
  $this->entityManager->flush();

  return $vehicle;
}

Puis de mettre à jour notre controller :

#[Route('/api', name: 'app_api', methods: ['POST'])]
public function index(Request $request): JsonResponse
{
    // code

    try {
        $vehicle = $this->vehicleManager->createVehicle($vehicleDTO);
    } catch (Exception $exception) {
        return $this->json($exception->getMessage(), Response::HTTP_BAD_REQUEST);
    }

    return new JsonResponse(
        $this->serializer->serialize($vehicle, 'json'),
        Response::HTTP_CREATED,
        [],
        true
    );
}

Conclusion

Et voilà ! Tu trouveras peut-être que c'est beaucoup de travail pour une fonctionnalité si petite, mais cet exemple pourrait très bien être plus complexe en fonction de la logique métier de ton application. L'important ici est de voir le cheminement entre la requête, la validation des données jusqu'à sa transformation en une entité pouvant être persistée.