5

I am trying to create a UniqueName annotation as a cutomize bean validation annotation for a create project api:

@PostMapping("/users/{userId}/projects")
public ResponseEntity createNewProject(@PathVariable("userId") String userId,
                                       @RequestBody @Valid ProjectParam projectParam) {
    User projectOwner = userRepository.ofId(userId).orElseThrow(ResourceNotFoundException::new);

    Project project = new Project(
        IdGenerator.nextId(),
        userId,
        projectParam.getName(),
        projectParam.getDescription()
    );
    ...
  }

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
class ProjectParam {

  @NotBlank
  @NameConstraint
  private String name;
  private String description;
}

@Constraint(validatedBy = UniqueProjectNameValidator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD })
public @interface UniqueName {

    public String message() default "already existed";

    public Class<?>[] groups() default {};

    public Class<? extends Payload>[] payload() default{};
}

public class UniqueProjectNameValidator implements ConstraintValidator<UniqueName, String> {
   @Autowired
   private ProjectQueryMapper mapper;

   public void initialize(UniqueName constraint) {
   }

   public boolean isValid(String value, ConstraintValidatorContext context) {
      // how can I get the userId info??
      return mapper.findByName(userId, value) == null;
   }
}

The problem is that name field just need uniqueness for user level. So I need to get the {userId} from the URL field for validation. But how can I add this into the UniqueProjectNameValidator? Or is there some better way to handle this validation? This is just a small part of a large object, the real object has many other complex validations in the request handler which make the code quite dirty.

aisensiy
  • 1,460
  • 3
  • 26
  • 42
  • Not sure, but you can use Spring's Expression Language (SpEL). Create service with `@Autowired Request request`. Now, add this expresion into `@interface` property: `#{requestService.getField('userId')}`. If you need static access, replace with `T(package.class)`: `#{T(org.organization...StaticRequestAccess).getField('userId')}` – Valijon Jul 08 '19 at 10:36
  • The `ConstrainValidator` ought to be static, doing this is very ugly & messy, and I don't think is possible, at least in a readable proper way. You should just have a validation after the controller accepts the request since this validation is beyond basic, but a logical validation that requires a db read. – buræquete Jul 09 '19 at 04:45
  • It's not a good practice use validation-api that way, you should handle that constraint in the domain layer – eHayik Jul 12 '19 at 22:44
  • @EduardoEljaiek can you show me an example of such a validation in domain layer? Maybe just a link of an example you found in the Internet – aisensiy Jul 14 '19 at 09:58
  • I did not try this hence not adding this as an answer but the last example in the blog which says @RequestMapping(value = "/path/{id}", method = RequestMethod.GET) public SampleDomainObject get(@ModelAttribute("id") @Validated(IdValidator.class) String id) { return ... // obtain and return object } should work in your case. For more, https://copyrightdev.tumblr.com/post/92560458673/tips-tricks-having-fun-with-spring-validators – Sunand Padmanabhan Jul 15 '19 at 11:16
  • @aisensiy I posted a fully answer with working code, I hope it helps you – eHayik Jul 15 '19 at 14:21
  • Looks like the best choice for this situation would be [`@BeanParam`](https://docs.oracle.com/javaee/7/api/javax/ws/rs/BeanParam.html) as defined by JAX-RS 2.0. – yegodm Jul 15 '19 at 18:31

4 Answers4

3

As @Abhijeet mentioned, dynamically passing the userId property to the constraint validator is impossible. As to how to handle this validation case better, there's the clean solution and the dirty solution.

The clean solution is to extract all the business logic to a service method, and validate the ProjectParam at the service level. This way, you can add a userId property to ProjectParam, and map it from the @PathVariable onto the @RequestBody before calling the service. You then adjust UniqueProjectNameValidator to validate ProjectParams rather than Strings.

The dirty solution is to use Hibernate Validator's cross-parameter constraints (see also this link for an example). You essentially treat both of your controller method parameters as the input for your custom validator.

crizzis
  • 9,978
  • 2
  • 28
  • 47
  • Yes, I think some kind of validator is good. I am thinking about this. Can you show me some good example to implement this pattern? I do have some ideas but not sophisticated enough. And the source is https://martinfowler.com/articles/replaceThrowWithNotification.html. – aisensiy Jul 14 '19 at 10:02
  • I'm sorry, but I'm not quite sure which part of my answer you're referring to, could you please clarify? – crizzis Jul 15 '19 at 12:18
0

If I'm not wrong, what you are asking is, how can you pass your userId to your custom annotation i.e. @UniqueName so that you can access the userId to validate projectName field against existing projectNames for passed userId.

It means you are asking about is, How to pass variable/parameter dynamically to annotation which is not possible. You have to use some other approach like Interceptors or Do the validation manually.

You can refer to the following answers as well:

How to pass value to custom annotation in java?

Passing dynamic parameters to an annotation?

Abhijeet
  • 4,069
  • 1
  • 22
  • 38
0

@Mikhail Dyakonov in this article proposed a rule of thumb to choose the best validation method using java:

  • JPA validation has limited functionality, but it is a great choice for the simplest constraints on entity classes if such constraints can be mapped to DDL.

  • Bean Validation is a flexible, concise, declarative, reusable, and readable way to cover most of the checks that you could have in your domain model classes. This is the best choice, in most cases, once you don't need to run validations inside a transaction.

  • Validation by Contract is a Bean validation for method calls. You can use it when you need to check input and output parameters of a method, for example, in a REST call handler.

  • Entity listeners although they are not as declarative as the Bean validation annotations, they are a great place to check big object's graphs or make a check that needs to be done inside a database transaction. For example, when you need to read some data from the DB to make a decision, Hibernate has analogs of such listeners.

  • Transaction listeners are a dangerous yet ultimate weapon that works inside the transactional context. Use it when you need to decide at runtime what objects have to be validated or when you need to check different types of your entities against the same validation algorithm.

I think Entity listeners match your unique constraint validation issue, because within the Entity Listener you'll be able to access your JPA Entity before persisting/updating it and executing your check query easier.

However as @crizzis pointed me, there is a significant restriction with this approach. As stated in JPA 2 specification (JSR 317):

In general, the lifecycle method of a portable application should not invoke EntityManager or Query operations, access other entity instances, or modify relationships within the same persistence context. A lifecycle callback method may modify the non-relationship state of the entity on which it is invoked.

Whether you try this approach, first you'll need an ApplicationContextAware implementation for getting current EntityManager instance. It's an old Spring Framework trick, maybe You're already using it.

import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

@Component
public final class BeanUtil implements ApplicationContextAware {

   private static ApplicationContext CONTEXT;

        @Override
        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
            CONTEXT = applicationContext;
        }

        public static <T> T getBean(Class<T> beanClass) {
            return CONTEXT.getBean(beanClass);
        }    
    }

This is my Entity Listener

@Slf4j
public class GatewaUniqueIpv4sListener { 

    @PrePersist
    void onPrePersist(Gateway gateway) {       
       try {
           EntityManager entityManager = BeanUtil.getBean(EntityManager.class);
           Gateway entity = entityManager
                .createQuery("SELECT g FROM Gateway g WHERE g.ipv4 = :ipv4", Gateway.class)
                .setParameter("ipv4", gateway.getIpv4())
                .getSingleResult();

           // Already exists a Gateway with the same Ipv4 in the Database or the PersistenceContext
           throw new IllegalArgumentException("Can't be to gateways with the same Ip address " + gateway.getIpv4());
       } catch (NoResultException ex) {
           log.debug(ex.getMessage(), ex);
       }
    }
}

Finally, I added this annotation to my Entity Class @EntityListeners(GatewaUniqueIpv4sListener.class)

You can find the complete working code here gateways-java

A clean and simple approach could be check validations in which you need to access the database within your transactional services. Even you could use the Specification, Strategy, and Chain of Responsibility patterns in order to implement a better solution.

eHayik
  • 2,981
  • 1
  • 21
  • 33
  • JPA Spec: 'In general, the lifecycle method of a portable application should not invoke EntityManager or query operations, access other entity instances, or modify relationships within the same persistence context' – crizzis Jul 17 '19 at 22:11
  • Thanks @crizzis it is good to know this. Could you suggest another approach ? – eHayik Jul 18 '19 at 07:21
  • @crizzis Ups sorry, I'll see it – eHayik Jul 18 '19 at 08:51
  • @crizzis Thanks for your correction I included it in my answer – eHayik Jul 18 '19 at 09:23
0

I believe you can do what you're asking, but you might need to generalize your approach just a bit.

As others have mentioned, you can not pass two attributes into a validator, but, if you changed your validator to be class level validator instead of a field level validator, it can work.

Here is a validator we created that makes sure that two fields are the same value when submitted. Think of the password and confirm password use case that you often see websites, or email and confirm email use case.

Of course, in your particular case, you'll need to pass in the user's id and the name of the project that they are trying to create.

Annotation:

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
 * Taken from:
 * http://stackoverflow.com/questions/1972933/cross-field-validation-with-hibernate-validator-jsr-303
 * <p/>
 * Validation annotation to validate that 2 fields have the same value.
 * An array of fields and their matching confirmation fields can be supplied.
 * <p/>
 * Example, compare 1 pair of fields:
 *
 * @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match")
 * <p/>
 * Example, compare more than 1 pair of fields:
 * @FieldMatch.List({
 * @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match"),
 * @FieldMatch(first = "email", second = "confirmEmail", message = "The email fields must match")})
 */
@Target({TYPE, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = FieldMatchValidator.class)
@Documented
public @interface FieldMatch {
    String message() default "{constraints.fieldmatch}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    /**
     * @return The first field
     */
    String first();

    /**
     * @return The second field
     */
    String second();

    /**
     * Defines several <code>@FieldMatch</code> annotations on the same element
     *
     * @see FieldMatch
     */
    @Target({TYPE, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        FieldMatch[] value();
    }
}

The Validator:

import org.apache.commons.beanutils.BeanUtils;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * Taken from:
 * http://stackoverflow.com/questions/1972933/cross-field-validation-with-hibernate-validator-jsr-303
 */
public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> {
    private String firstFieldName;
    private String secondFieldName;

    @Override
    public void initialize(FieldMatch constraintAnnotation) {

        firstFieldName = constraintAnnotation.first();
        secondFieldName = constraintAnnotation.second();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {

        try {
            Object firstObj = BeanUtils.getProperty(value, firstFieldName);
            Object secondObj = BeanUtils.getProperty(value, secondFieldName);

            return firstObj == null && secondObj == null || firstObj != null && firstObj.equals(secondObj);
        } catch (Exception ignore) {
            // ignore
        }
        return true;
    }
}

Then here our command object:

import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;

import javax.validation.GroupSequence;

@GroupSequence({Required.class, Type.class, Data.class, Persistence.class, ChangePasswordCommand.class})
@FieldMatch(groups = Data.class, first = "password", second = "confirmNewPassword", message = "The New Password and Confirm New Password fields must match.")
public class ChangePasswordCommand {

    @NotBlank(groups = Required.class, message = "New Password is required.")
    @Length(groups = Data.class, min = 6, message = "New Password must be at least 6 characters in length.")
    private String password;

    @NotBlank(groups = Required.class, message = "Confirm New Password is required.")
    private String confirmNewPassword;

    ...
}
hooknc
  • 4,854
  • 5
  • 31
  • 60
  • Here is the stack overflow question referenced in the annotation and validator classes: https://stackoverflow.com/q/1972933/42962 – hooknc Jul 15 '19 at 18:39