JEE Back-end

Spring MVC et Rest

Plan de la séance

Dans cette séance, nous allons couvrir plusieurs thèmes avancés de Spring MVC.

  • Validation des formulaires
  • Accès à la session HTTP
  • Ecriture d'une API Rest
  • Consommer des API depuis le back-end

Avant de commencer, nous allons revenir sur les beans que gère Spring.

Rappel

Source: Documentation de Spring MVC

Les beans gérés par Spring (1/2)

@SpringBootApplication
public class Application {
 
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
 
    @Bean
    public CommandLineRunner printAllBeans(ApplicationContext applicationContext) {
        return args -> {
            Arrays.stream(applicationContext.getBeanDefinitionNames())
            	.forEach(System.out::println);
        };
    }

}
  • ApplicationContext est le conteneur IoC,
  • il est injecté dans le bean printAllBeans (le @Autowire est implicite),
  • les beans du type CommandLineRunner sont exécutés au démarrage de l'application (après la création de tous les beans).

Les beans gérés par Spring (2/2)

Il y a 164 beans dans le conteneur IoC.

...
rectangleController
...
dispatcherServlet
...
mvcViewResolver
...

On retrouve notre controlleur, le servlet dispatcher, le viewResolver ...

Les beans injectés par Spring sont par défaut des singletons et correspondent à des composants logiciels.

Validation des formulaires

public record Rectangle( @Min(value=0,message="Width must be positive") int width,
                         @Min(value=0,message="Height must be positive") int height) {
    public int area() { return width*height; }
}    
@PostMapping("/rectanglevalidation")
public String rectangleFormProcess(@Valid @ModelAttribute Rectangle parameter,
                                   BindingResult bindingResult, Model model){
    if (bindingResult.hasErrors()){
        return "rectangle-validation";
    }
    return "rectangle-validation-result";
}

Les messages d'erreurs définis dans le schéma de validation sont accessibles depuis la vue en cas d'erreur de validation.

Validation des formulaires

<form action="#" th:action="@{/rectanglevalidation}" method="post" th:object="${rectangleAttribute}">
    <h2>Rectangle area calculator</h2>
    <div><label for="h">Height:</label>
        <input id="h" type="text" th:field="*{height}"> 
        <span  th:if="${#fields.hasErrors('height')}" th:errors="*{height}">Invalid height</span></div>

    <div><label for="w">Width:</label>
        <input type="text" id="w" th:field="*{width}"> 
        <span  th:if="${#fields.hasErrors('width')}" th:errors="*{width}">Invalid width</span></div>

    <div><button type="submit">Compute</button></div>
</form>	
  • Les valeurs de l'attribut rectangleAttribute du Model sont injectés dans le formulaire.
  • En cas d'erreur, le message défini dans le schéma de validation est récupéré.

Validation des formulaires (1/2)

Tout se passe comme prévu!

Validation des formulaires (2/2)

Le problème ici est qu'il y a une erreur au binding et pas à la validation.

Trois solutions

  • On peut faire la vérification sur le front-end en JS pour être user-friendly. Sur le back-end, on fait la vérification mais on lève simplement une exception dans le constructeur. Si l'utilisateur passe par le front-end, tout est okay. Si il ne passe pas par le front-end, il n'a pas de retour sur pourquoi le formulaire n'est pas valide mais ce n'est pas grave car il n'utilisait pas le front-end.
  • Mettre les champs de Rectangle en String et valider avec @Pattern que ce sont bien des chaînes représentant des entiers.
    Cela marche mais on perd l'abstraction.
  • On peut modifier les messages par défaut en cas de type mismatch au moment du binding.
    Plus lourd à mettre en oeuvre mais on garde l'abstraction.
    Pour cela, il faut lire des messages d'erreurs depuis un fichier.

Messages d'erreur dans un fichier (1/3)

On peut définir des messages dans un fichier messages.properties.

height.positive=Height must be a positive number.
width.positive=Width must be a positive number.
typeMismatch.rectangleAttribute.height=Heigh must be an integer number
typeMismatch.rectangleAttribute.width=Heigh must be an integer number

Pour les messages d'erreur au binding, on se base sur le nom de l'attribut dans le Model. Dans notre cas, c'est rectangleAttribut.

Les autres messages peuvent être utilisé dans les schémas de validation.

On peut alors facilement définir des messages dans plusieurs langues dans des fichiers messages_FR.properties, messages_ES.properties.

Messages d'erreur dans un fichier (2/3)

Il faut dire à Spring d'utiliser notre fichier de messages pour la validation.

@SpringBootApplication
public class Application {

   @Bean
    public MessageSource messageSource() {
         ReloadableResourceBundleMessageSource messageSource
            = new ReloadableResourceBundleMessageSource();

        messageSource.setBasename("classpath:messages");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
     }

    @Bean
    public LocalValidatorFactoryBean getValidator() {
        LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
        bean.setValidationMessageSource(messageSource());
        return bean;
    }

}

Messages d'erreur dans un fichier (3/3)

Bonus 1 : on peut utiliser nos messages pour la validation !

Bonus 2 : on peut traduire notre application sans toucher au code !

public class Rectangle {
    @Min(value=0,message="{height.positive}")
    private int width;
    @Min(value=0,message="{width.positive}")
    private int height;
    ...
}

Accéder à la session HTTP en Spring MVC

Nous allons voir 3 méthodes:

  • récupérer la HttpSession depuis la HTTPRequest,
  • jouer sur le scope du controlleur,
  • utiliser @AttributeSession.

Préparation

Pour illustrer, on reprend l'exercice sur l'application Rectangle du TD.

public class History {

    public record Computation(int width, int height, int area) {...}

    private final ArrayDeque<String> computations = new ArrayDeque<>();

    public void addComputation(Computation computation){...}
    public boolean isEmpty(){...}
    public String latest(){...}
    public List<Computation> pastComputations(){...}

}

Un concept = Un objet

Attention, l'implémentation du History

La vue

<form action="#" th:action="@{/rectanglesession}" method="post" th:object="${rectangleAttribute}">
...
</form>

<div th:if="${!history.isEmpty() and !#fields.hasErrors('${rectangleAttribute.*}')}">
<div>
    <h2>Result</h2>
    <p th:text="${history.latest}"/>
</div>
<div th:if="${!history.pastComputations.isEmpty()}">
<h2>Previous computations</h2>
<ul>
<li th:each="line : ${history.pastComputations}" th:text="${line}"/>
</ul>
</div>
</div>
  • Le Modèle doit contenir un attribut rectangleAttribut et un attribut history.
  • La vue ne changera pas quelque soit la gestion de la session (MVC).

Avec HttpSession

    @PostMapping("/rectanglesession1")
    public String form(@Valid @ModelAttribute("rectangleAttribute") Rectangle rectangle, 
                        BindingResult bindingResult, 
                        HttpSession session
                        Model model){
        var history = getOrCreateHistoryFromSession(session);
        model.addAttribute("history",history);
        if (bindingResult.hasErrors()){
            return "rectangle-session";
        }
        history.addComputation(new Computation(rectange.width(),rectangle.height(),rectangle.area());
        return "rectangle-session";

    }

Facile à mettre en oeuvre mais on gère explicitement la Map de HttpSession.

En jouant sur le scope du Controller (1/2)

Par défaut, il y a un unique controlleur pour tous les clients (Singleton). On peut demander Spring de créer un controlleur par session HTTP.

@Controller
@Scope(value = WebApplicationContext.SCOPE_SESSION)
public class RectangleSessionWithSessionScope {

    private final History history = new History();

    @GetMapping("/rectanglesession")
    public String rectangleForm(Model model){
        model.addAttribute("history",history);
        model.addAttribute("rectangleAttribute",new Rectangle(0,0));
        return "rectangle-session";
    }
}

Facile à mettre en oeuvre mais potentiellement couteux si le controlleur est complexe. De plus, pas moyen de partager des données de session avec un autre controlleur.
A éviter globalement.

En jouant sur le scope du Controller (2/2)

Mieux, on peut définir un bean History dont le scope est la session HTTP tout en gardant un seul controlleur.

@SpringBootApplication
public class Application {
   ...
   @Bean
   @Scope(value = WebApplicationContext.SCOPE_SESSION,proxyMode = ScopedProxyMode.TARGET_CLASS)
   public History beanHistory() {
       return new History();
   }
}
@Controller
public class RectangleSessionWithSessionScope {

    private final History history;

    public RectangleSessionWithSessionScope(History history){
        this.history = history;
    } 

...
}

Pourquoi faut-il passer par un proxy ?

Un peu plus difficile à mettre en oeuvre que HttpSession mais en gagne en abstraction au prix des indirections du proxy.

@SessionAttribute

Spring MVC permet de récupérer des attributs du Model dans la Session HTTP.

@Controller
@SessionAttributes("history")
public class RectangleSessionWithSessionAttribute {

    @ModelAttribute("history")
    public History history(){
        return new History();
    }

    ...

    @PostMapping("/rectanglesession")
    public String form(@Valid  @ModelAttribute("rectangleAttribute") Rectangle rectangle,
                       BindingResult bindingResult,
                       Model model,
                       @ModelAttribute("history") History history){
        ...
    }

}

Controlleur API Rest (1/2)

Dans un API Rest, les requêtes et les réponses sont toujours envoyées en HTTP mais les informations pertinentes sont systématiquement encodées en JSON.

Un controlleur Rest est annoté par @RestController et ne renvoie pas le nom d'une vue mais un objet Java qui sera transformé en JSON.

Spring MVC peut transformer le JSON en un objet Java (l'inverse) en se basant sur le nom des champs. On peut modifier la conversion avec des annotations.

public record Student {
    @JsonIgnore
    long uid,
    @JsonProperty("first_name")
    String firstName,
    @JsonProperty("last_name")    
    String lastName,
    ...
}

Controlleur API Rest (2/2)

@RestController
public class HelloRestController {

    private final Map<Long,Student> students = Map.of(...);

    @GetMapping("/students/{id}")
    public Student getStudent(@PathVariable("id") long id) {
        var student = students.get(id);
        if (student==null){
            throw new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "No student with id ("+id+")");
        } else {
            return student;
        }
    }
}

Spring utilise convertie automatiquement le type renvoyé en JSON.

Consommation d'une API Rest (1/2)

Il arrive souvent que le back-end doivent faire des requêtes à des API.

RestTemplate est deprecated. Il faut maintenant utiliser WebClient qui permet de faire des requêtes HTTP en bloquant ou en non-bloquant.

Consommation d'une API Rest (2/2)

Dans sa forme bloquante, l'utilisation de WebClient est simple:

WebClient webClient = WebClient.create();
Student student = webClient.get()
                           .uri("http://localhost:8080/students/1")
                           .retrieve()
                           .bodyToMono(Student.class)
                           .block();

Pour accéder à la puissance des appels non-bloquants, il faut quelques concepts supplémentaires.

Reactor

WebClient utilise le framework Reactor pour écrire des applications non-bloquantes.

  • Mono<T> : Publisher d'un T
  • Flux<T> : Publisher d'un flot de T

Creation d'un flux (1/2)

Mono<Student> student(int id){
    WebClient webClient = WebClient.create();
    return webClient.get()
                    .uri("http://localhost:8080/students/"+id)
                    .retrieve()
                    .bodyToMono(Student.class);
}

La méthode student(int id) renvoie la promesse d'un student.

Pour attendre que la promesse soit réalisée, on peut utiliser la méthode mono.block().

Creation d'un flux (2/2)

Grâce à Flux.merge, on peut fusionner plusieurs promesses de Student en un Flux<Student>.

    List<Mono<Student>> monos = List.of(1,2,3).stream().map(id -> getStudent(id)).toList();
    Flux<Student> flux = Flux.merge(monos);
    Stream<Student> students = flux.toStream();

La méthode flux.toStream() va exécuter le code des différentes Mono dans un pool de threads et combiner les résultat en Stream<Student> au fur et à mesure que les résultats arrivent.