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} &mdash; 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} &mdash; 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 &mdash; 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 &mdash; via either a {@link ValidationException} from the
083    * provider lookup or a {@link Throwable} from {@link Validation#buildDefaultValidatorFactory()} &mdash;
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 &quot;no provider on classpath&quot; 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)} &mdash; 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 &mdash; 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 &quot;missing body&quot; 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 &quot;no user override&quot;).
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 &mdash; 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 &mdash; forces the &quot;no provider on classpath&quot; 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    * &quot;missing provider&quot; 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}