Validation ve Global Exception Handling
Bir API'nin dış dünyaya bakan iki kritik sorumluluğu vardır: gelen veriyi doğrulamak ve hata olduğunda istemciye anlamlı, tutarlı bir cevap dönmek. Spring Framework bölümünde Bean Validation'ı öğrendik; şimdi onu bir…
Validation ve Global Exception Handling
Bir API'nin dış dünyaya bakan iki kritik sorumluluğu vardır: gelen veriyi doğrulamak ve hata
olduğunda istemciye anlamlı, tutarlı bir cevap dönmek. Spring Framework bölümünde Bean
Validation'ı öğrendik; şimdi onu bir web API'sinde @Valid ile otomatik çalıştırmayı ve tüm
hataları tek yerden, zarif JSON gövdeleriyle yönetmeyi (@RestControllerAdvice) görüyoruz.
@Valid ile otomatik doğrulama
Spring MVC'de gelen JSON gövdesini bir DTO'ya bağlarken @Valid eklersen, Spring controller
metodunu çalıştırmadan önce kuralları kontrol eder. İhlal varsa
MethodArgumentNotValidException fırlar ve Spring Boot otomatik olarak 400 Bad Request döner:
record KullaniciDto(@NotBlank String ad, @Email String eposta, @Min(18) int yas) {} @PostMapping("/api/kullanicilar") public String kaydet(@Valid @RequestBody KullaniciDto dto) { ... } // geçersizse buraya HİÇ girilmez
Örnek 1 (./Ornek1.java) geçerli bir isteğin 200, geçersiz bir isteğin (boş ad, kötü e-posta,
yaş<18) 400 döndüğünü canlı gösterir. Geçersiz veri controller'a hiç ulaşmaz — ama Boot'un
varsayılan hata gövdesi kabadır ve API'ne özel değildir. Bunu düzeltmek için global bir hata
yöneticisi gerekir.
@RestControllerAdvice ile global hata yönetimi
@RestControllerAdvice, tüm controller'lardaki istisnaları tek bir yerde yakalamanı sağlar.
Her istisna tipi için bir @ExceptionHandler yazar, istediğin durum kodu ve gövdeyi dönersin.
Böylece hata yanıtların tutarlı ve anlamlı olur:
@RestControllerAdvice class GlobalHataYonetimi { @ExceptionHandler(MethodArgumentNotValidException.class) ResponseEntity<?> dogrulama(MethodArgumentNotValidException ex) { // alan bazlı temiz mesajlar -> 400 } @ExceptionHandler(KaynakBulunamadiException.class) ResponseEntity<?> bulunamadi(KaynakBulunamadiException ex) { // anlamlı gövde -> 404 } }
Örnek 2 (./Ornek2.java) doğrulama hatalarını alan bazlı temiz bir JSON'a (400) ve özel bir
"kaynak bulunamadı" istisnasını anlamlı bir gövdeye (404) çevirir. Artık API genelinde hatalar
aynı yapıda döner — istemci için öngörülebilir bir sözleşme.
Kısa yol ve standart: @ResponseStatus ve ProblemDetail
İki ek teknik:
@ResponseStatus: Bir istisna sınıfına doğrudan HTTP kodu iliştirirsin; o istisna fırladığında Boot o kodu döner — advice yazmana bile gerek kalmaz (basit durumlar için hızlı).@ResponseStatus(HttpStatus.CONFLICT) class StokYetersizException extends RuntimeException {}ProblemDetail(RFC 7807, Spring 6+): Hata gövdeleri için standart, makine-okunur bir biçim (type,title,status,detail+ özel alanlar). Mikroservislerde tutarlı hata sözleşmesi için idealdir.ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, mesaj); pd.setTitle("Sipariş bulunamadı");
Örnek 3 (./Ornek3.java) her ikisini de gösterir.
İyi uygulama notları
- DTO'ları doğrula, entity'leri değil: İstek gövdesi için ayrı DTO kullan; entity'leri doğrudan dışarı açma.
- İç içe nesneler:
@Valid'i iç içe alanlara da koyarsan derinlemesine doğrulanır. - Tutarlı hata sözleşmesi: Tüm endpoint'ler aynı hata yapısını dönsün (advice veya ProblemDetail ile). İstemci tek bir formatı işler.
- Hassas bilgi sızdırma: Hata gövdesinde stack trace / iç ayrıntı döndürme (üretimde).
Özet
@Valid ile gelen veriyi otomatik doğrulamayı ve geçersiz isteklerin 400 döndüğünü (Örnek 1),
@RestControllerAdvice ile tüm hataları tek yerden tutarlı JSON'a çevirmeyi (Örnek 2) ve
@ResponseStatus/ProblemDetail ile kısa yol ve standart hata gövdelerini (Örnek 3) canlı bir
API üzerinde gördük. API'nin giriş ve çıkış kapıları artık sağlam. Sırada, uygulamayı yetkisiz
erişime karşı koruyan kritik konu: Spring Security.
▶ Kod Örnekleri(3)
Ornek1
ortam gerekir1// Ornek1: @Valid ile istek gövdesini doğrulama — geçersiz veri otomatik 400 Bad Request olur.
2// Çalıştırma: portal derleyip gömülü Tomcat ile başlatır.
3package com.egitim.springboot.hata;
4
5import jakarta.validation.Valid;
6import jakarta.validation.constraints.Email;
7import jakarta.validation.constraints.Min;
8import jakarta.validation.constraints.NotBlank;
9import org.springframework.boot.CommandLineRunner;
10import org.springframework.boot.SpringApplication;
11import org.springframework.boot.autoconfigure.SpringBootApplication;
12import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
13import org.springframework.context.annotation.Bean;
14import org.springframework.http.MediaType;
15import org.springframework.web.bind.annotation.PostMapping;
16import org.springframework.web.bind.annotation.RequestBody;
17import org.springframework.web.bind.annotation.RestController;
18import org.springframework.web.client.RestClient;
19
20@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
21public class Ornek1 {
22
23 public static void main(String[] args) { SpringApplication.run(Ornek1.class, args); }
24
25 // Gelen JSON'un uyması gereken kurallar DTO üzerinde tanımlı.
26 static class KullaniciDto {
27 @NotBlank(message = "ad zorunludur")
28 public String ad;
29 @Email(message = "geçerli bir e-posta girin")
30 public String eposta;
31 @Min(value = 18, message = "yaş en az 18 olmalı")
32 public int yas;
33 }
34
35 @RestController
36 static class KullaniciController {
37 // @Valid: Spring, gövdeyi controller'a girmeden önce doğrular.
38 // İhlal varsa MethodArgumentNotValidException -> Boot otomatik 400 Bad Request döner.
39 @PostMapping("/api/kullanicilar")
40 public String kaydet(@Valid @RequestBody KullaniciDto dto) {
41 return "Kullanıcı kaydedildi: " + dto.ad;
42 }
43 }
44
45 @Bean
46 CommandLineRunner selfTest() {
47 return args -> {
48 RestClient c = RestClient.create("http://localhost:8080");
49 System.out.println("\n================ @Valid DOĞRULAMA ================");
50
51 // Geçerli istek -> 200
52 String body = "{\"ad\":\"Ada\",\"eposta\":\"ada@site.com\",\"yas\":30}";
53 var ok = c.post().uri("/api/kullanicilar").contentType(MediaType.APPLICATION_JSON)
54 .body(body).retrieve().toEntity(String.class);
55 System.out.println("Geçerli -> " + ok.getStatusCode() + " | " + ok.getBody());
56
57 // Geçersiz istek -> 400 (status + gövdeyi exchange ile okuyoruz, RestClient 4xx'te fırlatır)
58 String kotu = "{\"ad\":\"\",\"eposta\":\"gecersiz\",\"yas\":15}";
59 c.post().uri("/api/kullanicilar").contentType(MediaType.APPLICATION_JSON).body(kotu)
60 .exchange((req, res) -> {
61 System.out.println("Geçersiz -> " + res.getStatusCode());
62 String govde = new String(res.getBody().readAllBytes());
63 System.out.println(" gövde (Boot varsayılan hata): "
64 + govde.substring(0, Math.min(140, govde.length())) + "...");
65 return null;
66 });
67 System.out.println("==================================================");
68 System.out.println("@Valid sayesinde geçersiz veri controller'a HİÇ ulaşmadı; Boot 400 döndü.");
69 System.out.println("Ama varsayılan hata gövdesi kabadır -> Örnek 2'de @RestControllerAdvice ile düzelteceğiz.");
70 };
71 }
72}Bu örnek Spring / Spring Boot (Gradle) ortamı gerektirir; tek dosya olarak java Ornek1.java ile çalışmaz. Orijinal portal bunu gömülü Tomcat / Spring context ile koşuyordu. Beklenen davranış yukarıdaki anlatım ve kodda açıklanmıştır.
Ornek2
ortam gerekir1// Ornek2: @RestControllerAdvice ile global hata yönetimi — tutarlı, anlamlı JSON hata gövdeleri.
2// Çalıştırma: portal derleyip gömülü Tomcat ile başlatır.
3package com.egitim.springboot.hata;
4
5import jakarta.validation.Valid;
6import jakarta.validation.constraints.NotBlank;
7import org.springframework.boot.CommandLineRunner;
8import org.springframework.boot.SpringApplication;
9import org.springframework.boot.autoconfigure.SpringBootApplication;
10import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
11import org.springframework.context.annotation.Bean;
12import org.springframework.http.HttpStatus;
13import org.springframework.http.MediaType;
14import org.springframework.http.ResponseEntity;
15import org.springframework.web.bind.MethodArgumentNotValidException;
16import org.springframework.web.bind.annotation.*;
17import org.springframework.web.client.RestClient;
18
19import java.util.HashMap;
20import java.util.Map;
21
22@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
23public class Ornek2 {
24
25 public static void main(String[] args) { SpringApplication.run(Ornek2.class, args); }
26
27 static class UrunDto {
28 @NotBlank(message = "ad zorunludur")
29 public String ad;
30 }
31
32 // Özel iş istisnası.
33 static class KaynakBulunamadiException extends RuntimeException {
34 KaynakBulunamadiException(String m) { super(m); }
35 }
36
37 @RestController
38 @RequestMapping("/api/urunler")
39 static class UrunController {
40 @PostMapping
41 public String olustur(@Valid @RequestBody UrunDto dto) { return "oluşturuldu: " + dto.ad; }
42
43 @GetMapping("/{id}")
44 public String bul(@PathVariable Long id) {
45 if (id > 100) throw new KaynakBulunamadiException("Ürün bulunamadı: id=" + id);
46 return "Ürün #" + id;
47 }
48 }
49
50 // @RestControllerAdvice: TÜM controller'lardaki istisnalar burada tek yerde yakalanır.
51 @RestControllerAdvice
52 static class GlobalHataYonetimi {
53
54 // Doğrulama hatası -> 400 + alan bazlı temiz mesajlar.
55 @ExceptionHandler(MethodArgumentNotValidException.class)
56 public ResponseEntity<Map<String, Object>> dogrulama(MethodArgumentNotValidException ex) {
57 Map<String, String> alanlar = new HashMap<>();
58 ex.getBindingResult().getFieldErrors()
59 .forEach(fe -> alanlar.put(fe.getField(), fe.getDefaultMessage()));
60 return ResponseEntity.badRequest().body(Map.of(
61 "durum", 400, "hata", "Doğrulama hatası", "alanlar", alanlar));
62 }
63
64 // Kaynak bulunamadı -> 404 + anlamlı gövde.
65 @ExceptionHandler(KaynakBulunamadiException.class)
66 public ResponseEntity<Map<String, Object>> bulunamadi(KaynakBulunamadiException ex) {
67 return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(
68 "durum", 404, "hata", "Bulunamadı", "mesaj", ex.getMessage()));
69 }
70 }
71
72 @Bean
73 CommandLineRunner selfTest() {
74 return args -> {
75 RestClient c = RestClient.create("http://localhost:8080");
76 System.out.println("\n================ GLOBAL HATA YÖNETİMİ ================");
77
78 c.post().uri("/api/urunler").contentType(MediaType.APPLICATION_JSON).body("{\"ad\":\"\"}")
79 .exchange((req, res) -> {
80 System.out.println("Geçersiz POST -> " + res.getStatusCode());
81 System.out.println(" " + new String(res.getBody().readAllBytes()));
82 return null;
83 });
84
85 c.get().uri("/api/urunler/999").exchange((req, res) -> {
86 System.out.println("GET /999 -> " + res.getStatusCode());
87 System.out.println(" " + new String(res.getBody().readAllBytes()));
88 return null;
89 });
90 System.out.println("=====================================================");
91 System.out.println("Artık tüm hatalar TUTARLI, anlamlı JSON gövdeleriyle dönüyor (tek yerde yönetiliyor).");
92 };
93 }
94}Bu örnek Spring / Spring Boot (Gradle) ortamı gerektirir; tek dosya olarak java Ornek2.java ile çalışmaz. Orijinal portal bunu gömülü Tomcat / Spring context ile koşuyordu. Beklenen davranış yukarıdaki anlatım ve kodda açıklanmıştır.
Ornek3
ortam gerekir1// Ornek3: @ResponseStatus ile kısa yol ve ProblemDetail (RFC 7807) ile standart hata gövdesi.
2// Çalıştırma: portal derleyip gömülü Tomcat ile başlatır.
3package com.egitim.springboot.hata;
4
5import org.springframework.boot.CommandLineRunner;
6import org.springframework.boot.SpringApplication;
7import org.springframework.boot.autoconfigure.SpringBootApplication;
8import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
9import org.springframework.context.annotation.Bean;
10import org.springframework.http.HttpStatus;
11import org.springframework.http.ProblemDetail;
12import org.springframework.web.bind.annotation.ExceptionHandler;
13import org.springframework.web.bind.annotation.GetMapping;
14import org.springframework.web.bind.annotation.PathVariable;
15import org.springframework.web.bind.annotation.RestController;
16import org.springframework.web.bind.annotation.RestControllerAdvice;
17import org.springframework.web.client.RestClient;
18
19@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
20public class Ornek3 {
21
22 public static void main(String[] args) { SpringApplication.run(Ornek3.class, args); }
23
24 // YOL 1: @ResponseStatus — istisnaya doğrudan HTTP kodu iliştir (advice'a gerek yok).
25 @org.springframework.web.bind.annotation.ResponseStatus(HttpStatus.CONFLICT)
26 static class StokYetersizException extends RuntimeException {
27 StokYetersizException(String m) { super(m); }
28 }
29
30 // YOL 2 için iş istisnası (handler ProblemDetail döndürecek).
31 static class SiparisBulunamadiException extends RuntimeException {
32 SiparisBulunamadiException(String m) { super(m); }
33 }
34
35 @RestController
36 static class SiparisController {
37 @GetMapping("/api/siparis/{id}")
38 public String bul(@PathVariable Long id) {
39 if (id == 0) throw new StokYetersizException("Stok yetersiz");
40 if (id > 100) throw new SiparisBulunamadiException("Sipariş bulunamadı: id=" + id);
41 return "Sipariş #" + id;
42 }
43 }
44
45 @RestControllerAdvice
46 static class HataYonetimi {
47 // ProblemDetail: hata gövdeleri için standart (RFC 7807) biçim. Spring 6+ ile gelir.
48 @ExceptionHandler(SiparisBulunamadiException.class)
49 public ProblemDetail bulunamadi(SiparisBulunamadiException ex) {
50 ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
51 pd.setTitle("Sipariş bulunamadı");
52 pd.setProperty("kategori", "siparis");
53 return pd;
54 }
55 }
56
57 @Bean
58 CommandLineRunner selfTest() {
59 return args -> {
60 RestClient c = RestClient.create("http://localhost:8080");
61 System.out.println("\n================ @ResponseStatus + ProblemDetail ================");
62
63 c.get().uri("/api/siparis/0").exchange((req, res) -> {
64 System.out.println("GET /0 (@ResponseStatus) -> " + res.getStatusCode());
65 return null;
66 });
67
68 c.get().uri("/api/siparis/999").exchange((req, res) -> {
69 System.out.println("GET /999 (ProblemDetail) -> " + res.getStatusCode());
70 System.out.println(" " + new String(res.getBody().readAllBytes()));
71 return null;
72 });
73 System.out.println("=================================================================");
74 System.out.println("@ResponseStatus: hızlı, basit. ProblemDetail: standart, makine-okunur, zengin hata gövdesi.");
75 };
76 }
77}Bu örnek Spring / Spring Boot (Gradle) ortamı gerektirir; tek dosya olarak java Ornek3.java ile çalışmaz. Orijinal portal bunu gömülü Tomcat / Spring context ile koşuyordu. Beklenen davranış yukarıdaki anlatım ve kodda açıklanmıştır.