Skip to content

#295 Programmatic validation property path handling #510

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

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package io.quarkiverse.resteasy.problem;

import java.util.Set;

import jakarta.inject.Inject;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Valid;
import jakarta.validation.ValidationException;
import jakarta.validation.Validator;
import jakarta.validation.constraints.AssertFalse;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
Expand All @@ -18,6 +25,9 @@
@Produces(MediaType.APPLICATION_JSON)
public class ValidationExceptionsResource {

@Inject
Validator validator;

@GET
@Path("/validation-exception")
public void throwValidationException(@QueryParam("message") String message) {
Expand All @@ -44,9 +54,75 @@ public void throwConstraintViolationException(
@Valid TestRequestBody invalidPayload) {
}

@POST
@Path("/constraint-violation-exception/programmatic")
public void throwConstraintViolationExceptionProgrammatic(@QueryParam("name") String name) {
// Create an object with validation constraints
ProgrammaticTestBean bean = new ProgrammaticTestBean();
bean.name = name;
bean.email = "invalid-email"; // Invalid email format
bean.age = 5; // Below minimum age

// Validate programmatically
Set<ConstraintViolation<ProgrammaticTestBean>> violations = validator.validate(bean);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}

@POST
@Path("/constraint-violation-exception/programmatic/nested")
public void throwConstraintViolationExceptionProgrammaticNested(@QueryParam("companyName") String companyName) {
// Create nested object with validation constraints
ProgrammaticNestedTestBean bean = new ProgrammaticNestedTestBean();
bean.companyName = companyName;
bean.address = new ProgrammaticTestAddress();
bean.address.street = ""; // Empty street (invalid)
bean.address.city = "A"; // Too short city name

// Validate programmatically
Set<ConstraintViolation<ProgrammaticNestedTestBean>> violations = validator.validate(bean);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}

public static final class TestRequestBody {
@Min(15)
public int phraseName;
}

public static final class ProgrammaticTestBean {
@NotNull
@Length(min = 2, max = 50)
public String name;

@NotNull
@jakarta.validation.constraints.Email
public String email;

@Min(18)
public int age;
}

public static final class ProgrammaticNestedTestBean {
@NotNull
@Length(min = 3, max = 100)
public String companyName;

@Valid
@NotNull
public ProgrammaticTestAddress address;
}

public static final class ProgrammaticTestAddress {
@NotNull
@Length(min = 5, max = 200)
public String street;

@NotNull
@Length(min = 2, max = 100)
public String city;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,72 @@ void constraintViolationForArgumentsShouldProvideErrorDetails() {
.body("violations.find{it.in == 'body'}.message", equalTo("must be greater than or equal to 15"));
}

@Test
void constraintViolationDeclarativeShouldStripMethodNamesFromPropertyPath() {
// This test ensures that declarative validation violations properly strip method names
// from property paths (e.g., "methodName.parameter.field" becomes "field")
given()
.contentType(APPLICATION_JSON)
.body("{\"phraseName\": 1}")
.post("/throw/validation/constraint-violation-exception")
.then()
.statusCode(BAD_REQUEST.getStatusCode())
.body("violations", hasSize(1))
// Verify that field name is clean (no method name like "throwConstraintViolationException.body.phraseName")
.body("violations[0].field", equalTo("phraseName"))
.body("violations[0].message", equalTo("must be greater than or equal to 15"))
.body("violations[0].in", equalTo("body"));
}

@Test
void constraintViolationProgrammaticShouldProvideErrorDetails() {
given()
.queryParam("name", "A") // Too short name
.contentType(APPLICATION_JSON)
.post("/throw/validation/constraint-violation-exception/programmatic")
.then()
.statusCode(BAD_REQUEST.getStatusCode())
.body("title", equalTo(BAD_REQUEST.getReasonPhrase()))
.body("status", equalTo(BAD_REQUEST.getStatusCode()))
.body("violations", hasSize(3))
.body("violations.find{it.field == 'name'}.message", equalTo("length must be between 2 and 50"))
.body("violations.find{it.field == 'email'}.message", equalTo("must be a well-formed email address"))
.body("violations.find{it.field == 'age'}.message", equalTo("must be greater than or equal to 18"))
.body("stacktrace", nullValue());
}

@Test
void constraintViolationProgrammaticNestedShouldProvideErrorDetails() {
given()
.queryParam("companyName", "AB") // Too short company name
.contentType(APPLICATION_JSON)
.post("/throw/validation/constraint-violation-exception/programmatic/nested")
.then()
.statusCode(BAD_REQUEST.getStatusCode())
.body("title", equalTo(BAD_REQUEST.getReasonPhrase()))
.body("status", equalTo(BAD_REQUEST.getStatusCode()))
.body("violations", hasSize(3))
.body("violations.find{it.field == 'companyName'}.message", equalTo("length must be between 3 and 100"))
.body("violations.find{it.field == 'address.street'}.message", equalTo("length must be between 5 and 200"))
.body("violations.find{it.field == 'address.city'}.message", equalTo("length must be between 2 and 100"))
.body("stacktrace", nullValue());
}

@Test
void constraintViolationProgrammaticShouldNotStripMethodNamesFromPropertyPath() {
// This test ensures that programmatic validation violations preserve the original property path
// without stripping method names (since there are no method names in programmatic validation)
given()
.queryParam("name", "A") // Short name to trigger @Length constraint (but not @NotNull)
.contentType(APPLICATION_JSON)
.post("/throw/validation/constraint-violation-exception/programmatic")
.then()
.statusCode(BAD_REQUEST.getStatusCode())
.body("violations", hasSize(3))
// Verify that field names are simple property paths, not method.parameter.field
.body("violations.find{it.field == 'name'}.message", equalTo("length must be between 2 and 50"))
.body("violations.find{it.field == 'email'}.message", equalTo("must be a well-formed email address"))
.body("violations.find{it.field == 'age'}.message", equalTo("must be greater than or equal to 18"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@

import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

Expand Down Expand Up @@ -70,12 +66,22 @@ private List<Violation> toViolations(Set<ConstraintViolation<?>> constraintViola
private Violation toViolation(ConstraintViolation<?> constraintViolation) {
return matchEndpointMethodParameter(constraintViolation)
.map(param -> createViolation(constraintViolation, param))
.orElseGet(() -> Violation.In.unknown
.field(dropMethodName(constraintViolation.getPropertyPath()))
.message(constraintViolation.getMessage()));
.orElseGet(() -> {
String field = isDeclarativeValidation(constraintViolation)
? dropMethodName(constraintViolation.getPropertyPath())
: constraintViolation.getPropertyPath().toString();
return Violation.In.unknown
.field(field)
.message(constraintViolation.getMessage());
});

}

private Optional<Parameter> matchEndpointMethodParameter(ConstraintViolation<?> violation) {
if (resourceInfo == null) {
return Optional.empty();
}

Iterator<Path.Node> propertyPathIterator = violation.getPropertyPath().iterator();
if (!propertyPathIterator.hasNext()) {
return Optional.empty();
Expand All @@ -85,10 +91,11 @@ private Optional<Parameter> matchEndpointMethodParameter(ConstraintViolation<?>
return Optional.empty();
}
String paramName = propertyPathIterator.next().getName();
Method method = resourceInfo.getResourceMethod();
return Stream.of(method.getParameters())
.filter(param -> param.getName().equals(paramName))
.findFirst();

return Optional.ofNullable(resourceInfo.getResourceMethod())
.flatMap(method -> Stream.of(method.getParameters())
.filter(param -> param.getName().equals(paramName))
.findFirst());
}

private Violation createViolation(ConstraintViolation<?> constraintViolation, Parameter param) {
Expand Down Expand Up @@ -140,4 +147,35 @@ private String serializePath(Path propertyPath, int skipFirstSegments) {
return String.join(".", pathSegments);
}

private boolean isDeclarativeValidation(ConstraintViolation<?> violation) {
if (noJaxRsContext()) {
return false;
}

if (rootBeanResourceClassMatches(violation)) {
return true;
}

if (propertyPathStartsWithMethod(violation)) {
return true;
}

return false;
Comment on lines +151 to +163
Copy link
Collaborator Author

@pazkooda pazkooda Jul 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be even simplified to:

return rootBeanResourceClassMatches(violation) || propertyPathStartsWithMethod(violation);

UnitTest from Claude may fail but I think it performs unrealistic scenario. Or maybe code in line 81 will be sufficient to pass even theorethical case.

}

private boolean noJaxRsContext() {
return resourceInfo == null;
}

private boolean rootBeanResourceClassMatches(ConstraintViolation<?> violation) {
Object rootBean = violation.getRootBean();
return rootBean != null && resourceInfo.getResourceClass().isInstance(rootBean);
}

private boolean propertyPathStartsWithMethod(ConstraintViolation<?> violation) {
String propertyPath = violation.getPropertyPath().toString();
Method method = resourceInfo.getResourceMethod();
return method != null && propertyPath.startsWith(method.getName() + ".");
}

}
Loading