Cours Java Spring Boot
Projet CGB — Crédit Général Bank · API REST de virements bancaires
🤖 Prompt IA — Apprendre Java Spring Boot avec CGB
Copie ce prompt et donne-le à une IA (Claude, ChatGPT…) avec le projet CGB pour apprendre de zéro.
🏗 Architecture Spring Boot Fondations
Spring Boot applique le patron MVC en couches. Chaque couche a une responsabilité unique :
| Couche | Annotation | Rôle | Fichier CGB |
|---|---|---|---|
| Controller | @RestController | Reçoit les requêtes HTTP, renvoie JSON | TransferRestController.java |
| Service | @Service | Logique métier, transactions | TransferService.java |
| Repository | @Repository | Accès BDD via JPA | TransferRepository.java |
| Entity | @Entity | Classe mappée sur une table SQL | Transfer.java, Account.java |
// Flux d'une requête POST /api/transfers : HTTP Request → TransferRestController.createTransfer() // reçoit le JSON → TransferService.createTransfer() // applique les règles → AccountRepository.findById() // lit la BDD → TransferRepository.save() // écrit la BDD ← renvoie Transfer (entity) ← ResponseEntity.ok(transfer) // HTTP 200 + JSON
@Autowired. Tu ne fais jamais new TransferService() dans le controller — Spring s'en charge.
Dans quel fichier se trouve la logique qui vérifie que le solde est suffisant avant un virement ? Dans quel fichier se fait le stockage du virement en base de données ?
Vérification du solde : TransferService.java — c'est la couche Service qui contient les règles métier.
Stockage en BDD : aussi dans TransferService.java via transferRepository.save(transfer). Le Repository est appelé depuis le Service, jamais directement depuis le Controller.
// TransferService.java if (sourceAccount.getSolde().compareTo(amount) < 0) { throw new RuntimeException("Insufficient funds"); } // ... return transferRepository.save(transfer); // → couche Repository → H2
📦 Maven & pom.xml Build
Maven est l'outil de build Java de CGB. Il gère les dépendances (fichiers .jar) et compile le projet.
<!-- pom.xml — extrait des dépendances clés CGB --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.2</version> </parent> <dependencies> <!-- Spring Web : @RestController, ResponseEntity... --> <dependency>spring-boot-starter-web</dependency> <!-- JPA : @Entity, JpaRepository... --> <dependency>spring-boot-starter-data-jpa</dependency> <!-- H2 : base de données en mémoire/fichier --> <dependency>h2</dependency> <!-- Security : Basic Auth, JWT... --> <dependency>spring-boot-starter-security</dependency> <!-- Tests : JUnit 5, MockMvc --> <dependency>spring-boot-starter-test</dependency> </dependencies>
Les commandes Maven essentielles :
| Commande | Effet |
|---|---|
mvn spring-boot:run | Lance l'application |
mvn test | Lance les tests JUnit |
mvn package | Compile + crée le JAR exécutable |
mvn clean | Supprime le dossier target/ |
🚀 Point d'entrée Spring Boot
Toute application Spring Boot démarre par une classe annotée @SpringBootApplication.
@SpringBootApplication public class CgbApplication { public static void main(String[] args) { SpringApplication.run(CgbApplication.class, args); } }
@Configuration— cette classe peut déclarer des beans@EnableAutoConfiguration— Spring détecte et configure automatiquement H2, JPA, Security…@ComponentScan— Spring scanne le package et ses sous-packages pour trouver tous les@Component,@Service,@Repository,@Controller
@RestController Couche Web
@RestController combine @Controller (Spring MVC) et @ResponseBody (sérialise automatiquement les retours en JSON via Jackson).
@RestController @RequestMapping("/api/transfers") // préfixe de toutes les routes public class TransferRestController { @Autowired private TransferService transferService; @GetMapping // GET /api/transfers public List<Transfer> getAll() { ... } @PostMapping // POST /api/transfers public ResponseEntity<?> create(@RequestBody TransferRequest req) { ... } @DeleteMapping // DELETE /api/transfers public ResponseEntity<?> delete(@RequestBody Long id) { ... } }
| Annotation | Méthode HTTP | Usage |
|---|---|---|
@GetMapping | GET | Lire des données |
@PostMapping | POST | Créer une ressource |
@PutMapping | PUT | Remplacer une ressource |
@PatchMapping | PATCH | Modifier partiellement |
@DeleteMapping | DELETE | Supprimer une ressource |
Quelle annotation utilises-tu pour lire un objet JSON envoyé dans le corps d'une requête POST ? Et pour lire un paramètre d'URL comme /api/transfers/42 ?
Corps JSON : @RequestBody — Spring/Jackson désérialise automatiquement le JSON en objet Java.
Paramètre d'URL : @PathVariable + {id} dans le mapping.
// Corps JSON @PostMapping public ResponseEntity<?> create(@RequestBody TransferRequest req) { ... } // Paramètre d'URL /api/transfers/42 @GetMapping("/{id}") public Transfer getById(@PathVariable Long id) { ... }
DTO vs Entity Couche Web
CGB utilise deux types d'objets pour les virements. Il ne faut pas les confondre :
| DTO (Data Transfer Object) | Entity | |
|---|---|---|
| Classe | TransferRequest | Transfer |
| Annotation | aucune (POJO) | @Entity |
| Rôle | Reçoit les données du client | Représente une ligne en BDD |
| En BDD ? | Non | Oui, table transfer |
// DTO — ce que le client envoie dans le JSON public class TransferRequest { private String sourceAccountNumber; private String destinationAccountNumber; private Double amount; private LocalDate transferDate; private String description; // getters / setters } // Entity — ce qui est stocké en base @Entity public class Transfer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // auto-incrémenté par H2 private String sourceAccountNumber; private String destinationAccountNumber; private Double amount; private LocalDate transferDate; private String description; }
ResponseEntity Couche Web
ResponseEntity<T> permet de contrôler à la fois le code HTTP et le corps de la réponse.
// Succès — HTTP 200 avec le virement créé return ResponseEntity.ok(transfer); // Erreur client — HTTP 400 avec message d'erreur return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(new TransferResponse("FAILURE", e.getMessage())); // Ressource non trouvée — HTTP 404 return ResponseEntity.notFound().build(); // Créé avec succès — HTTP 201 return ResponseEntity.status(HttpStatus.CREATED).body(transfer);
Dans TransferRestController.createTransfer(), que se passe-t-il si le solde du compte source est insuffisant ? Quel code HTTP est renvoyé et pourquoi ?
Le TransferService lance une RuntimeException("Insufficient funds"). Le Controller la catch et renvoie HTTP 400 (BAD_REQUEST) avec un objet TransferResponse("FAILURE", "Insufficient funds").
catch (RuntimeException e) { TransferResponse errorResponse = new TransferResponse("FAILURE", e.getMessage()); return ResponseEntity .status(HttpStatus.BAD_REQUEST) .body(errorResponse); }
HTTP 400 = erreur du client (données invalides). HTTP 500 = erreur du serveur (bug non géré). Ici c'est bien une erreur métier liée à l'entrée, donc 400.
JPA & Repository Persistance
Spring Data JPA génère automatiquement les requêtes SQL à partir d'interfaces Java. Tu n'écris plus de SQL.
// Interface vide — Spring génère toutes les requêtes SQL ! public interface TransferRepository extends JpaRepository<Transfer, Long> { // Méthodes héritées : findAll(), findById(), save(), deleteById()... // Convention de nommage : Spring génère le SQL automatiquement List<Transfer> findBySourceAccountNumber(String accountNumber); List<Transfer> findByAmountGreaterThan(Double amount); } // Account — clé primaire String (numéro de compte) public interface AccountRepository extends JpaRepository<Account, String> { }
| Méthode JpaRepository | SQL généré |
|---|---|
findById(id) | SELECT * FROM transfer WHERE id = ? |
findAll() | SELECT * FROM transfer |
save(entity) | INSERT ou UPDATE selon si l'id existe |
deleteById(id) | DELETE FROM transfer WHERE id = ? |
count() | SELECT COUNT(*) FROM transfer |
Comment déclarer dans TransferRepository une méthode qui retrouve tous les virements dont le montant est exactement 100.0 ? Et tous ceux d'un mois donné ?
public interface TransferRepository extends JpaRepository<Transfer, Long> { // Montant exact = 100.0 List<Transfer> findByAmount(Double amount); // Virements dont transferDate est dans un mois donné // (Spring génère : WHERE transfer_date BETWEEN ?1 AND ?2) List<Transfer> findByTransferDateBetween(LocalDate start, LocalDate end); }
Spring analyse le nom de la méthode et génère le SQL. Mots-clés utiles : findBy, Between, GreaterThan, Like, OrderBy…
H2 & @PostConstruct Persistance
application.properties
# Base H2 sur fichier (données persistées entre les redémarrages) spring.datasource.url=jdbc:h2:file:./db/db_cgb;AUTO_SERVER=TRUE spring.datasource.username=sio spring.datasource.password=slam # Console H2 accessible dans le navigateur spring.h2.console.enabled=true spring.h2.console.path=/console # JPA : affiche les requêtes SQL dans la console spring.jpa.show-sql=true
La console H2 est accessible à http://localhost:8080/console (avec les credentials sio/slam).
@PostConstruct — initialisation avec IBAN valides (Mission 2)
@Component public class DatabaseInitializer { private final AccountRepository accountRepository; @Autowired public DatabaseInitializer(AccountRepository accountRepository) { this.accountRepository = accountRepository; // injection par constructeur (recommandée) } @PostConstruct public void init() { if (accountRepository.count() == 0) { insertSampleData(accountRepository); } } public static void insertSampleData(AccountRepository repo) { double[] soldes = { 300.00, 500.00, 2000.00, 1000.00, 1500.00, 750.00, 250.00, 4000.00, 800.00, 3000.00, 120.00, 600.00, 1200.00, 900.00, 5000.00, 450.00, 2500.00, 350.00, 1800.00, 700.00 }; for (double solde : soldes) { Account account = new Account(); account.setAccountNumber(IbanGenerator.generateValidIban()); // IBAN FR valide account.setSolde(solde); repo.save(account); } } }
@PostConstruct s'exécute après que Spring a injecté toutes les dépendances (accountRepository est disponible). Un constructeur s'exécuterait avant l'injection, donc accountRepository serait null.
@Transactional Persistance
Une transaction garantit que toutes les opérations réussissent — ou aucune. C'est indispensable pour les virements : débiter sans créditer serait catastrophique.
@Transactional public Transfer createTransfer(...) { // 1. Lire le compte source Account source = accountRepository.findById(sourceId).orElseThrow(...); // 2. Lire le compte destinataire Account dest = accountRepository.findById(destId).orElseThrow(...); // 3. Vérifier le solde if (source.getSolde() < amount) throw new RuntimeException("Insufficient funds"); // 4. Débiter / Créditer source.setSolde(source.getSolde() - amount); // ← si exception ici dest.setSolde(dest.getSolde() + amount); // ← ces deux sont annulées accountRepository.save(source); accountRepository.save(dest); // 5. Enregistrer le virement return transferRepository.save(transfer); // Si tout réussit → COMMIT automatique // Si exception → ROLLBACK automatique }
@Transactional, si le serveur plante après le débit mais avant le crédit, l'argent disparaît. Avec @Transactional, tout est annulé en cas d'erreur.
Optional<T> Persistance
findById() retourne un Optional<T> car la ressource peut ne pas exister. Optional force à gérer ce cas explicitement.
// Méthode 1 : orElseThrow — lève une exception si absent Account account = accountRepository.findById("123456789") .orElseThrow(() -> new RuntimeException("Account not found")); // Méthode 2 : isPresent / get Optional<Transfer> opt = transferRepository.findById(42L); if (opt.isPresent()) { Transfer t = opt.get(); } // Méthode 3 : orElse — valeur par défaut Transfer t = transferRepository.findById(42L).orElse(null); // Méthode 4 : ifPresent — lambda si présent transferRepository.findById(42L).ifPresent(tr -> System.out.println(tr)); // Méthode 5 : isEmpty (Java 11+) — inverse de isPresent if (opt.isEmpty()) { throw new RuntimeException("Not found"); }
transferRepository.deleteById(id); // ← supprime AVANT de vérifier if (opt.isEmpty()) throw new DeleteTransferException(...);Si
id n'existe pas, deleteById() ne fait rien (silencieux), puis on lève l'exception — mais sans rollback, c'est incohérent. La correction (Mission 3) : vérifier avant de supprimer.
Basic Auth — Spring Security Sécurité
Spring Security protège tous les endpoints par défaut. CGB utilise d'abord HTTP Basic Authentication.
@Configuration public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // Désactive CSRF (APIs stateless n'en ont pas besoin) .csrf(csrf -> csrf.disable()) // Toutes les requêtes doivent être authentifiées .authorizeHttpRequests(req -> req.anyRequest().authenticated()) // Active HTTP Basic (popup navigateur / header Authorization: Basic ...) .httpBasic(Customizer.withDefaults()) // Autorise la console H2 (qui utilise des iframes) .headers(h -> h.frameOptions(f -> f.sameOrigin())); return http.build(); } }
Authorization: Basic base64(login:password) dans chaque requête. Simple mais il faut toujours HTTPS car base64 n'est pas du chiffrement.
Que se passe-t-il si on envoie une requête à /api/transfers sans le header Authorization ? Quel code HTTP reçoit-on ? Pourquoi la console H2 nécessite-t-elle frameOptions.sameOrigin() ?
Sans Authorization : HTTP 401 Unauthorized. Spring Security bloque la requête avant qu'elle atteigne le Controller.
frameOptions.sameOrigin() : La console H2 affiche son interface dans des <iframe>. Par défaut, Spring Security ajoute l'entête X-Frame-Options: DENY qui bloque tout contenu en iframe. sameOrigin() l'assouplit : les iframes sont autorisées si elles viennent du même domaine (localhost:8080).
IBAN & Algorithme CRC Mission 2
Un IBAN français a 27 caractères : FR + 2 chiffres de contrôle + 23 caractères BBAN.
// Format : FR 76 30006 00011 00100000021 00 // ^^ ^^ ─────────── BBAN (23) ────── // pays CRC
Algorithme de vérification (modulo 97) — technique du cours
// Dans CGBIbanValidator.isIbanValide() — même logique que IbanGenerator public boolean isIbanValide(String iban) throws ExceptionInvalideIBAN { isIbanStructureValide(iban); // lève InvalidIbanFormatException si format KO // Étape 1 : déplacer les 4 premiers caractères à la fin String rearranged = iban.substring(4) + iban.substring(0, 4); // Étape 2 : convertir les lettres en chiffres (A=10, B=11... Z=35) StringBuilder numeric = new StringBuilder(); for (char c : rearranged.toCharArray()) { if (Character.isLetter(c)) { numeric.append(c - 'A' + 10); // F=15, R=27... } else { numeric.append(c); } } // Étape 3 : modulo 97 par accumulation chiffre par chiffre // Évite l'overflow sans BigInteger : on n'accumule jamais plus de 2 chiffres à la fois long remainder = 0; for (char c : numeric.toString().toCharArray()) { remainder = (remainder * 10 + Character.getNumericValue(c)) % 97; } if (remainder != 1) { throw new InvalidUnCheckableIbanException("CRC invalide pour l'IBAN : " + iban); } return true; }
long et pas BigInteger ? En calculant le reste à chaque chiffre (remainder = (remainder × 10 + chiffre) % 97), on ne dépasse jamais la capacité d'un long : la valeur intermédiaire est toujours entre 0 et 96×10+9 = 969. Pas besoin d'arithmétique sur grands entiers.
Quel est l'avantage de la technique d'accumulation chiffre par chiffre comparée à new BigInteger(numeric).mod(BigInteger.valueOf(97)) ?
Accumulation : la valeur intermédiaire (remainder) reste toujours ≤ 96×10+9 = 969, donc un long suffit. Aucune allocation mémoire d'objet, O(n) en une passe.
BigInteger : alloue un objet de taille proportionnelle au nombre de chiffres (~34 pour un IBAN), puis fait la division entière. Correct mais plus lent et verbeux.
Les deux approches donnent le même résultat — la technique long est celle utilisée dans IbanGenerator du cours.
Pattern Singleton Mission 2
Le Singleton garantit qu'une classe n'a qu'une seule instance pendant toute l'exécution. Utilisé pour CGBIbanValidator.
public class CGBIbanValidator { // 1. Instance unique — static, private private static CGBIbanValidator instance; // 2. Constructeur privé — interdit le new depuis l'extérieur private CGBIbanValidator() {} // 3. Méthode d'accès statique — nom imposé par le cahier des charges public static synchronized CGBIbanValidator getInstanceValidator() { if (instance == null) { instance = new CGBIbanValidator(); } return instance; } // 4a. Vérifie uniquement le format (FR + 25 chiffres) — lève InvalidIbanFormatException public boolean isIbanStructureValide(String iban) throws InvalidIbanFormatException { if (iban == null || !iban.matches("^FR\\d{25}$")) { throw new InvalidIbanFormatException("Format IBAN invalide : " + iban); } return true; } // 4b. Vérifie structure + CRC — lève InvalidUnCheckableIbanException si CRC KO public boolean isIbanValide(String iban) throws ExceptionInvalideIBAN { ... } } // Utilisation : CGBIbanValidator v = CGBIbanValidator.getInstanceValidator(); v.isIbanValide(IbanGenerator.generateValidIban()); // toujours true
instance est null au même moment.
Exceptions métier Mission 3
CGB utilise deux hiérarchies d'exceptions pour distinguer les erreurs métier des erreurs techniques.
Hiérarchie 1 — Virements
// TransferException est CONCRÈTE (pas abstract) — on peut la lever directement public class TransferException extends Exception { public TransferException(String message) { super(message); } } // Sous-classe pour la suppression, avec un enum des causes public class DeleteTransferException extends TransferException { public enum FailureTransfert { OBJECT_NOT_FOUND, REMOVAL_FAILURE } public DeleteTransferException(FailureTransfert ft) { super(ft.name()); } } // Utilisations dans TransferService : throw new TransferException("Solde insuffisant sur le compte " + sourceId); throw new DeleteTransferException(FailureTransfert.OBJECT_NOT_FOUND);
Hiérarchie 2 — Validation IBAN (Mission 2)
// Classe abstraite parente — non instanciable directement public abstract class ExceptionInvalideIBAN extends Exception { public ExceptionInvalideIBAN(String message) { super(message); } } // Format structurel invalide (mauvaise longueur, pas FR, lettres dans BBAN...) public class InvalidIbanFormatException extends ExceptionInvalideIBAN { ... } // Structure valide mais CRC modulo-97 incorrect public class InvalidUnCheckableIbanException extends ExceptionInvalideIBAN { ... }
isIbanStructureValide() ne peut lever que InvalidIbanFormatException.isIbanValide() déclare throws ExceptionInvalideIBAN pour couvrir les deux sous-types.
Voici le code bugué de TransferService.deleteTransfer(). Identifie le problème et propose la correction.
public Transfer deleteTransfer(Long id) throws DeleteTransferException { Optional<Transfer> oTransfer = transferRepository.findById(id); transferRepository.deleteById(id); // BUG if (oTransfer.isEmpty()) { throw new DeleteTransferException(FailureTransfert.OBJECT_NOT_FOUND); } return oTransfer.orElse(null); }
Bug : deleteById(id) est appelé avant de vérifier si le virement existe. Si id est inconnu, Spring JPA lève une EmptyResultDataAccessException non catchée, ou supprime silencieusement un enregistrement inexistant. L'exception métier n'est jamais lancée correctement.
Correction : vérifier avant de supprimer.
@Transactional public Transfer deleteTransfer(Long id) throws DeleteTransferException { Optional<Transfer> oTransfer = transferRepository.findById(id); // Vérifier AVANT de supprimer if (oTransfer.isEmpty()) { throw new DeleteTransferException(FailureTransfert.OBJECT_NOT_FOUND); } transferRepository.deleteById(id); return oTransfer.get(); }
États d'un virement Mission 4
Les lots et virements CGB ont chacun leur cycle de vie, défini par des enums imbriquées dans les entités.
// Entité Lot — cycle : RECU → CLOS @Entity public class Lot { @Enumerated(EnumType.STRING) private EtatLot etat; public enum EtatLot { RECU, // lot reçu, traitement asynchrone en attente CLOS // tous les virements ont un état final } } // Entité Virement — cycle : EN_ATTENTE → SUCCES / ECHEC / RETARDE / ANNULE @Entity public class Virement { @Enumerated(EnumType.STRING) // stocké "EN_ATTENTE", "SUCCES"... private EtatVirement etat = EtatVirement.EN_ATTENTE; public enum EtatVirement { EN_ATTENTE, // en file d'attente SUCCES, // virement exécuté ECHEC, // solde insuffisant, compte introuvable… RETARDE, // traitement différé ANNULE // annulé manuellement } }
@Enumerated(EnumType.STRING) stocke la valeur textuelle ("WAITING") plutôt que l'index numérique (0). C'est recommandé : si on réordonne l'enum, les données BDD restent cohérentes.
@Async & Traitement en lots Mission 4
Pour traiter des milliers de virements sans bloquer le thread HTTP, CGB utilise l'exécution asynchrone.
// 1. Activer l'async sur la classe principale @SpringBootApplication @EnableAsync public class ServerTransferApp { ... } // 2. Service : soumettreLot() persiste et délègue à traiterLot() en async @Service public class LotService { @Transactional public Lot soumettreLot(LotRequest lotRequest) { Lot lot = new Lot(); lot.setEtat(EtatLot.RECU); // ... remplir virements avec état EN_ATTENTE Lot lotSauvegarde = lotRepository.save(lot); traiterLot(lotSauvegarde); // appel async — retour immédiat return lotSauvegarde; } @Async @Transactional public void traiterLot(Lot lot) { for (Virement v : lot.getVirements()) { // vérifier compte dest, solde suffisant... v.setEtat(EtatVirement.SUCCES); // ou ECHEC } lot.setEtat(EtatLot.CLOS); lotRepository.save(lot); } } // 3. Controller — POST /send/lot/ répond 202 immédiatement @PostMapping("/") public ResponseEntity<Lot> soumettreLot(@RequestBody LotRequest req) { Lot lot = lotService.soumettreLot(req); return ResponseEntity.status(HttpStatus.ACCEPTED).body(lot); // HTTP 202 }
| Code HTTP | Signification |
|---|---|
| 200 OK | Traitement terminé, résultat inclus |
| 202 Accepted | Requête acceptée, traitement en cours |
| 204 No Content | Succès, aucun contenu à retourner |
Quelle est la différence entre une méthode @Async qui retourne void et une qui retourne CompletableFuture<T> ? Dans quel cas préfères-tu l'un ou l'autre ?
@Async void : "fire and forget" — on ne peut pas attendre la fin ni récupérer le résultat. Utile pour des notifications, logs, envois d'email.
@Async CompletableFuture<T> : on peut attendre la fin (.get()), enchaîner des actions (.thenApply()), ou combiner plusieurs futures (CompletableFuture.allOf()). Utile pour les traitements de lots dont on veut le résultat.
// Fire and forget — notifications @Async public void sendNotification(String userId, String msg) { ... } // Récupérer le résultat plus tard @Async public CompletableFuture<Integer> processLot(...) { return CompletableFuture.completedFuture(successCount); }
JUnit 5 & MockMvc Tests
CGB utilise deux niveaux de test selon le pattern du cours :
| Type | Annotation | Sécurité | BDD |
|---|---|---|---|
| Unitaire (Controller) | @WebMvcTest | désactivée (addFilters=false) | mock |
| Intégration | @SpringBootTest | @WithMockUser | H2 mémoire |
| Unitaire (Service) | @ExtendWith(MockitoExtension) | — | mock |
Test unitaire Controller — @WebMvcTest
@WebMvcTest(TransferRestController.class) // charge UNIQUEMENT la couche web @AutoConfigureMockMvc(addFilters = false) // désactive Spring Security public class TransferControllerUnitTest { @Autowired private MockMvc mockMvc; @MockBean // remplace le vrai service par un mock Mockito private TransferService transferService; @Test public void shouldReturnTransferWhenCreateTransferSucceeds() throws Exception { Transfer transfer = new Transfer(); transfer.setId(1L); transfer.setAmount(100.0); when(transferService.createTransfer(any(), any(), anyDouble(), any(), any())) .thenReturn(transfer); mockMvc.perform(post("/api/transfers") .contentType(MediaType.APPLICATION_JSON) .content(asJsonString(transfer))) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1L)); } @Test public void shouldReturnBadRequestWhenCreateTransferFails() throws Exception { when(transferService.createTransfer(any(), any(), anyDouble(), any(), any())) .thenThrow(new TransferException("Solde insuffisant.")); mockMvc.perform(post("/api/transfers") .contentType(MediaType.APPLICATION_JSON) .content("{}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value("FAILURE")); } }
Test d'intégration — @SpringBootTest
@SpringBootTest // charge tout le contexte Spring @AutoConfigureMockMvc @WithMockUser(username = "user") // simule un utilisateur authentifié public class TransferControllerTest { @Autowired private MockMvc mockMvc; @Test public void createTransferTest_Success() throws Exception { mockMvc.perform(post("/api/transfers") .contentType(MediaType.APPLICATION_JSON) .content(asJsonString(buildTransferRequest()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").exists()); } }
Test unitaire Service/Utilitaire — @ExtendWith(MockitoExtension.class)
@ExtendWith(MockitoExtension.class) // Mockito sans Spring, ultra rapide public class CGBIbanValidatorTest { @BeforeEach void setUp() { validator = CGBIbanValidator.getInstanceValidator(); } @Test public void shouldReturnSameInstanceWhenCalledMultipleTimes() { assertSame(CGBIbanValidator.getInstanceValidator(), CGBIbanValidator.getInstanceValidator()); } @Test public void shouldThrowInvalidIbanFormatExceptionWhenIbanIsNull() { assertThrows(InvalidIbanFormatException.class, () -> validator.isIbanStructureValide(null)); } @Test public void shouldThrowInvalidUnCheckableIbanExceptionWhenCrcIsWrong() { // FR + "00" (faux CRC) + 23 chiffres — structure OK, CRC KO assertThrows(InvalidUnCheckableIbanException.class, () -> validator.isIbanValide("FR00" + "12345678901234567890123")); } }
application.properties pour les tests
# src/test/resources/application.properties — H2 en mémoire, pas de fichier
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.sql.init.mode=never
Écris un test @WebMvcTest qui vérifie que supprimer un virement avec un ID inexistant retourne HTTP 400 avec status: "FAILURE".
@Test public void shouldReturnBadRequestWhenDeleteTransferNotFound() throws Exception { when(transferService.deleteTransfer(9999L)) .thenThrow(new DeleteTransferException(FailureTransfert.OBJECT_NOT_FOUND)); mockMvc.perform(delete("/api/transfers") .contentType(MediaType.APPLICATION_JSON) .content("9999")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value("FAILURE")); }
JWT — JSON Web Token Mission 8
JWT remplace Basic Auth pour une authentification stateless (sans session serveur). Le token est signé cryptographiquement avec HMAC-SHA256.
Structure d'un JWT
// Format : header.payload.signature (séparés par des points) eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header (base64url) .eyJzdWIiOiJ1c2VyIiwiZXhwIjoxNzA5MDAwfQ ← payload (base64url) .SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ ← signature HMAC-SHA256 // Header décodé : { "alg": "HS256", "typ": "JWT" } // Payload (claims) décodé : { "sub": "comptable", "iat": 1745000000, "exp": 1745036000 }
JwtService CGB — implémentation JJWT 0.11.5
// pom.xml — 3 artefacts jjwt obligatoires <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.11.5</version></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.11.5</version><scope>runtime</scope></dependency> <dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.11.5</version><scope>runtime</scope></dependency> @Service public class JwtService { @Value("${jwt.secret}") // clé Base64 dans application.properties private String secret; private SecretKey secretKey; private JwtParser jwtParser; @PostConstruct // s'exécute après injection des @Value public void init() { byte[] keyBytes = Decoders.BASE64.decode(secret); this.secretKey = Keys.hmacShaKeyFor(keyBytes); this.jwtParser = Jwts.parserBuilder() // API JJWT 0.11.x .setSigningKey(secretKey) .build(); } public String generateToken(UserDetails userDetails) { return Jwts.builder() .setClaims(new HashMap<>()) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 10h .signWith(secretKey, SignatureAlgorithm.HS256) // clé d'abord, algo ensuite .compact(); } public String extractUsername(String token) { return jwtParser.parseClaimsJws(token).getBody().getSubject(); } public boolean isTokenValid(String token, UserDetails userDetails) { String username = extractUsername(token); Date expiration = jwtParser.parseClaimsJws(token).getBody().getExpiration(); return username.equals(userDetails.getUsername()) && !expiration.before(new Date()); } }
Jwts.parser() est déprécié. Il faut utiliser Jwts.parserBuilder().setSigningKey(...).build(). Et dans signWith() : la clé passe en premier, l'algorithme en second.
Quelle est la différence entre Basic Auth et JWT pour une API REST ? Dans quel cas JWT est-il préférable ?
| Basic Auth | JWT | |
|---|---|---|
| Envoyé à chaque requête | login + mdp | token signé |
| Vérification serveur | BDD à chaque requête | signature locale (stateless) |
| Expiration | non | oui (exp claim, 10h dans CGB) |
| Révocation | immédiate | difficile (blacklist nécessaire) |
| Usage | APIs internes simples | APIs publiques, microservices |
JWT est préférable quand le serveur ne doit pas stocker d'état de session (microservices, scalabilité horizontale). Avec SessionCreationPolicy.STATELESS, Spring ne crée aucune session HTTP.
Mission 1 — Étude de l'API existante Mission
Objectif : comprendre et tester l'API CGB existante avec Postman ou curl.
Endpoints disponibles
| Méthode | URL | Description |
|---|---|---|
| GET | /api/transfers | Liste tous les virements |
| POST | /api/transfers | Créer un virement |
| DELETE | /api/transfers | Supprimer un virement (id dans le body) |
Tester avec curl
# Lister les virements (Basic Auth : user / password) curl -u user:password http://localhost:8080/api/transfers # Créer un virement curl -u user:password -X POST http://localhost:8080/api/transfers \ -H "Content-Type: application/json" \ -d '{"sourceAccountNumber":"234567891","destinationAccountNumber":"123456789","amount":50.0,"transferDate":"2024-01-15"}' # Supprimer le virement id=1 curl -u user:password -X DELETE http://localhost:8080/api/transfers \ -H "Content-Type: application/json" \ -d '1'
Lance l'application CGB (mvn spring-boot:run) et teste le POST avec un montant supérieur au solde du compte. Quel message d'erreur obtiens-tu ? Quel code HTTP ?
Le compte 123456789 a un solde de 300.00€. Si tu envoies amount: 500.0, le TransferService lève RuntimeException("Insufficient funds").
Réponse attendue — HTTP 400 :
{
"status": "FAILURE",
"message": "Insufficient funds"
}
Mission 2 — Validation IBAN Mission
Implémenter la validation IBAN dans le service avant de créer un virement.
Ajoute la validation IBAN dans TransferService.createTransfer() en utilisant CGBIbanValidator.getInstance(). Que faire si l'IBAN est invalide ?
@Transactional public Transfer createTransfer(String source, String dest, Double amount, LocalDate date, String desc) { // Validation IBAN (Mission 2) CGBIbanValidator validator = CGBIbanValidator.getInstance(); if (!validator.validate(source)) { throw new RuntimeException("Invalid source IBAN: " + source); } if (!validator.validate(dest)) { throw new RuntimeException("Invalid destination IBAN: " + dest); } // Suite du code existant... Account sourceAccount = accountRepository.findById(source).orElseThrow(...); // ... }
On lève une RuntimeException (catchée dans le Controller → HTTP 400). Pour aller plus loin : créer une InvalidIbanException extends TransferException.
Mission 3 — Correction des bugs Mission
Identifier et corriger les bugs dans TransferService.
Bug 1 — deleteTransfer (cf. Exercice 7)
deleteById() appelé avant la vérification d'existence. Correction : vérifier d'abord avec findById().
Bug 2 — Solde stocké en Double
Utiliser Double pour de l'argent cause des erreurs d'arrondi (virgule flottante IEEE 754). Exemple : 0.1 + 0.2 = 0.30000000000000004.
Quel type Java utiliser à la place de Double pour stocker un montant d'argent ? Pourquoi Double est-il dangereux ?
Utiliser BigDecimal — il stocke les décimaux de manière exacte.
// ❌ Double : imprécis Double solde = 0.1 + 0.2; // = 0.30000000000000004 // ✅ BigDecimal : exact BigDecimal solde = new BigDecimal("0.1") .add(new BigDecimal("0.2")); // = 0.3 exactement // Dans l'Entity : @Column(precision = 15, scale = 2) // 15 chiffres, 2 décimales private BigDecimal solde; // Comparaison (ne PAS utiliser ==) : if (solde.compareTo(amount) < 0) { // solde < amount throw new RuntimeException("Insufficient funds"); }
Mission 4 — Traitement asynchrone en lots Mission
Créer un endpoint POST /api/transfers/batch qui accepte une liste de virements et les traite en arrière-plan.
// Nouveau endpoint : @PostMapping("/batch") public ResponseEntity<String> createBatch(@RequestBody List<TransferRequest> requests) { batchService.processLot(requests); // @Async — retour immédiat return ResponseEntity .status(HttpStatus.ACCEPTED) // HTTP 202 .body("Lot de " + requests.size() + " virements en cours de traitement"); }
Mission 5 — Notifications Mission
Envoyer une notification asynchrone après chaque virement (email, log, webhook…).
@Service public class NotificationService { @Async public void notifyTransferCreated(Transfer transfer) { // Simulation d'envoi d'email / webhook System.out.println("[NOTIF] Virement créé : ID=" + transfer.getId() + " montant=" + transfer.getAmount()); } } // Dans TransferService.createTransfer() : Transfer saved = transferRepository.save(transfer); notificationService.notifyTransferCreated(saved); // async, non bloquant return saved;
Mission 6 — Rôles et entités Customer / UserCGB Mission
Ajouter les entités Role, Customer et UserCGB dans le package cgb.transfer.security.entity, plus leurs repositories, en suivant exactement le patron du cours CoursJava24.
Enum Role
package cgb.transfer.security.entity; public enum Role { USER, // utilisateur standard COMPTABLE, // accès aux fonctions comptables ADMIN // accès complet }
Entité Customer (client bancaire)
@Entity public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToMany(mappedBy = "customer") // "customer" = champ dans UserCGB private Set<UserCGB> users; // getters / setters }
Entité UserCGB (utilisateur de l'application)
@Entity @Table(name = "userappli") // évite le mot réservé "user" en SQL public class UserCGB { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String password; // stocké haché (BCrypt) @Enumerated(EnumType.STRING) // stocke le nom (ex: "COMPTABLE"), pas l'ordinal private Role role; @ManyToOne @JoinColumn(name = "customer_id") @JsonIgnore // évite les boucles infines lors de la sérialisation JSON private Customer customer; // getters / setters }
Repositories
// UserCGBRepository — findByUsername() pour l'authentification public interface UserCGBRepository extends JpaRepository<UserCGB, Long> { Optional<UserCGB> findByUsername(String username); } // CustomerRepository — standard, sans méthode custom public interface CustomerRepository extends JpaRepository<Customer, Long> { }
Initialisation des données (DatabaseInitializer enrichi)
// Création de 2 clients + 3 utilisateurs au démarrage private void insertSampleUsersAndCustomers() { Customer client1 = new Customer(); client1.setName("Dupont SA"); customerRepository.save(client1); UserCGB comptable = new UserCGB(); comptable.setUsername("comptable"); comptable.setPassword("comptable123"); // haché par registerUser() comptable.setRole(Role.COMPTABLE); comptable.setCustomer(client1); userDetailsService.registerUser(comptable); // encode le mot de passe }
@Table(name = "userappli") ? Le mot USER est un mot réservé en SQL (H2, MySQL…). Sans ce renommage, Hibernate génère une table USER qui provoque une erreur SQL au démarrage.
Quelle annotation JPA utilise-t-on pour une relation "un client possède plusieurs utilisateurs" ? Et pour la relation inverse "un utilisateur appartient à un client" ?
| Relation | Annotation côté "un" | Annotation côté "plusieurs" |
|---|---|---|
| 1 Customer → N UserCGB | @OneToMany(mappedBy="customer") | @ManyToOne |
| 1 UserCGB → 1 Role | @Enumerated(EnumType.STRING) — pas de table séparée | |
mappedBy = "customer" indique que c'est le champ UserCGB.customer qui porte la clé étrangère customer_id en BDD. Sans mappedBy, JPA créerait une table de jonction inutile.
Mission 7 — Rejeu des transactions en échec Mission
Rejouer un nouveau lot à partir des transactions delayed (RETARDE) d'un lot existant.
Un virement est RETARDE quand il n'a pas pu être exécuté faute de fonds suffisants au moment du traitement. L'endpoint génère un nouveau lot prêt à être soumis, dont la description reprend l'ancienne préfixée par "REJEU — ".
// POST /send/lot/{id}/rejeu → crée et soumet un nouveau lot depuis les RETARDE du lot {id} @PostMapping("/{id}/rejeu") public ResponseEntity<?> rejeuLot(@PathVariable Long id) { Lot lotOriginal = lotRepository.findById(id) .orElseThrow(() -> new RuntimeException("Lot introuvable : " + id)); // Filtrer uniquement les virements RETARDE List<Virement> delayed = lotOriginal.getVirements().stream() .filter(v -> v.getEtat() == EtatVirement.RETARDE) .toList(); if (delayed.isEmpty()) { return ResponseEntity.badRequest().body("Aucun virement RETARDE dans ce lot."); } // Construire un nouveau LotRequest à partir des virements delayed LotRequest rejeu = new LotRequest(); rejeu.setSourceAccount(lotOriginal.getSourceAccount()); rejeu.setDescriptionLot("REJEU — " + lotOriginal.getDescriptionLot()); rejeu.setRefLot("REJEU-" + lotOriginal.getRefLot()); rejeu.setVirements(delayed.stream().map(v -> { VirementRequest vr = new VirementRequest(); vr.setDestAccount(v.getDestAccount()); vr.setAmount(v.getAmount()); vr.setDescription(v.getDescription()); return vr; }).toList()); Lot nouveauLot = lotService.soumettreLot(rejeu); return ResponseEntity.status(HttpStatus.ACCEPTED).body(nouveauLot); }
Mission 8 — Authentification JWT Mission
Remplacer Basic Auth par JWT. L'utilisateur appelle POST /login et reçoit un token à envoyer dans le header Authorization: Bearer ... de chaque requête.
Flux complet
// 1. Login → reçoit le token (string brut) POST /login Body: { "username": "comptable", "password": "comptable123" } ← 200 OK : eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjb21wdGFibGUi... // 2. Toutes les requêtes suivantes portent le token POST /api/transfers Header: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9... ← 200 OK : { "id": 1, ... }
AuthControllerJeton — endpoint de login
@RestController public class AuthControllerJeton { @Autowired private AuthenticationManager authenticationManager; @Autowired private MyUserDetailsService userDetailsService; @Autowired private JwtService jwtUtil; @PostMapping("/login") public String login(@RequestBody AuthenticationRequest req) throws Exception { // 1. Vérifie username + password via AuthenticationManager authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(req.getUsername(), req.getPassword()) ); // 2. Charge les UserDetails (username + authorities) UserDetails userDetails = userDetailsService.loadUserByUsername(req.getUsername()); // 3. Génère et retourne le token JWT return jwtUtil.generateToken(userDetails); } }
JwtAuthenticationFilter — vérifie le token sur chaque requête
// Non @Component — instancié manuellement dans SecurityConfig (évite la circularité) public class JwtAuthenticationFilter extends OncePerRequestFilter { private JwtService jwtService; private MyUserDetailsService userDetailsService; // Setters injectés depuis SecurityConfig public void setJwtService(JwtService s) { this.jwtService = s; } public void setUserDetailsService(MyUserDetailsService s) { this.userDetailsService = s; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String jwt = authHeader.substring(7); String username = jwtService.extractUsername(jwt); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); if (jwtService.isTokenValid(jwt, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } } filterChain.doFilter(request, response); // passe au filtre suivant dans la chaîne } }
SecurityConfig — remplace httpBasic() par le filtre JWT
@Configuration @EnableWebSecurity @EnableMethodSecurity // active @PreAuthorize sur les méthodes public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtService jwtService, MyUserDetailsService uds) throws Exception { // Instanciation manuelle pour éviter la dépendance circulaire JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(); jwtFilter.setJwtService(jwtService); jwtFilter.setUserDetailsService(uds); http.csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/console/**").permitAll() // console H2 libre .requestMatchers("/login/**").permitAll() // login sans token .anyRequest().authenticated()) .formLogin(form -> form.disable()) .sessionManagement(s -> s.sessionCreationPolicy( SessionCreationPolicy.STATELESS)) // JWT = pas de session .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .headers(h -> h.frameOptions(f -> f.sameOrigin())); return http.build(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
MyUserDetailsService — pont entre UserCGB et Spring Security
@Service public class MyUserDetailsService implements UserDetailsService { private final UserCGBRepository userRepository; private final PasswordEncoder passwordEncoder; @Autowired public MyUserDetailsService(UserCGBRepository repo, PasswordEncoder enc) { this.userRepository = repo; this.passwordEncoder = enc; } @Override public UserDetails loadUserByUsername(String username) { UserCGB user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Spring exige le préfixe ROLE_ pour que hasRole('COMPTABLE') fonctionne List<GrantedAuthority> authorities = List.of( new SimpleGrantedAuthority("ROLE_" + user.getRole().name()) ); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities ); } public UserCGB registerUser(UserCGB user) { user.setPassword(passwordEncoder.encode(user.getPassword())); return userRepository.save(user); } }
| Username | Password | Rôle | Client |
|---|---|---|---|
comptable | comptable123 | COMPTABLE | Dupont SA |
user1 | user123 | USER | Dupont SA |
admin | admin123 | ADMIN | Martin SARL |
Pourquoi le JwtAuthenticationFilter est-il instancié manuellement dans SecurityConfig et non déclaré @Component ?
Si JwtAuthenticationFilter est un @Component, Spring l'injecte directement dans son propre bean. Mais SecurityConfig déclare le bean PasswordEncoder, dont dépend MyUserDetailsService, dont dépend le filtre… ce qui crée une dépendance circulaire :
SecurityConfig → PasswordEncoder → MyUserDetailsService → JwtAuthFilter → SecurityConfig ↻
En l'instanciant manuellement via des setters, on brise le cycle. Spring n'essaie plus de l'injecter automatiquement et la chaîne s'arrête.