/* * ModeShape (http://www.modeshape.org) * * Licensed 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.modeshape.schematic.internal.schema; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.EnumSet; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import java.util.stream.Collectors; import org.modeshape.schematic.SchemaLibrary; import org.modeshape.schematic.document.Document; import org.modeshape.schematic.document.Document.Field; import org.modeshape.schematic.document.JsonSchema; import org.modeshape.schematic.document.JsonSchema.Type; import org.modeshape.schematic.document.Null; import org.modeshape.schematic.document.Path; import org.modeshape.schematic.document.Symbol; import org.modeshape.schematic.internal.document.Paths; public class JsonSchemaValidatorFactory implements Validator.Factory { private CompositeValidator topLevelValidator = new CompositeValidator(); private final Problems problems; private final URI uri; protected JsonSchemaValidatorFactory( URI uri, Problems problems ) { this.uri = uri; this.problems = problems; } @Override public Validator create( Document schemaDocument, Path pathToDoc ) { CompositeValidator validators = new CompositeValidator(); if (this.topLevelValidator == null) { this.topLevelValidator = validators; } // Dereference any "$ref" value, replacing this schema document with the referenced one ... Validator derefValidator = dereference(schemaDocument, pathToDoc, problems); if (derefValidator != null) { return derefValidator; } addValidatorsForTypes(schemaDocument, pathToDoc, problems, validators); addValidatorsForProperties(schemaDocument, pathToDoc, problems, validators); addValidatorsForPatternProperties(schemaDocument, pathToDoc, problems, validators); addValidatorsForItems(schemaDocument, pathToDoc, problems, validators); addValidatorsForRequired(schemaDocument, pathToDoc, problems, validators); addValidatorsForMinimum(schemaDocument, pathToDoc, problems, validators); addValidatorsForMaximum(schemaDocument, pathToDoc, problems, validators); addValidatorsForMinimumItems(schemaDocument, pathToDoc, problems, validators); addValidatorsForMaximumItems(schemaDocument, pathToDoc, problems, validators); addValidatorsForUniqueItems(schemaDocument, pathToDoc, problems, validators); addValidatorsForPattern(schemaDocument, pathToDoc, problems, validators); addValidatorsForMinimumLength(schemaDocument, pathToDoc, problems, validators); addValidatorsForMaximumLength(schemaDocument, pathToDoc, problems, validators); addValidatorsForEnum(schemaDocument, pathToDoc, problems, validators); addValidatorsForDivisibleBy(schemaDocument, pathToDoc, problems, validators); addValidatorsForDisallowedTypes(schemaDocument, pathToDoc, problems, validators); switch (validators.size()) { case 0: return null; case 1: return validators.getFirst(); default: return validators; } } protected Validator dereference( Document schemaDocument, Path pathToDoc, Problems problems ) { String ref = schemaDocument.getString("$ref"); if (ref == null) { return null; } if ("#".equals(ref)) { return topLevelValidator; } // Try to resolve the absolute or relative key ... // See if this is a relative URI ... String resolvedReference = null; URI refUri = null; try { refUri = new URI(ref); URI resolvedUri = this.uri.resolve(refUri); resolvedReference = resolvedUri.toString(); } catch (URISyntaxException e) { problems.recordWarning(pathToDoc, "The URI of the referenced schema '" + uri + "' is not a valid URI"); } if (uri.equals(resolvedReference)) { return topLevelValidator; } if (!ref.equals(resolvedReference)) { // The resolved reference is different than what we just looked up, so look it up ... assert resolvedReference != null; return new ResolvingValidator(resolvedReference); } return null; } protected void addValidatorsForTypes( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Object value = parent.get("type"); if (value instanceof String) { // Simple type ... Type type = JsonSchema.Type.byName((String)value); if (type == Type.ANY || type == Type.UNKNOWN) return; validators.add(new TypeValidator(type)); } else if (value instanceof List<?>) { // Union type ... List<Validator> unionValidators = new ArrayList<Validator>(); List<?> types = (List<?>)value; for (Object obj : types) { Validator validator = null; if (obj instanceof Document) { // It's either a schema or a reference to a schema ... Document schemaOrRef = (Document)obj; validator = create(schemaOrRef, parentPath.with("type")); } else if (obj instanceof String) { Type type = JsonSchema.Type.byName((String)obj); if (type == Type.ANY || type == Type.UNKNOWN) continue; validator = new TypeValidator(type); } if (validator != null) unionValidators.add(validator); } if (unionValidators.size() == 1) { // Just one validator ... validators.add(unionValidators.get(0)); } else if (unionValidators.size() > 1) { // More than one validator, so use a union ... validators.add(new UnionValidator(unionValidators)); } } } protected void addValidatorsForProperties( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Document properties = parent.getDocument("properties"); Set<String> propertiesWithSchemas = new HashSet<>(); if (properties != null && properties.size() != 0) { for (Field field : properties.fields()) { String name = field.getName(); Object value = field.getValue(); Path path = Paths.path(parentPath, "properties", name); if (!(value instanceof Document)) { problems.recordError(path, "Expected a nested object"); } Document propertySchema = (Document)value; Validator propertyValidator = create(propertySchema, path); if (propertyValidator != null) { validators.add(new PropertyValidator(name, propertyValidator)); } propertiesWithSchemas.add(name); } } // Check the additional properties ... boolean additionalPropertiesAllowed = parent.getBoolean("additionalProperties", true); if (!additionalPropertiesAllowed) { validators.add(new NoOtherAllowedPropertiesValidator(propertiesWithSchemas)); } else { Document additionalSchema = parent.getDocument("additionalProperties"); if (additionalSchema != null) { Path path = parentPath.with("additionalProperties"); Validator additionalValidator = create(additionalSchema, path); if (additionalValidator != null) { validators.add(new AllowedPropertiesValidator(propertiesWithSchemas, additionalValidator)); } } // Otherwise, additional properties are allowed so we need to do nothing } } protected void addValidatorsForPatternProperties( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Document properties = parent.getDocument("patternProperties"); if (properties != null && properties.size() != 0) { for (Field field : properties.fields()) { String name = field.getName(); Object value = field.getValue(); Path path = Paths.path(parentPath, "patternProperties", name); if (!(value instanceof Document)) { problems.recordError(path, "Expected a nested object"); } Document propertySchema = (Document)value; try { Pattern namePattern = Pattern.compile(name); Validator propertyValidator = create(propertySchema, path); if (propertyValidator != null) { validators.add(new PatternPropertyValidator(namePattern, propertyValidator)); } } catch (PatternSyntaxException e) { problems.recordError(path, "Expected the field name to be a regular expression"); } } } } protected void addValidatorsForItems( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Object items = parent.get("items"); if (Null.matches(items)) return; Path path = parentPath.with("items"); String requiredName = parentPath.getLast(); if (requiredName == null) return; // Either a schema or an array of schemas ... if (items instanceof Document) { Document schema = (Document)items; Validator validator = create(schema, path); if (validator != null) { validators.add(new AllItemsMatchValidator(requiredName, validator)); } } else if (items instanceof List<?>) { // This is called "tuple typing" in the spec, and can also have 'additionalItems' ... List<?> array = (List<?>)items; List<Validator> itemValidators = new ArrayList<>(array.size()); for (Object item : array) { if (item instanceof Document) { Validator validator = create((Document)item, path); if (validator != null) { itemValidators.add(validator); } } } // Check the additional items ... boolean additionalItemsAllowed = parent.getBoolean("additionalItems", true); Validator additionalItemsValidator = null; if (!additionalItemsAllowed) { additionalItemsValidator = new NotValidValidator(); } else { // additional items are allowed, but check whether there is a schema for the additional items ... Document additionalItems = parent.getDocument("additionalItems"); if (additionalItems != null) { Path additionalItemsPath = parentPath.with("additionalItems"); additionalItemsValidator = create(additionalItems, additionalItemsPath); } } if (!itemValidators.isEmpty()) { validators.add(new EachItemMatchesValidator(requiredName, itemValidators, additionalItemsValidator, additionalItemsAllowed)); } } } protected void addValidatorsForRequired( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Boolean required = parent.getBoolean("required", Boolean.FALSE); if (required.booleanValue()) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new RequiredValidator(requiredName)); } } } protected void addValidatorsForMinimum( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Number minimum = parent.getNumber("minimum"); if (minimum != null) { String requiredName = parentPath.getLast(); if (requiredName != null) { if (parent.getBoolean("exclusiveMinimum", Boolean.FALSE)) { validators.add(new ExclusiveMinimumValidator(requiredName, minimum)); } else { validators.add(new MinimumValidator(requiredName, minimum)); } } } } protected void addValidatorsForMaximum( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Double maximum = parent.getDouble("maximum"); if (maximum != null) { String requiredName = parentPath.getLast(); if (requiredName != null) { if (parent.getBoolean("exclusiveMinimum", Boolean.FALSE)) { validators.add(new ExclusiveMaximumValidator(requiredName, maximum)); } else { validators.add(new MaximumValidator(requiredName, maximum)); } } } } protected void addValidatorsForMinimumItems( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { int minimum = parent.getInteger("minItems", 0); if (minimum > 0) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new MinimumItemsValidator(requiredName, minimum)); } } } protected void addValidatorsForMaximumItems( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { int maximum = parent.getInteger("maxItems", 0); if (maximum > 0) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new MaximumItemsValidator(requiredName, maximum)); } } } protected void addValidatorsForUniqueItems( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { if (parent.getBoolean("uniqueItems", false)) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new UniqueItemsValidator(requiredName)); } } } protected void addValidatorsForPattern( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { String regex = parent.getString("pattern"); if (regex != null) { String requiredName = parentPath.getLast(); if (requiredName != null) { try { Pattern pattern = Pattern.compile(regex); validators.add(new PatternValidator(requiredName, pattern)); } catch (PatternSyntaxException e) { problems.recordError(parentPath.with("pattern"), "The supplied value '" + regex + "' is expected to be a valid regular expression, but there was an error at position " + e.getIndex() + ": " + e.getDescription()); } } } } protected void addValidatorsForMinimumLength( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { int minimumLength = parent.getInteger("minimumLength", 0); if (minimumLength > 0) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new MinimumLengthValidator(requiredName, minimumLength)); } } } protected void addValidatorsForMaximumLength( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { int maximumLength = parent.getInteger("maximumLength", 0); if (maximumLength > 0) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new MaximumLengthValidator(requiredName, maximumLength)); } } } protected void addValidatorsForEnum( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { List<?> enumValues = parent.getArray("enum"); if (enumValues != null && !enumValues.isEmpty()) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new EnumValidator(requiredName, enumValues)); } } } protected void addValidatorsForDivisibleBy( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Number denominator = parent.getNumber("divisibleBy", 1); if (denominator != null) { int denominatorIntValue = denominator.intValue(); if (denominatorIntValue != 0 && denominatorIntValue != 1) { String requiredName = parentPath.getLast(); if (requiredName != null) { validators.add(new DivisibleByValidator(requiredName, denominator.intValue())); } } } } protected void addValidatorsForDisallowedTypes( Document parent, Path parentPath, Problems problems, CompositeValidator validators ) { Object disallowed = parent.get("disallowed"); if (Null.matches(disallowed)) return; String requiredName = parentPath.getLast(); if (requiredName != null) { EnumSet<Type> disallowedTypes = Type.typesWithNames(disallowed); validators.add(new DisallowedTypesValidator(requiredName, disallowedTypes)); } } protected class TypeValidator implements Validator { private static final long serialVersionUID = 1L; private final Type type; public TypeValidator( Type type ) { this.type = type; assert this.type != null; } @Override public void validate( Object fieldValue, String fieldName, Document document, Path pathToDocument, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue == null) { if (fieldName != null) { fieldValue = document.get(fieldName); } else { // We're supposed to check the whole document is the correct type ... fieldValue = document; } } if (fieldValue != null) { Type actual = Type.typeFor(fieldValue); if (!type.isEquivalent(actual)) { // See if the value is convertable ... Object converted = type.convertValueFrom(fieldValue, actual); Path pathToField = fieldName != null ? pathToDocument.with(fieldName) : pathToDocument; String reason = "Field value for '" + pathToField + "' expected to be of type " + type + " but was of type " + actual; if (converted != null) { // We could convert the value, so record this as a special error ... problems.recordTypeMismatch(pathToField, reason, actual, fieldValue, type, converted); } else { problems.recordError(pathToField, reason); } } else { problems.recordSuccess(); } } } @Override public String toString() { return "Type is '" + type + "'"; } } protected static interface ValidatorCollection extends Iterable<Validator> { } protected class UnionValidator implements Validator, ValidatorCollection { private static final long serialVersionUID = 1L; private final List<Validator> validators; public UnionValidator( List<Validator> validators ) { this.validators = validators; assert this.validators != null && !this.validators.isEmpty(); } @Override public void validate( Object fieldValue, String fieldName, Document document, Path pathToDocument, Problems problems, SchemaDocumentResolver resolver ) { // Try each validator with a new problems; the first one to return without any problems passes ... ValidationResult problemsForMostSuccesses = null; int mostSuccesses = -1; for (Validator validator : validators) { ValidationResult newProblems = new ValidationResult(); validator.validate(fieldValue, fieldName, document, pathToDocument, newProblems, resolver); if (!newProblems.hasErrors()) { problems.recordSuccess(); return; } if (newProblems.successCount() > mostSuccesses) { mostSuccesses = newProblems.successCount(); problemsForMostSuccesses = newProblems; } } // All unioned types had problems, but record the problems with the one that had the most successful validations ... if (problemsForMostSuccesses != null) problemsForMostSuccesses.recordIn(problems); } @Override public Iterator<Validator> iterator() { return validators.iterator(); } @Override public String toString() { return "Union of " + validators.size() + " validators"; } } protected class ResolvingValidator implements Validator { private static final long serialVersionUID = 1L; private final String schemaUri; public ResolvingValidator( String schemaUri ) { this.schemaUri = schemaUri; } @Override public void validate( Object fieldValue, String fieldName, Document document, Path pathToDocument, Problems problems, SchemaDocumentResolver resolver ) { SchemaDocument resolved = resolver.get(schemaUri, problems); if (resolved == null) { problems.recordError(pathToDocument.with(fieldName), "Unable to find referenced schema '" + schemaUri + "'"); } else { problems.recordSuccess(); resolved.getValidator().validate(fieldValue, fieldName, document, pathToDocument, problems, resolver); } } @Override public String toString() { return "Resolves to schema '" + schemaUri + "'"; } } protected static class RequiredValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; public RequiredValidator( String propertyName ) { this.propertyName = propertyName; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (Null.matches(fieldValue) && fieldName != null) { if (pathToParent.size() == 0) { problems.recordError(pathToParent.with(fieldName), "The top-level '" + fieldName + "' field is required"); } else { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' is required"); } } else { problems.recordSuccess(); } } @Override public String toString() { return "required '" + propertyName + "'"; } } protected static abstract class NumericValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final Number number; private final double value; protected NumericValidator( String propertyName, Number number ) { this.propertyName = propertyName; this.number = number; this.value = number.doubleValue(); } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof Number) { Number actualNumber = (Number)fieldValue; double actualValue = actualNumber.doubleValue(); if (isValid(value, actualValue)) { problems.recordSuccess(); } else { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' is '" + actualNumber + "' but must be " + ruleDescription() + " '" + number + "'"); } } // otherwise the value is not a number and the minimum doesn't apply } /** * Evaluate whether the actual value and expected value violate the schema rule. * * @param expectedValue the expected value * @param actualValue the actual value * @return true if the value is valid, or false if there is an error */ protected abstract boolean isValid( double expectedValue, double actualValue ); protected abstract String ruleDescription(); @Override public String toString() { return "'" + propertyName + "' is '" + ruleDescription() + " '" + number + "'"; } } protected static class MinimumValidator extends NumericValidator { private static final long serialVersionUID = 1L; public MinimumValidator( String propertyName, Number minimum ) { super(propertyName, minimum); } @Override protected boolean isValid( double minimum, double actualValue ) { return actualValue >= minimum; } @Override protected String ruleDescription() { return "greater than or equal to"; } } /** * Validation rule that states fails if the actual value is equal to or less than the minimum value. */ protected static class ExclusiveMinimumValidator extends NumericValidator { private static final long serialVersionUID = 1L; public ExclusiveMinimumValidator( String propertyName, Number minimum ) { super(propertyName, minimum); } @Override protected boolean isValid( double minimum, double actualValue ) { return actualValue > minimum; } @Override protected String ruleDescription() { return "greater than"; } } protected static class MaximumValidator extends NumericValidator { private static final long serialVersionUID = 1L; public MaximumValidator( String propertyName, Number maximum ) { super(propertyName, maximum); } @Override protected boolean isValid( double maximum, double actualValue ) { return actualValue <= maximum; } @Override protected String ruleDescription() { return "less than or equal to"; } } protected static class ExclusiveMaximumValidator extends NumericValidator { private static final long serialVersionUID = 1L; public ExclusiveMaximumValidator( String propertyName, Number maximum ) { super(propertyName, maximum); } @Override protected boolean isValid( double maximum, double actualValue ) { return actualValue < maximum; } @Override protected String ruleDescription() { return "less than"; } } protected static class MinimumLengthValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final int minimumLength; public MinimumLengthValidator( String propertyName, int minimumLength ) { this.propertyName = propertyName; this.minimumLength = minimumLength; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof String || fieldValue instanceof Symbol) { String value = fieldValue.toString(); if (value.length() < minimumLength) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had " + value.length() + " characters, but was expected to have at least " + minimumLength); } else { problems.recordSuccess(); } } } @Override public String toString() { return "'" + propertyName + "' has a minimum length of " + minimumLength; } } protected static class MaximumLengthValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final int maximumLength; public MaximumLengthValidator( String propertyName, int maximumLength ) { this.propertyName = propertyName; this.maximumLength = maximumLength; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof String || fieldValue instanceof Symbol) { String value = fieldValue.toString(); if (value.length() > maximumLength) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had " + value.length() + " characters, but was expected to have no more than " + maximumLength); } else { problems.recordSuccess(); } } } @Override public String toString() { return "'" + propertyName + "' has a maximum length of " + maximumLength; } } protected static class DivisibleByValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final int denominator; public DivisibleByValidator( String propertyName, int denominator ) { this.propertyName = propertyName; this.denominator = denominator; assert this.denominator != 0; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (Null.matches(fieldValue)) return; if (fieldValue instanceof Integer) { int value = ((Integer)fieldValue).intValue(); if (value % denominator != 0) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had a value of " + value + " and was not divisible by " + denominator); } else { problems.recordSuccess(); } } else if (fieldValue instanceof Long) { long value = ((Long)fieldValue).longValue(); if (value % denominator != 0L) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had a value of " + value + " and was not divisible by " + denominator); } else { problems.recordSuccess(); } } else if (fieldValue instanceof Short) { int value = ((Short)fieldValue).intValue(); if (value % denominator != 0) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had a value of " + value + " and was not divisible by " + denominator); } else { problems.recordSuccess(); } } else if (fieldValue instanceof Float) { float value = ((Float)fieldValue).floatValue(); if (value % denominator != 0.0f) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had a value of " + value + " and was not divisible by " + denominator); } else { problems.recordSuccess(); } } else if (fieldValue instanceof Double) { double value = ((Double)fieldValue).floatValue(); if (value % denominator != 0.0d) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' had a value of " + value + " and was not divisible by " + denominator); } else { problems.recordSuccess(); } } } @Override public String toString() { return "'" + propertyName + "' must be divisible by " + denominator; } } protected static abstract class ItemCountValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final int number; protected ItemCountValidator( String propertyName, int number ) { this.propertyName = propertyName; this.number = number; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof List) { List<?> array = (List<?>)fieldValue; if (evaluate(number, array.size())) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' has '" + array.size() + "' values but should have " + ruleDescription() + " '" + number + "'"); } else { problems.recordSuccess(); } } // otherwise the value is not a number and the minimum doesn't apply } protected abstract boolean evaluate( double value, double actualValue ); protected abstract String ruleDescription(); @Override public String toString() { return "'" + propertyName + "' has '" + ruleDescription() + " '" + number + "' items"; } } protected static class MinimumItemsValidator extends ItemCountValidator { private static final long serialVersionUID = 1L; public MinimumItemsValidator( String propertyName, int minimum ) { super(propertyName, minimum); } @Override protected boolean evaluate( double minimumCount, double actualCount ) { return minimumCount < actualCount; } @Override protected String ruleDescription() { return "at least"; } } protected static class MaximumItemsValidator extends ItemCountValidator { private static final long serialVersionUID = 1L; public MaximumItemsValidator( String propertyName, int maximum ) { super(propertyName, maximum); } @Override protected boolean evaluate( double maximumCount, double actualCount ) { return maximumCount < actualCount; } @Override protected String ruleDescription() { return "no more than"; } } protected static class UniqueItemsValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; public UniqueItemsValidator( String propertyName ) { this.propertyName = propertyName; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { // This only applies if the value is a JSON array ... if (fieldValue instanceof List) { List<?> array = (List<?>)fieldValue; Set<?> uniqueValues = new HashSet<>(array); int numDups = array.size() - uniqueValues.size(); if (numDups != 0) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' must contain unique values, but contains " + numDups + " duplicate values"); } else { problems.recordSuccess(); } } } @Override public String toString() { return "'" + propertyName + "' contains unique items"; } } protected static class PatternValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final Pattern pattern; public PatternValidator( String propertyName, Pattern pattern ) { this.propertyName = propertyName; this.pattern = pattern; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof String || fieldValue instanceof Symbol) { String value = fieldValue.toString(); Matcher matcher = pattern.matcher(value); if (!matcher.matches()) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' failed match the pattern specified by '" + pattern.pattern() + "'"); } else { problems.recordSuccess(); } } } @Override public String toString() { return "'" + propertyName + "' matches pattern '" + pattern.pattern() + "'"; } } protected static class EnumValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final Set<String> values; public EnumValidator( String propertyName, Collection<?> values ) { this.propertyName = propertyName; this.values = values.stream().map(object -> object.toString().toLowerCase()).collect(Collectors.toSet()); } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (!propertyName.equals(fieldName)) return; // This only applies if the value is a JSON array ... if (fieldValue instanceof List) { for (Object value : (List<?>)fieldValue) { if (values.contains(value.toString().toLowerCase())) { problems.recordSuccess(); } else { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' contains a value '" + value + "' in the array that is not part of the enumeration: " + values); } } } else if (fieldValue != null) { if (values.contains(fieldValue.toString().toLowerCase())) { problems.recordSuccess(); } else { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' has a value of '" + fieldValue + "' that is not part of the enumeration: " + values); } } } @Override public String toString() { return "'" + propertyName + "' contains values from enumeration: " + values; } } protected static class DisallowedTypesValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final EnumSet<Type> disallowedTypes; public DisallowedTypesValidator( String propertyName, EnumSet<Type> disallowedTypes ) { this.propertyName = propertyName; this.disallowedTypes = disallowedTypes; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { Type type = Type.typeFor(fieldValue); if (type != Type.NULL) { if (disallowedTypes.contains(type)) { problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' contains a value '" + fieldValue + "' whose type '" + type + "' is disallowed."); } else { problems.recordSuccess(); } } } @Override public String toString() { return "'" + propertyName + "' may not have values with the types " + disallowedTypes; } } /** * The {@link Validator} for item values that should all match a single schema. * * @author Randall Hauch <rhauch@redhat.com> (C) 2011 Red Hat Inc. * @since 5.1 */ protected static class AllItemsMatchValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final Validator itemValidator; private final SingleProblem itemProblems = new SingleProblem(); public AllItemsMatchValidator( String propertyName, Validator itemValidator ) { this.propertyName = propertyName; this.itemValidator = itemValidator; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof List) { // Each item in the list must match the itemValidator or additionalItemsValidator ... List<?> items = (List<?>)fieldValue; Path path = pathToParent.with(fieldName); int i = 1; boolean success = true; for (Object item : items) { itemProblems.clear(); itemValidator.validate(item, fieldName, parent, pathToParent, itemProblems, resolver); if (itemProblems.hasProblem()) { problems.recordError(path, "The '" + fieldName + "' field on '" + pathToParent + "' is an array, but the " + i + th(i) + " item does not satisfy the schema for the " + i + th(i) + " item"); success = false; } ++i; } if (success) problems.recordSuccess(); } else if (parent instanceof List) { // we are dealing with an optional array of items List<?> items = (List<?>)parent; int i = 1; boolean success = true; for (Object item : items) { itemProblems.clear(); if (item instanceof Document) { itemValidator.validate(null, null, (Document)item, pathToParent, itemProblems, resolver); if (itemProblems.hasProblem()) { success = false; } } else { fieldName = item.toString(); Path path = pathToParent.with(fieldName); itemValidator.validate(item, fieldName, parent, pathToParent, itemProblems, resolver); if (itemProblems.hasProblem()) { problems.recordError(path, "The '" + fieldName + "' field on '" + pathToParent + "' is an array, but the " + i + th(i) + " item does not satisfy the schema for the " + i + th(i) + " item"); success = false; } } ++i; } if (success) problems.recordSuccess(); } } @Override public String toString() { return "'" + propertyName + "' may be an array with items matching the schema: " + itemValidator; } } /** * The {@link Validator} for "tuple typing", when item values should each match a corresponding schema or, if applicable, an * additional items schema. * * @author Randall Hauch <rhauch@redhat.com> (C) 2011 Red Hat Inc. * @since 5.1 */ protected static class EachItemMatchesValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final List<Validator> itemValidators; private final Validator additionalItemsValidator; private final SingleProblem itemProblems = new SingleProblem(); private final boolean additionalItemsAllowed; public EachItemMatchesValidator( String propertyName, List<Validator> itemValidators, Validator additionalItemsValidator, boolean additionalItemsAllowed ) { this.propertyName = propertyName; this.itemValidators = itemValidators; this.additionalItemsValidator = additionalItemsValidator; this.additionalItemsAllowed = additionalItemsAllowed; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue instanceof List) { // Each item in the list must match the itemValidator or additionalItemsValidator ... List<?> items = (List<?>)fieldValue; Path path = pathToParent.with(fieldName); int i = 0; Iterator<?> itemIterator = items.iterator(); Iterator<Validator> itemValidatorIterator = itemValidators.iterator(); boolean success = true; while (itemIterator.hasNext() && itemValidatorIterator.hasNext()) { ++i; Object item = itemIterator.next(); Validator itemValidator = itemValidatorIterator.next(); itemValidator.validate(item, fieldName, parent, pathToParent, itemProblems, resolver); } if (additionalItemsAllowed && additionalItemsValidator != null) { while (itemIterator.hasNext()) { ++i; Object item = itemIterator.next(); itemProblems.clear(); additionalItemsValidator.validate(item, fieldName, parent, pathToParent, itemProblems, resolver); if (itemProblems.hasProblem()) { problems.recordError(path, "The '" + fieldName + "' field on '" + pathToParent + "' is an array, but the " + i + th(i) + " item does have a corresponding schema and does not satisfy the additional items schema)"); success = false; } } } else if (!additionalItemsAllowed) { while (itemIterator.hasNext()) { ++i; problems.recordError(path, "The '" + fieldName + "' field on '" + pathToParent + "' is an array, but the " + i + th(i) + " item does have a corresponding schema (and no additional items were specified)"); success = false; } } if (success) problems.recordSuccess(); } } @Override public String toString() { return "'" + propertyName + "' may be an array with items matching the schemas: " + itemValidators + (additionalItemsValidator == null ? "" : " or the additional items schema " + additionalItemsValidator); } } protected static class NotValidValidator implements Validator { private static final long serialVersionUID = 1L; public NotValidValidator() { } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { problems.recordError(pathToParent, ""); } @Override public String toString() { return "not valid"; } } protected static String th( int i ) { switch (i) { case 1: return "st"; case 2: return "nd"; case 3: return "rd"; } return "th"; } protected static RequiredValidator getRequiredValidator( Validator validator ) { if (validator instanceof RequiredValidator) return (RequiredValidator)validator; if (validator instanceof ValidatorCollection) { for (Validator val : ((ValidatorCollection)validator)) { if (val instanceof RequiredValidator) return (RequiredValidator)val; } } return null; } protected static class PropertyValidator implements Validator { private static final long serialVersionUID = 1L; private final String propertyName; private final Validator validator; private final RequiredValidator required; public PropertyValidator( String propertyName, Validator validator ) { this.propertyName = propertyName; this.validator = validator; this.required = getRequiredValidator(validator); } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldName == null) { fieldName = propertyName; } if (fieldValue == null) { fieldValue = parent.get(propertyName); } if (fieldValue == null) { if (required != null) { // The field is required ... required.validate(fieldValue, fieldName, parent, pathToParent, problems, resolver); } return; } if (fieldValue instanceof Document) { validator.validate(null, null, (Document)fieldValue, pathToParent.with(fieldName), problems, resolver); } else { validator.validate(fieldValue, fieldName, parent, pathToParent, problems, resolver); } } @Override public String toString() { return "property '" + propertyName + "': " + validator.toString(); } } protected static class PatternPropertyValidator implements Validator { private static final long serialVersionUID = 1L; private final Pattern propertyNamePattern; private final Validator validator; public PatternPropertyValidator( Pattern propertyNamePattern, Validator validator ) { this.propertyNamePattern = propertyNamePattern; this.validator = validator; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue == null) return; Matcher matcher = propertyNamePattern.matcher(fieldName); if (matcher.matches()) { // Apply the validator to the field value ... validator.validate(fieldValue, fieldName, parent, pathToParent, problems, resolver); } } @Override public String toString() { return "pattern property '" + propertyNamePattern.pattern() + "': " + validator.toString(); } } protected static class AllowedPropertiesValidator implements Validator { private static final long serialVersionUID = 1L; private final Set<String> allowedPropertyNames; private final Validator validator; public AllowedPropertiesValidator( Set<String> allowedPropertyNames, Validator validator ) { this.allowedPropertyNames = allowedPropertyNames; this.validator = validator; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldName != null && !allowedPropertyNames.contains(fieldName)) { // Then the field is not handled by an explicit schema, so we need to check it here validator.validate(fieldValue, fieldName, parent, pathToParent, problems, resolver); } else if (fieldName == null) { // we need to validate each defined additional property which has a schema for (Field field : parent.fields()) { if (field.getValue() instanceof Document) { validator.validate(null, null, (Document)field.getValue(), pathToParent.with(field.getName()), problems, resolver); } else { validator.validate(field.getValue(), field.getName(), parent, pathToParent, problems, resolver); } } } } @Override public String toString() { return "additional properties allowed: " + validator.toString(); } } protected static class NoOtherAllowedPropertiesValidator implements Validator { private static final long serialVersionUID = 1L; private final Set<String> allowedPropertyNames; public NoOtherAllowedPropertiesValidator( Set<String> allowedPropertyNames ) { this.allowedPropertyNames = allowedPropertyNames; } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { if (fieldValue == null) { if (fieldName == null) { // Go through all of the fields in the document ... for (Field field : parent.fields()) { validate(field.getValue(), field.getName(), parent, pathToParent, problems, resolver); } } } else { if (!allowedPropertyNames.contains(fieldName)) { // Then the field is not handled by an explicit schema, so it's not allowed ... problems.recordError(pathToParent.with(fieldName), "The '" + fieldName + "' field on '" + pathToParent + "' is not defined in the schema and the schema does not allow additional properties."); } else { problems.recordSuccess(); } } } @Override public String toString() { return "additional properties not allowed"; } } protected static class CompositeValidator implements Validator, ValidatorCollection { private static final long serialVersionUID = 1L; private final List<Validator> validators = new ArrayList<>(); public CompositeValidator() { } protected void add( Validator validator ) { this.validators.add(validator); } protected int size() { return this.validators.size(); } protected Validator getFirst() { return this.validators.get(0); } @Override public void validate( Object fieldValue, String fieldName, Document parent, Path pathToParent, Problems problems, SchemaDocumentResolver resolver ) { for (Validator validator : validators) { try { validator.validate(fieldValue, fieldName, parent, pathToParent, problems, resolver); } catch (Throwable t) { problems.recordError(pathToParent, t.getMessage(), t); } } } @Override public Iterator<Validator> iterator() { return validators.iterator(); } @Override public String toString() { StringBuilder sb = new StringBuilder(); for (Validator validator : validators) { sb.append(validator.toString()); sb.append("\n"); } return sb.toString(); } } protected static class SingleProblem implements Problems { private SchemaLibrary.ProblemType type; private Path path; private String message; private Throwable exception; private Object actualValue; private Object convertedValue; private Type actualType; private Type requiredType; private boolean mismatch = false; private boolean success = false; @Override public void recordSuccess() { success = true; } @Override public void recordError( Path path, String message ) { this.type = SchemaLibrary.ProblemType.ERROR; this.path = path; this.message = message; this.exception = null; this.actualValue = null; this.convertedValue = null; this.actualType = null; this.requiredType = null; this.mismatch = false; this.success = false; } @Override public void recordError( Path path, String message, Throwable exception ) { this.type = SchemaLibrary.ProblemType.ERROR; this.path = path; this.message = message; this.exception = exception; this.actualValue = null; this.convertedValue = null; this.actualType = null; this.requiredType = null; this.mismatch = false; this.success = false; } @Override public void recordWarning( Path path, String message ) { this.type = SchemaLibrary.ProblemType.WARNING; this.path = path; this.message = message; this.exception = null; this.actualValue = null; this.convertedValue = null; this.actualType = null; this.requiredType = null; this.mismatch = false; this.success = false; } @Override public void recordTypeMismatch( Path path, String message, Type actualType, Object actualValue, Type requiredType, Object convertedValue ) { this.type = SchemaLibrary.ProblemType.ERROR; this.path = path; this.message = message; this.exception = null; this.actualValue = actualValue; this.convertedValue = convertedValue; this.actualType = actualType; this.requiredType = requiredType; this.mismatch = true; this.success = false; } public boolean hasProblem() { return this.type != null; } public void clear() { this.type = null; this.success = false; } } }