Aller au contenu principal

TP10.1 - Gestion Centralisée des Erreurs

Objectifs du TP​

Dans ce TP, vous allez:

  1. ✅ Créer une hiérarchie d'exceptions métier
  2. ✅ Implémenter un GlobalExceptionHandler
  3. ✅ Standardiser les réponses d'erreur
  4. ✅ Gérer les erreurs de validation
  5. ✅ Tester les différents scénarios

Durée estimée: 1h30


Contexte​

Vous développez une API REST pour une librairie en ligne. L'API doit retourner des messages d'erreur clairs et structurés pour faciliter l'intégration frontend.


Partie 1: Structure du Projet​

src/main/java/com/bookstore/
├── exception/
│ ├── ApplicationException.java
│ ├── ResourceNotFoundException.java
│ ├── ValidationException.java
│ ├── ConflictException.java
│ └── BusinessRuleException.java
├── dto/
│ └── ErrorResponse.java
├── handler/
│ └── GlobalExceptionHandler.java
├── entity/
│ └── Book.java
├── repository/
│ └── BookRepository.java
├── service/
│ └── BookService.java
└── controller/
└── BookController.java

Partie 2: Les Exceptions​

ApplicationException (Base)​

package com.bookstore.exception;

import lombok.Getter;

@Getter
public abstract class ApplicationException extends RuntimeException {

private final String errorCode;

protected ApplicationException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}

protected ApplicationException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}

ResourceNotFoundException​

package com.bookstore.exception;

public class ResourceNotFoundException extends ApplicationException {

public ResourceNotFoundException(String resourceType, Object id) {
super("RESOURCE_NOT_FOUND",
String.format("%s with ID '%s' not found", resourceType, id));
}
}

ConflictException​

// TODO: Implémenter
// Code: "CONFLICT"
// Usage: throw new ConflictException("ISBN already exists: " + isbn);

ValidationException​

// TODO: Implémenter
// Code: "VALIDATION_FAILED"
// Doit stocker une liste de FieldError

BusinessRuleException​

// TODO: Implémenter
// Code: Paramétrable
// Usage: throw new BusinessRuleException("BOOK_NOT_AVAILABLE", "Book is out of stock");

Partie 3: ErrorResponse DTO​

package com.bookstore.dto;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.List;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {

private String code;
private String message;
private LocalDateTime timestamp;
private String path;
private List<FieldError> errors;

@Data
@AllArgsConstructor
public static class FieldError {
private String field;
private String message;
private Object rejectedValue;
}

public static ErrorResponse of(String code, String message, String path) {
return ErrorResponse.builder()
.code(code)
.message(message)
.timestamp(LocalDateTime.now())
.path(path)
.build();
}
}

Partie 4: GlobalExceptionHandler​

package com.bookstore.handler;

import com.bookstore.dto.ErrorResponse;
import com.bookstore.exception.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

// ResourceNotFoundException → 404
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}

// TODO: ConflictException → 409

// TODO: BusinessRuleException → 422

// TODO: MethodArgumentNotValidException → 400 (avec liste des erreurs de validation)

// TODO: ConstraintViolationException → 400

// TODO: IllegalArgumentException → 400

// TODO: Exception générique → 500 (ne pas exposer le message!)
}

Partie 5: L'Entité et le Service​

Book Entity​

package com.bookstore.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Entity
@Table(name = "books")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@NotBlank(message = "Title is required")
private String title;

@NotBlank(message = "Author is required")
private String author;

@Column(unique = true)
@Pattern(regexp = "\\d{13}", message = "ISBN must be 13 digits")
private String isbn;

@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
private BigDecimal price;

@Min(value = 0, message = "Stock cannot be negative")
private Integer stock = 0;
}

BookService​

package com.bookstore.service;

import com.bookstore.entity.Book;
import com.bookstore.exception.*;
import com.bookstore.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class BookService {

private final BookRepository bookRepository;

public List<Book> findAll() {
return bookRepository.findAll();
}

public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Book", id));
}

@Transactional
public Book create(Book book) {
// Vérifier l'unicité de l'ISBN
if (bookRepository.existsByIsbn(book.getIsbn())) {
throw new ConflictException("ISBN already exists: " + book.getIsbn());
}

log.info("Creating book: {}", book.getTitle());
return bookRepository.save(book);
}

@Transactional
public Book update(Long id, Book updated) {
Book existing = findById(id);

// Vérifier l'ISBN si modifié
if (!existing.getIsbn().equals(updated.getIsbn())
&& bookRepository.existsByIsbn(updated.getIsbn())) {
throw new ConflictException("ISBN already exists: " + updated.getIsbn());
}

existing.setTitle(updated.getTitle());
existing.setAuthor(updated.getAuthor());
existing.setIsbn(updated.getIsbn());
existing.setPrice(updated.getPrice());
existing.setStock(updated.getStock());

return bookRepository.save(existing);
}

@Transactional
public void delete(Long id) {
Book book = findById(id);
bookRepository.delete(book);
log.info("Deleted book: {}", id);
}

@Transactional
public void decreaseStock(Long id, int quantity) {
Book book = findById(id);

if (book.getStock() < quantity) {
throw new BusinessRuleException(
"INSUFFICIENT_STOCK",
String.format("Not enough stock. Available: %d, Requested: %d",
book.getStock(), quantity)
);
}

book.setStock(book.getStock() - quantity);
bookRepository.save(book);
}
}

BookController​

package com.bookstore.controller;

import com.bookstore.entity.Book;
import com.bookstore.service.BookService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/books")
@RequiredArgsConstructor
public class BookController {

private final BookService bookService;

@GetMapping
public List<Book> getAll() {
return bookService.findAll();
}

@GetMapping("/{id}")
public Book getById(@PathVariable Long id) {
return bookService.findById(id);
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book create(@Valid @RequestBody Book book) {
return bookService.create(book);
}

@PutMapping("/{id}")
public Book update(@PathVariable Long id, @Valid @RequestBody Book book) {
return bookService.update(id, book);
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
bookService.delete(id);
}

@PostMapping("/{id}/decrease-stock")
public void decreaseStock(@PathVariable Long id, @RequestParam int quantity) {
bookService.decreaseStock(id, quantity);
}
}

Partie 6: Tests avec cURL​

Test 1: Créer un livre valide​

curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "Spring Boot in Action",
"author": "Craig Walls",
"isbn": "9781617292545",
"price": 45.99,
"stock": 10
}'

Réponse attendue: 201 Created

Test 2: Livre non trouvé (404)​

curl http://localhost:8080/api/books/999

Réponse attendue:

{
"code": "RESOURCE_NOT_FOUND",
"message": "Book with ID '999' not found",
"timestamp": "2024-01-15T10:30:00",
"path": "/api/books/999"
}

Test 3: ISBN dupliqué (409)​

curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "Another Book",
"author": "Author",
"isbn": "9781617292545",
"price": 29.99,
"stock": 5
}'

Réponse attendue:

{
"code": "CONFLICT",
"message": "ISBN already exists: 9781617292545",
"timestamp": "...",
"path": "/api/books"
}

Test 4: Validation échouée (400)​

curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{
"title": "",
"author": "",
"isbn": "123",
"price": -10,
"stock": -5
}'

Réponse attendue:

{
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"timestamp": "...",
"path": "/api/books",
"errors": [
{"field": "title", "message": "Title is required", "rejectedValue": ""},
{"field": "isbn", "message": "ISBN must be 13 digits", "rejectedValue": "123"},
{"field": "price", "message": "Price must be positive", "rejectedValue": -10}
]
}

Test 5: Stock insuffisant (422)​

curl -X POST "http://localhost:8080/api/books/1/decrease-stock?quantity=100"

Réponse attendue:

{
"code": "INSUFFICIENT_STOCK",
"message": "Not enough stock. Available: 10, Requested: 100",
"timestamp": "...",
"path": "/api/books/1/decrease-stock"
}

Partie 7: Solutionnaire​

Cliquer pour voir le GlobalExceptionHandler complet
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}

@ExceptionHandler(ConflictException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(ConflictException ex, HttpServletRequest request) {
log.warn("Conflict: {}", ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}

@ExceptionHandler(BusinessRuleException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponse handleBusinessRule(BusinessRuleException ex, HttpServletRequest request) {
log.warn("Business rule violation: {} - {}", ex.getErrorCode(), ex.getMessage());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ErrorResponse.FieldError> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(e -> new ErrorResponse.FieldError(
e.getField(),
e.getDefaultMessage(),
e.getRejectedValue()
))
.collect(Collectors.toList());

ErrorResponse response = ErrorResponse.of("VALIDATION_FAILED", "Validation failed", request.getRequestURI());
response.setErrors(errors);
return response;
}

@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest request) {
List<ErrorResponse.FieldError> errors = ex.getConstraintViolations()
.stream()
.map(v -> new ErrorResponse.FieldError(
v.getPropertyPath().toString(),
v.getMessage(),
v.getInvalidValue()
))
.collect(Collectors.toList());

ErrorResponse response = ErrorResponse.of("CONSTRAINT_VIOLATION", "Constraint violation", request.getRequestURI());
response.setErrors(errors);
return response;
}

@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) {
log.warn("Bad request: {}", ex.getMessage());
return ErrorResponse.of("BAD_REQUEST", ex.getMessage(), request.getRequestURI());
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex, HttpServletRequest request) {
log.error("Unexpected error at {}", request.getRequestURI(), ex);
return ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred", request.getRequestURI());
}
}

Checklist​

  • Toutes les exceptions personnalisées créées
  • GlobalExceptionHandler complet
  • Réponses JSON structurées
  • Codes HTTP corrects (400, 404, 409, 422, 500)
  • Logs appropriés (warn pour client errors, error pour server errors)
  • Tests cURL réussis

🎯 Prochaine Étape​

➡️ TP10.2: Système de Logging


⬅️ Retour au Module 10