pimatic
Version:
A home automation server and framework for the Raspberry PI running on node.js
300 lines (255 loc) • 10.6 kB
JavaScript
//
// JaySchema (draft v4) validator for Node.js.
//
'use strict';
var Errors = require('./errors.js')
, crypto = require('crypto')
, SchemaRegistry = require('./schemaRegistry.js')
, when = require('when')
, keys = require('when/keys')
;
var DEFAULT_SCHEMA_VERSION = 'http://json-schema.org/draft-04/schema#';
var ANON_URI_SCHEME = 'anon-schema';
var testRunners = {
'http://json-schema.org/draft-04/schema#': require('./suites/draft-04'),
'http://json-schema.org/schema#': require('./suites/draft-04')
};
// ******************************************************************
// Constructor
// ******************************************************************
var JaySchema = function(loader) {
// public
this.maxRecursion = 5;
this.loader = (typeof loader === 'function') ? loader : null;
// internal
this._schemaRegistry = new SchemaRegistry();
this._customFormatHandlers = {};
// _refsRequested is an object where the key is the normalized ID
// of the schema ref that was requested, and the value is a
// promise for the content of that schema.
this._refsRequested = {};
};
// ******************************************************************
// [static] Pre-defined schema loaders (can be passed to the
// constructor)
// ******************************************************************
JaySchema.loaders = {
http: require('./httpLoader.js')
};
// ******************************************************************
// [static] Export the errors classes so they can be used by schema
// loaders and others.
// ******************************************************************
JaySchema.errors = Errors;
// ******************************************************************
// Get an array of $refs for which we don’t have a schema yet.
// ******************************************************************
JaySchema.prototype.getMissingSchemas = function() {
return this._schemaRegistry.getMissingSchemas();
};
// ******************************************************************
// Return boolean indicating whether the specified schema id has
// previously been registered.
// ******************************************************************
JaySchema.prototype.isRegistered = function(id) {
return this._schemaRegistry.isRegistered(id);
};
// ******************************************************************
// Register a schema.
// ******************************************************************
JaySchema.prototype.register = function() {
return this._schemaRegistry.register.apply(this._schemaRegistry, arguments);
};
// ******************************************************************
// Register a handler for the format keyword. Handlers have the
// following signature:
//
// function handler(value) -> returns null (if value is valid) or
// error description string (if value is
// not valid)
// ******************************************************************
JaySchema.prototype.addFormat = function(attributeName, handler) {
// Wrap the format handler to make its signature match that of the
// internal handlers.
var handlerWrapper = function(config) {
var errors = [];
var result = handler(config.inst, config.schema);
if (typeof result === 'string') {
errors.push(new Errors.FormatValidationError(config.resolutionScope,
config.instanceContext, 'format', attributeName, config.inst, result));
} else if (result !== null) {
var desc = 'failed custom format validation';
errors.push(new Errors.FormatValidationError(config.resolutionScope,
config.instanceContext, 'format', attributeName, config.inst, desc));
}
return errors;
};
this._customFormatHandlers[attributeName] = handlerWrapper;
};
// ******************************************************************
// [static] Return a hash for an object. We rely on JSON.stringify
// to always return the same value for a given object. (If it
// doesn’t return the same value, the parser will be somewhat slower
// and use more memory. It does seem to always return the same
// value based on observation and on Ecma-262 5.1 § 15.12.3.)
// ******************************************************************
JaySchema._getObjectHash = function(obj) {
var shasum = crypto.createHash('sha1');
shasum.update(JSON.stringify(obj));
return shasum.digest('hex');
};
// ******************************************************************
// Helper to call the user-provided schema loader.
// ******************************************************************
JaySchema.prototype._loadMissingRefs = function(depth, callback) {
var err;
// try not to request the same ref more than once
var schemasNeeded = this._schemaRegistry.getMissingSchemas();
// get a list of missing refs that have not been requested yet
var missing = schemasNeeded.filter(function(ref) {
var registered = this._schemaRegistry.isRegistered(ref);
var requested = (SchemaRegistry._normalizeId(ref) in this._refsRequested);
return (!registered && !requested);
}, this);
// are we in too deep?
if (missing.length && !depth) {
var desc = 'would exceed max recursion depth fetching these referenced ' +
'schemas (set the maxRecursion property if you need to go deeper): ' +
missing;
err = new Errors.ValidationError(undefined, undefined, undefined,
undefined, undefined, desc);
return callback(err);
}
// function called when the loader finishes loading a schema
var onLoaded = function(ref, deferred, loaderErr, schema) {
if (loaderErr) { return deferred.reject(loaderErr); }
if (!this._schemaRegistry.isRegistered(ref)) { this.register(schema, ref); }
deferred.resolve(schema);
};
// now request the missing refs that have not been requested yet
for (var index = 0, len = missing.length; index !== len; ++index) {
var ref = missing[index];
var deferred = when.defer();
this._refsRequested[SchemaRegistry._normalizeId(ref)] = deferred.promise;
this.loader(ref, onLoaded.bind(this, ref, deferred));
}
// wait for all of them
var self = this;
keys.all(this._refsRequested).then(
function() {
// all promises fulfilled
if (missing.length) {
// We loaded some schemas. We need to recurse to load
// additional schemas that were referenced by the schemas we
// just loaded.
self._loadMissingRefs(depth - 1, callback);
} else {
// We didn’t load any more schemas ourselves; we were just
// waiting for any loader promises to be fulfilled.
callback();
}
},
callback // one or more promises rejected
);
};
// ******************************************************************
// The main validation guts (internal implementation).
// ******************************************************************
JaySchema.prototype._validateImpl = function(instance, schema, resolutionScope,
instanceContext)
{
// for schemas that have no id, use an internal anonymous id
var schemaId = schema.id || resolutionScope ||
ANON_URI_SCHEME + '://' + JaySchema._getObjectHash(schema) + '/#';
if (!this._schemaRegistry.isRegistered(schemaId)) {
this.register(schema, schemaId, resolutionScope);
}
resolutionScope = resolutionScope || schemaId;
if (resolutionScope.indexOf('#') === -1) { resolutionScope += '#'; }
// no schema passed
if (!schema) { return [];}
// run the tests
var config = {
clone: function() {
var result = {};
var keys = Object.keys(this);
for (var index = 0, len = keys.length; index !== len; ++index) {
result[keys[index]] = this[keys[index]];
}
return result;
},
inst: instance,
schema: schema,
resolutionScope: resolutionScope,
instanceContext: instanceContext || '#',
schemaRegistry: this._schemaRegistry,
customFormatHandlers: this._customFormatHandlers
};
var testRunner = testRunners[schema.$schema || DEFAULT_SCHEMA_VERSION];
if (!testRunner) {
var desc = 'The top-level schema (' + schema.$schema + ') is not ' +
'supported by this validator. Supported schemas are: ' +
Object.keys(testRunners).join(', ');
var err = new Errors.ValidationError(undefined, undefined, undefined,
undefined, undefined, desc);
return [err];
}
return testRunner(config);
};
// ******************************************************************
// The main validation function (public API). Our raison d'être.
// ******************************************************************
JaySchema.prototype.validate = function(instance, schema, callback)
{
var desc, err;
if (typeof schema === 'string') {
schema = this._schemaRegistry.get(schema) || { $ref: schema };
}
if (!schema) {
desc = 'No schema provided for validation.';
err = new Errors.ValidationError(undefined, '#', undefined, undefined,
undefined, desc);
if (callback) {
return process.nextTick(callback.bind(null, [err]));
} else {
return [err];
}
}
// for schemas that have no id, use an internal anonymous id
var schemaId = schema.id || ANON_URI_SCHEME + '://' +
JaySchema._getObjectHash(schema) + '/#';
this.register(schema, schemaId);
if (callback) {
var self = this;
var result;
if (self.loader) {
// If the user provided a loader callback, load all unresolved
// $references schemas at this time.
self._loadMissingRefs(self.maxRecursion, function(err) {
if (err) { return callback([err]); }
result = self._validateImpl(instance, schema);
if (result.length) { process.nextTick(callback.bind(null, result)); }
else { process.nextTick(callback); }
});
} else {
// no loader, but user still wants a callback
result = this._validateImpl(instance, schema);
if (result.length) { process.nextTick(callback.bind(null, result)); }
else { process.nextTick(callback); }
}
} else {
// traditional, non-callback validation
var errs = [];
if (this.loader) {
desc = 'You provided a loader callback, but you are calling ' +
'validate() synchronously. Your loader will be ignored and ' +
'validation will fail if any missing $refs are encountered.';
err = new Errors.LoaderAsyncError(undefined, undefined, undefined,
undefined, undefined, desc);
errs.push(err);
}
errs = errs.concat(this._validateImpl(instance, schema));
return errs;
}
};
module.exports = JaySchema;