Skip to content

Commit f2f9209

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

16 files changed

Lines changed: 537 additions & 109 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+
}

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(

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

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@
55
*/
66
package io.jooby.avaje.validator;
77

8-
import static io.jooby.validation.ValidationResult.ErrorType.FIELD;
9-
import static io.jooby.validation.ValidationResult.ErrorType.GLOBAL;
10-
import static java.util.stream.Collectors.groupingBy;
11-
12-
import java.util.ArrayList;
13-
import java.util.List;
14-
import java.util.Map;
15-
168
import org.slf4j.Logger;
179
import org.slf4j.LoggerFactory;
1810

19-
import io.avaje.validation.ConstraintViolation;
2011
import io.avaje.validation.ConstraintViolationException;
2112
import io.jooby.Context;
2213
import io.jooby.ErrorHandler;
2314
import io.jooby.StatusCode;
15+
import io.jooby.internal.avaje.validator.ConstraintViolationMapper;
16+
import io.jooby.validation.ValidationExceptionMapper;
2417
import io.jooby.validation.ValidationResult;
2518

2619
/**
@@ -56,17 +49,16 @@
5649
* @since 3.2.10
5750
*/
5851
public class ConstraintViolationHandler implements ErrorHandler {
59-
private static final String ROOT_VIOLATIONS_PATH = "";
6052
private final Logger log = LoggerFactory.getLogger(getClass());
6153
private final StatusCode statusCode;
62-
private final String title;
54+
private final ValidationExceptionMapper mapper;
6355
private final boolean logException;
6456
private final boolean problemDetailsEnabled;
6557

6658
public ConstraintViolationHandler(
6759
StatusCode statusCode, String title, boolean logException, boolean problemDetailsEnabled) {
6860
this.statusCode = statusCode;
69-
this.title = title;
61+
this.mapper = new ConstraintViolationMapper(statusCode, title);
7062
this.logException = logException;
7163
this.problemDetailsEnabled = problemDetailsEnabled;
7264
}
@@ -77,34 +69,11 @@ public void apply(Context ctx, Throwable cause, StatusCode code) {
7769
if (logException) {
7870
log.error(ErrorHandler.errorMessage(ctx, code), cause);
7971
}
80-
var violations = ex.violations();
81-
82-
var groupedByPath = violations.stream().collect(groupingBy(ConstraintViolation::path));
83-
var errors = collectErrors(groupedByPath);
84-
85-
var result = new ValidationResult(title, statusCode.value(), errors);
72+
var result = mapper.toResult(code, ex);
8673
renderOrPropagate(ctx, result, code);
8774
}
8875
}
8976

90-
private List<ValidationResult.Error> collectErrors(
91-
Map<String, List<ConstraintViolation>> groupedViolations) {
92-
List<ValidationResult.Error> errors = new ArrayList<>();
93-
for (var entry : groupedViolations.entrySet()) {
94-
var path = entry.getKey();
95-
if (ROOT_VIOLATIONS_PATH.equals(path)) {
96-
errors.add(new ValidationResult.Error(null, extractMessages(entry.getValue()), GLOBAL));
97-
} else {
98-
errors.add(new ValidationResult.Error(path, extractMessages(entry.getValue()), FIELD));
99-
}
100-
}
101-
return errors;
102-
}
103-
104-
private List<String> extractMessages(List<ConstraintViolation> violations) {
105-
return violations.stream().map(ConstraintViolation::message).toList();
106-
}
107-
10877
private void renderOrPropagate(Context ctx, ValidationResult result, StatusCode code) {
10978
if (problemDetailsEnabled) {
11079
ctx.getRouter().getErrorHandler().apply(ctx, result.toHttpProblem(), code);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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.avaje.validator;
7+
8+
import static io.jooby.validation.ValidationResult.ErrorType.FIELD;
9+
import static io.jooby.validation.ValidationResult.ErrorType.GLOBAL;
10+
import static java.util.stream.Collectors.groupingBy;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
import io.avaje.validation.ConstraintViolation;
17+
import io.avaje.validation.ConstraintViolationException;
18+
import io.jooby.StatusCode;
19+
import io.jooby.validation.ValidationExceptionMapper;
20+
import io.jooby.validation.ValidationResult;
21+
22+
public class ConstraintViolationMapper implements ValidationExceptionMapper {
23+
private static final String ROOT_VIOLATIONS_PATH = "";
24+
25+
private final StatusCode statusCode;
26+
private final String title;
27+
28+
public ConstraintViolationMapper(StatusCode statusCode, String title) {
29+
this.statusCode = statusCode;
30+
this.title = title;
31+
}
32+
33+
@Override
34+
public ValidationResult toResult(StatusCode suggestedCode, Exception cause) {
35+
if (cause instanceof ConstraintViolationException constraintViolationException) {
36+
var violations = constraintViolationException.violations();
37+
38+
var groupedByPath = violations.stream().collect(groupingBy(ConstraintViolation::path));
39+
var errors = collectErrors(groupedByPath);
40+
41+
return new ValidationResult(title, statusCode.value(), errors);
42+
}
43+
return null;
44+
}
45+
46+
private List<ValidationResult.Error> collectErrors(
47+
Map<String, List<ConstraintViolation>> groupedViolations) {
48+
List<ValidationResult.Error> errors = new ArrayList<>();
49+
for (var entry : groupedViolations.entrySet()) {
50+
var path = entry.getKey();
51+
if (ROOT_VIOLATIONS_PATH.equals(path)) {
52+
errors.add(new ValidationResult.Error(null, extractMessages(entry.getValue()), GLOBAL));
53+
} else {
54+
errors.add(new ValidationResult.Error(path, extractMessages(entry.getValue()), FIELD));
55+
}
56+
}
57+
return errors;
58+
}
59+
60+
private List<String> extractMessages(List<ConstraintViolation> violations) {
61+
return violations.stream().map(ConstraintViolation::message).toList();
62+
}
63+
}

modules/jooby-avaje-validator/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
/** Avaje Validator Module. */
77
module io.jooby.avaje.validator {
88
exports io.jooby.avaje.validator;
9+
exports io.jooby.internal.avaje.validator;
910

1011
requires transitive io.jooby;
1112
requires static org.jspecify;

0 commit comments

Comments
 (0)