Tutoriel sur la validation HTML 5 avec Spring MVC et Bean Validation

Antoine Rey

Cet article explique comment étendre Spring MVC pour générer le code HTML 5 des champs de saisie (input fields) à partir des annotations Bean Validation (JSR 330) apposées sur des Entités ou de simples DTO.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum. Commentez .

- Titre : Tutoriel sur la validation HTML 5 avec Spring MVC et Bean Validation

- Auteur : Antoine Rey

- Parution : 11 décembre 2014

- Licence : Copyright ® 2014 Antoine Rey. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Validation HTML 5

- III. Bean Validation

- IV. Code HTML 5 généré

- V. Mise en œuvre

- VI. Tests unitaires

- VII. Intégration manuelle du tag

- VIII. Taglib prête à l'emploi

- IX. Démo

- X. Conclusion

- XI. Références

- XII. Remerciements

I - Introduction

Dans une application web, valider les écrans de saisie côté client permet de donner un retour rapide à l'utilisateur. Avant HTML 5, le développeur web était bien démuni pour implémenter ces contrôles de surface sur le Navigateur. Certes, HTML 4 permettait de spécifier la taille max des champs de saisie (balise maxLength) et leur caractère obligatoire ou non (balise required). Les autres contrôles effectués côté serveur étaient alors bien souvent recodés en JavaScript à l'aide de jQuery, de CSS et de quelques plugins.
Aujourd'hui, HTML 5 se démocratise et le code JavaScript de validation devrait bientôt s'alléger drastiquement. En effet, cette spécification permet de standardiser la validation des champs de saisie côté client. Le développeur a désormais la possibilité de spécifier le type de champs (ex. : nombre, date, URL…), des valeurs min et max ou bien encore un pattern de validation à l'aide d'une expression régulière.

II - Validation HTML 5

Dans l'exemple ci-dessous exploitant les capacités du HTML 5, Google Chrome gère nativement la validation du formulaire et l'affichage du message d'erreur.
Les icônes sont obtenues à l'aide d'un style CSS utilisant les pseudoclasses input:required:invalid, input:focus:invalid et input:required:valid.

Voici la représentation HTML de ce formulaire :

Formulaire de saisie HTML 5

<form id="customer" action="/htmlvalidation" method="post">
<div>
<label>First Name</label>
<input id="firstName" type="text" required="required" />
</div>
<div>
<label>Last Name</label>
<input id="lastName" type="text" required="required" />
</div>
<div>
<label>Address</label>
<input id="address" type="text" maxlength="20" />
</div>
<div>
<label>City</label>
<input id="city" type="text" required="required" />
</div>
<div>
<label>Telephone</label>
<input id="telephone"type="text" maxlength="10" />
</div>
<div>
<label>Email</label>
<input id="email" type="email" />
</div>
<div>
<label>Website URL</label>
<input id="website" type="url" />
</div>
<div>
<label>Age</label>
<input id="age" type="number" max="99" min="18" />
</div>
<div>
<button type="submit">Add Customer</button>
</div>
</form>

Dans la suite de cet article, nous verrons comment Spring MVC peut générer ce code HTML 5.

Attention toutefois, chaque navigateur implémente différemment cette norme.
Par exemple, sous Google Chrome 36, les champs de type date sont particulièrement aboutis, avec masque de saisie et calendrier ; voir ci-dessous la représentation de la ligne HTML Birthdate : <input type="date" name="birthdate">. Par contre, ni Internet Explorer 11 ni Firefox 31 ne fournissent un tel confort de saisie.

III - Bean Validation

Dans le monde Java, la spécification Bean Validation (JSR-303 et JSR-349) n'a plus à faire ses preuves. Elle s'est tout d'abord imposée au niveau de la couche de persistance. Ses annotations sont en effet utilisées par JPA pour générer la structure de la base de données et pour valider les données avant d'exécuter les ordres SQL d'insertion et de mise à jour. Bean Validation a ensuite fait son entrée au niveau de la couche de présentation : avec JSF 2 puis dans Spring MVC via l'annotation @Valid.
Enfin, Bean Validation peut également être utilisé en dehors de tout framework, par exemple pour valider les données en entrée d'un web service.

IV - Code HTML 5 généré

Spring MVC permet de binder bidirectionnellement un champ de saisie avec la propriété d'une classe. Cette classe peut être aussi bien une entité métier qu'un simple DTO. En supposant que Bean Validation est utilisé pour valider les données de cette classe côté serveur, nous allons demander à Spring d'exploiter ces annotations lors de la génération des attributs HTML de la balise <input/>, et ceci via le tag JSP personnalisé <jem:input /> que nous allons développer.

Voici le code HTML 5 que nous aimerions que Spring MVC génère :

Code Java

Page JSP

HTML 5 généré

@NotEmpty
String firstName;

<jem:input path="firstName" />

<input id="firstName" type="text" required="required" />

@NotNull
String city;

<jem:input path="city" />

<input id="city" type="text" required="required" />

@Size(max=40)
String address;

<jem:input path="address" />

<input id="address" type="text" maxlength="40" />

@Size(max=40)
String address;

<jem:input path="address" maxlength="20"/>

<input id="address" type="text" maxlength="20" />

@Min(value = 18)
@Max(value=99)
Integer age;

<jem:input path="age" />

<input id="age" type="number" max="99" min="18" />

@Email
String email;

<jem:input path="email" />

<input id="email" type="email" />

@URL
String website;

<jem:input path="website" />

<input id="website" type="url" />

Integer birthYear;

<jem:input path="birthYear" />

<input id="birthYear" type="number" />

Remarques

- Les attributs font partie de la classe Customer. Côté contrôleur web, une instance est ajoutée au modèle Spring MVC de la vue.

- Le préfixe <jem: permet de distinguer notre balise personnalisée avec la balise input de Spring MVC (<form:input />).

- Les tags <jem:input /> sont disposés dans le formulaire <form:form modelAttribute="customer">.

- Si besoin est, l'attribut maxlength peut être redéfini manuellement via le tag <jem:input />.

V - Mise en œuvre

L'implémentation du tag Html5InputTag interprétant les contraintes Bean Validation demande un peu moins de 200 lignes de code. Elle spécialise la classe org.springframework.web.servlet.tags.form.InputTag de Spring MVC.
Trois méthodes y sont redéfinies :

1. Avant d'appeler la méthode parent, la méthode writeTagContent analyse la propriété à binder à la recherche de contraintes matérialisées par des annotations Bean Validation. Le résultat est stocké dans une Map et sera utilisé dans les deux autres méthodes.

2. En complément des attributs type et value, la méthode writeValue est chargée d'écrire les attributs maxLength, min, max et required à partir des contraintes portées par la propriété à binder.

3. Enfin, la méthode getType détermine la valeur de l'attribut type en fonction du type de la propriété à binder (ex. : Integer) ou des contraintes qu'elle porte.

Pour davantage de détails, voici le code source complet de la classe Html5InputTag :

Tag JSP HTML5

package com.javaetmoi.core.mvc.tag;

import java.lang.annotation.Annotation;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.jsp.JspException;
import javax.validation.Validator;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import javax.validation.metadata.BeanDescriptor;
import javax.validation.metadata.ConstraintDescriptor;
import javax.validation.metadata.PropertyDescriptor;

import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.NotEmpty;
import org.hibernate.validator.constraints.URL;
import org.springframework.beans.ConfigurablePropertyAccessor;
import org.springframework.beans.PropertyAccessorFactory;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.tags.form.InputTag;
import org.springframework.web.servlet.tags.form.TagWriter;

/**
* Add HTML5 form validation to the default Spring MVC input tag by using Bean Validation
* constraints.
*
* <p>
* Supports {@link Max}, {@link Min}, {@link NotNull} and {@link Size} form the Bean Validation API.<br/>
* Supports custom {@link Email}, {@link NotEmpty} and {@link URL} annotations from the Hibernate Validator implementation.
*
*/
public class Html5InputTag extends InputTag {

public static final String MAX_ATTRIBUTE = "max";

public static final String MIN_ATTRIBUTE = "min";

private Map<Class<? extends Annotation>, ConstraintDescriptor<?>> annotations;

private Class<?> valueType;

private static final Map<Class<?>, String> javaToHtmlTypes;

static {
javaToHtmlTypes = new HashMap<Class<?>, String>();
javaToHtmlTypes.put(Integer.class, "number");
javaToHtmlTypes.put(Long.class, "number");
}

@Override
protected int writeTagContent(TagWriter tagWriter) throws JspException {
PropertyDescriptor propertyDescriptor = getPropertyDescriptor();
if ((propertyDescriptor != null) && !propertyDescriptor.getConstraintDescriptors().isEmpty()) {
annotations = constrainteByAnnotationType(propertyDescriptor);
}
valueType = getBindStatus().getValueType();
return super.writeTagContent(tagWriter);
}

@Override
protected void writeValue(TagWriter tagWriter) throws JspException {
super.writeValue(tagWriter);
if (annotations != null) {
writeHtml5Attributes(tagWriter);
}
}

protected void writeHtml5Attributes(TagWriter tagWriter) throws JspException {
writeMaxLengthAttribute(tagWriter);
writeRequiredAttribute(tagWriter);
writeMaxAttribute(tagWriter);
writeMinAttribute(tagWriter);
}

protected void writeMaxLengthAttribute(TagWriter tagWriter) throws JspException {
if (annotations.containsKey(Size.class)) {
Size size = getAnnotation(Size.class);
if ((size.max() != Integer.MAX_VALUE) && StringUtils.isEmpty(getMaxlength())) {
writeOptionalAttribute(tagWriter, MAXLENGTH_ATTRIBUTE, String.valueOf(size.max()));
}
}
}

protected void writeRequiredAttribute(TagWriter tagWriter) throws JspException {
if (annotations.containsKey(NotEmpty.class) || annotations.containsKey(NotNull.class)) {
writeOptionalAttribute(tagWriter, "required", "required");
}
}

protected void writeMaxAttribute(TagWriter tagWriter) throws JspException {
if (annotations.containsKey(Max.class)) {
Max max = getAnnotation(Max.class);
writeOptionalAttribute(tagWriter, MAX_ATTRIBUTE, String.valueOf(max.value()));
}
}

protected void writeMinAttribute(TagWriter tagWriter) throws JspException {
if (annotations.containsKey(Min.class)) {
Min min = getAnnotation(Min.class);
writeOptionalAttribute(tagWriter, MIN_ATTRIBUTE, String.valueOf(min.value()));
}
}

@Override
protected String getType()  {
String type = javaToHtmlTypes.get(valueType);
if (type != null) {
return type;
}
type = "text";
if (annotations != null) {
if (annotations.containsKey(Email.class)) {
type = "email";
} else if (annotations.containsKey(URL.class)) {
type = "url";
} else if (annotations.containsKey(Max.class) || annotations.containsKey(Min.class)) {
type = "number";
}
}
return type;
}

protected Object getBean(String beanName, Map<String, Object> model) {
Object bean;
if (model != null) {
bean = model.get(beanName);
} else {
ConfigurablePropertyAccessor bw = PropertyAccessorFactory.forDirectFieldAccess(getRequestContext());
HttpServletRequest request = (HttpServletRequest) bw.getPropertyValue("request");
bean = request.getAttribute(beanName);
}
return bean;
}

private Map<Class<? extends Annotation>, ConstraintDescriptor<?>> constrainteByAnnotationType(
PropertyDescriptor propertyDescriptor) {
Map<Class<? extends Annotation>, ConstraintDescriptor<?>> annotationMap = new HashMap<Class<? extends Annotation>, ConstraintDescriptor<?>>();
Set<ConstraintDescriptor<?>> constraintDescriptors = propertyDescriptor.getConstraintDescriptors();
for (ConstraintDescriptor<?> constraintDescriptor : constraintDescriptors) {
annotationMap.put(constraintDescriptor.getAnnotation().annotationType(), constraintDescriptor);
}
return annotationMap;
}

/**
* @return PropertyDescriptor may be null when JavaBean do not have any Bean Validation
*         annotations.
*/
private PropertyDescriptor getPropertyDescriptor() throws JspException {
String path = getBindStatus().getPath();
int dotPos = path.indexOf('.');
if (dotPos == -1) {
return null;
}
String beanName = path.substring(0, dotPos);
String expression = path.substring(dotPos + 1);

Map<String, Object> model = getRequestContext().getModel();
Object bean = getBean(beanName, model);

Validator validator = getRequestContext().getWebApplicationContext().getBean(Validator.class);
BeanDescriptor constraints = validator.getConstraintsForClass(bean.getClass());
return constraints.getConstraintsForProperty(expression);
}

@SuppressWarnings("unchecked")
private <T extends Annotation> T getAnnotation(Class<T> annotationClass) {
return (T) annotations.get(annotationClass).getAnnotation();
}
}

Cette classe peut être reprise et adaptée en fonction de vos besoins.

VI - Tests unitaires

La classe TestHtml5InputTag teste unitairement chacune des annotations Bean Validation supportées par le tag.
À titre d'exemple, voici la méthode testant le HTML généré à partir de l'annotation @Size :

Test unitaire

@Test
public void writeMaxLengthFromSizeMaxConstraint() throws JspException {
inputTag.setPath("pet.name");
Map<String, Object> model = new HashMap<String, Object>();
Pet pet = new Pet();
pet.name = "Medor";
model.put("pet", pet);
pageContext = createAndPopulatePageContext(model);
inputTag.setPageContext(pageContext);

inputTag.doStartTag();

assertNotNull(writer.toString());
assertEquals(
"<input id=\"name\" name=\"name\" type=\"text\" value=\"Medor\" maxlength=\"40\"/>", writer.toString());
}

VII - Intégration manuelle du tag

Pour utiliser cette classe dans une application Spring MVC, il est nécessaire de déclarer le tag correspondant dans un fichier TLD qui sera analysé par le conteneur web à son démarrage. Ce descripteur doit se situer dans le répertoire WEB-INF\tld (pour un WAR) ou META-INF\tld (pour un JAR).

La description du tag reprend exactement celle du tag input de Spring MVC déclaré dans le descripteur META_INF/spring-form.tld du module spring-webmvc. Seule l'implémentation change.

VIII - Taglib prête à l'emploi

Pour faciliter l'intégration de ce tag, le projet open source spring-mvc-toolkit propose un taglib. L'URI du taglib est http://javaetmoi.com/core/spring-mvc.

Afin de pouvoir l'utiliser sur une application existante, l'ajout de la dépendance maven suivante est nécessaire :

Dépendance maven vers spring-mvc-toolkit

<dependency>
<groupId>com.javaetmoi.core</groupId>
<artifactId>spring-mvc-toolkit</artifactId>
<version>0.1</version>
</dependency>

Lors d'un mvn clean install, le JAR sera téléchargé depuis Maven Central.

IX - Démo

Le projet spring-mvc-toolkit vient avec une application démo mettant en œuvre les différentes fonctionnalités offertes par le projet. La page htmlvalidation.jsp. montre comment utiliser le tag Html5InputTag. Remarquez qu'aucun code JavaScript n'est utilisé. Afin d'uniformiser le comportement sur l'ensemble des navigateurs, deux styles CSS sont appliqués aux pseudoclasses :valid et :invalid pour afficher des icônes à droite du champ de saisie.

Dans le pom.xml de cette application web de démo, le plugin Jetty pour maven est préconfiguré.

Voici la démarche à suivre pour tester la page :

1. Récupérer le code hébergé sur GitHub :

git clone git://github.com/arey/spring-mvc-toolkit.git

2. Construire le projet avec maven :

cd spring-mvc-tookit mvn clean install

3. Démarrer Jetty

cd spring-mvc-toolkit-demo
mvn jetty:run-war

4. Accéder à la page de test du tag depuis votre Navigateur :
http://localhost:8080/htmlvalidation

X - Conclusion

Cet article aura montré comment étendre les tags JSP de Spring MVC pour ajouter la validation apportée par HTML 5 côté client. L'enrichissement du HTML généré par les tags se base sur les contraintes Bean Validation.

À ce jour, la classe Html5InputTag supporte quatre annotations Bean Validation (@Min, @Max, @NotNull et @Size) et trois annotations spécifiques à Hibernate Validator (@Email, @NotEmpty et @URL).
Le support d'autres annotations pourrait être ajouté. L'annotation @Pattern pourrait par exemple générer l'attribut pattern qui accepte une expression régulière. La difficulté réside dans l'adaptation d'une regex Java en regex JavaScript, ce qui a été fait dans le sens inverse par l'équipe GWT.
Le support des groups Bean Validation pourrait également être ajouté.
Enfin, ce qui a ici été appliqué pour la classe InputTag peut l'être à moindre échelle sur la classe TextAreaTag.

XI - Références

1. HTML5 Form Validation Examples

2. Formulaires HTML5 : placeholder, required, pattern et validation

3. Classe RegExp.java de GWT

XII - Remerciements

Cet article a été publié avec l'aimable autorisation de Antoine Rey.

Nous tenons à remercier Claude LELOUP pour sa relecture orthographique attentive de cet article et Régis Pouiller pour la mise au gabarit.