Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Prochaines sessions inter-entreprises : 28-31 mars 2017 / 13-16 juin 2017
Sessions intra-entreprises sur demande.
Inscrivez-vous vite !

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...).


Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.