Issue
[UPDATE 2021-10-11] Added MCVE
https://github.com/SalathielGenese/issue-spring-webflux-reactive-error-advice
For reusability concerns, I run my validation on the service layer, which returns Mono.error( constraintViolationException )
...
So that my web handlers merely forward the unmarshalled domain to the service layer.
So far, so great.
But how do I advise (AOP) my web handlers so that it returns HTTP 422
with the formatted constraint violations ?
WebExchangeBindException
only handle exceptions thrown synchronously (I don't want synchronous validation to break the reactive flow).
My AOP advice trigger and error b/c :
- my web handler return
Mono<DataType>
- but my advice return a
ResponseEntity
And if I wrap my response entity (from the advice) into a Mono<ResponseEntity>
, I an HTTP 200 OK
with the response entity serialized :(
Code Excerpt
@Aspect
@Component
class CoreWebAspect {
@Pointcut("withinApiCorePackage() && @annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMappingWebHandler() {
}
@Pointcut("within(project.package.prefix.*)")
public void withinApiCorePackage() {
}
@Around("postMappingWebHandler()")
public Object aroundWebHandler(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
try {
final var proceed = proceedingJoinPoint.proceed();
if (proceed instanceof Mono<?> mono) {
try {
return Mono.just(mono.toFuture().get());
} catch (ExecutionException exception) {
if (exception.getCause() instanceof ConstraintViolationException constraintViolationException) {
return Mono.just(getResponseEntity(constraintViolationException));
}
throw exception.getCause();
}
}
return proceed;
} catch (ConstraintViolationException constraintViolationException) {
return getResponseEntity(constraintViolationException);
}
}
private ResponseEntity<Set<Violation>> getResponseEntity(final ConstraintViolationException constraintViolationException) {
final var violations = constraintViolationException.getConstraintViolations().stream().map(violation -> new Violation(
stream(violation.getPropertyPath().spliterator(), false).map(Node::getName).collect(toList()),
violation.getMessageTemplate().replaceFirst("^\\{(.*)\\}$", "$1"))
).collect(Collectors.toSet());
return status(UNPROCESSABLE_ENTITY).body(violations);
}
@Getter
@AllArgsConstructor
private static class Violation {
private final List<String> path;
private final String template;
}
}
Solution
From observation (I haven't found any proof in the documentation), Mono.just()
on response is automatically translated into 200 OK
regardless of the content. For that reason, Mono.error()
is needed. However, its constructors require Throwable
so ResponseStatusException
comes into play.
return Mono.error(new ResponseStatusException(UNPROCESSABLE_ENTITY));
- Request:
curl -i --request POST --url http://localhost:8080/welcome \ --header 'Content-Type: application/json' \ --data '{}'
- Response (formatted):
HTTP/1.1 422 Unprocessable Entity Content-Type: application/json Content-Length: 147 { "error": "Unprocessable Entity", "message": null, "path": "/welcome", "requestId": "7a3a464e-1", "status": 422, "timestamp": "2021-10-13T16:44:18.225+00:00" }
Finally, 422 Unprocessable Entity
is returned!
Sadly, the required List<Violation>
as a body can be passed into ResponseStatusException
only as a String reason
which ends up with an ugly response:
return Mono.error(new ResponseStatusException(UNPROCESSABLE_ENTITY, violations.toString()));
- Same request
- Response (formatted):
HTTP/1.1 422 Unprocessable Entity Content-Type: application/json Content-Length: 300 { "timestamp": "2021-10-13T16:55:30.927+00:00", "path": "/welcome", "status": 422, "error": "Unprocessable Entity", "message": "[IssueSpringWebfluxReactiveErrorAdviceApplication.AroundReactiveWebHandler.Violation(template={javax.validation.constraints.NotNull.message}, path=[name])]", "requestId": "de92dcbd-1" }
But there is a solution defining the ErrorAttributes
bean and adding violations into the body. Start with a custom exception and don't forget to annotate it with @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
to define the correct response status code:
@Getter
@RequiredArgsConstructor
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public class ViolationException extends RuntimeException {
private final List<Violation> violations;
}
Now define the ErrorAttributes
bean, get the violations and add it into the body:
@Bean
public ErrorAttributes errorAttributes() {
return new DefaultErrorAttributes() {
@Override
public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
Map<String, Object> errorAttributes = super.getErrorAttributes(request, options);
Throwable error = getError(request);
if (error instanceof ViolationException) {
ViolationException violationException = (ViolationException) error;
errorAttributes.put("violations", violationException .getViolations());
}
return errorAttributes;
}
};
}
And finally, do this in your aspect:
return Mono.error(new ViolationException(violations));
And test it out:
- Same request
- Response (formatted):
HTTP/1.1 422 Unprocessable Entity Content-Type: application/json Content-Length: 238 { "timestamp": "2021-10-13T17:07:07.668+00:00", "path": "/welcome", "status": 422, "error": "Unprocessable Entity", "message": "", "requestId": "a80b54d9-1", "violations": [ { "template": "{javax.validation.constraints.NotNull.message}", "path": [ "name" ] } ] }
The tests will pass. Don't forget some classes are newly from the reactive packages:
org.springframework.boot.web.reactive.error.ErrorAttributes
org.springframework.boot.web.reactive.error.DefaultErrorAttributes
org.springframework.web.reactive.function.server.ServerRequest
Answered By - Nikolas Charalambidis