Mise en place d'une API Rest avec Spring (1)

Les services WEB

Nous avons maintenant besoin de généraliser et d'améliorer l'hétérogénéité de nos solutions. Pour ce faire, nous allons introduire la notion de Services WEB (Web Services) qui consiste à utiliser des documents XML pour coder les requêtes et les réponses qui transitent entre le client et serveur via le protocole HTTP :

+------------------+         Requêtes         +------------------+
|                  |  ----->  ----->  ----->  |                  |
|      Client      |          HTTP            |     Serveur      |
|   (application)  |  <-----  <-----  <-----  |  (couche métier) |
+------------------+    Réponses Json/Xml     +------------------+

Ces WS peuvent exposer des données et/ou permettre aux clients de modifier les données métiers. Ces WS échangent souvent des documents XML sur la base du protocole Soap. Le protocole SOAP est géré dans JEE par l'API JAX-WS.

Les services REST

Depuis quelques années, les WS se démocratisent et se simplifient. Afin d'éviter le recours à XML, des auteurs ont suggéré d'utiliser toute la puissance du protocole HTTP pour coder les requêtes en utilisant à la fois :

  • la méthode HTTP (GET, POST, PUT, DELETE, ...) pour coder la nature de la requête,
  • les paramètres HTTP placés dans l'entête ou même dans l'URI pour préciser les arguments de la requête. Par exemple
    GET http://monservice.fr/ws/produits/A255/prix/euros
    
    pour obtenir le prix en euros du produit identifié par le code A255. Le résultat peut être codé en mode texte, en XML ou par le langage JSON. Nous venons de définir les services REST (representational state transfer). Ils sont gérés dans JEE par l'API JAX-RS.

Préalables

Travail à faire :
  • Reprenez le projet du TP précédent.
  • créez le package myboot.app3.model : pour les données.
  • créez le package myboot.app3.dao : pour les dépôts.
  • créez le package myboot.app3.web : pour les contrôleurs.
  • créez le package myboot.app3.test dans le répertoire test.

Un premier service WEB

Commencons par un service REST très simple :

package myboot.app3.web;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
public class HelloRestController {

    @GetMapping("/hello")
    public String hello() {
        return "Hello";
    }

    @GetMapping(value = "/list")
    public List<Integer> list() {
        return List.of(10, 20, 30);
    }

}
Travail à faire :
  • Testez ce service dans votre navigateur (Chrome et Firefox).
  • Dans Chrome, ajoutez l'extension Talend API Tester afin de tester plus facilement votre API.
  • Testez cette API avec le client HTTP curl :
    curl -X "GET" "http://localhost:8081/api/hello"
    

Tester une API REST en créant un client

Travail à faire :
  • Avec les RestTemplate, construisez un test unitaire pour ces deux points d'entrée.

Tester une API REST avec MockMvc

Nous ne sommes pas obligé de lancer le serveur pour tester une API et nous pouvons utiliser la classe MockMvc. Essayez l'exemple ci-dessous :

package myboot.app3.test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
public class TestHelloRestApiMockMvc {

    @Autowired
    private MockMvc mvc;

    @Test
    public void testHello() throws Exception {
        mvc.perform(get("/api/hello")).andDo(print())//
                .andExpect(status().isOk())//
                .andExpect(content().string("Hello"));
    }

    @Test
    public void testHelloJohn() throws Exception {
        mvc.perform(get("/api/hello/john")).andDo(print())//
                .andExpect(status().isOk())//
                .andExpect(content().string("Hello john"));
    }

    @Test
    public void testList() throws Exception {
        mvc.perform(get("/api/list")).andDo(print())//
                .andExpect(status().isOk())//
                .andExpect(content().json("[10,20,30]"));
    }

}

Enrichir les contrôleurs

Travail à faire :
  • Ajoutez la méthode suivante afin de traiter les paramètres placés dans l'URI :
    @GetMapping("/hello/{message}")
    public String helloWithMessage(@PathVariable String message) {
        return "Hello " + message;
    }
    
  • Ajoutez la méthode suivante afin de tester l'utilisation du ResponseEntity. Cette solution est moins directe, mais elle permet de mieux contrôler le résultat. Explorer la JavaDoc de cette classe.
    @GetMapping(value = "/hello2")
    public ResponseEntity<String> hello2() {
        return ResponseEntity.ok("Hello");
    }
    
  • Construisez une entrée GET /api/notFound qui renvoie une code http NOT_FOUND (avec la méthode notFound de ResponseEntity).
  • Faites la même chose pour GET /api/noContent.
  • À chaque fois, construisez les tests unitaires (avec MockMvc) afin de vérifier le bon fonctionnement de ces routes.

Utiliser les headers

Voila un exemple de traitement des entêtes dans des requêtes REST :

@GetMapping(value = "/headers")
public ResponseEntity<String> headers(@RequestHeader String myHeader) {
    HttpHeaders responseHeaders = new HttpHeaders();
    responseHeaders.set("resultHeader", myHeader.toUpperCase());
    var res = ResponseEntity.ok()//
            .headers(responseHeaders)//
            .header("xx", "yy")//
            .body("HEADER " + myHeader);
    return res;
}
Travail à faire : Préparez un test unitaire avec la méthode exchange d'un RestTemplate et le code ci-dessous pour préparer, envoyer et récupérer des entêtes :
    HttpHeaders headers = new HttpHeaders();
    headers.add("myHeader", "myHeaderValue");
    HttpEntity entity = new HttpEntity(headers);
    ResponseEntity<String> response = restTemplate.exchange(url,
                HttpMethod.GET, entity, String.class);

Utiliser des javaBeans

Nous allons maintenant construire une API REST autour de l'entité Movie utilisée dans le premier TP :

package myboot.app3.web;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.Data;
import myboot.app1.model.Movie;
import myboot.app1.web.MovieService;

@RestController
@RequestMapping("/api/movies")
@Data
public class MovieRestController {

    private final MovieService movieService;

    @GetMapping("")
    public Iterable<Movie> getMovies() {
        return movieService.findAll();
    }

    @GetMapping("/{id}")
    public Movie getMovieV1(@PathVariable int id) {
        return movieService.find(id).get();
    }

}
Travail à faire :
  • Préparez un test unitaire pour vérifier ces deux routes.
  • Préparez un test unitaire pour /movies/ID sur un film inconnu. Le résultat est-il satisfaisant ?
  • Faites les mêmes tests avec le client curl en ligne de commande afin de visualiser le code JSON.
Travail à faire :
  • Préparez et testez cette deuxième version qui va renvoyer NOT_FOUND si le film n'existe.
    @GetMapping("/{id}/v2")
    public ResponseEntity<Movie> getMovieV2(@PathVariable int id) {
        var movie = movieService.find(id);
        if (movie.isEmpty())
            return ResponseEntity.notFound().build();
        return ResponseEntity.ok(movie.get());
    }
    
  • Cette deuxième version est correcte mais un peu compliquée. En voila une troisième plus simple (à vérifier avec le test unitaire).
    @GetMapping("/{id}/v3")
    public ResponseEntity<Movie> getMovieV3(@PathVariable int id) {
        return ResponseEntity.of(movieService.find(id));
    }
    

Contrôler les données renvoyés

Travail à faire :
  • Avec ces explications, ajoutez des annotations @JsonView afin d'éviter que le champ description ne soit renvoyée. Vous devrez également déclarer une annotation @JsonView sur le contrôleur pour indiquer quelle est la vue à utiliser pour sérialiser l'instance de Movies.
  • Vous pouvez également explorer cette présentation afin de mieux comprendre les annotations JSON. Utilisez notamment @JsonIgnore pour supprimer une information. Explorez (au travers de test), quelques autres possibilités.

Supprimer des données

Ajoutez la nouvelle route ci-dessous :

@DeleteMapping("/movies/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void deleteMovie(@PathVariable long id) {
    movieService.delete(id);
}
Travail à faire : Vérifiez par un test unitaire son bon fonctionnement.

Ajouter des données

Ajoutez la nouvelle route ci-dessous :

@PostMapping("/movies")
public Movie postMovie(@RequestBody Movie m) {
    return movieService.add(m);
}
Travail à faire :
  • Vérifiez par un test unitaire son bon fonctionnement.
  • Essayez d'ajouter un film avec curl (exemple ci-dessous à adapter).
    curl -X POST -H "Content-Type: application/json" \
        -d '{"name": "John", "email": "john@example.com"}' \
        https://example/contact
    
  • Ajoutez l'annotation @Valid à l'argument Movie. Les films à ajouter doivent maintenant être valides (en fonction des annotations de validation). Tentez, dans un test unitaire, d'enregister un film invalide.

Modifier des données

En respectant la même forme, traitez la requête PUT /movies qui va mettre à jour un film. Il faut traiter le cas de la mise à jour d'un film qui n'existe pas en BD (je vous conseille d'utiliser la méthode orElseThrow du résultat Optional renvoyé par la méthode Spring Boot findById - plus d'informations).

Travail à faire : Ajoutez un test unitaire pour valider cette modification.

Gérer les erreurs

Nous avons déjà une classe pour l'application WEB, mais nous pouvons en ajouter une autre spécifique pour les contrôleurs REST :

package myboot.app3.web;

import java.util.NoSuchElementException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(annotations = RestController.class)
public class RestErrorControler {

    protected final Log logger = LogFactory.getLog(getClass());

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public void notValidException(Exception e) {
        logger.error("+++ Not valid argument " + e);
    }

    @ExceptionHandler(NoSuchElementException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public void entityNotFound(Exception e) {
        logger.error("+++ Entity Not Found " + e);
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public void otherException(Exception e) {
        logger.error("+++ Other Exception " + e);
    }

}
Travail à faire : Testez son bon fonctionnement (par observation des traces).

Filtrer des données

Modifiez le traitement de la route GET /movies afin d'ajouter des fonctions de filtrage sur des paramètres optionnels comme

GET /movies?name=Fred&year=1999
Travail à faire : Prenez en compte la présence de l'un des critères ou aucun des deux. Je vous conseille de prévoir des valeurs par défaut pour les paramètres. Il est préférable de prévoir un javaBean pour coder les paramètres de filtrage.