Skip to content

HandlerMethodValidator should support simple Cross-Parameter constraints #33271

Closed
@jmax01

Description

@jmax01

It seems sensible that the HandlerMethodValidator should directly handle Cross-Parameter constraints or at a minimum not throw an IllegalArgumentException.

Similarly to @Valid annotations on @RequestBody arguments, Cross-Parameter constraints are applied only if another argument of the method has an @Constriant declared.

Unfortunately, unless the ConstraintValidatorContext of the ConstraintValidator.isValid method is manipulated with a new ConstraintViolation that has a node kind in its path that MethodValidationAdapter.adaptViolations handles (PROPERTY, RETURN_VALUE, PARAMETER) an exception is thrown:

	at org.springframework.util.Assert.notEmpty(Assert.java:381)
	at org.springframework.validation.method.DefaultMethodValidationResult.<init>(DefaultMethodValidationResult.java:42)
	at org.springframework.validation.method.MethodValidationResult.create(MethodValidationResult.java:115)
	at org.springframework.validation.beanvalidation.MethodValidationAdapter.adaptViolations(MethodValidationAdapter.java:384)
	at org.springframework.validation.beanvalidation.MethodValidationAdapter.validateArguments(MethodValidationAdapter.java:246)
	at org.springframework.web.method.annotation.HandlerMethodValidator.validateArguments(HandlerMethodValidator.java:115)
	at org.springframework.web.method.annotation.HandlerMethodValidator.applyArgumentValidation(HandlerMethodValidator.java:83)
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:184)
	at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831)
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
	... 82 more

Here is a spring-boot-based example (apologies for not having time to do a 'pure' spring-framework one without security)

package org.springframework.gh33271;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.*;
import static org.springframework.http.MediaType.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.test.context.TestConstructor.AutowireMode.ALL;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraintvalidation.SupportedValidationTarget;
import jakarta.validation.constraintvalidation.ValidationTarget;
import lombok.AllArgsConstructor;
import lombok.Value;
import lombok.experimental.Accessors;
import lombok.experimental.NonFinal;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.gh33271.Gh33271Test.CrossParameterSimpleExample.CrossParameterSimpleExampleValidator;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.TestConstructor;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@WebMvcTest(controllers = { org.springframework.gh33271.Gh33271Test.Config.SimpleController.class })
@TestConstructor(autowireMode = ALL)
@AllArgsConstructor
class Gh33271Test {

    MockMvc mockMvc;

    @WithMockUser(value = "spring")
    @Test
    void testCrossParam() throws Exception {

        this.mockMvc
                .perform(get("/has/cross/param/simple/{pathTestString}", "path-test-string")
                        .queryParam("queryParmTestString", "query-param-test-string")
                        .with(jwt()))
                .andDo(print())
                .andReturn();

    }

    @Configuration
    @EnableAutoConfiguration
    @Value
    @NonFinal
    @Accessors(fluent = true)
    @ImportAutoConfiguration({ MockMvcAutoConfiguration.class })
    static class Config {

        @RestController
        @Value
        @NonFinal
        public static class SimpleController {

            @SuppressWarnings("static-method")
            @GetMapping(path = { "/has/cross/param/simple/{pathTestString}" }, produces = TEXT_PLAIN_VALUE)
            @CrossParameterSimpleExample
            String hasCrossParamSimple(@SuppressWarnings("unused") @PathVariable @NotBlank final String pathTestString,
                    @SuppressWarnings("unused") @RequestParam @NotBlank final String queryParmTestString) {

                return "can't get here";
            }

        }
    }

    @Documented
    @Constraint(validatedBy = CrossParameterSimpleExampleValidator.class)
    @Target({ CONSTRUCTOR, METHOD })
    @Retention(RUNTIME)
    public @interface CrossParameterSimpleExample {

        String message()

        default "Cross ${validatedValue}";

        Class<?>[] groups() default {};

        Class<? extends Payload>[] payload() default {};

        @SupportedValidationTarget(ValidationTarget.PARAMETERS)
        @Value
        @Accessors(fluent = true)
        class CrossParameterSimpleExampleValidator
                implements ConstraintValidator<CrossParameterSimpleExample, Object[]> {

            @Override
            public boolean isValid(final Object[] parameters, final ConstraintValidatorContext context) {

                return false;

            }
        }

    }
}

(Hacky) Workaround

This workaround will create two violations, one for each parameter with the same message.

            @Override
            public boolean isValid(final Object[] parameters, final ConstraintValidatorContext context) {
                //validation of the parameters array omitted
                var hibernateCrossParameterConstraintValidatorContext
                        = context.unwrap(HibernateCrossParameterConstraintValidatorContext.class);

                hibernateCrossParameterConstraintValidatorContext.disableDefaultConstraintViolation();

                var methodParameterNames = hibernateCrossParameterConstraintValidatorContext.getMethodParameterNames();
                
                var hibernateConstraintViolationBuilder = hibernateCrossParameterConstraintValidatorContext
                        .addMessageParameter("param0Name", methodParameterNames
                            .get(0))
                        .addMessageParameter("param1Name", methodParameterNames
                            .get(1))
                        .addMessageParameter("param0Value", parameters[0])
                        .addMessageParameter("param1Value", parameters[1])
                        .buildConstraintViolationWithTemplate(
                            "A {param0Name} with a value of {param0Value} and a {param1Name} with a value of {param1Value} are not compatible.");

                hibernateConstraintViolationBuilder.addParameterNode(0);
                hibernateConstraintViolationBuilder.addConstraintViolation();
                
                hibernateConstraintViolationBuilder.addParameterNode(1);
                hibernateConstraintViolationBuilder.addConstraintViolation();

                return false;

            }

Metadata

Metadata

Assignees

Labels

in: webIssues in web modules (web, webmvc, webflux, websocket)type: enhancementA general enhancement

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions