diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java index 04b4833..a043026 100644 --- a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/AbstractErrorHandlingConfiguration.java @@ -1,6 +1,7 @@ package io.github.wimdeblauwe.errorhandlingspringbootstarter; import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.BindApiExceptionHandler; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.HandlerMethodValidationExceptionHandler; import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.HttpMessageNotReadableApiExceptionHandler; import io.github.wimdeblauwe.errorhandlingspringbootstarter.handler.TypeMismatchApiExceptionHandler; import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; @@ -91,6 +92,14 @@ public BindApiExceptionHandler bindApiExceptionHandler(ErrorHandlingProperties p return new BindApiExceptionHandler(properties, httpStatusMapper, errorCodeMapper, errorMessageMapper); } + @Bean + @ConditionalOnMissingBean + public HandlerMethodValidationExceptionHandler handlerMethodValidationExceptionHandler(HttpStatusMapper httpStatusMapper, + ErrorCodeMapper errorCodeMapper, + ErrorMessageMapper errorMessageMapper) { + return new HandlerMethodValidationExceptionHandler(httpStatusMapper, errorCodeMapper, errorMessageMapper); + } + @Bean @ConditionalOnMissingBean public ApiErrorResponseSerializer apiErrorResponseSerializer(ErrorHandlingProperties properties) { diff --git a/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandler.java b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandler.java new file mode 100644 index 0000000..6ee22ba --- /dev/null +++ b/src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandler.java @@ -0,0 +1,60 @@ +package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; + +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiFieldError; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiGlobalError; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorCodeMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.ErrorMessageMapper; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.mapper.HttpStatusMapper; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.method.annotation.HandlerMethodValidationException; + +import java.util.List; +import java.util.Optional; + +public class HandlerMethodValidationExceptionHandler extends AbstractApiExceptionHandler { + + public HandlerMethodValidationExceptionHandler(HttpStatusMapper httpStatusMapper, + ErrorCodeMapper errorCodeMapper, + ErrorMessageMapper errorMessageMapper) { + + super(httpStatusMapper, errorCodeMapper, errorMessageMapper); + } + + @Override + public boolean canHandle(Throwable exception) { + return exception instanceof HandlerMethodValidationException; + } + + @Override + public ApiErrorResponse handle(Throwable ex) { + var response = new ApiErrorResponse(HttpStatus.BAD_REQUEST, getErrorCode(ex), getErrorMessage(ex)); + var validationException = (HandlerMethodValidationException) ex; + List errors = validationException.getAllErrors(); + + errors.forEach(error -> { + if (error instanceof FieldError fieldError) { + var apiFieldError = new ApiFieldError( + errorCodeMapper.getErrorCode(fieldError.getCode()), + fieldError.getField(), + errorMessageMapper.getErrorMessage(fieldError.getCode(), fieldError.getDefaultMessage()), + fieldError.getRejectedValue(), + null); + response.addFieldError(apiFieldError); + } else { + var lastCode = Optional.ofNullable(error.getCodes()) + .filter(codes -> codes.length > 0) + .map(codes -> codes[codes.length - 1]) + .orElse(null); + var apiGlobalErrorMessage = new ApiGlobalError( + errorCodeMapper.getErrorCode(lastCode), + errorMessageMapper.getErrorMessage(lastCode, error.getDefaultMessage())); + response.addGlobalError(apiGlobalErrorMessage); + } + }); + + return response; + } +} diff --git a/src/main/resources/error-handling-defaults.properties b/src/main/resources/error-handling-defaults.properties index 2f5fa11..e911ede 100644 --- a/src/main/resources/error-handling-defaults.properties +++ b/src/main/resources/error-handling-defaults.properties @@ -1,4 +1,6 @@ error.handling.codes.org.springframework.web.bind.MethodArgumentNotValidException=VALIDATION_FAILED +error.handling.codes.org.springframework.web.method.annotation.HandlerMethodValidationException=VALIDATION_FAILED +error.handling.messages.org.springframework.web.method.annotation.HandlerMethodValidationException=There was a validation failure. error.handling.codes.org.springframework.http.converter.HttpMessageNotReadableException=MESSAGE_NOT_READABLE error.handling.codes.jakarta.validation.ConstraintViolationException=VALIDATION_FAILED error.handling.codes.org.springframework.beans.TypeMismatchException=TYPE_MISMATCH diff --git a/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandlerTest.java b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandlerTest.java new file mode 100644 index 0000000..48cc96f --- /dev/null +++ b/src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/HandlerMethodValidationExceptionHandlerTest.java @@ -0,0 +1,154 @@ +package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler; + +import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties; +import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration; +import jakarta.validation.*; +import jakarta.validation.constraints.NotNull; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockPart; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.lang.annotation.*; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@WebMvcTest +@ContextConfiguration(classes = {ServletErrorHandlingConfiguration.class, + HandlerMethodValidationExceptionHandlerTest.TestController.class}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class HandlerMethodValidationExceptionHandlerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + @WithMockUser + void testHandlerMethodViolationException() throws Exception { + mockMvc.perform(multipart("/test/update-event") + .part(new MockPart("eventRequest", null, "{}".getBytes(StandardCharsets.UTF_8), MediaType.APPLICATION_JSON)) + .part(new MockPart("file", "file.jpg", new byte[0], MediaType.IMAGE_JPEG)) + .with(request -> { + request.setMethod(HttpMethod.PUT.name()); + return request; + }) + .with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("message").value("There was a validation failure.")) + .andExpect(jsonPath("fieldErrors", hasSize(1))) + .andExpect(jsonPath("fieldErrors..code", allOf(hasItem("REQUIRED_NOT_NULL")))) + .andExpect(jsonPath("fieldErrors..property", allOf(hasItem("dateTime")))) + .andExpect(jsonPath("fieldErrors..message", allOf(hasItem("must not be null")))) + .andExpect(jsonPath("fieldErrors..rejectedValue", allOf(hasItem(nullValue())))) + .andExpect(jsonPath("globalErrors", hasSize(1))) + .andExpect(jsonPath("globalErrors..code", allOf(hasItem("ValidFileType")))) + .andExpect(jsonPath("globalErrors..message", allOf(hasItem("")))) + ; + } + + @Test + @WithMockUser + void testHandlerMethodViolationException_customValidationAnnotationOverride(@Autowired ErrorHandlingProperties properties) throws Exception { + properties.getCodes().put("ValidFileType", "INVALID_FILE_TYPE"); + properties.getMessages().put("ValidFileType", "The file type is invalid. Only text/plain and application/pdf allowed."); + mockMvc.perform(multipart("/test/update-event") + .part(new MockPart("eventRequest", null, "{}".getBytes(StandardCharsets.UTF_8), MediaType.APPLICATION_JSON)) + .part(new MockPart("file", "file.jpg", new byte[0], MediaType.IMAGE_JPEG)) + .with(request -> { + request.setMethod(HttpMethod.PUT.name()); + return request; + }) + .with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("message").value("There was a validation failure.")) + .andExpect(jsonPath("fieldErrors", hasSize(1))) + .andExpect(jsonPath("fieldErrors..code", allOf(hasItem("REQUIRED_NOT_NULL")))) + .andExpect(jsonPath("fieldErrors..property", allOf(hasItem("dateTime")))) + .andExpect(jsonPath("fieldErrors..message", allOf(hasItem("must not be null")))) + .andExpect(jsonPath("fieldErrors..rejectedValue", allOf(hasItem(nullValue())))) + .andExpect(jsonPath("globalErrors", hasSize(1))) + .andExpect(jsonPath("globalErrors..code", allOf(hasItem("INVALID_FILE_TYPE")))) + .andExpect(jsonPath("globalErrors..message", allOf(hasItem("The file type is invalid. Only text/plain and application/pdf allowed.")))) + ; + } + + @RestController + @RequestMapping + static class TestController { + + @PutMapping("/test/update-event") + public void updateEvent( + @Valid @RequestPart EventRequest eventRequest, + @Valid @ValidFileType @RequestPart MultipartFile file) { + + } + } + + static class EventRequest { + @NotNull + private LocalDateTime dateTime; + + public LocalDateTime getDateTime() { + return dateTime; + } + + public void setDateTime(LocalDateTime dateTime) { + this.dateTime = dateTime; + } + } + + @Documented + @Constraint(validatedBy = MultiPartFileValidator.class) + @Target(ElementType.PARAMETER) + @Retention(RetentionPolicy.RUNTIME) + @interface ValidFileType { + + // Default list of allowed file types + String[] value() default { + MediaType.TEXT_PLAIN_VALUE, + MediaType.APPLICATION_PDF_VALUE + }; + + String message() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + } + + static class MultiPartFileValidator implements ConstraintValidator { + + private List allowed; + + @Override + public void initialize(ValidFileType constraintAnnotation) { + allowed = List.of(constraintAnnotation.value()); + } + + @Override + public boolean isValid(MultipartFile file, ConstraintValidatorContext context) { + return file == null || allowed.contains(file.getContentType()); + } + } +}