Valider des données avec Vavr

La validation de données est importante dans une application. Ses avantages : maîtriser au plus tôt les données, mieux gérer les erreurs, ou encore éviter d’engorger le serveur avec des requêtes qui ne pourront pas aboutir…

Il y a plusieurs manières de la couvrir, comme par exemple avec les Bean Validations de Spring MVC. Mais je vous propose plutôt de jeter un œil aux validations Vavr, cette bibliothèque qui tente d’apporter à Java des outils pour rendre le code plus concis et plus fonctionnel, inspirés de ce qu’on peut trouver en Scala.

Un des apports de ces validations Vavr, en dehors du fait d’être particulièrement adaptées à la programmation fonctionnelle, est de permettre de renvoyer une liste d’erreurs en retour d’un service. Cela vient du fait qu’une Validation ne fera pas un court-circuit en cas d’erreur (contrairement aux exceptions) mais les accumulera avant de les renvoyer.

Comment ça marche ?

Une validation Vavr est une interface, ou une structure de données qui modélise un retour avec ses deux implémentations :

  • Valid, qui représente de la donnée valide
  • Invalid, qui représente nos erreurs.

Écrivons une première fonction simple qui doit valider la présence d’un produit :

public static Validation<Error, Product> validateProduct(final Product product) {
  return Option(product).isDefined() ? Valid(product) : Invalid(new Error());
}

On encapsule le produit dans un Option (un équivalent Vavr du Optional de Java), et on retourne des implémentations de Validation différentes, en fonction de la présence du prix.
Remarquons le retour de la fonction dans la signature, Validation<Error, Product>. A gauche les erreurs, à droite le produit qui est retourné si c’est valide.

En programmation purement fonctionnelle, ça donnerait :

public static Validation<Error, Merchandising> validateProduct(final Merchandising product) {
  return Option(product).toValidation(Error::new);
}

La fonction toValidation() permet de convertir un Option en Validation, et prend en paramètre un supplier du type qui sera contenu dans l' Invalid, si l’ Option est vide.

Comment exploiter le résultat d’une validation ?

On a donc notre fonction qui nous donne un Invalid, ou un Valid. A présent il va falloir l’exploiter et en faire une réponse de service.
Imaginons une méthode qui doit créer en base de données un objet Merchandising (contenant des offres, un produit, et des images) seulement si le produit existe, puis renvoyer une réponse ( MerchResponse ).

public ResponseEntity<MerchResponse> createMerchandising(final Merchandising merchandising) {
  return validateProduct(merchandising.getProduct())
     .map(product -> DomainMerchandising.builder().product(product).build())
     .map(MerchService::createMerch)
     .map(DomainMerchandising::toMerchResponse)
     .map(ResponseEntity::ok)
     .getOrElseGet(RestErrorMapper::toMerchResponse);
}

On appelle dans un premier temps la validation du produit, écrite plus haut. Si le produit est bien présent, le validateProduct nous renvoie un Valid(Product). On map et on instancie un produit du domaine ( DomainMerchandising ) avec le résultat de la validation, puis on le crée en BDD : createMerch(domainMerchandising), écrit ici en référence de méthode. Idéalement, on aurait exporté l’instanciation de DomainMerchandising dans une classe de mapping pour respecter les bonnes pratiques, et mis la validation de la donnée dans le domaine, mais je les laisse ici pour simplifier.
Finalement, on transforme le résultat en MerchResponse, grâce à une méthode toMerchResponse(), qu’on encapsule dans une ResponseEntity. On obtient donc notre retour de type ResponseEntity<MerchResponse> pour le cas valide.

S’il y a eu erreur, et que le validateProduct nous renvoie un Invalid(Error), on saute tous les maps et on rentre dans le getOrElseGet(), qui transforme l’erreur en MerchResponse.

Faisons plus concis, avec un fold Vavr qui permet de mapper la partie gauche et droite d’une validation.

public ResponseEntity<MerchResponse> createMerchandising(final Merchandising merchandising) {
  return validateProduct(merchandising.getProduct())
     .map(product -> DomainMerchandising.builder().product(product).build())
     .map(MerchService::createMerch)
     .map(DomainMerchandising::toMerchResponse)
     .fold(RestErrorMapper::toMerchResponse, ResponseEntity::ok);
}

Plusieurs validations consécutives ?

Nous avons validé un seul champ de l’objet Merchandising : son produit.
Validons-en plusieurs, avec la méthode Validation.combine(), qui nous permet d’enchaîner jusqu’à 8 validations :

public ResponseEntity<MerchResponse> createMerchandising2(final Merchandising merchandising) {
  return combine(
         validateProduct(merchandising.getProduct()),
         validateOffers(merchandising.getOffers()),
         validateAssets(merchandising.getAssets()))
     .ap(
         (product, offers, assets) ->
             DomainMerchandising.builder()
                 .product(product)
                 .offers(offers)
                 .assets(assets)
                 .build())
     .map(MerchService::createMerch)
     .map(DomainMerchandising::toMerchResponse)
     .fold(RestErrorMapper::listToMerchResponse, ResponseEntity::ok);
}

Chaque validation ne prend qu’un paramètre en entrée, et retourne un Valid ou Invalid. On peut y vérifier la présence des valeurs, comme vu avant, mais aussi implémenter n’importe quelle règle, comme par exemple vérifier que les prix soient bien positifs, ou autre.
Le combine() peut prendre plusieurs validations en entrée, et il n’en forme qu’une en sortie.
S’il y a des erreurs ( Invalid ) issues des différentes validations, elles sont empilées dans une séquence. Le type de retour du bloc combine(...).ap(...) est donc une Validation<Seq<EmptyFieldError>, Merchandising>.

Une fois les validations exécutées et si elles sont toutes valides, le ap() prend en entrée une fonction dont les paramètres correspondent aux résultats valides : product, offers, assets. Sinon, cette fonction n’est pas éxécutée.
A partir de ces paramètres, on peut alors instancier l’objet du domaine, puis faire appel à createMerch(), comme précédemment.

Si par contre une des validations au moins renvoie un Invalid, les autres validations sont quand-même exécutées (pour récupérer les autres erreurs potentielles), mais pas le code contenu dans .ap(...). Le retour du bloc combine(...).ap(...) est dans ce cas Invalid(Seq(error1, error2,…)).
La fonction RestErrorMapper.toMerchResponse a aussi dû être modifiée pour prendre une séquence d’erreurs (plutôt qu’une seule), et en faire une MerchResponse. Cette MerchResponse pourra contenir toutes les erreurs rencontrées lors des différentes validations.

Une validation peut-elle dépendre d’autres validations ?

Certainement. Par exemple, la validation d’un produit pourrait reposer sur les validations des champs qui composent ce produit. Reprenons le premier bout de code qui valide un produit s’il existe :

public static Validation<Seq<Error>, DomainProduct> validateProduct(final Product product) {
  return Option(product)
        .toValidation(Error::new).mapError(API::Seq)
        .flatMap(
            product ->
                combine(
                        validateId(product), 
                        validateBrand(product), 
                        validateType(product))
                    .ap(
                        (id, brand, type) ->
                            DomainProduct.builder()
                                .id(id)
                                .brand(brand)
                                .type(type)
                                .build()));
}

Cette fois-ci, nous pouvons obtenir plusieurs erreurs (au moins une par validation), qui sont agrégées dans une séquence par le combine().  La signature de la fonction doit donc être changée : Validation<Seq<Error>, DomainProduct>.
Récupérons le résultat de vérification de présence du produit (avec un flatmap) pour valider chacun des champs, dans le combine(). La présence de .mapError(API::Seq) qui transforme Error en Seq<Error> permet de ne pas avoir de conflit de type d’erreur ( combine() renvoie une Seq<Error>, lui).

(Note : Profitons-en pour instancier un objet du domaine DomainProduct à partir des différents résultats des validations, plutôt que de renvoyer le produit d’entrée.)

Nous validons donc la présence du produit, son id, sa marque et son type, et nous retournons une séquence d’erreurs. Pour finir, adaptons la fonction appelante pour prendre en compte le changement de signature :

public ResponseEntity<MerchResponse> createMerchandising(final Merchandising merchandising) {
  return combine(
         validateProduct(merchandising.getProduct()),
         validateOffers(merchandising.getOffers()).mapError(API::Seq),
         validateAssets(merchandising.getAssets()).mapError(API::Seq))
     .ap(
         (domainProduct, domainOffers, domainAssets) ->
             DomainMerchandising.builder()
                 .product(domainProduct)
                 .offers(domainOffers)
                 .assets(domainAssets)
                 .build())
     .mapError(nestedErrors -> nestedErrors.flatMap(identity()))
     .map(MerchService::createMerch)
     .map(DomainMerchandising::toMerchResponse)
     .fold(RestErrorMapper::listToMerchResponse, ResponseEntity::ok);
}

Le combine() ne tolérant pas de divergence dans le type d’erreurs retournées par les validations, ajoutons mapError(API::Seq) pour uniformiser et retourner une séquence d’erreurs en sortie de chaque validation, pour celles qui n’en renvoyaient qu’une.
Par contre, chaque validation nous renvoie maintenant une Seq<Error>, et comme dit plus haut, le rôle du combine() est de les empiler dans une séquence…  Sans la ligne  mapError(nestedErrors -> nestedErrors.flatMap(identity())) qui remet à plat les erreurs dans une Seq<Error>, nous nous retrouverions donc avec une Seq<Seq<Error>>.

Comment tester ?

Vavr propose son propre outil pour tester le contenu d’une validation, dans le package org.assertj.vavr.api.VavrAssertions.
Il se rapproche du assertThat(...).isEqualTo(...) de JUnit, mais se base sur la validité ou l’invalidité du résultat :

// GIVEN …
// WHEN
val result = validateProduct(product)

// THEN
assertThat(result).isValid() 
// ou, si on s’attend à un résultat invalide :
assertThat(result).isInvalid()


Mais pour être plus précis, il vaut mieux détailler le contenu, avec .containsValid/Invalid :

assertThat(actual)
   .containsValid(
       DomainProduct.builder()
           .id(id)
           .type(type)
           .brand(brand)
           .build());
// ou

assertThat(actual)
   .containsInvalid(
       MerchanisingError.builder()
           .message(“merchandising is not valid”)
           .build());

Nous voyons que grâce à ce système de validations qu’on peut combiner ou composer, nous arrivons à retourner toutes les informations relatives à la validation des données au consommateur d’un service. Si l’utilisation de la bibliothèque Vavr peut demander un temps d’adaptation, le gain lié à son utilisation est important : validation de données, mais aussi immutabilité des structures de données, concision du code, tout en étant complètement interopérable avec Java. Mais ces sujets méritent leurs propres articles…