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 :
<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 |
<jem:input path="firstName" /> |
<input id="firstName" type="text" required="required" /> |
@NotNull |
<jem:input path="city" /> |
<input id="city" type="text" required="required" /> |
@Size(max=40) |
<jem:input path="address" /> |
<input id="address" type="text" maxlength="40" /> |
@Size(max=40) |
<jem:input path="address" maxlength="20"/> |
<input id="address" type="text" maxlength="20" /> |
@Min(value = 18) |
<jem:input path="age" /> |
<input id="age" type="number" max="99" min="18" /> |
@Email |
<jem:input path="email" /> |
<input id="email" type="email" /> |
@URL |
<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 :
- 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.
- 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.
- 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 :
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
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 :
<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 :
-
Récupérer le code hébergé sur GitHub :
Sélectionnezgit clone git://github.com/arey/spring-mvc-toolkit.git
-
Construire le projet avec maven :
Sélectionnezcd spring-mvc-tookit mvn clean install
-
Démarrer Jetty
Sélectionnezcd spring-mvc-toolkit-demo mvn jetty:run-war
- 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▲
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.