RackHD/on-core

View on GitHub
lib/common/json-schema-validator.js

Summary

Maintainability
D
2 days
Test Coverage
// Copyright 2016, EMC, Inc.

'use strict';

module.exports = jsonSchemaValidatorFactory;

jsonSchemaValidatorFactory.$provide = 'JsonSchemaValidator';
jsonSchemaValidatorFactory.$inject = [
    'Ajv',
    'Assert',
    'fs',
    'path',
    'Promise',
    'url',
    '_'
];

function jsonSchemaValidatorFactory(
    Ajv,
    assert,
    nodeFs,
    path,
    Promise,
    url,
    _
) {
    var fs = Promise.promisifyAll(nodeFs);

    function JsonSchemaValidator(options) {
        this.options = options || {};
        this.nameSpace = options.nameSpace || '';
        this._ajv = new Ajv(this.options);
        this.addMetaSchema = this._ajv.addMetaSchema;
        this.removeSchema = this._ajv.removeSchema;
    }

    /**
     * validate JSON data with given JSON schema
     * @param  {Object|String} schema  JSON schema Object or schema ref id
     * @param  {Object} data  JSON data to be validated
     * @return {Boolean} validation pass or throw error
     */
    JsonSchemaValidator.prototype.validate = function (schema, data) {
        if (this._ajv.validate(schema, data)) {
            return true;
        } else {
            var err = new Error('JSON schema validation failed - ' + this._ajv.errorsText());
            err.errorList = this._ajv.errors;
            throw err;
        }
    };

    /**
     * validate JSON data with given JSON schema and ignore some failure if the error matches some
     * patterns.
     *
     * NOTE: this function depends on the option 'allErrors` and 'verbose' is enabled
     *
     * @param  {Object|String} schema - JSON schema Object or schema ref id
     * @param  {Object} data - JSON data to be validated
     * @param  {Regex|Array<Regex>} skipPatterns - The skip patterns, if any skip pattern is
     * matched, that validation error will be ignored.
     * @return {Boolean} true if validation passes, otherwise throw error
     */
    JsonSchemaValidator.prototype.validatePatternsSkipped = function (schema, data, skipPatterns) {
        assert.equal(this.options.allErrors, true,
                  "option 'allErrors' need be enabled for validatePatternSkipped");
        assert.equal(this.options.verbose, true,
                 "option 'verbose' need be enabled for validatePatternSkipped");

        if (this._ajv.validate(schema, data)) {
            return true;
        }

        var errors = this._ajv.errors;

        if (skipPatterns) {
            if (!_.isArray(skipPatterns)) {
                skipPatterns = [skipPatterns];
            }
            assert.arrayOfRegexp(skipPatterns, 'skip pattern should be regexp');

            //If any skip patter is matched, then that error will be skipped.
            errors = _.filter(errors, function(error) {
                return !_.some(skipPatterns, function(pattern) {
                    var result = pattern.test(error.data + '');
                    return result;
                });
            });
        }

        if (_.isEmpty(errors)) {
            return true;
        } else {
            var err = new Error('JSON schema validation failed - ' + this._ajv.errorsText(errors));
            err.errorList = errors;
            throw err;
        }
    };

    /**
     * Add schema to the instance.
     * @param {Object} schema
     * @param {String} [name] - Optional schema name when schema has an id
     */
    JsonSchemaValidator.prototype.addSchema = function (schema, name) {
        assert.equal(_.isEmpty(name) && _.isEmpty(schema.id), false,
                '`name` should not be empty, or `schema` should has an `id` property');

        schema.id = url.resolve(this.nameSpace, name || schema.id);
        return this._ajv.addSchema(schema);
    };

    /**
     * Add array of schemas to the instance.
     * @param {Array<Object>} schemas - array of schema
     */
    JsonSchemaValidator.prototype.addSchemas = function (schemas) {
        assert.arrayOfObject(schemas,
                '`schemas` should be an array of JSON schema');
        var self = this;
        _.forEach(schemas, function (schema) {
            self.addSchema(schema);
        });
    };

    /**
     * Add all schema in the folder to the instance.
     * @param {String} schemaDir - the directory where schema files in
     * @param {String} [metaSchemaName] - Optional the meta schema name
     * @return {Promise}
     */
    JsonSchemaValidator.prototype.addSchemasByDir = function (schemaDir, metaSchemaName) {
        var self = this;
        return Promise.try(function () {
            assert.string(schemaDir, 'schemaDir');
            if (metaSchemaName) {
                assert.string(metaSchemaName, 'metaSchemaName');
            }
        })
        .then(function () {
            return fs.readdirAsync(schemaDir);
        })
        .filter(function (fileName) {
            return /\.json$/i.test(fileName);
        })
        .reduce(function (entries, fileName) {
            return fs.readFileAsync(path.resolve(schemaDir, fileName))
            .then(function (fileData) {
                var content = JSON.parse(fileData);
                content.id = fileName;
                entries[fileName] = content;
                return entries;
            });
        }, {})
        .then(function (entries) {
            //meta schema should be added first, so subsequent schemas can be validated againist it
            if (metaSchemaName) {
                var metaSchema = entries[metaSchemaName];
                if (!metaSchema) {
                    throw new Error('Cannot find the meta schema "' + metaSchemaName +
                        '" in directory "' + schemaDir + '"');
                }
                self.addMetaSchema(metaSchema);
                delete entries[metaSchemaName];
            }
            return entries;
        })
        .then(function (entries) {
            self.addSchemas(_.values(entries));
        });
    };

    /**
     * Get all schema names from the instance.
     * @param {Object} [option] - namespace will not be included by default,
     *      set { includeNameSpace: true } when need to include namespace.
     * @return {Array<String>} schemaNames
     */
    JsonSchemaValidator.prototype.getAllSchemaNames = function (option) {
        var self = this;
        return _(self._ajv._schemas).keys()
        .filter(function (name) {
            // filter out the default json-schema.org metaSchema
            return  !/^http:\/\/json-schema.org/.test(name);
        })
        .map(function (name) {
            return option && option.includeNameSpace ? name : path.basename(name);
        })
        .value();
    };

    /**
     * Get schema from the instance by name.
     * @param  {String} name schema name or id
     * @return {Object} schema
     */
    JsonSchemaValidator.prototype.getSchema = function (name) {
        var key = url.resolve(this.nameSpace, name);
        var schemaCompiled = this._ajv.getSchema(key);
        if (schemaCompiled) {
            return schemaCompiled.schema;
        }
    };

    /**
     * Get reference resolved schema by name.
     * @param  {String} name schema name or id
     * @return {Object} schema with reference resolved
     */
    JsonSchemaValidator.prototype.getSchemaResolved = function (name) {
        var key = url.resolve(this.nameSpace, name);
        var schemaCompiled = this._ajv.getSchema(key);
        if (undefined === schemaCompiled) {
            return;
        }

        if (_.isEmpty(schemaCompiled.refs)) {
            return schemaCompiled.schema;
        }

        var resolvedValues = {};
        resolveRef(schemaCompiled, resolvedValues);

        var schemaResolved = _.cloneDeep(schemaCompiled.schema);
        // replaced is a map will be set refId as when there is any $ref object 
        // been replaced. repeat replaceRefObj when replace real happened in last call,
        // until all $ref object been replaced. Each replaceRefObj will cover the $ref
        // in the same nest level (depth)
        var replaced = {};
        replaceRefObj(schemaResolved, resolvedValues, schemaResolved.id, replaced);

        while (_.size(replaced) > 0) {
            var replacedLastLoop = replaced;
            replaced = {};
            _.forOwn(replacedLastLoop, function (refValue, refId) { //jshint ignore: line
                replaceRefObj(refValue, resolvedValues, refId, replaced);
            });
        }

        return schemaResolved;

        // resolve reference recursively
        // it search the compiled schema data structure from ajv.getSchema, find out
        // and store referenced value to resolvedValues map
        function resolveRef(schemaObj, resolvedValues) {
            // the array store referenced value
            var refVal = schemaObj.refVal;
            // the map store full ref id and index of the referenced value in refVal
            // example: { 'test#definitions/option' : 1, 'test#definitions/repo' : 2 }
            var refs = schemaObj.refs;
            // the map to store schema value with sub reference
            var subRefs = {};

            _.forEach(refs, function (index, refId) {
                // if reference id already resolved then continue the loop
                if (refId in resolvedValues) {
                    return true; // continue
                }

                var refValue = refVal[index];
                // if no further nested reference, add to resolved map
                if (_.isEmpty(refValue.refs)) {
                    resolvedValues[refId] = refValue;
                    return true;
                }

                // add schema value with sub reference to map to resolve later
                subRefs[refId] = refValue;
            });

            // resolve sub reference recursively
            _.forEach(subRefs, function (subRef, refId) {
                resolvedValues[refId] = 1;
                resolvedValues[refId] = resolveRef(subRef, resolvedValues);
            });

            return schemaObj.schema;
        }

        // replace reference in schema recursively
        // it search the schema object and replace the $ref object with the real value
        // example: { $ref: '#/definitions/test'} -> { type: 'string', format: 'uri'}
        function replaceRefObj (obj, resolvedValues, baseId, replaced) {
            // if found $ref key, other properties of the obj will be ignored
            if (obj.$ref) {
                var refId = url.resolve(baseId, obj.$ref);
                if (refId in resolvedValues) {
                    replaced[refId] = resolvedValues[refId];
                    return resolvedValues[refId];
                }
            }

            _.forOwn(obj, function(val, k) {
                if (!(val instanceof Object)) {
                    return true; //continue
                }

                var resolvedObj = replaceRefObj(val, resolvedValues, baseId, replaced);
                if (resolvedObj) {
                    obj[k] = resolvedObj;
                }
            });
        }
    };

    /**
     * Reset JSON-Schema validator
     * After reset, all added schemas and meta-schemas will be deleted.
     */
    JsonSchemaValidator.prototype.reset = function () {
        this._ajv = new Ajv(this.options);
    };

    return JsonSchemaValidator;
}