Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions jooby/src/main/java/io/jooby/ModelAndView.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ public static MapModelAndView map(String view, Map<String, Object> 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<Map<String, Object>> 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.
Expand Down
10 changes: 10 additions & 0 deletions jooby/src/main/java/io/jooby/internal/RouterImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -551,6 +552,15 @@ public Router start(Jooby app) {
} else {
err = err.then(globalErrHandler);
}
// Validation mapper
var services = app.getServices();
List<ValidationExceptionMapper> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<ValidationExceptionMapper> mappers = new ArrayList<>();

/**
* Adds a {@link ValidationExceptionMapper} to the chain.
*
* <p>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}.
*
* <p>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)));
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
19 changes: 16 additions & 3 deletions jooby/src/test/java/io/jooby/MapModelAndViewTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 2 additions & 0 deletions modules/jooby-apt/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
generated
generated_tests
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading