json-schema-mapper
Version:
A json-schema mapper
655 lines (555 loc) • 19 kB
JavaScript
var EventEmitter = require('events').EventEmitter,
util = require('util'),
_ = require('underscore'),
url = require('url'),
URI = require('URIjs'),
Path = require('path');
var errors = {
'notimplemented': function(method, cls){
Error.call(this); //super constructor
Error.captureStackTrace(this, this.constructor); //super helper method to include stack trace in error object
this.name = 'Method not implemented';
this.message = method + ' - must be implemented when extending ' + cls;
},
'import_problem': function(problem){
Error.call(this); //super constructor
Error.captureStackTrace(this, this.constructor); //super helper method to include stack trace in error object
this.name = 'Import Error';
this.message = problem;
},
'tbi': function(func){
Error.call(this); //super constructor
Error.captureStackTrace(this, this.constructor); //super helper method to include stack trace in error object
this.name = 'Function not defined (yet)';
this.message = 'The function ' + func + ' has not been written yet please provide your own';
}
};
Object.keys(errors).forEach(function(fnName){
util.inherits(errors[fnName], Error);
});
var readyStates = {
'unitialised': 0,
'initialised': 1,
'running': 2,
'complete': 3
};
/**
* Very simple log function that outputs to console
*
* @param {string} message Message to push to console
* @param {string} level The type of message
*/
function basicLogger (message, level){
console.log(message);
}
/**
* BaseImporter psuedo class to be extended
*
*/
function BaseImporter () {
this._schema = null;
this._export = null;
this._types = {};
this._model = {
properties:{},
definitions:{},
ids:{}
};
this.error = null;
var baseImporter = this;
//setup listeners
this.on('readyStateChanged', function (readyState) {
//state changed
if(readyStates.complete === readyState){
//state says loading has finished
if(baseImporter.error){
baseImporter.emit('failed', baseImporter.error);
} else {
baseImporter.emit('success', null, baseImporter.getModel());
}
//send general complete notification
baseImporter.emit('complete', baseImporter.error, baseImporter.getModel());
}
});
}
util.inherits(BaseImporter, EventEmitter);
BaseImporter.prototype.keywords = ['title', 'description', 'default', 'multipleOf', 'maximum', 'exclusiveMaximum',
'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', 'pattern', 'items',
'enum', 'type', 'required'];
/**
* Abstract function to be overwritten
*
* @param {object} schema, Description
* @param {function} callback Description
*/
BaseImporter.prototype.importSchema = function (schema, callback) {
throw new errors.notimplemented('importSchema', 'BaseImporter');
};
/**
* Abstract function to be overwritten
*/
BaseImporter.prototype.exportORM = function () {
throw new errors.notimplemented('exportORM', 'BaseImporter');
};
/**
* A function that will retrieve (and parse) a schema
*
* @param {string} uri URI to the schema to be loaded
* @param {function} callback A callback function that will called with the requested schema
*/
BaseImporter.prototype.getExternalSchema = function (uri, callback) {
throw new errors.tbi('getExternalSchema');
};
/**
* Internal function to kick off import of schema
*/
BaseImporter.prototype.doImport = function () {
var baseImporter = this;
baseImporter.setReadyState(readyStates.running);
baseImporter.indexIDs(this._schema, null, null, '', function(){
process.nextTick(function () {
baseImporter.emit('indexing:complete');
});
});
baseImporter.on('indexing:complete', function () {
baseImporter._export = {};
//handle schema references
if(baseImporter._schema.$ref){
baseImporter.resolveRef.call(baseImporter,baseImporter._schema.$ref, function (ref){
if(!ref){
baseImporter.log('failed to resolve :: ' + baseImporter._schema.$ref);
console.log(baseImporter._definitions);
}else{
//deal with a reference
baseImporter._model.properties = _.extend(baseImporter._model.properties, ref);
}
doImportContinue();
});
}else{
doImportContinue();
}
function doImportContinue () {
if(baseImporter._schema.definitions){
//deal with definitions
Object.keys(baseImporter._schema.definitions).forEach(function(stDef){
baseImporter.doImportDefinition.call(baseImporter, stDef, baseImporter._schema.definitions[stDef], baseImporter._model.definitions);
});
}
if(baseImporter._schema.properties){
Object.keys(baseImporter._schema.properties).forEach(function(stProp){
baseImporter.doImportProperty.call(baseImporter, stProp, baseImporter._schema.properties[stProp], baseImporter._model.properties);
});
}
baseImporter.setReadyState(readyStates.complete);
}
});
baseImporter.on('indexing:failed', function (err) {
baseImporter.error = err;
baseImporter.setReadyState(readyStates.complete);
});
};
/**
* Returns the internal model of the schema
*
* @return {object}
*/
BaseImporter.prototype.getModel = function () {
return this._model;
};
/**
* Returns true if ready state is 'complete' and there are no errors
*
* @return boolean
*/
BaseImporter.prototype.isLoaded = function () {
return this.getReadyState() === readyStates.complete && !this.error;
};
/**
* Returns the current readyState
*
* @return int
*/
BaseImporter.prototype.getReadyState = function () {
return this.readyState;
};
/**
* Set the current readyState
*
* @param {string|int} newState Description
* @return int
*/
BaseImporter.prototype.setReadyState = function (newState) {
var currentState = this.readyState;
if('number' === typeof newState){
//assumes an integer would be direct from readyStates
this.readyState = newState;
}else if('string' === typeof newState){
//only set if the string is a known state
this.readyState = readyStates[newState] || this.readyState;
}
if(currentState !== this.readyState){
//readyState has changed notify listeners
this.emit('readyStateChanged', this.readyState);
}
return this.readyState;
};
/**
* Load a schema from a json resource
*
* @param {string|buffer} schemaJSON Description
*/
BaseImporter.prototype.loadSchemaFromJSON = function (schemaJSON) {
try{
schema = JSON.parse(schemaJSON);
if(schema){
this._schema = schema;
this.setReadyState(readyStates.initialised);
this.doImport();
}
} catch (e) {
this.error = e;
this.setReadyState(readyStates.complete);
}
};
/**
* Function to walk the imported schema for ids
* for use in reference resolutions
*
* @param {object} schemaIn The schema or part there of to be indexed
* @param {string} scope Current scope to be used
* @param {string} index Alternative id usually from the key of the property/definition
* @param {function} callback Function to call when all actions complete
*/
BaseImporter.prototype.indexIDs = function (schemaIn, scope, index, path, callback) {
var baseimporter = this;
var currentScope = scope || false;
var keys = Object.keys(schemaIn);
var newScope = false;
path = path || '';
index = index || '';
var listenFor = []; //array for use in recursion
/**
* Factory function for generating next tick
* Always returns a function, though passed an undefined nextSchema the
* returned function will be empty
*
* @param {object} nextSchema Object to index
* @param {string} nextScope Scope to resolve against
* @param {string} nextIndex Index of the property/definiton passed as nextSchema
*
* @return {function} a function to be passed to process.nextTick()
*/
var factoryNext = function(nextSchema, nextScope, nextIndex, nextPath, nextCBack){
var nSchema = nextSchema || false;
var nScope = nextScope;
var nIndex = nextIndex;
var nPath = nextPath;
var nCBack = nextCBack || function(){};
if(false === nSchema){
return function(){};
}else{
return function(){
baseimporter.indexIDs.call(baseimporter, nSchema, nScope, nIndex, nPath, nCBack);
};
}
};
var myCallback = function ( name ) {
if(-1 !== listenFor.indexOf(name)){
listenFor.splice(listenFor.indexOf(name), 1);
}
if(0 === listenFor.length){
//all child processes have reported in
process.nextTick(function () {
callback(index);
});
}
};
if(keys && keys.length){
if(schemaIn.id){
if(!currentScope){
//no parent scope set to this id
newScope = schemaIn.id;
}else{
var iduri = new URI(schemaIn.id);
if(true === iduri.is('relative')){
newScope = iduri.relativeTo(currentScope);
}else if(true === iduri.is('absolute')){
newScope = schemaIn.id;
}
}
}else if('string' === typeof index){
if(!currentScope){
newScope = index;
}else{
var iduri = new URI(index);
newScope = iduri.absoluteTo(currentScope).toString();
}
}
if(false !== newScope){
//store index
baseimporter._model.ids[schemaIn.id||index] = {absolute:newScope};
baseimporter._model.ids[schemaIn.id||index].path = Path.join(path, index);
//continue loop down through
//properties
if(schemaIn.properties){
//run the properties
Object.keys(schemaIn.properties).forEach(function(prop){
//recurrance - defer to nextTick
listenFor.push(prop);
process.nextTick(factoryNext(schemaIn.properties[prop], newScope, prop, Path.join(baseimporter._model.ids[schemaIn.id||index].path, 'properties'), myCallback));
});
}
//definitions
if(schemaIn.definitions){
//run the properties
Object.keys(schemaIn.definitions).forEach(function(def){
//recurrance - defer to nextTick
listenFor.push(def);
process.nextTick(factoryNext(schemaIn.definitions[def], newScope, def, Path.join(baseimporter._model.ids[schemaIn.id||index].path,'definitions'), myCallback));
});
}
}
if(0 === listenFor.length){
//not waiting for anything - fire the callback
callback(index);
}
}
};
/**
* Resolve a $ref in a schema
*
* @param {string} reference The reference to be reolved
* @param {string} scope The resolution scope for the reference
* @return {object|boolean} Returns false if the Ref cannot be resolved
*/
BaseImporter.prototype.resolveRef = function (reference, cback) {
//types of reference
// - Canonical (MUST)
// - inline referencing (OPTIONAL)
var refURI = new URI(reference);
var importer = this;
if(refURI.is('absolute')){
//Canonical
importer.getExternalSchema(refURI.href(), function (err, schema) {
if(err){
return cback(false);
}
return cback(schema.findRef(refURI.fragment()));
});
}else if(refURI.is('relative')){
//relative
if(refURI.host()){
importer.log('Problem resolving relative path with host');
}else{
//it's completely internal
return cback(importer.findRef(refURI.fragment()));
}
}
return false;
};
/**
* Search by fragment
*
* @param {string} frag Fragment to search by
*/
BaseImporter.prototype.findRef = function (frag) {
var searchId = Path.basename(frag);
if(!searchId){
searchId = Path.basename(Path.dirname(frag));
}
if(!searchId){
throw new errors.import_problem('Cannot find searchid in ref from : ' + frag );
}
if(searchId == '.'){
//root
return this._model.properties;
}
if(this._model.ids[searchId]){
//find from precalc'd path
pth = this._model.ids[searchId].path.split('/');
var pntr = this._model;
pth.forEach(function(elname){
if(pntr && pntr[elname]){
pntr = pntr[elname];
} else {
pntr = false;
}
});
return pntr;
}
};
/**
* Function to import a definiton from an element
*
*
* @param String stDef Description
* @param Object src Description
* @param Object parent Description
*/
BaseImporter.prototype.doImportDefinition = function ( stDef, src, parent){
var baseImporter = this;
if('string' !== typeof stDef){
throw new errors.import_problem('Property name type expected "String" but found "' + (typeof stDef) + '"');
}
if('object' !== typeof src){
throw new errors.import_problem('Source element type expected "Object" but found "' + (typeof src) + '"');
}
if('object' !== typeof parent){
throw new errors.import_problem('Parent element type expected "Object" but found "' + (typeof parent) + '"');
}
var def = parent[stDef] = {};
baseImporter.keywords.forEach(function (stKey) {
if('undefined' !== typeof src[stKey]){
def[stKey] = src[stKey];
}
});
if(src.properties){
Object.keys(src.properties).forEach(function(stProp){
baseImporter.doImportProperty.call(baseImporter, stProp, src.properties[stProp], def);
});
}
};
/**
* @private
* Function to import the properties from an element
*
* @param String stProp String of the property key
* @param Object src The object from the schema
* @param Object parent The target
*/
BaseImporter.prototype.doImportProperty = function ( stProp, src, parent){
if('string' !== typeof stProp){
throw new errors.import_problem('Property name type expected "String" but found "' + (typeof stProp) + '"');
}
if('object' !== typeof src){
throw new errors.import_problem('Source element type expected "Object" but found "' + (typeof src) + '"');
}
if('object' !== typeof parent){
throw new errors.import_problem('Parent element type expected "Object" but found "' + (typeof parent) + '"');
}
//setup the export object
var exprop = parent[stProp] = {};
//check the property type
if(src.type){
exprop.type = src.type;
}
var importer = this;
//handle schema references
if(src.$ref){
importer.resolveRef.call(this,src.$ref, function (ref){
if(!ref){
importer.log('failed to resolve :: ' + src.$ref);
console.log(importer._definitions);
}else{
//deal with a reference
exprop = parent[stProp] = _.extend(exprop,ref);
}
doImportPropContinue();
});
}else{
doImportPropContinue();
}
function doImportPropContinue () {
importer.keywords.forEach(function (stKey) {
if('undefined' !== typeof src[stKey]){
exprop[stKey] = src[stKey];
}
});
//handle sub properties
if(src.properties){
Object.keys(src.properties).forEach(function(stSubProp){
importer.doImportProperty.call(importer, stSubProp, src.properties[stSubProp], exprop);
});
}
}
};
BaseImporter.prototype.log = basicLogger;
//import manager store references
//each one is an instance of BaseImporter
//
var importManagerCache = {};
var baseImportManagerDefaults = {
importClass: BaseImporter,
logger: false
};
function BaseImportManager (opts) {
opts = opts || {};
this.options = _.extend(baseImportManagerDefaults, opts);
if(this.options.logger){
this.log = this.options.logger;
}
}
util.inherits(BaseImportManager, EventEmitter);
/**
* Stub function for loading a file, to be provided by the implementor
*
* @param {string} uri Description
* @param {function} callback Callback function to provided contents of the file at
*/
BaseImportManager.prototype.readFile = function (uri, callback) {
callback(new errors.notimplemented('readFile', 'BaseImportManager'), null);
};
/**
* Fetch a schema from the specified uri
*
* @param {string} uri URI to the schema
* @param {function} callback The callback that will be called with schema as 2nd param
*/
BaseImportManager.prototype.fetchSchema = function (uri, callback) {
if(!uri){
return false;
}
if(importManagerCache[uri]){
if(importManagerCache[uri].isLoaded()){
callback(null, importManagerCache[uri]);
return true;
}else if(importManagerCache[uri].getReadyState() < readyStates.complete){
//listen for the end event fired after success or failure to load
importManagerCache[uri].once('complete', function(err, model){
callback(err, importManagerCache[uri]);
});
return true;
}else{
//it's not loaded successfully
//and it's not mid-load
//it must have failed
callback(importManagerCache[uri].error, null);
return true;
}
}
//it wasn't in the cache needs loaded
var importManager = this;
importManager.readFile(uri, function (err, contents) {
if( importManagerCache[uri] ){
//another request for this uri has already taken place while the file was read
return importManager.fetchSchema(uri, callback);
}
importManagerCache[uri] = new importManager.options.importClass();
//swap out the getExternal schema function for the managers fetchSchema
importManagerCache[uri].getExternalSchema = importManager.fetchSchema.bind(importManager); //@todo check this doesn't need bind
//there was an error fetching the file, attach this to the new instance
if(err){
importManagerCache[uri].error = err;
importManagerCache[uri].setReadyState(readyStates.complete);
return callback(err, null);
}
importManagerCache[uri].once('complete', function(err, model){
callback(err, importManagerCache[uri]);
});
importManagerCache[uri].loadSchemaFromJSON(contents);
});
return true;
};
BaseImportManager.prototype.getImporter = function (uri) {
importManagerCache[uri] = new this.options.importClass();
importManagerCache[uri].getExternalSchema = this.fetchSchema.bind(this);
return importManagerCache[uri];
};
//provide a copy of the ready states but not the reference used internally
BaseImportManager.prototype.readyStates = _.clone(readyStates);
BaseImportManager.prototype.log = basicLogger;
module.exports = {
"BaseImporter":BaseImporter,
"BaseImportManager": BaseImportManager,
"errors": errors
};