Tutoriel pour apprendre à contrôler les préconditions des méthodes d'une API Java

François-Xavier Robin

Comme tout système s'appuyant sur des inputs, il est très important de contrôler les arguments des méthodes quand on élabore une API, qu'elle soit locale sous forme de JAR ou distante via un service REST, afin de non seulement la rendre plus robuste et stable, mais aussi de se prémunir de certaines attaques.

Cependant, en Java de base, c'est particulièrement laborieux, rébarbatif et cela engendre une fainéantise exacerbée. Conséquences directes et désastreuses : baisse de la qualité, de la robustesse du code et créations potentielles de failles de sécurité.

Cet article tente de faire le tour de la question, sans prétention, en ratissant assez large. C'est-à-dire en allant de Java classique jusqu'à Bean Validation et JAX-RS, en passant par une implémentation spécifique « faite maison ».

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

- Titre : Tutoriel pour apprendre à contrôler les préconditions des méthodes d'une API Java

- Auteur : François-Xavier Robin

- Parution : 6 juillet 2018

- Mise à jour : 28 juillet 2018

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2018 François-Xavier Robin. 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. La problématique

- II. Factorisation : éléments communs

- II-A. Vérification d'une image PNG

- II-B. Messages d'erreurs

- III. La manière classique en Java

- IV. Solutions

- IV-A. Java assert

- IV-B. Apache Commons Lang

- IV-C. Guava

- IV-D. Spring

- IV-E. Better Preconditions

- IV-F. Java 8 Objects

- IV-G. Préconditions « Faites maison »

- IV-H. Bean Validation

- V. REST : JAX-RS et Bean Validation

- V-A. JAX-RS et Checker « fait maison »

- V-B. JAX-RS et Bean Validation

- VI. Quelques réflexions supplémentaires

- VII. Remerciements

I - La problématique

Partons du principe que nous devons coder une méthode, accessible depuis du code tiers sous forme d'API.

Cette méthode acceptera quatre arguments : un nom exprimé en majuscules, sans espaces, ni caractères spéciaux ; un âge entre 0 et 150 ans ; une image PNG contenue dans un tableau de byte ; une liste de compétences, sous forme de chaînes de caractères.

Ce gentil monsieur représentera notre jeu de test :

- nom : WAYNE (j'espère ne pas trahir un secret… j'ai un doute)

- âge : 35 ans (à vue de nez…)

- photo : une image au format PNG (format préconisé par la ligue des justiciers)

- liste de compétences : ninja, hacking, justice, grosse baffe, boomerang, Cluedo le week-end avec Alfred…

Il ne faut pas être mentaliste pour se rendre compte qu'on a intérêt à bien valider les informations pour qu'il soit content ! Il n'a pas l'air commode… Image : © DC Comics and Time Warner Company

Dans cet article, on va donc tester :

- Java assert ;

- Apache Commons Lang ;

- Guava ;

- Spring ;

- Better Preconditions ;

- Java 8 Objects ;

- Une solution perso (bien que j'évite d'en faire en temps normal) ;

- Bean Validation.

On terminera avec une mise en pratique avec JAX-RS :

- avec la solution « faite maison » ;

- avec Bean Validation.

Puis on conclura avec quelques réflexions et points d'attention supplémentaires.

II - Factorisation : éléments communs

II-A - Vérification d'une image PNG

Globalement, tous ces cas de figure auront besoin à un moment ou à un autre de vérifier qu'un tableau d'octets byte[] contient bien une image PNG.

Ici, on descend « bas niveau » afin de vérifier une simple séquence d'octets qui représentent la signature d'un fichier PNG :

1. package fr.fxjavadevblog.demo;

3. import java.util.Arrays;

5. public final class ValidationUtils

6. {

7. public static byte[] PNG_SIGNATURE = new byte[] { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };

9. private ValidationUtils()

10. {

11. // protection de cette classe utilitaire.

12. }

14. /**

15. * vérifie que le tableau d'octets contient bien la signature PNG.

16. *

17. * @param data

18. *         tableau d'octets à tester

19. * @return

20. *         true si la signature PNG est trouvée, false dans le cas contraire.

21. */

22. public static boolean isPngData(byte[] data)

23. {

24. return Arrays.equals(PNG_SIGNATURE, Arrays.copyOf(data, PNG_SIGNATURE.length));

25. }

27. /**

28. * vérifie que la référence désigne bien un tableau d'octets et que celui-ci

29. * contient bien la signature PNG.

30. *

31. * @param data

32. *         référence potentielle vers un tableau d'octets

33. * @return

34. *         true si la signature PNG est trouvée, false dans le cas contraire

35. */

36. public static boolean isPngData(Object data)

37. {

38. return (data instanceof byte[] && ValidationUtils.isPngData((byte[]) data));

39. }

40. }

Ces méthodes seront appelées, voire implémentées au moyen d'une expression lambda ou désignées par une référence de méthode, au moment opportun.

II-B - Messages d'erreurs

Tous les messages seront conservés sous forme de constantes. Je suis un peu fainéant sur ce coup, il me faudrait faire un bundle i18n… mais ce n'est pas trop l'objet de cet article.

En espérant que Mickaël Baron dans l'oreillette ne me dise pas que j'aurais vraiment dû le faire :-)

1. package fr.fxjavadevblog.resources;

3. /**

4. * contient les chaînes de formatage des messages.

5. *

6. * @author fxjavadevblog

7. *

8. */

9. public final class PreconditionsMessages

10. {

11. public static final String MSG_NOT_NULL = "L'argument %s ne peut pas être null";

12. public static final String MSG_MAJUSCULES = "L'argument %s doit être écrit en majuscules";

13. public static final String MSG_IMAGE_PNG = "L'argument %s doit être  une image PNG valide";

14. public static final String MSG_AGE_ENTRE = "L'argument %s doit être  %d et %d";

16. private PreconditionsMessages()

17. {

18. // protection

19. }

20. }

Quelques règles « métier » seront déportées, accessibles via la classe PreconditionsRules ci-dessous :

1. package fr.fxjavadevblog.resources;

3. /**

4. * contient constantes globales utilisées pour valider

5. * les données en arguments de méthodes.

6. *

7. * @author fxjavadevblog

8. *

9. */

10. public final class PreconditionsRules

11. {

12. public static final int AGE_MIN = 0;

13. public static final int AGE_MAX = 150;

14. public static final String REGEXP_MAJUSCULES = "[A-Z]*";

16. private PreconditionsRules()

17. {

18. // protection

19. }

20. }

Et enfin, comme on voudra désigner une exception par référence de méthode (vers un constructeur), il nous en faut une de type RuntimeException :

1. import static fr.fxjavadevblog.resources.PreconditionsMessages.MSG_RANGE_PATTERN;

2. import static fr.fxjavadevblog.resources.PreconditionsRules.AGE_MAX;

3. import static fr.fxjavadevblog.resources.PreconditionsRules.AGE_MIN;

6. @SuppressWarnings("serial")

7. public class AgeException extends RuntimeException

8. {

9. public AgeException(String argumentName, Integer argumentValue)

10. {

11. super(String.format(MSG_RANGE_PATTERN, argumentName , argumentValue, AGE_MIN, AGE_MAX));

12. }

13. }

III - La manière classique en Java

On commence donc par la version « Java Classique » :

1. public static void executeOldSchoolJava(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. if (name == null)

4. {

5. throw new IllegalArgumentException(String.format(MSG_NOT_NULL, "name"));

6. }

8. if (!pattern.matcher(name).matches())

9. {

10. throw new IllegalArgumentException(String.format(MSG_UPPER_CASE, "name"));

11. }

13. if (age == null)

14. {

15. throw new IllegalArgumentException(String.format(MSG_NOT_NULL, "age"));

16. }

18. if (age <= AGE_MIN && age >= AGE_MAX)

19. {

20. throw new IllegalArgumentException(String.format(MSG_RANGE_PATTERN, "age", AGE_MIN, AGE_MAX));

21. }

23. if (photo == null)

24. {

25. throw new IllegalArgumentException(String.format(MSG_NOT_NULL, "photo"));

26. }

28. if (!ValidationUtils.isPngData(photo))

29. {

30. throw new IllegalArgumentException(String.format(MSG_NOT_PNG_IMAGE, "photo"));

31. }

33. if (competences == null || competences.isEmpty())

34. {

35. throw new IllegalArgumentException(String.format(MSG_NOT_EMPTY_COLLECTION, "competences"));

36. }

38. // log.info("Toutes les préconditions sont passées");

39. }

On est d'accord, c'est long et ennuyant… voire carrément lourdingue, pour ne pas dire pire.

Qu'existe-t-il pour nous faciliter tout cela ?

IV - Solutions

IV-A - Java assert

Soyons succinct : cette technique est déconseillée. Elle ne peut être utilisée que pour une phase expérimentale et requiert un paramètre de JVM au lancement de l'application pour être prise en compte.

De plus, cette solution est assez limitée. On oublie : chaînes de formatage String.format, lazy instanciation avec lambda, etc.

J'en parle parce qu'il le faut, mais volontairement je ne donnerai pas d'exemple.

IV-B - Apache Commons Lang

C'est à mon sens LA bibliothèque la plus fournie pour les tests des arguments. Elle existe depuis 2003, avec sa classe Validate.

Néanmoins, conçue avant Java 8, elle n'offre aucune intégration de lambdas ni de références de méthodes. Elle permet la concaténation a posteriori de style String.format ou printf, ce qui est une bonne optimisation.

Pour l'utiliser, il suffit de déclarer la dépendance MAVEN suivante :

1. <dependency>

2. <groupId>org.apache.commons</groupId>

3. <artifactId>commons-lang3</artifactId>

4. <version>3.7</version>

5. </dependency>

Exemple :

1. public static void executeApacheCommonsLang(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. Validate.notNull(name, MSG_NOT_NULL, "name");

4. Validate.matchesPattern(name, REGEXP_MAJUSCULES, MSG_UPPER_CASE, "name");

5. Validate.notNull(age, MSG_NOT_NULL, "age");

6. Validate.inclusiveBetween(AGE_MIN, AGE_MAX, age.intValue());

7. Validate.notNull(photo, MSG_NOT_NULL, "photo");

8. Validate.isTrue(ValidationUtils.isPngData(photo), MSG_NOT_PNG_IMAGE, "photo");

9. Validate.notNull(competences, MSG_NOT_NULL, "competences");

10. Validate.notEmpty(competences);

12. // ici, toutes les vérifications sont passées.

13. }

IV-C - Guava

La classe Preconditions de Guava existe depuis 2010. Historiquement elle faisait partie de leur ancien projet Google Collection Library datant de 2009.

Cette solution est très certainement la plus proche de ce qui me serait utile, mais elle ne dispose pas de support de lambda et donc d'instanciation lazy des exceptions à lever, par exemple.

Elle permet toutefois d'éviter la concaténation directe des chaînes au moyen de chaînes de formatage et d'arguments, comme l'offre String.format ou printf.

Pour l'utiliser, il suffit de déclarer la dépendance MAVEN suivante :

1. <dependency>

2. <groupId>com.google.guava</groupId>

3. <artifactId>guava</artifactId>

4. <version>25.0-jre</version>

5. </dependency>

Exemple :

1. public static void executeGuava(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. Preconditions.checkNotNull(name, MSG_NOT_NULL, "name");

4. Preconditions.checkNotNull(age, MSG_NOT_NULL, "age");

5. Preconditions.checkNotNull(photo, MSG_NOT_NULL, "photo");

6. Preconditions.checkArgument(pattern.matcher(name).matches(), REGEXP_MAJUSCULES, MSG_UPPER_CASE, "name");

7. Preconditions.checkArgument(age >= AGE_MIN && age <= AGE_MAX, MSG_RANGE_PATTERN, "age", age, AGE_MIN, AGE_MAX);

8. Preconditions.checkArgument(ValidationUtils.isPngData(photo), MSG_NOT_PNG_IMAGE, "photo");

9. Preconditions.checkNotNull(competences, MSG_NOT_NULL, "competences");

10. Preconditions.checkArgument(competences.size() > 0, MSG_NOT_EMPTY_COLLECTION, "competences");

11. }

IV-D - Spring

Spring offre de quoi valider les arguments aussi depuis la version 1.0 de 2004 !

Depuis la version 5, chaque méthode de vérification se voit surchargée avec la capacité de fournir un Supplier<String>, donc une lambda qui fournira une chaîne seulement si le test de validation échoue. C'est ce comportement qui est le plus optimisé et que je recherche, mais il ne prend pas de référence vers des constructeurs d'Exception.

De plus, Spring 5 ne propose pas le formatage String.format ou printf. Dommage !

Pour l'utiliser, il suffit de déclarer la dépendance MAVEN suivante :

1. <dependency>

2. <groupId>org.springframework</groupId>

3. <artifactId>spring-core</artifactId>

4. <version>5.0.5.RELEASE</version>

5. </dependency>

Exemple :

1. public static void executeSpringFramework(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. Assert.notNull(name, () -> String.format(MSG_NOT_NULL, "name"));

4. Assert.notNull(age, () -> String.format(MSG_NOT_NULL, "age"));

5. Assert.notNull(photo, () -> String.format(MSG_NOT_NULL, "photo"));

6. Assert.notNull(competences, () -> String.format(MSG_NOT_NULL, "competences"));

7. Assert.isTrue(pattern.matcher(name).matches(), () -> String.format(MSG_UPPER_CASE, "name"));

8. Assert.isTrue(age >= AGE_MIN && age <= AGE_MAX, () -> String.format(MSG_RANGE_PATTERN, "age", age, AGE_MIN, AGE_MAX));

9. Assert.isTrue(ValidationUtils.isPngData(photo), () -> String.format(MSG_NOT_PNG_IMAGE, "photo"));

10. Assert.notEmpty(competences, () -> String.format(MSG_NOT_EMPTY_COLLECTION, "competences"));

11. }

Clairement, bien qu'il y ait la possibilité de désigner des lambdas, c'est à mon sens la moins agréable à utiliser.

IV-E - Better Preconditions

Petite coquette « bibliothèquounette » très intéressante sur son approche au moyen d'une API fluent que j'ai pu dénicher au hasard de mes recherches sur GitHub.

Il manque cependant des choses primordiales comme la validation avec une regexp ou simplement un test booléen.

L'API n'exploite pas Java 8 et utilise Joda Time pour les dates, par exemple.

1. <dependency>

2. <groupId>com.github.choonchernlim</groupId>

3. <artifactId>better-preconditions</artifactId>

4. <version>0.1.1</version>

5. </dependency>

Exemple :

1. public static void executeBetterPreconditions(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. PreconditionFactory.expect(name).not().toBeNull().check();

4. PreconditionFactory.expect(age).not().toBeNull().toBeEqualOrGreaterThan(AGE_MIN).toBeEqualOrLessThan(AGE_MAX).check();

5. PreconditionFactory.expect(photo).not().toBeNull().check();

6. PreconditionFactory.expect(competences).not().toBeEmpty();

7. // TODO : créer des custom matcher pour les actions plus "particulières".

8. }

En fait, si j'avais un peu de temps à consacrer à un projet sympa, je pense que je le forkerais pour en faire une version Java 8 avec support de lambda.

Niveau performances, rien de notable, aucune dégradation constatée même après un tir de 10 000 tests.

À noter qu'il faut implémenter une interface Matcher<V> pour les tests plus élaborés. Par exemple en l'état, pas évident de tester le nom avec une expression régulière ni le contenu du tableau d'octets.

IV-F - Java 8 Objects

Enfin, Java 8 arrive avec sa classe Objects avec quelques méthodes pour valider.

Extrait de la JAVADOC de la classe Objects et de sa méthode requireNonNull :

1. public Foo(Bar bar, Baz baz) {

2. this.bar = Objects.requireNonNull(bar, "bar must not be null");

3. this.baz = Objects.requireNonNull(baz, "baz must not be null");

4. }

Intéressant, mais beaucoup trop limité, bien qu'elle puisse prendre un Supplier<String> comme message. On utilise en fait la classe Objects plutôt dans des filter(Objects::nonNull) dans l'API Stream de JAVA 8.

Donc, cela ne répond pas au besoin en l'état.

IV-G - Préconditions « Faites maison »

Le « fait maison », sauf au restaurant, en général j'évite : je fais une entorse à ce principe quand je ne trouve vraiment rien qui me convienne et c'est malheureusement le cas avec toutes les solutions décrites ci-dessus.

En effet, ces librairies n'exploitent pas la puissance des expressions lambdas et notamment des références de méthodes et constructeurs, ou quand elles le font, elles ne le permettent pas au bon endroit.

Impossible par exemple de désigner le constructeur d'une exception pour qu'elle soit instanciée a posteriori, ou encore de produire une chaîne avec du formatage type printf ou String.format, ou encore, et c'est le plus important de déclencher a posteriori les expressions booléennes ou des prédicats.

Il faut donc un mix entre toutes ces solutions : notre propre « bibliothèque ».

bibliothèque est un bien grand mot, car cela va se résumer à une seule classe !

Donc c'est parti, j'ai décidé de ne pas en faire une API fluent, malgré l'envie, pour des raisons de performances supposées : je souhaite éviter l'instanciation d'objets pendant cette phase pour ne pas surcharger le Garbage Collector.

Grosso modo, cela va ressembler à Apache Commons Lang Validation avec ce qui lui manque de prise en compte des lambdas.

Dans un premier temps, la solution permettra de faire les tests suivants, en liaison avec mon besoin immédiat :

- non nullité d'un argument ;

- une plage de valeurs d'entiers ;

- matching d'une expression régulière ;

- test booléen standard et par Supplier ainsi que par Predicate ;

- désignation par référence et déclenchement d'une exception ;

- collection non vide.

Usage :

1. public static void executeHomeMadePreconditions(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. Checker.notNull(name, MSG_NOT_NULL, "name");

4. Checker.notNull(age, MSG_NOT_NULL, "age");

5. Checker.notNull(photo, MSG_NOT_NULL, "photo");

6. Checker.respects(name, pattern, MSG_UPPER_CASE, "name");

7. Checker.inRange(age, AGE_MIN, AGE_MAX, AgeException::new, "age");

8. Checker.respects(photo, ValidationUtils::isPngData, MSG_NOT_PNG_IMAGE, "photo");

9. Checker.notEmpty(competences, MSG_NOT_EMPTY_COLLECTION, "competences");

10. }

Voici le code source de la classe Checker (seule et unique classe) utilisée ci-dessus :

1. package fr.fxjavadevblog.preconditions;

3. import java.util.Collection;

4. import java.util.Map;

5. import java.util.Map.Entry;

6. import java.util.function.BiFunction;

7. import java.util.function.BooleanSupplier;

8. import java.util.function.Function;

9. import java.util.function.Predicate;

10. import java.util.regex.Pattern;

12. /**

13. * cette classe est responsable du test de validité d'arguments en fonction de

14. * contraintes.

15. *

16. * @author fxjavadevblog

17. *

18. */

19. public final class Checker

20. {

21. /**

22. * vérifie que la référence n'est pas nulle. Si elle l'est, une exception de

23. * type "IllegalArgumentException" est levée avec le message (format et

24. * arguments) passé en paramètres.

25. *

26. * @param arg

27. *            référence à tester

28. * @param format

29. *            chaîne de formatage

30. * @param vals

31. *            valeurs à injecter dans la chaîne de formatage

32. *

33. * @see String#format(String, Object...)

34. * @see IllegalArgumentException

35. */

36. public static void notNull(Object arg, String format, Object... vals)

37. {

38. if (arg == null)

39. {

40. throw new IllegalArgumentException(String.format(format, vals));

41. }

42. }

44. /**

45. * vérifie que la référence n'est pas nulle. Si elle l'est, le supplier

46. * d'IllegalArgumentException est invoqué, et le message est fourni à la

47. * construction de l'exception. Enfin, l'exception est levée.

48. *

49. * @param arg

50. *            référence à tester

51. * @param exceptionSupplier

52. *            supplier d'IllegalArgumentException

53. * @param argName

54. *            message à fournir à l'exception

55. *

56. * @see IllegalArgumentException

57. */

58. public static void notNull(Object arg, Function<String, IllegalArgumentException> exceptionSupplier, String argName)

59. {

60. if (arg == null)

61. {

62. throw exceptionSupplier.apply(argName);

63. }

64. }

67. /**

68. * vérifie que l'argument se situe bien sans une plage de valeurs Integer.

69. *

70. * @param arg

71. *          argumenter à tester

72. * @param min

73. *         valeur minimale incluse

74. * @param max

75. *         valeur maximale incluse

76. * @param msgRangePattern

77. *      format message en cas d'erreur (voir String.format)

78. * @param argName

79. *          nom de l'argument testé

80. */

81. public static void inRange(Integer arg, int min, int max, String msgRangePattern, String argName)

82. {

83. if (arg == null || arg < min || arg > max)

84. {

85. throw new IllegalArgumentException(String.format(msgRangePattern, argName, arg, min, max));

86. }

87. }

89. /**

90. * vérifie que l'argument se situe bien sans une plage de valeurs Integer.

91. * Cette méthode permet de désigner une exception, pour la démo (à compléter dans les autres méthodes).

92. *

93. * @param arg

94. *          argumenter à tester

95. * @param min

96. *         valeur minimale incluse

97. * @param max

98. *         valeur maximale incluse

99. * @param function

100. *         fonction lambda prenant une chaîne de caractères et un entier

101. *         et retournant une instance de RuntimeException. Permet de désigner une exception

102. *         notamment avec l'un de ses constructeurs.

103. * @param argumentName

104. */

105. public static void inRange(Integer arg,

106. int min,

107. int max,

108. BiFunction<String, Integer, ? extends RuntimeException> function,

109. String argumentName)

110. {

111. if (arg == null || arg < min || arg > max)

112. {

113. throw function.apply(argumentName, arg);

114. }

115. }

117. /**

118. * vérifie qu'une collection n'est ni nulle, ni vide.

119. *

120. * @param competences

121. *            collection à tester

122. * @param format

123. *            format message en cas d'erreur (voir String.format)

124. * @param vals

125. *            valeurs à injecter dans le format de message

126. *

127. * @see String#format(String, Object...)

128. * @see IllegalArgumentException

129. */

130. public static void notEmpty(Collection<?> competences, String format, Object... vals)

131. {

132. if (competences == null || competences.isEmpty())

133. {

134. throw new IllegalArgumentException(String.format(format, vals));

135. }

136. }

138. /**

139. * vérifie qu'une chaîne de caractères respecte bien une expression

140. * régulière définie dans un Pattern.

141. *

142. * @param data

143. *            chaîne à tester

144. * @param pattern

145. *            expression régulière

146. * @param format

147. *            format message en cas d'erreur (voir String.format)

148. * @param vals

149. *            valeurs à injecter dans le format de message

150. *

151. * @see String#format(String, Object...)

152. * @see IllegalArgumentException

153. */

155. public static void respects(String data, Pattern pattern, String format, Object... vals)

156. {

157. if (!pattern.matcher(data).matches())

158. {

159. throw new IllegalArgumentException(String.format(format, vals));

160. }

161. }

163. /**

164. * vérifie que l'expression booléenne "predicate" est bien à true, sinon une

165. * exception "IllegalException" est levée avec le message fourni.

166. *

167. * @param predicate

168. *            expression booléenne.

169. * @param format

170. *            format message en cas d'erreur (voir String.format)

171. * @param vals

172. *            valeurs à injecter dans le format de message

173. *

174. * @see String#format(String, Object...)

175. * @see IllegalArgumentException

176. */

177. public static void respects(boolean predicate, String format, Object... vals)

178. {

179. if (!predicate)

180. {

181. throw new IllegalArgumentException(String.format(format, vals));

182. }

183. }

185. /**

186. * vérifie que l'expression booléenne évaluée a posteriori par le

187. * BooleanSupplier fourni en paramètre retourne bien à true, sinon une

188. * exception "IllegalException" est levée avec le message fourni.

189. *

190. * @param predicate

191. *            supplier d'expression booléenne.

192. * @param format

193. *            format message en cas d'erreur (voir String.format)

194. * @param vals

195. *            valeurs à injecter dans le format de message

196. *

197. * @see String#format(String, Object...)

198. * @see IllegalArgumentException

199. */

200. public static void respects(BooleanSupplier predicate, String format, Object... vals)

201. {

202. if (!predicate.getAsBoolean())

203. {

204. throw new IllegalArgumentException(String.format(format, vals));

205. }

206. }

208. /**

209. * vérifie que la référence, confrontée au prédicat, retourne true, sinon

210. * une exception "IllegalException" est levée avec le message fourni.

211. *

212. * @param predicate

213. *            prédicat prenant un type T et qui contient la logique "true"

214. *            ou "false".

215. * @param format

216. *            format message en cas d'erreur (voir String.format)

217. * @param vals

218. *            valeurs à injecter dans le format de message

219. *

220. * @see String#format(String, Object...)

221. * @see IllegalArgumentException

222. */

223. public static <T> void respects(T t, Predicate<T> predicate, String format, Object... vals)

224. {

225. if (!predicate.test(t))

226. {

227. throw new IllegalArgumentException(String.format(format, vals));

228. }

229. }

231. /**

232. * vérifie que la référence, confrontée au prédicat, retourne true, sinon

233. * une exception "IllegalException" est levée avec le message fourni.

234. *

235. * @param predicate

236. *            prédicat prenant un type T et qui contient la logique "true"

237. *            ou "false"

238. * @param function

239. *             fonction lambda prenant le nom de l'argument testé et un T

240. *             qui retourne une instance de RuntimeException. Permet de désigner une exception

241. *             notamment avec l'un de ses constructeurs.

242. *

243. * @param argumentName

244. *             nom de l'argument testé

245. *

246. *    @see IllegalArgumentException

247. */

249. public static <T> void respects(T t, Predicate<T> predicate,  BiFunction<String, T, ? extends RuntimeException> function, String argumentName)

250. {

251. if (!predicate.test(t))

252. {

253. throw function.apply(argumentName, t);

254. }

255. }

257. /**

258. * vérifie qu'aucune valeur de la Map ne fasse référence à "null", sinon une

259. * exception de type IllegalArgumentException est levée avec le message

260. * fourni.

261. *

262. * Le message devra prendre forcément un "%s" en entrée pour faire référence

263. * à la clé de la Map dont la valeur associée est nulle.

264. *

265. * @param arguments

266. *            map à tester

267. *

268. * @param format

269. *            chaîne de formatage du message.

270. *

271. */

272. public static void notAnyNullValue(Map<?, ?> arguments, String format)

273. {

274. for (Entry<?, ?> e : arguments.entrySet())

275. {

276. if (e.getValue() == null)

277. {

278. throw new IllegalArgumentException(String.format(format, e.getKey()));

279. }

280. }

281. }

283. }

Bien sûr, ici il ne s'agit que d'un embryon du début d'un commencement de prémices de classe utilitaire. Mais cela donne une bonne idée du cadre qui est offert et, en l'état, offre déjà beaucoup de souplesse, notamment grâce au Predicate<T>.

Je vais commenter toutefois l'une des méthodes de cette classe qui est, à mon sens, la plus intéressante :

1. public static <T> void respects(T t,

2. Predicate<T> predicate,

3. BiFunction<String, T, ? extends RuntimeException> function,

4. String argumentName)

5. {

6. if (!predicate.test(t))

7. {

8. throw function.apply(argumentName, t);

9. }

10. }

De prime abord, cette méthode attend pas mal de choses pour fonctionner : elle devrait elle-même contrôler ses arguments… mais je ne le fais pas pour être un peu plus concis dans cet article déjà bien long.

En regardant la signature de la méthode, voici quelques éléments de compréhension :

- <T> : type générique qui représentera le type de l'argument testé, et par conséquent t est la valeur de l'argument.

- Predicate<T> predicate interface fonctionnelle capable de tester un <T> et le fait que sa valeur soit correcte. Un Predicate retourne toujours true ou false.

- BiFunction<String, T, ? extends RuntimeException> function : cela peut paraître complexe, mais permet de désigner une interface fonctionnelle prenant un String (le nom de l'argument) et un T (la valeur de l'argument) et retournant une instance d'une classe héritant directement ou indirectement de RuntimeException. Cela permettra de désigner AgeException::new par exemple, car son constructeur est le suivant pour mémoire : public AgeException(String, Integer).

IV-H - Bean Validation

Petit détour avec une API de « haut niveau » mais trop peu utilisée à mon goût.

Ambiance ring de boxe : Et voici Beeeeeaaaan Vaaaaalidationnnnnn Image : Panza Kick Boxing © Loriciels 1990 - Futura, Pascal Jarry, Marco de Flores.

Bean Validation permet de placer des annotations de validation de valeur sur des attributs ou des arguments. C'est une spécification extensible dont l'implémentation de référence est Hibernate Validator.

J'adore cette spec. Elle est vraiment très puissante. Elle s'intègre parfaitement à un ensemble conséquent d'autres specs de Java EE, comme notamment JPA, JSF, EJB, CDI. De plus elle est extensible aussi bien par annotation que par programmation… Si vous ne connaissez pas Bean Validation, je vous recommande de jeter un coup d'œil rapide sachant qu'on peut l'utiliser aussi en Java SE.

Pour l'utiliser :

1. <dependency>

2. <groupId>org.hibernate.validator</groupId>

3. <artifactId>hibernate-validator</artifactId>

4. <version>6.0.9.Final</version>

5. </dependency>

L'idée est d'encapsuler les arguments dans une classe spécifique et de la confronter en validation avant usage.

Voici ce qui va permettre de déclencher Bean Validation :

1. /**

2. * permet de déclencher une validation via BeanValidation.

3. *

4. * @author fxjavadevblog

5. *

6. */

7. public class BeanValidationChecker

8. {

9. // instanciation en EAGER d'un validator BeanValidation.

10. private static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

12. /**

13. * lance une validation sur l'objet data, potentiellement annoté avec des

14. * contraintes BeanValidation.

15. *

16. * Les contraintes violées sont agrégées au sein d'une et une seule

17. * "IllegalArgumentException".

18. *

19. * @param data

20. *            instance à tester.

21. */

22. public static <T> void check(T data)

23. {

24. Set<ConstraintViolation<T>> violations = validator.validate(data);

25. if (!violations.isEmpty())

26. {

27. StringBuilder sb = new StringBuilder();

28. violations.forEach(v -> sb.append(v.getMessage()).append("\n"));

29. throw new IllegalArgumentException(sb.toString());

30. }

31. }

33. }

Ensuite voici la classe qui encapsulera les arguments (utilisation de Lombok ici, juste pour plus de concision) :

1. @Getter

2. @AllArgsConstructor

3. protected static class InputData

4. {

5. @NotNull

6. @NotEmpty

7. @Pattern(regexp = PreconditionsMessages.REGEXP_MAJUSCULES)

8. private String name;

10. @NotNull

11. @Min(0)

12. @Max(150)

13. private Integer age;

15. @NotNull

16. @PngData

17. private byte[] photo;

19. @NotNull

20. @NotEmpty

21. private Collection<String> competences;

22. }

Voici la création de l'annotation « @PngData » :

1. package fr.fxjavadevblog.demo;

3. import java.lang.annotation.Documented;

4. import java.lang.annotation.ElementType;

5. import java.lang.annotation.Retention;

6. import java.lang.annotation.RetentionPolicy;

7. import java.lang.annotation.Target;

9. import javax.validation.Constraint;

10. import javax.validation.Payload;

12. @Constraint(validatedBy = { PngDataValidator.class })

13. @Target({ ElementType.FIELD, ElementType.PARAMETER })

14. @Retention(value = RetentionPolicy.RUNTIME)

15. @Documented

16. public @interface PngData

17. {

18. String message() default "Invalid PNG data";

20. Class<?>[] groups() default {};

22. Class<? extends Payload>[] payload() default {};

23. }

Et voici son ConstraintValidator associé :

1. package fr.fxjavadevblog.demo;

3. import javax.validation.ConstraintValidator;

4. import javax.validation.ConstraintValidatorContext;

6. public class PngDataValidator implements ConstraintValidator<PngData, Object>

7. {

8. @Override

9. public boolean isValid(Object arg, ConstraintValidatorContext arg1)

10. {

11. if (arg instanceof byte[])

12. {

13. byte[] data = (byte[]) arg;

14. return ValidationUtils.isPngData(data);

15. }

16. else

17. {

18. return false;

19. }

20. }

21. }

et enfin l'usage :

1. public static void executeBeanValidation(String name, Integer age, byte[] photo, Collection<String> competences)

2. {

3. InputData input = new InputData(name, age, photo);

4. BeanValidationChecker.check(input);

6. // c'est court ! Mais ça fonctionne parfaitement

7. }

V - REST : JAX-RS et Bean Validation

On pose le décor : JAX-RS est une spécification, incluse dans Java EE, capable de motoriser simplement une API que l'on souhaite exposer sous forme REST.

Soyons succinct : JAX-RS est capable déclencher des annotations Bean Validation !

Trop classe !

On passe à la démo avec d'abord un extrait du POM :

1. ...

3. <packaging>war</packaging>

5. <properties>

6. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

7. <maven.compiler.target>1.8</maven.compiler.target>

8. <maven.compiler.source>1.8</maven.compiler.source>

9. <failOnMissingWebXml>false</failOnMissingWebXml>

10. </properties>

12. <dependencies>

13. <dependency>

14. <groupId>javax</groupId>

15. <artifactId>javaee-api</artifactId>

16. <version>8.0</version>

17. <scope>provided</scope>

18. </dependency>

19. <dependency>

20. <groupId>org.projectlombok</groupId>

21. <artifactId>lombok</artifactId>

22. <version>1.16.20</version>

23. </dependency>

24. ...

25. </dependencies>

On dépend donc de Java EE 8 qui sera motorisé dans mon cas par OpenLiberty, mais cela n'a pas d'incidence sur le développement.

On déclare le endpoint global JAX-RS :

1. @ApplicationPath("/bat-api")

2. public class BatCaveSystem extends Application

3. {

4. // vide ! et oui, rien à faire de spécial ici.

5. }

Comme je vais fournir une API REST sur le backend de SI de Bruce au manoir Wayne, je nomme donc l'url /bat-api. Bruce souhaite pouvoir envoyer un nom et un âge, et que les informations en entrée soient validées. C'est un peu idiot, mais comme c'est Bruce Wayne, eh bien on obéit…

V-A - JAX-RS et Checker « fait maison »

Pour des raisons de concision, je restreins le champ d'études à deux types d'arguments : le nom et l'âge.

Le contrôle des entrées est réalisé avec le Checker présenté précédemment :

1. @Path("/directory")

2. public class BatCaveDirectory

3. {

4. private static java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("[A-Z]*");

6. @POST

7. @Path("/add")

8. public Response add(String name, Integer age)

9. {

10. Checker.notNull(name, MSG_NOT_NULL, "name");

11. Checker.notNull(age, MSG_NOT_NULL, "age");

12. Checker.respects(name, pattern, MSG_UPPER_CASE, "name");

13. Checker.inRange(age, AGE_MIN, AGE_MAX, AgeException::new, "age");

15. System.out.printf("Name : %s %n", name); // bouhh les sysout moches !

16. System.out.printf("Age : %d %n", age);   // un peu fainéant, je l'avoue !

18. return Response.ok(LocalDateTime.now().toString()).build();

19. }

20. }

V-B - JAX-RS et Bean Validation

Voici donc le moyen de contrôler tout cela en JAX-RS avec Bean Validation :

1. @Path("/directory")

2. public class BatCaveDirectory

3. {

4. @POST

5. @Path("/add")

6. public Response add(@FormParam(value = "name") String name,

7. @FormParam(value = "age") @Min(0) @Max(150) Integer age)

8. {

9. System.out.printf("Name : %s %n", name);

10. System.out.printf("Age : %d %n", age);

12. return Response.ok(LocalDateTime.now().toString()).build();

13. }

14. }

Afin d'avoir un retour « lisible » en cas d'erreur, il faut coder un ExceptionManager :

1. package fr.fxjavadevblog.demo;

3. import java.util.List;

4. import java.util.stream.Collectors;

6. import javax.validation.ConstraintViolation;

7. import javax.validation.ConstraintViolationException;

8. import javax.ws.rs.core.MediaType;

9. import javax.ws.rs.core.Response;

10. import javax.ws.rs.ext.ExceptionMapper;

11. import javax.ws.rs.ext.Provider;

13. import lombok.Data;

14. import lombok.Builder;

16. /**

17. * gestionnaire d'exception spécifique pour JAX-RS.

18. *

19. * source : https://stackoverflow.com/questions/44308101/customizing-jax-rs-response-when-a-constraintviolationexception-is-thrown-by-bea

20. *

21. */

24. @Provider

25. public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException>

26. {

27. // classe interne qui détient les erreurs de validation, usage de Lombok au passage

28. @Data

29. @Builder

30. public static class ValidationError

31. {

32. private String path;

33. private String message;

34. }

36. // transforme l'ensemble des contraintes violées en une liste de ValidationError

37. // retournée sous forme JSON

38. @Override

39. public Response toResponse(ConstraintViolationException exception)

40. {

41. List<ValidationError> errors = exception.getConstraintViolations().stream().map(this::toValidationError).collect(Collectors.toList());

42. return Response.status(Response.Status.BAD_REQUEST).entity(errors).type(MediaType.APPLICATION_JSON).build();

43. }

45. // transforme une contrainte de validation en une ValidationError

46. private ValidationError toValidationError(ConstraintViolation<?> constraintViolation)

47. {

48. return ValidationError.builder()

49. .path(constraintViolation.getPropertyPath().toString())

50. .message(constraintViolation.getMessage()).build();

51. }

52. }

On peut même faire en sorte que les données soient directement mappées et validées au sein d'une classe de type Value Object. Dans cet exemple, j'utilise une classe interne statique, encore un peu de Lombok et son annotation @Data qui, pour une fois, convient bien :

1. @Path("/directory")

2. public class BatCaveDirectory

3. {

4. @Data

5. public static class InputData

6. {

7. @FormParam(value = "name")

8. @NotNull

9. @NotEmpty

10. private String name;

12. @FormParam(value = "age")

13. @Min(0) @Max(150)

14. private Integer age;

15. }

17. @POST

18. @Path("/add")

19. public Response addEntity(@Valid @BeanParam InputData input)

20. {

21. return Response.ok(LocalDateTime.now().toString() + " : " + input).build();

22. }

23. }

Cela devient vraiment simple et puissant avec @Valid et @BeanParam ! On peut bien sûr envisager de faire sa propre contrainte BeanValidation…

Oulah, mais oui, dis donc ! C'est bien puissant !

Enfin, si les données ne sont pas valides, on obtient alors une erreur sérialisée en JSON :

1. [

2. {

3. "message":"doit être inférieur ou égal à 150",

4. "path":"addEntity.arg0.age"

5. },

6. {

7. "message":"doit correspondre à \"[A-Z]*\"",

8. "path":"addEntity.arg0.name"

9. }

10. ]

VI - Quelques réflexions supplémentaires

On voit dans les exemples ci-dessus que le nombre d'arguments peut être trop élevé : en général, j'encapsule cela dans une nouvelle classe, par exemple une classe static interne. L'avantage, c'est que cette classe pourra porter des annotations Bean Validation et donc être soumise à validation. Cependant en cas de très forte sollicitation, étant donné le nombre important d'objets temporaires créés uniquement pour encapsuler, il faudra faire attention à la consommation mémoire et au coût de passage du garbage collector.

Il m'arrive même souvent que ces classes soient aussi des classes JPA. Pas de mélange des genres selon moi, car tant qu'aucune instance n'est validée par Bean Validation, JPA ne la persiste pas et ne fait donc pas partie du contexte du persistence. C'est une sorte de DTO temporaire qui m'évite de redéfinir les champs : un bon développeur se doit d'être paresseux.

Dans tous les cas, je pense qu'il ne faut pas généraliser les tests de préconditions à toutes les classes d'une application Java. Il faut, à mon sens, se concentrer sur ce qui est offert en public par l'API, que ce soit localement ou à distance via services REST.

Au sujet des webservices, j'aimerais rappeler qu'avec JAX-WS (SOAP) ou JAX-RS (REST) les annotations Bean Validation sont prises en compte :

- lors de la génération des schémas XSD et contrat WSDL. En entrée d'un WS SOAP, avant l'invocation de la méthode Java, les arguments sont donc automatiquement validés ;

- lors de l'appel de la méthode dans le cas de REST (donc plus tardivement).

En espérant ne pas vous avoir effrayé avec ces tests de préconditions…

VII - Remerciements

Cet article a été publié avec l'aimable autorisation François-Xavier Robin.

Nous tenons à remercier f-leb pour sa relecture orthographique attentive de cet article et Mickael Baron pour la mise au gabarit.