1. Présentation et objectifs

Le but est de continuer le développement de notre architecture "à la microservice".

Nous allons aujourd’hui développer la WEB-UI de gestion des dresseurs de Pokemon.

Ce micro-service se connectera au micro service de pokemon management et trainer management !

trainer gui

On ressemble de plus en plus à une architecture micro-service, comme celle d’UBER !

Les pré-requis au développement de ce TP sont :

Nous allons développer :

  1. deux repositories, qui accèdent aux données de trainer management et pokemon management

  2. un service d’accès aux données

  3. annoter ces composants avec les annotations de Spring et les tester

  4. créer un controlleur spring pour gérer nos requêtes HTTP / REST

  5. créer des templates pour afficher nos données

Nous repartons de zéro pour ce TP !

2. GitLab

Cliquez sur le lien suivant pour créer votre repository git: GitLab classroom

Clonez ensuite votre repository git sur votre poste !

N’oubliez pas ! Vous n’avez pas besoin de forker ce repostiory pour travailler, il vous appartient !

3. Architecture

Pour préparer les développements, on va également tout de suite créer quelques packages Java qui vont matérialiser notre architecture applicative.

Cette architecture est maintenant habituelle pour vous ! C’est l’architecture que l’on retrouve sur de nombreux projets

Créer les packages suivants:

  • fr.univ_lille.alom.game_ui.views : va contenir les controlleurs MVC de notre application

  • fr.univ_lille.alom.game_ui.config : va contenir la configuration de notre application

  • fr.univ_lille.alom.game_ui.pokemonTypes : va contenir les classes liées aux pokemons (bo et services)

  • fr.univ_lille.alom.game_ui.trainers : va contenir les classes liées aux dresseurs (bo et services)

Notre GUI va manipuler des concepts de plusieurs domaines métier (Trainer et Pokemon). Nous organisons notre application pour refléter ces domaines.

Notre projet est prêt !

4. La première vue !

4.1. Le controlleur index

Nous allons développer un Controlleur simple qui servira notre page d’index !

4.1.1. Le test unitaire

Implémentez le test unitaire suivant :

fr.univ_lille.alom.game_ui.views.IndexControllerTest.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package fr.univ_lille.alom.game_ui.views;

import org.junit.jupiter.api.Test;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import static org.junit.jupiter.api.Assertions.*;

class IndexControllerTest {

    @Test
    void controllerShouldBeAnnotated(){
        assertNotNull(IndexController.class.getAnnotation(Controller.class)); (1)
    }

    @Test
    void index_shouldReturnTheNameOfTheIndexTemplate() {
        var indexController = new IndexController();
        var viewName = indexController.index();

        assertEquals("index", viewName); (2)
    }

    @Test
    void index_shouldBeAnnotated() throws NoSuchMethodException {
        var indexMethod = IndexController.class.getMethod("index");
        var getMapping = indexMethod.getAnnotation(GetMapping.class);

        assertNotNull(getMapping);
        assertArrayEquals(new String[]{"/"}, getMapping.value()); (3)
    }
}
1 notre controller doit être annoté @Controller (à ne pas confondre avec @RestController)
2 si le retour de la méthode du controlleur est une chaîne de caractères, cette chaîne sera utilisée pour trouver la vue à afficher
3 on écoute les requêtes arrivant à /

4.1.2. L’implémentation

Implémentez la classe IndexController !

fr.univ_lille.alom.game_ui.views.IndexController.java
1
2
3
4
5
6
7
8
9
// TODO
public class IndexController {

    // TODO
    public String index(){
        return ""; // TODO
    }

}

4.2. Ajout du moteur de template

Nous allons utiliser le moteur de template Mustache.

Pour ce faire, ajoutez la dépendance suivante dans votre pom.xml

pom.xml
1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mustache</artifactId>
</dependency>

Par défaut, les templates Mustache :

  • sont positionnés dans un répertoire du classpath /templates (donc dans src/main/resources/templates, puisque Maven ajoute src/main/resources au classpath).

  • sont des fichiers nommés .mustache

Les propriétés disponibles sont détaillées dans la documentation Spring

Nous allons modifier le suffixe des fichiers de template, pour être .html.

Créez le fichier src/main/resources/application.properties et ajoutez-y les propriétés suivantes.

src/main/resources/application.properties
(1)
spring.mustache.prefix=classpath:/templates/
(2)
spring.mustache.suffix=.html
(3)
server.port=9000
1 On garde ici la valeur par défaut.
2 On modifie la propriété pour prendre en compte les fichiers .html au lieu de .mustache
3 On en profite pour demander à Spring d’écouter sur le port 9000 !

4.3. Ajout du template !

Nous pouvons enfin ajouter notre template de page d’accueil !

La page d’accueil va contenir 2 liens, un lien pour se créer un compte, et un lien pour s’authentifier.

Créer le fichier src/main/resources/templates/index.html

src/main/resources/templates/index.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!doctype html> (1)
<html lang="en">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Pokemon Manager</title>

    <!-- Bootstrap CSS --> (2)
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
</head>
<body>
<div class="container">
    <h1 class="pt-md-5 pb-md-5">Pokemon Manager</h1> (3)

    <div>
        <a href="/login" class="btn btn-primary" role="button">
            Login
        </a>
        <a href="/register" class="btn btn-secondary" role="button">
            Register
        </a>
    </div>
</div>

</body>
</html>
1 On crée une page HTML
2 En important les CSS de Bootstrap par exemple
3 On affiche un titre !

4.4. L’application

Démarrez votre application et allez consulter le résultat sur http://localhost:9000 !

5. Création de compte

Nous allons maintenant créer un formulaire de saisie permettant à un utilisateur de se créer un compte.

5.1. Servir des ressources statiques

Par défaut, Spring est capable de servir des ressources statiques.

Pour ce faire, il suffit de les placer au bon endroit !

Télécharger l’image chen.png et placez-la dans le répertoire src/main/resources/static/images ou src/main/resources/public/images

Une image pokemon-logo.png est aussi disponible pour votre page d’accueil.

Le positionnement des ressources statiques est paramétrable à l’aide de l’application.properties :

application.properties
# Path pattern used for static resources. (1)
spring.mvc.static-path-pattern=/**
# Locations of static resources. (2)
spring.web.resources.static-locations=classpath:/META-INF/resources/,classpath:/resources/,classpath:/static/,classpath:/public/
1 Ce paramétrage indique que l’ensemble des requêtes entrantes peut être une ressource statique !
2 Et on indique à spring dans quels répertoires il doit chercher les ressources !

5.2. Ajouter un formulaire

Créez une page register.html, contenant un formulaire :

register.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<div class="row">
    <img  src="/images/chen.png" class="col-md-2"/> (1)

    <div class="row col">

        <div style="white-space: pre-line"> (2)
            Hello there!
            Welcome to the world of Pokémon!
            My name is Oak! People call me the Pokémon Prof!
            This world is inhabited by creatures called Pokémon!
            For some people, Pokémon are pets. Other use them for fights.
            Myself… I study Pokémon as a profession. First, what is your name?
        </div>

        <form action="/register" method="post"> (3)
            <div class="form-group">
                <label for="trainerName">Trainer name</label>
                <input type="text" class="form-control" id="trainerName" name="trainerName" aria-describedby="trainerHelp" placeholder="Enter your name">
                <small id="trainerHelp" class="form-text text-muted">This will be your name in the game !</small>
            </div>
            <button type="submit" class="btn btn-primary">Submit</button>
        </form>
    </div>

</div>
1 Nous ajoutons notre ressource statique.
2 Le discours d’introduction original du Professeur Chen dans Pokémon Bleu et Rouge !
3 Un formulaire de création de dresseur !

Notez comme la ressource statique est référencée par /images/chen.png, et qu’elle est positionnée dans le répertoire src/main/resources/static/images/chen.png. Spring utilise le répertoire paramétré comme base de recherche, les sous-répertoires sont parcourus également !

5.3. Les impacts sur la couche controlleur

Créez un nouveau controlleur pour servir notre page register, et recevoir la requête POST /register de création.

5.3.1. Les tests unitaires

Ajouter les tests unitaires suivants :

fr.univ_lille.alom.game_ui.views.RegisterControllerTest.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Test
void registerNewTrainer_shouldReturnAModelAndView(){
    var registerController = new RegisterController();
    var modelAndView = registerController.registerNewTrainer("Blue");

    assertNotNull(modelAndView);
    assertEquals("registered", modelAndView.getViewName());
    assertEquals("Blue", modelAndView.getModel().get("name"));
}

@Test
void registerNewTrainer_shouldBeAnnotated() throws NoSuchMethodException {
    var registerMethod = RegisterController.class.getDeclaredMethod("registerNewTrainer", String.class);
    var getMapping = registerMethod.getAnnotation(PostMapping.class);

    assertNotNull(getMapping);
    assertArrayEquals(new String[]{"/register"}, getMapping.value());
}

5.3.2. L’implémentation

Implémenter le RegisterController.

fr.univ_lille.alom.game_ui.views.RegisterController.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Controller
public class RegisterController {

    @GetMapping("/register")
    String register(){
        return "register";
    }

    // TODO
    ModelAndView registerNewTrainer(String trainerName){
        // TODO
    }

}

5.3.3. Le nouveau template

Nous allons devoir également créer un nouveau template pour afficher le résultat.

Créez le template registered.html

src/main/resources/templates/registered.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<!doctype html> (1)
<html lang="en">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Pokemon Manager</title>

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
</head>
<body>
<div class="container">
    <h1 class="pt-md-5 pb-md-5">Pokemon Manager - Welcome {{name}}</h1> (1)

    <div class="row">
        <img  src="/images/chen.png" class="col-md-2"/>

        <div class="row col-md-10">

            <p style="white-space: pre-line">
                Right! So your name is {{name}}! (1)

                {{name}}! (1)

                Your very own Pokémon legend is about to unfold!
                A world of dreams and adventures with Pokémon awaits!
                Let's go!
            </p>
            <p>
                <a href="/profile" class="btn btn-primary" role="button">View you profile !</a>
            </p>

        </div>

    </div>

</div>

</body>
</html>
1 On utilise le champ name du model pour alimenter notre titre et notre texte !

6. Ajouter du layouting

6.1. Le header

Nous allons utiliser l’inclusion de templates pour éviter de copier/coller notre header de page sur l’ensemble de notre application !

Créez un répertoire layout dans src/main/resources/templates. Ce répertoire va nous permettre de gérer les templates liés à la mise en page de notre application.

Dans le répertoire layout, créez un fichier que l’on appellera header.html :

header.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!doctype html> (1)
<html lang="en">
<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Pokemon Manager</title>

    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
</head>

6.2. L’inclusion

L’utilisation de notre header dans un template se fait alors avec une inclusion Mustache.

Modifiez vos templates pour utiliser l’inclusion :

index.html
1
2
3
4
5
{{> /layout/header}}

<body>
    [...]
</body>

7. Se connecter aux micro-services

Nous allons maintenant appeler le micro-service pokemon-type-api, que nous avons écrit lors du TP 3 !.

7.1. Le business object

La classe du business object va être la copie de la classe du micro-service que l’on va consommer.

Nous avons donc besoin ici de deux record (que vous pouvez copier/coller depuis votre TP 3 !) :

  • PokemonType: représentation d’un type de Pokemon

  • Sprites: représentation des images du Pokemon (avant et arrière)

fr.univ_lille.alom.game_ui.pokemonTypes.PokemonType
1
public record PokemonType {}
fr.univ_lille.alom.game_ui.pokemonTypes.Sprites
1
public record Sprites {}

7.2. Le service

7.2.1. L’interface PokemonService

Écrire l’interface de service suivante :

fr.univ_lille.alom.game_ui.pokemonTypes.PokemonTypeService
1
2
3
4
5
public interface PokemonTypeService {

    List<PokemonType> listPokemonsTypes();

}

7.2.2. La configuration du RestTemplate

Par défaut, Spring n’instancie pas de RestTemplate.

Il nous faut donc en instancier un, et l’ajouter à l' application context afin de le rendre disponible en injection de dépendances.

Pour ce faire, nous allons développer une simple classe de configuration :

fr.univ_lille.alom.game_ui.config.RestConfiguration.java
1
2
3
4
5
6
7
8
9
@Configuration (1)
public class RestConfiguration {

    @Bean (2)
    RestTemplate restTemplate(){
        return new RestTemplate(); (3)
    }

}
1 L’annotation @Configuration enregistre notre classe RestConfiguration dans l’application context (comme @Component, ou @Service)
2 L’annotation @Bean permet d’annoter une méthode, dont le résultat sera enregistré comme un bean dans l' application context de spring.

7.2.3. Le test d’intégration

Implémentez le test d’intégration suivant :

fr.univ_lille.alom.game_ui.pokemonTypes.PokemonTypeServiceImplTest
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package fr.univ_lille.alom.game_ui.pokemonTypes;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

@RestClientTest(PokemonTypeServiceImpl.class)
@AutoConfigureWebClient(registerRestTemplate = true)
@TestPropertySource(properties = "pokemonType.service.url=http://localhost:8080")
class PokemonTypeServiceIntegrationTest {

    @Autowired
    PokemonTypeService pokemonTypeService;

    @Autowired
    MockRestServiceServer server;

    @Autowired
    PokemonTypeService service;

    @Autowired
    RestTemplate restTemplate;

    @Test
    void serviceAndTemplateShouldNotBeNull(){
        assertNotNull(service);
        assertNotNull(restTemplate);
    }

    @Test
    void listPokemonsTypes_shouldCallTheRemoteService() {
        // given
        var response = """
                       [
                           {
                               "id": 151,
                               "name": "mew",
                               "types": ["psychic"]
                           }
                       ]
                       """;
        server.expect(requestTo("http://localhost:8080/pokemon-types/"))
                .andRespond(withSuccess(response, MediaType.APPLICATION_JSON));

        var pokemons = pokemonTypeService.listPokemonsTypes();
        assertThat(pokemons).hasSize(1);
    }

    @Test
    void pokemonServiceImpl_shouldBeAnnotatedWithService(){
        assertNotNull(PokemonTypeServiceImpl.class.getAnnotation(Service.class));
    }

    @Test
    void setRestTemplate_shouldBeAnnotatedWithAutowired() throws NoSuchMethodException {
        var setRestTemplateMethod = PokemonTypeServiceImpl.class.getDeclaredMethod("setRestTemplate", RestTemplate.class);
        assertNotNull(setRestTemplateMethod.getAnnotation(Autowired.class));
    }

}

7.2.4. L’implémentation

Pour exécuter les appels au micro-service de gestion des pokemons, nous allons utiliser le RestTemplate de Spring. Le RestTemplate de Spring fournit des méthodes simples pour exécuter des requêtes HTTP. La librairie jackson-databind est utilisée pour transformer le résultat reçu (en JSON), vers notre classe de BO.

  • la javadoc du RestTemplate ici

  • la documentation de spring qui explique le fonctionnement et l’usage du RestTemplate ici

Implémentez la classe suivante :

fr.univ_lille.alom.game_ui.pokemonTypes.PokemonTypeServiceImpl
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// TODO
public class PokemonTypeServiceImpl implements PokemonTypeService {

    public List<PokemonType> listPokemonsTypes() {
        // TODO
    }

    void setRestTemplate(RestTemplate restTemplate) {
        // TODO
    }

    void setPokemonTypeServiceUrl(String pokemonServiceUrl) {
        // TODO
    }
}

7.2.5. L’injection des properties

Nous allons également utiliser l’injection de dépendance pour l’URL d’accès au service !

Les paramètres de configuration d’une application sont souvent injectés selon la méthode que nous allons voir !

Modifiez le fichier application.properties pour y ajouter une nouvelle propriété :

src/main/resources/application.properties
pokemonType.service.url=http://localhost:8080  (1)
1 Nous utilisons un paramètre indiquant à quelle URL sera disponible notre micro-service de pokemons! Utilisez l’url à laquelle votre service est déployé, ou une URL en localhost.

Ajoutez le test unitaire suivant au PokemonServiceIntegrationTest

1
2
3
4
5
6
7
@Test
void setPokemonServiceUrl_shouldBeAnnotatedWithValue() throws NoSuchMethodException {
    var setter = PokemonTypeServiceImpl.class.getDeclaredMethod("setPokemonTypeServiceUrl", String.class);
    var valueAnnotation = setter.getAnnotation(Value.class); (1)
    assertNotNull(valueAnnotation);
    assertEquals("${pokemonType.service.url}", valueAnnotation.value()); (2)
}
1 On utilise une annotation @Value pour faire l’injection de dépendances de properties
2 Une expression ${} (spring-expression-language) est utilisée pour calculer la valeur à injecter
Un guide intéressant sur l’injection de valeurs avec l’annotation @Value ici

7.3. Le controlleur

Nous allons maintenant écrire le controlleur PokemonTypeController !

7.3.1. Le test unitaire

Implémentez le test unitaire suivant :

fr.univ_lille.alom.game_ui.views.PokemonTypeControllerTest.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package fr.univ_lille.alom.game_ui.views;

import fr.univ_lille.alom.game_ui.pokemonTypes.PokemonType;
import fr.univ_lille.alom.game_ui.pokemonTypes.PokemonTypeService;
import org.junit.jupiter.api.Test;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class PokemonTypeControllerTest {
    @Test
    void controllerShouldBeAnnotated(){
        assertNotNull(PokemonTypeController.class.getAnnotation(Controller.class));
    }

    @Test
    void pokemons_shouldReturnAModelAndView() {
        var pokemonTypeService = mock(PokemonTypeService.class);

        var pikachu = new PokemonType("pikachu", 25);

        when(pokemonTypeService.listPokemonsTypes()).thenReturn(List.of(pikachu));

        var pokemonTypeController = new PokemonTypeController();
        pokemonTypeController.setPokemonTypeService(pokemonTypeService);
        var modelAndView = pokemonTypeController.pokedex();

        assertEquals("pokedex", modelAndView.getViewName());
        var pokemons = (List<PokemonType>)modelAndView.getModel().get("pokemonTypes");
        assertEquals(1, pokemons.size());
        verify(pokemonTypeService).listPokemonsTypes();
    }

    @Test
    void pokemons_shouldBeAnnotated() throws NoSuchMethodException {
        var pokemonsMethod = PokemonTypeController.class.getDeclaredMethod("pokedex");
        var getMapping = pokemonsMethod.getAnnotation(GetMapping.class);

        assertNotNull(getMapping);
        assertArrayEquals(new String[]{"/pokedex"}, getMapping.value());
    }


}

7.3.2. L’implémentation

Implémentez le controlleur :

fr.univ_lille.alom.game_ui.views.PokemonTypeController.java
1
2
3
4
5
6
7
8
9
// TODO
public class PokemonTypeController {

    // TODO
    public ModelAndView pokedex(){
        // TODO
    }

}

7.3.3. Le template

Nous allons créer une petite page qui va afficher pour chaque type de pokémon son nom, son image, ainsi que ses statistiques

Créer le template suivant :

src/main/resources/templates/pokedex.html
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{{> layout/header}}

<body>

    <div class="container">
        <h1 class="pt-md-5 pb-md-5">Pokedex</h1>

        <div class="card-deck">
            {{#pokemonTypes}} (1)
            <div class="col-md-3">
                <div class="card shadow-sm mb-3">
                    <div class="card-header">
                        (2)
                        <h4 class="my-0 font-weight-normal">{{name}} <span class="badge text-bg-secondary">Id {{}} </span></h4>(3)
                    </div>
                    <img class="card-img-top" src="{{}}" alt="Pokemon"/> (3)

                    <div class="card-body">
                        <span class="badge text-bg-primary">Type : {{}}</span> (3)
                    </div>
                </div>
            </div>
            {{/pokemonTypes}}
        </div>

    </div>

</body>
</html>
1 Voici comment on itère sur une liste !
2 On affiche quelques valeurs
3 à compléter
Attention, si le template n’est pas correct, la vue ne s’affichera pas quand elle est requêtée, et des exceptions peuvent apparaître dans la console, en particulier des NullPointerException ou StringIndexOutOfBoundsException.

8. Pour aller plus loin

  1. Affichez sur le Pokedex les types de chaque Pokemon (plante, électrique…​)

  2. Affichez sur le Pokedex les images "vues de derrière"

  3. Développez une page web qui affiche la liste des dresseurs de Pokemons (accessible à /trainers)

  4. Développez une page qui affiche le détail d’un dresseur de Pokemon (accessible à /trainers/{name}) :

    • son nom

    • son équipe