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"); + } +}