Skip to content

Commit

Permalink
Merge pull request #107 from wimdeblauwe/feature/gh-102
Browse files Browse the repository at this point in the history
feat: Support HandlerMethodValidationException
  • Loading branch information
wimdeblauwe authored Sep 25, 2024
2 parents ceae8ae + 5c8f8ef commit 34d2cec
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<? extends MessageSourceResolvable> 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;
}
}
2 changes: 2 additions & 0 deletions src/main/resources/error-handling-defaults.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<? extends Payload>[] payload() default {};
}

static class MultiPartFileValidator implements ConstraintValidator<ValidFileType, MultipartFile> {

private List<String> 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());
}
}
}

0 comments on commit 34d2cec

Please sign in to comment.