fix(rest-api): bean-валидация возвращает 400 вместо 500
GlobalExceptionHandler не имел @ExceptionHandler для MethodArgumentNotValidException — провал bean-валидации @Valid @RequestBody (@NotBlank businessKey, @NotNull data и т.п.) падал в generic Exception catch → HTTP 500 internal_error с пустым traceId. admin-ui на создании/редактировании записи получал бесполезный «Internal server error» без полей вместо 400 с пофайловой разбивкой. Добавлен handler → 400 validation_failed с FieldError-списком. Покрыто GlobalExceptionHandlerTest.
This commit is contained in:
+27
@@ -9,6 +9,7 @@ import org.springframework.beans.factory.ObjectProvider;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
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.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -140,6 +141,32 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(e.getStatusCode()).body(body);
|
return ResponseEntity.status(e.getStatusCode()).body(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bean-валидация {@code @Valid @RequestBody} провалилась — клиент прислал
|
||||||
|
* тело, не прошедшее {@code @NotBlank}/{@code @NotNull}/{@code @Size} и т.п.
|
||||||
|
*
|
||||||
|
* <p>До этого 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<ApiErrorResponse> onMethodArgumentNotValid(MethodArgumentNotValidException e) {
|
||||||
|
List<ApiErrorResponse.FieldError> 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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiErrorResponse> onGeneric(Exception e) {
|
public ResponseEntity<ApiErrorResponse> onGeneric(Exception e) {
|
||||||
log.error("Unhandled exception in REST API", e);
|
log.error("Unhandled exception in REST API", e);
|
||||||
|
|||||||
+63
@@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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<Tracer> 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<Tracer> 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<ApiErrorResponse> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user