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.bean.jsonschema;
018
019import static org.apache.juneau.commons.utils.ObjectUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.lang.reflect.*;
023import java.math.*;
024import java.util.*;
025import java.util.regex.*;
026
027import org.apache.juneau.commons.bean.*;
028import org.apache.juneau.commons.httppart.*;
029import org.apache.juneau.marshall.collections.*;
030import org.apache.juneau.marshall.marshaller.*;
031
032/**
033 * Validates values against a {@link JsonSchema} bean per the JSON Schema Draft 2020-12 specification.
034 *
035 * <p>
036 * This is the {@code juneau-bean-jsonschema} implementation of the commons-side
037 * {@link PropertyValidator} SPI.  It is registered as a {@link PropertyValidatorFactory} via
038 * {@link ServiceLoader ServiceLoader} so that marshalling-side code (e.g.
039 * {@code MarshalledPropertyPostProcessor}) can install schema validation closures on bean properties without taking a
040 * compile-time dependency on this module.
041 *
042 * <h5 class='section'>Supported keywords (v1):</h5>
043 * <ul class='spaced-list'>
044 *    <li>Any type: {@code type}, {@code enum}, {@code const}.
045 *    <li>Numeric: {@code minimum}, {@code maximum}, {@code exclusiveMinimum}, {@code exclusiveMaximum},
046 *       {@code multipleOf} (Draft 2020-12 numeric form for the exclusive bounds).
047 *    <li>String: {@code minLength}, {@code maxLength}, {@code pattern}.
048 *    <li>Array: {@code minItems}, {@code maxItems}, {@code uniqueItems}, {@code items} (recursive — single-schema
049 *       form only; tuple form via {@link JsonSchemaArray} is not yet validated).
050 *    <li>Object: {@code minProperties}, {@code maxProperties}, {@code required}, {@code properties} (recursive on
051 *       {@link Map}-shaped values).
052 * </ul>
053 *
054 * <h5 class='section'>Deferred keywords:</h5>
055 * <ul class='spaced-list'>
056 *    <li>{@code format} — informational only by default; validators may treat it as advisory.
057 *    <li>{@code patternProperties}, {@code additionalProperties}, {@code prefixItems}, {@code contains},
058 *       {@code minContains}, {@code maxContains}, {@code dependentRequired}, {@code dependentSchemas}.
059 *    <li>Composition keywords: {@code allOf}, {@code anyOf}, {@code oneOf}, {@code not}, {@code if}/{@code then}/{@code else}.
060 *    <li>References: {@code $ref}, {@code $defs} (the bean carries them; the validator does not resolve them yet).
061 * </ul>
062 *
063 * <h5 class='section'>Descriptive keywords (always ignored):</h5>
064 * <ul class='spaced-list'>
065 *    <li>{@code title}, {@code summary}, {@code description}, {@code examples}, {@code $comment}, {@code deprecated},
066 *       {@code readOnly}, {@code writeOnly}.
067 * </ul>
068 *
069 * <h5 class='section'>Java-value mapping:</h5>
070 * <p>
071 * Java values are matched against JSON Schema {@code type} keywords as follows:
072 * <ul class='spaced-list'>
073 *    <li>{@code string} — {@link CharSequence} or {@link Character}.
074 *    <li>{@code integer} — integral {@link Number} (Byte, Short, Integer, Long, BigInteger) or a floating-point number
075 *       whose value has no fractional part.
076 *    <li>{@code number} — any {@link Number}.
077 *    <li>{@code boolean} — {@link Boolean}.
078 *    <li>{@code array} — {@link Collection} or Java array.
079 *    <li>{@code object} — {@link Map} or any other reference type (treated as a bean — the marshalling layer installs
080 *       separate validators on the bean's own {@code @Schema}-annotated properties).
081 *    <li>{@code null} — {@code null}.
082 *    <li>{@code any} — always matches.
083 * </ul>
084 *
085 * <h5 class='section'>Nested validation:</h5>
086 * <p>
087 * For property-level validation, the validator recurses into:
088 * <ul class='spaced-list'>
089 *    <li>{@code items} — every element of an array / collection is validated against the items schema.
090 *    <li>{@code properties} on a {@link Map} value — each declared sub-property is validated against its sub-schema.
091 * </ul>
092 *
093 * <p>
094 * The validator does <b>not</b> recurse into Java beans via reflection.  Inner {@code @Schema} annotations on a
095 * nested bean's own properties are validated independently by the marshalling layer's per-property install hooks.
096 *
097 * <h5 class='section'>Thread safety:</h5>
098 * Instances are immutable and safe to share across threads.  The compiled {@link Pattern} for the {@code pattern}
099 * keyword is cached on construction.
100 *
101 * <h5 class='section'>Interruptible {@code pattern} matching:</h5>
102 * <p>
103 * A {@code pattern} keyword can encode a catastrophically-backtracking regular expression that a hostile input can
104 * drive into effectively-unbounded work. {@link java.util.regex.Matcher} never checks {@link Thread#isInterrupted()},
105 * so such a match cannot be stopped by interrupting the matching thread - it runs to completion, pinning a core even
106 * after the caller has given up. To make a runaway match abortable, the string is fed to the matcher through an
107 * {@link InterruptibleCharSequence}: because the matcher reads its input exclusively via {@link CharSequence#charAt(int)},
108 * every character read periodically samples the thread's interrupt status and, once set, throws to unwind the match
109 * promptly. A caller that bounds validation on a worker thread (for example an MCP {@code tools/call} DoS guard) can
110 * therefore reclaim the thread by interrupting it, rather than leaking a core to a match that ignores cancellation.
111 *
112 * <h5 class='section'>See Also:</h5><ul>
113 *    <li class='link'><a href="https://json-schema.org/draft/2020-12/json-schema-validation.html">JSON Schema 2020-12 Validation</a>
114 * </ul>
115 *
116 * @see PropertyValidator
117 * @see JsonSchemaPropertyValidatorFactory
118 * @since 10.0.0
119 */
120@SuppressWarnings({
121   "java:S3776" // validateAgainst(), validateNumber() and validateObject() check many independent JSON Schema keywords in a single pass each
122})
123public final class JsonSchemaValidator implements PropertyValidator {
124
125   private final JsonSchema<?> schema;
126   private final Pattern patternCache;
127
128   /**
129    * Builds a validator from a {@link JsonSchema} bean.
130    *
131    * @param schema The schema to validate against.  Must not be <jk>null</jk>, or an {@link IllegalArgumentException} is thrown.
132    * @return A new validator.
133    */
134   public static JsonSchemaValidator of(JsonSchema<?> schema) {
135      reqnn("schema", schema);
136      return new JsonSchemaValidator(schema);
137   }
138
139   /**
140    * Builds a validator from a JSON-Schema-shaped {@link JsonMap}.
141    *
142    * <p>
143    * Equivalent to {@code of(JsonSchemaBeanGenerator.toBean(schemaMap))}.
144    *
145    * @param schemaMap The schema map.  Must not be <jk>null</jk>, or an {@link IllegalArgumentException} is thrown.
146    * @return A new validator.
147    */
148   public static JsonSchemaValidator of(JsonMap schemaMap) {
149      reqnn("schemaMap", schemaMap);
150      return of(JsonSchemaBeanGenerator.toBean(schemaMap));
151   }
152
153   private JsonSchemaValidator(JsonSchema<?> schema) {
154      this.schema = schema;
155      var p = schema.getPattern();
156      this.patternCache = p == null ? null : Pattern.compile(p);
157   }
158
159   /**
160    * Returns the schema this validator was built from.
161    *
162    * @return The schema.
163    */
164   @SuppressWarnings({
165      "java:S1452" // Self-bounded (CRTP) generic: the validated schema's concrete SELF subtype is not known here, so JsonSchema<?> is the only sound return type.
166   })
167   public JsonSchema<?> getSchema() {
168      return schema;
169   }
170
171   @Override
172   public void validate(Object value) throws SchemaValidationException {
173      validateAgainst(schema, value, patternCache);
174   }
175
176   private static void validateAgainst(JsonSchema<?> s, Object value, Pattern patternOverride) {
177      validateEnum(s, value);
178      validateConst(s, value);
179
180      if (value == null)
181         return;
182
183      validateType(s, value);
184
185      if (value instanceof CharSequence value2) {
186         validateString(s, value2.toString(), patternOverride);
187      } else if (value instanceof Character value2) {
188         validateString(s, value2.toString(), patternOverride);
189      } else if (value instanceof Number value2) {
190         validateNumber(s, value2);
191      } else if (value instanceof Collection<?> value2) {
192         validateArray(s, value2);
193      } else if (value.getClass().isArray()) {
194         validateArray(s, arrayAsList(value));
195      } else if (value instanceof Map<?,?> value2) {
196         validateObject(s, value2);
197      }
198      // Java beans: per-property @Schema validators are installed independently on the bean side.
199   }
200
201   // =================================================================================================================
202   // Type / enum / const
203   // =================================================================================================================
204
205   private static void validateEnum(JsonSchema<?> s, Object value) {
206      var enums = s.getEnum();
207      if (ie(enums))
208         return;
209      for (var e : enums) {
210         if (jsonEquals(e, value))
211            return;
212      }
213      throw new SchemaValidationException("Value '%s' does not match one of the allowed enum values.", value);
214   }
215
216   private static void validateConst(JsonSchema<?> s, Object value) {
217      var c = s.getConst();
218      if (c == null)
219         return;  // const=null is indistinguishable from "not set" in the bean; treat as unset.
220      if (! jsonEquals(c, value))
221         throw new SchemaValidationException("Value '%s' does not match required const '%s'.", value, c);
222   }
223
224   private static void validateType(JsonSchema<?> s, Object value) {
225      var single = s.getTypeAsJsonType();
226      if (nn(single)) {
227         if (! matchesType(single, value))
228            throw new SchemaValidationException("Value of type '%s' does not match expected schema type '%s'.", typeName(value), single);
229         return;
230      }
231      var arr = s.getTypeAsJsonTypeArray();
232      if (ie(arr))
233         return;
234      for (var t : arr) {
235         if (matchesType(t, value))
236            return;
237      }
238      throw new SchemaValidationException("Value of type '%s' does not match any expected schema types '%s'.", typeName(value), arr);
239   }
240
241   @SuppressWarnings({
242      "java:S1142"  // Multiple returns — switch arms each return their own predicate result.
243   })
244   private static boolean matchesType(JsonType t, Object value) {
245      switch (t) {
246         case STRING:
247            return value instanceof CharSequence || value instanceof Character;
248         case INTEGER:
249            return isIntegralValue(value);
250         case NUMBER:
251            return value instanceof Number;
252         case BOOLEAN:
253            return value instanceof Boolean;
254         case ARRAY:
255            return value instanceof Collection<?> || (nn(value) && value.getClass().isArray());
256         case OBJECT:
257            return value instanceof Map<?,?> || isBeanLike(value);
258         case NULL:
259            return value == null;
260         case ANY:
261            return true;
262         default:
263            return true;
264      }
265   }
266
267   private static boolean isIntegralValue(Object value) {
268      if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long || value instanceof BigInteger)
269         return true;
270      if (value instanceof Number value2) {
271         try {
272            var bd = toBigDecimal(value2);
273            return bd.stripTrailingZeros().scale() <= 0;
274         } catch (@SuppressWarnings("unused") NumberFormatException e) {
275            return false;
276         }
277      }
278      return false;
279   }
280
281   private static boolean isBeanLike(Object value) {
282      if (value == null)
283         return false;
284      var c = value.getClass();
285      return ! (value instanceof CharSequence || value instanceof Character || value instanceof Number
286         || value instanceof Boolean || value instanceof Collection<?> || c.isArray());
287   }
288
289   private static String typeName(Object value) {
290      return value == null ? "null" : cns(value);
291   }
292
293   // =================================================================================================================
294   // String
295   // =================================================================================================================
296
297   private static void validateString(JsonSchema<?> s, String value, Pattern patternOverride) {
298      var minL = s.getMinLength();
299      var maxL = s.getMaxLength();
300      var len = value.codePointCount(0, value.length());
301      if (nn(minL) && len < minL)
302         throw new SchemaValidationException("String length %s is less than minLength %s.", len, minL);
303      if (nn(maxL) && len > maxL)
304         throw new SchemaValidationException("String length %s exceeds maxLength %s.", len, maxL);
305      var pattern = patternOverride;
306      if (pattern == null && nn(s.getPattern()))
307         pattern = Pattern.compile(s.getPattern());
308      if (pattern != null && ! pattern.matcher(new InterruptibleCharSequence(value)).find())
309         throw new SchemaValidationException("Value '%s' does not match pattern '%s'.", value, s.getPattern());
310   }
311
312   // =================================================================================================================
313   // Number
314   // =================================================================================================================
315
316   private static void validateNumber(JsonSchema<?> s, Number value) {
317      BigDecimal bd;
318      try {
319         bd = toBigDecimal(value);
320      } catch (@SuppressWarnings("unused") NumberFormatException e) {
321         throw new SchemaValidationException("Value '%s' is not a finite number.", value);
322      }
323
324      if (nn(s.getMinimum()) && bd.compareTo(toBigDecimal(s.getMinimum())) < 0)
325         throw new SchemaValidationException("Value %s is less than minimum %s.", bd, s.getMinimum());
326      if (nn(s.getMaximum()) && bd.compareTo(toBigDecimal(s.getMaximum())) > 0)
327         throw new SchemaValidationException("Value %s exceeds maximum %s.", bd, s.getMaximum());
328      if (nn(s.getExclusiveMinimum()) && bd.compareTo(toBigDecimal(s.getExclusiveMinimum())) <= 0)
329         throw new SchemaValidationException("Value %s is not greater than exclusive minimum %s.", bd, s.getExclusiveMinimum());
330      if (nn(s.getExclusiveMaximum()) && bd.compareTo(toBigDecimal(s.getExclusiveMaximum())) >= 0)
331         throw new SchemaValidationException("Value %s is not less than exclusive maximum %s.", bd, s.getExclusiveMaximum());
332      if (nn(s.getMultipleOf())) {
333         var mof = toBigDecimal(s.getMultipleOf());
334         if (mof.signum() != 0 && bd.remainder(mof).compareTo(BigDecimal.ZERO) != 0)
335            throw new SchemaValidationException("Value %s is not a multiple of %s.", bd, mof);
336      }
337   }
338
339   // =================================================================================================================
340   // Array
341   // =================================================================================================================
342
343   private static void validateArray(JsonSchema<?> s, Collection<?> value) {
344      var min = s.getMinItems();
345      var max = s.getMaxItems();
346      var size = value.size();
347      if (nn(min) && size < min)
348         throw new SchemaValidationException("Array size %s is less than minItems %s.", size, min);
349      if (nn(max) && size > max)
350         throw new SchemaValidationException("Array size %s exceeds maxItems %s.", size, max);
351      if (isTrue(s.getUniqueItems()) && hasDuplicates(value))
352         throw new SchemaValidationException("Array contains duplicate items but uniqueItems is true.");
353      var items = s.getItemsAsSchema();
354      if (nn(items)) {
355         for (var item : value)
356            validateAgainst(items, item, null);
357      }
358   }
359
360   private static List<Object> arrayAsList(Object array) {
361      var len = Array.getLength(array);
362      var out = new ArrayList<>(len);
363      for (var i = 0; i < len; i++)
364         out.add(Array.get(array, i));
365      return out;
366   }
367
368   private static boolean hasDuplicates(Collection<?> c) {
369      if (c.size() < 2)
370         return false;
371      var seen = new ArrayList<>(c.size());
372      for (var e : c) {
373         for (var s : seen) {
374            if (jsonEquals(e, s))
375               return true;
376         }
377         seen.add(e);
378      }
379      return false;
380   }
381
382   // =================================================================================================================
383   // Object (Map / bean shape)
384   // =================================================================================================================
385
386   private static void validateObject(JsonSchema<?> s, Map<?,?> value) {
387      var minP = s.getMinProperties();
388      var maxP = s.getMaxProperties();
389      var size = value.size();
390      if (nn(minP) && size < minP)
391         throw new SchemaValidationException("Object property count %s is less than minProperties %s.", size, minP);
392      if (nn(maxP) && size > maxP)
393         throw new SchemaValidationException("Object property count %s exceeds maxProperties %s.", size, maxP);
394      var required = s.getRequired();
395      if (nn(required)) {
396         for (var key : required) {
397            if (! value.containsKey(key))
398               throw new SchemaValidationException("Required property '%s' is missing.", key);
399         }
400      }
401      var props = s.getProperties();
402      if (nn(props)) {
403         for (var entry : props.entrySet()) {
404            var name = entry.getKey();
405            var sub = entry.getValue();
406            if (value.containsKey(name))
407               validateAgainst(sub, value.get(name), null);
408         }
409      }
410   }
411
412   // =================================================================================================================
413   // Helpers
414   // =================================================================================================================
415
416   private static BigDecimal toBigDecimal(Number n) {
417      if (n instanceof BigDecimal n2)
418         return n2;
419      if (n instanceof BigInteger n2)
420         return new BigDecimal(n2);
421      if (n instanceof Long || n instanceof Integer || n instanceof Short || n instanceof Byte)
422         return BigDecimal.valueOf(n.longValue());
423      return new BigDecimal(n.toString());
424   }
425
426   private static boolean jsonEquals(Object a, Object b) {
427      if (a == b)
428         return true;
429      if (a == null || b == null)
430         return false;
431      if (a instanceof Number a2 && b instanceof Number b2)
432         return toBigDecimal(a2).compareTo(toBigDecimal(b2)) == 0;
433      if (a instanceof Number a2 && b instanceof CharSequence b2) {
434         var bd = tryAsBigDecimal(b2.toString());
435         return nn(bd) && toBigDecimal(a2).compareTo(bd) == 0;
436      }
437      if (b instanceof Number b2 && a instanceof CharSequence a2) {
438         var bd = tryAsBigDecimal(a2.toString());
439         return nn(bd) && toBigDecimal(b2).compareTo(bd) == 0;
440      }
441      if (a instanceof CharSequence a2 && b instanceof CharSequence b2)
442         return a2.toString().equals(b2.toString());
443      if (a.equals(b))
444         return true;
445      try {
446         return Json.of(a).equals(Json.of(b));
447      } catch (@SuppressWarnings("unused") Exception e) {
448         return false;
449      }
450   }
451
452   private static BigDecimal tryAsBigDecimal(String s) {
453      try {
454         return new BigDecimal(s);
455      } catch (@SuppressWarnings("unused") NumberFormatException e) {
456         return null;
457      }
458   }
459
460   // =================================================================================================================
461   // Interruptible pattern matching
462   // =================================================================================================================
463
464   /**
465    * A read-only {@link CharSequence} view over the string being matched that lets a runaway {@link Matcher} be
466    * aborted by interrupting the matching thread.
467    *
468    * <p>
469    * {@link Matcher} reads its input exclusively through {@link #charAt(int)} and never observes the thread's
470    * interrupt status, so a catastrophically-backtracking pattern cannot be stopped by {@link Thread#interrupt()}
471    * alone. Interposing this sequence makes every {@link #CHECK_INTERVAL}-th character read sample
472    * {@link Thread#isInterrupted()} and throw {@link InterruptedMatchException} once it is set, unwinding the match
473    * promptly. The interrupt status is checked without clearing it, so an outer caller can still observe it.
474    *
475    * <p>
476    * Instances are single-use and not thread-safe: a {@link Matcher} drives its input from one thread, which is the
477    * same thread whose interrupt status is being sampled.
478    */
479   private static final class InterruptibleCharSequence implements CharSequence {
480
481      /**
482       * Number of character reads between interrupt checks. Large enough that the sampling overhead is negligible
483       * against the matcher's per-character work, small enough that an interrupt aborts a runaway match within
484       * microseconds (catastrophic backtracking re-reads characters an enormous number of times).
485       */
486      private static final int CHECK_INTERVAL = 1 << 12;
487
488      private final CharSequence delegate;
489      private int readsUntilCheck = CHECK_INTERVAL;
490
491      InterruptibleCharSequence(CharSequence delegate) {
492         this.delegate = delegate;
493      }
494
495      @Override
496      public char charAt(int index) {
497         if (--readsUntilCheck <= 0) {
498            readsUntilCheck = CHECK_INTERVAL;
499            if (Thread.currentThread().isInterrupted())
500               throw new InterruptedMatchException();
501         }
502         return delegate.charAt(index);
503      }
504
505      @Override
506      public int length() {
507         return delegate.length();
508      }
509
510      @Override
511      public CharSequence subSequence(int start, int end) {
512         return delegate.subSequence(start, end);
513      }
514
515      @Override
516      public String toString() {
517         return delegate.toString();
518      }
519   }
520
521   /**
522    * Thrown by {@link InterruptibleCharSequence} to abort a {@code pattern} match whose thread has been interrupted.
523    *
524    * <p>
525    * This is control flow rather than a reportable error: the stack trace is suppressed since the abort point (deep
526    * inside {@link Matcher}) carries no useful diagnostic, and the only thing that interrupts a validating thread is
527    * a caller cancelling the work.
528    */
529   private static final class InterruptedMatchException extends RuntimeException {
530
531      private static final long serialVersionUID = 1L;
532
533      InterruptedMatchException() {
534         super(null, null, false, false);
535      }
536   }
537}