Source: composer-common/lib/serializer/resourcevalidator.js

/*
 * 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.
 */

'use strict';

const ClassDeclaration = require('../introspect/classdeclaration');
const Field = require('../introspect/field');
const RelationshipDeclaration = require('../introspect/relationshipdeclaration');
const EnumDeclaration = require('../introspect/enumdeclaration');
const Relationship = require('../model/relationship');
const Resource = require('../model/resource');
const Concept = require('../model/concept');
const Identifiable = require('../model/identifiable');
const Util = require('../util');
const ModelUtil = require('../modelutil');
const ValidationException = require('./validationexception');
const Globalize = require('../globalize');

/**
 * <p>
 * Validates a Resource or Field against the models defined in the ModelManager.
 * This class is used with the Visitor pattern and visits the class declarations
 * (etc) for the model, checking that the data in a Resource / Field is consistent
 * with the model.
 * </p>
 * The parameters for the visit method must contain the following properties:
 * <ul>
 *  <li> 'stack' - the TypedStack of objects being processed. It should
 * start as [Resource] or [Field]</li>
 * <li> 'rootResourceIdentifier' - the identifier of the resource being validated </li>
 * <li> 'modelManager' - the ModelManager instance to use for type checking</li>
 * </ul>
 * @private
 * @class
 * @memberof module:composer-common
 */
class ResourceValidator {
    /**
     * Visitor design pattern.
     *
     * @param {Object} thing - the object being visited
     * @param {Object} parameters  - the parameter
     * @return {Object} the result of visiting or null
     * @private
     */
    visit(thing, parameters) {
        const obj = parameters.stack.peek();
        this.log('visit', thing.toString() + ' with value (' + obj + ')');

        if (thing instanceof EnumDeclaration) {
            return this.visitEnumDeclaration(thing, parameters);
        } else if (thing instanceof ClassDeclaration) {
            return this.visitClassDeclaration(thing, parameters);
        } else if (thing instanceof RelationshipDeclaration) {
            return this.visitRelationshipDeclaration(thing, parameters);
        } else if (thing instanceof Field) {
            return this.visitField(thing, parameters);
        }
    }

    /**
     * Visitor design pattern
     *
     * @param {EnumDeclaration} enumDeclaration - the object being visited
     * @param {Object} parameters  - the parameter
     * @return {Object} the result of visiting or null
     * @private
     */
    visitEnumDeclaration(enumDeclaration, parameters) {
        const obj = parameters.stack.pop();

        // now check that obj is one of the enum values
        const properties = enumDeclaration.getProperties();
        let found = false;
        for(let n=0; n < properties.length; n++) {
            const property = properties[n];
            if(property.getName() === obj) {
                found = true;
            }
        }

        if(!found) {
            ResourceValidator.reportInvalidEnumValue( parameters.rootResourceIdentifier, enumDeclaration, obj );
        }

        return null;
    }

    /**
     * Visitor design pattern
     * @param {ClassDeclaration} classDeclaration - the object being visited
     * @param {Object} parameters  - the parameter
     * @return {Object} the result of visiting or null
     * @private
     */
    visitClassDeclaration(classDeclaration, parameters) {

        const obj = parameters.stack.pop();

        // are we dealing with a Resouce?
        if(!((obj instanceof Resource) || (obj instanceof Concept))) {
            ResourceValidator.reportNotResouceViolation(parameters.rootResourceIdentifier, classDeclaration, obj );
        }

        if(obj instanceof Identifiable) {
            parameters.rootResourceIdentifier = obj.getFullyQualifiedIdentifier();
        }

        const toBeAssignedClassDeclaration = parameters.modelManager.getType(obj.getFullyQualifiedType());

        // is the type we are assigning to abstract?
        // the only way this can happen is if the type is non-abstract
        // and then gets redeclared as abstract
        if(toBeAssignedClassDeclaration.isAbstract()) {
            ResourceValidator.reportAbstractClass(toBeAssignedClassDeclaration);
        }

        // are there extra fields in the object?
        let props = Object.getOwnPropertyNames(obj);
        for (let n = 0; n < props.length; n++) {
            let propName = props[n];
            if(!this.isSystemProperty(propName)) {
                const field = toBeAssignedClassDeclaration.getProperty(propName);
                if (!field) {
                    if(obj instanceof Identifiable) {
                        ResourceValidator.reportUndeclaredField(obj.getIdentifier(), propName, toBeAssignedClassDeclaration.getFullyQualifiedName());
                    }
                    else {
                        ResourceValidator.reportUndeclaredField(this.currentIdentifier, propName, toBeAssignedClassDeclaration.getFullyQualifiedName());
                    }
                }
            }
        }

        if(obj instanceof Identifiable) {
            this.currentIdentifier = obj.getFullyQualifiedIdentifier();
        }

        // now validate each property
        const properties = toBeAssignedClassDeclaration.getProperties();
        for(let n=0; n < properties.length; n++) {
            const property = properties[n];
            const value = obj[property.getName()];
            if(!Util.isNull(value)) {
                parameters.stack.push(value);
                property.accept(this,parameters);
            }
            else {
                if(!property.isOptional()) {
                    ResourceValidator.reportMissingRequiredProperty( parameters.rootResourceIdentifier, property);
                }
            }
        }
        return null;
    }

    /**
     * Returns true if the property is a system property.
     * System properties are not declared in the model.
     * @param {String} propertyName - the name of the property
     * @return {Boolean} true if the property is a system property
     * @private
     */
    isSystemProperty(propertyName) {
        return propertyName.charAt(0) === '$';
    }

    /**
     * Visitor design pattern
     * @param {Field} field - the object being visited
     * @param {Object} parameters  - the parameter
     * @return {Object} the result of visiting or null
     * @private
     */
    visitField(field, parameters) {
        const obj = parameters.stack.pop();

        let dataType = typeof(obj);
        let propName = field.getName();

        if (dataType === 'undefined' || dataType === 'symbol') {
            ResourceValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field);
        }

        if(field.isTypeEnum()) {
            this.checkEnum(obj, field,parameters);
        }
        else {
            if(field.isArray()) {
                this.checkArray(obj, field,parameters);
            }
            else {
                this.checkItem(obj, field,parameters);
            }
        }

        return null;
    }

    /**
     * Check a Field that is declared as an Array.
     * @param {Object} obj - the object being validated
     * @param {Field} field - the object being visited
     * @param {Object} parameters  - the parameter
     * @private
     */
    checkEnum(obj,field,parameters) {

        if(field.isArray() && !(obj instanceof Array)) {
            ResourceValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, field.getName(), obj, field);
        }

        const enumDeclaration = field.getParent().getModelFile().getType(field.getType());

        if(field.isArray()) {
            for(let n=0; n < obj.length; n++) {
                const item = obj[n];
                parameters.stack.push(item);
                enumDeclaration.accept(this, parameters);
            }
        }
        else {
            const item = obj;
            parameters.stack.push(item);
            enumDeclaration.accept(this, parameters);
        }
    }

    /**
     * Check a Field that is declared as an Array.
     * @param {Object} obj - the object being validated
     * @param {Field} field - the object being visited
     * @param {Object} parameters  - the parameter
     * @private
     */
    checkArray(obj,field,parameters) {

        if(!(obj instanceof Array)) {
            ResourceValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, field.getName(), obj, field);
        }

        for(let n=0; n < obj.length; n++) {
            const item = obj[n];
            this.checkItem(item, field, parameters);
        }
    }

    /**
     * Check a single (non-array) field.
     * @param {Object} obj - the object being validated
     * @param {Field} field - the object being visited
     * @param {Object} parameters  - the parameter
     * @private
     */
    checkItem(obj,field, parameters) {
        let dataType = typeof obj;
        let propName = field.getName();

        if (dataType === 'undefined' || dataType === 'symbol') {
            ResourceValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field);
        }

        if(field.isPrimitive()) {
            let invalid = false;

            switch(field.getType()) {
            case 'String':
                if(dataType !== 'string') {
                    invalid = true;
                }
                break;
            case 'Double':
            case 'Long':
            case 'Integer':
                if(dataType !== 'number') {
                    invalid = true;
                }
                break;
            case 'Boolean':
                if(dataType !== 'boolean') {
                    invalid = true;
                }
                break;
            case 'DateTime':
                if(!(obj instanceof Date)) {
                    invalid = true;
                }
                break;
            }
            if (invalid) {
                ResourceValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field);
            }
            else {
                if(field.getValidator() !== null) {
                    field.getValidator().validate(this.currentIdentifier, obj);
                }
            }
        }
        else {
            // a field that points to a transaction, asset, participant...
            let classDeclaration = parameters.modelManager.getType(field.getFullyQualifiedTypeName());
            if(obj instanceof Identifiable) {
                classDeclaration = parameters.modelManager.getType(obj.getFullyQualifiedType());

                if(!classDeclaration) {
                    ResourceValidator.reportFieldTypeViolation(parameters.rootResourceIdentifier, propName, obj, field);
                }

              // is it compatible?
                if(!ModelUtil.isAssignableTo(classDeclaration.getModelFile(), classDeclaration.getFullyQualifiedName(), field)) {
                    ResourceValidator.reportInvalidFieldAssignment(parameters.rootResourceIdentifier, propName, obj, field);
                }
            }

            // recurse
            parameters.stack.push(obj);
            classDeclaration.accept(this, parameters);
        }
    }

    /**
     * Visitor design pattern
     * @param {RelationshipDeclaration} relationshipDeclaration - the object being visited
     * @param {Object} parameters  - the parameter
     * @return {Object} the result of visiting or null
     * @private
     */
    visitRelationshipDeclaration(relationshipDeclaration, parameters) {
        const obj = parameters.stack.pop();

        if(relationshipDeclaration.isArray()) {
            if(!(obj instanceof Array)) {
                ResourceValidator.reportInvalidFieldAssignment(parameters.rootResourceIdentifier, relationshipDeclaration.getName(), obj, relationshipDeclaration);
            }

            for(let n=0; n < obj.length; n++) {
                const item = obj[n];
                this.checkRelationship(parameters, relationshipDeclaration, item);
            }
        }
        else {
            this.checkRelationship(parameters, relationshipDeclaration, obj);
        }
        return null;
    }

    /**
     * Check a single relationship
     * @param {Object} parameters  - the parameter
     * @param {relationshipDeclaration} relationshipDeclaration - the object being visited
     * @param {Object} obj - the object being validated
     * @private
     */
    checkRelationship(parameters, relationshipDeclaration, obj) {
        if(!(obj instanceof Relationship)) {
            ResourceValidator.reportNotRelationshipViolation(parameters.rootResourceIdentifier, relationshipDeclaration, obj);
        }

        const relationshipType = parameters.modelManager.getType(obj.getFullyQualifiedType());

        if(relationshipType.isConcept()) {
            throw new Error('Cannot have a relationship to a concept. Relationships must be to resources.');
        }

        if(!ModelUtil.isAssignableTo(relationshipType.getModelFile(), obj.getFullyQualifiedType(), relationshipDeclaration)) {
            ResourceValidator.reportInvalidFieldAssignment(parameters.rootResourceIdentifier, relationshipDeclaration.getName(), obj, relationshipDeclaration);
        }
    }

    /**
     * @param {String} callSite - the location
     * @param {String} message - the message to log.
     */
    log(callSite, message) {
        const log = false;
        if(log) {
            if(!message) {
                message = '';
            }

            console.log('[' + callSite + '] ' + message );
        }
    }

    /**
     * Throw a new error for a model violation.
     * @param {string} id - the identifier of this instance.
     * @param {string} propName - the name of the field.
     * @param {*} value - the value of the field.
     * @param {Field} field - the field
     * @throws {ValidationException} the exception
     * @private
     */
    static reportFieldTypeViolation(id, propName, value, field) {
        let isArray = field.isArray() ? '[]' : '';
        let typeOfValue = typeof value;

        if(value instanceof Identifiable) {
            typeOfValue = value.getFullyQualifiedType();
            value = value.getFullyQualifiedIdentifier();
        }
        else {
            if(value) {
                try {
                    value = JSON.stringify(value);
                }
                catch(err) {
                    value = value.toString();
                }
            }
        }

        let formatter = Globalize.messageFormatter('resourcevalidator-fieldtypeviolation');
        throw new ValidationException(formatter({
            resourceId: id,
            propertyName: propName,
            fieldType: field.getType() + isArray,
            value: value,
            typeOfValue: typeOfValue
        }));
    }

    /**
     * Throw a new error for a model violation.
     * @param {string} id - the identifier of this instance.
     * @param {classDeclaration} classDeclaration - the declaration of the classs
     * @param {Object} value - the value of the field.
     * @private
     */
    static reportNotResouceViolation(id, classDeclaration, value) {
        let formatter = Globalize.messageFormatter('resourcevalidator-notresourceorconcept');
        throw new ValidationException(formatter({
            resourceId: id,
            classFQN: classDeclaration.getFullyQualifiedName(),
            invalidValue: value.toString()
        }));
    }

    /**
     * Throw a new error for a model violation.
     * @param {string} id - the identifier of this instance.
     * @param {RelationshipDeclaration} relationshipDeclaration - the declaration of the classs
     * @param {Object} value - the value of the field.
     * @private
     */
    static reportNotRelationshipViolation(id, relationshipDeclaration, value) {
        let formatter = Globalize.messageFormatter('resourcevalidator-notrelationship');
        throw new ValidationException(formatter({
            resourceId: id,
            classFQN: relationshipDeclaration.getFullyQualifiedTypeName(),
            invalidValue: value.toString()
        }));
    }

    /**
     * Throw a new error for a missing, but required field.
     * @param {string} id - the identifier of this instance.
     * @param {Field} field - the field/
     * @private
     */
    static reportMissingRequiredProperty(id, field) {
        let formatter = Globalize.messageFormatter('resourcevalidator-missingrequiredproperty');
        throw new ValidationException(formatter({
            resourceId: id,
            fieldName: field.getName()
        }));
    }

    /**
     * Throw a new error for a missing, but required field.
     * @param {string} id - the identifier of this instance.
     * @param {Field} field - the field
     * @param {string} obj - the object value
     * @private
     */
    static reportInvalidEnumValue(id, field, obj) {
        let formatter = Globalize.messageFormatter('resourcevalidator-invalidenumvalue');
        throw new ValidationException(formatter({
            resourceId: id,
            value: obj,
            fieldName: field.getName()
        }));
    }

    /**
     * Throw a validation exception for an abstract class
     * @param {ClassDeclaration} classDeclaration - the class declaration
     * @throws {ValidationException} the validation exception
     * @private
     */
    static reportAbstractClass(classDeclaration) {
        let formatter = Globalize.messageFormatter('resourcevalidator-abstractclass');
        throw new ValidationException(formatter({
            className: classDeclaration.getFullyQualifiedName(),
        }));
    }

    /**
     * Throw a validation exception for an abstract class
     * @param {string} resourceId - the id of the resouce being validated
     * @param {string} propertyName - the name of the property that is not declared
     * @param {string} fullyQualifiedTypeName - the fully qualified type being validated
     * @throws {ValidationException} the validation exception
     * @private
     */
    static reportUndeclaredField(resourceId, propertyName, fullyQualifiedTypeName ) {
        let formatter = Globalize.messageFormatter('resourcevalidator-undeclaredfield');
        throw new ValidationException(formatter({
            resourceId: resourceId,
            propertyName: propertyName,
            fullyQualifiedTypeName: fullyQualifiedTypeName
        }));
    }

    /**
     * Throw a validation exception for an invalid field assignment
     * @param {string} resourceId - the id of the resouce being validated
     * @param {string} propName - the name of the property that is being assigned
     * @param {*} obj - the Field
     * @param {Field} field - the Field
     * @throws {ValidationException} the validation exception
     * @private
     */
    static reportInvalidFieldAssignment(resourceId, propName, obj, field) {
        let formatter = Globalize.messageFormatter('resourcevalidator-invalidfieldassignment');
        let typeName = field.getFullyQualifiedTypeName();

        if(field.isArray()) {
            typeName += '[]';
        }

        throw new ValidationException(formatter({
            resourceId: resourceId,
            propertyName: propName,
            objectType: obj.getFullyQualifiedType(),
            fieldType: typeName
        }));
    }
}

module.exports = ResourceValidator;