Skip to content

Commit

Permalink
Add generic custom constraint using SpEL expression
Browse files Browse the repository at this point in the history
  • Loading branch information
Mihael Cacko committed Oct 2, 2023
1 parent 56b87e5 commit 4544d65
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 2 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ subprojects { Project subproject ->
sourceCompatibility = "17"

java {
["envers", "reactor", "web", "webMvc", "webFlux", "hibernate"].forEach {
["envers", "reactor", "web", "webMvc", "webFlux", "hibernate", "spel"].forEach {
registerFeature(it) {
usingSourceSet(sourceSets.main)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2020-2023 CROZ d.o.o, the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package net.croz.nrich.validation.api.constraint;

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

import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.CONSTRUCTOR;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;


/**
* The annotated element is validated against a provided SpEL expression
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Repeatable(SpelExpression.List.class)
@Documented
@Constraint(validatedBy = {})
public @interface SpelExpression {

String message() default "{nrich.constraint.spelExpression.invalid.message}";

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

/**
* SpEL expression that is evaluated
*
* @return SpEL expression
*/
String value();

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

/**
* Defines several {@link SpelExpression} annotations on the same element.
*
* @see SpelExpression
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@interface List {

SpelExpression[] value();
}
}
1 change: 1 addition & 0 deletions nrich-validation-spring-boot-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ and default ones disabled then through `nrich.validation.register-messages` prop
| `@MaxSizeInBytes` | Validates that the annotated element size in bytes must be less than specified maximum |
| `@NotNullWhen` | Validates that the annotated element must not be null when condition is satisfied |
| `@NullWhen` | Validates that the annotated element must be null when condition is satisfied |
| `@SpellExpression` | Validates the annotated element with the defined SpEL expression |
| `@ValidFile` | Validates that the annotated element matches specified content type list, allowed extension list and/or allowed regex |
| `@ValidFileResolvable` | Validates that the annotated element matches specified content type list, allowed extension list and/or allowed regex |
| `@ValidOib` | Validates that the annotated element is valid OIB (Personal Identification number) |
Expand Down
28 changes: 28 additions & 0 deletions nrich-validation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,31 @@ public class SearchPersonRequest {
```

Above request will require that either both firstName and lastName are not null or that id is not null.

#### SpelExpression

Validates property against a provided SpEL Expression

```java

@Setter
@Getter
public class ExampleRequest {

@SpelExpression("@spelValidationTestService.validateUuid(#this)")
private String uuid;

}

@Setter
@Getter
@SpelExpression("@spelValidationTestService.validateUuid(uuid)")
public class ExampleTypeRequest {

private String uuid;

}

```

In an example above the property uuid is required to be a valid UUID format. The format check is achieved by calling `validateUuid` method of `spelValidationTestService` bean.
3 changes: 3 additions & 0 deletions nrich-validation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ dependencies {

runtimeOnly "org.apache.tomcat.embed:tomcat-embed-el"

spelImplementation "org.springframework:spring-expression"
spelImplementation "org.springframework:spring-context"

webImplementation "org.springframework:spring-web"

testAnnotationProcessor "org.projectlombok:lombok"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package net.croz.nrich.validation.aot;

import net.croz.nrich.validation.constraint.mapping.DefaultConstraintMappingContributor;
import net.croz.nrich.validation.constraint.validator.SpelExpressionValidator;
import net.croz.nrich.validation.constraint.validator.InListValidator;
import net.croz.nrich.validation.constraint.validator.MaxSizeInBytesValidator;
import net.croz.nrich.validation.constraint.validator.NotNullWhenValidator;
Expand All @@ -43,7 +44,7 @@ public class ValidationRuntimeHintsRegistrar implements RuntimeHintsRegistrar {

public static final List<TypeReference> TYPE_REFERENCE_LIST = Collections.unmodifiableList(TypeReference.listOf(
DefaultConstraintMappingContributor.class, ValidOibValidator.class, ValidSearchPropertiesValidator.class, ValidRangeValidator.class, MaxSizeInBytesValidator.class,
NotNullWhenValidator.class, NullWhenValidator.class, ValidFileValidator.class, ValidFileResolvableValidator.class, InListValidator.class
NotNullWhenValidator.class, NullWhenValidator.class, ValidFileValidator.class, ValidFileResolvableValidator.class, InListValidator.class, SpelExpressionValidator.class
));

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package net.croz.nrich.validation.constraint.mapping;

import net.croz.nrich.validation.api.constraint.SpelExpression;
import net.croz.nrich.validation.api.constraint.InList;
import net.croz.nrich.validation.api.constraint.MaxSizeInBytes;
import net.croz.nrich.validation.api.constraint.NotNullWhen;
Expand All @@ -26,6 +27,7 @@
import net.croz.nrich.validation.api.constraint.ValidOib;
import net.croz.nrich.validation.api.constraint.ValidRange;
import net.croz.nrich.validation.api.constraint.ValidSearchProperties;
import net.croz.nrich.validation.constraint.validator.SpelExpressionValidator;
import net.croz.nrich.validation.constraint.validator.InListValidator;
import net.croz.nrich.validation.constraint.validator.MaxSizeInBytesValidator;
import net.croz.nrich.validation.constraint.validator.NotNullWhenValidator;
Expand All @@ -50,5 +52,6 @@ public void createConstraintMappings(ConstraintMappingBuilder builder) {
builder.addConstraintMapping().constraintDefinition(ValidFile.class).validatedBy(ValidFileValidator.class);
builder.addConstraintMapping().constraintDefinition(ValidFileResolvable.class).validatedBy(ValidFileResolvableValidator.class);
builder.addConstraintMapping().constraintDefinition(InList.class).validatedBy(InListValidator.class);
builder.addConstraintMapping().constraintDefinition(SpelExpression.class).validatedBy(SpelExpressionValidator.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2020-2023 CROZ d.o.o, the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package net.croz.nrich.validation.constraint.validator;

import net.croz.nrich.validation.api.constraint.SpelExpression;
import org.springframework.context.ApplicationContext;
import org.springframework.context.expression.BeanFactoryResolver;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class SpelExpressionValidator implements ConstraintValidator<SpelExpression, Object> {

private String spelExpression;

private ExpressionParser expressionParser;

private StandardEvaluationContext evaluationContext;

private ApplicationContext applicationContext;

public SpelExpressionValidator(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}

@Override
public void initialize(SpelExpression constraintAnnotation) {
spelExpression = constraintAnnotation.value();
expressionParser = new SpelExpressionParser();
evaluationContext = new StandardEvaluationContext();
evaluationContext.setBeanResolver(new BeanFactoryResolver(applicationContext));
}

@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
// will be validated by other constraints
if (value == null) {
return true;
}

Expression expression = expressionParser.parseExpression(spelExpression);

return expression.getValue(evaluationContext, value, Boolean.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package net.croz.nrich.validation;

import net.croz.nrich.validation.constraint.stub.NullWhenTestService;
import net.croz.nrich.validation.constraint.stub.SpelValidationTestService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.Validator;
Expand All @@ -35,4 +36,9 @@ public Validator validator() {
public NullWhenTestService nullWhenTestService() {
return new NullWhenTestService();
}

@Bean
public SpelValidationTestService spelValidationTestService() {
return new SpelValidationTestService();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2020-2023 CROZ d.o.o, the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package net.croz.nrich.validation.constraint.stub;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import net.croz.nrich.validation.api.constraint.SpelExpression;

@RequiredArgsConstructor
@Getter
public class SpelExpressionTestRequest {

@SpelExpression("@spelValidationTestService.validateUuid(#this)")
private final String uuid;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package net.croz.nrich.validation.constraint.stub;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class SpelValidationTestService {

private static final Pattern PATTERN = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");

public boolean validateUuid(String uuid) {
Matcher matcher = PATTERN.matcher(uuid);
return matcher.find();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package net.croz.nrich.validation.constraint.validator;

import net.croz.nrich.validation.ValidationTestConfiguration;
import net.croz.nrich.validation.constraint.stub.SpelExpressionTestRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

@SpringJUnitConfig(ValidationTestConfiguration.class)
public class SpelExpressionValidatorTest {

@Autowired
private Validator validator;

@Test
void shouldNotReportErrorForNullValue() {
// given
SpelExpressionTestRequest request = new SpelExpressionTestRequest(null);

// when
Set<ConstraintViolation<SpelExpressionTestRequest>> constraintViolationList = validator.validate(request);

// then
assertThat(constraintViolationList).isEmpty();
}

@Test
void shouldNotReportErrorWhenValueIsValid() {
// given
SpelExpressionTestRequest request = new SpelExpressionTestRequest("4adf9bf9-2656-468b-880a-706ff704e6b4");

// when
Set<ConstraintViolation<SpelExpressionTestRequest>> constraintViolationList = validator.validate(request);

// then
assertThat(constraintViolationList).isEmpty();
}

@Test
void shouldReportErrorWhenValueIsNotValid() {
// given
SpelExpressionTestRequest request = new SpelExpressionTestRequest("4adf9bf9-2656-xxxx-xxxx-706ff704e6b4");

// when
Set<ConstraintViolation<SpelExpressionTestRequest>> constraintViolationList = validator.validate(request);

// then
assertThat(constraintViolationList).isNotEmpty();
}
}

0 comments on commit 4544d65

Please sign in to comment.