This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new fefd0c3fcb feat(rest): Jakarta Bean Validation 3.x integration —
opt-in via @Valid, off by default (TODO-68)
fefd0c3fcb is described below
commit fefd0c3fcb68105b6b4876b1224e5d109d5af40d
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 16:07:39 2026 -0400
feat(rest): Jakarta Bean Validation 3.x integration — opt-in via @Valid,
off by default (TODO-68)
---
.../apache/juneau/commons/inject/JsrSupport.java | 24 +++
juneau-rest/juneau-rest-server/pom.xml | 13 ++
.../java/org/apache/juneau/rest/RestContext.java | 56 +++++
.../org/apache/juneau/rest/arg/ContentArg.java | 13 +-
.../org/apache/juneau/rest/arg/FormDataArg.java | 22 +-
.../org/apache/juneau/rest/arg/RequestBeanArg.java | 11 +-
.../juneau/rest/validation/BeanValidator.java | 239 ++++++++++++++++++++
.../rest/validation/ValidationException.java | 106 +++++++++
.../rest/validation/ValidationViolation.java | 151 +++++++++++++
.../juneau/rest/validation/package-info.java | 65 ++++++
juneau-utest/pom.xml | 23 ++
.../juneau/rest/validation/BeanValidator_Test.java | 139 ++++++++++++
.../validation/RestValidation_Content_Test.java | 240 +++++++++++++++++++++
.../RestValidation_CustomValidator_Test.java | 108 ++++++++++
.../RestValidation_MissingProvider_Test.java | 92 ++++++++
.../validation/RestValidation_Nested_Test.java | 139 ++++++++++++
.../RestValidation_OffByDefault_Test.java | 147 +++++++++++++
.../RestValidation_ProblemDetails_Test.java | 125 +++++++++++
18 files changed, 1704 insertions(+), 9 deletions(-)
diff --git
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/JsrSupport.java
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/JsrSupport.java
index af5552fabd..2b43d0cba4 100644
---
a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/JsrSupport.java
+++
b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/inject/JsrSupport.java
@@ -70,6 +70,14 @@ public final class JsrSupport {
public static final String JUNEAU_VALUE =
"org.apache.juneau.commons.inject.Value";
public static final String SPRING_VALUE =
"org.springframework.beans.factory.annotation.Value";
+ // Jakarta Bean Validation 3.x (and the older javax. namespace).
Detected by FQN so juneau-commons
+ // stays free of a compile-time jakarta.validation dependency; the
opt-in marker is recognized whether
+ // the consumer pulls in jakarta.validation-api 3.x,
javax.validation:validation-api 2.x, or Spring's
+ // own @Validated.
+ public static final String JAKARTA_VALID = "jakarta.validation.Valid";
+ public static final String JAVAX_VALID = "javax.validation.Valid";
+ public static final String SPRING_VALIDATED =
"org.springframework.validation.annotation.Validated";
+
private JsrSupport() {}
/**
@@ -175,4 +183,20 @@ public final class JsrSupport {
return null;
return annotation.getValue().orElse(null);
}
+
+ /**
+ * Returns <jk>true</jk> if the annotation is a Jakarta Bean Validation
opt-in marker.
+ *
+ * <p>
+ * Recognizes Jakarta 3.x ({@code jakarta.validation.Valid}), the older
Javax 2.x equivalent
+ * ({@code javax.validation.Valid}), and Spring's {@code @Validated}
group-selector by FQN — no
+ * compile-time Jakarta Validation or Spring dependency in {@code
juneau-commons}.
+ *
+ * @param annotation The annotation to inspect.
+ * @return <jk>true</jk> if {@code annotation} is one of the recognized
validation opt-in markers.
+ */
+ public static boolean isValidAnnotation(AnnotationInfo<?> annotation) {
+ var name = annotation.getName();
+ return eqAny(name, JAKARTA_VALID, JAVAX_VALID,
SPRING_VALIDATED);
+ }
}
diff --git a/juneau-rest/juneau-rest-server/pom.xml
b/juneau-rest/juneau-rest-server/pom.xml
index fdb2cc5542..1f250a5b3a 100644
--- a/juneau-rest/juneau-rest-server/pom.xml
+++ b/juneau-rest/juneau-rest-server/pom.xml
@@ -75,6 +75,19 @@
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
</dependency>
+
+ <!--
+ Jakarta Bean Validation 3.x — opt-in, off by default.
+ Declared "provided" so consumers who never write @Valid
pay no transitive cost; the
+ BeanValidator integration is a no-op at runtime when
the API and a concrete provider
+ (e.g. org.hibernate.validator:hibernate-validator) are
not on the consumer's classpath.
+ -->
+ <dependency>
+ <groupId>jakarta.validation</groupId>
+ <artifactId>jakarta.validation-api</artifactId>
+ <version>3.0.2</version>
+ <scope>provided</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 96a08af665..9c48a3dc93 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -53,6 +53,7 @@ import java.util.logging.*;
import java.util.stream.*;
import org.apache.juneau.*;
+import org.apache.juneau.bean.rfc7807.Problem;
import org.apache.juneau.bean.rfc7807.adapter.ProblemAdapters;
import org.apache.juneau.bean.openapi3.OpenApi;
import org.apache.juneau.bean.swagger.Swagger;
@@ -92,6 +93,7 @@ import org.apache.juneau.rest.stats.*;
import org.apache.juneau.rest.openapi.*;
import org.apache.juneau.rest.swagger.*;
import org.apache.juneau.rest.util.*;
+import org.apache.juneau.rest.validation.*;
import org.apache.juneau.rest.vars.*;
import org.apache.juneau.serializer.*;
import org.apache.juneau.commons.svl.*;
@@ -3644,6 +3646,9 @@ public class RestContext extends Context {
try {
var statusCode = e2.getStatusLine().getStatusCode();
+ if (e2 instanceof ValidationException ve &&
writeValidationErrorBody(res, ve, statusCode, isProblemDetails()))
+ return;
+
if (isProblemDetails() && writeProblemDetailsBody(res,
e2, statusCode))
return;
@@ -3694,6 +3699,57 @@ public class RestContext extends Context {
}
}
+ /**
+ * Writes a {@link ValidationException} as a structured JSON body
carrying the per-field
+ * {@link ValidationViolation} list, so client UIs can render
field-level errors.
+ *
+ * <p>
+ * Two shapes depending on the resource's {@code @Rest(problemDetails)}
opt-in:
+ * <ul class='spaced-list'>
+ * <li><b>Problem-details ON</b> — emits {@code
application/problem+json} with the standard
+ * RFC 7807 {@code status}/{@code title}/{@code detail}
members plus an {@code errors[]} extension
+ * array populated from {@link
ValidationException#getViolations()}. Bypasses the generic
+ * {@link
ProblemAdapters#fromException(BasicHttpException)} path because that adapter is
+ * intentionally narrow (no extensions) — validation
needs its own per-field payload.
+ * <li><b>Problem-details OFF (default)</b> — emits {@code
application/json} with the simple
+ * {@code { "status":400, "errors":[ ... ] }} envelope.
Keeps the response usable from clients that
+ * can't parse {@code application/problem+json} without
making validation responses look like a
+ * text/plain stack trace.
+ * </ul>
+ *
+ * @return {@code true} if the body was written (response committed).
{@code false} on serialization
+ * failure so the caller can fall back to the legacy {@code
text/plain} writer.
+ */
+ @SuppressWarnings({
+ "resource" // output stream owned by the servlet response;
closed by the container
+ })
+ private static boolean writeValidationErrorBody(HttpServletResponse
res, ValidationException ve, int statusCode, boolean problemDetails) {
+ try {
+ res.setStatus(statusCode);
+ res.setHeader("Content-Encoding", "identity");
+ var os = res.getOutputStream();
+ if (problemDetails) {
+ var problem = new Problem()
+ .setStatus(statusCode)
+
.setTitle(ve.getStatusLine().getReasonPhrase())
+ .setDetail(ve.getMessage())
+ .set("errors", ve.getViolations());
+
res.setContentType(ContentType.APPLICATION_PROBLEM_JSON.getValue());
+ JsonSerializer.DEFAULT.serialize(problem, os);
+ } else {
+ var envelope = new
LinkedHashMap<String,Object>();
+ envelope.put("status", statusCode);
+ envelope.put("errors", ve.getViolations());
+ res.setContentType("application/json");
+ JsonSerializer.DEFAULT.serialize(envelope, os);
+ }
+ os.flush();
+ return true;
+ } catch (Exception ex) {
+ return false;
+ }
+ }
+
/**
* Handle the case where a matching method was not found.
*
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/ContentArg.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/ContentArg.java
index 61e84a2c4f..f1f832c965 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/ContentArg.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/ContentArg.java
@@ -24,6 +24,7 @@ import org.apache.juneau.httppart.*;
import org.apache.juneau.rest.*;
import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.httppart.*;
+import org.apache.juneau.rest.validation.*;
/**
* Resolves method parameters and parameter types annotated with {@link
Content} on {@link RestOp}-annotated Java methods.
@@ -65,6 +66,14 @@ public class ContentArg implements RestOpArg {
private final Type type;
+ /**
+ * Pre-computed flag — {@code true} iff this parameter carries a
Jakarta Bean Validation
+ * {@code @Valid} (or equivalent) marker. Validation is off by default;
only parameters that opt in
+ * pay the per-request {@link BeanValidator#validate(Object,
org.apache.juneau.commons.inject.BeanStore)}
+ * cost.
+ */
+ private final boolean validate;
+
/**
* Constructor.
*
@@ -73,10 +82,12 @@ public class ContentArg implements RestOpArg {
protected ContentArg(ParameterInfo paramInfo) {
this.type = paramInfo.getParameterType().innerType();
this.schema = HttpPartSchema.create(Content.class, paramInfo);
+ this.validate = BeanValidator.isValidationRequested(paramInfo);
}
@Override /* Overridden from RestOpArg */
public Object resolve(RestOpSession opSession) throws Exception {
- return
opSession.getRequest().getContent().setSchema(schema).as(type);
+ var bean =
opSession.getRequest().getContent().setSchema(schema).as(type);
+ return validate ? BeanValidator.validate(bean,
opSession.getBeanStore()) : bean;
}
}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/FormDataArg.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/FormDataArg.java
index ef4391a936..5ac49e59f2 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/FormDataArg.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/FormDataArg.java
@@ -32,6 +32,7 @@ import org.apache.juneau.commons.httppart.*;
import org.apache.juneau.rest.*;
import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.httppart.*;
+import org.apache.juneau.rest.validation.*;
/**
* Resolves method parameters and parameter types annotated with {@link
FormData} on {@link RestOp}-annotated Java methods.
@@ -117,6 +118,12 @@ public class FormDataArg implements RestOpArg {
private final String def;
private final ClassInfo type;
+ /**
+ * Pre-computed flag — {@code true} iff this parameter carries a
Jakarta Bean Validation
+ * {@code @Valid} (or equivalent) marker. Off-by-default opt-in —
see {@link BeanValidator}.
+ */
+ private final boolean validate;
+
/**
* Constructor.
*
@@ -140,6 +147,7 @@ public class FormDataArg implements RestOpArg {
var pp = (Class<? extends HttpPartParser>)schema.getParser();
this.partParser = nn(pp) ?
HttpPartParser.creator().type(pp).apply(annotations).create() : null;
this.multi = schema.getCollectionFormat() ==
HttpPartCollectionFormat.MULTI;
+ this.validate = BeanValidator.isValidationRequested(pi);
if (multi && ! type.isCollectionOrArray())
throw new ArgException(pi, "Use of multipart flag on
@FormData parameter that is not an array or Collection");
@@ -157,6 +165,7 @@ public class FormDataArg implements RestOpArg {
var bs = req.getMarshallingSession();
var cm = bs.getClassMeta(type.innerType());
+ Object result;
if (multi) {
Collection c;
if (cm.isArray()) {
@@ -167,15 +176,14 @@ public class FormDataArg implements RestOpArg {
c = new JsonList();
}
rh.getAll(name).stream().map(x ->
x.parser(ps).schema(schema).as(cm.getElementType()).orElse(null)).forEach(c::add);
- return cm.isArray() ? toArray(c,
cm.getElementType().inner()) : c;
- }
-
- if (cm.isMapOrBean() && isOneOf(name, "*", "")) {
+ result = cm.isArray() ? toArray(c,
cm.getElementType().inner()) : c;
+ } else if (cm.isMapOrBean() && isOneOf(name, "*", "")) {
var m = new JsonMap();
rh.forEach(e -> m.put(e.getName(),
e.parser(ps).schema(schema == null ? null :
schema.getProperty(e.getName())).as(cm.getValueType()).orElse(null)));
- return req.getMarshallingSession().convertToType(m, cm);
+ result = req.getMarshallingSession().convertToType(m,
cm);
+ } else {
+ result =
rh.getLast(name).parser(ps).schema(schema).def(def).as(type.innerType()).orElse(null);
}
-
- return
rh.getLast(name).parser(ps).schema(schema).def(def).as(type.innerType()).orElse(null);
+ return validate ? BeanValidator.validate(result,
opSession.getBeanStore()) : result;
}
}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/RequestBeanArg.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/RequestBeanArg.java
index 5eae1b50c3..80e9b54a88 100644
---
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/RequestBeanArg.java
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/arg/RequestBeanArg.java
@@ -22,6 +22,7 @@ import org.apache.juneau.http.annotation.*;
import org.apache.juneau.httppart.bean.*;
import org.apache.juneau.rest.*;
import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.validation.*;
/**
* Resolves method parameters annotated with {@link Request} on {@link
RestOp}-annotated Java methods.
@@ -60,6 +61,12 @@ public class RequestBeanArg implements RestOpArg {
private final RequestBeanMeta meta;
+ /**
+ * Pre-computed flag — {@code true} iff this parameter carries a
Jakarta Bean Validation
+ * {@code @Valid} (or equivalent) marker. Off-by-default opt-in —
see {@link BeanValidator}.
+ */
+ private final boolean validate;
+
/**
* Constructor.
*
@@ -68,10 +75,12 @@ public class RequestBeanArg implements RestOpArg {
*/
protected RequestBeanArg(ParameterInfo paramInfo, AnnotationWorkList
annotations) {
this.meta = RequestBeanMeta.create(paramInfo, annotations);
+ this.validate = BeanValidator.isValidationRequested(paramInfo);
}
@Override /* Overridden from RestOpArg */
public Object resolve(RestOpSession opSession) throws Exception {
- return opSession.getRequest().getRequest(meta);
+ var bean = opSession.getRequest().getRequest(meta);
+ return validate ? BeanValidator.validate(bean,
opSession.getBeanStore()) : bean;
}
}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/BeanValidator.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/BeanValidator.java
new file mode 100644
index 0000000000..ebda3ecafe
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/BeanValidator.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import java.util.*;
+import java.util.concurrent.atomic.*;
+import java.util.logging.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.reflect.*;
+
+import jakarta.validation.*;
+
+/**
+ * Static dispatcher for the optional Jakarta Bean Validation 3.x integration.
+ *
+ * <p>
+ * Acts as a thin shim between Juneau's REST argument resolvers ({@code
ContentArg}, {@code FormDataArg},
+ * {@code RequestBeanArg}) and the {@code jakarta.validation.Validator}
engine. Per the package-level contract
+ * (see {@link org.apache.juneau.rest.validation package overview}),
<b>validation is off by default and never
+ * runs automatically</b>: callers query {@link
#isValidationRequested(ParameterInfo)} once at arg-resolver
+ * construction time, then conditionally invoke {@link #validate(Object,
BeanStore)} per request only when an
+ * explicit {@code @jakarta.validation.Valid} (or equivalent) marker is
present.
+ *
+ * <h5 class='topic'>Validator resolution order</h5>
+ * <ol>
+ * <li>A user-supplied {@code Validator} bean visible from the supplied
{@link BeanStore} — typically
+ * contributed via a {@code @Bean public Validator validator()}
factory on a {@code @Rest}-annotated
+ * resource. This is the recommended path when the application
needs custom message interpolators,
+ * group sequences, or constraint-validator factories.
+ * <li>A lazily-built JVM-wide default obtained from
+ * {@link Validation#buildDefaultValidatorFactory()}. Cached after
the first successful build so the
+ * factory cost is paid at most once per JVM.
+ * <li>{@code null} — signals graceful degradation when neither a
bean-store entry nor a runtime
+ * provider is available. {@link #validate(Object, BeanStore)}
returns the input bean unchanged in this
+ * case and logs a single {@code WARNING} per JVM so misconfigured
deployments are visible without
+ * flooding the log.
+ * </ol>
+ *
+ * <h5 class='topic'>Why static dispatch?</h5>
+ * <p>
+ * Each REST argument resolver is built once per {@code @RestOp} method at
servlet startup, then invoked on
+ * every request. By keeping the validator lookup off the arg-resolver
constructor and inside this class's
+ * cached static accessor, the cost of locating the {@code Validator} is paid
once per JVM rather than once
+ * per request — and resolvers for {@code @RestOp} methods that never
opt in pay nothing at all (the
+ * static initializer doesn't run until the first call to {@link
#validate(Object, BeanStore)}).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link ValidationException}
+ * <li class='jc'>{@link ValidationViolation}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerValidation">REST Server
— Jakarta Validation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public final class BeanValidator {
+
+ private static final Logger LOG =
Logger.getLogger(BeanValidator.class.getName());
+
+ /**
+ * Holds the lazily-resolved JVM-wide default {@link Validator} (built
once on first need from
+ * {@link Validation#buildDefaultValidatorFactory()}). {@code null}
until first successful build.
+ */
+ private static final AtomicReference<Validator> DEFAULT_VALIDATOR = new
AtomicReference<>();
+
+ /**
+ * Flips to {@code true} once we've discovered — via either a
{@link ValidationException} from the
+ * provider lookup or a {@link Throwable} from {@link
Validation#buildDefaultValidatorFactory()} —
+ * that no runtime validator engine is reachable on this classpath.
Short-circuits subsequent calls so we
+ * don't repeatedly pay the failed-lookup cost.
+ */
+ private static final AtomicBoolean DEFAULT_PROVIDER_UNAVAILABLE = new
AtomicBoolean();
+
+ /** One-shot guard so the "no provider on classpath" warning
only logs once per JVM. */
+ private static final AtomicBoolean MISSING_PROVIDER_LOGGED = new
AtomicBoolean();
+
+ private BeanValidator() {}
+
+ /**
+ * Returns <jk>true</jk> if the supplied parameter carries any of the
recognized validation opt-in
+ * markers (currently {@code jakarta.validation.Valid}, {@code
javax.validation.Valid}, or Spring's
+ * {@code @Validated}).
+ *
+ * <p>
+ * Detection is purely FQN-based via {@link
JsrSupport#isValidAnnotation(AnnotationInfo)} — no
+ * compile-time dependency on the Jakarta Validation or Spring
annotation classes themselves is required
+ * to recognize the marker.
+ *
+ * <p>
+ * Arg resolvers should call this exactly once at construction time and
cache the result; the per-request
+ * hot path then becomes a single boolean check before deciding whether
to call
+ * {@link #validate(Object, BeanStore)}.
+ *
+ * @param paramInfo The Java method parameter being inspected. Must not
be <jk>null</jk>.
+ * @return <jk>true</jk> if the parameter has been opted into
validation.
+ */
+ public static boolean isValidationRequested(ParameterInfo paramInfo) {
+ return
paramInfo.getAnnotations().stream().anyMatch(JsrSupport::isValidAnnotation);
+ }
+
+ /**
+ * Runs Jakarta Bean Validation against the supplied bean and throws a
{@link ValidationException} if any
+ * constraints are violated.
+ *
+ * <p>
+ * Resolution order for the {@link Validator} is described on the class
javadoc. When no validator can
+ * be obtained (no bean-store entry, no runtime provider), this method
logs a one-shot warning and
+ * returns {@code bean} unchanged — preserving the off-by-default
contract even when an arg
+ * resolver was constructed with {@code @Valid} but the deployment
forgot to ship a provider.
+ *
+ * <p>
+ * A {@code null} {@code bean} returns {@code null} without invoking
the validator (Jakarta's
+ * {@code validator.validate(null)} throws {@code
IllegalArgumentException}, which would just produce an
+ * unhelpful 500 instead of the natural "missing body" 400
the upstream arg resolver already
+ * surfaces).
+ *
+ * @param <T> The bean type being validated.
+ * @param bean The resolved bean to validate. May be <jk>null</jk>.
+ * @param beanStore The op-session bean store consulted for a
user-supplied {@link Validator}. May be
+ * <jk>null</jk> (treated as "no user override").
+ * @return The input {@code bean} (unchanged).
+ * @throws ValidationException If one or more constraints are violated.
+ */
+ public static <T> T validate(T bean, BeanStore beanStore) {
+ if (bean == null)
+ return null;
+ var validator = resolveValidator(beanStore);
+ if (validator == null) {
+ warnMissingProviderOnce();
+ return bean;
+ }
+ var violations = validator.validate(bean);
+ if (violations.isEmpty())
+ return bean;
+ throw new ValidationException(toViolationList(violations));
+ }
+
+ /**
+ * Test/diagnostic hook — resets the JVM-wide default-validator
cache so the next call to
+ * {@link #validate(Object, BeanStore)} re-resolves the provider. Not
part of the public contract;
+ * intended for unit tests that simulate provider-missing scenarios.
+ */
+ public static void resetCachedDefaultForTesting() {
+ DEFAULT_VALIDATOR.set(null);
+ DEFAULT_PROVIDER_UNAVAILABLE.set(false);
+ MISSING_PROVIDER_LOGGED.set(false);
+ }
+
+ /**
+ * Test/diagnostic hook — forces the "no provider on
classpath" code path. Not part of
+ * the public contract; intended for unit tests that need to assert the
graceful-degradation behavior
+ * without actually removing the Hibernate Validator JAR from the test
classpath.
+ *
+ * <p>
+ * After calling this, {@link #validate(Object, BeanStore)} will behave
as if no Jakarta Bean
+ * Validation provider is reachable: it short-circuits to a no-op and
emits the one-shot
+ * "missing provider" warning. Call {@link
#resetCachedDefaultForTesting()} afterwards to
+ * restore normal lookup.
+ */
+ public static void simulateProviderMissingForTesting() {
+ DEFAULT_VALIDATOR.set(null);
+ DEFAULT_PROVIDER_UNAVAILABLE.set(true);
+ MISSING_PROVIDER_LOGGED.set(false);
+ }
+
+ private static Validator resolveValidator(BeanStore beanStore) {
+ if (beanStore != null) {
+ var fromStore =
beanStore.getBean(Validator.class).orElse(null);
+ if (fromStore != null)
+ return fromStore;
+ }
+ return getDefaultValidator();
+ }
+
+ private static Validator getDefaultValidator() {
+ if (DEFAULT_PROVIDER_UNAVAILABLE.get())
+ return null;
+ var cached = DEFAULT_VALIDATOR.get();
+ if (cached != null)
+ return cached;
+ try {
+ var built =
Validation.buildDefaultValidatorFactory().getValidator();
+ DEFAULT_VALIDATOR.compareAndSet(null, built);
+ return DEFAULT_VALIDATOR.get();
+ } catch (Throwable t) { // NOSONAR: ValidationException,
NoClassDefFoundError, LinkageError all in scope here.
+ DEFAULT_PROVIDER_UNAVAILABLE.set(true);
+ return null;
+ }
+ }
+
+ private static void warnMissingProviderOnce() {
+ if (MISSING_PROVIDER_LOGGED.compareAndSet(false, true)) {
+ LOG.log(Level.WARNING,
+ "Jakarta Bean Validation was requested via
@Valid on a REST argument, but no jakarta.validation.Validator " +
+ "bean is registered and no runtime provider
(e.g. org.hibernate.validator:hibernate-validator + " +
+ "jakarta.el implementation) is on the
classpath. Validation will be silently skipped for this and " +
+ "subsequent requests. Add a provider dependency
or register a Validator bean to enable validation.");
+ }
+ }
+
+ private static List<ValidationViolation> toViolationList(Set<? extends
ConstraintViolation<?>> violations) {
+ var result = new
ArrayList<ValidationViolation>(violations.size());
+ for (var cv : violations) {
+ result.add(new ValidationViolation(
+ cv.getPropertyPath() == null ? null :
cv.getPropertyPath().toString(),
+ cv.getMessage(),
+ cv.getConstraintDescriptor() == null ||
cv.getConstraintDescriptor().getAnnotation() == null
+ ? null
+ :
cv.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName()
+ ));
+ }
+ // Stable ordering — Jakarta returns a Set, but tests assert on
payload shape so we sort by path+message.
+ result.sort((a, b) -> {
+ var ap = a.getPath() == null ? "" : a.getPath();
+ var bp = b.getPath() == null ? "" : b.getPath();
+ var c = ap.compareTo(bp);
+ if (c != 0)
+ return c;
+ var am = a.getMessage() == null ? "" : a.getMessage();
+ var bm = b.getMessage() == null ? "" : b.getMessage();
+ return am.compareTo(bm);
+ });
+ return result;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/ValidationException.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/ValidationException.java
new file mode 100644
index 0000000000..6131972834
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/ValidationException.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.http.response.*;
+
+/**
+ * Specialization of {@link BadRequest} thrown by {@link BeanValidator} when
one or more Jakarta Bean Validation
+ * constraints on a {@code @Content}, {@code @FormData}, or {@code
@Request}-bound parameter are violated.
+ *
+ * <p>
+ * Behaves like a plain {@code 400 Bad Request} from the wire's perspective
— same status code, same reason
+ * phrase, same default JSON envelope shape used by the rest of the
framework's exception path — but adds a
+ * structured, serialized {@link #getViolations() violations list} so the
response body can include the list of
+ * field-level errors that produced the failure.
+ *
+ * <h5 class='section'>Wire shape:</h5>
+ * <p>
+ * The response body depends on whether RFC 7807 problem-details mode is
enabled on the resource:
+ * <ul class='spaced-list'>
+ * <li><b>Problem-details ON</b> — {@code application/problem+json}
with the standard {@code status} /
+ * {@code title} / {@code detail} members plus an {@code errors[]}
extension array populated from
+ * {@link #getViolations()}.
+ * <li><b>Problem-details OFF (default)</b> — {@code
application/json} with a simple
+ * {@code { "status":400, "errors":[ ... ] }} envelope.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link ValidationViolation}
+ * <li class='jc'>{@link BeanValidator}
+ * <li class='jc'>{@link BadRequest}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerValidation">REST Server
— Jakarta Validation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class ValidationException extends BadRequest {
+
+ private static final long serialVersionUID = 1L;
+
+ /** The default detail message used when constructed without an
explicit message. */
+ public static final String DEFAULT_MESSAGE = "Request validation
failed";
+
+ private final List<ValidationViolation> violations;
+
+ /**
+ * Constructor.
+ *
+ * @param violations The list of constraint violations. Must not be
<jk>null</jk> (use an empty list to signal
+ * "validation requested but no violations" —
though that case never actually throws in practice).
+ */
+ public ValidationException(List<ValidationViolation> violations) {
+ super(DEFAULT_MESSAGE);
+ this.violations = List.copyOf(violations);
+ }
+
+ /**
+ * Constructor with a custom detail message.
+ *
+ * @param violations The list of constraint violations. Must not be
<jk>null</jk>.
+ * @param msg The detail message. May be <jk>null</jk>.
+ * Treated as a format pattern when {@code args} is non-empty.
+ * @param args Optional message arguments.
+ */
+ public ValidationException(List<ValidationViolation> violations, String
msg, Object...args) {
+ super(msg, args);
+ this.violations = List.copyOf(violations);
+ }
+
+ /**
+ * Returns the immutable list of constraint violations that produced
this exception.
+ *
+ * @return The list of violations, never <jk>null</jk> and never
modifiable.
+ */
+ public List<ValidationViolation> getViolations() {
+ return violations;
+ }
+
+ /**
+ * Returns a defensive, mutable copy of the violation list for callers
that need to inspect / filter it
+ * without mutating the exception's own state.
+ *
+ * @return A new mutable copy. Never <jk>null</jk>.
+ */
+ public List<ValidationViolation> copyViolations() {
+ return copyOf(violations);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/ValidationViolation.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/ValidationViolation.java
new file mode 100644
index 0000000000..125d29ba95
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/ValidationViolation.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import org.apache.juneau.annotation.*;
+
+/**
+ * Wire-friendly snapshot of a single Jakarta Bean Validation constraint
violation, suitable for inclusion in an
+ * HTTP error response body.
+ *
+ * <p>
+ * Produced by {@link BeanValidator} from a {@code
jakarta.validation.ConstraintViolation}, with deliberately
+ * narrow surface so the bean is decoupled from the optional {@code
jakarta.validation} runtime dependency. The
+ * {@link #invalidValue} field is omitted by default to avoid echoing
potentially sensitive request data back to
+ * the client; callers can opt back in via {@link #setInvalidValue(String)}.
+ *
+ * <h5 class='section'>JSON shape:</h5>
+ * <p class='bjson'>
+ * {
+ * <jok>"path"</jok>: <jov>"name"</jov>,
+ * <jok>"message"</jok>: <jov>"must not be blank"</jov>,
+ * <jok>"constraint"</jok>: <jov>"NotBlank"</jov>
+ * }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link ValidationException}
+ * <li class='jc'>{@link BeanValidator}
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerValidation">REST Server
— Jakarta Validation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@Marshalled
+public class ValidationViolation {
+
+ private String path;
+ private String message;
+ private String constraint;
+ private String invalidValue;
+
+ /**
+ * Default constructor.
+ */
+ public ValidationViolation() {}
+
+ /**
+ * Convenience constructor populating the three non-sensitive fields.
+ *
+ * @param path The property path string (e.g. {@code "name"} or {@code
"items[2].sku"}). May be <jk>null</jk>.
+ * @param message The localized violation message produced by the
constraint validator. May be <jk>null</jk>.
+ * @param constraint The simple name of the constraint annotation (e.g.
{@code "NotBlank"}). May be <jk>null</jk>.
+ */
+ public ValidationViolation(String path, String message, String
constraint) {
+ this.path = path;
+ this.message = message;
+ this.constraint = constraint;
+ }
+
+ /**
+ * The Jakarta-Validation property path string — the
dotted/indexed path from the validated root bean
+ * down to the violating field (e.g. {@code "name"} or {@code
"items[2].sku"}).
+ *
+ * @return The property path, or <jk>null</jk> if not set.
+ */
+ public String getPath() { return path; }
+
+ /**
+ * Sets the property path.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the
property.
+ * @return This object.
+ */
+ public ValidationViolation setPath(String value) {
+ path = value;
+ return this;
+ }
+
+ /**
+ * The localized violation message from the constraint validator (e.g.
{@code "must not be blank"}).
+ *
+ * @return The message, or <jk>null</jk> if not set.
+ */
+ public String getMessage() { return message; }
+
+ /**
+ * Sets the message.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the
property.
+ * @return This object.
+ */
+ public ValidationViolation setMessage(String value) {
+ message = value;
+ return this;
+ }
+
+ /**
+ * The simple name of the constraint annotation that was violated (e.g.
{@code "NotBlank"}, {@code "Size"}).
+ *
+ * @return The constraint name, or <jk>null</jk> if not set.
+ */
+ public String getConstraint() { return constraint; }
+
+ /**
+ * Sets the constraint.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the
property.
+ * @return This object.
+ */
+ public ValidationViolation setConstraint(String value) {
+ constraint = value;
+ return this;
+ }
+
+ /**
+ * The (string-rendered) invalid value that triggered the violation.
+ *
+ * <p>
+ * Omitted by default to avoid echoing potentially sensitive request
data back to the client; populated only
+ * when the caller explicitly opts in via {@link
#setInvalidValue(String)} (typically from a
+ * {@code BeanValidator} configured with {@code
includeInvalidValue(true)}).
+ *
+ * @return The invalid value, or <jk>null</jk> if not set.
+ */
+ public String getInvalidValue() { return invalidValue; }
+
+ /**
+ * Sets the invalid value.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the
property.
+ * @return This object.
+ */
+ public ValidationViolation setInvalidValue(String value) {
+ invalidValue = value;
+ return this;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/package-info.java
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/package-info.java
new file mode 100644
index 0000000000..4e97d0fe05
--- /dev/null
+++
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/validation/package-info.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * Optional <a class="doclink"
href="https://jakarta.ee/specifications/bean-validation/">Jakarta Bean
Validation 3.x</a>
+ * integration for {@code juneau-rest-server} — declarative validation
of request beans via
+ * {@code jakarta.validation.constraints.*} annotations.
+ *
+ * <h5 class='topic'>Off by default — opt in per parameter</h5>
+ * <p>
+ * Validation is <b>off by default and never runs automatically</b>. A {@link
org.apache.juneau.rest.validation.BeanValidator}
+ * is only constructed when an arg resolver sees a {@code
@jakarta.validation.Valid} (or Spring
+ * {@code @org.springframework.validation.annotation.Validated}) annotation on
a {@code @Content}, {@code @FormData},
+ * or {@code @Request}-bound parameter. Without that annotation:
+ * <ul class='spaced-list'>
+ * <li>No {@code jakarta.validation.Validator} is created.
+ * <li>No constraint check is invoked.
+ * <li>No runtime cost is paid — the request bean flows through the
unmodified Juneau pipeline as if
+ * Jakarta Validation were not on the classpath at all.
+ * </ul>
+ * <p>
+ * There is no global "turn on validation for the whole resource"
switch — opt-in is per-parameter
+ * via {@code @Valid}. This is a deliberate behavior difference from Spring
MVC (which auto-validates beans whose
+ * type carries constraint annotations) and from the standard Jakarta cascade
rules.
+ *
+ * <h5 class='topic'>Dependency stance</h5>
+ * <p>
+ * {@code juneau-rest-server} declares {@code
jakarta.validation:jakarta.validation-api} in {@code provided}
+ * scope. A concrete provider (e.g. {@code
org.hibernate.validator:hibernate-validator}) is <b>not</b> bundled
+ * and must be added by the consumer when they want validation to actually
run. When the provider is missing at
+ * runtime, the integration degrades to a no-op — the request bean is
returned unmodified and a warning is
+ * logged on first attempted use.
+ *
+ * <h5 class='topic'>Failure handling</h5>
+ * <p>
+ * A constraint violation throws {@link
org.apache.juneau.rest.validation.ValidationException} (a 400 Bad Request
+ * subclass) carrying the list of {@link
org.apache.juneau.rest.validation.ValidationViolation} details. The
+ * response body shape depends on whether RFC 7807 problem-details mode is
enabled on the resource:
+ * <ul class='spaced-list'>
+ * <li><b>Problem-details ON</b> — {@code application/problem+json}
with the standard {@code status} /
+ * {@code title} / {@code detail} members plus an {@code errors[]}
extension array.
+ * <li><b>Problem-details OFF (default)</b> — {@code
application/json} with a simple
+ * {@code { "status":400, "errors":[ ... ] }} envelope.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='link'><a class="doclink"
href="https://juneau.apache.org/docs/topics/RestServerValidation">REST Server
— Jakarta Validation</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.validation;
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index 2c66fd21b4..dcf546ac91 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -330,6 +330,29 @@
<version>3.0.2</version>
<scope>test</scope>
</dependency>
+ <!--
+ Hibernate Validator 8.0.x is the Jakarta Bean
Validation 3.0 reference implementation.
+ Provides the concrete jakarta.validation.Validator
engine that backs the optional
+ BeanValidator integration in juneau-rest-server.
Test-scope only — production consumers
+ supply their own provider on their classpath.
+ -->
+ <dependency>
+ <groupId>org.hibernate.validator</groupId>
+ <artifactId>hibernate-validator</artifactId>
+ <version>8.0.3.Final</version>
+ <scope>test</scope>
+ </dependency>
+ <!--
+ Jakarta Expression Language implementation — required
at runtime by Hibernate Validator
+ for interpolating dynamic constraint messages. Without
it the validator throws
+ ValidationException on first .validate() call
("HV000183: Unable to load 'jakarta.el.*'").
+ -->
+ <dependency>
+ <groupId>org.glassfish.expressly</groupId>
+ <artifactId>expressly</artifactId>
+ <version>5.0.0</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/BeanValidator_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/BeanValidator_Test.java
new file mode 100644
index 0000000000..cdebfe0a59
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/BeanValidator_Test.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.lang.reflect.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.reflect.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.constraints.*;
+
+/**
+ * Direct unit tests for {@link BeanValidator}, exercising the entrypoint
contract independent of the
+ * REST argument-resolver wiring covered elsewhere.
+ */
+class BeanValidator_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: isValidationRequested — recognizes @Valid (and equivalents) by
FQN, ignores non-validation annotations.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ static class A_Holder {
+ void noMarker(String x) {}
+ void jakartaValid(@jakarta.validation.Valid String x) {}
+ void deprecated(@Deprecated String x) {}
+ }
+
+ @Test
+ void a01_noMarker_returnsFalse() throws Exception {
+ var pi = paramInfo("noMarker", 0);
+ assertFalse(BeanValidator.isValidationRequested(pi));
+ }
+
+ @Test
+ void a02_jakartaValid_returnsTrue() throws Exception {
+ var pi = paramInfo("jakartaValid", 0);
+ assertTrue(BeanValidator.isValidationRequested(pi));
+ }
+
+ @Test
+ void a03_nonValidationAnnotation_returnsFalse() throws Exception {
+ var pi = paramInfo("deprecated", 0);
+ assertFalse(BeanValidator.isValidationRequested(pi));
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: validate(null, ...) is a hard no-op — Jakarta's
Validator.validate(null) throws IAE, and we don't want to
+ // surface that as a 500 when the upstream arg resolver already handled
the "missing body" case as a 400.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void b01_nullBean_returnsNull() {
+ // Null bean + null beanStore — even when the default validator
can be resolved successfully, a null bean
+ // short-circuits before any provider lookup.
+ assertNull(BeanValidator.validate((Object)null, null));
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // C: Happy / sad path through the default JVM-wide validator. This
implicitly verifies that the test classpath
+ // carries a provider (Hibernate Validator + Glassfish Expressly) — if
either is missing the test will fail loudly
+ // with a missing-provider degradation rather than a constraint check.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ static class C_Bean {
+ @NotNull
+ String name;
+ }
+
+ @Test
+ void c01_validate_validBean_returnsSame() {
+ var b = new C_Bean();
+ b.name = "alice";
+ assertSame(b, BeanValidator.validate(b, null));
+ }
+
+ @Test
+ void c02_validate_violatingBean_throwsValidationException() {
+ var b = new C_Bean(); // name is null — violates @NotNull
+ var ex = assertThrows(ValidationException.class, () ->
BeanValidator.validate(b, null));
+ assertEquals(1, ex.getViolations().size());
+ assertEquals("name", ex.getViolations().get(0).getPath());
+ assertEquals("NotNull",
ex.getViolations().get(0).getConstraint());
+ // invalidValue is omitted by default — guarded by
ValidationViolation, not BeanValidator, but worth pinning
+ // here to catch regressions where the dispatcher accidentally
populates it from the ConstraintViolation.
+ assertNull(ex.getViolations().get(0).getInvalidValue());
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // D: ValidationException carries the immutable copy contract.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void d01_getViolations_isUnmodifiable() {
+ var ex = new ValidationException(List.of(new
ValidationViolation("x", "msg", "NotNull")));
+ assertThrows(UnsupportedOperationException.class, () ->
ex.getViolations().add(null));
+ }
+
+ @Test
+ void d02_copyViolations_isMutableAndIndependent() {
+ var ex = new ValidationException(List.of(new
ValidationViolation("x", "msg", "NotNull")));
+ var copy = ex.copyViolations();
+ copy.clear();
+ assertEquals(1, ex.getViolations().size());
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Helpers
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ private static ParameterInfo paramInfo(String methodName, int index)
throws Exception {
+ Method m = null;
+ for (var mm : A_Holder.class.getDeclaredMethods()) {
+ if (mm.getName().equals(methodName)) {
+ m = mm;
+ break;
+ }
+ }
+ assertNotNull(m, "method " + methodName + " not found");
+ return ParameterInfo.of(m.getParameters()[index]);
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_Content_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_Content_Test.java
new file mode 100644
index 0000000000..0984c32867
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_Content_Test.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.*;
+import jakarta.validation.constraints.*;
+
+/**
+ * Coverage for the "happy / sad" pairs of common Jakarta Bean
Validation constraints when applied to
+ * {@code @Content}-bound request beans on opted-in {@code @RestOp} handlers.
Each block exercises one
+ * constraint type so a regression in the dispatch-or-render path surfaces
with a clear test name.
+ */
+class RestValidation_Content_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: @NotBlank — String must contain non-whitespace content.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class ANameBean {
+ @NotBlank
+ public String name;
+ }
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class A {
+ @RestPost("/echo")
+ public String echo(@Valid @Content ANameBean bean) {
+ return "ok:" + bean.name;
+ }
+ }
+
+ @Test
+ void a01_notBlank_valid() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/echo", "{\"name\":\"alice\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"ok:alice\"");
+ }
+
+ @Test
+ void a02_notBlank_violated() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/echo", "{\"name\":\"\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"name\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: @Size — String length range.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class BCodeBean {
+ @Size(min=3, max=5)
+ public String code;
+ }
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class B {
+ @RestPost("/code")
+ public String submit(@Valid @Content BCodeBean bean) {
+ return "ok:" + bean.code;
+ }
+ }
+
+ @Test
+ void b01_size_violated_tooShort() throws Exception {
+ var b = MockRestClient.buildLax(B.class);
+ b.post("/code", "{\"code\":\"ab\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"code\"");
+ }
+
+ @Test
+ void b02_size_violated_tooLong() throws Exception {
+ var b = MockRestClient.buildLax(B.class);
+ b.post("/code", "{\"code\":\"abcdef\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"code\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // C: @Min / @Max numeric range — multi-field bean with two distinct
constraints.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class CRangeBean {
+ @Min(1)
+ public int qty;
+ @Max(100)
+ public int pct;
+ }
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class C {
+ @RestPost("/range")
+ public String submit(@Valid @Content CRangeBean bean) {
+ return "ok";
+ }
+ }
+
+ @Test
+ void c01_minViolated_listsPath() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ c.post("/range", "{\"qty\":0,\"pct\":50}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"qty\"");
+ }
+
+ @Test
+ void c02_maxViolated_listsPath() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ c.post("/range", "{\"qty\":1,\"pct\":101}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"pct\"");
+ }
+
+ @Test
+ void c03_bothViolated_payloadCarriesBothErrors() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ c.post("/range", "{\"qty\":0,\"pct\":101}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"pct\"",
"\"path\":\"qty\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // D: @Pattern — regex must match.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class DPatternBean {
+ @Pattern(regexp="^[A-Z]{3}$")
+ public String code;
+ }
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class D {
+ @RestPost("/pattern")
+ public String submit(@Valid @Content DPatternBean bean) {
+ return "ok";
+ }
+ }
+
+ @Test
+ void d01_pattern_violated() throws Exception {
+ var d = MockRestClient.buildLax(D.class);
+ d.post("/pattern", "{\"code\":\"abc\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"code\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // E: @NotNull — required field absent.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class ERequiredBean {
+ @NotNull
+ public String value;
+ }
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class E {
+ @RestPost("/required")
+ public String submit(@Valid @Content ERequiredBean bean) {
+ return "ok";
+ }
+ }
+
+ @Test
+ void e01_notNull_violated_absent() throws Exception {
+ var e = MockRestClient.buildLax(E.class);
+ e.post("/required", "{}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"path\":\"value\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // F: invalidValue suppression — payload must NOT echo the offending
value back to the client by default.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class FSecretBean {
+ @Size(min=10)
+ public String creditCard;
+ }
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class F {
+ @RestPost("/secret")
+ public String submit(@Valid @Content FSecretBean bean) {
+ return "ok";
+ }
+ }
+
+ @Test
+ void f01_invalidValue_omittedByDefault() throws Exception {
+ var f = MockRestClient.buildLax(F.class);
+ f.post("/secret", "{\"creditCard\":\"1234\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ // The offending value "1234" must NOT appear in the
response body.
+ .assertContent().isNotContains("\"invalidValue\"",
"1234");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_CustomValidator_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_CustomValidator_Test.java
new file mode 100644
index 0000000000..b25df75ab9
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_CustomValidator_Test.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import java.lang.annotation.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.*;
+import jakarta.validation.constraints.*;
+
+/**
+ * Coverage for user-defined Jakarta Bean Validation constraint annotations.
+ *
+ * <p>
+ * Verifies that Juneau's integration delegates entirely to the underlying
{@code jakarta.validation}
+ * engine for constraint discovery — meaning a user can ship their own
{@code @Constraint}
+ * annotation backed by a {@code ConstraintValidator} and have it picked up
alongside the built-in
+ * {@code jakarta.validation.constraints.*} family with no Juneau-specific
glue.
+ */
+class RestValidation_CustomValidator_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Custom @Sku constraint + validator. The validator just enforces a
fixed regex; the point of the
+ // test isn't the constraint itself but to prove user-defined
annotations participate in the
+ // Juneau-dispatched validation pass.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Target({ ElementType.FIELD })
+ @Retention(RetentionPolicy.RUNTIME)
+ @Constraint(validatedBy = SkuValidator.class)
+ public @interface Sku {
+
+ String message() default "must match SKU-NNNN format";
+
+ Class<?>[] groups() default {};
+
+ Class<? extends jakarta.validation.Payload>[] payload() default
{};
+ }
+
+ public static class SkuValidator implements ConstraintValidator<Sku,
String> {
+
+ @Override
+ public boolean isValid(String value, ConstraintValidatorContext
context) {
+ return value != null && value.matches("SKU-\\d{4}");
+ }
+ }
+
+ public static class Order {
+
+ @Sku
+ public String sku;
+
+ @Min(1)
+ public int qty;
+ }
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class A {
+
+ @RestPost("/order")
+ public String create(@Valid @Content Order o) {
+ return "ok:" + o.sku;
+ }
+ }
+
+ @Test
+ void a01_customConstraint_violation_isReported() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ // SKU regex requires SKU-NNNN; "BAD" fails the custom
validator. Also verifies the constraint
+ // surfaces by its simple-name (`Sku`) in the errors[] payload,
matching how built-in constraints
+ // like NotBlank are reported.
+ a.post("/order", "{\"sku\":\"BAD\",\"qty\":1}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"status\":400",
"\"errors\":[", "\"sku\"", "Sku");
+ }
+
+ @Test
+ void a02_customConstraint_satisfied_reachesHandler() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/order", "{\"sku\":\"SKU-1234\",\"qty\":1}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"ok:SKU-1234\"");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_MissingProvider_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_MissingProvider_Test.java
new file mode 100644
index 0000000000..908256b1df
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_MissingProvider_Test.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.*;
+import jakarta.validation.constraints.*;
+
+/**
+ * Graceful-degradation coverage for the missing-provider scenario.
+ *
+ * <p>
+ * Simulates the "{@code jakarta.validation-api} is on the classpath at
compile time but no
+ * concrete provider engine is reachable at runtime" case — i.e.
the deployment forgot to
+ * ship Hibernate Validator (or an equivalent). The contract is: {@code
@Valid} markers are still
+ * detected (so arg resolvers don't crash), but the actual {@code
validator.validate(...)} call is
+ * silently skipped and the bean reaches the handler unchanged. A one-shot
{@code WARNING} log is
+ * emitted so misconfigured deployments are visible without flooding the log.
+ *
+ * <p>
+ * Implementation note: We can't actually remove Hibernate Validator from the
test classpath, so the
+ * "no provider" state is forced via {@link
BeanValidator#simulateProviderMissingForTesting()}
+ * and reset between tests so other test classes aren't affected.
+ */
+class RestValidation_MissingProvider_Test extends TestBase {
+
+ @AfterEach
+ void resetValidatorCache() {
+ BeanValidator.resetCachedDefaultForTesting();
+ }
+
+ public static class Payload {
+
+ @NotBlank
+ public String name;
+ }
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class A {
+
+ @RestPost("/echo")
+ public String echo(@Valid @Content Payload p) {
+ return "ok:[" + (p.name == null ? "" : p.name) + "]";
+ }
+ }
+
+ @Test
+ void a01_missingProvider_violatingPayload_isPassedThrough() throws
Exception {
+ BeanValidator.simulateProviderMissingForTesting();
+ var a = MockRestClient.buildLax(A.class);
+ // Even though @Valid is present and the bean has a
@NotBlank-violating field, with the provider
+ // simulated as missing the validator is skipped and the bean
reaches the handler unchanged.
+ a.post("/echo", "{\"name\":\"\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"ok:[]\"");
+ }
+
+ @Test
+ void a02_missingProvider_validPayload_alsoPassesThrough() throws
Exception {
+ BeanValidator.simulateProviderMissingForTesting();
+ var a = MockRestClient.buildLax(A.class);
+ // Sanity check — the missing-provider path also lets
valid payloads through. We're verifying the
+ // no-op behavior is symmetric (not biased toward letting only
good requests through by coincidence).
+ a.post("/echo", "{\"name\":\"alice\"}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"ok:[alice]\"");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_Nested_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_Nested_Test.java
new file mode 100644
index 0000000000..edad65c725
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_Nested_Test.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.*;
+import jakarta.validation.constraints.*;
+
+/**
+ * Cascading-validation coverage. Jakarta Validation's contract is that {@code
@Valid} on a nested
+ * property tells the validator to recurse into that property and re-evaluate
its constraints —
+ * but only if the parent's {@code @Valid} has triggered validation in the
first place.
+ *
+ * <p>
+ * These tests verify two contracts:
+ * <ol>
+ * <li>When the parent parameter is opted in via {@code @Valid}, cascading
{@code @Valid} on a
+ * nested property surfaces violations from the nested bean's fields.
+ * <li>The cascade still respects the off-by-default contract: if the parent
has no {@code @Valid},
+ * the nested {@code @Valid} alone does <i>not</i> trigger validation
— the validator is
+ * never invoked on the root and so the nested bean is never inspected
either.
+ * </ol>
+ */
+class RestValidation_Nested_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Domain model: Customer has-many Address. Address.zip is constrained;
Customer.addresses is
+ // marked @Valid so the validator recurses into each Address.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class Address {
+
+ @Pattern(regexp = "\\d{5}")
+ public String zip;
+ }
+
+ public static class Customer {
+
+ @NotBlank
+ public String name;
+
+ @Valid
+ public List<Address> addresses;
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: parent opted in via @Valid → nested @Valid cascades into
Address.zip.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class A {
+
+ @RestPost("/customer")
+ public String create(@Valid @Content Customer c) {
+ return "ok:" + c.name;
+ }
+ }
+
+ @Test
+ void a01_validParent_violatingChild_isBlocked() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ // Customer.name is fine but Address[0].zip violates the
@Pattern. Cascading should pick this up
+ // and the path should reflect the nested location
(addresses[0].zip), proving recursion happened.
+ a.post("/customer",
"{\"name\":\"alice\",\"addresses\":[{\"zip\":\"abc\"}]}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"status\":400",
"\"errors\":[", "addresses[0].zip", "Pattern");
+ }
+
+ @Test
+ void a02_validParent_violatingParent_isBlocked() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ // Sanity check — if the parent itself violates a
constraint, the parent's path surfaces too.
+ a.post("/customer",
"{\"name\":\"\",\"addresses\":[{\"zip\":\"12345\"}]}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"status\":400",
"\"errors\":[", "\"name\"", "NotBlank");
+ }
+
+ @Test
+ void a03_validParent_validChild_passesThrough() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/customer",
"{\"name\":\"alice\",\"addresses\":[{\"zip\":\"12345\"}]}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"ok:alice\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: parent NOT opted in → nested @Valid alone is inert. This is
the cross-cutting off-by-default
+ // invariant: cascading is a property of the validation pass, not a
side-channel that bypasses it.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class B {
+
+ @RestPost("/customer")
+ public String create(@Content Customer c) {
+ return "lax:" + (c.name == null ? "" : c.name);
+ }
+ }
+
+ @Test
+ void b01_noParentValid_nestedValidAlone_isInert() throws Exception {
+ var b = MockRestClient.buildLax(B.class);
+ // Same violating payload as a01. Without @Valid on the
parameter, the validator is never invoked,
+ // so even Address.zip's nested @Pattern is ignored. Bean
reaches handler unchanged.
+ b.post("/customer",
"{\"name\":\"\",\"addresses\":[{\"zip\":\"abc\"}]}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"lax:\"");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_OffByDefault_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_OffByDefault_Test.java
new file mode 100644
index 0000000000..ef21439067
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_OffByDefault_Test.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.constraints.*;
+
+/**
+ * Tests the non-negotiable "validation is opt-in and disabled by
default" contract for
+ * {@link BeanValidator} integration with Juneau REST.
+ *
+ * <p>
+ * Every test here pairs a request bean carrying Jakarta constraint
annotations with a {@code @RestOp} handler
+ * that does <b>not</b> opt in via {@code @Valid}. The expectation is that the
violating bean reaches the
+ * handler body unmodified — not blocked by validation. A second test in
each pair adds {@code @Valid} to
+ * the parameter to prove validation does run when opted in, providing the
positive-control bar for the
+ * negative assertion.
+ */
+class RestValidation_OffByDefault_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Shared request bean with strict constraints. Any of these violated
should produce a 400 — but only when @Valid is
+ // present on the parameter. Without @Valid the validator is never
invoked.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ public static class Order {
+ @NotBlank
+ public String sku;
+ @Min(1)
+ public int quantity;
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: @Content without @Valid — violating bean must reach the handler
(validation off by default).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class A {
+ @RestPost("/order")
+ public String submit(@Content Order order) {
+ return "got sku=[" + order.sku + "] qty=" +
order.quantity;
+ }
+ }
+
+ @Test
+ void a01_content_noValid_violatingBean_reachesHandler() throws
Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/order", "{\"sku\":\"\",\"quantity\":0}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"got sku=[] qty=0\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: @Content with @Valid — positive-control. Now the same violating
bean is blocked with 400.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class B {
+ @RestPost("/order")
+ public String submit(@jakarta.validation.Valid @Content Order
order) {
+ return "should not reach handler";
+ }
+ }
+
+ @Test
+ void b01_content_validPresent_violatingBean_isBlocked() throws
Exception {
+ var b = MockRestClient.buildLax(B.class);
+ b.post("/order", "{\"sku\":\"\",\"quantity\":0}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+ .assertContent().isContains("\"status\":400",
"\"errors\":[");
+ }
+
+ @Test
+ void b02_content_validPresent_validBean_reachesHandler() throws
Exception {
+ var b = MockRestClient.buildLax(B.class);
+ // Bean satisfies all constraints — even with @Valid,
validation passes and the handler runs (and throws an
+ // assertion since the handler returns a hard-coded "should not
reach" string — which IS what we expect on
+ // success, since the handler isn't really wired to do useful
work in this minimal test).
+ b.post("/order", "{\"sku\":\"WIDGET-1\",\"quantity\":3}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"should not reach handler\"");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // C: Multiple handlers in the same resource — adding @Valid to ONE
method must not enable validation on the OTHER
+ // (proves per-parameter granularity).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class C {
+ @RestPost("/strict")
+ public String strict(@jakarta.validation.Valid @Content Order
order) {
+ return "strict-ok";
+ }
+ @RestPost("/lax")
+ public String lax(@Content Order order) {
+ return "lax-ok sku=[" + order.sku + "] qty=" +
order.quantity;
+ }
+ }
+
+ @Test
+ void c01_perOpGranularity_strict_blocks() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ c.post("/strict", "{\"sku\":\"\",\"quantity\":0}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400);
+ }
+
+ @Test
+ void c02_perOpGranularity_lax_passesThrough() throws Exception {
+ var c = MockRestClient.buildLax(C.class);
+ // Same violating bean, sibling handler without @Valid — the
validator must NOT be invoked, and the bean
+ // must reach the lax handler unchanged. This is the
non-negotiable "off by default" contract.
+ c.post("/lax", "{\"sku\":\"\",\"quantity\":0}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"lax-ok sku=[] qty=0\"");
+ }
+}
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_ProblemDetails_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_ProblemDetails_Test.java
new file mode 100644
index 0000000000..78fd16e9d2
--- /dev/null
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/validation/RestValidation_ProblemDetails_Test.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.validation;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+
+import jakarta.validation.*;
+import jakarta.validation.constraints.*;
+
+/**
+ * Verifies the wire-shape contract for {@link ValidationException} under both
+ * {@code @Rest(problemDetails="true")} (RFC 7807) and the default (no opt-in)
modes.
+ */
+class RestValidation_ProblemDetails_Test extends TestBase {
+
+ public static class PaymentBean {
+ @NotBlank
+ public String account;
+ @Min(1)
+ public int amount;
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // A: problemDetails OFF (default) — application/json + { "status":400,
"errors":[...] } envelope.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(serializers=JsonSerializer.class, parsers=JsonParser.class,
defaultAccept="application/json")
+ public static class A {
+ @RestPost("/pay")
+ public String pay(@Valid @Content PaymentBean bean) {
+ return "ok";
+ }
+ }
+
+ @Test
+ void a01_noProblemDetails_emitsPlainJsonEnvelope() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/pay", "{\"account\":\"\",\"amount\":0}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+
.assertHeader("Content-Type").isContains("application/json")
+ .assertContent().isContains("\"status\":400",
"\"errors\":[");
+ }
+
+ @Test
+ void a02_noProblemDetails_envelopeIsNotProblemJson() throws Exception {
+ var a = MockRestClient.buildLax(A.class);
+ a.post("/pay", "{\"account\":\"\",\"amount\":0}")
+ .contentType("application/json")
+ .run()
+
.assertHeader("Content-Type").isNotContains("application/problem+json");
+ }
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // B: problemDetails ON — application/problem+json with RFC 7807
standard members + errors[] extension.
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ @Rest(problemDetails="true", serializers=JsonSerializer.class,
parsers=JsonParser.class, defaultAccept="application/json")
+ public static class B {
+ @RestPost("/pay")
+ public String pay(@Valid @Content PaymentBean bean) {
+ return "ok";
+ }
+ }
+
+ @Test
+ void b01_problemDetailsOn_emitsProblemJson_withErrorsExtension() throws
Exception {
+ var b = MockRestClient.buildLax(B.class);
+ b.post("/pay", "{\"account\":\"\",\"amount\":0}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(400)
+
.assertHeader("Content-Type").isContains("application/problem+json")
+ .assertContent().isContains(
+ "\"status\":400",
+ "\"title\":\"Bad Request\"",
+ "\"errors\":[",
+ "\"path\":\"account\"",
+ "\"path\":\"amount\"");
+ }
+
+ @Test
+ void b02_problemDetailsOn_acceptIgnoredOnErrorPath() throws Exception {
+ var b = MockRestClient.buildLax(B.class);
+ // Per RFC 7807 §3 and Juneau's existing problem-details
opt-in: error path ignores Accept and always emits
+ // problem+json on opted-in resources. Validation errors must
follow that same rule.
+ b.post("/pay", "{\"account\":\"\",\"amount\":0}")
+ .contentType("application/json")
+ .accept("text/html")
+ .run()
+ .assertStatus(400)
+
.assertHeader("Content-Type").isContains("application/problem+json");
+ }
+
+ @Test
+ void b03_problemDetailsOn_validBean_handlerStillRuns() throws Exception
{
+ // Sanity: opting in to problemDetails must not break the happy
path.
+ var b = MockRestClient.buildLax(B.class);
+ b.post("/pay", "{\"account\":\"ACCT-1\",\"amount\":100}")
+ .contentType("application/json")
+ .run()
+ .assertStatus(200)
+ .assertContent("\"ok\"");
+ }
+}