Skip to content

Commit d45e134

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

18 files changed

Lines changed: 656 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.Nullable;
13+
14+
import io.jooby.StatusCode;
15+
import io.jooby.validation.ValidationExceptionMapper;
16+
import io.jooby.validation.ValidationResult;
17+
18+
/**
19+
* ValidationExceptionChain provides a way to combine multiple {@link ValidationExceptionMapper}
20+
* implementations into a single chain. This allows sequential delegation of validation exception
21+
* mapping to the contained mappers.
22+
*
23+
* <p>The chain processes exceptions by iterating over the registered mappers. Each mapper attempts
24+
* to convert the given exception into a {@link ValidationResult}. The first non-null result found
25+
* is returned. If none of the mappers produce a result, a default {@link ValidationResult} is
26+
* generated with a global error indicating validation failure.
27+
*
28+
* <p>This class is useful in scenarios where different exception mapping strategies are needed and
29+
* should be applied in a specific sequence.
30+
*
31+
* @author edgar
32+
* @since 4.5.0
33+
*/
34+
public class ValidationExceptionChain implements ValidationExceptionMapper {
35+
private final List<ValidationExceptionMapper> mappers = new ArrayList<>();
36+
37+
/**
38+
* Adds a {@link ValidationExceptionMapper} to the chain.
39+
*
40+
* <p>This method allows the registration of a new mapper, which will be used in sequence for
41+
* exception mapping. The newly added mapper will be appended to the chain, maintaining the order
42+
* of insertion.
43+
*
44+
* @param mapper the {@link ValidationExceptionMapper} to be added to the chain
45+
* @return the current {@link ValidationExceptionChain} instance to allow for method chaining
46+
*/
47+
public ValidationExceptionChain add(ValidationExceptionMapper mapper) {
48+
mappers.add(mapper);
49+
return this;
50+
}
51+
52+
/**
53+
* Converts the given {@link StatusCode} and {@link Exception} into a {@link ValidationResult}.
54+
*
55+
* <p>This method iterates through the chain of registered {@link ValidationExceptionMapper}
56+
* instances. Each mapper attempts to produce a {@link ValidationResult} for the specified status
57+
* code and exception. If a non-null result is produced, it is returned immediately. If no mapper
58+
* produces a valid result, a default {@link ValidationResult} is returned indicating a global
59+
* validation failure.
60+
*
61+
* @param suggestedCode the status code associated with the exception
62+
* @param cause the exception that needs to be converted into a validation result
63+
* @return the converted {@link ValidationResult} from the first applicable mapper, or a default
64+
* result if no mapper can process the exception
65+
*/
66+
@Override
67+
public @Nullable ValidationResult toResult(StatusCode suggestedCode, Exception cause) {
68+
for (var mapper : mappers) {
69+
var result = mapper.toResult(suggestedCode, cause);
70+
if (result != null) {
71+
return result;
72+
}
73+
}
74+
if (suggestedCode.value() >= 500) {
75+
// Not handled
76+
return null;
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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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.mockito.Mockito.mock;
13+
import static org.mockito.Mockito.verify;
14+
import static org.mockito.Mockito.verifyNoInteractions;
15+
import static org.mockito.Mockito.when;
16+
17+
import java.util.Collections;
18+
import java.util.List;
19+
20+
import org.junit.jupiter.api.Test;
21+
22+
import io.jooby.StatusCode;
23+
import io.jooby.validation.ValidationExceptionMapper;
24+
import io.jooby.validation.ValidationResult;
25+
26+
class ValidationExceptionChainTest {
27+
28+
@Test
29+
void shouldReturnResultFromFirstApplicableMapper() {
30+
ValidationExceptionChain chain = new ValidationExceptionChain();
31+
32+
ValidationExceptionMapper mapper1 = mock(ValidationExceptionMapper.class);
33+
ValidationExceptionMapper mapper2 = mock(ValidationExceptionMapper.class);
34+
ValidationExceptionMapper mapper3 = mock(ValidationExceptionMapper.class);
35+
36+
Exception cause = new RuntimeException("Test error");
37+
ValidationResult expectedResult =
38+
new ValidationResult("Custom title", 422, Collections.emptyList());
39+
40+
// Mapper 1 returns null (cannot handle)
41+
when(mapper1.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause)).thenReturn(null);
42+
// Mapper 2 returns a valid result
43+
when(mapper2.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause)).thenReturn(expectedResult);
44+
45+
// Chaining add methods
46+
chain.add(mapper1).add(mapper2).add(mapper3);
47+
48+
ValidationResult result = chain.toResult(StatusCode.UNPROCESSABLE_ENTITY, cause);
49+
50+
assertSame(expectedResult, result);
51+
verify(mapper1).toResult(StatusCode.UNPROCESSABLE_ENTITY, cause);
52+
verify(mapper2).toResult(StatusCode.UNPROCESSABLE_ENTITY, cause);
53+
// Mapper 3 should never be called since Mapper 2 handled it
54+
verifyNoInteractions(mapper3);
55+
}
56+
57+
@Test
58+
void shouldReturnDefaultResultWhenNoMapperHandlesItAndStatusCodeIsClientError() {
59+
ValidationExceptionChain chain = new ValidationExceptionChain();
60+
Exception cause = new IllegalArgumentException("Invalid input provided");
61+
62+
ValidationResult result = chain.toResult(StatusCode.BAD_REQUEST, cause);
63+
64+
assertNotNull(result);
65+
assertEquals("Validation failed", result.getTitle());
66+
assertEquals(400, result.getStatus());
67+
68+
assertEquals(1, result.getErrors().size());
69+
ValidationResult.Error error = result.getErrors().get(0);
70+
assertNull(error.field());
71+
assertEquals(ValidationResult.ErrorType.GLOBAL, error.type());
72+
assertEquals(List.of("Invalid input provided"), error.messages());
73+
}
74+
75+
@Test
76+
void shouldReturnDefaultResultUsingClassNameWhenExceptionHasNoMessage() {
77+
ValidationExceptionChain chain = new ValidationExceptionChain();
78+
// Exception without a message
79+
Exception cause = new NullPointerException();
80+
81+
ValidationResult result = chain.toResult(StatusCode.BAD_REQUEST, cause);
82+
83+
assertNotNull(result);
84+
assertEquals("Validation failed", result.getTitle());
85+
assertEquals(400, result.getStatus());
86+
87+
assertEquals(1, result.getErrors().size());
88+
ValidationResult.Error error = result.getErrors().get(0);
89+
assertNull(error.field());
90+
assertEquals(ValidationResult.ErrorType.GLOBAL, error.type());
91+
// Fallback to the class simple name
92+
assertEquals(List.of("NullPointerException"), error.messages());
93+
}
94+
95+
@Test
96+
void shouldReturnNullWhenStatusCodeIsServerError() {
97+
ValidationExceptionChain chain = new ValidationExceptionChain();
98+
Exception cause = new IllegalStateException("Database connection failed");
99+
100+
// >= 500 status code
101+
assertNull(chain.toResult(StatusCode.SERVER_ERROR, cause));
102+
}
103+
}

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)