Checklist API REST Java : 25 points avant de mettre en production
Les 25 vérifications indispensables avant de déployer une API REST Java/Spring Boot en production : sécurité, performance, documentation, gestion des erreurs, monitoring et bonnes pratiques.
Une API REST Java mise en production sans checklist, c’est un risque calculé qu’on finit toujours par regretter. Cette checklist couvre les 25 points que j’audite systématiquement avant tout déploiement — que ce soit un nouveau développement ou une reprise de code.
Design et contrats
1. Versioning de l’API
// ✅ Versionner dès le départ
@RequestMapping("/api/v1/commandes")
// ❌ Pas de version = impossible à faire évoluer sans casser les clients
@RequestMapping("/api/commandes")
2. Conventions REST respectées
- GET : lecture, idempotent, sans side-effects
- POST : création, retourne 201 Created + Location header
- PUT : remplacement complet, idempotent
- PATCH : modification partielle
- DELETE : suppression, retourne 204 No Content
3. Nommage des ressources en noms (pas en verbes)
✅ GET /api/v1/commandes/{id}
❌ GET /api/v1/getCommande/{id}
❌ POST /api/v1/createCommande
4. Pagination sur toutes les collections
@GetMapping("/commandes")
public Page<CommandeDto> getCommandes(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "dateCreation,desc") String sort
) {
return commandeService.findAll(PageRequest.of(page, size, Sort.by(sort.split(","))));
}
Ne jamais retourner une liste non bornée — une table avec 500 000 lignes répondra à votre GET sans filtre.
Gestion des erreurs
5. Format d’erreur standardisé (RFC 7807)
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage()
);
problem.setTitle("Ressource introuvable");
problem.setType(URI.create("https://api.monapp.com/errors/not-found"));
return problem;
}
6. Codes HTTP corrects
200 OK — succès d'un GET/PUT/PATCH
201 Created — ressource créée avec succès
204 No Content — DELETE réussi
400 Bad Request — données invalides (validation failed)
401 Unauthorized— token absent ou invalide
403 Forbidden — token valide mais droits insuffisants
404 Not Found — ressource inexistante
409 Conflict — doublon, contrainte d'unicité
422 Unprocessable — données syntaxiquement correctes mais sémantiquement invalides
429 Too Many Requests — rate limiting
500 Internal Server Error — erreur inattendue (ne pas exposer les détails)
7. Pas de stack trace dans les réponses d’erreur en production
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleGeneric(Exception ex) {
// Logger l'exception complète côté serveur
log.error("Erreur inattendue", ex);
// Retourner un message générique au client (pas la stack trace)
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setDetail("Une erreur inattendue est survenue. Référence : " + UUID.randomUUID());
return ResponseEntity.status(500).body(problem);
}
}
8. Validation des entrées avec Bean Validation
public record CommandeRequest(
@NotBlank(message = "Le numéro client est obligatoire")
String numeroClient,
@NotEmpty(message = "La commande doit contenir au moins un produit")
@Size(max = 100, message = "Maximum 100 produits par commande")
List<@Valid LigneCommandeRequest> lignes,
@FutureOrPresent(message = "La date de livraison ne peut pas être dans le passé")
LocalDate dateLivraison
) {}
Sécurité
9. Authentification JWT bien configurée
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/produits/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.csrf(AbstractHttpConfigurer::disable) // OK pour API stateless
.build();
}
10. Durée de vie des tokens
- Access token : 15 minutes max
- Refresh token : 7 jours, révocable
- Ne jamais stocker de données sensibles dans le payload JWT (il est encodé, pas chiffré)
11. Protection CORS correctement configurée
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://monapp.com", "https://admin.monapp.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
// ❌ Ne jamais faire : config.setAllowedOrigins(List.of("*"))
return source;
}
12. Rate limiting
// Avec Bucket4j
@Bean
public FilterRegistrationBean<RateLimitFilter> rateLimitFilter() {
Bandwidth limit = Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1)));
// 100 requêtes/minute par IP
return new FilterRegistrationBean<>(new RateLimitFilter(limit));
}
13. Vérification des dépendances vulnérables
# Maven
mvn dependency-check:check
# Gradle
./gradlew dependencyCheckAnalyze
Configurer une alerte CI qui bloque si une CVE critique est détectée.
Performance
14. N+1 Query Problem résolu
// ❌ N+1 : 1 requête pour les commandes + N requêtes pour les lignes
List<Commande> commandes = commandeRepository.findAll();
commandes.forEach(c -> c.getLignes().size()); // lazy loading déclenché N fois
// ✅ JOIN FETCH : 1 seule requête
@Query("SELECT c FROM Commande c LEFT JOIN FETCH c.lignes WHERE c.statut = :statut")
List<Commande> findByStatutWithLignes(@Param("statut") StatutCommande statut);
15. Projections DTO pour les lectures
// Retourner uniquement les champs nécessaires
public interface CommandeResume {
String getNumero();
String getStatut();
BigDecimal getTotal();
}
List<CommandeResume> resumés = commandeRepository.findAllProjectedBy();
16. Cache pour les données stables
@Cacheable(value = "catalogueProduits", key = "#categorieId")
public List<ProduitDto> getProduitsParCategorie(Long categorieId) {
return produitRepository.findByCategorieId(categorieId)
.stream().map(mapper::toDto).toList();
}
@CacheEvict(value = "catalogueProduits", allEntries = true)
public void mettreAJourProduit(Long id, ProduitRequest request) { ... }
17. Timeout sur les appels externes
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectionRequestTimeout(2000); // 2s pour obtenir une connexion du pool
factory.setConnectTimeout(3000); // 3s pour établir la connexion
factory.setReadTimeout(10000); // 10s pour lire la réponse
return new RestTemplate(factory);
}
Documentation et contrat
18. OpenAPI / Swagger généré automatiquement
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
@Operation(summary = "Récupérer une commande par son ID")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Commande trouvée"),
@ApiResponse(responseCode = "404", description = "Commande inexistante")
})
@GetMapping("/{id}")
public CommandeDto getById(@PathVariable Long id) { ... }
19. Environnements et propriétés documentés
Toutes les propriétés application.properties personnalisées doivent être dans un fichier docs/configuration.md avec leur description, valeur par défaut et impact.
Monitoring et observabilité
20. Actuator configuré (sans tout exposer)
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when-authorized
management.endpoints.web.base-path=/actuator
Ne jamais exposer /actuator/env ou /actuator/beans en production — ils exposent des secrets.
21. Logs structurés (JSON) pour l’indexation
logging.pattern.console=%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n
# En production : Logstash encoder pour des logs JSON
// Contexte dans chaque log
log.info("Commande créée", Map.of(
"commandeId", commande.getId(),
"client", commande.getNumeroClient(),
"total", commande.getTotal()
));
22. Health checks personnalisés
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
@Override
public Health health() {
try {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
return Health.up().withDetail("database", "PostgreSQL responsive").build();
} catch (Exception e) {
return Health.down().withDetail("error", e.getMessage()).build();
}
}
}
Tests avant mise en production
23. Tests d’intégration sur les endpoints critiques
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
class CommandeControllerIT {
@Test
void creerCommande_avecDonneesValides_retourne201() throws Exception {
mockMvc.perform(post("/api/v1/commandes")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(validRequest()))
.header("Authorization", "Bearer " + validToken()))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.statut").value("EN_ATTENTE"));
}
}
24. Tests de charge sur les endpoints critiques
# k6 — test de charge simple
k6 run --vus 50 --duration 60s script.js
25. Smoke test post-déploiement automatisé
#!/bin/bash
# smoke-test.sh — lancé par le pipeline CI après chaque déploiement
BASE_URL="${API_URL:-https://api.monapp.com}"
check() {
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$1")
if [ "$STATUS" != "$2" ]; then
echo "❌ FAIL : $1 → HTTP $STATUS (attendu : $2)"
exit 1
fi
echo "✅ OK : $1 → HTTP $STATUS"
}
check "$BASE_URL/actuator/health" 200
check "$BASE_URL/api/v1/produits?size=1" 200
check "$BASE_URL/api/v1/commandes" 401 # sans token → 401 attendu
Récapitulatif
| Catégorie | Points | Bloquant avant prod |
|---|---|---|
| Design & contrats | 4 | Versioning, pagination |
| Gestion des erreurs | 4 | Format, codes HTTP, pas de stack trace |
| Sécurité | 5 | Auth, CORS, dépendances vulnérables |
| Performance | 4 | N+1, timeout |
| Documentation | 2 | OpenAPI |
| Monitoring | 3 | Actuator, logs structurés |
| Tests | 3 | Intégration, smoke test |
Les points “bloquants avant prod” sont non-négociables. Les autres peuvent faire l’objet d’une dette technique documentée.
Amine MEGDICHE
Développeur AEM & Java Full Stack — Freelance depuis 2013