Skip to content

Commit 66c24da

Browse files
committed
feature: pluggable exception mapping to ValidationResult for Business Logic
- fix #3937
1 parent 428ab85 commit 66c24da

18 files changed

Lines changed: 661 additions & 112 deletions

File tree

jooby/src/main/java/io/jooby/ModelAndView.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,27 @@ public static MapModelAndView map(String view, Map<String, Object> model) {
7777
return new MapModelAndView(view, model);
7878
}
7979

80+
/**
81+
* Creates a model and view based on the provided view name and model. If the model is null, a
82+
* map-based model and view is created. If the model is an instance of {@code Map}, a map-based
83+
* model and view is created using the provided map. Otherwise, a generic model and view is
84+
* created with the specified view name and model.
85+
*
86+
* @param view The name of the view, which may include a file extension.
87+
* @param model The data model to be associated with the view. This can be null, a {@code Map}, or
88+
* any other object.
89+
* @return A {@code ModelAndView} instance corresponding to the specified view and model.
90+
*/
91+
public static ModelAndView<Map<String, Object>> of(String view, Object model) {
92+
if (model == null) {
93+
return map(view);
94+
}
95+
if (model instanceof Map mapModel) {
96+
return map(view, mapModel);
97+
}
98+
return new ModelAndView(view, model);
99+
}
100+
80101
/**
81102
* Sets the locale used when rendering the view, if the template engine supports setting it.
82103
* Specifying {@code null} triggers a fallback to a locale determined by the current request.

jooby/src/main/java/io/jooby/internal/RouterImpl.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import io.jooby.internal.handler.WebSocketHandler;
4444
import io.jooby.output.OutputFactory;
4545
import io.jooby.problem.ProblemDetailsHandler;
46+
import io.jooby.validation.ValidationExceptionMapper;
4647
import io.jooby.value.ValueFactory;
4748

4849
public class RouterImpl implements Router {
@@ -551,6 +552,15 @@ public Router start(Jooby app) {
551552
} else {
552553
err = err.then(globalErrHandler);
553554
}
555+
// Validation mapper
556+
var services = app.getServices();
557+
List<ValidationExceptionMapper> validationExceptionMappers =
558+
services.getOrNull(Reified.list(ValidationExceptionMapper.class));
559+
var validationExceptionChain = new ValidationExceptionChain();
560+
if (validationExceptionMappers != null) {
561+
validationExceptionMappers.forEach(validationExceptionChain::add);
562+
}
563+
services.put(ValidationExceptionMapper.class, validationExceptionChain);
554564

555565
ExecutionMode mode = app.getExecutionMode();
556566
for (Route route : routes) {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal;
7+
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.Optional;
11+
12+
import org.jspecify.annotations.NonNull;
13+
14+
import io.jooby.SneakyThrows;
15+
import io.jooby.StatusCode;
16+
import io.jooby.validation.ValidationExceptionMapper;
17+
import io.jooby.validation.ValidationResult;
18+
19+
/**
20+
* ValidationExceptionChain provides a way to combine multiple {@link ValidationExceptionMapper}
21+
* implementations into a single chain. This allows sequential delegation of validation exception
22+
* mapping to the contained mappers.
23+
*
24+
* <p>The chain processes exceptions by iterating over the registered mappers. Each mapper attempts
25+
* to convert the given exception into a {@link ValidationResult}. The first non-null result found
26+
* is returned. If none of the mappers produce a result, a default {@link ValidationResult} is
27+
* generated with a global error indicating validation failure.
28+
*
29+
* <p>This class is useful in scenarios where different exception mapping strategies are needed and
30+
* should be applied in a specific sequence.
31+
*
32+
* @author edgar
33+
* @since 4.5.0
34+
*/
35+
public class ValidationExceptionChain implements ValidationExceptionMapper {
36+
private final List<ValidationExceptionMapper> mappers = new ArrayList<>();
37+
38+
/**
39+
* Adds a {@link ValidationExceptionMapper} to the chain.
40+
*
41+
* <p>This method allows the registration of a new mapper, which will be used in sequence for
42+
* exception mapping. The newly added mapper will be appended to the chain, maintaining the order
43+
* of insertion.
44+
*
45+
* @param mapper the {@link ValidationExceptionMapper} to be added to the chain
46+
* @return the current {@link ValidationExceptionChain} instance to allow for method chaining
47+
*/
48+
public ValidationExceptionChain add(ValidationExceptionMapper mapper) {
49+
mappers.add(mapper);
50+
return this;
51+
}
52+
53+
/**
54+
* Converts the given {@link StatusCode} and {@link Exception} into a {@link ValidationResult}.
55+
*
56+
* <p>This method iterates through the chain of registered {@link ValidationExceptionMapper}
57+
* instances. Each mapper attempts to produce a {@link ValidationResult} for the specified status
58+
* code and exception. If a non-null result is produced, it is returned immediately. If no mapper
59+
* produces a valid result, a default {@link ValidationResult} is returned indicating a global
60+
* validation failure.
61+
*
62+
* @param suggestedCode the status code associated with the exception
63+
* @param cause the exception that needs to be converted into a validation result
64+
* @return the converted {@link ValidationResult} from the first applicable mapper, or a default
65+
* result if no mapper can process the exception
66+
*/
67+
@Override
68+
public @NonNull ValidationResult toResult(StatusCode suggestedCode, Exception cause) {
69+
for (var mapper : mappers) {
70+
var result = mapper.toResult(suggestedCode, cause);
71+
if (result != null) {
72+
return result;
73+
}
74+
}
75+
if (suggestedCode.value() >= 500) {
76+
throw SneakyThrows.propagate(cause);
77+
}
78+
// Assume is a client error, provide a default result
79+
return new ValidationResult(
80+
"Validation failed",
81+
suggestedCode.value(),
82+
List.of(
83+
new ValidationResult.Error(
84+
null,
85+
List.of(
86+
Optional.ofNullable(cause.getMessage())
87+
.orElse(cause.getClass().getSimpleName())),
88+
ValidationResult.ErrorType.GLOBAL)));
89+
}
90+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.validation;
7+
8+
import org.jspecify.annotations.Nullable;
9+
10+
import io.jooby.StatusCode;
11+
12+
/**
13+
* This interface defines a contract for mapping exceptions to validation results. It is primarily
14+
* used to convert exceptions, such as those thrown during bean validation, into instances of {@link
15+
* ValidationResult}. This allows for a consistent representation of validation errors across the
16+
* application.
17+
*
18+
* <p>Implementers are responsible for interpreting the given exception and translating it into an
19+
* appropriate {@link ValidationResult}, which may encapsulate details such as error messages,
20+
* status codes, and specific fields that failed validation.
21+
*
22+
* @author edgar
23+
* @since 4.5.0
24+
*/
25+
@FunctionalInterface
26+
public interface ValidationExceptionMapper {
27+
28+
/**
29+
* Converts the provided exception into a {@link ValidationResult}. This method interprets the
30+
* given exception, typically from a validation process, and maps it into a {@link
31+
* ValidationResult} instance, encapsulating details such as validation errors and status
32+
* information.
33+
*
34+
* @param suggestedCode the suggested status code for the validation result. Usually overriden
35+
* with {@link StatusCode#UNPROCESSABLE_ENTITY}.
36+
* @param cause the exception to be mapped to a {@link ValidationResult}.
37+
* @return a {@link ValidationResult} representing the mapped exception.
38+
*/
39+
@Nullable ValidationResult toResult(StatusCode suggestedCode, Exception cause);
40+
}

jooby/src/test/java/io/jooby/MapModelAndViewTest.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
*/
66
package io.jooby;
77

8-
import static org.junit.jupiter.api.Assertions.assertEquals;
9-
import static org.junit.jupiter.api.Assertions.assertSame;
10-
import static org.junit.jupiter.api.Assertions.assertTrue;
8+
import static org.junit.jupiter.api.Assertions.*;
119

1210
import java.util.HashMap;
1311
import java.util.Locale;
@@ -80,4 +78,19 @@ void testSetLocale() {
8078
assertSame(mav, result, "setLocale should return the current instance for fluent chaining");
8179
assertEquals(locale, mav.getLocale());
8280
}
81+
82+
@Test
83+
void testOfWithNullModel() {
84+
assertInstanceOf(MapModelAndView.class, ModelAndView.of("index.html", null));
85+
}
86+
87+
@Test
88+
void testOfWithMapModel() {
89+
assertInstanceOf(MapModelAndView.class, ModelAndView.of("index.html", Map.of()));
90+
}
91+
92+
@Test
93+
void testOfWithBeanModel() {
94+
assertInstanceOf(ModelAndView.class, ModelAndView.of("index.html", new Object()));
95+
}
8396
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
import static org.junit.jupiter.api.Assertions.assertNotNull;
10+
import static org.junit.jupiter.api.Assertions.assertNull;
11+
import static org.junit.jupiter.api.Assertions.assertSame;
12+
import static org.junit.jupiter.api.Assertions.assertThrows;
13+
import static org.mockito.Mockito.mock;
14+
import static org.mockito.Mockito.verify;
15+
import static org.mockito.Mockito.verifyNoInteractions;
16+
import static org.mockito.Mockito.when;
17+
18+
import java.util.Collections;
19+
import java.util.List;
20+
21+
import org.junit.jupiter.api.Test;
22+
23+
import io.jooby.StatusCode;
24+
import io.jooby.validation.ValidationExceptionMapper;
25+
import io.jooby.validation.ValidationResult;
26+
27+
class ValidationExceptionChainTest {
28+
29+
@Test
30+
void shouldReturnResultFromFirstApplicableMapper() {
31+
ValidationExceptionChain chain = new ValidationExceptionChain();
32+
33+
ValidationExceptionMapper mapper1 = mock(ValidationExceptionMapper.class);
34+
ValidationExceptionMapper mapper2 = mock(ValidationExceptionMapper.class);
35+
ValidationExceptionMapper mapper3 = mock(ValidationExceptionMapper.class);
36+
37+
Exception cause = new RuntimeException("Test error");
38+
ValidationResult expectedResult =
39+
new ValidationResult("Custom title", 422, Collections.emptyList());
40+
41+
// Mapper 1 returns null (cannot handle)
42+
when(mapper1.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause)).thenReturn(null);
43+
// Mapper 2 returns a valid result
44+
when(mapper2.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause)).thenReturn(expectedResult);
45+
46+
// Chaining add methods
47+
chain.add(mapper1).add(mapper2).add(mapper3);
48+
49+
ValidationResult result = chain.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause);
50+
51+
assertSame(expectedResult, result);
52+
verify(mapper1).toResult(StatusCode.UNPROCESSABLE_ENTITY, cause);
53+
verify(mapper2).toResult(StatusCode.UNPROCESSABLE_ENTITY, cause);
54+
// Mapper 3 should never be called since Mapper 2 handled it
55+
verifyNoInteractions(mapper3);
56+
}
57+
58+
@Test
59+
void shouldReturnDefaultResultWhenNoMapperHandlesItAndStatusCodeIsClientError() {
60+
ValidationExceptionChain chain = new ValidationExceptionChain();
61+
Exception cause = new IllegalArgumentException("Invalid input provided");
62+
63+
ValidationResult result = chain.toResult(StatusCode.BAD_REQUEST, cause);
64+
65+
assertNotNull(result);
66+
assertEquals("Validation failed", result.getTitle());
67+
assertEquals(400, result.getStatus());
68+
69+
assertEquals(1, result.getErrors().size());
70+
ValidationResult.Error error = result.getErrors().get(0);
71+
assertNull(error.field());
72+
assertEquals(ValidationResult.ErrorType.GLOBAL, error.type());
73+
assertEquals(List.of("Invalid input provided"), error.messages());
74+
}
75+
76+
@Test
77+
void shouldReturnDefaultResultUsingClassNameWhenExceptionHasNoMessage() {
78+
ValidationExceptionChain chain = new ValidationExceptionChain();
79+
// Exception without a message
80+
Exception cause = new NullPointerException();
81+
82+
ValidationResult result = chain.toResult(StatusCode.BAD_REQUEST, cause);
83+
84+
assertNotNull(result);
85+
assertEquals("Validation failed", result.getTitle());
86+
assertEquals(400, result.getStatus());
87+
88+
assertEquals(1, result.getErrors().size());
89+
ValidationResult.Error error = result.getErrors().get(0);
90+
assertNull(error.field());
91+
assertEquals(ValidationResult.ErrorType.GLOBAL, error.type());
92+
// Fallback to the class simple name
93+
assertEquals(List.of("NullPointerException"), error.messages());
94+
}
95+
96+
@Test
97+
void shouldPropagateExceptionWhenStatusCodeIsServerError() {
98+
ValidationExceptionChain chain = new ValidationExceptionChain();
99+
Exception cause = new IllegalStateException("Database connection failed");
100+
101+
// >= 500 status code triggers the throw
102+
IllegalStateException thrown =
103+
assertThrows(
104+
IllegalStateException.class, () -> chain.toResult(StatusCode.SERVER_ERROR, cause));
105+
106+
assertEquals("Database connection failed", thrown.getMessage());
107+
}
108+
}

modules/jooby-avaje-validator/src/main/java/io/jooby/avaje/validator/AvajeValidatorModule.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
import io.jooby.Extension;
2222
import io.jooby.Jooby;
2323
import io.jooby.StatusCode;
24+
import io.jooby.internal.avaje.validator.ConstraintViolationMapper;
2425
import io.jooby.validation.BeanValidator;
26+
import io.jooby.validation.ValidationExceptionMapper;
2527

2628
/**
2729
* Avaje Validator Module: https://jooby.io/modules/avaje-validator.
@@ -157,9 +159,13 @@ public void install(Jooby app) {
157159
configurer.accept(builder);
158160
}
159161

162+
var services = app.getServices();
160163
var validator = builder.build();
161-
app.getServices().put(Validator.class, validator);
162-
app.getServices().put(BeanValidator.class, new BeanValidatorImpl(validator));
164+
services.put(Validator.class, validator);
165+
services.put(BeanValidator.class, new BeanValidatorImpl(validator));
166+
services
167+
.listOf(ValidationExceptionMapper.class)
168+
.add(new ConstraintViolationMapper(statusCode, title));
163169

164170
if (!disableDefaultViolationHandler) {
165171
app.error(

0 commit comments

Comments
 (0)