diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java
index 0634b82..899a9b4 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandler.java
@@ -9,6 +9,7 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
@@ -140,6 +141,32 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(e.getStatusCode()).body(body);
}
+ /**
+ * Bean-валидация {@code @Valid @RequestBody} провалилась — клиент прислал
+ * тело, не прошедшее {@code @NotBlank}/{@code @NotNull}/{@code @Size} и т.п.
+ *
+ *
До этого handler'а {@link MethodArgumentNotValidException} падал в
+ * generic {@link Exception} catch ниже → 500 {@code internal_error} с пустым
+ * traceId вместо 400. Эффект: admin-ui на создании/редактировании записи
+ * получал бесполезный «Internal server error» без указания полей, форма
+ * молча не сохранялась. Теперь — 400 {@code validation_failed} с
+ * пофайловой разбивкой ошибок (как у доменного {@link ValidationException},
+ * только статус 400 — тело запроса malformed, а не бизнес-правило).
+ */
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity onMethodArgumentNotValid(MethodArgumentNotValidException e) {
+ List fields = e.getBindingResult().getFieldErrors().stream()
+ .map(fe -> new ApiErrorResponse.FieldError(
+ fe.getField(),
+ fe.getCode() != null ? fe.getCode() : "invalid",
+ fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid"))
+ .toList();
+ log.debug("Bean validation failed: {} field error(s)", fields.size());
+ var body = ApiErrorResponse.withFieldErrors(
+ 400, "validation_failed", "Запрос не прошёл валидацию", fields, currentTraceId());
+ return ResponseEntity.badRequest().body(body);
+ }
+
@ExceptionHandler(Exception.class)
public ResponseEntity onGeneric(Exception e) {
log.error("Unhandled exception in REST API", e);
diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandlerTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandlerTest.java
new file mode 100644
index 0000000..d8dcf5b
--- /dev/null
+++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/error/GlobalExceptionHandlerTest.java
@@ -0,0 +1,63 @@
+package cloud.nstart.terravault.ordinis.restapi.error;
+
+import io.micrometer.tracing.Tracer;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.core.MethodParameter;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.BeanPropertyBindingResult;
+import org.springframework.validation.BindingResult;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+
+import java.util.Collections;
+import java.util.Iterator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests для {@link GlobalExceptionHandler}.
+ *
+ * Regression: {@link MethodArgumentNotValidException} (провал bean-валидации
+ * {@code @Valid @RequestBody}) раньше не имел собственного handler'а → падал в
+ * generic {@code Exception} catch → HTTP 500 {@code internal_error}. admin-ui
+ * на создании/редактировании записи получал бесполезный «Internal server error»,
+ * форма молча не сохранялась. Должен возвращать 400 {@code validation_failed}.
+ */
+class GlobalExceptionHandlerTest {
+
+ /** Пустой ObjectProvider — currentTraceId() получит null, без трассировки. */
+ private static final ObjectProvider NO_TRACER = new ObjectProvider<>() {
+ @Override public Tracer getObject() { throw new UnsupportedOperationException(); }
+ @Override public Tracer getObject(Object... args) { throw new UnsupportedOperationException(); }
+ @Override public Tracer getIfAvailable() { return null; }
+ @Override public Tracer getIfUnique() { return null; }
+ @Override public Iterator iterator() { return Collections.emptyIterator(); }
+ };
+
+ private final GlobalExceptionHandler handler = new GlobalExceptionHandler(NO_TRACER);
+
+ /** Цель только для конструктора {@link MethodParameter} — нужен реальный параметр метода. */
+ @SuppressWarnings("unused")
+ private void sampleEndpoint(String body) {}
+
+ @Test
+ void beanValidationFailureReturns400WithFieldErrors() throws Exception {
+ BindingResult br = new BeanPropertyBindingResult(new Object(), "createRecordRequest");
+ br.addError(new FieldError("createRecordRequest", "businessKey", "must not be blank"));
+ br.addError(new FieldError("createRecordRequest", "data", "must not be null"));
+ MethodParameter mp = new MethodParameter(
+ GlobalExceptionHandlerTest.class.getDeclaredMethod("sampleEndpoint", String.class), 0);
+ MethodArgumentNotValidException ex = new MethodArgumentNotValidException(mp, br);
+
+ ResponseEntity resp = handler.onMethodArgumentNotValid(ex);
+
+ // Главное: 400, НЕ 500.
+ assertThat(resp.getStatusCode().value()).isEqualTo(400);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().code()).isEqualTo("validation_failed");
+ assertThat(resp.getBody().fieldErrors())
+ .extracting(ApiErrorResponse.FieldError::path)
+ .containsExactlyInAnyOrder("businessKey", "data");
+ }
+}