diff --git a/docs/asciidoc/modules/avaje-inject.adoc b/docs/asciidoc/modules/avaje-inject.adoc index eeb9913742..753e839515 100644 --- a/docs/asciidoc/modules/avaje-inject.adoc +++ b/docs/asciidoc/modules/avaje-inject.adoc @@ -21,7 +21,7 @@ io.avaje avaje-inject-generator - 10.0 + 10.3 diff --git a/docs/asciidoc/modules/avaje-validator.adoc b/docs/asciidoc/modules/avaje-validator.adoc new file mode 100644 index 0000000000..542d646eeb --- /dev/null +++ b/docs/asciidoc/modules/avaje-validator.adoc @@ -0,0 +1,311 @@ +== Avaje Validator + +Bean validation via https://avaje.io/validator/[Avaje Validator]. + +=== Usage + +1) Add the dependency: + +[dependency, artifactId="jooby-avaje-validator"] +. + +2) Configure annotation processor + +.Maven +[source, xml, role = "primary"] +---- + + + + org.apache.maven.plugins + maven-compiler-plugin + ... + + + + io.avaje + avaje-validator-generator + 2.1 + + + + + + +---- + +.Gradle +[source, kotlin, role = "secondary"] +---- +plugins { + id "org.jetbrains.kotlin.kapt" version "1.9.10" +} + +dependencies { + kapt 'io.avaje:avaje-validator-generator:2.1' +} +---- + +3) Install + +.Java +[source, java, role="primary"] +---- +import io.jooby.avaje.validator.AvajeValidatorModule; + +{ + install(new AvajeValidatorModule()); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.avaje.validator.AvajeValidatorModule + +{ + install(new AvajeValidatorModule()) +} +---- + +4) Usage in MVC routes + +.Java +[source,java,role="primary"] +---- +import io.jooby.annotation.*; +import jakarta.validation.Valid; + +@Path("/mvc") +public class Controller { + + @POST("/validate-body") + public void validateBody(@Valid Bean bean) { // <1> + ... + } + + @POST("/validate-query") + public void validateQuery(@Valid @QueryParam Bean bean) { // <2> + ... + } + + @POST("/validate-list") + public void validateList(@Valid List beans) { // <3> + ... + } + + @POST("/validate-map") + public void validateMap(@Valid Map beans) { // <4> + ... + } +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.annotation.*; +import jakarta.validation.Valid + +@Path("/mvc") +class Controller { + + @POST("/validate-body") + fun validateBody(@Valid bean: Bean) : Unit { // <1> + ... + } + + @POST("/validate-query") + fun validateQuery(@Valid @QueryParam bean: Bean) : Unit { // <2> + ... + } + + @POST("/validate-list") + fun validateList(@Valid beans: List) : Unit { // <3> + ... + } + + @POST("/validate-map") + fun validateMap(@Valid beans: Map) : Unit { // <4> + ... + } +} +---- + +<1> Validate a bean decoded from the request body +<2> Validate a bean parsed from query parameters. This works the same for `@FormParam` or `@BindParam` +<3> Validate a list of beans. This also applies to arrays `@Valid Bean[] beans` +<4> Validate a map of beans + +4) Usage in in script/lambda routes + +Jooby doesn't provide fully native bean validation in script/lambda at the moment, +but you can use a helper that we utilize under the hood in MVC routes: + +.Java +[source, java, role="primary"] +---- +import io.jooby.validation.BeanValidator; + +{ + post("/validate", ctx -> { + Bean bean = BeanValidator.validate(ctx, ctx.body(Bean.class)); + ... + }); +} +---- + +.Kotlin +[source, kt, role="secondary"] +---- +import io.jooby.validation.BeanValidator + +{ + post("/validate") { + val bean = BeanValidator.validate(ctx, ctx.body(Bean.class)) + ... + } +} +---- + +`BeanValidator.validate()` behaves identically to validation in MVC routes. +It also supports validating list, array, and map of beans + +=== Constraint Violations Rendering + +`AvajeValidatorModule` provides default built-in error handler that +catches `ConstraintViolationException` and transforms it into the following response: + +.JSON: +---- +{ + "title": "Validation failed", + "status": 422, + "errors": [ + { + "field": "firstName", + "messages": [ + "must not be empty", + "must not be null" + ], + "type": "FIELD" + }, + { + "field": null, + "messages": [ + "passwords are not the same" + ], + "type": "GLOBAL" + } + ] +} +---- + +It is possible to override the `title` and `status` code of the response above: + +[source, java] +---- + +{ + install(new AvajeJsonbModule()); + install(new AvajeValidatorModule() + .statusCode(StatusCode.BAD_REQUEST) + .validationTitle("Incorrect input data") + ); +} +---- + +If the default error handler doesn't fully meet your needs, you can always disable it and provide your own: + +[source, java] +---- + +{ + install(new AvajeJsonbModule()); + install(new AvajeValidatorModule().disableViolationHandler()); + + error(ConstraintViolationException.class, new MyConstraintViolationHandler()); +} +---- + +=== Manual Validation + +The module exposes `Validator` as a service, allowing you to run validation manually at any time. + +==== Script/lambda: + +[source, java] +---- +import io.avaje.validation.Validator; + +{ + post("/validate", ctx -> { + Validator validator = require(Validator.class); + validator.validate(ctx.body(Bean.class)); + ... + }); +} +---- + +==== MVC routes with dependency injection: + +1) Install DI framework at first. + +[source, java] +---- +import io.jooby.avaje.validator.AvajeValidatorModule; + +{ + install(AvajeInjectModule.of()); // <1> + install(new AvajeValidatorModule()); +} +---- + +<1> `Avaje` is just an example, you can achieve the same with `Dagger` or `Guice` + +2) Inject `Validator` in controller, service etc. + +[source, java] +---- +import io.avaje.validation.Validator; +import jakarta.inject.Inject; + +@Path("/mvc") +public class Controller { + + private final Validator validator; + + @Inject + public Controller(Validator validator) { + this.validator = validator; + } + + @POST("/validate") + public void validate(Bean bean) { + Set> violations = validator.validate(bean); + ... + } +} +---- + +=== Configuration +Any property defined at `validation` will be added automatically: + +.application.conf +[source, properties] +---- +validation.fail_fast = true +---- + +Or programmatically: + +[source, java] +---- +import io.jooby.avaje.validator.AvajeValidatorModule; + +{ + install(new AvajeValidatorModule().doWith(cfg -> { + cfg.failFast(true); + })); +} +---- \ No newline at end of file diff --git a/docs/asciidoc/modules/modules.adoc b/docs/asciidoc/modules/modules.adoc index 98537fc089..5a957840bf 100644 --- a/docs/asciidoc/modules/modules.adoc +++ b/docs/asciidoc/modules/modules.adoc @@ -27,6 +27,7 @@ Available modules are listed next. * link:/modules/redis[Redis]: Redis module. === Validation + * link:/modules/avaje-validator[Avaje Validator]: Avaje Validator module. * link:/modules/hibernate-validator[Hibernate Validator]: Hibernate Validator module. === Development Tools diff --git a/modules/jooby-avaje-inject/pom.xml b/modules/jooby-avaje-inject/pom.xml index 5da897dfcf..30f4e6e877 100644 --- a/modules/jooby-avaje-inject/pom.xml +++ b/modules/jooby-avaje-inject/pom.xml @@ -11,7 +11,11 @@ 4.0.0 jooby-avaje-inject - + + + full + + com.github.spotbugs @@ -21,7 +25,6 @@ io.jooby jooby - ${jooby.version} @@ -33,7 +36,7 @@ io.avaje avaje-inject-generator - provided + test @@ -74,30 +77,4 @@ test - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - test - test-compile - - - - - -parameters - - - - io.avaje - avaje-inject-generator - - - - - - diff --git a/modules/jooby-avaje-validator/pom.xml b/modules/jooby-avaje-validator/pom.xml new file mode 100644 index 0000000000..ae0ee8095e --- /dev/null +++ b/modules/jooby-avaje-validator/pom.xml @@ -0,0 +1,117 @@ + + + + + io.jooby + modules + 3.3.1-SNAPSHOT + + + 4.0.0 + jooby-avaje-validator + + + full + + + + + io.jooby + jooby + + + + io.jooby + jooby-validation + ${project.version} + + + + + io.avaje + avaje-validator + + + + jakarta.validation + jakarta.validation-api + + + + + io.jooby + jooby-netty + test + + + + io.jooby + jooby-apt + test + + + io.avaje + avaje-validator-generator + test + + + + io.jooby + jooby-jackson + test + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + io.jooby + jooby-test + test + + + + io.rest-assured + rest-assured + test + + + + org.assertj + assertj-core + 3.26.3 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + test + test-compile + + + + + -parameters + + + + + + diff --git a/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/AvajeValidatorModule.java b/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/AvajeValidatorModule.java new file mode 100644 index 0000000000..bdc50beae5 --- /dev/null +++ b/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/AvajeValidatorModule.java @@ -0,0 +1,177 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.avaje.validator; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Consumer; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.avaje.validation.ConstraintViolationException; +import io.avaje.validation.Validator; +import io.jooby.Context; +import io.jooby.Extension; +import io.jooby.Jooby; +import io.jooby.StatusCode; +import io.jooby.validation.MvcValidator; + +/** + * Avaje Validator Module: https://jooby.io/modules/avaje-validator. + * + *
{@code
+ * {
+ *   install(new AvajeValidatorModule());
+ *
+ * }
+ *
+ * public class Controller {
+ *
+ *   @POST("/create")
+ *   public void create(@Valid Bean bean) {
+ *   }
+ *
+ * }
+ * }
+ * + *

Supports validation of a single bean, list, array, or map. + * + *

The module also provides a built-in error handler that catches {@link + * ConstraintViolationException} and transforms it into a {@link + * io.jooby.validation.ValidationResult} + * + * @authors kliushnichenko, SentryMan + * @since 3.3.1 + */ +public class AvajeValidatorModule implements Extension { + + private Consumer configurer; + private StatusCode statusCode = StatusCode.UNPROCESSABLE_ENTITY; + private String title = "Validation failed"; + private boolean disableDefaultViolationHandler = false; + + /** + * Setups a configurer callback. + * + * @param configurer Configurer callback. + * @return This module. + */ + public AvajeValidatorModule doWith(@NonNull final Consumer configurer) { + this.configurer = configurer; + return this; + } + + /** + * Overrides the default status code for the errors produced by validation. Default code is + * UNPROCESSABLE_ENTITY(422) + * + * @param statusCode new status code + * @return This module. + */ + public AvajeValidatorModule statusCode(@NonNull StatusCode statusCode) { + this.statusCode = statusCode; + return this; + } + + /** + * Overrides the default title for the errors produced by validation. Default title is "Validation + * failed" + * + * @param title new title + * @return This module. + */ + public AvajeValidatorModule validationTitle(@NonNull String title) { + this.title = title; + return this; + } + + /** + * Disables default constraint violation handler. By default {@link AvajeValidatorModule} provides + * built-in error handler for the {@link ConstraintViolationException} Such exceptions are + * transformed into response of {@link io.jooby.validation.ValidationResult} Use this flag to + * disable default error handler and provide your custom. + * + * @return This module. + */ + public AvajeValidatorModule disableViolationHandler() { + this.disableDefaultViolationHandler = true; + return this; + } + + @Override + public void install(@NonNull Jooby app) throws Exception { + + var props = app.getEnvironment(); + + final var locales = new ArrayList(); + final var builder = Validator.builder(); + Optional.ofNullable(props.getProperty("validation.failFast", "false")) + .map(Boolean::valueOf) + .ifPresent(builder::failFast); + + Optional.ofNullable(props.getProperty("validation.resourcebundle.names")) + .map(s -> s.split(",")) + .ifPresent(builder::addResourceBundles); + + Optional.ofNullable(props.getProperty("validation.locale.default")) + .map(Locale::forLanguageTag) + .ifPresent( + l -> { + builder.setDefaultLocale(l); + locales.add(l); + }); + + Optional.ofNullable(props.getProperty("validation.locale.addedLocales")).stream() + .flatMap(s -> Arrays.stream(s.split(","))) + .map(Locale::forLanguageTag) + .forEach( + l -> { + builder.addLocales(l); + locales.add(l); + }); + + Optional.ofNullable(props.getProperty("validation.temporal.tolerance.value")) + .map(Long::valueOf) + .ifPresent( + duration -> { + final var unit = + Optional.ofNullable(props.getProperty("validation.temporal.tolerance.chronoUnit")) + .map(ChronoUnit::valueOf) + .orElse(ChronoUnit.MILLIS); + builder.temporalTolerance(Duration.of(duration, unit)); + }); + + if (configurer != null) { + configurer.accept(builder); + } + + Validator validator = builder.build(); + app.getServices().put(Validator.class, validator); + app.getServices().put(MvcValidator.class, new MvcValidatorImpl(validator)); + + if (!disableDefaultViolationHandler) { + app.error( + ConstraintViolationException.class, new ConstraintViolationHandler(statusCode, title)); + } + } + + static class MvcValidatorImpl implements MvcValidator { + + private final Validator validator; + + MvcValidatorImpl(Validator validator) { + this.validator = validator; + } + + @Override + public void validate(Context ctx, Object bean) throws ConstraintViolationException { + validator.validate(bean, ctx.locale()); + } + } +} diff --git a/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/ConstraintViolationHandler.java b/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/ConstraintViolationHandler.java new file mode 100644 index 0000000000..76181c7343 --- /dev/null +++ b/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/ConstraintViolationHandler.java @@ -0,0 +1,95 @@ +package io.jooby.avaje.validator; + +import static io.jooby.validation.ValidationResult.ErrorType.FIELD; +import static io.jooby.validation.ValidationResult.ErrorType.GLOBAL; +import static java.util.stream.Collectors.groupingBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import edu.umd.cs.findbugs.annotations.NonNull; +import io.avaje.validation.ConstraintViolation; +import io.avaje.validation.ConstraintViolationException; +import io.jooby.Context; +import io.jooby.ErrorHandler; +import io.jooby.StatusCode; +import io.jooby.validation.ValidationResult; + +/** + * Catches and transform {@link ConstraintViolationException} into {@link ValidationResult} + * + *

Payload example: + * + *

{@code
+ * {
+ *    "title": "Validation failed",
+ *    "status": 422,
+ *    "errors": [
+ *       {
+ *          "field": null,
+ *          "messages": [
+ *             "Passwords should match"
+ *          ],
+ *          "type": "GLOBAL"
+ *       },
+ *       {
+ *          "field": "firstName",
+ *          "messages": [
+ *             "must not be empty",
+ *             "must not be null"
+ *          ],
+ *          "type": "FIELD"
+ *       }
+ *    ]
+ * }
+ * }
+ * + * @author kliushnichenko + * @since 3.2.10 + */ +public class ConstraintViolationHandler implements ErrorHandler { + + private static final String ROOT_VIOLATIONS_PATH = ""; + + private final StatusCode statusCode; + private final String title; + + public ConstraintViolationHandler(StatusCode statusCode, String title) { + this.statusCode = statusCode; + this.title = title; + } + + @Override + public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) { + var ex = (ConstraintViolationException) cause; + + var violations = ex.violations(); + + Map> groupedByPath = + violations.stream().collect(groupingBy(violation -> violation.path().toString())); + + List errors = collectErrors(groupedByPath); + + ValidationResult result = new ValidationResult(title, statusCode.value(), errors); + ctx.setResponseCode(statusCode).render(result); + } + + private List collectErrors( + Map> groupedViolations) { + List errors = new ArrayList<>(); + for (Map.Entry> entry : groupedViolations.entrySet()) { + var path = entry.getKey(); + if (ROOT_VIOLATIONS_PATH.equals(path)) { + errors.add(new ValidationResult.Error(null, extractMessages(entry.getValue()), GLOBAL)); + } else { + errors.add(new ValidationResult.Error(path, extractMessages(entry.getValue()), FIELD)); + } + } + return errors; + } + + private List extractMessages(List violations) { + return violations.stream().map(ConstraintViolation::message).toList(); + } +} diff --git a/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/package-info.java b/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/package-info.java new file mode 100644 index 0000000000..aaf68dc3bb --- /dev/null +++ b/modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/package-info.java @@ -0,0 +1 @@ +package io.jooby.avaje.validator; diff --git a/modules/jooby-avaje-validator/src/main/java/module-info.java b/modules/jooby-avaje-validator/src/main/java/module-info.java new file mode 100644 index 0000000000..d6ecdf13b8 --- /dev/null +++ b/modules/jooby-avaje-validator/src/main/java/module-info.java @@ -0,0 +1,17 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +/** + * Avaje Validator Module. + */ +module io.jooby.avaje.validator { + exports io.jooby.avaje.validator; + + requires transitive io.jooby; + requires static com.github.spotbugs.annotations; + requires typesafe.config; + requires transitive io.avaje.validation; + requires transitive io.jooby.validation; +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/AvajeValidatorModuleTest.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/AvajeValidatorModuleTest.java new file mode 100644 index 0000000000..f1c9e74e6c --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/AvajeValidatorModuleTest.java @@ -0,0 +1,197 @@ +package io.jooby.avaje.validator; + +import io.jooby.avaje.validator.app.App; +import io.jooby.avaje.validator.app.NewAccountRequest; +import io.jooby.avaje.validator.app.Person; +import io.jooby.test.JoobyTest; +import io.jooby.validation.ValidationResult; +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static io.jooby.StatusCode.UNPROCESSABLE_ENTITY_CODE; +import static io.jooby.avaje.validator.app.App.DEFAULT_TITLE; +import static io.restassured.RestAssured.given; + +@JoobyTest(value = App.class, port = 8099) +public class AvajeValidatorModuleTest { + + protected static RequestSpecification SPEC = + new RequestSpecBuilder() + .setPort(8099) + .setContentType(ContentType.JSON) + .setAccept(ContentType.JSON) + .build(); + + static { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Test + public void validate_personBean_shouldDetect2Violations() { + Person person = new Person(null, "Last Name"); + + ValidationResult actualResult = + given() + .spec(SPEC) + .with() + .body(person) + .post("/create-person") + .then() + .assertThat() + .statusCode(UNPROCESSABLE_ENTITY_CODE) + .extract() + .as(ValidationResult.class); + + var fieldError = + new ValidationResult.Error( + "firstName", List.of("must not be empty"), ValidationResult.ErrorType.FIELD); + ValidationResult expectedResult = buildResult(List.of(fieldError)); + + Assertions.assertThat(expectedResult) + .usingRecursiveComparison() + .ignoringCollectionOrderInFieldsMatchingRegexes("errors\\.messages") + .isEqualTo(actualResult); + } + + @Test + public void validate_arrayOfPerson_shouldDetect2Violations() { + Person person1 = new Person("First Name", "Last Name"); + Person person2 = new Person(null, "Last Name 2"); + + ValidationResult actualResult = + given() + .spec(SPEC) + .with() + .body(new Person[] {person1, person2}) + .post("/create-array-of-persons") + .then() + .assertThat() + .statusCode(UNPROCESSABLE_ENTITY_CODE) + .extract() + .as(ValidationResult.class); + + var fieldError = + new ValidationResult.Error( + "firstName", List.of("must not be empty"), ValidationResult.ErrorType.FIELD); + ValidationResult expectedResult = buildResult(List.of(fieldError)); + + Assertions.assertThat(expectedResult) + .usingRecursiveComparison() + .ignoringCollectionOrderInFieldsMatchingRegexes("errors\\.messages") + .isEqualTo(actualResult); + } + + @Test + public void validate_listOfPerson_shouldDetect2Violations() { + Person person1 = new Person("First Name", "Last Name"); + Person person2 = new Person(null, "Last Name 2"); + + ValidationResult actualResult = + given() + .spec(SPEC) + .with() + .body(List.of(person1, person2)) + .post("/create-list-of-persons") + .then() + .assertThat() + .statusCode(UNPROCESSABLE_ENTITY_CODE) + .extract() + .as(ValidationResult.class); + + var fieldError = + new ValidationResult.Error( + "firstName", List.of("must not be empty"), ValidationResult.ErrorType.FIELD); + ValidationResult expectedResult = buildResult(List.of(fieldError)); + + Assertions.assertThat(expectedResult) + .usingRecursiveComparison() + .ignoringCollectionOrderInFieldsMatchingRegexes("errors\\.messages") + .isEqualTo(actualResult); + } + + @Test + public void validate_mapOfPerson_shouldDetect2Violations() { + Person person1 = new Person("First Name", "Last Name"); + Person person2 = new Person(null, "Last Name 2"); + + ValidationResult actualResult = + given() + .spec(SPEC) + .with() + .body(Map.of("1", person1, "2", person2)) + .post("/create-map-of-persons") + .then() + .assertThat() + .statusCode(UNPROCESSABLE_ENTITY_CODE) + .extract() + .as(ValidationResult.class); + + var fieldError = + new ValidationResult.Error( + "firstName", List.of("must not be empty"), ValidationResult.ErrorType.FIELD); + ValidationResult expectedResult = buildResult(List.of(fieldError)); + + Assertions.assertThat(expectedResult) + .usingRecursiveComparison() + .ignoringCollectionOrderInFieldsMatchingRegexes("errors\\.messages") + .isEqualTo(actualResult); + } + + @Test + public void validate_newAccountBean_shouldDetect6Violations() { + NewAccountRequest request = new NewAccountRequest(); + request.setLogin("jk"); + request.setPassword("123"); + request.setConfirmPassword("1234"); + request.setPerson(new Person(null, "Last Name")); + + ValidationResult actualResult = + given() + .spec(SPEC) + .with() + .body(request) + .post("/create-new-account") + .then() + .assertThat() + .statusCode(UNPROCESSABLE_ENTITY_CODE) + .extract() + .as(ValidationResult.class); + + List errors = + List.of( + new ValidationResult.Error( + "password", + List.of("length must be between 8 and 24"), + ValidationResult.ErrorType.FIELD), + new ValidationResult.Error( + "person.firstName", List.of("must not be empty"), ValidationResult.ErrorType.FIELD), + new ValidationResult.Error( + "confirmPassword", + List.of("length must be between 8 and 24"), + ValidationResult.ErrorType.FIELD), + new ValidationResult.Error( + "login", + List.of("length must be between 3 and 16"), + ValidationResult.ErrorType.FIELD)); + + ValidationResult expectedResult = buildResult(errors); + + Assertions.assertThat(expectedResult) + .usingRecursiveComparison() + .ignoringCollectionOrderInFieldsMatchingRegexes("errors") + .ignoringCollectionOrderInFieldsMatchingRegexes("errors\\.messages") + .isEqualTo(actualResult); + } + + private ValidationResult buildResult(List errors) { + return new ValidationResult(DEFAULT_TITLE, UNPROCESSABLE_ENTITY_CODE, errors); + } +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/App.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/App.java new file mode 100644 index 0000000000..885feaabfc --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/App.java @@ -0,0 +1,25 @@ +package io.jooby.avaje.validator.app; + +import io.jooby.Jooby; +import io.jooby.StatusCode; +import io.jooby.avaje.validator.AvajeValidatorModule; +import io.jooby.avaje.validator.ConstraintViolationHandler; +import io.jooby.jackson.JacksonModule; +import jakarta.validation.ConstraintViolationException; + +public class App extends Jooby { + + private static final StatusCode STATUS_CODE = StatusCode.UNPROCESSABLE_ENTITY; + public static final String DEFAULT_TITLE = "Validation failed"; + + { + install(new JacksonModule()); + install(new AvajeValidatorModule()); + + mvc(new Controller()); + + error( + ConstraintViolationException.class, + new ConstraintViolationHandler(STATUS_CODE, DEFAULT_TITLE)); + } +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/Controller.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/Controller.java new file mode 100644 index 0000000000..1adfb5ae11 --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/Controller.java @@ -0,0 +1,27 @@ +package io.jooby.avaje.validator.app; + +import io.jooby.annotation.POST; +import io.jooby.annotation.Path; +import jakarta.validation.Valid; + +import java.util.List; +import java.util.Map; + +@Path("") +public class Controller { + + @POST("/create-person") + public void createPerson(@Valid Person person) {} + + @POST("/create-array-of-persons") + public void createArrayOfPersons(@Valid Person[] persons) {} + + @POST("/create-list-of-persons") + public void createListOfPersons(@Valid List persons) {} + + @POST("/create-map-of-persons") + public void createMapOfPersons(@Valid Map persons) {} + + @POST("/create-new-account") + public void createNewAccount(@Valid NewAccountRequest request) {} +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/NewAccountRequest.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/NewAccountRequest.java new file mode 100644 index 0000000000..129618e7ce --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/NewAccountRequest.java @@ -0,0 +1,59 @@ +package io.jooby.avaje.validator.app; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Valid +@PasswordsShouldMatch +public class NewAccountRequest { + @NotNull + @NotEmpty + @Size(min = 3, max = 16) + private String login; + + @NotNull + @NotEmpty + @Size(min = 8, max = 24) + private String password; + + @NotNull + @NotEmpty + @Size(min = 8, max = 24) + private String confirmPassword; + + @Valid private Person person; + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getConfirmPassword() { + return confirmPassword; + } + + public void setConfirmPassword(String confirmPassword) { + this.confirmPassword = confirmPassword; + } + + public Person getPerson() { + return person; + } + + public void setPerson(Person person) { + this.person = person; + } +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/PasswordsShouldMatch.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/PasswordsShouldMatch.java new file mode 100644 index 0000000000..ae11aea568 --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/PasswordsShouldMatch.java @@ -0,0 +1,20 @@ +package io.jooby.avaje.validator.app; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +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; + +@Constraint(validatedBy = {}) +@Target({TYPE, ANNOTATION_TYPE}) +@Retention(RUNTIME) +public @interface PasswordsShouldMatch { + String message() default "Passwords should match"; + + Class[] groups() default {}; +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/PasswordsShouldMatchValidator.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/PasswordsShouldMatchValidator.java new file mode 100644 index 0000000000..073c025057 --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/PasswordsShouldMatchValidator.java @@ -0,0 +1,21 @@ +package io.jooby.avaje.validator.app; + +import io.avaje.validation.adapter.AbstractConstraintAdapter; +import io.avaje.validation.adapter.ConstraintAdapter; +import io.avaje.validation.adapter.ValidationContext.AdapterCreateRequest; + +@ConstraintAdapter(PasswordsShouldMatch.class) +public class PasswordsShouldMatchValidator extends AbstractConstraintAdapter { + + public PasswordsShouldMatchValidator(AdapterCreateRequest request) { + super(request); + } + + @Override + public boolean isValid(NewAccountRequest request) { + if (request.getPassword() == null || request.getConfirmPassword() == null) { + return false; + } + return request.getPassword().equals(request.getConfirmPassword()); + } +} diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/Person.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/Person.java new file mode 100644 index 0000000000..0687bc0ac1 --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/avaje/validator/app/Person.java @@ -0,0 +1,33 @@ +package io.jooby.avaje.validator.app; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +@Valid +public class Person { + + @NotEmpty private String firstName; + private String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } +} diff --git a/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/ConstraintViolationHandler.java b/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/ConstraintViolationHandler.java index ad35352567..dcc5f16bbc 100644 --- a/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/ConstraintViolationHandler.java +++ b/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/ConstraintViolationHandler.java @@ -46,7 +46,7 @@ * } * * @author kliushnichenko - * @since 3.2.10 + * @since 3.3.1 */ public class ConstraintViolationHandler implements ErrorHandler { diff --git a/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/HibernateValidatorModule.java b/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/HibernateValidatorModule.java index 970191425b..5f7a3afe95 100644 --- a/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/HibernateValidatorModule.java +++ b/modules/jooby-hibernate-validator/src/main/java/io/jooby/hibernate/validator/HibernateValidatorModule.java @@ -7,6 +7,7 @@ import com.typesafe.config.Config; import edu.umd.cs.findbugs.annotations.NonNull; +import io.jooby.Context; import io.jooby.Extension; import io.jooby.Jooby; import io.jooby.StatusCode; @@ -47,7 +48,7 @@ * and transforms it into a {@link io.jooby.validation.ValidationResult}

* * @author kliushnichenko - * @since 3.2.10 + * @since 3.3.1 */ public class HibernateValidatorModule implements Extension { @@ -142,7 +143,7 @@ static class MvcValidatorImpl implements MvcValidator { } @Override - public void validate(Object bean) throws ConstraintViolationException { + public void validate(Context ctx, Object bean) throws ConstraintViolationException { Set> violations = validator.validate(bean); if (!violations.isEmpty()) { throw new ConstraintViolationException(violations); diff --git a/modules/jooby-validation/src/main/java/io/jooby/validation/BeanValidator.java b/modules/jooby-validation/src/main/java/io/jooby/validation/BeanValidator.java index d445ff20d0..4d81969f9d 100644 --- a/modules/jooby-validation/src/main/java/io/jooby/validation/BeanValidator.java +++ b/modules/jooby-validation/src/main/java/io/jooby/validation/BeanValidator.java @@ -20,33 +20,27 @@ */ public final class BeanValidator { + private BeanValidator() {} + public static T validate(Context ctx, T bean) { MvcValidator validator = ctx.require(MvcValidator.class); if (bean instanceof Collection) { - validateCollection(validator, (Collection) bean); + validateCollection(validator, ctx, (Collection) bean); } else if (bean.getClass().isArray()) { - validateCollection(validator, Arrays.asList((Object[]) bean)); + validateCollection(validator, ctx, Arrays.asList((Object[]) bean)); } else if (bean instanceof Map) { - validateCollection(validator, ((Map) bean).values()); + validateCollection(validator, ctx, ((Map) bean).values()); } else { - validateObject(validator, bean); + validator.validate(ctx, bean); } return bean; } - private static void validateCollection(MvcValidator validator, Collection beans) { - for (Object item : beans) { - validateObject(validator, item); - } - } - - private static void validateObject(MvcValidator validator, Object bean) { - try { - validator.validate(bean); - } catch (Throwable e) { - SneakyThrows.propagate(e); + private static void validateCollection(MvcValidator validator, Context ctx, Collection beans) { + for (var item : beans) { + validator.validate(ctx, item); } } } diff --git a/modules/jooby-validation/src/main/java/io/jooby/validation/MvcValidator.java b/modules/jooby-validation/src/main/java/io/jooby/validation/MvcValidator.java index d17b7b6eb7..7fcca35fbb 100644 --- a/modules/jooby-validation/src/main/java/io/jooby/validation/MvcValidator.java +++ b/modules/jooby-validation/src/main/java/io/jooby/validation/MvcValidator.java @@ -1,5 +1,7 @@ package io.jooby.validation; +import io.jooby.Context; + /** * This interface should be implemented by modules that provide bean validation functionality. * An instance of this interface must be registered in the Jooby service registry. @@ -11,7 +13,8 @@ public interface MvcValidator { /** * Method should validate the bean and throw an exception if any constraint violations are detected * @param bean bean to be validated + * @param ctx request context * @throws RuntimeException an exception with violations to be thrown (e.g. ConstraintViolationException) */ - void validate(Object bean) throws RuntimeException; + void validate(Context ctx, Object bean) throws RuntimeException; } diff --git a/modules/pom.xml b/modules/pom.xml index 3369be6e33..d18d100347 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -60,6 +60,7 @@ jooby-validation + jooby-avaje-validator jooby-hibernate-validator jooby-pac4j diff --git a/pom.xml b/pom.xml index 870dd9dd6a..18ffc63809 100644 --- a/pom.xml +++ b/pom.xml @@ -25,7 +25,6 @@ 3.2.2 2.17.2 2.11.0 - 2.1 3.0.1 3.0.4 1.4.0 @@ -47,7 +46,6 @@ 1.4.3 - 10.3 7.0.0 @@ -80,6 +78,11 @@ 2.5.2 + + 10.3 + 2.1 + 2.1 + 2.0.1 3.1.0 @@ -346,6 +349,12 @@ ${jooby.version} + + io.jooby + jooby-avaje-validator + ${jooby.version} + + io.jooby jooby-hibernate @@ -673,26 +682,39 @@ io.avaje avaje-inject - ${avaje-inject.version} + ${avaje.inject.version} io.avaje avaje-inject-generator - ${avaje-inject.version} + ${avaje.inject.version} io.avaje avaje-jsonb - ${avaje-jsonb.version} + ${avaje.jsonb.version} io.avaje avaje-jsonb-generator - ${avaje-jsonb.version} + ${avaje.jsonb.version} + + + + + io.avaje + avaje-validator + ${avaje.validator.version} + + + + io.avaje + avaje-validator-generator + ${avaje.validator.version}