class_name JSONSchema extends RefCounted # JSON Schema main script # Inherits from Reference for easy use const SMALL_FLOAT_THRESHOLD = 0.001 const MAX_DECIMAL_PLACES = 3 const DEF_KEY_NAME = "schema root" const DEF_ERROR_STRING = "##error##" const JST_ARRAY = "array" const JST_BOOLEAN = "boolean" const JST_INTEGER = "integer" const JST_NULL = "null" const JST_NUMBER = "number" const JST_OBJECT = "object" const JST_STRING = "string" const JSKW_TYPE = "type" const JSKW_PROP = "properties" const JSKW_REQ = "required" const JSKW_TITLE = "title" const JSKW_DESCR = "description" const JSKW_DEFAULT = "default" const JSKW_EXAMPLES = "examples" const JSKW_COMMENT = "$comment" const JSKW_ENUM = "enum" const JSKW_CONST = "const" const JSKW_PREFIX_ITEMS = "prefixItems" const JSKW_ITEMS = "items" const JSKW_MIN_ITEMS = "minItems" const JSKW_MAX_ITEMS = "maxItems" const JSKW_CONTAINS = "contains" const JSKW_ADD_ITEMS = "additionalItems" const JSKW_UNIQUE_ITEMS = "uniqueItems" const JSKW_MULT_OF = "multipleOf" const JSKW_MINIMUM = "minimum" const JSKW_MIN_EX = "exclusiveMinimum" const JSKW_MAXIMUM = "maximum" const JSKW_MAX_EX = "exclusiveMaximum" const JSKW_PROP_ADD = "additionalProperties" const JSKW_PROP_PATTERN = "patternProperties" const JSKW_PROP_NAMES = "propertyNames" const JSKW_PROP_MIN = "minProperties" const JSKW_PROP_MAX = "maxProperties" const JSKW_DEPEND = "dependencies" const JSKW_LENGTH_MIN = "minLength" const JSKW_LENGTH_MAX = "maxLength" const JSKW_PATTERN = "pattern" const JSKW_FORMAT = "format" const JSKW_COLOR = "color" const JSM_GREATER = "greater" const JSM_GREATER_EQ = "greater or equal" const JSM_LESS = "less" const JSM_LESS_EQ = "less or equal" const JSM_OBJ_DICT = "object (dictionary)" const JSL_AND = "%s and %s" const JSL_OR = "%s or %s" const ERR_SCHEMA_FALSE = "Schema declared as deny all" const ERR_WRONG_SCHEMA_GEN = "Schema error: " const ERR_WRONG_SCHEMA_TYPE = "Schema error: schema must be empty object or object with 'type' keyword or boolean value" const ERR_WRONG_SHEMA_NOTA = "Schema error: expected that all elements of '%s.%s' must be '%s'" const ERR_WRONG_PROP_TYPE = "Schema error: any schema item must be object with 'type' keyword" const ERR_REQ_PROP_GEN = "Schema error: expected array of required properties for '%s'" const ERR_REQ_PROP_MISSING = "Missing required property: '%s' for '%s'" const ERR_NO_PROP_ADD = "Additional properties are not required: found '%s'" const ERR_FEW_PROP = "%d propertie(s) are not enough properties, at least %d are required" const ERR_MORE_PROP = "%d propertie(s) are too many properties, at most %d are allowed" const ERR_FEW_ITEMS = "%s item(s) are not enough items, at least %s are required" const ERR_MORE_ITEMS = "%s item(s) are too many items, at most %s are allowed" const ERR_INVALID_JSON_GEN = "Validation fails with message: %s" const ERR_INVALID_JSON_EXT = "Invalid JSON data passed with message: %s" const ERR_TYPE_MISMATCH_GEN = "Type mismatch: expected %s for '%s'" const ERR_INVALID_NUMBER = "The %s key that equals %s should have a maximum of %s decimal places" const ERR_INVALID_MULT = "Multiplier in key %s that equals %s must be greater or equal to %s" const ERR_MULT_D = "Key %s that equal %d must be multiple of %d" const ERR_MULT_F = "Key %s that equal %f must be multiple of %f" const ERR_RANGE_D = "Key %s that equal %d must be %s than %d" const ERR_RANGE_F = "Key %s that equal %f must be %s than %f" const ERR_RANGE_S = "Length of '%s' (%d) %s than declared (%d)" const ERR_WRONG_PATTERN = "String '%s' does not match its corresponding pattern" const ERR_FORMAT = "String '%s' does not match its corresponding format '%s'" # This is one and only function that need you to call outside # If all validation checks passes, this OK func validate(json_data : String, schema: String) -> String: var error: int var json = JSON.new() # General validation input data as JSON file error = json.parse(json_data) if error: return ERR_INVALID_JSON_EXT % error_string(error) # General validation input schema as JSONSchema file error = json.parse(schema) if not error == OK : return ERR_WRONG_SCHEMA_GEN + error_string(error) var test_json_conv = JSON.new() test_json_conv.parse(schema) var parsed_schema = test_json_conv.get_data() match typeof(parsed_schema): TYPE_BOOL: if !parsed_schema: return ERR_INVALID_JSON_GEN % ERR_SCHEMA_FALSE else: return "" TYPE_DICTIONARY: if parsed_schema.is_empty(): return "" elif parsed_schema.keys().size() > 0 && !parsed_schema.has(JSKW_TYPE): return ERR_WRONG_SCHEMA_TYPE _: return ERR_WRONG_SCHEMA_TYPE # All inputs seems valid. Begin type validation # Normal return empty string, meaning OK return _type_selection(json_data, parsed_schema) func _to_string(): return "[JSONSchema:%d]" % get_instance_id() # TODO: title, description, default, examples, $comment, enum, const func _type_selection(json_data: String, schema: Dictionary, key: String = DEF_KEY_NAME) -> String: # If the schema is an empty object it always passes validation if schema.is_empty(): return "" if typeof(schema) == TYPE_BOOL: # If the schema is true it always passes validation if schema: return "" # If the schema is false it always vales validation else: return ERR_INVALID_JSON_GEN + "false is always invalid" var typearr: Array = _var_to_array(schema.type) var test_json_conv = JSON.new() test_json_conv.parse(json_data) var parsed_data = test_json_conv.get_data() var error: String = ERR_TYPE_MISMATCH_GEN % [typearr, key] for type in typearr: match type: JST_ARRAY: if typeof(parsed_data) == TYPE_ARRAY: error = _validate_array(parsed_data, schema, key) else: error = ERR_TYPE_MISMATCH_GEN % [[JST_ARRAY], key] JST_BOOLEAN: if typeof(parsed_data) != TYPE_BOOL: return ERR_TYPE_MISMATCH_GEN % [[JST_BOOLEAN], key] else: error = "" JST_INTEGER: if typeof(parsed_data) == TYPE_INT: error = _validate_integer(parsed_data, schema, key) if typeof(parsed_data) == TYPE_FLOAT && parsed_data == int(parsed_data): error = _validate_integer(int(parsed_data), schema, key) JST_NULL: if typeof(parsed_data) != TYPE_NIL: return ERR_TYPE_MISMATCH_GEN % [[JST_NULL], key] else: error = "" JST_NUMBER: if typeof(parsed_data) == TYPE_FLOAT: error = _validate_number(parsed_data, schema, key) else: error = ERR_TYPE_MISMATCH_GEN % [[JST_NUMBER], key] JST_OBJECT: if typeof(parsed_data) == TYPE_DICTIONARY: error = _validate_object(parsed_data, schema, key) else: error = ERR_TYPE_MISMATCH_GEN % [[JST_OBJECT], key] JST_STRING: if typeof(parsed_data) == TYPE_STRING: error = _validate_string(parsed_data, schema, key) else: error = ERR_TYPE_MISMATCH_GEN % [[JST_STRING], key] return error func _var_to_array(variant) -> Array: var result : Array = [] if typeof(variant) == TYPE_ARRAY: result = variant else: result.append(variant) return result func _validate_array(input_data: Array, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: # TODO: contains minContains maxContains uniqueItems # Initialize variables var error : String = "" # Variable to store any error messages var items_array : Array # Array of items in the schema var suberror : Array = [] # Array of suberrors in each item var additional_items_schema: Dictionary # Schema for additional items in the input data var is_additional_item_allowed: bool # Flag to check if additional items are allowed # Check if minItems key exists in the schema if input_schema.has(JSKW_MIN_ITEMS): # Check if non negative number if input_schema.minItems < 0: return ERR_WRONG_SCHEMA_GEN + "minItems must be a non-negative number." if input_data.size() < input_schema.minItems: return ERR_FEW_ITEMS % [input_data.size(), input_schema.minItems] # Check if maxItems key exists in the schema if input_schema.has(JSKW_MAX_ITEMS): # Check if non negative number if input_schema.maxItems < 0: return ERR_WRONG_SCHEMA_GEN + "minItems must be a non-negative number." if input_data.size() > input_schema.maxItems: return ERR_MORE_ITEMS % [input_data.size(), input_schema.maxItems] # Check if prefixItems key exists in the schema if input_schema.has(JSKW_PREFIX_ITEMS): # Check if items key exists in the schema if not input_schema.has(JSKW_ITEMS): return ERR_REQ_PROP_MISSING % [JSKW_ITEMS, JSKW_PREFIX_ITEMS] # Return error if items key is not a bool or a dictionary if not typeof(input_schema.items) == TYPE_DICTIONARY and not typeof(input_schema.items) == TYPE_BOOL: return ERR_WRONG_SCHEMA_TYPE if typeof(input_schema.items) == TYPE_BOOL: # Check if additional items in the input data are allowed if input_schema.items == false: # Check if there are more items in the input data than specified in prefixItems if input_data.size() > input_schema.prefixItems.size(): # Create an error message if there are more items than allowed var substr := "Array '%s' is of size %s but no addition items allowed." % [input_data, input_data.size()] return ERR_INVALID_JSON_GEN % substr # If the 'items' key is set to true all types are allowed for addition items. else: additional_items_schema = {} # Check if items key is a dictionary if typeof(input_schema.items) == TYPE_DICTIONARY: # Any items after the specified ones in prefixItems have to be validated with this schema # Set the schema for additional array items additional_items_schema = input_schema.items # Check if all entries in prefixItems are a dictionary for schema in input_schema.prefixItems: if typeof(schema) != TYPE_DICTIONARY: return ERR_WRONG_SHEMA_NOTA % [property_name, JSKW_ITEMS, JST_OBJECT] # Check every item in the input data for index in input_data.size(): var item = input_data[index] var current_schema: Dictionary var key_substr: String if index <= input_schema.prefixItems.size() - 1: # As long as there are prefixItems in the array work with those current_schema = input_schema.prefixItems[index] key_substr = ".prefixItems" else: # After that use the items schema current_schema = additional_items_schema key_substr = ".items" var sub_error_message := _type_selection(JSON.stringify(item), current_schema, property_name + key_substr + "[" + str(index) + "]") if not sub_error_message == "": suberror.append(sub_error_message) if suberror.size() > 0: return ERR_INVALID_JSON_GEN % str(suberror) # Return inside this if block, because we don't want to validate the items key twice. return error # Check if items key exists in the schema if input_schema.has(JSKW_ITEMS): #'items' must be an object if not typeof(input_schema.items) == TYPE_DICTIONARY: return ERR_WRONG_SHEMA_NOTA % [property_name, JSKW_ITEMS, JST_OBJECT] # Check every item of input Array on for index in input_data.size(): index = index - 1 # Validate the array item with the schema defined by the 'items' key var sub_error_message := _type_selection(JSON.stringify(input_data[index]), input_schema.items, property_name + "[" + str(index) + "]") if not sub_error_message == "": suberror.append(sub_error_message) if suberror.size() > 0: return ERR_INVALID_JSON_GEN % str(suberror) return error func _validate_boolean(input_data: bool, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: # nothing to check return "" func _validate_integer(input_data: int, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: # all processing is performed in return _validate_number(input_data, input_schema, property_name) func _validate_null(input_data, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: # nothing to check return "" func _validate_number(input_data: float, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: var types: Array = _var_to_array(input_schema.type) # integer mode turns on only if types has integer and has not number var integer_mode: bool = types.has(JST_INTEGER) && !types.has(JST_NUMBER) # Processing multiple check if input_schema.has(JSKW_MULT_OF): var mult: float var mod: float var is_zero: bool # Get the multipleOf value from the schema and convert to float mult = float(input_schema[JSKW_MULT_OF]) # Convert to integer if integer_mode is enabled mult = int(mult) if integer_mode else mult # Check if the number has more decimal places then allowed var decimal_places := str(input_data).get_slice('.', 1) if not decimal_places.is_empty() and decimal_places.length() > MAX_DECIMAL_PLACES: return ERR_INVALID_NUMBER % [property_name, input_data, str(MAX_DECIMAL_PLACES)] # Check if multipleOf is smaller than SMALL_FLOAT_THRESHOLD if not mult >= SMALL_FLOAT_THRESHOLD: return ERR_INVALID_MULT % [property_name, mult, str(SMALL_FLOAT_THRESHOLD)] # Multiply by a big number if input is smaller than 1 to prevent float issues if input_data < 1.0 or mult < 1.0: mod = fmod(input_data * 1000, mult * 1000) else: mod = fmod(input_data, mult) # Check if the remainder is close to zero is_zero = is_zero_approx(mod) # Return error message if remainder is not close to zero if not is_zero: if integer_mode: return ERR_MULT_D % [property_name, input_data, mult] else: return ERR_MULT_F % [property_name, input_data, mult] # processing minimum check if input_schema.has(JSKW_MINIMUM): var minimum = float(input_schema[JSKW_MINIMUM]) minimum = int(minimum) if integer_mode else minimum if input_data < minimum: if integer_mode: return ERR_RANGE_D % [property_name, input_data, JSM_GREATER_EQ, minimum] else: return ERR_RANGE_F % [property_name, input_data, JSM_GREATER_EQ, minimum] # processing exclusive minimum check if input_schema.has(JSKW_MIN_EX): var minimum = float(input_schema[JSKW_MIN_EX]) minimum = int(minimum) if integer_mode else minimum if input_data <= minimum: if integer_mode: return ERR_RANGE_D % [property_name, input_data, JSM_GREATER, minimum] else: return ERR_RANGE_F % [property_name, input_data, JSM_GREATER, minimum] # processing maximum check if input_schema.has(JSKW_MAXIMUM): var maximum = float(input_schema[JSKW_MAXIMUM]) maximum = int(maximum) if integer_mode else maximum if input_data > maximum: if integer_mode: return ERR_RANGE_D % [property_name, input_data, JSM_LESS_EQ, maximum] else: return ERR_RANGE_F % [property_name, input_data, JSM_LESS_EQ, maximum] # processing exclusive minimum check if input_schema.has(JSKW_MAX_EX): var maximum = float(input_schema[JSKW_MAX_EX]) maximum = int(maximum) if integer_mode else maximum if input_data >= maximum: if integer_mode: return ERR_RANGE_D % [property_name, input_data, JSM_LESS, maximum] else: return ERR_RANGE_F % [property_name, input_data, JSM_LESS, maximum] return "" func _validate_object(input_data: Dictionary, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: # TODO: patternProperties var error : String = "" # Process dependencies if input_schema.has(JSKW_DEPEND): for dependency in input_schema.dependencies.keys(): if input_data.has(dependency): match typeof(input_schema.dependencies[dependency]): TYPE_ARRAY: if input_schema.has(JSKW_REQ): for property in input_schema.dependencies[dependency]: input_schema.required.append(property) else: input_schema.required = input_schema.dependencies[dependency] TYPE_DICTIONARY: for key in input_schema.dependencies[dependency].keys(): if input_schema.has(key): match typeof(input_schema[key]): TYPE_ARRAY: for element in input_schema.dependencies[dependency][key]: input_schema[key].append(element) TYPE_DICTIONARY: for element in input_schema.dependencies[dependency][key].keys(): input_schema[key][element] = input_schema.dependencies[dependency][key][element] _: input_schema[key] = input_schema.dependencies[dependency][key] else: input_schema[key] = input_schema.dependencies[dependency][key] _: return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSL_OR % [JST_ARRAY, JSM_OBJ_DICT], property_name] # Process properties if input_schema.has(JSKW_PROP): # Process required if input_schema.has(JSKW_REQ): if typeof(input_schema.required) != TYPE_ARRAY: return ERR_REQ_PROP_GEN % property_name for i in input_schema.required: if !input_data.has(i): return ERR_REQ_PROP_MISSING % [i, property_name] # Continue validating schema subelements if typeof(input_schema.properties) != TYPE_DICTIONARY: return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSM_OBJ_DICT, property_name] # Process property items for key in input_schema.properties: if !input_schema.properties[key].has(JSKW_TYPE): return ERR_WRONG_PROP_TYPE if input_data.has(key): error = _type_selection(JSON.stringify(input_data[key]), input_schema.properties[key], key) else: pass if error: return error # Process additional properties if input_schema.has(JSKW_PROP_ADD): match typeof(input_schema.additionalProperties): TYPE_BOOL: if not input_schema.additionalProperties: for key in input_data: if not input_schema.properties.has(key): return ERR_NO_PROP_ADD % key TYPE_DICTIONARY: for key in input_data: if not input_schema.properties.has(key): return _type_selection(JSON.stringify(input_data[key]), input_schema.additionalProperties, key) _: return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSL_OR % [JST_BOOLEAN, JSM_OBJ_DICT], property_name] # Process properties names if input_schema.has(JSKW_PROP_NAMES): if typeof(input_schema.propertyNames) != TYPE_DICTIONARY: return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JSM_OBJ_DICT, property_name] for key in input_data: error = _validate_string(key, input_schema.propertyNames, key) if error: return error # Process minProperties maxProperties if input_schema.has(JSKW_PROP_MIN): if typeof(input_schema[JSKW_PROP_MIN]) != TYPE_FLOAT: return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name] if input_data.keys().size() < input_schema[JSKW_PROP_MIN]: return ERR_FEW_PROP % [input_data.keys().size(), input_schema[JSKW_PROP_MIN]] if input_schema.has(JSKW_PROP_MAX): if typeof(input_schema[JSKW_PROP_MAX]) != TYPE_FLOAT: return ERR_WRONG_SCHEMA_GEN + ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name] if input_data.keys().size() > input_schema[JSKW_PROP_MAX]: return ERR_MORE_PROP % [input_data.keys().size(), input_schema[JSKW_PROP_MAX]] return error func _validate_string(input_data: String, input_schema: Dictionary, property_name: String = DEF_KEY_NAME) -> String: # TODO: format var error : String = "" if input_schema.has(JSKW_LENGTH_MIN): if not (typeof(input_schema[JSKW_LENGTH_MIN]) == TYPE_INT || typeof(input_schema[JSKW_LENGTH_MIN]) == TYPE_FLOAT): return ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name+"."+JSKW_LENGTH_MIN] if input_data.length() < input_schema[JSKW_LENGTH_MIN]: return ERR_INVALID_JSON_GEN % ERR_RANGE_S % [property_name, input_data.length(), JSM_LESS ,input_schema[JSKW_LENGTH_MIN]] if input_schema.has(JSKW_LENGTH_MAX): if not (typeof(input_schema[JSKW_LENGTH_MAX]) == TYPE_INT || typeof(input_schema[JSKW_LENGTH_MAX]) == TYPE_FLOAT): return ERR_TYPE_MISMATCH_GEN % [JST_INTEGER, property_name+"."+JSKW_LENGTH_MAX] if input_data.length() > input_schema[JSKW_LENGTH_MAX]: return ERR_INVALID_JSON_GEN % ERR_RANGE_S % [property_name, input_data.length(), JSM_GREATER, input_schema[JSKW_LENGTH_MAX]] if input_schema.has(JSKW_PATTERN): if not (typeof(input_schema[JSKW_PATTERN]) == TYPE_STRING): return ERR_TYPE_MISMATCH_GEN % [JST_STRING, property_name+"."+JSKW_PATTERN] var regex = RegEx.new() regex.compile(input_schema[JSKW_PATTERN]) if regex.search(input_data) == null: return ERR_INVALID_JSON_GEN % ERR_WRONG_PATTERN % property_name if input_schema.has(JSKW_FORMAT): # validate "color" format if input_schema.format.to_lower() == JSKW_COLOR: if not input_data.is_valid_html_color(): return ERR_INVALID_JSON_GEN % ERR_FORMAT % [property_name, JSKW_COLOR] return error