diff --git a/jooby/src/main/java/io/jooby/ModelAndView.java b/jooby/src/main/java/io/jooby/ModelAndView.java index d7e598ecd0..91e08cc0e4 100644 --- a/jooby/src/main/java/io/jooby/ModelAndView.java +++ b/jooby/src/main/java/io/jooby/ModelAndView.java @@ -77,6 +77,27 @@ public static MapModelAndView map(String view, Map model) { return new MapModelAndView(view, model); } + /** + * Creates a model and view based on the provided view name and model. If the model is null, a + * map-based model and view is created. If the model is an instance of {@code Map}, a map-based + * model and view is created using the provided map. Otherwise, a generic model and view is + * created with the specified view name and model. + * + * @param view The name of the view, which may include a file extension. + * @param model The data model to be associated with the view. This can be null, a {@code Map}, or + * any other object. + * @return A {@code ModelAndView} instance corresponding to the specified view and model. + */ + public static ModelAndView> of(String view, Object model) { + if (model == null) { + return map(view); + } + if (model instanceof Map mapModel) { + return map(view, mapModel); + } + return new ModelAndView(view, model); + } + /** * Sets the locale used when rendering the view, if the template engine supports setting it. * Specifying {@code null} triggers a fallback to a locale determined by the current request. diff --git a/jooby/src/main/java/io/jooby/internal/RouterImpl.java b/jooby/src/main/java/io/jooby/internal/RouterImpl.java index 62a73baa0e..3ab9f7f5e7 100644 --- a/jooby/src/main/java/io/jooby/internal/RouterImpl.java +++ b/jooby/src/main/java/io/jooby/internal/RouterImpl.java @@ -43,6 +43,7 @@ import io.jooby.internal.handler.WebSocketHandler; import io.jooby.output.OutputFactory; import io.jooby.problem.ProblemDetailsHandler; +import io.jooby.validation.ValidationExceptionMapper; import io.jooby.value.ValueFactory; public class RouterImpl implements Router { @@ -551,6 +552,15 @@ public Router start(Jooby app) { } else { err = err.then(globalErrHandler); } + // Validation mapper + var services = app.getServices(); + List validationExceptionMappers = + services.getOrNull(Reified.list(ValidationExceptionMapper.class)); + var validationExceptionChain = new ValidationExceptionChain(); + if (validationExceptionMappers != null) { + validationExceptionMappers.forEach(validationExceptionChain::add); + } + services.put(ValidationExceptionMapper.class, validationExceptionChain); ExecutionMode mode = app.getExecutionMode(); for (Route route : routes) { diff --git a/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java b/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java new file mode 100644 index 0000000000..cef59bbad3 --- /dev/null +++ b/jooby/src/main/java/io/jooby/internal/ValidationExceptionChain.java @@ -0,0 +1,90 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jspecify.annotations.Nullable; + +import io.jooby.StatusCode; +import io.jooby.validation.ValidationExceptionMapper; +import io.jooby.validation.ValidationResult; + +/** + * ValidationExceptionChain provides a way to combine multiple {@link ValidationExceptionMapper} + * implementations into a single chain. This allows sequential delegation of validation exception + * mapping to the contained mappers. + * + *

The chain processes exceptions by iterating over the registered mappers. Each mapper attempts + * to convert the given exception into a {@link ValidationResult}. The first non-null result found + * is returned. If none of the mappers produce a result, a default {@link ValidationResult} is + * generated with a global error indicating validation failure. + * + *

This class is useful in scenarios where different exception mapping strategies are needed and + * should be applied in a specific sequence. + * + * @author edgar + * @since 4.5.0 + */ +public class ValidationExceptionChain implements ValidationExceptionMapper { + private final List mappers = new ArrayList<>(); + + /** + * Adds a {@link ValidationExceptionMapper} to the chain. + * + *

This method allows the registration of a new mapper, which will be used in sequence for + * exception mapping. The newly added mapper will be appended to the chain, maintaining the order + * of insertion. + * + * @param mapper the {@link ValidationExceptionMapper} to be added to the chain + * @return the current {@link ValidationExceptionChain} instance to allow for method chaining + */ + public ValidationExceptionChain add(ValidationExceptionMapper mapper) { + mappers.add(mapper); + return this; + } + + /** + * Converts the given {@link StatusCode} and {@link Exception} into a {@link ValidationResult}. + * + *

This method iterates through the chain of registered {@link ValidationExceptionMapper} + * instances. Each mapper attempts to produce a {@link ValidationResult} for the specified status + * code and exception. If a non-null result is produced, it is returned immediately. If no mapper + * produces a valid result, a default {@link ValidationResult} is returned indicating a global + * validation failure. + * + * @param suggestedCode the status code associated with the exception + * @param cause the exception that needs to be converted into a validation result + * @return the converted {@link ValidationResult} from the first applicable mapper, or a default + * result if no mapper can process the exception + */ + @Override + public @Nullable ValidationResult toResult(StatusCode suggestedCode, Exception cause) { + for (var mapper : mappers) { + var result = mapper.toResult(suggestedCode, cause); + if (result != null) { + return result; + } + } + if (suggestedCode.value() >= 500) { + // Not handled + return null; + } + // Assume is a client error, provide a default result + return new ValidationResult( + "Validation failed", + suggestedCode.value(), + List.of( + new ValidationResult.Error( + null, + List.of( + Optional.ofNullable(cause.getMessage()) + .orElse(cause.getClass().getSimpleName())), + ValidationResult.ErrorType.GLOBAL))); + } +} diff --git a/jooby/src/main/java/io/jooby/validation/ValidationExceptionMapper.java b/jooby/src/main/java/io/jooby/validation/ValidationExceptionMapper.java new file mode 100644 index 0000000000..2ab2f47fc3 --- /dev/null +++ b/jooby/src/main/java/io/jooby/validation/ValidationExceptionMapper.java @@ -0,0 +1,40 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.validation; + +import org.jspecify.annotations.Nullable; + +import io.jooby.StatusCode; + +/** + * This interface defines a contract for mapping exceptions to validation results. It is primarily + * used to convert exceptions, such as those thrown during bean validation, into instances of {@link + * ValidationResult}. This allows for a consistent representation of validation errors across the + * application. + * + *

Implementers are responsible for interpreting the given exception and translating it into an + * appropriate {@link ValidationResult}, which may encapsulate details such as error messages, + * status codes, and specific fields that failed validation. + * + * @author edgar + * @since 4.5.0 + */ +@FunctionalInterface +public interface ValidationExceptionMapper { + + /** + * Converts the provided exception into a {@link ValidationResult}. This method interprets the + * given exception, typically from a validation process, and maps it into a {@link + * ValidationResult} instance, encapsulating details such as validation errors and status + * information. + * + * @param suggestedCode the suggested status code for the validation result. Usually overriden + * with {@link StatusCode#UNPROCESSABLE_ENTITY}. + * @param cause the exception to be mapped to a {@link ValidationResult}. + * @return a {@link ValidationResult} representing the mapped exception. + */ + @Nullable ValidationResult toResult(StatusCode suggestedCode, Exception cause); +} diff --git a/jooby/src/test/java/io/jooby/MapModelAndViewTest.java b/jooby/src/test/java/io/jooby/MapModelAndViewTest.java index a4b785b311..bff6ece1b3 100644 --- a/jooby/src/test/java/io/jooby/MapModelAndViewTest.java +++ b/jooby/src/test/java/io/jooby/MapModelAndViewTest.java @@ -5,9 +5,7 @@ */ package io.jooby; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.util.HashMap; import java.util.Locale; @@ -80,4 +78,19 @@ void testSetLocale() { assertSame(mav, result, "setLocale should return the current instance for fluent chaining"); assertEquals(locale, mav.getLocale()); } + + @Test + void testOfWithNullModel() { + assertInstanceOf(MapModelAndView.class, ModelAndView.of("index.html", null)); + } + + @Test + void testOfWithMapModel() { + assertInstanceOf(MapModelAndView.class, ModelAndView.of("index.html", Map.of())); + } + + @Test + void testOfWithBeanModel() { + assertInstanceOf(ModelAndView.class, ModelAndView.of("index.html", new Object())); + } } diff --git a/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java b/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java new file mode 100644 index 0000000000..5c93184521 --- /dev/null +++ b/jooby/src/test/java/io/jooby/internal/ValidationExceptionChainTest.java @@ -0,0 +1,103 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.jooby.StatusCode; +import io.jooby.validation.ValidationExceptionMapper; +import io.jooby.validation.ValidationResult; + +class ValidationExceptionChainTest { + + @Test + void shouldReturnResultFromFirstApplicableMapper() { + ValidationExceptionChain chain = new ValidationExceptionChain(); + + ValidationExceptionMapper mapper1 = mock(ValidationExceptionMapper.class); + ValidationExceptionMapper mapper2 = mock(ValidationExceptionMapper.class); + ValidationExceptionMapper mapper3 = mock(ValidationExceptionMapper.class); + + Exception cause = new RuntimeException("Test error"); + ValidationResult expectedResult = + new ValidationResult("Custom title", 422, Collections.emptyList()); + + // Mapper 1 returns null (cannot handle) + when(mapper1.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause)).thenReturn(null); + // Mapper 2 returns a valid result + when(mapper2.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause)).thenReturn(expectedResult); + + // Chaining add methods + chain.add(mapper1).add(mapper2).add(mapper3); + + ValidationResult result = chain.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause); + + assertSame(expectedResult, result); + verify(mapper1).toResult(StatusCode.UNPROCESSABLE_ENTITY, cause); + verify(mapper2).toResult(StatusCode.UNPROCESSABLE_ENTITY, cause); + // Mapper 3 should never be called since Mapper 2 handled it + verifyNoInteractions(mapper3); + } + + @Test + void shouldReturnDefaultResultWhenNoMapperHandlesItAndStatusCodeIsClientError() { + ValidationExceptionChain chain = new ValidationExceptionChain(); + Exception cause = new IllegalArgumentException("Invalid input provided"); + + ValidationResult result = chain.toResult(StatusCode.BAD_REQUEST, cause); + + assertNotNull(result); + assertEquals("Validation failed", result.getTitle()); + assertEquals(400, result.getStatus()); + + assertEquals(1, result.getErrors().size()); + ValidationResult.Error error = result.getErrors().get(0); + assertNull(error.field()); + assertEquals(ValidationResult.ErrorType.GLOBAL, error.type()); + assertEquals(List.of("Invalid input provided"), error.messages()); + } + + @Test + void shouldReturnDefaultResultUsingClassNameWhenExceptionHasNoMessage() { + ValidationExceptionChain chain = new ValidationExceptionChain(); + // Exception without a message + Exception cause = new NullPointerException(); + + ValidationResult result = chain.toResult(StatusCode.BAD_REQUEST, cause); + + assertNotNull(result); + assertEquals("Validation failed", result.getTitle()); + assertEquals(400, result.getStatus()); + + assertEquals(1, result.getErrors().size()); + ValidationResult.Error error = result.getErrors().get(0); + assertNull(error.field()); + assertEquals(ValidationResult.ErrorType.GLOBAL, error.type()); + // Fallback to the class simple name + assertEquals(List.of("NullPointerException"), error.messages()); + } + + @Test + void shouldReturnNullWhenStatusCodeIsServerError() { + ValidationExceptionChain chain = new ValidationExceptionChain(); + Exception cause = new IllegalStateException("Database connection failed"); + + // >= 500 status code + assertNull(chain.toResult(StatusCode.SERVER_ERROR, cause)); + } +} diff --git a/modules/jooby-apt/.gitignore b/modules/jooby-apt/.gitignore new file mode 100644 index 0000000000..16ae778b1d --- /dev/null +++ b/modules/jooby-apt/.gitignore @@ -0,0 +1,2 @@ +generated +generated_tests 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 index 2fe33f24dc..a28ccef404 100644 --- 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 @@ -21,7 +21,9 @@ import io.jooby.Extension; import io.jooby.Jooby; import io.jooby.StatusCode; +import io.jooby.internal.avaje.validator.ConstraintViolationMapper; import io.jooby.validation.BeanValidator; +import io.jooby.validation.ValidationExceptionMapper; /** * Avaje Validator Module: https://jooby.io/modules/avaje-validator. @@ -157,9 +159,13 @@ public void install(Jooby app) { configurer.accept(builder); } + var services = app.getServices(); var validator = builder.build(); - app.getServices().put(Validator.class, validator); - app.getServices().put(BeanValidator.class, new BeanValidatorImpl(validator)); + services.put(Validator.class, validator); + services.put(BeanValidator.class, new BeanValidatorImpl(validator)); + services + .listOf(ValidationExceptionMapper.class) + .add(new ConstraintViolationMapper(statusCode, title)); if (!disableDefaultViolationHandler) { app.error( 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 index 193b694b7f..6d902ddeaf 100644 --- 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 @@ -5,22 +5,15 @@ */ 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; -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.internal.avaje.validator.ConstraintViolationMapper; +import io.jooby.validation.ValidationExceptionMapper; import io.jooby.validation.ValidationResult; /** @@ -56,17 +49,16 @@ * @since 3.2.10 */ public class ConstraintViolationHandler implements ErrorHandler { - private static final String ROOT_VIOLATIONS_PATH = ""; private final Logger log = LoggerFactory.getLogger(getClass()); private final StatusCode statusCode; - private final String title; + private final ValidationExceptionMapper mapper; private final boolean logException; private final boolean problemDetailsEnabled; public ConstraintViolationHandler( StatusCode statusCode, String title, boolean logException, boolean problemDetailsEnabled) { this.statusCode = statusCode; - this.title = title; + this.mapper = new ConstraintViolationMapper(statusCode, title); this.logException = logException; this.problemDetailsEnabled = problemDetailsEnabled; } @@ -77,34 +69,11 @@ public void apply(Context ctx, Throwable cause, StatusCode code) { if (logException) { log.error(ErrorHandler.errorMessage(ctx, code), cause); } - var violations = ex.violations(); - - var groupedByPath = violations.stream().collect(groupingBy(ConstraintViolation::path)); - var errors = collectErrors(groupedByPath); - - var result = new ValidationResult(title, statusCode.value(), errors); + var result = mapper.toResult(code, ex); renderOrPropagate(ctx, result, code); } } - private List collectErrors( - Map> groupedViolations) { - List errors = new ArrayList<>(); - for (var 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(); - } - private void renderOrPropagate(Context ctx, ValidationResult result, StatusCode code) { if (problemDetailsEnabled) { ctx.getRouter().getErrorHandler().apply(ctx, result.toHttpProblem(), code); diff --git a/modules/jooby-avaje-validator/src/main/java/io/jooby/internal/avaje/validator/ConstraintViolationMapper.java b/modules/jooby-avaje-validator/src/main/java/io/jooby/internal/avaje/validator/ConstraintViolationMapper.java new file mode 100644 index 0000000000..7431ea701f --- /dev/null +++ b/modules/jooby-avaje-validator/src/main/java/io/jooby/internal/avaje/validator/ConstraintViolationMapper.java @@ -0,0 +1,63 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.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 io.avaje.validation.ConstraintViolation; +import io.avaje.validation.ConstraintViolationException; +import io.jooby.StatusCode; +import io.jooby.validation.ValidationExceptionMapper; +import io.jooby.validation.ValidationResult; + +public class ConstraintViolationMapper implements ValidationExceptionMapper { + private static final String ROOT_VIOLATIONS_PATH = ""; + + private final StatusCode statusCode; + private final String title; + + public ConstraintViolationMapper(StatusCode statusCode, String title) { + this.statusCode = statusCode; + this.title = title; + } + + @Override + public ValidationResult toResult(StatusCode suggestedCode, Exception cause) { + if (cause instanceof ConstraintViolationException constraintViolationException) { + var violations = constraintViolationException.violations(); + + var groupedByPath = violations.stream().collect(groupingBy(ConstraintViolation::path)); + var errors = collectErrors(groupedByPath); + + return new ValidationResult(title, statusCode.value(), errors); + } + return null; + } + + private List collectErrors( + Map> groupedViolations) { + List errors = new ArrayList<>(); + for (var 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/module-info.java b/modules/jooby-avaje-validator/src/main/java/module-info.java index cb4b11b505..6c3d3c066a 100644 --- a/modules/jooby-avaje-validator/src/main/java/module-info.java +++ b/modules/jooby-avaje-validator/src/main/java/module-info.java @@ -6,6 +6,7 @@ /** Avaje Validator Module. */ module io.jooby.avaje.validator { exports io.jooby.avaje.validator; + exports io.jooby.internal.avaje.validator; requires transitive io.jooby; requires static org.jspecify; 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 index 83d73afb4f..f4890029a0 100644 --- 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 @@ -39,9 +39,12 @@ import io.jooby.Jooby; import io.jooby.ServiceRegistry; import io.jooby.StatusCode; +import io.jooby.internal.avaje.validator.ConstraintViolationMapper; import io.jooby.validation.BeanValidator; +import io.jooby.validation.ValidationExceptionMapper; @ExtendWith(MockitoExtension.class) +@SuppressWarnings("unchecked") class AvajeValidatorModuleTest { @Mock Jooby app; @@ -54,16 +57,17 @@ void setup() { lenient().when(app.getServices()).thenReturn(registry); lenient().when(app.problemDetailsIsEnabled()).thenReturn(false); - // Explicitly return null to prevent Mockito from returning an empty list, - // which crashes the module's locales.get(0) check. lenient().when(app.getLocales()).thenReturn(null); } @Test void testInstall_Defaults() { - // Default: No config properties are set when(config.hasPath(anyString())).thenReturn(false); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(registry.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + try (MockedStatic validatorMock = mockStatic(Validator.class)) { Validator.Builder builder = mock(Validator.Builder.class, Answers.RETURNS_SELF); Validator validator = mock(Validator.class); @@ -73,48 +77,45 @@ void testInstall_Defaults() { AvajeValidatorModule module = new AvajeValidatorModule(); module.install(app); - // Verify services are registered verify(registry).put(Validator.class, validator); ArgumentCaptor beanValidatorCaptor = ArgumentCaptor.forClass(BeanValidator.class); verify(registry).put(eq(BeanValidator.class), beanValidatorCaptor.capture()); assertNotNull(beanValidatorCaptor.getValue()); - // Verify constraint handler is registered + verify(exceptionMappers).add(any(ConstraintViolationMapper.class)); + verify(app).error(any(ConstraintViolationHandler.class)); } } @Test void testInstall_WithConfigStringsAndLocales() { - // Enable all configuration paths when(config.hasPath(anyString())).thenReturn(true); - // Boolean: failFast when(config.getBoolean("validation.failFast")).thenReturn(true); - // String: resourcebundle.names ConfigValue rbNameVal = mock(ConfigValue.class); when(rbNameVal.valueType()).thenReturn(ConfigValueType.STRING); when(config.getValue("validation.resourcebundle.names")).thenReturn(rbNameVal); when(config.getString("validation.resourcebundle.names")).thenReturn("messages"); - // Application Locales when(app.getLocales()).thenReturn(List.of(Locale.US, Locale.UK)); - // String: locale.default when(config.getString("validation.locale.default")).thenReturn("fr-FR"); - // String: locale.addedLocales ConfigValue addedLocalesVal = mock(ConfigValue.class); when(addedLocalesVal.valueType()).thenReturn(ConfigValueType.STRING); when(config.getValue("validation.locale.addedLocales")).thenReturn(addedLocalesVal); when(config.getString("validation.locale.addedLocales")).thenReturn("de-DE"); - // Long & String: temporal tolerance and chrono unit when(config.getLong("validation.temporal.tolerance.value")).thenReturn(100L); when(config.getString("validation.temporal.tolerance.chronoUnit")).thenReturn("SECONDS"); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(registry.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + try (MockedStatic validatorMock = mockStatic(Validator.class)) { Validator.Builder builder = mock(Validator.Builder.class, Answers.RETURNS_SELF); Validator validator = mock(Validator.class); @@ -123,19 +124,15 @@ void testInstall_WithConfigStringsAndLocales() { new AvajeValidatorModule().install(app); - // Verify the builder was configured correctly verify(builder).failFast(true); verify(builder).addResourceBundles("messages"); - // Verification for app.getLocales() verify(builder).setDefaultLocale(Locale.US); verify(builder).addLocales(Locale.UK); - // Verification for explicit locale settings verify(builder).setDefaultLocale(Locale.forLanguageTag("fr-FR")); verify(builder).addLocales(Locale.forLanguageTag("de-DE")); - // Verification for temporal tolerance verify(builder).temporalTolerance(Duration.of(100, ChronoUnit.SECONDS)); } } @@ -147,22 +144,23 @@ void testInstall_WithConfigListsAndDefaultChronoUnit() { when(config.hasPath("validation.locale.addedLocales")).thenReturn(true); when(config.hasPath("validation.temporal.tolerance.value")).thenReturn(true); - // List: resourcebundle.names ConfigValue rbNameVal = mock(ConfigValue.class); when(rbNameVal.valueType()).thenReturn(ConfigValueType.LIST); when(config.getValue("validation.resourcebundle.names")).thenReturn(rbNameVal); when(config.getStringList("validation.resourcebundle.names")) .thenReturn(List.of("msg1", "msg2")); - // List: locale.addedLocales ConfigValue addedLocalesVal = mock(ConfigValue.class); when(addedLocalesVal.valueType()).thenReturn(ConfigValueType.LIST); when(config.getValue("validation.locale.addedLocales")).thenReturn(addedLocalesVal); when(config.getStringList("validation.locale.addedLocales")).thenReturn(List.of("es", "it")); - // Long: temporal tolerance with missing unit (fallback to MILLIS) when(config.getLong("validation.temporal.tolerance.value")).thenReturn(50L); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(registry.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + try (MockedStatic validatorMock = mockStatic(Validator.class)) { Validator.Builder builder = mock(Validator.Builder.class, Answers.RETURNS_SELF); Validator validator = mock(Validator.class); @@ -171,13 +169,11 @@ void testInstall_WithConfigListsAndDefaultChronoUnit() { new AvajeValidatorModule().install(app); - // Verify list unpacking verify(builder).addResourceBundles("msg1"); verify(builder).addResourceBundles("msg2"); verify(builder).addLocales(Locale.forLanguageTag("es")); verify(builder).addLocales(Locale.forLanguageTag("it")); - // Verify ChronoUnit defaults to MILLIS verify(builder).temporalTolerance(Duration.of(50, ChronoUnit.MILLIS)); } } @@ -186,6 +182,10 @@ void testInstall_WithConfigListsAndDefaultChronoUnit() { void testInstall_WithModuleBuilderMethodsAndDisabledHandler() { when(config.hasPath(anyString())).thenReturn(false); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(registry.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + try (MockedStatic validatorMock = mockStatic(Validator.class)) { Validator.Builder builder = mock(Validator.Builder.class, Answers.RETURNS_SELF); Validator validator = mock(Validator.class); @@ -202,10 +202,8 @@ void testInstall_WithModuleBuilderMethodsAndDisabledHandler() { module.install(app); - // Verify the custom configurer was called verify(builder).failFast(false); - // Verify the default violation handler is bypassed completely verify(app, never()).error(any(ErrorHandler.class)); } } @@ -222,7 +220,6 @@ void testBeanValidatorImpl_Validate() { Object testBean = new Object(); beanValidator.validate(ctx, testBean); - // Verify it delegates to the underlying Avaje validator with the correct locale verify(validator).validate(testBean, Locale.CANADA); } } diff --git a/modules/jooby-avaje-validator/src/test/java/io/jooby/internal/avaje/validator/ConstraintViolationMapperTest.java b/modules/jooby-avaje-validator/src/test/java/io/jooby/internal/avaje/validator/ConstraintViolationMapperTest.java new file mode 100644 index 0000000000..e11f3fc15e --- /dev/null +++ b/modules/jooby-avaje-validator/src/test/java/io/jooby/internal/avaje/validator/ConstraintViolationMapperTest.java @@ -0,0 +1,84 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.avaje.validator; + +import static io.jooby.validation.ValidationResult.ErrorType.FIELD; +import static io.jooby.validation.ValidationResult.ErrorType.GLOBAL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.avaje.validation.ConstraintViolation; +import io.avaje.validation.ConstraintViolationException; +import io.jooby.StatusCode; +import io.jooby.validation.ValidationResult; + +@SuppressWarnings("unchecked") +class ConstraintViolationMapperTest { + + @Test + void shouldReturnNullForNonConstraintViolationExceptions() { + ConstraintViolationMapper mapper = + new ConstraintViolationMapper(StatusCode.UNPROCESSABLE_ENTITY, "Validation failed"); + + ValidationResult result = + mapper.toResult(StatusCode.BAD_REQUEST, new RuntimeException("Other error")); + + assertNull(result); + } + + @Test + void shouldMapGlobalAndFieldViolationsToValidationResult() { + ConstraintViolationMapper mapper = + new ConstraintViolationMapper(StatusCode.UNPROCESSABLE_ENTITY, "Validation failed"); + + ConstraintViolation globalViolation = mock(ConstraintViolation.class); + when(globalViolation.path()).thenReturn(""); + when(globalViolation.message()).thenReturn("Invalid configuration"); + + ConstraintViolation fieldViolation1 = mock(ConstraintViolation.class); + when(fieldViolation1.path()).thenReturn("user.email"); + when(fieldViolation1.message()).thenReturn("Email cannot be null"); + + ConstraintViolation fieldViolation2 = mock(ConstraintViolation.class); + when(fieldViolation2.path()).thenReturn("user.email"); + when(fieldViolation2.message()).thenReturn("Email must be valid"); + + ConstraintViolationException exception = mock(ConstraintViolationException.class); + + Set violations = Set.of(globalViolation, fieldViolation1, fieldViolation2); + when(exception.violations()).thenReturn(violations); + + ValidationResult result = mapper.toResult(StatusCode.BAD_REQUEST, exception); + + assertNotNull(result); + assertEquals("Validation failed", result.getTitle()); + assertEquals(StatusCode.UNPROCESSABLE_ENTITY.value(), result.getStatus()); + assertEquals(2, result.getErrors().size()); + + ValidationResult.Error globalError = + result.getErrors().stream().filter(e -> e.type() == GLOBAL).findFirst().orElseThrow(); + + assertNull(globalError.field()); + assertEquals(1, globalError.messages().size()); + assertTrue(globalError.messages().contains("Invalid configuration")); + + ValidationResult.Error fieldError = + result.getErrors().stream().filter(e -> e.type() == FIELD).findFirst().orElseThrow(); + + assertEquals("user.email", fieldError.field()); + assertEquals(2, fieldError.messages().size()); + assertTrue(fieldError.messages().contains("Email cannot be null")); + assertTrue(fieldError.messages().contains("Email must be valid")); + } +} 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 065ad36875..2f8e7de85e 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 @@ -5,22 +5,14 @@ */ package io.jooby.hibernate.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 org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.jooby.Context; import io.jooby.ErrorHandler; import io.jooby.StatusCode; +import io.jooby.internal.hibernate.validator.ConstraintViolationMapper; import io.jooby.validation.ValidationResult; -import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; /** @@ -56,17 +48,16 @@ * @since 3.3.1 */ public class ConstraintViolationHandler implements ErrorHandler { - private static final String ROOT_VIOLATIONS_PATH = ""; private final Logger log = LoggerFactory.getLogger(ConstraintViolationHandler.class); private final StatusCode statusCode; - private final String title; + private final ConstraintViolationMapper mapper; private final boolean logException; private final boolean problemDetailsEnabled; public ConstraintViolationHandler( StatusCode statusCode, String title, boolean logException, boolean problemDetailsEnabled) { this.statusCode = statusCode; - this.title = title; + this.mapper = new ConstraintViolationMapper(statusCode, title); this.logException = logException; this.problemDetailsEnabled = problemDetailsEnabled; } @@ -77,37 +68,11 @@ public void apply(Context ctx, Throwable cause, StatusCode code) { if (logException) { log.error(ErrorHandler.errorMessage(ctx, code), cause); } - var violations = ex.getConstraintViolations(); - - var groupedByPath = - violations.stream() - .collect(groupingBy(violation -> violation.getPropertyPath().toString())); - - var errors = collectErrors(groupedByPath); - - var result = new ValidationResult(title, statusCode.value(), errors); + var result = mapper.toResult(code, ex); renderOrPropagate(ctx, result, code); } } - private List collectErrors( - Map>> groupedViolations) { - List errors = new ArrayList<>(); - for (var 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::getMessage).toList(); - } - private void renderOrPropagate(Context ctx, ValidationResult result, StatusCode code) { if (problemDetailsEnabled) { ctx.getRouter().getErrorHandler().apply(ctx, result.toHttpProblem(), code); 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 ee4cef5d60..7c71138f04 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 @@ -15,7 +15,9 @@ import io.jooby.*; import io.jooby.internal.hibernate.validator.CompositeConstraintValidatorFactory; +import io.jooby.internal.hibernate.validator.ConstraintViolationMapper; import io.jooby.validation.BeanValidator; +import io.jooby.validation.ValidationExceptionMapper; import jakarta.validation.ConstraintValidatorFactory; import jakarta.validation.ConstraintViolationException; import jakarta.validation.Validator; @@ -158,6 +160,8 @@ public void install(Jooby app) throws Exception { var validator = factory.getValidator(); services.put(Validator.class, validator); services.put(BeanValidator.class, new BeanValidatorImpl(validator)); + var mapper = new ConstraintViolationMapper(statusCode, title); + services.listOf(ValidationExceptionMapper.class).add(mapper); // Allow to access validator factory so hibernate can access later var constraintValidatorFactory = factory.getConstraintValidatorFactory(); services.put(ConstraintValidatorFactory.class, constraintValidatorFactory); diff --git a/modules/jooby-hibernate-validator/src/main/java/io/jooby/internal/hibernate/validator/ConstraintViolationMapper.java b/modules/jooby-hibernate-validator/src/main/java/io/jooby/internal/hibernate/validator/ConstraintViolationMapper.java new file mode 100644 index 0000000000..d2005d35fb --- /dev/null +++ b/modules/jooby-hibernate-validator/src/main/java/io/jooby/internal/hibernate/validator/ConstraintViolationMapper.java @@ -0,0 +1,68 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.hibernate.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 org.jspecify.annotations.Nullable; + +import io.jooby.StatusCode; +import io.jooby.validation.ValidationExceptionMapper; +import io.jooby.validation.ValidationResult; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + +public class ConstraintViolationMapper implements ValidationExceptionMapper { + private static final String ROOT_VIOLATIONS_PATH = ""; + + private final String title; + + private final StatusCode statusCode; + + public ConstraintViolationMapper(StatusCode statusCode, String title) { + this.statusCode = statusCode; + this.title = title; + } + + @Override + public @Nullable ValidationResult toResult(StatusCode suggestedCode, Exception cause) { + if (cause instanceof ConstraintViolationException constraintViolation) { + var violations = constraintViolation.getConstraintViolations(); + var groupedByPath = + violations.stream() + .collect(groupingBy(violation -> violation.getPropertyPath().toString())); + + var errors = collectErrors(groupedByPath); + + return new ValidationResult(title, statusCode.value(), errors); + } + return null; + } + + private List collectErrors( + Map>> groupedViolations) { + List errors = new ArrayList<>(); + for (var 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::getMessage).toList(); + } +} diff --git a/modules/jooby-hibernate-validator/src/main/java/module-info.java b/modules/jooby-hibernate-validator/src/main/java/module-info.java index 3908671e92..0d37ee6c92 100644 --- a/modules/jooby-hibernate-validator/src/main/java/module-info.java +++ b/modules/jooby-hibernate-validator/src/main/java/module-info.java @@ -6,6 +6,7 @@ /** Hibernate Validator Module. */ module io.jooby.hibernate.validator { exports io.jooby.hibernate.validator; + exports io.jooby.internal.hibernate.validator; requires transitive io.jooby; requires static org.jspecify; diff --git a/modules/jooby-hibernate-validator/src/test/java/io/jooby/hibernate/validator/HibernateValidatorModuleTest.java b/modules/jooby-hibernate-validator/src/test/java/io/jooby/hibernate/validator/HibernateValidatorModuleTest.java index e6ace3de83..e9b143f584 100644 --- a/modules/jooby-hibernate-validator/src/test/java/io/jooby/hibernate/validator/HibernateValidatorModuleTest.java +++ b/modules/jooby-hibernate-validator/src/test/java/io/jooby/hibernate/validator/HibernateValidatorModuleTest.java @@ -27,7 +27,9 @@ import io.jooby.Jooby; import io.jooby.ServiceRegistry; import io.jooby.StatusCode; +import io.jooby.internal.hibernate.validator.ConstraintViolationMapper; import io.jooby.validation.BeanValidator; +import io.jooby.validation.ValidationExceptionMapper; import jakarta.validation.ConstraintValidatorFactory; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -45,30 +47,36 @@ void shouldInstallWithDefaults() throws Exception { when(app.getServices()).thenReturn(services); when(app.getConfig()).thenReturn(ConfigFactory.empty()); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(services.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + HibernateValidatorModule module = new HibernateValidatorModule(); module.install(app); - // Verify services bindings verify(services).put(eq(Validator.class), any(Validator.class)); verify(services).put(eq(BeanValidator.class), any(BeanValidator.class)); verify(services) .put(eq(ConstraintValidatorFactory.class), any(ConstraintValidatorFactory.class)); - // Verify default error handler is attached + verify(exceptionMappers).add(any(ConstraintViolationMapper.class)); + verify(app) .error(eq(ConstraintViolationException.class), any(ConstraintViolationHandler.class)); - // Verify application stop hook registers the factory close verify(app).onStop(any(AutoCloseable.class)); } @Test void shouldInstallWithConfigurationProperties() throws Exception { when(app.getServices()).thenReturn(services); - // Mimics the 'hibernate.validator' properties block var config = ConfigFactory.parseMap(Map.of("hibernate.validator.fail_fast", "true")); when(app.getConfig()).thenReturn(config); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(services.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + HibernateValidatorModule module = new HibernateValidatorModule(); module.install(app); @@ -80,12 +88,16 @@ void shouldApplyFluentSettersAndDisableDefaultHandler() throws Exception { when(app.getServices()).thenReturn(services); when(app.getConfig()).thenReturn(ConfigFactory.empty()); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(services.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + HibernateValidatorModule module = new HibernateValidatorModule() .statusCode(StatusCode.BAD_REQUEST) .validationTitle("Custom Validation Title") .logException() - .disableViolationHandler(); // This causes the error registration to be skipped + .disableViolationHandler(); module.install(app); } @@ -95,10 +107,13 @@ void shouldAcceptCustomConstraintValidatorFactories() throws Exception { when(app.getServices()).thenReturn(services); when(app.getConfig()).thenReturn(ConfigFactory.empty()); + ServiceRegistry.MultiBinder exceptionMappers = + mock(ServiceRegistry.MultiBinder.class); + when(services.listOf(ValidationExceptionMapper.class)).thenReturn(exceptionMappers); + ConstraintValidatorFactory customFactory1 = mock(ConstraintValidatorFactory.class); ConstraintValidatorFactory customFactory2 = mock(ConstraintValidatorFactory.class); - // Chaining hits both `factories == null` and `factories != null` internal list initialization HibernateValidatorModule module = new HibernateValidatorModule().with(customFactory1).with(customFactory2); @@ -119,7 +134,6 @@ void beanValidatorImplShouldNotThrowOnEmptyViolations() { HibernateValidatorModule.BeanValidatorImpl beanValidator = new HibernateValidatorModule.BeanValidatorImpl(mockValidator); - // Assert that no exceptions are thrown when validation succeeds assertDoesNotThrow(() -> beanValidator.validate(mockCtx, testBean)); } @@ -135,7 +149,6 @@ void beanValidatorImplShouldThrowOnViolations() { HibernateValidatorModule.BeanValidatorImpl beanValidator = new HibernateValidatorModule.BeanValidatorImpl(mockValidator); - // Assert that it bubbles up the ConstraintViolationException assertThrows( ConstraintViolationException.class, () -> beanValidator.validate(mockCtx, testBean)); } diff --git a/modules/jooby-hibernate-validator/src/test/java/io/jooby/internal/hibernate/validator/ConstraintViolationMapperTest.java b/modules/jooby-hibernate-validator/src/test/java/io/jooby/internal/hibernate/validator/ConstraintViolationMapperTest.java new file mode 100644 index 0000000000..953b757192 --- /dev/null +++ b/modules/jooby-hibernate-validator/src/test/java/io/jooby/internal/hibernate/validator/ConstraintViolationMapperTest.java @@ -0,0 +1,96 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.hibernate.validator; + +import static io.jooby.validation.ValidationResult.ErrorType.FIELD; +import static io.jooby.validation.ValidationResult.ErrorType.GLOBAL; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.jooby.StatusCode; +import io.jooby.validation.ValidationResult; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Path; + +@SuppressWarnings("unchecked") +class ConstraintViolationMapperTest { + + @Test + void shouldReturnNullForNonConstraintViolationExceptions() { + ConstraintViolationMapper mapper = + new ConstraintViolationMapper(StatusCode.UNPROCESSABLE_ENTITY, "Validation failed"); + + ValidationResult result = + mapper.toResult(StatusCode.BAD_REQUEST, new RuntimeException("Other error")); + + assertNull(result); + } + + @Test + void shouldMapGlobalAndFieldViolationsToValidationResult() { + ConstraintViolationMapper mapper = + new ConstraintViolationMapper(StatusCode.UNPROCESSABLE_ENTITY, "Validation failed"); + + // Mock Paths + Path rootPath = mock(Path.class); + when(rootPath.toString()).thenReturn(""); + + Path fieldPath = mock(Path.class); + when(fieldPath.toString()).thenReturn("user.email"); + + // Mock Violations + ConstraintViolation globalViolation = mock(ConstraintViolation.class); + when(globalViolation.getPropertyPath()).thenReturn(rootPath); + when(globalViolation.getMessage()).thenReturn("Invalid configuration"); + + ConstraintViolation fieldViolation1 = mock(ConstraintViolation.class); + when(fieldViolation1.getPropertyPath()).thenReturn(fieldPath); + when(fieldViolation1.getMessage()).thenReturn("Email cannot be null"); + + ConstraintViolation fieldViolation2 = mock(ConstraintViolation.class); + when(fieldViolation2.getPropertyPath()).thenReturn(fieldPath); + when(fieldViolation2.getMessage()).thenReturn("Email must be valid"); + + // Create Exception with Violations + Set> violations = + Set.of(globalViolation, fieldViolation1, fieldViolation2); + ConstraintViolationException exception = new ConstraintViolationException(violations); + + // Execute + ValidationResult result = mapper.toResult(StatusCode.BAD_REQUEST, exception); + + // Verify Root Properties + assertNotNull(result); + assertEquals("Validation failed", result.getTitle()); + assertEquals(StatusCode.UNPROCESSABLE_ENTITY.value(), result.getStatus()); + assertEquals(2, result.getErrors().size()); + + // Verify Error mapping logic + ValidationResult.Error globalError = + result.getErrors().stream().filter(e -> e.type() == GLOBAL).findFirst().orElseThrow(); + + assertNull(globalError.field()); + assertEquals(1, globalError.messages().size()); + assertTrue(globalError.messages().contains("Invalid configuration")); + + ValidationResult.Error fieldError = + result.getErrors().stream().filter(e -> e.type() == FIELD).findFirst().orElseThrow(); + + assertEquals("user.email", fieldError.field()); + assertEquals(2, fieldError.messages().size()); + assertTrue(fieldError.messages().contains("Email cannot be null")); + assertTrue(fieldError.messages().contains("Email must be valid")); + } +}