fév.
2017
Spring MVC : Développer un convertisseur personnalisé
La modélisation d'une API REST permettant de gérer une ressource naturellement arborescente pose un problème technique intéressant.
Pour l'exemple, imaginons une API permettant de lire les métadonnées (taille, date d'accès...) d'un fichier sur disque, identifié par son chemin.
Selon la logique RESTful, l'URI associée au fichier foo/bar/baz.txt
doit être http://<server:port>/file/foo/bar/baz.txt
, et le endpoint Spring MVC associé mappé comme ceci :
@GetMapping("/file/{path}") public HttpEntity<FileInfo> fileInfo(@PathVariable("path") String path) { return filesystemDao.getFileInfo(path); }
Problème : Spring MVC ne sait pas capturer les fragments d'URL (@PathVariable) contenant des "slash" , même encodés en %2F
.
En réfléchissant plus avant, on s'aperçoit que les "slash" ne sont pas les seuls caractères pouvant poser problème : les noms des répertoire et fichiers sont autorisés à contenir des caractères incompatibles avec les URLs. Il nous faut donc encoder tout le chemin, mais, à cause du bug indiqué plus haut, un simple "urlencode" ne suffit pas.
We need a bigger boat.
Encode all the things !
Un encodage qui élimine les caractères spéciaux et les slashes... et pourquoi pas Base64 ?
Le très problématique chemin foo/bar/baz.txt
devient alors http://<server:port>/file/L2Zvby9iYXIvYmF6LnR4dA
- plus de problèmes !
Côté client, Javascript fournit la méthode btoa
, employée ci-dessous dans un service Angular "2-mais-faut-plus-dire-2"[1] :
[javascript] getFileInfo(path: string): Observable<FileInfo> { // Cf. https://developer.mozilla.org/fr/docs/D%C3%A9coder_encoder_en_base64 const b64Path = btoa(unescape(encodeURIComponent(path))); return this.http .get(`${this.API}/file/${b64Path}`) .map(response => response.json()); }
Côté serveur, Java propose (enfin !) la classe java.util.Base64
. Modifions notre contrôleur en conséquence :
@GetMapping("/file/{path}") public HttpEntity<FileInfo> fileInfo(@PathVariable("path") String path) { byte[] decodedPathBytes = java.util.Base64.getUrlDecoder().decode(path); String decodedPath = new String(decodedPathBytes, StandardCharsets.UTF_8); return filesystemDao.getFileInfo(decodedPath); }
Industrialisation
Ce système fonctionne bien, mais le décodage manuel du chemin dans chaque contrôleur est pénible. Peut-on l'automatiser la conversion ?
Rappel sur les convertisseurs
Les utilisateurs de Spring ont depuis longtemps appris à faire confiance à sa "magie".
Spring MVC, en particulier, réalise automatiquement de nombreuses opérations de conversion entre différents éléments d'une requête HTTP, tous reçus au format texte (path, query params, headers...) et les paramètres d'entrée de nos contrôleurs, fortement typés. Pour cela, il s'appuie sur un ensemble de convertisseurs, déclarés dans un registre[2].
Spring propose deux types de convertisseurs, représentés par des interfaces :
Converter<S,T>
Le plus simple. Il convertit une valeur de type S en une valeur de type T, sans connaissance du contexte.GenericConverter
Ce type de convertisseur a accès au contexte de la conversion, et notamment aux variables source et destination - lui permettant, par exemple, d'inspecter leurs annotations.
Ces convertisseurs sont inconditionnels - ils sont appelés dès lors qu'une conversion correspondant à leur signature est requise - ils ne peuvent donc pas refuser la conversion.
Cela pose problème dans notre cas. Notre type de destination étant String
, type extrêmement commun, il serait appelé systématiquement pour chaque paramètre de type String
de chaque contrôleur...
Heureusement, une troisième interface, ConditionalConverter
, permet de définir un convertisseur comme conditionnel.
Un convertisseur Base64 personnalisé
Pour identifier les chaînes à décoder, appuyons-nous sur une annotation, judicieusement nommée @Base64
.
Faisons-la porter également les paramètres nécessaires au décodage : variante de Base64 (Basic/URL) et Charset
utilisé :
@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.PARAMETER}) public @interface Base64 { String charset() default "UTF-8"; Decoder decoder() default Decoder.BASIC; enum Decoder { BASIC, URL } }
Le convertisseur en lui-même n'est pas très compliqué :
public class Base64StringToStringConverter implements ConditionalGenericConverter { @Override public Set<ConvertiblePair> getConvertibleTypes() { // Conversion String -> String return Collections.singleton(new ConvertiblePair(String.class, String.class)); } @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { // Conversion uniquement si la variable cible est annotée @Base64 return targetType.hasAnnotation(Base64.class); } @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { Base64 annotation = targetType.getAnnotation(Base64.class); String value = (String) source; if (value == null) { return null; } else { try { // Décodage - même algorithme que précédemment, java.util.Base64.Decoder decoder = annotation.decoder() == Base64.Decoder.BASIC ? java.util.Base64.getDecoder() : java.util.Base64.getUrlDecoder(); byte[] bytes = decoder.decode(value); return new String(bytes, annotation.charset()); } catch (IllegalArgumentException | UnsupportedEncodingException e) { throw new IllegalStateException("Failed to base64-decode value " + value, e); } } } }
Déclaration et utilisation du convertisseur
Il ne nous reste plus qu'à enregistrer notre convertisseur auprès du registre de Spring.
La solution la plus simple consiste tout simplement à le définir comme un composant Spring (@Component
/ @Bean
) ; il est alors auto-détecté par le registre.
@Bean public Base64StringToStringConverter converter() { return new Base64StringToStringConverter(); }
Alternativement, il peut être déclaré explicitement auprès du registre via la méthode WebMvcConfigurerAdapter.addFormatters()
(ce qui n'est pas évident à deviner...) :
@Configuration public class WebConfiguration extends WebMvcConfigurerAdapter { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new Base64StringToStringConverter()); } }
Après quoi, il ne nous reste plus qu'à annoter le paramètre du contrôleur, et à laisser la magie de Spring opérer !
@GetMapping("/file/{path}") public HttpEntity<FileInfo> fileInfo(@PathVariable("path") @Base64 String path) { // Look ma, no manual conversion ! return filesystemDao.getFileInfo(decodedPath); }
Test unitaire
Pour prouver le bon fonctionnement de notre système, rien de tel qu'un test minimaliste.
Ecrivons un contrôleur qui reçoit une chaîne encodée, et renvoie la version décodée automatiquement par Spring :
@RestController public class Base64Controller { @GetMapping("/test/{base64}") public String decodeBase64(@PathVariable("base64") @Base64 String base64) { return base64; } }
Voici le test correspondant :
@RunWith(SpringRunner.class) @WebMvcTest(Base64Controller.class) public class Base64ControllerTest { @Autowired private MockMvc mvc; @Test public void controllerDecodesBase64() throws Exception { String message = "Hello World"; // Encodage du message Charset charset = StandardCharsets.UTF_8; byte[] bytes = Base64.getEncoder().encode(message.getBytes(charset)); String encoded = new String(bytes, charset); // Appel du contrôleur et vérification mvc.perform(get("/test/" + encoded)) .andExpect(status().is2xxSuccessful()) .andDo(r -> System.out.println("Message: " + r.getResponse().getContentAsString())) .andExpect(MockMvcResultMatchers.content().string(message)); } }
Lançons le test...
// Test vert + message dans la console : Message: Hello World
Test en conditions réelles
Tout le code présenté ici est disponible sur GitHub.
Je vous encourage à cloner l'application, la lancer, et appeler le endpoint REST en lui passant en paramètre un chemin vers un fichier de votre disque dur, préalablement encodé en Base64 (par exemple grâce à base64encode.org).
Le résultat normalement affiché sera de la forme suivante :
{ path: "/home/olivier/Bureau/post.html", name: "post.html", parent: "/home/olivier/Bureau", directory: false, lastModified: 1488244037000, contentType: "text/html" }
Conclusion
Nous avons abordé beaucoup de sujets dans cet article.
Le besoin de modéliser une API REST représentant une ressource arborescente a permis de mettre en lumière certains bugs gênants et malheureusement persistants de Spring MVC.
La solution proposée, consistant à encoder l'identifiant de la ressource en Base64, nous a donné une excellente occasion de découvrir ou réviser le fonctionnemnet des convertisseurs dans Spring, et d'en développer un personnalisé.
Notes
[1] Suis-je le seul à trouver cette polémique de nommage non seulement ridicule, mais en plus franchement contre-productive pour l'industrie ? (confusion au niveau des recruteurs, des CV, etc.)
[2] Pour les curieux, dans le cas d'une application web, le registre est de type org.springframework.format.support.DefaultFormattingConversionService
. Il "emprunte" un certain nombre de ses convertisseurs au org.springframework.core.convert.support.DefaultConversionService
, dont ceux pour les types scalaires (cf. addScalarConverters()
), pour les collections (cf. addCollectionConverters()
) et pour un certain nombre de classes intéressantes (Optional, celles de java.time...).