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}