2

[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;
    }
}
Nikolas Charalambidis
  • 40,893
  • 16
  • 117
  • 183
Salathiel Genese
  • 1,639
  • 2
  • 21
  • 37
  • 1
    I am neither a reactive programming nor a Spring expert, just an AOP expert. But if you have an [MCVE](https://stackoverflow.com/help/mcve)on GitHub for me which reproduces your situation in the simplest possible setup, I can take a look. I need to see for myself what exactly is happening. – kriegaex Oct 11 '21 at 15:44
  • Done. Thank you @kriegaex – Salathiel Genese Oct 11 '21 at 19:09
  • I looked at your MCVE and can reproduce the problem. This really looks like a reactive programming issue, and I am out of my depths here, never having learned anything about reactive programming. But I am confident that the MCVE enables some reactive geeks to find a solution for you. – kriegaex Oct 13 '21 at 10:03
  • Neither AOP nor reactive PI issues... More of a Spring Webflux implementation issue. Seems like it didn't handle reactive error at the right point in the node. – Salathiel Genese Oct 13 '21 at 13:58
  • 1
    Hm, I cannot say anything intelligent about that. But if you solved your problem, please write an answer in order to return something to the community. Thank you. – kriegaex Oct 13 '21 at 14:09
  • Thank you for taking the time to look closer at it, @kriegaex – Salathiel Genese Oct 13 '21 at 14:25

2 Answers2

3

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
Nikolas Charalambidis
  • 40,893
  • 16
  • 117
  • 183
  • 1
    This indeed keeps the spirit of reactivity as I spec'd in the bounty message. Making my joy most complete. I used it with `ObjectMapper` injected and `.onErrorMap(ConstraintViolationException.class, this::handleConstraintViolationException)`... Manyfold thanks, @Nikolas Charalambidis – Salathiel Genese Oct 14 '21 at 09:10
3

How about replacing the aspect with a @ControllerAdvice containing an @ExceptionHandler? But let us clean up the main application class, extracting the inner classes from it into an extra class:

package name.genese.salathiel.issuespringwebfluxreactiveerroradvice;

import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

import static org.springframework.boot.SpringApplication.run;

@SpringBootApplication
@EnableAspectJAutoProxy
public class IssueSpringWebfluxReactiveErrorAdviceApplication {
  public static void main(String[] args) {
    run(IssueSpringWebfluxReactiveErrorAdviceApplication.class, args);
  }
}
package name.genese.salathiel.issuespringwebfluxreactiveerroradvice;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.validation.ConstraintViolationException;
import javax.validation.Path;
import java.util.List;
import java.util.stream.Collectors;

import static java.util.stream.StreamSupport.stream;

@ControllerAdvice
public class ConstraintViolationExceptionHandler {
  @ExceptionHandler(ConstraintViolationException.class)
  public ResponseEntity<List<Violation>> handleException(ConstraintViolationException constraintViolationException) {
    final List<Violation> violations = constraintViolationException.getConstraintViolations().stream()
      .map(violation -> new Violation(
        violation.getMessageTemplate(),
        stream(violation.getPropertyPath().spliterator(), false)
          .map(Path.Node::getName)
          .collect(Collectors.toList())
      )).collect(Collectors.toList());

    return ResponseEntity.unprocessableEntity().body(violations);
  }

  @Getter
  @RequiredArgsConstructor
  static class Violation {
    private final String template;
    private final List<String> path;
  }
}

Now your tests both pass.

BTW, not being a Spring user, I got the idea from this answer.

kriegaex
  • 63,017
  • 15
  • 111
  • 202
  • 1
    This is interesting... So with async error, `WebExchangeBindException` is not triggered (as I observed during testing) but `ConstraintViolationException` is. Well done!!! – Salathiel Genese Oct 14 '21 at 08:41
  • An interesting approach using `@ExceptionHandler`. The final response body will have a list of validations. – Nikolas Charalambidis Oct 14 '21 at 10:09
  • As you can see, I put a `List` into the response body, copying the code from the OP's original approach. But actually, you could put anything there. Actually, a `@ControllerAdvice`, as the name implies, internally uses AOP-like, proxy-based interception too, but in this case seems to be the more adequate and painless approach compared to trying to intercept a controller method manually using an aspect. Actually, I think that this should be the accepted answer, which is of course completely up to the OP to decide. – kriegaex Oct 14 '21 at 11:02
  • I like the simplicity and usefulness of your answer. However, not all `ConstraintViolationException` might want to be intercepted this way and further transformation would be needed. Therefore, it is silly to think over what answer deserves to be accepted. – Nikolas Charalambidis Oct 14 '21 at 12:06
  • Thank you for calling me silly. Let Salathiel decide which answer best addresses the issue. He could still cover other types of errors with aspects or other exception handlers. Of course, your elaborate approach of defining extra error attribute and exception classes also does the job. – kriegaex Oct 14 '21 at 15:25
  • 1
    For what it is worth, @kriegaex, your approach is the most simple one. And I will not take back the bounty :) But I will add two things. First while it works on the MVCE, it doesn't on my really project, which I guess has to to with Java module and potentially, my multi maven module approach. Now even if it works great, I would still go for @Nikolas' answer b/c I usually wrap my responses `content` into more complex objects (status, pagination, and HAL/HAETOAS-like metadata). With reactive programming, I wasn't able to use @RestControllerAdvice for that. – Salathiel Genese Oct 15 '21 at 05:37
  • 1
    For the above-mentioned reasons, I wanted to ALSO accept @NikolasCharalambidis' answer with a bounty... And it seems I CANNOT award the same amount of bounty twice !? (or maybe just for the same question)... So I went 300... But I hope it doesn't say much about which answer is "better" (since everything is relative to real-life use-cases). – Salathiel Genese Oct 15 '21 at 05:41