001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.server.validation; 018 019import java.util.*; 020import java.util.concurrent.atomic.*; 021import java.util.logging.*; 022import java.util.stream.*; 023 024import org.apache.juneau.commons.inject.*; 025import org.apache.juneau.commons.reflect.*; 026 027import jakarta.validation.*; 028 029/** 030 * Static dispatcher for the optional Jakarta Bean Validation 3.x integration. 031 * 032 * <p> 033 * Acts as a thin shim between Juneau's REST argument resolvers ({@code ContentArg}, {@code FormDataArg}, 034 * {@code RequestBeanArg}) and the {@code jakarta.validation.Validator} engine. Per the package-level contract 035 * (see {@link org.apache.juneau.rest.server.validation package overview}), <b>validation is off by default and never 036 * runs automatically</b>: callers query {@link #isValidationRequested(ParameterInfo)} once at arg-resolver 037 * construction time, then conditionally invoke {@link #validate(Object, BeanStore)} per request only when an 038 * explicit {@code @jakarta.validation.Valid} (or equivalent) marker is present. 039 * 040 * <h5 class='topic'>Validator resolution order</h5> 041 * <ol> 042 * <li>A user-supplied {@code Validator} bean visible from the supplied {@link BeanStore} — typically 043 * contributed via a {@code @Bean public Validator validator()} factory on a {@code @Rest}-annotated 044 * resource. This is the recommended path when the application needs custom message interpolators, 045 * group sequences, or constraint-validator factories. 046 * <li>A lazily-built JVM-wide default obtained from 047 * {@link Validation#buildDefaultValidatorFactory()}. Cached after the first successful build so the 048 * factory cost is paid at most once per JVM. 049 * <li>{@code null} — signals graceful degradation when neither a bean-store entry nor a runtime 050 * provider is available. {@link #validate(Object, BeanStore)} returns the input bean unchanged in this 051 * case and logs a single {@code WARNING} per JVM so misconfigured deployments are visible without 052 * flooding the log. 053 * </ol> 054 * 055 * <h5 class='topic'>Why static dispatch?</h5> 056 * <p> 057 * Each REST argument resolver is built once per {@code @RestOp} method at servlet startup, then invoked on 058 * every request. By keeping the validator lookup off the arg-resolver constructor and inside this class's 059 * cached static accessor, the cost of locating the {@code Validator} is paid once per JVM rather than once 060 * per request — and resolvers for {@code @RestOp} methods that never opt in pay nothing at all (the 061 * static initializer doesn't run until the first call to {@link #validate(Object, BeanStore)}). 062 * 063 * <h5 class='section'>See Also:</h5><ul> 064 * <li class='jc'>{@link ValidationException} 065 * <li class='jc'>{@link ValidationViolation} 066 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerValidation">Jakarta Bean Validation</a> 067 * </ul> 068 * 069 * @since 10.0.0 070 */ 071public final class BeanValidator { 072 073 private static final Logger LOG = Logger.getLogger(BeanValidator.class.getName()); 074 075 /** 076 * Holds the lazily-resolved JVM-wide default {@link Validator} (built once on first need from 077 * {@link Validation#buildDefaultValidatorFactory()}). {@code null} until first successful build. 078 */ 079 private static final AtomicReference<Validator> DEFAULT_VALIDATOR = new AtomicReference<>(); 080 081 /** 082 * Flips to {@code true} once we've discovered — via either a {@link ValidationException} from the 083 * provider lookup or a {@link Throwable} from {@link Validation#buildDefaultValidatorFactory()} — 084 * that no runtime validator engine is reachable on this classpath. Short-circuits subsequent calls so we 085 * don't repeatedly pay the failed-lookup cost. 086 */ 087 private static final AtomicBoolean DEFAULT_PROVIDER_UNAVAILABLE = new AtomicBoolean(); 088 089 /** One-shot guard so the "no provider on classpath" warning only logs once per JVM. */ 090 private static final AtomicBoolean MISSING_PROVIDER_LOGGED = new AtomicBoolean(); 091 092 private BeanValidator() {} 093 094 /** 095 * Returns <jk>true</jk> if the supplied parameter carries any of the recognized validation opt-in 096 * markers (currently {@code jakarta.validation.Valid}, {@code javax.validation.Valid}, or Spring's 097 * {@code @Validated}). 098 * 099 * <p> 100 * Detection is purely FQN-based via {@link JsrSupport#isValidAnnotation(AnnotationInfo)} — no 101 * compile-time dependency on the Jakarta Validation or Spring annotation classes themselves is required 102 * to recognize the marker. 103 * 104 * <p> 105 * Arg resolvers should call this exactly once at construction time and cache the result; the per-request 106 * hot path then becomes a single boolean check before deciding whether to call 107 * {@link #validate(Object, BeanStore)}. 108 * 109 * @param paramInfo The Java method parameter being inspected. Must not be <jk>null</jk>. 110 * @return <jk>true</jk> if the parameter has been opted into validation. 111 */ 112 public static boolean isValidationRequested(ParameterInfo paramInfo) { 113 return paramInfo.getAnnotations().stream().anyMatch(JsrSupport::isValidAnnotation); 114 } 115 116 /** 117 * Runs Jakarta Bean Validation against the supplied bean and throws a {@link ValidationException} if any 118 * constraints are violated. 119 * 120 * <p> 121 * Resolution order for the {@link Validator} is described on the class javadoc. When no validator can 122 * be obtained (no bean-store entry, no runtime provider), this method logs a one-shot warning and 123 * returns {@code bean} unchanged — preserving the off-by-default contract even when an arg 124 * resolver was constructed with {@code @Valid} but the deployment forgot to ship a provider. 125 * 126 * <p> 127 * A {@code null} {@code bean} returns {@code null} without invoking the validator (Jakarta's 128 * {@code validator.validate(null)} throws {@code IllegalArgumentException}, which would just produce an 129 * unhelpful 500 instead of the natural "missing body" 400 the upstream arg resolver already 130 * surfaces). 131 * 132 * @param <T> The bean type being validated. 133 * @param bean The resolved bean to validate. May be <jk>null</jk>. 134 * @param beanStore The op-session bean store consulted for a user-supplied {@link Validator}. May be 135 * <jk>null</jk> (treated as "no user override"). 136 * @return The input {@code bean} (unchanged). 137 * @throws ValidationException If one or more constraints are violated. 138 */ 139 public static <T> T validate(T bean, BeanStore beanStore) { 140 if (bean == null) 141 return null; 142 var validator = resolveValidator(beanStore); 143 if (validator == null) { 144 warnMissingProviderOnce(); 145 return bean; 146 } 147 var violations = validator.validate(bean); 148 if (violations.isEmpty()) 149 return bean; 150 throw new ValidationException(toViolationList(violations)); 151 } 152 153 /** 154 * Test/diagnostic hook — resets the JVM-wide default-validator cache so the next call to 155 * {@link #validate(Object, BeanStore)} re-resolves the provider. Not part of the public contract; 156 * intended for unit tests that simulate provider-missing scenarios. 157 */ 158 public static void resetCachedDefaultForTesting() { 159 DEFAULT_VALIDATOR.set(null); 160 DEFAULT_PROVIDER_UNAVAILABLE.set(false); 161 MISSING_PROVIDER_LOGGED.set(false); 162 } 163 164 /** 165 * Test/diagnostic hook — forces the "no provider on classpath" code path. Not part of 166 * the public contract; intended for unit tests that need to assert the graceful-degradation behavior 167 * without actually removing the Hibernate Validator JAR from the test classpath. 168 * 169 * <p> 170 * After calling this, {@link #validate(Object, BeanStore)} will behave as if no Jakarta Bean 171 * Validation provider is reachable: it short-circuits to a no-op and emits the one-shot 172 * "missing provider" warning. Call {@link #resetCachedDefaultForTesting()} afterwards to 173 * restore normal lookup. 174 */ 175 public static void simulateProviderMissingForTesting() { 176 DEFAULT_VALIDATOR.set(null); 177 DEFAULT_PROVIDER_UNAVAILABLE.set(true); 178 MISSING_PROVIDER_LOGGED.set(false); 179 } 180 181 private static Validator resolveValidator(BeanStore beanStore) { 182 if (beanStore != null) { 183 var fromStore = beanStore.getBean(Validator.class).orElse(null); 184 if (fromStore != null) 185 return fromStore; 186 } 187 return getDefaultValidator(); 188 } 189 190 @SuppressWarnings({ 191 "resource" // ValidatorFactory is intentionally not closed; it is cached for the process lifetime. 192 }) 193 private static Validator getDefaultValidator() { 194 if (DEFAULT_PROVIDER_UNAVAILABLE.get()) 195 return null; 196 var cached = DEFAULT_VALIDATOR.get(); 197 if (cached != null) 198 return cached; 199 try { 200 var built = Validation.buildDefaultValidatorFactory().getValidator(); 201 DEFAULT_VALIDATOR.compareAndSet(null, built); 202 return DEFAULT_VALIDATOR.get(); 203 } catch (Throwable t) { // NOSONAR: ValidationException, NoClassDefFoundError, LinkageError all in scope here. 204 DEFAULT_PROVIDER_UNAVAILABLE.set(true); 205 return null; 206 } 207 } 208 209 private static void warnMissingProviderOnce() { 210 if (MISSING_PROVIDER_LOGGED.compareAndSet(false, true)) { 211 LOG.log(Level.WARNING, 212 "Jakarta Bean Validation was requested via @Valid on a REST argument, but no jakarta.validation.Validator " + 213 "bean is registered and no runtime provider (e.g. org.hibernate.validator:hibernate-validator + " + 214 "jakarta.el implementation) is on the classpath. Validation will be silently skipped for this and " + 215 "subsequent requests. Add a provider dependency or register a Validator bean to enable validation."); 216 } 217 } 218 219 @SuppressWarnings({ 220 "java:S3776" // Cognitive complexity is marginally over (16) due to null-guard ternaries and the stable-ordering comparator; the validation payload shape is asserted by tests, so the logic is kept inline rather than fragmented. 221 }) 222 private static List<ValidationViolation> toViolationList(Set<? extends ConstraintViolation<?>> violations) { 223 var result = violations.stream().map(cv -> new ValidationViolation( 224 cv.getPropertyPath() == null ? null : cv.getPropertyPath().toString(), 225 cv.getMessage(), 226 cv.getConstraintDescriptor() == null || cv.getConstraintDescriptor().getAnnotation() == null 227 ? null 228 : cv.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName() 229 )).collect(Collectors.toCollection(ArrayList::new)); 230 // Stable ordering — Jakarta returns a Set, but tests assert on payload shape so we sort by path+message. 231 result.sort((a, b) -> { 232 var ap = a.getPath() == null ? "" : a.getPath(); 233 var bp = b.getPath() == null ? "" : b.getPath(); 234 var c = ap.compareTo(bp); 235 if (c != 0) 236 return c; 237 var am = a.getMessage() == null ? "" : a.getMessage(); 238 var bm = b.getMessage() == null ? "" : b.getMessage(); 239 return am.compareTo(bm); 240 }); 241 return result; 242 } 243}