You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/26 20:07:45 UTC

(juneau) branch master updated: feat(rest): Jakarta Bean Validation 3.x integration — opt-in via @Valid, off by default (TODO-68)

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 <ja...@apache.org>
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\"");
+	}
+}