On construit une API REST de blog complète : articles, utilisateurs, authentification JWT. Ce chapitre pose les fondations — la structure du projet, les règles d'architecture, et le squelette de code qu'on remplira dans les chapitres suivants.
1. Ce qu'on va construire
Le projet fil rouge est une API REST de blog. Voici les fonctionnalités visées :
Méthode
Route
Description
Authentification
POST
/api/auth/register
Créer un compte
Publique
POST
/api/auth/login
Se connecter, recevoir un JWT
Publique
GET
/api/articles
Lister les articles publiés (paginé)
Publique
GET
/api/articles/{id}
Consulter un article
Publique
POST
/api/articles
Créer un article
JWT requis
PUT
/api/articles/{id}
Modifier son article
JWT + être l'auteur
DELETE
/api/articles/{id}
Supprimer son article
JWT + être l'auteur ou ADMIN
2. Les quatre couches
L'architecture en couches consiste à découper l'application en niveaux de responsabilité, chacun ne connaissant que celui juste en dessous.
Controller
Reçoit la requête HTTP. Valide le format. Délègue au Service. Retourne une réponse HTTP.
Accès aux données uniquement. Spring Data génère le SQL. Aucune logique métier.
↓ lit / écrit
Entity
Représentation Java d'une table SQL. Ne sort jamais du Service — on retourne des DTOs.
Les trois règles à respecter
Une couche ne saute pas de niveau
Le Controller n'appelle jamais le Repository directement. Même pour un simple findAll(), il passe par le Service.
L'entité ne sort pas du Service
Le Controller ne manipule que des DTOs. Retourner une entité JPA en JSON expose la structure de la base et provoque des problèmes de chargement LAZY.
Chaque couche a une seule responsabilité
Pas de règle métier dans le Controller. Pas de manipulation HTTP dans le Service. Pas de logique dans le Repository.
⚠ Piège classique — Retourner une entité JPA directement en JSON
C'est l'erreur la plus fréquente et elle provoque plusieurs problèmes simultanés :
// ❌ MAUVAIS — retourner l'entité directement
@GetMapping("/{id}")
public Article parId(@PathVariable Long id) {
return articleRepository.findById(id).orElseThrow(); // entité JPA en JSON
}
Fuite de données — si Article a une relation vers User, le JSON contiendra le mot de passe hashé de l'auteur.
LazyInitializationException — Jackson tente de sérialiser une relation LAZY hors transaction → exception à l'exécution.
Boucle infinie — si Article pointe vers User et User vers ses Article, Jackson boucle jusqu'au StackOverflowError.
Couplage API ↔ base — renommer une colonne SQL casse le contrat de l'API.
// ✅ BON — retourner un DTO construit par le service
@GetMapping("/{id}")
public ArticleResponse parId(@PathVariable Long id) {
return articleService.findById(id); // le service retourne un DTO
}
3. Le trajet d'une requête
Suivre une requête de bout en bout aide à comprendre le rôle exact de chaque couche. Prenons POST /api/articles :
1. Le client envoie :
POST /api/articles
Authorization: Bearer eyJhbGci...
{ "titre": "Spring Boot 4", "contenu": "...", "publie": true }
2. JwtAuthenticationFilter intercepte la requête
→ valide le token, place l'utilisateur dans SecurityContextHolder
3. ArticleController.creer() reçoit la requête
→ Jackson désérialise le JSON en ArticleRequest (record)
→ @Valid vérifie les contraintes (@NotBlank sur titre, etc.)
→ @AuthenticationPrincipal injecte le User connecté
→ appelle articleService.creer(request, user.getEmail())
4. ArticleService.creer() applique la logique métier
→ cherche l'auteur : userRepository.findByEmail(email)
→ construit une entité Article à partir du DTO
→ appelle articleRepository.save(article)
→ convertit l'entité sauvegardée en ArticleResponse
5. ArticleRepository.save() persiste
→ Hibernate génère : INSERT INTO article (titre, contenu, ...) VALUES (...)
→ retourne l'entité avec son id généré
6. ArticleController retourne la réponse
→ ResponseEntity.status(201).body(articleResponse)
→ Jackson sérialise ArticleResponse en JSON
→ HTTP 201 Created + Location: /api/articles/42
Chaque couche transforme — JSON → DTO (Controller), DTO → Entité (Service), Entité → SQL (Repository), puis le chemin inverse au retour. Cette séparation est ce qui rend chaque couche testable indépendamment.
Organisation par couche technique — c'est la structure classique et la plus simple à comprendre. Le Projet 3 montrera une alternative (organisation par domaine métier) avec l'architecture hexagonale.
5. Le pom.xml complet
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<groupId>fr.jeandecode</groupId>
<artifactId>blog-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>blog-api</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- API REST — nom Spring Boot 4 : webmvc et non web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Persistance : Hibernate + Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Authentification et autorisations -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Validation des DTOs : @NotBlank, @Size, @Email -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Driver PostgreSQL — scope runtime : pas nécessaire à la compilation -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok — génère constructeurs, getters, setters -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JJWT — génération et validation des tokens JWT -->
<!-- Trois artefacts : api (compilation), impl et jackson (runtime) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Rechargement à chaud en développement -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Tests — nom Spring Boot 4 : webmvc-test et non test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<!-- H2 en mémoire pour les tests d'intégration -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Utilitaires de test Spring Security : @WithMockUser -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Pourquoi trois artefacts JJWT ? — jjwt-api contient les interfaces (nécessaires à la compilation), jjwt-impl l'implémentation et jjwt-jackson la sérialisation JSON des claims. Les deux derniers sont en runtime car le code ne les référence jamais directement.
6. Le point d'entrée et la configuration
package fr.jeandecode.blogapi;
@SpringBootApplication
public class BlogApiApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApiApplication.class, args);
}
}
spring:
application:
name: blog-api
datasource:
url: jdbc:postgresql://localhost:5432/blog_db
username: postgres
password: postgres
# HikariCP est le pool de connexions par défaut
hikari:
maximum-pool-size: 10
jpa:
hibernate:
# update : Hibernate crée/modifie les tables au démarrage
# ⚠ développement uniquement — en production : validate + Flyway
ddl-auto: update
show-sql: true # affiche les requêtes SQL en console
properties:
hibernate:
format_sql: true # SQL indenté et lisible
# Active le format d'erreur RFC 7807 (ProblemDetail)
mvc:
problemdetails:
enabled: true
# Configuration applicative personnalisée
app:
jwt:
# ⚠ en production : variable d'environnement, jamais en dur
secret: ${JWT_SECRET:bWEtY2xlLXNlY3JldGUtZGUtZGV2ZWxvcHBlbWVudC1hLWNoYW5nZXI=}
expiration-ms: 86400000 # 24 heures
# Logs
logging:
level:
fr.jeandecode.blogapi: DEBUG
org.hibernate.SQL: DEBUG
⚠ Piège classique — ddl-auto: update en production
update laisse Hibernate modifier le schéma automatiquement. C'est pratique en développement, mais dangereux en production :
Hibernate ajoute des colonnes mais ne les supprime jamais — le schéma se pollue.
Un changement de type peut échouer silencieusement ou corrompre des données.
Aucun historique, aucun rollback possible.
En production : ddl-auto: validate (vérifie que le schéma correspond aux entités, sans le modifier) et gérer les migrations avec Flyway ou Liquibase.
# Configuration de production
spring:
jpa:
hibernate:
ddl-auto: validate # échoue au démarrage si le schéma ne correspond pas
show-sql: false
7. Premier démarrage
# 1. Démarrer PostgreSQL
$ docker compose up -d
[+] Running 2/2
✔ Volume "blog-api_blog_pgdata" Created
✔ Container blog-postgres Started
# 2. Vérifier que la base répond
$ docker compose ps
NAME STATUS PORTS
blog-postgres Up 10 seconds (healthy) 0.0.0.0:5432->5432/tcp
# 3. Démarrer l'application
$ mvn spring-boot:run
. ____ _
/\\ / ___'_ __ _ _(_)_ __ __ _
( ( )\___ | '_ | '_| | '_ \/ _` |
\\/ ___)| |_)| | | | | || (_| |
' |____| .__|_| |_|_| |_\__, |
=========|_|==============|___/
:: Spring Boot :: (v4.1.0)
INFO --- Starting BlogApiApplication using Java 21
INFO --- HikariPool-1 - Start completed.
INFO --- Tomcat started on port 8080 (http)
INFO --- Started BlogApiApplication in 2.847 seconds
⚠ Piège classique — L'application ne démarre pas — les erreurs les plus fréquentes
Failed to configure a DataSource — PostgreSQL n'est pas démarré ou l'URL est fausse. Vérifier docker compose ps.
Connection refused: localhost:5432 — le conteneur n'a pas fini de démarrer. Attendre le statut healthy.
Web server failed to start. Port 8080 was already in use — une autre application occupe le port. La tuer (lsof -ti:8080 | xargs kill) ou changer le port avec server.port: 8081.
Using generated security password: xxx — normal tant que SecurityConfig n'est pas écrit. On le fera au chapitre JWT.
Exo 1Repérer les violations d'architecture
Le code suivant contient trois violations des règles d'architecture. Les identifier et expliquer pourquoi elles posent problème.
@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleRepository articleRepository;
private final ArticleService articleService;
@GetMapping
public List lister() {
return articleRepository.findAll();
}
@PostMapping
public Article creer(@RequestBody ArticleRequest req) {
if (req.titre().length() > 200) {
throw new IllegalArgumentException("Titre trop long");
}
return articleService.creer(req);
}
}
Voir la solution
Violation 1 — le Controller injecte le Repository. Le Controller ne doit connaître que le Service. Sauter la couche Service signifie qu'aucune logique métier ni transaction ne s'applique, et que le code n'est pas réutilisable.
Violation 2 — retour d'entités Article au lieu de DTOs. Expose la structure de la base, risque de LazyInitializationException et de fuite de données (mot de passe de l'auteur via la relation).
Violation 3 — règle métier dans le Controller. La longueur maximale du titre est une règle métier. Sa place est soit dans le DTO (@Size(max = 200)), soit dans le Service — pas dans le Controller.
Version corrigée :
@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {
// ✅ une seule dépendance : le service
private final ArticleService articleService;
// ✅ retourne des DTOs
@GetMapping
public List lister() {
return articleService.findAll();
}
// ✅ la validation est déclarative, via @Valid + @Size dans le DTO
@PostMapping
public ResponseEntity creer(@Valid @RequestBody ArticleRequest req) {
return ResponseEntity.status(HttpStatus.CREATED).body(articleService.creer(req));
}
}
public record ArticleRequest(
@NotBlank(message = "Le titre est obligatoire")
@Size(max = 200, message = "Le titre ne peut pas dépasser 200 caractères")
String titre,
@NotBlank String contenu,
boolean publie
) {}
JPA fait le pont entre les classes Java et les tables SQL. Spring Data génère les requêtes courantes à partir du simple nom des méthodes. Ce chapitre construit les entités User et Article, leurs relations, et leurs repositories.
1. Comprendre l'ORM
Un ORM (Object-Relational Mapping) traduit automatiquement entre le monde objet de Java et le monde relationnel de SQL. En Spring Boot, l'ORM est Hibernate, piloté via la spécification JPA.
Monde Java
Monde SQL
Annotation JPA
Une classe
Une table
@Entity / @Table
Un champ
Une colonne
@Column
Une instance
Une ligne
—
Une référence vers un objet
Une clé étrangère
@ManyToOne / @JoinColumn
Une List<T>
Plusieurs lignes liées
@OneToMany
// Cette classe Java...
@Entity
@Table(name = "article")
public class Article {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String titre;
}
-- ...produit cette table SQL (générée par Hibernate avec ddl-auto: update)
CREATE TABLE article (
id BIGSERIAL NOT NULL,
titre VARCHAR(200) NOT NULL,
PRIMARY KEY (id)
);
Voir le SQL généré — avec spring.jpa.show-sql: true et format_sql: true dans application.yml, toutes les requêtes s'affichent en console. Indispensable pour comprendre ce qu'Hibernate fait réellement.
# Démarrer
$ docker compose up -d
# Se connecter en ligne de commande pour inspecter la base
$ docker exec -it blog-postgres psql -U postgres -d blog_db
blog_db=# \dt -- lister les tables
blog_db=# \d article -- décrire la table article
blog_db=# SELECT * FROM article; -- voir les données
blog_db=# \q -- quitter
# Arrêter (les données sont conservées dans le volume)
$ docker compose down
# Tout supprimer, données comprises
$ docker compose down -v
3. L'entité User
User a une particularité : elle implémente UserDetails, l'interface de Spring Security. Cela permet à Spring de la manipuler directement lors de l'authentification, sans classe intermédiaire.
@Entity
// "user" est un mot réservé en SQL — il faut renommer la table
@Table(name = "utilisateur")
@Getter @Setter
@NoArgsConstructor // JPA exige un constructeur sans arguments
@AllArgsConstructor
@Builder // permet User.builder().email("...").build()
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 180)
private String email;
// Stocke le hash BCrypt, jamais le mot de passe en clair
@Column(nullable = false)
private String password;
@Column(nullable = false, length = 100)
private String nom;
// EnumType.STRING stocke "USER"/"ADMIN" en base
// EnumType.ORDINAL stockerait 0/1 — à éviter absolument
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@Builder.Default
private Role role = Role.USER;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
}
// ── Méthodes exigées par UserDetails (Spring Security) ──────
@Override
public String getUsername() {
return email; // l'identifiant de connexion est l'email
}
@Override
public Collection extends GrantedAuthority> getAuthorities() {
// Spring Security attend des rôles préfixés par "ROLE_"
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
}
public enum Role {
USER,
ADMIN
}
Pourquoi @Table(name = "utilisateur") ? — USER est un mot-clé réservé en PostgreSQL. Sans renommage, Hibernate génère CREATE TABLE user (...) qui échoue avec une erreur de syntaxe SQL. Autres mots à éviter : order, group, table.
⚠ Piège classique — @Enumerated(EnumType.ORDINAL) — le défaut dangereux
Si on omet @Enumerated, JPA utilise ORDINAL par défaut : l'enum est stockée comme un entier correspondant à sa position.
// Version initiale — stockée en base : USER=0, ADMIN=1
public enum Role {
USER, // 0
ADMIN // 1
}
// Six mois plus tard, on ajoute un rôle au milieu...
public enum Role {
USER, // 0
MODERATOR, // 1 ← décale tout !
ADMIN // 2
}
// Tous les administrateurs en base (valeur 1) deviennent des modérateurs. 💥
Toujours écrire @Enumerated(EnumType.STRING). La base stocke "ADMIN", insensible à l'ordre de déclaration. Le coût en espace est négligeable.
4. L'entité Article et la relation ManyToOne
@Entity
@Table(name = "article")
@Getter @Setter
@NoArgsConstructor
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String titre;
// TEXT en PostgreSQL — pas de limite de longueur
@Column(columnDefinition = "TEXT", nullable = false)
private String contenu;
@Column(nullable = false)
private boolean publie = false;
// ── RELATION : plusieurs articles pour un auteur ──────────
// FetchType.LAZY : l'auteur n'est PAS chargé automatiquement
// Il ne le sera que si on appelle article.getAuteur()
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "auteur_id", nullable = false)
private User auteur;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// ── CALLBACKS JPA ─────────────────────────────────────────
// Appelé automatiquement par Hibernate avant l'INSERT
@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
// Appelé automatiquement avant chaque UPDATE
@PreUpdate
void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
La colonne générée en base est auteur_id BIGINT NOT NULL REFERENCES utilisateur(id). Côté Java, on manipule un objet User — Hibernate fait la traduction.
FetchType — LAZY ou EAGER
FetchType.LAZY
FetchType.EAGER
Comportement
Charge la relation seulement quand on y accède
Charge la relation immédiatement, avec l'entité
Requêtes SQL
Une requête de base, une autre si on accède à la relation
Une seule requête avec JOIN
Défaut pour @ManyToOne
❌ EAGER (mauvais défaut)
✅ à changer manuellement
Défaut pour @OneToMany
✅ LAZY (bon défaut)
—
Recommandation
Toujours LAZY, puis charger explicitement au besoin
À éviter — provoque des requêtes inutiles
⚠ Piège classique — Le problème N+1
C'est le piège de performance le plus classique avec JPA. Charger 100 articles et accéder à leur auteur déclenche 101 requêtes SQL :
// ❌ Provoque N+1
public List findAll() {
List articles = articleRepository.findAll(); // 1 requête
return articles.stream()
.map(a -> new ArticleResponse(
a.getId(), a.getTitre(),
a.getAuteur().getEmail() // ← 1 requête PAR article : 100 requêtes !
))
.toList();
}
-- Ce que Hibernate exécute réellement :
SELECT * FROM article; -- 1 requête
SELECT * FROM utilisateur WHERE id = 1; -- +1
SELECT * FROM utilisateur WHERE id = 2; -- +1
SELECT * FROM utilisateur WHERE id = 3; -- +1
... (97 requêtes de plus)
Solution — utiliser JOIN FETCH dans une requête JPQL pour charger tout en une seule requête :
public interface ArticleRepository extends JpaRepository {
// JOIN FETCH charge l'auteur dans la même requête SQL
@Query("SELECT a FROM Article a JOIN FETCH a.auteur WHERE a.publie = true")
List findPubliesAvecAuteur();
}
-- Une seule requête avec jointure :
SELECT a.*, u.*
FROM article a
JOIN utilisateur u ON a.auteur_id = u.id
WHERE a.publie = true;
5. Les repositories Spring Data
Spring Data JPA génère l'implémentation des repositories à partir de l'interface. On ne code jamais de SELECT, INSERT, UPDATE à la main pour les cas standards.
Ce que JpaRepository fournit gratuitement
// JpaRepository
public interface ArticleRepository extends JpaRepository {
// Cette interface vide fournit déjà toutes ces méthodes :
}
Méthode héritée
Ce qu'elle fait
save(entity)
INSERT si l'id est null, UPDATE sinon. Retourne l'entité avec son id.
saveAll(entities)
Sauvegarde une collection en une fois
findById(id)
Retourne un Optional<T>
findAll()
Toutes les entités
findAll(Pageable)
Version paginée — retourne un Page<T>
findAll(Sort)
Version triée
existsById(id)
Test d'existence — plus efficace qu'un findById
count()
Nombre total de lignes
deleteById(id)
Suppression par id
delete(entity)
Suppression d'une entité
Les requêtes dérivées du nom de méthode
C'est la fonctionnalité la plus impressionnante de Spring Data : il analyse le nom de la méthode et génère le SQL correspondant. Il suffit de respecter la convention de nommage.
public interface ArticleRepository extends JpaRepository {
// WHERE publie = true
List findByPublieTrue();
// WHERE titre LIKE '%motCle%'
List findByTitreContaining(String motCle);
// WHERE titre LIKE '%motCle%' (insensible à la casse)
List findByTitreContainingIgnoreCase(String motCle);
// WHERE auteur_id = ? (navigue dans la relation)
List findByAuteurId(Long auteurId);
// WHERE auteur.email = ? (navigue sur deux niveaux)
List findByAuteurEmail(String email);
// WHERE publie = true AND auteur_id = ?
List findByPublieTrueAndAuteurId(Long auteurId);
// WHERE publie = true ORDER BY created_at DESC
List findByPublieTrueOrderByCreatedAtDesc();
// WHERE created_at > ?
List findByCreatedAtAfter(LocalDateTime date);
// SELECT COUNT(*) WHERE auteur_id = ?
long countByAuteurId(Long auteurId);
// SELECT EXISTS(... WHERE titre = ?)
boolean existsByTitre(String titre);
// DELETE WHERE auteur_id = ?
void deleteByAuteurId(Long auteurId);
}
findBy… / getBy… / readBy…
Préfixes de lecture — équivalents
countBy… / existsBy…
Comptage et test d'existence
deleteBy… / removeBy…
Suppression — nécessite @Transactional
And / Or
Combiner des critères : findByTitreAndPublie
Containing / StartingWith / EndingWith
LIKE avec %
IgnoreCase
Insensible à la casse
Between / LessThan / GreaterThan / After / Before
Comparaisons et intervalles
IsNull / IsNotNull / True / False
Tests de valeur
OrderBy…Asc / OrderBy…Desc
Tri
Top / First
Limiter : findTop5ByOrderByCreatedAtDesc
⚠ Piège classique — Une faute de frappe dans le nom de méthode
Spring Data valide les noms de méthodes au démarrage. Si un champ n'existe pas, l'application refuse de démarrer :
org.springframework.data.repository.query.QueryCreationException:
Could not create query for public abstract java.util.List
fr.jeandecode.blogapi.repository.ArticleRepository.findByTitle(java.lang.String)
Reason: Failed to create query for method ...
No property 'title' found for type 'Article'.
Did you mean 'titre'?
C'est une bonne nouvelle : l'erreur est détectée au démarrage, pas en production. Le message indique même le nom probable.
Requêtes personnalisées avec @Query
Au-delà d'un certain niveau de complexité, le nom de méthode devient illisible (findByPublieTrueAndAuteurEmailAndCreatedAtAfterOrderByCreatedAtDesc…). On passe alors à @Query en JPQL.
JPQL ressemble à SQL, mais manipule des entités Java et leurs champs, pas des tables et des colonnes.
public interface ArticleRepository extends JpaRepository {
// JPQL : "Article" est la CLASSE, "a.auteur.email" navigue dans les OBJETS
@Query("SELECT a FROM Article a WHERE a.auteur.email = :email AND a.publie = true")
List findPubliesParAuteur(@Param("email") String email);
// JOIN FETCH pour éviter le N+1
@Query("SELECT a FROM Article a JOIN FETCH a.auteur WHERE a.publie = true")
List findPubliesAvecAuteur();
// Recherche multi-champs
// Recherche multi-champs — concatenation de chaines
@Query("SELECT a FROM Article a "
+ "WHERE a.publie = true "
+ " AND (LOWER(a.titre) LIKE LOWER(CONCAT('%', :q, '%')) "
+ " OR LOWER(a.contenu) LIKE LOWER(CONCAT('%', :q, '%')))")
Page rechercher(@Param("q") String q, Pageable pageable);
// Projection : retourner autre chose que l'entité complète
@Query("SELECT a.auteur.email, COUNT(a) FROM Article a GROUP BY a.auteur.email")
List
JPQL n'est pas du SQL — on écrit Article (la classe) et non article (la table), a.auteur.email (navigation entre objets) et non une jointure explicite. Hibernate traduit en SQL. Pour du multi-ligne, Java 15+ propose aussi les text blocks avec des triples guillemets.
Le repository User
public interface UserRepository extends JpaRepository {
// Utilisé par Spring Security lors de l'authentification
Optional findByEmail(String email);
// Utilisé à l'inscription pour vérifier l'unicité
boolean existsByEmail(String email);
}
6. La pagination
Retourner 10 000 articles d'un coup n'est pas viable. Spring Data gère la pagination nativement avec Pageable et Page<T>.
public interface ArticleRepository extends JpaRepository {
// Il suffit d'ajouter un paramètre Pageable et de retourner Page
Page findByPublieTrue(Pageable pageable);
}
public Page findPublies(Pageable pageable) {
// Page.map() convertit le contenu tout en conservant les métadonnées
return articleRepository.findByPublieTrue(pageable)
.map(ArticleResponse::from);
}
@GetMapping
public Page lister(
// @PageableDefault définit les valeurs si le client ne précise rien
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return articleService.findPublies(pageable);
}
# Le client contrôle la pagination via les paramètres d'URL
GET /api/articles?page=0&size=10
GET /api/articles?page=2&size=10&sort=titre,asc
GET /api/articles?sort=createdAt,desc&sort=titre,asc # tri multiple
Deux requêtes SQL — Spring Data exécute un SELECT ... LIMIT 10 OFFSET 0 pour le contenu, plus un SELECT COUNT(*) pour totalElements. Sur de très grosses tables, utiliser Slice<T> à la place de Page<T> évite le COUNT.
7. Les transactions
Une transaction garantit qu'un ensemble d'opérations réussit entièrement, ou échoue entièrement (rollback). En Spring, on la déclare avec @Transactional.
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserRepository userRepository;
// Tout réussit ou tout est annulé
@Transactional
public void transfererArticles(Long ancienAuteurId, Long nouvelAuteurId) {
User nouveau = userRepository.findById(nouvelAuteurId).orElseThrow();
List articles = articleRepository.findByAuteurId(ancienAuteurId);
for (Article a : articles) {
a.setAuteur(nouveau);
articleRepository.save(a);
}
// Si une exception survient à la 5e itération sur 100,
// les 4 premières modifications sont annulées automatiquement
}
// readOnly = true : optimisation pour les lectures
// Hibernate désactive le dirty checking — moins de mémoire, plus rapide
@Transactional(readOnly = true)
public List findAll() {
return articleRepository.findAll().stream()
.map(ArticleResponse::from)
.toList();
}
}
⚠ Piège classique — @Transactional sur une méthode privée ou appelée en interne
Spring implémente @Transactional via un proxy qui enveloppe le bean. L'annotation ne fonctionne que si l'appel passe par ce proxy — c'est-à-dire depuis l'extérieur de la classe.
@Service
public class ArticleService {
public void traiterTout(List ids) {
// ❌ Appel interne : ne passe PAS par le proxy
ids.forEach(this::traiterUn); // @Transactional ignoré !
}
@Transactional
public void traiterUn(Long id) {
// Pas de transaction ici si appelé depuis traiterTout()
}
// ❌ @Transactional sur une méthode private est TOUJOURS ignoré
@Transactional
private void methodePrivee() { }
}
Solutions — mettre @Transactional sur la méthode publique appelée de l'extérieur, ou extraire la méthode transactionnelle dans un autre bean qui sera injecté.
8. Le cycle de vie d'une entité
Hibernate suit chaque entité dans l'un de quatre états. Comprendre ces états explique beaucoup de comportements surprenants.
TRANSIENT
new Article() — objet Java simple, Hibernate ne le connaît pas, pas de ligne en base
↓ save() / persist()
MANAGED
Suivi par Hibernate dans la transaction. Toute modification est détectée et sauvegardée automatiquement.
↓ fin de la transaction
DETACHED
L'objet existe en base mais n'est plus suivi. Les modifications ne sont plus persistées.
↓ delete()
REMOVED
Marqué pour suppression. Le DELETE partira au flush.
@Transactional
public void modifierTitre(Long id, String nouveauTitre) {
// findById retourne une entité MANAGED (suivie par Hibernate)
Article article = articleRepository.findById(id).orElseThrow();
article.setTitre(nouveauTitre);
// Pas besoin d'appeler save() !
// À la fin de la transaction, Hibernate compare l'état actuel
// avec l'état au chargement (dirty checking) et génère
// automatiquement : UPDATE article SET titre = ? WHERE id = ?
}
Le dirty checking — dans une transaction, modifier une entité chargée suffit à la persister. Appeler save() n'est pas faux, mais inutile. Hors transaction en revanche (entité DETACHED), save() est indispensable.
⚠ Piège classique — LazyInitializationException
Accéder à une relation LAZY après la fin de la transaction lève cette exception :
// ❌ Sans transaction
public String getEmailAuteur(Long id) {
Article article = articleRepository.findById(id).orElseThrow();
// La transaction implicite de findById est déjà fermée ici
return article.getAuteur().getEmail(); // 💥 LazyInitializationException
}
// ✅ Avec transaction — la session Hibernate reste ouverte
@Transactional(readOnly = true)
public String getEmailAuteur(Long id) {
Article article = articleRepository.findById(id).orElseThrow();
return article.getAuteur().getEmail(); // ✅ chargement à la demande OK
}
Trois solutions : annoter la méthode @Transactional, utiliser JOIN FETCH dans la requête, ou construire le DTO à l'intérieur de la transaction.
Exo 1Écrire les requêtes d'un repository
Compléter ArticleRepository avec les méthodes suivantes :
Les 5 articles publiés les plus récents
Les articles d'un auteur donné (par email), publiés uniquement, paginés
Le nombre d'articles publiés depuis une date
Une recherche par mot-clé dans le titre ou le contenu, insensible à la casse, avec l'auteur chargé (pas de N+1)
Voir la solution
public interface ArticleRepository extends JpaRepository {
// 1. Les 5 plus récents — "Top5" limite le résultat
List findTop5ByPublieTrueOrderByCreatedAtDesc();
// 2. Articles publiés d'un auteur, paginés
// Navigation dans la relation : Auteur → Email
Page findByPublieTrueAndAuteurEmail(String email, Pageable pageable);
// 3. Comptage depuis une date
long countByPublieTrueAndCreatedAtAfter(LocalDateTime date);
// 4. Recherche multi-champs avec JOIN FETCH
// Le nom de méthode serait illisible → @Query
@Query("""
SELECT a FROM Article a
JOIN FETCH a.auteur
WHERE a.publie = true
AND (LOWER(a.titre) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(a.contenu) LIKE LOWER(CONCAT('%', :q, '%')))
ORDER BY a.createdAt DESC
""")
List rechercher(@Param("q") String q);
}
Noter que les trois premières s'écrivent avec la convention de nommage, sans une ligne de SQL. La quatrième dépasse ce que le nommage permet d'exprimer lisiblement — c'est le bon moment pour passer à @Query.
Exo 2Corriger une entité
L'entité suivante contient cinq problèmes. Les identifier.
@Entity
@Table(name = "comment")
@Data
public class Commentaire {
@Id
private int id;
private String contenu;
@ManyToOne
private Article article;
@Enumerated
private StatutCommentaire statut;
}
Voir la solution
1. @Id sans @GeneratedValue — l'id doit être fourni manuellement à chaque insertion, ce qui provoque des collisions.
2. private int id — doit être Long (objet). Un int vaut 0 par défaut, JPA ne peut pas distinguer une nouvelle entité.
3. @Data sur une entité JPA — génère equals()/hashCode() sur tous les champs, y compris la relation article. Provoque des chargements LAZY intempestifs et des boucles infinies dans toString().
4. @ManyToOne sans fetch = FetchType.LAZY — le défaut est EAGER, ce qui charge systématiquement l'article même quand on n'en a pas besoin.
5. @Enumerated sans paramètre — utilise ORDINAL par défaut. Ajouter un statut au milieu de l'enum corrompt les données existantes.
Bonus : pas de constructeur sans arguments explicite (Lombok @Data n'en génère pas), et la table s'appelle comment — un mot réservé dans certains SGBD.
@Entity
@Table(name = "commentaire") // ✅ nom non réservé
@Getter @Setter // ✅ pas @Data sur une entité
@NoArgsConstructor // ✅ requis par JPA
public class Commentaire {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // ✅ génération auto
private Long id; // ✅ Long, pas int
@Column(columnDefinition = "TEXT", nullable = false)
private String contenu;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // ✅ LAZY explicite
@JoinColumn(name = "article_id", nullable = false)
private Article article;
@Enumerated(EnumType.STRING) // ✅ STRING, pas ORDINAL
@Column(nullable = false, length = 20)
private StatutCommentaire statut = StatutCommentaire.EN_ATTENTE;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@PrePersist
void onCreate() { this.createdAt = LocalDateTime.now(); }
}
Les DTOs isolent l'API de la structure de la base. La validation garantit que les données entrantes sont exploitables. La gestion centralisée des erreurs produit des réponses HTTP cohérentes. Ce chapitre construit les trois.
1. Pourquoi des DTOs ?
Un DTO (Data Transfer Object) est un objet dédié à l'échange avec l'extérieur. Il ne sert qu'à ça : transporter des données entre l'API et son client.
Quatre raisons concrètes de ne jamais exposer les entités directement :
Problème sans DTO
Ce qui se passe
Fuite de données
L'entité User contient password. Retourner un Article avec sa relation auteur expose le hash du mot de passe dans le JSON.
LazyInitializationException
Jackson sérialise hors transaction et tente de charger une relation LAZY → exception à l'exécution.
Couplage API ↔ base
Renommer la colonne titre en title casse tous les clients de l'API.
Sur-exposition
Le client reçoit createdAt, updatedAt, l'id interne de l'auteur… dont il n'a pas besoin.
Avec des DTOs, l'API définit son propre contrat, indépendant du modèle de persistance. On peut faire évoluer la base sans casser les clients, et inversement.
2. DTOs d'entrée et de sortie
On sépare toujours les DTOs de requête (ce que le client envoie) des DTOs de réponse (ce qu'on retourne). Ils n'ont pas les mêmes champs.
// DTO d'ENTRÉE — ce que le client envoie en POST/PUT
// Pas d'id : il est généré par la base
// Pas d'auteur : il vient du token JWT, pas du client
public record ArticleRequest(
@NotBlank(message = "Le titre est obligatoire")
@Size(max = 200, message = "Le titre ne peut pas dépasser 200 caractères")
String titre,
@NotBlank(message = "Le contenu est obligatoire")
@Size(min = 10, message = "Le contenu doit faire au moins 10 caractères")
String contenu,
boolean publie
) {}
// DTO de SORTIE — ce qu'on retourne au client
// Contient l'id, les timestamps, et l'email de l'auteur (pas l'objet User entier)
public record ArticleResponse(
Long id,
String titre,
String contenu,
String auteurEmail,
String auteurNom,
boolean publie,
LocalDateTime createdAt
) {
// Fabrique statique : centralise le mapping entité → DTO
// Évite de dupliquer cette logique dans chaque méthode du service
public static ArticleResponse from(Article a) {
return new ArticleResponse(
a.getId(),
a.getTitre(),
a.getContenu(),
a.getAuteur().getEmail(),
a.getAuteur().getNom(),
a.isPublie(),
a.getCreatedAt()
);
}
}
// Variante allégée pour les listes — pas de contenu complet
// Réduit fortement la taille de la réponse sur une liste de 100 articles
public record ArticleSummary(
Long id,
String titre,
String extrait, // 200 premiers caractères
String auteurNom,
LocalDateTime createdAt
) {
public static ArticleSummary from(Article a) {
String contenu = a.getContenu();
String extrait = contenu.length() > 200
? contenu.substring(0, 197) + "..."
: contenu;
return new ArticleSummary(
a.getId(), a.getTitre(), extrait,
a.getAuteur().getNom(), a.getCreatedAt()
);
}
}
Plusieurs DTOs pour une même entité, c'est normal — ArticleSummary pour les listes, ArticleResponse pour le détail. Chaque endpoint retourne exactement ce dont son client a besoin, ni plus ni moins.
⚠ Piège classique — Mettre la méthode from() dans l'entité
Tentant, mais ça inverse la dépendance : l'entité (couche persistance) connaîtrait le DTO (couche API). La couche basse ne doit jamais connaître la couche haute.
Deux emplacements corrects : une fabrique statique dans le DTO (comme ci-dessus — le DTO connaît l'entité, c'est le bon sens de dépendance), ou une classe ArticleMapper dédiée si le mapping devient complexe.
3. La validation Bean Validation
Les annotations de validation se placent sur les champs du DTO d'entrée. Elles sont déclaratives : on décrit la contrainte, le framework la vérifie.
Annotation
Vérifie que
S'applique à
@NotNull
la valeur n'est pas null
tout type
@NotEmpty
non null et non vide
String, Collection, Map, tableau
@NotBlank
non null et contient autre chose que des espaces
String uniquement
@Size(min, max)
la longueur/taille est dans l'intervalle
String, Collection, Map
@Min / @Max
la valeur numérique est dans les bornes
nombres entiers
@Positive / @Negative
signe du nombre
nombres
@DecimalMin / @DecimalMax
bornes pour les décimaux
BigDecimal, double
@Email
format d'adresse email
String
@Pattern(regexp)
correspond à une expression régulière
String
@Past / @Future
date dans le passé / le futur
dates
@Valid
valide récursivement un objet imbriqué
objets, collections
public record RegisterRequest(
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
String email,
@NotBlank(message = "Le mot de passe est obligatoire")
@Size(min = 8, message = "Le mot de passe doit faire au moins 8 caractères")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
message = "Le mot de passe doit contenir une minuscule, une majuscule et un chiffre"
)
String password,
@NotBlank(message = "Le nom est obligatoire")
@Size(max = 100)
String nom
) {}
Déclencher la validation avec @Valid
// @Valid déclenche la vérification de TOUTES les annotations du DTO
// Sans @Valid, elles sont purement décoratives et totalement ignorées
@PostMapping
public ResponseEntity creer(
@Valid @RequestBody ArticleRequest req,
@AuthenticationPrincipal User user) {
ArticleResponse cree = articleService.creer(req, user.getEmail());
URI location = URI.create("/api/articles/" + cree.id());
return ResponseEntity.created(location).body(cree);
}
Les annotations @NotBlank, @Email, @Size ne s'exécutent jamais toutes seules. Sans @Valid sur le paramètre, un titre vide passe sans aucune erreur.
Symptôme : des lignes vides ou invalides apparaissent en base alors que le DTO semble protégé. Le réflexe est de vérifier la présence de @Valid avant de chercher ailleurs.
Note : pour les objets imbriqués, il faut aussi @Valid sur le champ :
public record CommandeRequest(
@NotNull
@Valid // ← sinon les contraintes d'AdresseDto sont ignorées
AdresseDto adresseLivraison,
@NotEmpty @Valid // ← valide chaque élément de la liste
List lignes
) {}
4. Le service — la logique métier
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // lecture par défaut — surchargé sur les écritures
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserRepository userRepository;
// ── CRÉATION ──────────────────────────────────────────────
@Transactional // surcharge : transaction en écriture
public ArticleResponse creer(ArticleRequest req, String auteurEmail) {
User auteur = userRepository.findByEmail(auteurEmail)
.orElseThrow(() -> new UserNotFoundException(auteurEmail));
Article article = new Article();
article.setTitre(req.titre());
article.setContenu(req.contenu());
article.setPublie(req.publie());
article.setAuteur(auteur);
return ArticleResponse.from(articleRepository.save(article));
}
// ── LECTURE ───────────────────────────────────────────────
public ArticleResponse findById(Long id) {
return articleRepository.findById(id)
.map(ArticleResponse::from)
.orElseThrow(() -> new ArticleNotFoundException(id));
}
public Page findPublies(Pageable pageable) {
return articleRepository.findByPublieTrue(pageable)
.map(ArticleSummary::from);
}
// ── MODIFICATION ──────────────────────────────────────────
@Transactional
public ArticleResponse modifier(Long id, ArticleRequest req, String demandeurEmail) {
Article article = articleRepository.findById(id)
.orElseThrow(() -> new ArticleNotFoundException(id));
// Règle métier : seul l'auteur peut modifier son article
if (!article.getAuteur().getEmail().equals(demandeurEmail)) {
throw new AccesRefuseException(
"Seul l'auteur peut modifier cet article");
}
article.setTitre(req.titre());
article.setContenu(req.contenu());
article.setPublie(req.publie());
// Pas besoin de save() : dirty checking dans la transaction
return ArticleResponse.from(article);
}
// ── SUPPRESSION ───────────────────────────────────────────
@Transactional
public void supprimer(Long id, String demandeurEmail, boolean estAdmin) {
Article article = articleRepository.findById(id)
.orElseThrow(() -> new ArticleNotFoundException(id));
boolean estAuteur = article.getAuteur().getEmail().equals(demandeurEmail);
if (!estAuteur && !estAdmin) {
throw new AccesRefuseException(
"Seul l'auteur ou un administrateur peut supprimer cet article");
}
articleRepository.delete(article);
}
}
@Transactional(readOnly = true) au niveau classe — toutes les méthodes sont en lecture seule par défaut (optimisation Hibernate). Les méthodes qui écrivent portent leur propre @Transactional qui surcharge celui de la classe.
5. Le contrôleur complet
@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
// ── GET /api/articles?page=0&size=20 ──────────────────────
// Liste paginée des articles publiés — accessible sans authentification
@GetMapping
public Page lister(
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC)
Pageable pageable) {
return articleService.findPublies(pageable);
}
// ── GET /api/articles/42 ──────────────────────────────────
@GetMapping("/{id}")
public ArticleResponse parId(@PathVariable Long id) {
return articleService.findById(id);
}
// ── POST /api/articles ────────────────────────────────────
// @AuthenticationPrincipal injecte l'utilisateur authentifié
// (placé dans le contexte par JwtAuthenticationFilter)
@PostMapping
public ResponseEntity creer(
@Valid @RequestBody ArticleRequest req,
@AuthenticationPrincipal User user) {
ArticleResponse cree = articleService.creer(req, user.getEmail());
// 201 Created + header Location pointant vers la ressource créée
URI location = URI.create("/api/articles/" + cree.id());
return ResponseEntity.created(location).body(cree);
}
// ── PUT /api/articles/42 ──────────────────────────────────
@PutMapping("/{id}")
public ArticleResponse modifier(
@PathVariable Long id,
@Valid @RequestBody ArticleRequest req,
@AuthenticationPrincipal User user) {
return articleService.modifier(id, req, user.getEmail());
}
// ── DELETE /api/articles/42 ───────────────────────────────
// 204 No Content : succès sans corps de réponse
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void supprimer(
@PathVariable Long id,
@AuthenticationPrincipal User user) {
boolean estAdmin = user.getRole() == Role.ADMIN;
articleService.supprimer(id, user.getEmail(), estAdmin);
}
}
Choisir le bon code HTTP
Code
Signification
Quand l'utiliser
200 OK
Succès
GET, PUT réussi avec un corps de réponse
201 Created
Ressource créée
POST réussi — ajouter un header Location
204 No Content
Succès sans corps
DELETE réussi
400 Bad Request
Requête malformée
Validation échouée, JSON invalide
401 Unauthorized
Non authentifié
Token absent, invalide ou expiré
403 Forbidden
Authentifié mais pas autorisé
L'utilisateur n'est pas l'auteur
404 Not Found
Ressource inexistante
L'id demandé n'existe pas
409 Conflict
Conflit d'état
Email déjà utilisé à l'inscription
500 Internal Server Error
Erreur serveur
Exception non prévue — jamais volontaire
⚠ Piège classique — Confondre 401 et 403
La distinction est précise et souvent inversée :
401 Unauthorized — « je ne sais pas qui tu es ». Le token est absent, invalide ou expiré. Le client doit se reconnecter.
403 Forbidden — « je sais qui tu es, mais tu n'as pas le droit ». L'utilisateur est authentifié mais n'est pas l'auteur de l'article. Se reconnecter ne changera rien.
Retourner 401 dans le second cas envoie le client dans une boucle de reconnexion inutile.
6. Les exceptions métier
Plutôt que de lever des exceptions génériques, on définit des exceptions qui expriment un concept métier. Elles seront traduites en réponses HTTP par le gestionnaire global.
// RuntimeException : pas besoin de déclarer throws partout
public class ArticleNotFoundException extends RuntimeException {
private final Long id;
public ArticleNotFoundException(Long id) {
super("Article introuvable : " + id);
this.id = id;
}
public Long getId() { return id; }
}
public class AccesRefuseException extends RuntimeException {
public AccesRefuseException(String message) {
super(message);
}
}
public class EmailDejaUtiliseException extends RuntimeException {
public EmailDejaUtiliseException(String email) {
super("Un compte existe déjà avec l'email : " + email);
}
}
7. ProblemDetail — le format RFC 7807
La RFC 7807 définit un format standard pour les erreurs HTTP en JSON. Spring Boot l'implémente via la classe ProblemDetail. L'avantage : tous les clients savent à quoi s'attendre, quelle que soit l'API.
spring:
mvc:
problemdetails:
enabled: true # active le format RFC 7807 pour les erreurs Spring natives
// Structure d'une réponse ProblemDetail
{
"type": "about:blank", // URI décrivant le type d'erreur
"title": "Article introuvable", // résumé court, lisible
"status": 404, // code HTTP
"detail": "Article introuvable : 999", // explication spécifique à cette occurrence
"instance": "/api/articles/999" // URI de la requête concernée
}
Le gestionnaire global d'exceptions
@RestControllerAdvice intercepte les exceptions levées par n'importe quel contrôleur et les convertit en réponses HTTP. C'est le point unique de traduction exception → HTTP.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ── 404 — ressource inexistante ───────────────────────────
@ExceptionHandler(ArticleNotFoundException.class)
public ProblemDetail handleArticleNotFound(ArticleNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Article introuvable");
pd.setDetail(ex.getMessage());
// Propriété personnalisée — ajoutée au JSON
pd.setProperty("articleId", ex.getId());
return pd;
}
// ── 403 — authentifié mais pas autorisé ───────────────────
@ExceptionHandler(AccesRefuseException.class)
public ProblemDetail handleAccesRefuse(AccesRefuseException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
pd.setTitle("Accès refusé");
pd.setDetail(ex.getMessage());
return pd;
}
// ── 409 — conflit d'état ──────────────────────────────────
@ExceptionHandler(EmailDejaUtiliseException.class)
public ProblemDetail handleEmailDejaUtilise(EmailDejaUtiliseException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
pd.setTitle("Email déjà utilisé");
pd.setDetail(ex.getMessage());
return pd;
}
// ── 400 — validation @Valid échouée ───────────────────────
// Le cas le plus important : lister TOUS les champs en erreur
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Données invalides");
pd.setDetail("Un ou plusieurs champs sont invalides");
Map erreurs = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(err ->
erreurs.put(err.getField(), err.getDefaultMessage())
);
pd.setProperty("erreurs", erreurs);
return pd;
}
// ── 500 — filet de sécurité ───────────────────────────────
// Attrape tout ce qui n'a pas été traité au-dessus
@ExceptionHandler(Exception.class)
public ProblemDetail handleTout(Exception ex) {
// Logger la stack trace complète côté serveur
log.error("Erreur non gérée", ex);
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
pd.setTitle("Erreur interne");
// ⚠ ne JAMAIS exposer ex.getMessage() : peut contenir du SQL, des chemins…
pd.setDetail("Une erreur inattendue est survenue");
return pd;
}
}
L'ordre importe — Spring choisit le handler dont le type d'exception est le plus spécifique. handleTout(Exception) ne s'active que si aucun handler plus précis ne correspond.
# Requête avec des données invalides
$ curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGci..." \
-d '{"titre": "", "contenu": "court", "publie": true}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "about:blank",
"title": "Données invalides",
"status": 400,
"detail": "Un ou plusieurs champs sont invalides",
"instance": "/api/articles",
"erreurs": {
"titre": "Le titre est obligatoire",
"contenu": "Le contenu doit faire au moins 10 caractères"
}
}
⚠ Piège classique — Exposer ex.getMessage() sur une erreur 500
Le message d'une exception technique peut contenir des informations sensibles :
// ❌ Ce qui peut fuiter avec pd.setDetail(ex.getMessage())
{
"status": 500,
"detail": "ERROR: duplicate key value violates unique constraint
\"utilisateur_email_key\"
Detail: Key (email)=(admin@interne.fr) already exists.
SQL: INSERT INTO utilisateur (email, password, role) VALUES (?, ?, ?)"
}
// → structure de la base, noms de contraintes, emails internes exposés
Règle : logger le détail complet côté serveur (log.error("...", ex)) et retourner un message générique au client.
Exo 1Écrire un DTO et son handler d'erreur
Créer le nécessaire pour un endpoint d'inscription :
Un record RegisterRequest avec email, mot de passe et nom, correctement validés
Un record AuthResponse retournant le token et l'email
Le handler qui transforme EmailDejaUtiliseException en réponse 409 avec le champ concerné
Voir la solution
public record RegisterRequest(
@NotBlank(message = "L'email est obligatoire")
@Email(message = "Format d'email invalide")
@Size(max = 180)
String email,
@NotBlank(message = "Le mot de passe est obligatoire")
@Size(min = 8, max = 72, message = "Le mot de passe doit faire entre 8 et 72 caractères")
String password,
@NotBlank(message = "Le nom est obligatoire")
@Size(max = 100)
String nom
) {}
public record AuthResponse(
String token,
String type, // "Bearer"
String email,
String role
) {
public static AuthResponse of(String token, User user) {
return new AuthResponse(token, "Bearer",
user.getEmail(), user.getRole().name());
}
}
// Dans GlobalExceptionHandler
@ExceptionHandler(EmailDejaUtiliseException.class)
public ProblemDetail handleEmailDejaUtilise(EmailDejaUtiliseException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT); // 409
pd.setTitle("Email déjà utilisé");
pd.setDetail(ex.getMessage());
pd.setProperty("champ", "email"); // aide le front à cibler le champ fautif
return pd;
}
Le @Size(max = 72) sur le mot de passe n'est pas arbitraire : BCrypt tronque silencieusement au-delà de 72 octets. Sans cette limite, deux mots de passe différents mais partageant les 72 premiers caractères seraient équivalents.
Exo 2Compléter le gestionnaire d'erreurs
Ajouter à GlobalExceptionHandler les handlers pour :
HttpMessageNotReadableException — le JSON envoyé est malformé
MethodArgumentTypeMismatchException — un paramètre d'URL a le mauvais type (ex : /api/articles/abc)
NoResourceFoundException — l'URL n'existe pas du tout
Voir la solution
// 1. JSON malformé — parenthèse manquante, virgule en trop…
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail handleJsonInvalide(HttpMessageNotReadableException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("JSON invalide");
pd.setDetail("Le corps de la requête n'est pas un JSON valide");
return pd;
}
// 2. Mauvais type de paramètre — GET /api/articles/abc au lieu d'un nombre
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail handleTypeInvalide(MethodArgumentTypeMismatchException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Paramètre invalide");
pd.setDetail("Le paramètre '" + ex.getName() + "' est invalide : "
+ ex.getValue());
pd.setProperty("parametre", ex.getName());
pd.setProperty("typeAttendu", ex.getRequiredType() != null
? ex.getRequiredType().getSimpleName()
: "inconnu");
return pd;
}
// 3. Route inexistante
@ExceptionHandler(NoResourceFoundException.class)
public ProblemDetail handleRouteInconnue(NoResourceFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Route inexistante");
pd.setDetail("Aucun endpoint ne correspond à cette URL");
return pd;
}
Sans ces handlers, ces trois cas remontent au filet handleTout(Exception) et retournent un 500 alors que ce sont des erreurs client (400/404). Le client ne peut pas comprendre ce qu'il a fait de travers.
Spring Security 7 protège l'API avec des tokens JWT. L'utilisateur s'authentifie une fois, reçoit un token signé, et l'envoie à chaque requête. Aucune session côté serveur — l'API reste stateless.
1. Qu'est-ce qu'un JWT ?
Un JSON Web Token est une chaîne de caractères qui contient des informations sur l'utilisateur, signée cryptographiquement par le serveur. La signature garantit que le contenu n'a pas été modifié.
Un JWT se compose de trois parties séparées par des points :
// 1. HEADER (base64) — quel algorithme de signature
{
"alg": "HS256",
"typ": "JWT"
}
// 2. PAYLOAD (base64) — les "claims", les informations transportées
{
"sub": "alice@test.fr", // subject : l'identifiant de l'utilisateur
"role": "USER", // claim personnalisé
"iat": 1735000000, // issued at : date d'émission (timestamp)
"exp": 1735086400 // expiration : date de fin de validité
}
// 3. SIGNATURE — calculée par le serveur avec sa clé secrète
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
cle_secrete
)
⚠ Un JWT n'est pas chiffré, il est signé
Le payload est encodé en base64, pas chiffré. N'importe qui peut le décoder (essayer sur jwt.io) et lire son contenu.
Ce que la signature garantit, c'est l'intégrité : personne ne peut modifier le payload sans invalider la signature, car il faudrait connaître la clé secrète du serveur.
Conséquence pratique : ne jamais mettre d'information sensible dans un JWT. Pas de mot de passe, pas de numéro de carte, pas de données personnelles confidentielles. Un email et un rôle, c'est le niveau approprié.
2. Le flux d'authentification complet
① Inscription
POST /api/auth/register — mot de passe hashé en BCrypt, stocké en base
↓
② Connexion
POST /api/auth/login avec email + mot de passe
↓ AuthenticationManager vérifie le mot de passe
③ Génération
JwtService crée un token signé contenant email + rôle + expiration
← réponse : { "token": "eyJhbGci..." }
④ Stockage client
Le client conserve le token et l'envoie à chaque requête
↓ Authorization: Bearer eyJhbGci...
⑤ Filtre
JwtAuthenticationFilter extrait et valide le token à chaque requête
↓ token valide
⑥ Contexte
L'utilisateur est placé dans SecurityContextHolder — @AuthenticationPrincipal devient disponible
3. JwtService — générer et valider les tokens
@Service
public class JwtService {
// La clé secrète vient d'application.yml, jamais du code
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration-ms}")
private long expirationMs;
// Construit la clé de signature à partir du secret encodé en base64
private SecretKey cle() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
// ── GÉNÉRATION ────────────────────────────────────────────
public String genererToken(User user) {
Date maintenant = new Date();
Date expiration = new Date(maintenant.getTime() + expirationMs);
return Jwts.builder()
.subject(user.getEmail()) // claim "sub"
.claim("role", user.getRole().name()) // claim personnalisé
.claim("nom", user.getNom())
.issuedAt(maintenant) // claim "iat"
.expiration(expiration) // claim "exp"
.signWith(cle()) // signature HMAC-SHA256
.compact(); // produit la chaîne finale
}
// ── EXTRACTION ────────────────────────────────────────────
public String extraireEmail(String token) {
return extraireClaims(token).getSubject();
}
public String extraireRole(String token) {
return extraireClaims(token).get("role", String.class);
}
public Date extraireExpiration(String token) {
return extraireClaims(token).getExpiration();
}
// ── VALIDATION ────────────────────────────────────────────
public boolean estValide(String token) {
try {
extraireClaims(token); // lève une exception si signature ou expiration KO
return true;
} catch (ExpiredJwtException e) {
return false; // token expiré
} catch (JwtException | IllegalArgumentException e) {
return false; // signature invalide, format incorrect, token vide…
}
}
// Parse le token et vérifie la signature EN MÊME TEMPS
// verifyWith() échoue si la signature ne correspond pas
private Claims extraireClaims(String token) {
return Jwts.parser()
.verifyWith(cle())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
JJWT 0.12 a changé son API — les anciennes méthodes setSubject(), setExpiration(), parserBuilder() sont dépréciées. La version 0.12+ utilise subject(), expiration(), Jwts.parser().verifyWith(). Beaucoup de tutoriels utilisent encore l'ancienne API.
Générer une clé secrète solide
# HMAC-SHA256 exige une clé d'au moins 256 bits (32 octets)
# Générer une clé aléatoire encodée en base64 :
$ openssl rand -base64 32
xK9mPq2vN8wR5tY7uI1oA3sD6fG0hJ4kL7zX2cV5bN8=
# La placer dans application.yml (dev) ou en variable d'environnement (prod)
app:
jwt:
# ${VAR:defaut} — utilise la variable d'environnement si présente
secret: ${JWT_SECRET:xK9mPq2vN8wR5tY7uI1oA3sD6fG0hJ4kL7zX2cV5bN8=}
expiration-ms: 86400000 # 24 heures en millisecondes
⚠ Piège classique — Une clé secrète trop courte ou committée dans Git
Clé trop courte — JJWT refuse de démarrer avec une exception explicite :
io.jsonwebtoken.security.WeakKeyException:
The signing key's size is 128 bits which is not secure enough
for the HS256 algorithm. The JWT JWA Specification (RFC 7518, Section 3.2)
states that keys used with HS256 MUST have a size >= 256 bits.
Clé dans Git — c'est plus grave. Quiconque a accès au dépôt peut forger des tokens valides pour n'importe quel utilisateur, y compris un administrateur. En production, la clé doit venir d'une variable d'environnement ou d'un gestionnaire de secrets (Vault, AWS Secrets Manager). Si une clé a été committée par erreur, la considérer comme compromise et la changer — la retirer du dépôt ne suffit pas, l'historique Git la conserve.
4. Le filtre d'authentification
Le filtre s'exécute avant chaque requête. Son rôle : extraire le token du header, le valider, et placer l'utilisateur dans le contexte de sécurité.
Il hérite de OncePerRequestFilter, qui garantit une seule exécution par requête même en cas de forward interne.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// ── 1. Extraire le header Authorization ───────────────
String header = request.getHeader("Authorization");
// Pas de header ou mauvais format : on laisse passer sans authentifier
// C'est SecurityConfig qui décidera si la route exige une authentification
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// ── 2. Extraire le token (retirer "Bearer ") ──────────
String token = header.substring(7);
if (!jwtService.estValide(token)) {
filterChain.doFilter(request, response); // token invalide → non authentifié
return;
}
// ── 3. Charger l'utilisateur ──────────────────────────
String email = jwtService.extraireEmail(token);
// Ne rien faire si l'utilisateur est déjà authentifié
if (email == null
|| SecurityContextHolder.getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}
UserDetails user = userDetailsService.loadUserByUsername(email);
// ── 4. Placer l'authentification dans le contexte ─────
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
user, // le principal → @AuthenticationPrincipal
null, // les credentials : inutiles après validation
user.getAuthorities() // les rôles → utilisés par les contrôles d'accès
);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
// ── 5. Continuer la chaîne de filtres ─────────────────
filterChain.doFilter(request, response);
}
}
Le filtre n'interdit rien — il se contente d'authentifier si possible. C'est SecurityConfig qui décide quelles routes exigent une authentification. Cette séparation permet d'avoir des routes publiques et protégées dans la même application.
Si une branche du filtre oublie d'appeler filterChain.doFilter(request, response), la requête s'arrête là : le contrôleur n'est jamais atteint et le client reçoit une réponse vide avec un code 200.
C'est un bug particulièrement déroutant car aucune exception n'est levée. Vérifier que chaque chemin de sortie du filtre appelle bien doFilter().
5. SecurityConfig
SecurityConfig définit la chaîne de filtres de sécurité et les règles d'accès. Spring Security 7 utilise le lambda DSL : chaque bloc de configuration est une lambda, il n'y a plus de chaînage avec .and().
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
private final UserRepository userRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ── Pas de session : chaque requête porte son token ──
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// ── CSRF inutile pour une API stateless ──────────────
// La protection CSRF vise les attaques via cookies de session.
// Sans session ni cookie, il n'y a rien à protéger.
.csrf(csrf -> csrf.disable())
// ── CORS : autoriser le front à appeler l'API ────────
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ── Règles d'accès par route ─────────────────────────
// ⚠ L'ORDRE COMPTE : la première règle qui correspond gagne
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// ── Insérer notre filtre AVANT celui de Spring ───────
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// ── Comment charger un utilisateur depuis la base ────────
// Utilisé par le filtre JWT et par AuthenticationManager
@Bean
public UserDetailsService userDetailsService() {
return email -> userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"Utilisateur introuvable : " + email));
}
// ── Le hachage des mots de passe ─────────────────────────
// BCrypt intègre un sel aléatoire : deux hashs du même mot de passe
// sont différents, ce qui bloque les attaques par rainbow table
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 12 = coût, ~250ms par hash
}
// ── AuthenticationManager pour le login ──────────────────
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
// ── Configuration CORS ───────────────────────────────────
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000",
"https://jeandecode.fr"));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(List.of("Authorization","Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
⚠ Piège classique — L'ordre des requestMatchers
Spring Security évalue les règles dans l'ordre de déclaration et s'arrête à la première correspondance.
// ❌ MAUVAIS ordre — /api/admin/** est déjà couvert par anyRequest()
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated() // ← attrape TOUT
.requestMatchers("/api/admin/**").hasRole("ADMIN")) // jamais atteint
// ✅ BON ordre — du plus spécifique au plus général
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // ← toujours en DERNIER
Règle : anyRequest() est toujours la dernière ligne.
⚠ Piège classique — hasRole vs hasAuthority
hasRole("ADMIN") cherche en réalité l'autorité ROLE_ADMIN — Spring ajoute le préfixe automatiquement. hasAuthority("ADMIN") cherche exactement ADMIN.
C'est la source d'un bug fréquent : si getAuthorities() retourne new SimpleGrantedAuthority(role.name()) (donc "ADMIN") et que la config utilise hasRole("ADMIN") (donc "ROLE_ADMIN"), l'accès est toujours refusé avec un 403 incompréhensible.
// ✅ Cohérent avec hasRole("ADMIN")
@Override
public Collection extends GrantedAuthority> getAuthorities() {
// Ajouter explicitement le préfixe ROLE_
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
6. AuthService — inscription et connexion
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authManager;
// ── INSCRIPTION ───────────────────────────────────────────
@Transactional
public AuthResponse inscrire(RegisterRequest req) {
// Vérifier l'unicité de l'email avant d'insérer
if (userRepository.existsByEmail(req.email())) {
throw new EmailDejaUtiliseException(req.email());
}
User user = User.builder()
.email(req.email())
// ⚠ TOUJOURS hasher — jamais stocker le mot de passe en clair
.password(passwordEncoder.encode(req.password()))
.nom(req.nom())
.role(Role.USER)
.build();
userRepository.save(user);
return AuthResponse.of(jwtService.genererToken(user), user);
}
// ── CONNEXION ─────────────────────────────────────────────
public AuthResponse connecter(LoginRequest req) {
try {
// AuthenticationManager fait tout le travail :
// 1. charge l'utilisateur via UserDetailsService
// 2. compare le mot de passe avec passwordEncoder.matches()
// 3. lève BadCredentialsException si ça ne correspond pas
authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.email(), req.password())
);
} catch (BadCredentialsException e) {
// Message volontairement vague : ne pas révéler si c'est
// l'email ou le mot de passe qui est faux
throw new IdentifiantsInvalidesException();
}
User user = userRepository.findByEmail(req.email()).orElseThrow();
return AuthResponse.of(jwtService.genererToken(user), user);
}
}
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public AuthResponse inscrire(@Valid @RequestBody RegisterRequest req) {
return authService.inscrire(req);
}
@PostMapping("/login")
public AuthResponse connecter(@Valid @RequestBody LoginRequest req) {
return authService.connecter(req);
}
}
⚠ Piège classique — Révéler si l'email existe
Un message d'erreur trop précis renseigne un attaquant :
// ❌ Permet d'énumérer les comptes existants
if (!userRepository.existsByEmail(req.email())) {
throw new RuntimeException("Aucun compte avec cet email");
}
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new RuntimeException("Mot de passe incorrect");
}
// Un attaquant teste des emails et sait lesquels existent
// ✅ Message unique dans les deux cas
throw new IdentifiantsInvalidesException(); // "Email ou mot de passe incorrect"
7. Tester l'API
# ── 1. Inscription ────────────────────────────────────────
$ curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "alice@test.fr",
"password": "MotDePasse1",
"nom": "Alice Martin"
}'
HTTP/1.1 201 Created
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZUB0ZXN0LmZyIiwicm9sZSI6IlVTRVIi...",
"type": "Bearer",
"email": "alice@test.fr",
"role": "USER"
}
# ── 2. Connexion ──────────────────────────────────────────
$ curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "alice@test.fr", "password": "MotDePasse1"}'
# ── 3. Créer un article avec le token ─────────────────────
$ TOKEN="eyJhbGciOiJIUzI1NiJ9..."
$ curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"titre": "Mon premier article",
"contenu": "Le contenu de mon article, assez long pour passer la validation.",
"publie": true
}'
HTTP/1.1 201 Created
Location: /api/articles/1
# ── 4. Sans token — refusé ────────────────────────────────
$ curl -X POST http://localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"titre": "Test", "contenu": "..."}'
HTTP/1.1 401 Unauthorized
# ── 5. Lecture publique — autorisée sans token ────────────
$ curl http://localhost:8080/api/articles
HTTP/1.1 200 OK
8. Aller plus loin — le refresh token
Un token d'accès de 24 heures est un compromis discutable : trop long pour la sécurité, trop court pour le confort. La pratique courante est d'utiliser deux tokens.
Access token
Refresh token
Durée de vie
15 minutes
7 à 30 jours
Envoyé à
Chaque requête API
Uniquement /api/auth/refresh
Stockage
Mémoire du client
Cookie HttpOnly, ou base de données côté serveur
Révocable
Non (stateless)
Oui — stocké en base, supprimable
En cas de vol
Expire vite, dégâts limités
Grave — d'où le stockage sécurisé
// Le refresh token est stocké en base pour pouvoir être révoqué
@Entity
@Table(name = "refresh_token")
@Getter @Setter @NoArgsConstructor
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 512)
private String token;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "utilisateur_id")
private User utilisateur;
@Column(name = "expire_at", nullable = false)
private Instant expireAt;
@Column(nullable = false)
private boolean revoque = false;
}
Le refresh token est stateful — contrairement à l'access token, il est enregistré en base. C'est ce qui permet de le révoquer : un utilisateur qui se déconnecte, ou dont le compte est suspendu, voit son refresh token invalidé côté serveur.
Exo 1Ajouter un contrôle de rôle
Ajouter un endpoint DELETE /api/admin/articles/{id} réservé aux administrateurs, qui supprime n'importe quel article sans vérifier l'auteur.
Le protéger de deux façons différentes : par la configuration SecurityConfig, et par annotation sur la méthode.
Méthode 2 — via annotation. Il faut d'abord activer la sécurité au niveau méthode :
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // ← active @PreAuthorize / @PostAuthorize
public class SecurityConfig { }
@RestController
@RequestMapping("/api/admin/articles")
@RequiredArgsConstructor
public class AdminArticleController {
private final ArticleService articleService;
// Vérifié avant l'exécution de la méthode
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void supprimer(@PathVariable Long id,
@AuthenticationPrincipal User user) {
// estAdmin = true : contourne la vérification de l'auteur
articleService.supprimer(id, user.getEmail(), true);
}
}
Quand utiliser l'une ou l'autre ? La configuration par route est plus lisible pour des règles larges (tout /api/admin est réservé aux admins). L'annotation est plus adaptée aux règles fines, notamment celles qui dépendent des arguments de la méthode :
// Règle fine impossible à exprimer dans SecurityConfig
@PreAuthorize("hasRole('ADMIN') or #email == authentication.principal.username")
@GetMapping("/utilisateur/{email}")
public List parAuteur(@PathVariable String email) {
return articleService.findByAuteur(email);
}
Exo 2Diagnostiquer un 403
Un utilisateur authentifié avec le rôle ADMIN reçoit systématiquement un 403 Forbidden sur /api/admin/articles/1. Le token est valide, l'utilisateur est bien authentifié sur les autres routes.
Voici les deux morceaux de code concernés. Où est le problème ?
getAuthorities() retourne l'autorité "ADMIN". Mais hasRole("ADMIN") cherche en réalité "ROLE_ADMIN" — Spring Security ajoute le préfixe automatiquement. Les deux ne correspondent jamais, d'où le 403 systématique.
Deux corrections possibles (choisir l'une, pas les deux) :
// Correction 1 — ajouter le préfixe dans l'entité (recommandé)
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
// Correction 2 — utiliser hasAuthority au lieu de hasRole
.requestMatchers("/api/admin/**").hasAuthority("ADMIN")
La première est préférable : c'est la convention Spring Security, et elle reste cohérente si on utilise plus tard @PreAuthorize("hasRole('ADMIN')") ou @Secured("ROLE_ADMIN").
Pour diagnostiquer ce genre de cas, activer les logs de sécurité :
Spring Boot 4 remplace @MockBean par @MockitoBean. Ce chapitre couvre les trois niveaux de test — unitaire, couche web, intégration — avec le bon outil pour chacun et les pièges à connaître.
1. La pyramide de tests
Tous les tests ne se valent pas. Plus un test démarre de choses, plus il est lent et fragile. L'objectif est d'avoir beaucoup de tests rapides et peu de tests lents.
Tests unitaires
Beaucoup (~70%). Aucun contexte Spring. Quelques millisecondes chacun.
↑ moins nombreux, plus lents
Tests de tranche
Quelques-uns (~20%). Contexte Spring partiel (@WebMvcTest, @DataJpaTest). ~1 seconde.
↑
Tests d'intégration
Peu (~10%). Contexte complet (@SpringBootTest). Plusieurs secondes.
Niveau
Annotation
Ce qui démarre
Durée typique
Unitaire
@ExtendWith(MockitoExtension.class)
Rien — Java pur + Mockito
< 10 ms
Couche web
@WebMvcTest(XController.class)
Spring MVC seul, pas JPA
~1 s
Couche JPA
@DataJpaTest
JPA + base H2, pas le web
~1 s
Intégration
@SpringBootTest
Tout le contexte Spring
3–10 s
2. Test unitaire d'un service
Le test unitaire isole une classe et remplace ses dépendances par des mocks — des objets simulés dont on contrôle le comportement. Aucun contexte Spring, aucune base de données.
@ExtendWith(MockitoExtension.class) // active Mockito, PAS Spring
class ArticleServiceTest {
@Mock // dépendance simulée
ArticleRepository articleRepository;
@Mock
UserRepository userRepository;
@InjectMocks // instance réelle, mocks injectés dedans
ArticleService articleService;
// ── Un test = trois phases : Arrange, Act, Assert ─────────
@Test
@DisplayName("creer() doit sauvegarder l'article et retourner le DTO")
void creer_doitSauvegarderEtRetournerDto() {
// ARRANGE — préparer les données et le comportement des mocks
var req = new ArticleRequest("Mon titre", "Un contenu suffisamment long", true);
var auteur = new User();
auteur.setId(1L);
auteur.setEmail("alice@test.fr");
auteur.setNom("Alice");
var articleSauve = new Article();
articleSauve.setId(42L);
articleSauve.setTitre("Mon titre");
articleSauve.setContenu("Un contenu suffisamment long");
articleSauve.setAuteur(auteur);
articleSauve.setPublie(true);
// Programmer le comportement des mocks
given(userRepository.findByEmail("alice@test.fr"))
.willReturn(Optional.of(auteur));
given(articleRepository.save(any(Article.class)))
.willReturn(articleSauve);
// ACT — exécuter la méthode testée
ArticleResponse resultat = articleService.creer(req, "alice@test.fr");
// ASSERT — vérifier le résultat
assertThat(resultat.id()).isEqualTo(42L);
assertThat(resultat.titre()).isEqualTo("Mon titre");
assertThat(resultat.auteurEmail()).isEqualTo("alice@test.fr");
// Vérifier que save() a bien été appelé
then(articleRepository).should().save(any(Article.class));
}
}
@Mock vs @InjectMocks — @Mock crée un faux objet dont on contrôle les réponses. @InjectMocks crée une vraie instance de la classe testée et lui injecte les mocks via son constructeur. C'est l'injection par constructeur qui rend cela possible.
Les méthodes Mockito essentielles
given(...).willReturn(x)
Quand cette méthode est appelée, retourner x
given(...).willThrow(ex)
Lever une exception à l'appel
given(...).willAnswer(inv -> …)
Réponse dynamique calculée à partir des arguments
then(mock).should().methode()
Vérifier que la méthode a été appelée une fois
then(mock).should(never()).methode()
Vérifier qu'elle n'a jamais été appelée
then(mock).should(times(3)).methode()
Vérifier un nombre exact d'appels
any() / anyLong() / anyString()
Matcher : accepte n'importe quelle valeur du type
eq(valeur)
Matcher : accepte exactement cette valeur
ArgumentCaptor
Capturer l'argument réellement passé pour l'inspecter
// ── Tester le chemin d'erreur ─────────────────────────────
@Test
@DisplayName("findById() doit lever ArticleNotFoundException si l'id n'existe pas")
void findById_doitLeverException_siIntrouvable() {
given(articleRepository.findById(999L)).willReturn(Optional.empty());
assertThatThrownBy(() -> articleService.findById(999L))
.isInstanceOf(ArticleNotFoundException.class)
.hasMessageContaining("999");
}
// ── Tester une règle métier ───────────────────────────────
@Test
@DisplayName("modifier() doit refuser si le demandeur n'est pas l'auteur")
void modifier_doitRefuser_siPasAuteur() {
var auteur = new User();
auteur.setEmail("alice@test.fr");
var article = new Article();
article.setId(1L);
article.setAuteur(auteur);
given(articleRepository.findById(1L)).willReturn(Optional.of(article));
var req = new ArticleRequest("Nouveau titre", "Nouveau contenu long", true);
assertThatThrownBy(() -> articleService.modifier(1L, req, "bob@test.fr"))
.isInstanceOf(AccesRefuseException.class);
// Vérifier qu'AUCUNE sauvegarde n'a eu lieu
then(articleRepository).should(never()).save(any());
}
// ── ArgumentCaptor : inspecter ce qui est réellement passé ──
@Test
@DisplayName("creer() doit affecter l'auteur récupéré à l'article")
void creer_doitAffecterAuteur() {
var auteur = new User();
auteur.setEmail("alice@test.fr");
given(userRepository.findByEmail(anyString())).willReturn(Optional.of(auteur));
given(articleRepository.save(any())).willAnswer(inv -> inv.getArgument(0));
articleService.creer(
new ArticleRequest("T", "Contenu assez long ici", false),
"alice@test.fr");
// Capturer l'Article réellement passé à save()
ArgumentCaptor captor = ArgumentCaptor.forClass(Article.class);
then(articleRepository).should().save(captor.capture());
assertThat(captor.getValue().getAuteur().getEmail()).isEqualTo("alice@test.fr");
}
⚠ Piège classique — UnnecessaryStubbingException
Mockito en mode strict (le défaut avec MockitoExtension) échoue si un given() n'est jamais utilisé pendant le test :
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
1. -> at ArticleServiceTest.modifier_doitRefuser(ArticleServiceTest.java:58)
Ce n'est pas un défaut, c'est un garde-fou : un stub inutilisé signale souvent que le test ne teste pas ce qu'on croit. La bonne réaction est de supprimer le stub inutile, pas de désactiver la vérification.
Si le stub est vraiment nécessaire dans certains cas seulement, utiliser lenient().when(...) ponctuellement.
3. Test de la couche web avec @WebMvcTest
@WebMvcTest démarre uniquement la couche web : contrôleurs, filtres, sérialisation JSON, validation. Ni JPA, ni base de données, ni services réels — on les remplace par des mocks.
C'est ici qu'intervient le changement Spring Boot 4 : @MockBean devient @MockitoBean.
@WebMvcTest(ArticleController.class) // charge CE contrôleur uniquement
class ArticleControllerTest {
@Autowired
MockMvc mockMvc; // client HTTP simulé — pas de vrai serveur
@Autowired
ObjectMapper objectMapper; // pour sérialiser les DTOs en JSON
// ⚠ Spring Boot 4 : @MockitoBean remplace @MockBean (déprécié)
@MockitoBean
ArticleService articleService;
// Les beans de sécurité doivent aussi être mockés,
// sinon le contexte @WebMvcTest ne démarre pas
@MockitoBean
JwtService jwtService;
@MockitoBean
JwtAuthenticationFilter jwtAuthenticationFilter;
@MockitoBean
UserDetailsService userDetailsService;
}
@MockitoBean vs @Mock — @Mock (Mockito pur) crée un objet simulé hors contexte Spring. @MockitoBean crée un mock ET l'enregistre comme bean dans le contexte Spring, en remplaçant le bean réel. Dans un test Spring, c'est @MockitoBean qu'il faut.
Écrire des tests MockMvc
// ── GET public — pas d'authentification requise ───────────
@Test
@DisplayName("GET /api/articles/1 retourne 200 et le JSON de l'article")
void parId_doitRetourner200() throws Exception {
var reponse = new ArticleResponse(
1L, "Spring Boot 4", "Contenu complet",
"alice@test.fr", "Alice", true, LocalDateTime.now());
given(articleService.findById(1L)).willReturn(reponse);
mockMvc.perform(get("/api/articles/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.titre").value("Spring Boot 4"))
.andExpect(jsonPath("$.auteurEmail").value("alice@test.fr"));
}
// ── POST authentifié ──────────────────────────────────────
@Test
@DisplayName("POST /api/articles retourne 201 avec le header Location")
@WithMockUser(username = "alice@test.fr") // simule un utilisateur authentifié
void creer_doitRetourner201() throws Exception {
var req = new ArticleRequest("Titre", "Contenu suffisamment long ici", true);
var reponse = new ArticleResponse(
42L, "Titre", "Contenu suffisamment long ici",
"alice@test.fr", "Alice", true, LocalDateTime.now());
given(articleService.creer(any(), anyString())).willReturn(reponse);
mockMvc.perform(post("/api/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/articles/42"))
.andExpect(jsonPath("$.id").value(42));
}
// ── Validation échouée ────────────────────────────────────
@Test
@DisplayName("POST avec un titre vide retourne 400 et liste les champs en erreur")
@WithMockUser(username = "alice@test.fr")
void creer_doitRetourner400_siTitreVide() throws Exception {
var req = new ArticleRequest("", "court", true); // deux champs invalides
mockMvc.perform(post("/api/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Données invalides"))
.andExpect(jsonPath("$.erreurs.titre").exists())
.andExpect(jsonPath("$.erreurs.contenu").exists());
// Le service ne doit jamais être appelé si la validation échoue
then(articleService).should(never()).creer(any(), anyString());
}
// ── 404 ───────────────────────────────────────────────────
@Test
@DisplayName("GET sur un id inexistant retourne 404")
void parId_doitRetourner404() throws Exception {
given(articleService.findById(999L))
.willThrow(new ArticleNotFoundException(999L));
mockMvc.perform(get("/api/articles/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Article introuvable"));
}
// ── Débugger un test qui échoue ───────────────────────────
@Test
void debug_afficherLaRequeteEtLaReponse() throws Exception {
mockMvc.perform(get("/api/articles/1"))
.andDo(print()) // affiche tout en console : headers, body, statut
.andExpect(status().isOk());
}
⚠ Piège classique — @WebMvcTest ne démarre pas — beans manquants
@WebMvcTest ne charge que les beans web. Si le contrôleur ou SecurityConfig dépend d'autre chose, le contexte échoue :
Parameter 0 of constructor in fr.jeandecode.blogapi.security.SecurityConfig
required a bean of type 'fr.jeandecode.blogapi.repository.UserRepository'
that could not be found.
Deux solutions :
// Solution 1 — mocker le bean manquant
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
@MockitoBean UserRepository userRepository;
@MockitoBean JwtService jwtService;
}
// Solution 2 — exclure la configuration de sécurité du test
@WebMvcTest(controllers = ArticleController.class,
excludeAutoConfiguration = SecurityAutoConfiguration.class)
class ArticleControllerTest { }
4. Test de la couche JPA avec @DataJpaTest
@DataJpaTest démarre JPA et une base H2 en mémoire, sans la couche web. Idéal pour vérifier que les requêtes des repositories fonctionnent réellement.
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop # schéma recréé à chaque test
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
app:
jwt:
secret: dGVzdC1zZWNyZXQtcG91ci1sZXMtdGVzdHMtdW5pdGFpcmVzLTEyMzQ1Ng==
expiration-ms: 3600000
@DataJpaTest // JPA + H2, transaction annulée après chaque test
@ActiveProfiles("test") // charge application-test.yml
class ArticleRepositoryTest {
@Autowired
ArticleRepository articleRepository;
@Autowired
TestEntityManager entityManager; // pour préparer les données de test
@Test
@DisplayName("findByPublieTrue() ne retourne que les articles publiés")
void findByPublieTrue_doitFiltrer() {
// ARRANGE — insérer des données de test
User auteur = new User();
auteur.setEmail("alice@test.fr");
auteur.setPassword("hash");
auteur.setNom("Alice");
auteur.setRole(Role.USER);
entityManager.persist(auteur);
entityManager.persist(creerArticle("Publié 1", true, auteur));
entityManager.persist(creerArticle("Brouillon", false, auteur));
entityManager.persist(creerArticle("Publié 2", true, auteur));
entityManager.flush(); // force l'écriture en base
// ACT
List resultat = articleRepository.findByPublieTrue();
// ASSERT
assertThat(resultat).hasSize(2);
assertThat(resultat)
.extracting(Article::getTitre)
.containsExactlyInAnyOrder("Publié 1", "Publié 2");
}
private Article creerArticle(String titre, boolean publie, User auteur) {
Article a = new Article();
a.setTitre(titre);
a.setContenu("Contenu de test suffisamment long");
a.setPublie(publie);
a.setAuteur(auteur);
return a;
}
}
@DataJpaTest est transactionnel — chaque test s'exécute dans une transaction annulée automatiquement à la fin. Les tests sont donc isolés les uns des autres, sans nettoyage manuel.
⚠ Piège classique — H2 n'est pas PostgreSQL
H2 est pratique et rapide, mais certaines requêtes valides en PostgreSQL échouent en H2 (fonctions spécifiques, types JSONB, recherche full-text to_tsvector…).
MODE=PostgreSQL dans l'URL améliore la compatibilité mais ne la garantit pas. Pour des requêtes SQL natives complexes, utiliser Testcontainers qui démarre un vrai PostgreSQL en Docker le temps des tests :
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ArticleRepositoryIT {
@Container
@ServiceConnection // Spring Boot 3.1+ configure la datasource automatiquement
static PostgreSQLContainer> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired ArticleRepository articleRepository;
// Les tests s'exécutent sur un vrai PostgreSQL
}
5. Test d'intégration avec @SpringBootTest
@SpringBootTest démarre le contexte Spring complet. C'est le test le plus proche de la réalité, mais aussi le plus lent — à réserver aux scénarios de bout en bout.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Transactional // chaque test est annulé à la fin — base propre
class ArticleIntegrationTest {
@Autowired TestRestTemplate restTemplate; // vrai client HTTP
@Autowired UserRepository userRepository;
@Autowired PasswordEncoder passwordEncoder;
@Test
@DisplayName("Parcours complet : inscription → connexion → création d'article")
void parcoursComplet() {
// ── 1. Inscription ────────────────────────────────────
var register = new RegisterRequest("alice@test.fr", "MotDePasse1", "Alice");
ResponseEntity authResp = restTemplate.postForEntity(
"/api/auth/register", register, AuthResponse.class);
assertThat(authResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(authResp.getBody()).isNotNull();
String token = authResp.getBody().token();
assertThat(token).isNotBlank();
// ── 2. Créer un article avec le token ─────────────────
var articleReq = new ArticleRequest(
"Mon article", "Un contenu suffisamment long pour la validation", true);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity articleResp = restTemplate.exchange(
"/api/articles", HttpMethod.POST,
new HttpEntity<>(articleReq, headers),
ArticleResponse.class);
assertThat(articleResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(articleResp.getBody().titre()).isEqualTo("Mon article");
assertThat(articleResp.getBody().auteurEmail()).isEqualTo("alice@test.fr");
// ── 3. Vérifier qu'il apparaît dans la liste publique ──
ResponseEntity liste = restTemplate.getForEntity(
"/api/articles", String.class);
assertThat(liste.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(liste.getBody()).contains("Mon article");
}
@Test
@DisplayName("Créer un article sans token retourne 401")
void creerSansToken_doitRetourner401() {
var req = new ArticleRequest("Titre", "Contenu assez long ici", true);
ResponseEntity resp = restTemplate.postForEntity(
"/api/articles", req, String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
⚠ Piège classique — @Transactional sur @SpringBootTest avec un vrai serveur
Avec webEnvironment = RANDOM_PORT, le serveur tourne dans un thread séparé. La transaction du test ne s'applique pas aux requêtes HTTP — elles ont leur propre transaction.
Conséquence : les données créées via HTTP ne sont pas annulées par le rollback du test, et les données préparées dans le test via un repository ne sont pas visibles par le serveur.
Solutions : utiliser WebEnvironment.MOCK avec MockMvc (même thread, transaction partagée), ou nettoyer explicitement avec @Sql ou un @AfterEach.
// Alternative : MOCK + MockMvc — transaction partagée, plus rapide
@SpringBootTest // webEnvironment = MOCK par défaut
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional // fonctionne correctement ici
class ArticleIntegrationTest {
@Autowired MockMvc mockMvc;
}
6. AssertJ — écrire des assertions lisibles
AssertJ est inclus dans spring-boot-starter-webmvc-test. Ses assertions se chaînent et produisent des messages d'erreur beaucoup plus clairs que assertEquals.
# Tous les tests
$ mvn test
# Une seule classe
$ mvn test -Dtest=ArticleServiceTest
# Une seule méthode
$ mvn test -Dtest=ArticleServiceTest#creer_doitSauvegarderEtRetournerDto
# Toutes les classes correspondant à un motif
$ mvn test -Dtest='*ServiceTest'
# Compiler + tester + packager
$ mvn verify
# Packager sans lancer les tests (build rapide)
$ mvn package -DskipTests
$ mvn test
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running fr.jeandecode.blogapi.service.ArticleServiceTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 - in 0.412 s
[INFO] Running fr.jeandecode.blogapi.controller.ArticleControllerTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 - in 1.847 s
[INFO] Running fr.jeandecode.blogapi.repository.ArticleRepositoryTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 - in 1.203 s
[INFO]
[INFO] Results:
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
8. Migrer depuis Spring Boot 3
Si on reprend du code de test écrit pour Spring Boot 3, deux annotations ont changé de nom :
Spring Boot 3 (déprécié)
Spring Boot 4
Rôle
@MockBean
@MockitoBean
Remplace un bean par un mock
@SpyBean
@MockitoSpyBean
Enveloppe le bean réel dans un espion
// ❌ Spring Boot 3 — déprécié, sera supprimé
import org.springframework.boot.test.mock.mockito.MockBean;
@MockBean ArticleService articleService;
// ✅ Spring Boot 4
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@MockitoBean ArticleService articleService;
La signature ne change pas — seuls l'import et le nom de l'annotation diffèrent. Un rechercher-remplacer suffit dans la plupart des projets.
Exo 1Écrire un test unitaire
Écrire le test unitaire de ArticleService.supprimer(Long id, String demandeurEmail, boolean estAdmin). Couvrir trois cas :
L'auteur supprime son propre article → succès, delete() appelé
Un autre utilisateur tente de supprimer → AccesRefuseException, delete() jamais appelé
Un administrateur supprime l'article d'un autre → succès
Voir la solution
@ExtendWith(MockitoExtension.class)
class ArticleServiceSupprimerTest {
@Mock ArticleRepository articleRepository;
@Mock UserRepository userRepository;
@InjectMocks ArticleService articleService;
// Fabrique commune aux trois tests
private Article articleDe(String emailAuteur) {
User auteur = new User();
auteur.setEmail(emailAuteur);
Article article = new Article();
article.setId(1L);
article.setAuteur(auteur);
return article;
}
@Test
@DisplayName("L'auteur peut supprimer son propre article")
void supprimer_parAuteur_doitReussir() {
Article article = articleDe("alice@test.fr");
given(articleRepository.findById(1L)).willReturn(Optional.of(article));
articleService.supprimer(1L, "alice@test.fr", false);
then(articleRepository).should().delete(article);
}
@Test
@DisplayName("Un autre utilisateur ne peut pas supprimer l'article")
void supprimer_parAutre_doitEchouer() {
Article article = articleDe("alice@test.fr");
given(articleRepository.findById(1L)).willReturn(Optional.of(article));
assertThatThrownBy(() -> articleService.supprimer(1L, "bob@test.fr", false))
.isInstanceOf(AccesRefuseException.class);
// Rien ne doit avoir été supprimé
then(articleRepository).should(never()).delete(any());
}
@Test
@DisplayName("Un administrateur peut supprimer l'article de n'importe qui")
void supprimer_parAdmin_doitReussir() {
Article article = articleDe("alice@test.fr");
given(articleRepository.findById(1L)).willReturn(Optional.of(article));
articleService.supprimer(1L, "admin@test.fr", true);
then(articleRepository).should().delete(article);
}
@Test
@DisplayName("Supprimer un article inexistant lève ArticleNotFoundException")
void supprimer_inexistant_doitEchouer() {
given(articleRepository.findById(999L)).willReturn(Optional.empty());
assertThatThrownBy(() -> articleService.supprimer(999L, "alice@test.fr", false))
.isInstanceOf(ArticleNotFoundException.class);
}
}
Noter le quatrième test ajouté : le cas « article inexistant » est un chemin d'erreur souvent oublié mais tout aussi important. Noter aussi la méthode articleDe() qui évite de dupliquer la construction des données dans chaque test.
Exo 2Corriger un test qui ne compile pas
Ce test a été écrit pour Spring Boot 3 et ne fonctionne plus. Trouver les trois problèmes.
Problème 1 — @MockBean est déprécié en Spring Boot 4. Le remplacer par @MockitoBean et corriger l'import.
Problème 2 — les beans de sécurité manquent.@WebMvcTest charge SecurityConfig, qui dépend de JwtAuthenticationFilter et UserRepository. Sans eux, le contexte ne démarre pas.
Problème 3 — pas d'assertion sur le contenu. Le test vérifie seulement le code 200. Un contrôleur qui retourne un JSON vide passerait ce test. Il faut vérifier le corps de la réponse.
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
// ✅ Problème 1 : @MockitoBean au lieu de @MockBean
@MockitoBean ArticleService articleService;
// ✅ Problème 2 : mocker les beans requis par SecurityConfig
@MockitoBean JwtService jwtService;
@MockitoBean JwtAuthenticationFilter jwtAuthenticationFilter;
@MockitoBean UserDetailsService userDetailsService;
@MockitoBean UserRepository userRepository;
@Test
@DisplayName("GET /api/articles/1 retourne 200 et le bon JSON")
void parId_doitRetourner200() throws Exception {
var reponse = new ArticleResponse(1L, "Titre", "Contenu",
"alice@test.fr", "Alice",
true, LocalDateTime.now());
given(articleService.findById(1L)).willReturn(reponse);
mockMvc.perform(get("/api/articles/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
// ✅ Problème 3 : vérifier réellement le contenu
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.titre").value("Titre"))
.andExpect(jsonPath("$.auteurEmail").value("alice@test.fr"));
}
}
Le passage de when().thenReturn() à given().willReturn() est cosmétique — les deux fonctionnent. La syntaxe BDD (given/then) se lit mieux et s'aligne sur le découpage Arrange/Act/Assert.
📌 Bilan du Projet 1
Architecture — quatre couches, chacune ne connaît que celle du dessous. L'entité ne sort jamais du service.
JPA — Long pour les IDs, @Enumerated(STRING), FetchType.LAZY partout, JOIN FETCH contre le N+1.
DTOs — records avec validation, @Valid obligatoire, ProblemDetail pour les erreurs.