# ObjectValidator.java

    /*
     * Copyright (c) 2019, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
     *
     * WSO2 Inc. licenses this file to you 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.apache.synapse.commons.json.jsonprocessor.validators;

    import com.google.gson.JsonArray;
    import com.google.gson.JsonElement;
    import com.google.gson.JsonNull;
    import com.google.gson.JsonObject;
    import org.apache.commons.lang3.math.NumberUtils;
    import org.apache.synapse.commons.json.jsonprocessor.constants.ValidatorConstants;
    import org.apache.synapse.commons.json.jsonprocessor.exceptions.ParserException;
    import org.apache.synapse.commons.json.jsonprocessor.exceptions.ValidatorException;
    import org.apache.synapse.commons.json.jsonprocessor.utils.DataTypeConverter;
    import org.apache.synapse.commons.json.jsonprocessor.utils.GSONDataTypeConverter;
    import org.apache.synapse.commons.json.jsonprocessor.utils.JsonProcessorUtils;

    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;

    /**
     * This class will validate json objects according to the schema.
     */
    public class ObjectValidator {

        private final static String ADDITIONAL_PROPERTIES = "additionalProperties";
        private final static String MAX_PROPERTIES = "maxProperties";
        private final static String MIN_PROPERTIES = "minProperties";
        private final static String PATTERN_PROPERTIES = "patternProperties";
        private final static String REQUIRED = "required";

        // Use without instantiating.
        private ObjectValidator() {

        }

        /**
         * This method will validate a given JSON input object according to
         *
         * @param object JSON Object
         * @param schema JSON schema
         * @return validated object
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        public static JsonObject validateObject(JsonObject object, JsonObject schema) throws ParserException,
                ValidatorException {
            int minimumProperties = -1;
            int maximumProperties = -1;
            // check whether all the required elements are present.
            // keyword MUST be an array. Elements of this array, if any, MUST be strings, and MUST be unique.
            if (schema.has(REQUIRED)) {
                JsonArray requiredArray = schema.getAsJsonArray(REQUIRED);
                for (JsonElement element : requiredArray) {
                    if (!object.has(element.getAsString())) {
                        throw new ValidatorException("Input object : " + object.toString() +
                                " does not contains all the elements required in the schema : " + schema.toString());
                    }
                }
            }
            if (schema.has(MIN_PROPERTIES)) {
                String minPropertiesString = JsonProcessorUtils.replaceEnclosingQuotes(
                        schema.get(MIN_PROPERTIES).getAsString());
                if (!minPropertiesString.isEmpty()) {
                    minimumProperties = DataTypeConverter.convertToInt(minPropertiesString);
                }
            }
            if (schema.has(MAX_PROPERTIES)) {
                String maxPropertiesString = JsonProcessorUtils.replaceEnclosingQuotes(
                        schema.get(MAX_PROPERTIES).getAsString());
                if (!maxPropertiesString.isEmpty()) {
                    maximumProperties = DataTypeConverter.convertToInt(maxPropertiesString);
                }
            }
            JsonObject schemaObject = null;
            if (schema.has("properties")) {
                schemaObject = (JsonObject) schema.get("properties");
            }
            Set<Map.Entry<String, JsonElement>> entryInput = object.entrySet();

            // doing structural validation
            doStructuralValidation(maximumProperties, minimumProperties, entryInput);

            ArrayList<String> inputProperties = new ArrayList<>();
            ArrayList<String> patternProperties = new ArrayList<>();
            ArrayList<String> schemaProperties = new ArrayList<>();

            if (schemaObject != null && !schemaObject.entrySet().isEmpty()) {
                for (Map.Entry<String, JsonElement> entry : schemaObject.entrySet()) {
                    if (object.has(entry.getKey())) {
                        schemaProperties.add(entry.getKey());
                    }
                }
            }

            // validate children elements according to the schema only if properties are defined for each item.
            processSchemaProperties(schemaObject, entryInput, inputProperties);

            // handling pattern properties
            if (schema.has(PATTERN_PROPERTIES)) {
                processPatternProperties(object, schema, patternProperties);
            }

            // handling additionalProperties
            processAdditionalProperties(object, schema, inputProperties, patternProperties, schemaProperties);

            return object;
        }

        /**
         * This method will process a given JSON object according to "properties" in respective JSON schema.
         *
         * @param schemaObject    JSON schema.
         * @param entryInput      JSON input as map.
         * @param inputProperties array which should be updated with matching properties in schema.
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        private static void processSchemaProperties(JsonObject schemaObject, Set<Map.Entry<String, JsonElement>>
                entryInput, ArrayList<String> inputProperties) throws ValidatorException, ParserException {
            if (schemaObject != null) {
                // Calling validation for internal items.
                for (Map.Entry<String, JsonElement> entry : entryInput) {
                    String keyValue = entry.getKey();
                    inputProperties.add(keyValue);
                    if (schemaObject.has(keyValue) && schemaObject.get(keyValue).isJsonObject()) {
                        JsonObject schemaObj = schemaObject.getAsJsonObject(keyValue);
                        if (schemaObj.has(ValidatorConstants.TYPE_KEY)) {
                            JsonElement typeElement = schemaObj.get(ValidatorConstants.TYPE_KEY);
                            if (typeElement.isJsonArray()) {
                                ArrayList<String> types = new ArrayList<>();
                                // Handling multiple types
                                JsonArray array = (JsonArray) typeElement;
                                for (JsonElement element : array) {
                                    types.add(JsonProcessorUtils.replaceEnclosingQuotes(element.toString()));
                                }
                                validateAndUpdateEntriesMap(schemaObject, entry, types);
                            } else {
                                validateAndUpdateEntriesMap(schemaObject, entry,
                                        JsonProcessorUtils.replaceEnclosingQuotes(typeElement.toString()));
                            }
                        }
                    }
                }
            }
        }

        /**
         * This method will validate and update the entries of input object map (multi-type).
         *
         * @param schemaObject JSON schema.
         * @param entry        entry from the map.
         * @param types        data types (multi-type support).
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        private static void validateAndUpdateEntriesMap(JsonObject schemaObject, Map.Entry<String, JsonElement> entry,
                                                        ArrayList<String> types) throws ValidatorException,
                ParserException {
            if (schemaObject.has(entry.getKey()) && schemaObject.get(entry.getKey()).isJsonObject()) {
                JsonObject schema = schemaObject.get(entry.getKey()).getAsJsonObject();
                if (entry.getValue().isJsonObject()) {
                    multiTypeCheckObject(entry, types, schema);
                } else if (entry.getValue().isJsonArray()) {
                    multiTypeCheckArray(entry, types, schema);
                } else if (entry.getValue().isJsonNull()) {
                    multiTypeCheckNull(entry, types, schema);
                } else if (entry.getValue().isJsonPrimitive()) {
                    multiTypeCheckPrimitive(entry, types, schema);
                }
            }
        }

        // Try to correct a JSON object against an schema with multiple types.
        private static void multiTypeCheckObject(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                                 JsonObject schema) throws ParserException, ValidatorException {
            boolean validated = false;
            for (String type : types) {
                if (ValidatorConstants.OBJECT_KEYS.contains(type)) {
                    entry.setValue(ObjectValidator.validateObject(entry.getValue().getAsJsonObject(), schema));
                    validated = true;
                }
            }
            if (!validated) {
                for (String type : types) {
                    if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                        // Single element array correction
                        JsonArray array = new JsonArray();
                        array.add(entry.getValue().getAsJsonObject());
                        entry.setValue(array);
                        entry.setValue(ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(
                                entry.getValue().toString()), schema));
                        validated = true;
                    }
                }
            }
            if (!validated) {
                throw new ValidatorException("JSON object found " + entry.getValue() + " without matching " +
                        "schema " + schema.toString());
            }
        }

        // Try to correct a JSON array against an schema with multiple types.
        private static void multiTypeCheckArray(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                                JsonObject schema) throws ParserException, ValidatorException {
            boolean validated = false;
            for (String type : types) {
                if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                    entry.setValue(ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(
                            entry.getValue().toString()), schema));
                    validated = true;
                }
            }
            if (!validated) {
                throw new ValidatorException("JSON array found " + entry.getValue() + " without matching " +
                        "schema " + schema.toString());
            }
        }

        // Try to correct a JSON null against an schema with multiple types.
        private static void multiTypeCheckNull(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                               JsonObject schema) throws ParserException, ValidatorException {
            boolean validated = false;
            for (String type : types) {
                if (ValidatorConstants.NULL_KEYS.contains(type)) {
                    entry.setValue(JsonNull.INSTANCE);
                    validated = true;
                }
            }
            if (!validated) {
                throw new ValidatorException("JSON null found " + entry.getValue() + " without matching " +
                        "schema " + schema.toString());
            }
        }

        // Try to correct a JSON primitive (nominal, numeric, boolean) against an schema with multiple types.
        private static void multiTypeCheckPrimitive(Map.Entry<String, JsonElement> entry, ArrayList<String> types,
                                                    JsonObject schema) throws ParserException, ValidatorException {

            String value = entry.getValue().getAsString();
            boolean validated = false;
            if ("true".equals(value) || "false".equals(value)) {
                // if boolean check for boolean type if type is nominal convert to string
                for (String type : types) {
                    if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                        entry.setValue(BooleanValidator.validateBoolean(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
                if (!validated) {
                    for (String type : types) {
                        if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                            entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                            validated = true;
                        }
                    }
                }
                if (!validated) {
                    throw new ValidatorException("Boolean found " + entry.getValue() + " without matching " +
                            "schema " + schema.toString());
                }
            } else if (NumberUtils.isParsable(value)) {
                // if number check for numeric types if type is nominal convert to string
                for (String type : types) {
                    if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                        entry.setValue(NumericValidator.validateNumeric(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
                if (!validated) {
                    for (String type : types) {
                        if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                            entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                            validated = true;
                        }
                    }
                }
                if (!validated) {
                    throw new ValidatorException("Number found " + entry.getValue() + " without matching " +
                            "schema " + schema.toString());
                }
            } else {
                // if string check for all types (nominal, boolean, numeric) and try to parse
                for (String type : types) {
                    if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                        entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                        validated = true;
                    }
                }
                if (!validated) {
                    for (String type : types) {
                        if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                            entry.setValue(BooleanValidator.validateBoolean(schema, entry.getValue().getAsString()));
                            validated = true;
                        }
                    }
                }
                if (!validated) {
                    for (String type : types) {
                        if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                            entry.setValue(NumericValidator.validateNumeric(schema, entry.getValue().getAsString()));
                            validated = true;
                        }
                    }
                }
                if (!validated) {
                    throw new ValidatorException(entry.getValue() + " not matching with the schema " + schema.toString());
                }
            }
        }

        /**
         * This method will validate and update the entries of input object map.
         *
         * @param schemaObject JSON schema.
         * @param entry        entry from the map.
         * @param type        data type.
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        private static void validateAndUpdateEntriesMap(JsonObject schemaObject, Map.Entry<String, JsonElement> entry,
                                                        String type) throws ValidatorException,
                ParserException {
            if (schemaObject.has(entry.getKey()) && schemaObject.get(entry.getKey()).isJsonObject()) {
                JsonObject schema = schemaObject.get(entry.getKey()).getAsJsonObject();
                if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                    entry.setValue(BooleanValidator.validateBoolean(schema, entry.getValue().getAsString()));
                } else if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                    entry.setValue(StringValidator.validateNominal(schema, entry.getValue().getAsString()));
                } else if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                    entry.setValue(NumericValidator.validateNumeric(schema, entry.getValue().getAsString()));
                } else if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                    entry.setValue(ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(entry.getValue()
                            .toString()), schema));
                } else if (ValidatorConstants.OBJECT_KEYS.contains(type)) {
                    entry.setValue(ObjectValidator.validateObject(entry.getValue().getAsJsonObject(), schema));
                } else if (ValidatorConstants.NULL_KEYS.contains(type)) {
                    if (entry.getValue() != null) {
                        NullValidator.validateNull(schema, entry.getValue().toString());
                    }
                    entry.setValue(JsonNull.INSTANCE);
                }
            }
        }

        /**
         * This method will handle the additional properties constraint.
         *
         * @param object            input JSON object.
         * @param schema            JSON schema.
         * @param inputProperties   array which contains all keys of input object.
         * @param patternProperties array which contains all matching keys with pattern properties.
         * @param schemaProperties  array which contains all schema keys.
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        private static void processAdditionalProperties(JsonObject object, JsonObject schema, ArrayList<String>
                inputProperties, ArrayList<String> patternProperties, ArrayList<String> schemaProperties)
                throws ParserException, ValidatorException {
            if (schema.has(ADDITIONAL_PROPERTIES)) {
                inputProperties.removeAll(schemaProperties);
                inputProperties.removeAll(patternProperties);
                if (schema.get(ADDITIONAL_PROPERTIES).isJsonPrimitive()) {
                    boolean allowAdditional = DataTypeConverter.convertToBoolean(schema.get(ADDITIONAL_PROPERTIES)
                            .getAsString());
                    if (!allowAdditional && !inputProperties.isEmpty()) {
                        throw new ValidatorException("Input object " + object.toString() +
                                " has additional properties than schema " + schema.toString() +
                                " and additional properties are not allowed");
                    }
                } else if (schema.get(ADDITIONAL_PROPERTIES).isJsonObject()) {
                    JsonObject additionalSchema = schema.get(ADDITIONAL_PROPERTIES).getAsJsonObject();
                    validateMultipleObjectsUsingOneSchema(inputProperties, object, additionalSchema);
                }
            }
        }

        /**
         * This method will process input objects matching with "patternProperties" regular expression.
         *
         * @param object            input JSON object.
         * @param schema            input schema.
         * @param patternProperties arrayList which should be updated with matching keys of patternProperty.
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        private static void processPatternProperties(JsonObject object, JsonObject schema, ArrayList<String>
                patternProperties) throws ParserException,
                ValidatorException {
            Set<String> inputObjectKeyset = object.keySet();
            JsonObject patternsObject = schema.getAsJsonObject(PATTERN_PROPERTIES);
            Set<Map.Entry<String, JsonElement>> patterns = patternsObject.entrySet();
            for (Map.Entry<String, JsonElement> pattern : patterns) {
                String regex = pattern.getKey();
                JsonObject tempSchema = pattern.getValue().getAsJsonObject();
                if (tempSchema.has(ValidatorConstants.TYPE_KEY)) {
                    String type = JsonProcessorUtils.replaceEnclosingQuotes(
                            tempSchema.get(ValidatorConstants.TYPE_KEY).getAsString());
                    // get the list of keys matched the regular expression
                    ArrayList<String> matchingKeys = getMatchRegexAgainstStringSet(inputObjectKeyset, regex);
                    for (String key : matchingKeys) {
                        // updating patternProperties array for later use in additional properties.
                        if (!patternProperties.contains(key)) {
                            patternProperties.add(key);
                        }
                        parseAndReplaceValues(object, tempSchema, type, key);
                    }
                } else {
                    throw new ValidatorException("Schema for object must have a " +
                            "type declaration : " + tempSchema.toString());
                }

            }
        }

        /**
         * Get matching set of keys inside object, for a given regular expression.
         *
         * @param keyset set of keys inside object.
         * @param regex  regular expression.
         * @return array of matching keys.
         */
        private static ArrayList<String> getMatchRegexAgainstStringSet(Set<String> keyset, String regex) {
            ArrayList<String> matchingKeys = new ArrayList<>();
            Pattern pattern = Pattern.compile(regex);
            Matcher matcher;
            for (String item : keyset) {
                matcher = pattern.matcher(item);
                if (matcher.find()) {
                    matchingKeys.add(item);
                }
            }
            return matchingKeys;
        }

        /**
         * Doing structural validations for a given JSON object according to schema.
         *
         * @param maximumProperties maximum number of properties allowed.
         * @param minimumProperties minimum number of properties allowed.
         * @param entryInput        input object as a Map.
         * @throws ValidatorException validation exception occurs.
         */
        private static void doStructuralValidation(int maximumProperties, int minimumProperties, Set<Map.Entry<String,
                JsonElement>> entryInput) throws ValidatorException {
            int numOfProperties = entryInput.size();

            if (minimumProperties != -1 && numOfProperties < minimumProperties) {
                throw new ValidatorException("Object violates the minimum number of properties " +
                        "constraint. Input object has less number of properties than allowed minimum " +
                        minimumProperties);
            }
            if (maximumProperties != -1 && numOfProperties > maximumProperties) {
                throw new ValidatorException("Object violates the maximum number of properties " +
                        "constraint. Input object has higher number of properties than allowed maximum " +
                        maximumProperties);
            }
        }

        /**
         * Given a single schema object and array of keys, this method will validate all input object keys
         * according to the schema.
         *
         * @param keysArray array of keys.
         * @param input     input object.
         * @param schema    schema object.
         */
        private static void validateMultipleObjectsUsingOneSchema(ArrayList<String> keysArray, JsonObject input,
                                                                  JsonObject schema) throws ValidatorException,
                ParserException {
            if (!schema.entrySet().isEmpty() && !keysArray.isEmpty()) {
                if (schema.has(ValidatorConstants.TYPE_KEY)) {
                    String type = JsonProcessorUtils.replaceEnclosingQuotes(
                            schema.get(ValidatorConstants.TYPE_KEY).toString());
                    for (String key : keysArray) {
                        parseAndReplaceValues(input, schema, type, key);
                    }
                } else {
                    throw new ValidatorException("Schema for array must have a " +
                            "type declaration : " + schema.toString());
                }
            }
        }

        /**
         * This method will parse and replace specific object defined by the key.
         *
         * @param input  input object.
         * @param schema JSON schema.
         * @param type   type of the object.
         * @param key    key of the specific object/ element.
         * @throws ParserException    Exception occurred in data type conversions.
         * @throws ValidatorException Exception occurred in schema validations.
         */
        private static void parseAndReplaceValues(JsonObject input, JsonObject schema, String type, String key) throws
                ParserException, ValidatorException {
            JsonElement result = null;
            if (ValidatorConstants.NUMERIC_KEYS.contains(type)) {
                result = NumericValidator.validateNumeric(schema, input.get(key).toString());
            } else if (ValidatorConstants.NOMINAL_KEYS.contains(type)) {
                result = StringValidator.validateNominal(schema, input.get(key).toString());
            } else if (ValidatorConstants.BOOLEAN_KEYS.contains(type)) {
                result = BooleanValidator.validateBoolean(schema, input.get(key).toString());
            } else if (ValidatorConstants.ARRAY_KEYS.contains(type)) {
                result = ArrayValidator.validateArray(GSONDataTypeConverter.getMapFromString(
                        input.get(key).toString()), schema);
            } else if (ValidatorConstants.OBJECT_KEYS.contains(type)) {
                result = ObjectValidator.validateObject(input.get(key).getAsJsonObject(), schema);
            } else if (ValidatorConstants.NULL_KEYS.contains(type)) {
                if (input.get(key) != null) {
                    NullValidator.validateNull(schema, input.get(key).toString());
                }
                result = JsonNull.INSTANCE;
            }
            input.remove(key);
            input.add(key, result);
        }
    }