How do I return the custom validation errors for Springboot 3.0 with WebFlux?
I have wired up the following controller
import jakarta.validation.Valid;
//...
@Validated
@RestController
@RequestMapping("/organizations")
@RequiredArgsConstructor
public class OrganizationController {
private final OrganizationService organizationService;
@PostMapping("/create")
public Mono<ResponseEntity<Organization>> create(@Valid @RequestBody final OrganizationDto organizationDto) {
return organizationService.create(organizationDto).map(ResponseEntity::ok);
}
}
The OrganizationDto
has been setup as:
import jakarta.validation.constraints.NotNull;
//...
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public final class OrganizationDto {
@NotNull
private String name;
//...
}
And finally I have what I thought was a correct ValidationHandler/ErrorController
@Slf4j
@ControllerAdvice
@RequiredArgsConstructor
public class ErrorController {
@ResponseBody
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleValidationExceptions(final MethodArgumentNotValidException ex) {
final BindingResult bindingResult = ex.getBindingResult();
final List<FieldError> fieldErrors = bindingResult.getFieldErrors();
final Map<String, String> errors = new HashMap<>();
fieldErrors.forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
return errors;
}
}
However if I send a payload to the endpoint in the controller of
{
"name": null
}
I get back
{
"timestamp": 1677430410704,
"path": "/organizations/create",
"status": 400,
"error": "Bad Request",
"requestId": "2050221b-2"
}
Which is almost what I want, but I'm trying to get the reason why it failed validation into the response but not having any luck
I've put breakpoints on the handleValidationExceptions
but looks like I'm never getting into it, and I'm also not seeing anything in the server side logs which points to whats going on.
I do have org.springframework.boot:spring-boot-starter-validation
on my classpath, and I'm using the latest Springboot 3.0.3
Have I missed a step or annotation here?
I was able to solve this by deleting the ErrorController
and going back to what I had previously tried which was writing a custom implementation of WebExceptionHandler
The important thing to note here is that you must set the @Order
value otherwise this implementation is skipped over and never called (Which is what lead me to ignore this solution originally).
My version of the WebExceptionHandler
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebExceptionHandler;
import reactor.core.publisher.Mono;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
@RequiredArgsConstructor
public class ValidationHandler implements WebExceptionHandler {
private final ObjectMapper objectMapper;
@Override
@SneakyThrows
public Mono<Void> handle(final ServerWebExchange exchange, final Throwable throwable) {
if (throwable instanceof WebExchangeBindException validationEx) {
final Map<String, String> errors = getValidationErrors(validationEx);
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
return writeResponse(exchange, objectMapper.writeValueAsBytes(errors));
} else {
return Mono.error(throwable);
}
}
private Map<String, String> getValidationErrors(final WebExchangeBindException validationEx) {
return validationEx.getBindingResult().getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField,
error -> Optional.ofNullable(error.getDefaultMessage()).orElse("")));
}
private Mono<Void> writeResponse(final ServerWebExchange exchange, final byte[] responseBytes) {
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(responseBytes)));
}
}
This will correctly return the response of:
{
"name": "must not be null"
}
When passing in the request used in my question.
Note: If anyone uses this, be careful with the @SneakyThrows