Search code examples
javaspringspring-bootaopspring-aop

How to make @Around advice (Spring AOP) on RestController GET Method execute after constraint validation in the GET method?


I want constraint validation in the method to happen before around advice aspect execution, but I see the opposite happening. The aspect is triggered without validation.

I have the following RestController class:

package com.pkg;

@RestController
@Validated
public class RestController {
  @GetMapping("/v1/{id}")
  public Object getIDInformation(
    @PathVariable("id")
    @Pattern(regexp = "^[0-9]*$", message = "Non numeric id")
    @Size(min = 9, max = 10, message = "Invalid id")
      String id,
    HttpServletRequest httpRequest,
    SomeClass someObject
  )
  {
    return service.getIDInformation(Long.parseLong(id), someObject);
  }
}

Then I have the following around aspect advice in a different class:

@Around(
  "execution(* com.pkg.RestController.getIDInformation(..)) && " +
  "args(id,httpRequest,..)"
)
public Object aspectMethod(ProceedingJoinPoint pjp, String id, HttpServletRequest httpRequest)
  throws Throwable
{
  SomeClass someObject = changedValue;
  Object[] targetMethodArgs = pjp.getArgs();

  if (!valid(id)) {
    //throw Exception
  }
  else {
    // Make use of HttpServletRequest httpRequest (not shown here) to modify
    // SomeClass someObject argument in the target method
    for (int i = 0; i < targetMethodArgs.length; i++) {
      if (targetMethodArgs[i] instanceof SomeClass) {
        targetMethodArgs[i] = someObject;
      }
    }
  }

  return pjp.proceed(targetMethodArgs);
}

If a request is made to GET handler method, the constraint validation for id path variable has to occur first before the around advice can execute. Is there any way I can achieve this?


Solution

  • Preface

    I am not a Spring user, so I cannot tell you

    • if there is any way to influence the order of advisor application for any advised bean in a generic, non-invasive way,
    • where exactly Spring creates the list of advisors associated with an advised bean while wiring the application.

    What I did find out, however, is that once the list of advisors for an advised bean has been set, it is simply applied in the order of elements in the list. You can influence aspect precedence via @Order or implementing @Ordered, but I have no idea if that approach can somehow be applied to method validation advisors.

    Proof of concept, version 1

    Because I was curious, I created a proof-of-concept, hacky workaround. Here is my MCVE replicating your original situation:

    Service, controller and helper classes:

    package de.scrum_master.spring.q71219717;
    
    public class SomeClass {
      private final String suffix;
    
      public SomeClass(String suffix) {
        this.suffix = suffix;
      }
    
      public String getSuffix() {
        return suffix;
      }
    }
    
    package de.scrum_master.spring.q71219717;
    
    import org.springframework.stereotype.Component;
    
    @Component
    public class MyService {
      public String getIDInformation(long id, SomeClass someObject) {
        return id + "-" + someObject.getSuffix();
      }
    }
    
    package de.scrum_master.spring.q71219717;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.validation.constraints.Pattern;
    import javax.validation.constraints.Size;
    
    @RestController
    @Validated
    public class MyRestController {
      @Autowired
      MyService service;
    
      @GetMapping("/v1/{id}")
      public Object getIDInformation(
        @PathVariable("id")
        @Pattern(regexp = "^[0-9]*$", message = "Non-numeric ID ${validatedValue}")
        @Size(min = 9, max = 10, message = "ID ${validatedValue} must be {min}-{max} numbers long")
          String id,
        HttpServletRequest httpRequest,
        SomeClass someObject
      )
      {
        return service.getIDInformation(Long.parseLong(id), someObject);
      }
    }
    

    Aspect:

    My dummy for the missing valid(String id) method in your example simply returns true if the ID contains a '0' character.

    package de.scrum_master.spring.q71219717;
    
    import org.aspectj.lang.ProceedingJoinPoint;
    import org.aspectj.lang.annotation.Around;
    import org.aspectj.lang.annotation.Aspect;
    import org.springframework.core.Ordered;
    import org.springframework.core.annotation.Order;
    import org.springframework.stereotype.Component;
    
    import javax.servlet.http.HttpServletRequest;
    
    @Aspect
    @Component
    public class MyRestControllerAspect {
      @Around(
        "execution(* de.scrum_master.spring.q71219717.MyRestController.getIDInformation(..)) && " +
        "args(id, httpRequest, ..)"
      )
      public Object aspectMethod(ProceedingJoinPoint pjp, String id, HttpServletRequest httpRequest)
        throws Throwable
      {
        System.out.println(pjp + " -> " + id);
        SomeClass changedValue = new SomeClass("ASPECT");
        SomeClass someObject = changedValue;
        Object[] targetMethodArgs = pjp.getArgs();
    
        if (!valid(id)) {
          throw new IllegalArgumentException("invalid ID " + id);
        }
        else {
          // Make use of HttpServletRequest httpRequest (not shown here) to modify
          // SomeClass someObject argument in the target method
          for (int i = 0; i < targetMethodArgs.length; i++) {
            if (targetMethodArgs[i] instanceof SomeClass) {
              targetMethodArgs[i] = someObject;
            }
          }
        }
    
        return pjp.proceed(targetMethodArgs);
      }
    
      private boolean valid(String id) {
        return id.contains("0");
      }
    }
    

    Driver application:

    package de.scrum_master.spring.q71219717;
    
    import org.springframework.aop.framework.Advised;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.context.ConfigurableApplicationContext;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.validation.beanvalidation.MethodValidationInterceptor;
    
    import java.util.Arrays;
    
    @SpringBootApplication
    @Configuration
    public class DemoApplication {
      public static void main(String[] args) throws Throwable {
        try (ConfigurableApplicationContext appContext = SpringApplication.run(DemoApplication.class, args)) {
          doStuff(appContext);
        }
      }
    
      private static void doStuff(ConfigurableApplicationContext appContext) {
        MyRestController restController = appContext.getBean(MyRestController.class);
        //reorderAdvisorsMethodValidationFirst(restController);
    
        printIDInfo(restController, "1234567890", "Valid @Pattern, valid @Size, valid for aspect (contains '0')");
        printIDInfo(restController, "123456789", "Valid @Pattern, valid @Size, invalid for aspect (does not contain '0')");
        printIDInfo(restController, "123", "Valid @Pattern, invalid @Size, invalid for aspect (does not contain '0')");
        printIDInfo(restController, "250", "Valid @Pattern, invalid @Size, valid for aspect (contains '0')");
        printIDInfo(restController, "x", "Invalid @Pattern, invalid @Size, invalid for aspect (does not contain '0')");
        printIDInfo(restController, "A0", "Invalid @Pattern, invalid @Size, valid for aspect (contains '0')");
      }
    
      private static void printIDInfo(MyRestController restController, String id, String infoMessage) {
        try {
          System.out.println(infoMessage);
          System.out.println("ID info: " + restController.getIDInformation(id, null, new SomeClass("ABC")));
        }
        catch (Exception e) {
          System.out.println(e);
        }
        System.out.println("----------");
      }
    
      public static void reorderAdvisorsMethodValidationFirst(Object targetBean) {
        if (!(targetBean instanceof Advised))
          return;
        Advised advisedBean = (Advised) targetBean;
        Arrays.stream(advisedBean.getAdvisors())
          .filter(advisor -> !(advisor.getAdvice() instanceof MethodValidationInterceptor))
          .forEach(advisor -> {
            advisedBean.removeAdvisor(advisor);
            advisedBean.addAdvisor(advisor);
          });
      }
    }
    

    Please note the one helper method call I commented out. When running the application like this, the console log says:

    Valid @Pattern, valid @Size, valid for aspect (contains '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> 1234567890
    ID info: 1234567890-ASPECT
    ----------
    Valid @Pattern, valid @Size, invalid for aspect (does not contain '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> 123456789
    java.lang.IllegalArgumentException: invalid ID 123456789
    ----------
    Valid @Pattern, invalid @Size, invalid for aspect (does not contain '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> 123
    java.lang.IllegalArgumentException: invalid ID 123
    ----------
    Valid @Pattern, invalid @Size, valid for aspect (contains '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> 250
    javax.validation.ConstraintViolationException: getIDInformation.id: ID 250 must be 9-10 numbers long
    ----------
    Invalid @Pattern, invalid @Size, invalid for aspect (does not contain '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> x
    java.lang.IllegalArgumentException: invalid ID x
    ----------
    Invalid @Pattern, invalid @Size, valid for aspect (contains '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> A0
    javax.validation.ConstraintViolationException: getIDInformation.id: Non-numeric ID A0, getIDInformation.id: ID A0 must be 9-10 numbers long
    

    As you can see from the logged execution joinpoints and ensuing IllegalArgumentExceptions, the aspect kicks in before method argument validation, just as you described.

    Now, let us uncomment (i.e. activate)

    reorderAdvisorsMethodValidationFirst(restController);
    

    What the method does, is

    • to check if the target object is an advised Spring bean,
    • if so, reorder the list of advisors by simply
      • temporarily removing each advisor which does not have a MethodValidationInterceptor advice
      • and then immediately appending it to the end of the list again.

    The effect is that now the method validation interceptors take precedence over other advice types for the target bean. The console log consequently changes to:

    Valid @Pattern, valid @Size, valid for aspect (contains '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> 1234567890
    ID info: 1234567890-ASPECT
    ----------
    Valid @Pattern, valid @Size, invalid for aspect (does not contain '0')
    execution(Object de.scrum_master.spring.q71219717.MyRestController.getIDInformation(String,HttpServletRequest,SomeClass)) -> 123456789
    java.lang.IllegalArgumentException: invalid ID 123456789
    ----------
    Valid @Pattern, invalid @Size, invalid for aspect (does not contain '0')
    javax.validation.ConstraintViolationException: getIDInformation.id: ID 123 must be 9-10 numbers long
    ----------
    Valid @Pattern, invalid @Size, valid for aspect (contains '0')
    javax.validation.ConstraintViolationException: getIDInformation.id: ID 250 must be 9-10 numbers long
    ----------
    Invalid @Pattern, invalid @Size, invalid for aspect (does not contain '0')
    javax.validation.ConstraintViolationException: getIDInformation.id: Non-numeric ID x, getIDInformation.id: ID x must be 9-10 numbers long
    ----------
    Invalid @Pattern, invalid @Size, valid for aspect (contains '0')
    javax.validation.ConstraintViolationException: getIDInformation.id: ID A0 must be 9-10 numbers long, getIDInformation.id: Non-numeric ID A0
    

    See? Now the aspect only kicks in in the first two cases, after method parameter validation has been passed successfully.

    Some Spring AOP internals:

    • Method CglibAopProxy.DynamicAdvisedInterceptor.intercept calls this.advised.getInterceptorsAndDynamicInterceptionAdvice(method, targetClass).
    • this.advised is of type AdvisedSupport, which is a public type, but unfortunately CglibAopProxy.DynamicAdvisedInterceptor is a private static inner class of CglibAopProxy and only used internally.
    • So there is no good way to get hold of the AdvisedSupport instance and e.g. call its setAdvisorChainFactory method. If that was possible, you could just inject a factory returning a list of advisors in an order different from the default one (a DefaultAdvisorChainFactory).

    Maybe some Spring pros here know of a canonical way to influence the internal advisor chain order by configuring Spring in order to wire the application the way you want it to, but I really do not know. I am just an AOP (mostly AspectJ) expert who sometimes looks into more specific Spring AOP questions.

    Proof of concept, version 2

    OK, I refactored the original solution into something more generic, using a BeanPostProcessor. The post-processor will

    • be called automatically for each Spring instantiated bean,
    • check if the created bean is Advised (i.e. is a Spring proxy with advisors),
    • filter for advised bean classes with @Validated annotations,
    • re-order the advisors like in my original solution.

    The advantage is that there is no more need to manually fetch bean instances from the application context and call reorderAdvisorsMethodValidationFirst(..) on them one by one. Spring takes care of post-processing each bean, which is how it is supposed to be. Sorry for coming up with this solution only in iteration #2, but like I said, I am a Spring noob.

    Updated, simplified driver application:

    package de.scrum_master.spring.q71219717;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.context.ConfigurableApplicationContext;
    import org.springframework.context.annotation.Configuration;
    
    @SpringBootApplication
    @Configuration
    public class DemoApplication {
      private static MyRestController restController;
    
      public static void main(String[] args) throws Throwable {
        try (ConfigurableApplicationContext appContext = SpringApplication.run(DemoApplication.class, args)) {
          doStuff(appContext);
        }
      }
    
      private static void doStuff(ConfigurableApplicationContext appContext) {
        restController = appContext.getBean(MyRestController.class);
        printIDInfo("1234567890", "Valid @Pattern, valid @Size, valid for aspect (contains '0')");
        printIDInfo("123456789", "Valid @Pattern, valid @Size, invalid for aspect (does not contain '0')");
        printIDInfo("123", "Valid @Pattern, invalid @Size, invalid for aspect (does not contain '0')");
        printIDInfo("250", "Valid @Pattern, invalid @Size, valid for aspect (contains '0')");
        printIDInfo("x", "Invalid @Pattern, invalid @Size, invalid for aspect (does not contain '0')");
        printIDInfo("A0", "Invalid @Pattern, invalid @Size, valid for aspect (contains '0')");
      }
    
      private static void printIDInfo(String id, String infoMessage) {
        try {
          System.out.println(infoMessage);
          System.out.println("ID info: " + restController.getIDInformation(id, null, new SomeClass("ABC")));
        }
        catch (Exception e) {
          System.out.println(e);
        }
        System.out.println("----------");
      }
    
    }
    

    Bean post-processor:

    package de.scrum_master.spring.q71219717;
    
    import org.springframework.aop.framework.Advised;
    import org.springframework.beans.BeansException;
    import org.springframework.beans.factory.config.BeanPostProcessor;
    import org.springframework.stereotype.Component;
    import org.springframework.validation.annotation.Validated;
    import org.springframework.validation.beanvalidation.MethodValidationInterceptor;
    
    import java.util.Arrays;
    
    @Component
    public class MethodValidationFirstBeanPostProcessor implements BeanPostProcessor {
      @Override
      public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof Advised) {
          Advised advisedBean = (Advised) bean;
          if (advisedBean.getTargetSource().getTargetClass().isAnnotationPresent(Validated.class)) {
            System.out.println("Reordering advisors to \"method validation first\" for bean " + beanName);
            reorderAdvisorsMethodValidationFirst(advisedBean);
          }
        }
        return BeanPostProcessor.super.postProcessAfterInitialization(bean, beanName);
      }
    
      public void reorderAdvisorsMethodValidationFirst(Advised advisedBean) {
        Arrays.stream(advisedBean.getAdvisors())
          .filter(advisor -> !(advisor.getAdvice() instanceof MethodValidationInterceptor))
          .forEach(advisor -> {
            advisedBean.removeAdvisor(advisor);
            advisedBean.addAdvisor(advisor);
          });
      }
    }
    

    The console logs with and without the active post-processor remain the same as in the original solution.