Search code examples
springspring-bootspring-webfluxproject-reactorspring-aop

How to advise (AOP) spring webflux web handlers to catch and transform reactive error


[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