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 &mdash; 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> &mdash; 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) &mdash; validation 
needs its own per-field payload.
+        *      <li><b>Problem-details OFF (default)</b> &mdash; 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 &mdash; {@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 &mdash; {@code true} iff this parameter carries a 
Jakarta Bean Validation
+        * {@code @Valid} (or equivalent) marker. Off-by-default opt-in &mdash; 
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 &mdash; {@code true} iff this parameter carries a 
Jakarta Bean Validation
+        * {@code @Valid} (or equivalent) marker. Off-by-default opt-in &mdash; 
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} &mdash; 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} &mdash; 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 &mdash; 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 
&mdash; 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 &mdash; via either a 
{@link ValidationException} from the
+        * provider lookup or a {@link Throwable} from {@link 
Validation#buildDefaultValidatorFactory()} &mdash;
+        * 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 &quot;no provider on classpath&quot; 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)} &mdash; 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 &mdash; 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 &quot;missing body&quot; 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 &quot;no user override&quot;).
+        * @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 &mdash; 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 &mdash; forces the &quot;no provider on 
classpath&quot; 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
+        * &quot;missing provider&quot; 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 
&mdash; same status code, same reason
+ * phrase, same default JSON envelope shape used by the rest of the 
framework's exception path &mdash; 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> &mdash; {@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> &mdash; {@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 
&mdash; 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
+        *      &quot;validation requested but no violations&quot; &mdash; 
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 
&mdash; 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 &mdash; 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} &mdash; declarative validation 
of request beans via
+ * {@code jakarta.validation.constraints.*} annotations.
+ *
+ * <h5 class='topic'>Off by default &mdash; 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 &mdash; 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 &quot;turn on validation for the whole resource&quot; 
switch &mdash; 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 &mdash; 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> &mdash; {@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> &mdash; {@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 
&mdash; 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 &quot;happy / sad&quot; 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 &mdash; 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 &quot;{@code jakarta.validation-api} is on the classpath at 
compile time but no
+ * concrete provider engine is reachable at runtime&quot; case &mdash; 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
+ * &quot;no provider&quot; 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 &mdash; 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 &mdash;
+ * 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 
&mdash; 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 &rarr; 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 &mdash; 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 &rarr; 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 &quot;validation is opt-in and disabled by 
default&quot; 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 &mdash; 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\"");
+       }
+}

Reply via email to