Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HandlerMethodValidator should support simple Cross-Parameter constraints #33271

Closed
jmax01 opened this issue Jul 25, 2024 · 7 comments
Closed
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Milestone

Comments

@jmax01
Copy link

jmax01 commented Jul 25, 2024

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;

            }
@jmax01 jmax01 changed the title HandlerMethodValidator should handle Cross-Parameter constraints HandlerMethodValidator should handle simple Cross-Parameter constraints without throwing an Exception Jul 25, 2024
@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Jul 25, 2024
@rstoyanchev
Copy link
Contributor

Thanks for the report. It would help a lot if you could provide snippets, or a small sample to experiment with.

@sbrannen sbrannen added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Jul 25, 2024
@sbrannen sbrannen changed the title HandlerMethodValidator should handle simple Cross-Parameter constraints without throwing an Exception HandlerMethodValidator should support simple Cross-Parameter constraints Jul 25, 2024
@jmax01
Copy link
Author

jmax01 commented Jul 25, 2024

Thanks for the report. It would help a lot if you could provide snippets, or a small sample to experiment with.

I will post one later today.

Note that it will simply be a GetMapping method with 2 arguments that have constraints and a method level cross-parameter constraint whose isValid method always returns false.

@bclozel bclozel added the status: waiting-for-feedback We need additional information before we can continue label Jul 25, 2024
@jmax01
Copy link
Author

jmax01 commented Jul 25, 2024

Example added.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Jul 25, 2024
@jmax01
Copy link
Author

jmax01 commented Jul 25, 2024

Workaround added.

@rstoyanchev
Copy link
Contributor

rstoyanchev commented Jul 31, 2024

Thanks for the sample.

For cross-parameter validation, the property path has the method node and then a cross-parameter node, but no other useful info on which parameters were validated or what the argument values were. Is that because the ConstraintValidator implementation is empty and just returns false? In other words is the validator able to and expected to update the context in a way that would expose more information in the resulting ConstraintViolation?

At any rate, a cross-parameter violation does not fit into the current model where a MethodValidationResult holds value results (violations of parameter constraints) or bean results (violations of nested object field constraints) both of which aggregate violations for a specific method parameter.

We could create a CrossParameterValidationResult that holds basic MessageSourceResolvable's prepared from the annotation name and the target class/method name, but I'm not sure if we can get anything more useful than that.

@jmax01
Copy link
Author

jmax01 commented Jul 31, 2024

I 100% agree that the current model doesn't fit.

The challenge in my mind is, at the end of the day, we need a single exception type that holds all method argument ConstraintViolations.

This exception type should contain violations of cross-parameter constraints and violations of individual arguments annotated with @Constraints or annotated with @Valid.

This would provide a complete solution for handling pre-method execution ConstraintViolations.

The MethodValidationResult and MethodValidationException names imply this functionality but currently require a lot of finagling to achieve it (ConstraintViolation manipulation in the case of cross-parameter constraints and the requirement to have additional @Constraints on the method for '@Valid` to be evaluated ).

Thanks for the sample.

For cross-parameter validation, the property path has the method node and then a cross-parameter node, but no other useful info on which parameters were validated or what the argument values were. Is that because the ConstraintValidator implementation is empty and just returns false? In other words, is the validator able to and expected to update the context in a way that would expose more information in the resulting ConstraintViolation?

At any rate, a cross-parameter violation does not fit into the current model where a MethodValidationResult holds value results (violations of parameter constraints) or bean results (violations of nested object field constraints) both of which aggregate violations for a specific method parameter.

We could create a CrossParameterValidationResult that holds basic MessageSourceResolvable's prepared from the annotation name and the target class/method name, but I'm not sure if we can get anything more useful than that.

@rstoyanchev
Copy link
Contributor

rstoyanchev commented Aug 1, 2024

the requirement to have additional @Constraints on the method for '@Valid` to be evaluated

I'm not sure what you mean. @Valid does not need anything else to trigger validation. It is true that if there are no @Constraints at all on method parameter, then it is validated on its own, raising MethodArgumentNotValidException, which is designed to be very similar to MethodValidationException and one can be adapted to the other. This is covered in the docs.

You do raise a point that with method level @Constraints, currently we assume they are for the return value, but it may be for cross-parameter validation. If this is what you are pointing out, then yes, we do need to update that.

ConstraintViolation manipulation in the case of cross-parameter constraints

For the rest, I proposed above a CrossParameterValidationResult held in MethodValidationResult in addition to ParameterValidationResults. However, I don't see a way to tell which specific parameters were involved, which means we can't aggregate cross-parameter violations in any way. So we might just expose a list of MessageSourceResolvables from MethodValidationResult that represent cross-param violations.

@rstoyanchev rstoyanchev self-assigned this Aug 1, 2024
@rstoyanchev rstoyanchev added type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on status: feedback-provided Feedback has been provided labels Aug 1, 2024
@rstoyanchev rstoyanchev added this to the 6.2.0-M7 milestone Aug 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

5 participants