问题
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.
回答1:
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 ProjectParam
s rather than String
s.
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.
回答2:
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?
回答3:
@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.
回答4:
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;
...
}
来源:https://stackoverflow.com/questions/56916520/bean-validation-with-extra-information