Repository: washingtonpost/ans-schema
Pull request #69 (2016-07-08 17:11:53)

ANS Validator Prototype (CA-49)
- Adds whole ANS Validator (moved from ANS Service) that traverses a document and validates each elements against its self-professed "type"
- Adds command line option for validation
- Uses validator in transform tests
- Minor fixes for tests, upverters (revealed by whole validator)



Patch:
diff --git a/README.md b/README.md
index 66c7250..5389fbd 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,34 @@ A few examples are provided of content documents that validate against the Washi
 2. [An Example Video](tests/fixtures/schema/0.5.6/video-fixture-nationals.json)
 3. [An Example Image](tests/fixtures/schema/0.5.6/image-fixture-good.json)
 
+## Validating Locally ##
+
+This project contains a node library that can be used to validate ANS documents locally.  It is the same validator used in the ANS Service: https://ans.arc.nile.works/validate/0.5.6
+
+You can run the validator on the command line from the project directory to validate an ANS document:
+```
+npm install
+npm run-script ans -- --ansdata='{"type":"story", "version":"0.5.6"}' --version=0.5.6 validate
+npm run-script ans -- --ansfile=test.json --version=0.5.6 validate
+```
+
+You can also load the validator as a library in your node project:
+
+```
+var ans = require('ans-schema');
+
+ans.getValidatorForVersion('0.5.6', function(err, validator) {
+  var errors = validator.validate({"type":"story", "version":"0.5.6"});
+
+  if (errors.length > 0) {
+    console.log("There were some errors: " + JSON.stringify(errors));
+  }
+  else {
+    console.log("It's valid!");
+  }
+});
+```
+
 ## Submitting a Proposal ##
 
 Changes to the ANS Schema should first be accepted by the ANS committee at The Washington Post.  To submit a proposal for a requested change to the schema, please [create an issue](https://github.com/washingtonpost/ans-schema/issues/new) in this repo's issue tracker.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index aaa4c53..8c920fe 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,12 @@
 # ANS Release Notes
 
+### 1.1.0 ###
+
+* Adds AnsValidator
+* Improves transformer testing
+* Fixes minor issues with upverters, tests
+* Removes unused fixtures
+
 ### 1.0.2 ###
 
 * Adds gallery operation
diff --git a/lib/ans.js b/lib/ans.js
new file mode 100644
index 0000000..f6b517f
--- /dev/null
+++ b/lib/ans.js
@@ -0,0 +1,25 @@
+'use strict';
+
+var schemas = require('./schemas'),
+    version = require('./version'),
+    transforms = require('./transforms'),
+    validator = require('./validator');
+
+
+module.exports = {
+  'version': version,
+
+  'getSchemas': schemas.getSchemas,
+  'getSchemaForVersion': schemas.loadSchema,
+  'schemas': schemas.allSchemas,
+  'schemasByVersion': schemas.schemasByVersion,
+
+  'getTransforms': function(cb) {
+    cb(null, transforms);
+  },
+  'transforms': transforms,
+
+  'AnsValidator': validator.AnsValidator,
+  'getValidatorForVersion': validator.getValidatorForVersion
+
+};
diff --git a/lib/cli.js b/lib/cli.js
index b46dfbf..3eeb8c2 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -3,7 +3,8 @@
 var options_parser = require('node-options');
 var ans = require('./schemas');
 var version = require('./version');
-var utils = require('./util')
+var validator = require('./validator');
+
 var _ = require('lodash');
 var async = require('async');
 var dir = require('node-dir');
@@ -14,13 +15,16 @@ var path = require('path');
 var rimraf = require('rimraf');
 var dir = require('node-dir');
 
+
 var BASE_DIR_SCHEMA = '../src/main/resources/schema/ans/';
 var BASE_DIR_TESTS = '../tests/fixtures/';
 var BASE_DIR_UPVERTERS = '../lib/upvert/';
 
 // Set default options
 var options = {
-  'version': null
+  'version': null,
+  'ansdata': null,
+  'ansfile': null
 }
 
 //console.log(options);
@@ -221,6 +225,50 @@ var commands = {
         }
       }
     );
+  },
+
+
+  'validate': function validate(opts, args) {
+    var current_version = opts.version === null ? version.version : opts.version;
+    console.log("ANS version: " + current_version);
+
+    if (current_version === null) {
+      console.error("ERROR: Could not determine ANS version to validate.");
+      process.exit(-1);
+    }
+
+    if (_.isNil(opts.ansfile) && _.isNil(opts.ansdata)) {
+      console.error("ERROR: Either --ansfile or --ansdata is required for the validate command.");
+      process.exit(-1);
+    }
+
+    var validateData = function(version, data) {
+      validator.getValidatorForVersion(version, function(err, ansValidator) {
+        if (err) {
+          console.error(err);
+          process.exit(-1);
+        }
+        var errors = ansValidator.getAllContentErrors(data, version, "");
+        console.log(JSON.stringify(errors, null, 2));
+        process.exit(0);
+      });
+    };
+
+    if (!_.isNil(opts.ansfile)) {
+      fs.readFile(opts.ansfile, function(err, data) {
+        if (err) {
+          console.error(err);
+          process.exit(-1);
+        }
+        else {
+          validateData(current_version, JSON.parse(data));
+        }
+      });
+    }
+    else {
+      validateData(current_version, JSON.parse(opts.ansdata));
+    }
+
   }
 };
 
@@ -231,7 +279,7 @@ var result = options_parser.parse(process.argv.slice(2), options);
 var args = result.args;
 
 if (result.errors || !_.includes(_.keys(commands), result.args[0])) {
-  console.error("USAGE: ans [--version=0.x.x] <command>\n\nwhere <command> is one of:\n   " +
+console.error("USAGE: ans [--version=0.x.x] [--ansfile=filename.json] [--ansdata=\'{..}\'] <command>\n\nwhere <command> is one of:\n   " +
                 _.reduce(_.keys(commands), function(x, str) { return x + ", " + str; }));
   process.exit(-1);
 }
diff --git a/lib/schemas.js b/lib/schemas.js
index 3e17ff4..b6b17f4 100644
--- a/lib/schemas.js
+++ b/lib/schemas.js
@@ -10,64 +10,113 @@ var baseDir = path.join(path.dirname(module.filename), '../src/main/resources/sc
 
 var callback = undefined;
 var loaded = false;
-var version = require('./version');
-var transforms = require('./transforms');
 
 
-// display contents of files in this script's directory
-dir.readFiles(
-  baseDir, {
-    match:  /.json$/
-  },
-  function(err, content, filename, next) {
-    if (err) throw err;
-    var name = path.relative(baseDir, filename);
-    try {
-      var content = JSON.parse(content);
-      schemas[name] = content;
-      var version = name.substring(0, name.indexOf('/'));
-      _.set(schemasByVersion,
-            '["' + version + '"].' + name.substring(name.indexOf('/') + 1),
-            content);
-    }
-    catch (err) {
-      console.log("Could not load " + name);
-    }
-    next();
-  },
-  function(err, files) {
-    if (err) {
-      if (typeof callback == 'function') {
-        callback(err);
+// Load schema files for a given version into memory
+var loadSchema = function loadSchema(version, done) {
+  if (_.isNil(version) || _.isEmpty(version)) {
+    done("version is required parameter");
+    return;
+  }
+
+  if (_.isObject(schemasByVersion[version])) {
+    done(null, schemasByVersion[version]);
+  }
+
+  var schemaDir = baseDir + '/' + version;
+
+  schemasByVersion[version] = {};
+
+  dir.readFiles(
+    schemaDir, {
+      match:  /.json$/
+    },
+    function(err, content, filename, next) {
+      if (err) throw err;
+
+      var name = path.relative(schemaDir, filename);
+      try {
+        var content = JSON.parse(content);
+        schemasByVersion[version][name] = content;
+      }
+      catch (err) {
+        console.log("Could not load " + name);
+      }
+      next();
+    },
+    function(err, files) {
+      if (err) {
+        if (typeof done == 'function') {
+          done(err);
+        }
+      }
+      loaded = true;
+      if (typeof done == 'function') {
+        done(null, schemasByVersion[version]);
       }
     }
-    loaded = true;
-    if (typeof callback == 'function') {
-      callback(null, schemas);
+  );
+
+};
+
+
+// Load all schemas at once. Left in for backwards compatibility.
+var loadAllSchemas = function loadAllSchemas(done) {
+  dir.readFiles(
+    baseDir, {
+      match:  /.json$/
+    },
+    function(err, content, filename, next) {
+      if (err) throw err;
+      var name = path.relative(baseDir, filename);
+      try {
+        var content = JSON.parse(content);
+        schemas[name] = content;
+        var version = name.substring(0, name.indexOf('/'));
+        /*_.set(schemasByVersion,
+              '["' + version + '"].' + name.substring(name.indexOf('/') + 1),
+              content);*/
+      }
+      catch (err) {
+        console.log("Could not load " + name);
+      }
+      next();
+    },
+    function(err, files) {
+      if (err) {
+        if (typeof done == 'function') {
+          done(err);
+        }
+      }
+      loaded = true;
+      if (typeof done == 'function') {
+        done(null, schemas);
+      }
     }
-  }
-);
+  );
+};
+
+
 
 
 module.exports = {
-  'version': version,
+
+  'loadSchema': loadSchema,
+
+  // For backwards compatibility
   'getSchemas': function(cb) {
     if (loaded) {
       cb(null, schemas);
     } else {
-      callback = cb;
+      loadAllSchemas(cb);
     }
   },
 
-  'getTransforms': function(cb) {
-    cb(null, transforms);
-  },
 
   // Direct accessors - won't work until schemas loaded,
   // so provided only for convenience
 
-  'schemas': schemas,
+  'allSchemas': schemas,
   'schemasByVersion': schemasByVersion,
-  'transforms': transforms
 
 };
diff --git a/lib/upvert/0.5.4.js b/lib/upvert/0.5.4.js
index 74d8db9..0c0607c 100644
--- a/lib/upvert/0.5.4.js
+++ b/lib/upvert/0.5.4.js
@@ -12,6 +12,10 @@ var upvert = function(input) {
   var output = version_incrementer("0.5.5")(input);
 
   var convert = function convert(ans) {
+    if (!_.isObject(ans)) {
+      return ans;
+    }
+
     var result = _.transform(ans, function(result, value, key, object) {
       if ( key === "additional_properties") {
         result[key] = value;
diff --git a/lib/upvert/0.5.5.js b/lib/upvert/0.5.5.js
index 256a1a3..43b55af 100644
--- a/lib/upvert/0.5.5.js
+++ b/lib/upvert/0.5.5.js
@@ -19,7 +19,10 @@ var upvert = function(ans) {
     }
 
     return _.transform(ans, function(result, value, key, object) {
-      if ( key === "sites" ) {
+      if ( key === "additional_properties") {
+        result[key] = value;
+      }
+      else if ( key === "sites" ) {
         // Update all sites.  Add 'primary' attribute.
         if (value.length && value.length > 0) {
           var sites = value.map(function (site) {
@@ -42,10 +45,8 @@ var upvert = function(ans) {
         // Update owner.  Add 'sponsored' attribute.
         value.sponsored = false;
         result[key] = value;
-      } else if (_.isObject(ans)) {
-        result[key] = convert(value);
       } else {
-        result[key] = value;
+        result[key] = convert(value);
       }
 
     }, accumulatorFor(ans));
diff --git a/lib/validator.js b/lib/validator.js
new file mode 100644
index 0000000..dbf8f5e
--- /dev/null
+++ b/lib/validator.js
@@ -0,0 +1,196 @@
+'use strict';
+var _ = require('lodash');
+var schemas = require('./schemas');
+var version = require('./version');
+var Ajv = require('ajv');
+
+var STORY_ELEMENTS = '/story_elements';
+var UTILS = '/utils';
+var TRAITS = '/traits';
+
+// Top-level content types
+var TYPES = [ 'story', 'image', 'video', 'gallery', 'results', 'content' ];
+
+var VERSIONS = _.map(
+  _.filter(version.history, function (item) {
+    return item.name.indexOf("0.5") > -1;
+  }),
+  function(item) {
+    return item.name;
+  }
+);
+
+var validatorsByVersion = {};
+
+var AnsValidator = function(_ajv, _schemas) {
+  this.ajv = _ajv;
+  this.schemas = {};
+  var _this = this;
+
+
+  _.forEach(VERSIONS, function(version) {
+    _this.schemas[version] = {};
+    _.forEach(TYPES, function(type) {
+      if (_.has(_schemas, version + '/' + type + ".json")) {
+        var content = _schemas[version + '/' + type + ".json"];
+        _this.schemas[version][type] = content;
+        _this.ajv.addSchema(content);
+      }
+    });
+  });
+
+  var getSubNames = function getElementNames(version, subdir) {
+    return _.map(
+      _.filter(
+        _.keys(_schemas),
+        function(item) {
+          return item.indexOf(version + subdir) === 0;
+        }),
+      function(item) {
+
+        var name = item.substring(version.length + subdir.length + 1);
+        return name.substring(0, name.indexOf("."));
+      });
+  };
+
+  _.forEach(VERSIONS, function(version) {
+    var element_names = getSubNames(version, STORY_ELEMENTS);
+    _.forEach(element_names, function(name) {
+
+      var content = _schemas[version + STORY_ELEMENTS + '/' + name + '.json'];
+      _this.schemas[version][name] = content;
+      _this.ajv.addSchema(content);
+    });
+
+    var util_names = getSubNames(version, UTILS);
+    _.forEach(util_names, function(name) {
+
+      var content = _schemas[version + UTILS + '/' + name + '.json'];
+      _this.schemas[version][name] = content;
+      _this.ajv.addSchema(content);
+
+    });
+
+    var trait_names = getSubNames(version, TRAITS);
+    _.forEach(trait_names, function(name) {
+      var content = _schemas[version + TRAITS + '/' + name + '.json'];
+      _this.schemas[version][name] = content;
+      _this.ajv.addSchema(content);
+    });
+
+
+  });
+
+};
+
+// Helper to force Ajv's validation syntax
+// into a function that consistently returns an array.
+AnsValidator.prototype.getValidationErrors = function getValidationErrors(schema, object) {
+  //console.log(this.ajv);
+  if (this.ajv.validate(schema, object)) {
+    return [];
+  } else {
+    return this.ajv.errors;
+  }
+};
+
+
+AnsValidator.prototype.findSchema = function findSchema(version, name) {
+  if (_.has(this.schemas, version)) {
+    if (_.has(this.schemas[version], name)) {
+      return this.schemas[version][name];
+    }
+  }
+  return null;
+};
+
+AnsValidator.prototype.validate = function validate(ans) {
+  return this.getAllContentErrors(ans, ans.version, "");
+};
+
+// Recursively validate an object and all relevant children
+// against their respective element schemas.
+AnsValidator.prototype.getAllContentErrors = function getAllContentErrors(ans, version, namespace) {
+  var errors = [];
+  var _this = this;
+
+  // Validate item against its own self-declared schema
+  var schema = this.findSchema(version, ans.type);
+  if (schema === null) {
+    errors = _.union(errors, [{ "dataPath": namespace + ".type",
+                                "params": ans.type,
+                                "message": "Could not find schema for specified type" }]);
+  }
+  else {
+    errors = _.union(errors, _.map(
+      this.getValidationErrors(schema, ans), function(error) {
+        return _.mapValues(error, function(value, key) {
+          if (key === "dataPath") {
+            value = "" + value;
+            return namespace + value;
+          }
+          else {
+            return value;
+          }
+        });
+      }));
+  }
+
+  // Recurse on items in content_elements
+  if (_.has(ans, 'content_elements')) {
+    errors = _.reduce(ans.content_elements, function(errors, element, i) {
+      return _.union(
+        errors, _this.getAllContentErrors(
+          element, version, namespace + ".content_elements[" + i + "]"));
+    }, errors);
+  }
+
+  // Ditto for promo_items
+  if (_.has(ans, 'promo_items')) {
+    errors = _.union(errors, _.reduce(
+      ans.promo_items, function(errors, element, key) {
+        return _.union(
+          errors, _this.getAllContentErrors(
+            element, version, namespace + ".promo_items." + key));
+      }, errors));;
+  }
+
+  // Ditto for related_content.*
+  if (_.has(ans, 'related_content')) {
+    errors = _.union(errors, _.reduce(
+      ans.related_content, function(errors, content_list, name) {
+        return _.union(errors, _.reduce(content_list, function(errors, item, i) {
+          return _.union(errors, _this.getAllContentErrors(
+            item, version, namespace + ".related_content." + name + "[" + i + "]"));
+        }, errors));
+      }, errors));
+  }
+
+  return errors;
+};
+
+
+module.exports = {
+  'AnsValidator': AnsValidator,
+  'getValidatorForVersion': function(version, callback) {
+    if (_.isObject(validatorsByVersion[version])) {
+      callback(null, validatorsByVersion[version]);
+    }
+    else {
+      var ajv = new Ajv({ allErrors: true });
+      schemas.loadSchema(version, function(err, schema) {
+        if (err) {
+          callback(err);
+          return;
+        }
+
+        var renamed_schema = _.mapKeys(schema, function(value, key) {
+          return version + '/' + key;
+        });
+        var validator = new AnsValidator(ajv, renamed_schema);
+        validatorsByVersion[version] = validator;
+        callback(null, validator);
+      });
+    }
+  }
+};
diff --git a/package.json b/package.json
index 96a3f98..1b8d6de 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "ans-schema",
   "description": "The Washington Post's Arc Native Specification",
-  "version": "1.0.2",
+  "version": "1.1.0",
   "homepage": "https://github.com/washingtonpost/ans-schema",
   "repository": {
     "type": "git",
@@ -11,23 +11,22 @@
   "dependencies": {
     "ajv": "~3.4.0",
     "node-dir": "~0.1.11",
-    "should": "~8.2.1",
     "lodash": "~4.0.1",
-    "async": "~1.5.2",
     "node-options": "^0.0.7",
     "ncp": "2.0.0",
     "rimraf": "2.5.2",
     "async": "2.0.0-rc.6",
     "replacestream": "4.0.0",
 
+    "should": "~8.2.1",
     "mocha": "2.4.5"
   },
   "engines": {
     "node": ">=5.5.0"
   },
-  "main": "./lib/schemas.js",
+  "main": "./lib/ans.js",
   "scripts": {
-    "test": "mocha tests/schema-tests-04.js tests/schema-tests-05.js tests/transform-tests",
+    "test": "mocha tests/schema-tests-04.js tests/schema-tests-05.js tests/transform-tests tests/validator",
     "ans": "node lib/cli.js"
   },
   "bin": {


Test patch:
diff --git a/tests/fixtures/transforms/0.5.0/story-fixture-misspelled-additional-properties.json b/tests/fixtures/transforms/0.5.0/story-fixture-misspelled-additional-properties.json
index 7de091b..78a32cf 100644
--- a/tests/fixtures/transforms/0.5.0/story-fixture-misspelled-additional-properties.json
+++ b/tests/fixtures/transforms/0.5.0/story-fixture-misspelled-additional-properties.json
@@ -23,10 +23,7 @@
     },
     {
       "type": "image",
-      "version": "0.5.0",
-      "additional_properties": {
-        "was": "correctly spelled"
-      }
+      "version": "0.5.0"
     },
     {
       "type": "story",
diff --git a/tests/fixtures/transforms/0.5.1/story-fixture-misspelled-additional-properties.json b/tests/fixtures/transforms/0.5.1/story-fixture-misspelled-additional-properties.json
deleted file mode 100644
index e0aaa5d..0000000
--- a/tests/fixtures/transforms/0.5.1/story-fixture-misspelled-additional-properties.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.1",
-  "content_elements": [
-    {
-      "type": "text",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.1"
-      }
-    },{
-      "type": "header",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.1"
-      }
-    },
-    {
-      "type": "image",
-      "version": "0.5.1",
-      "additional_properties": {
-        "was": "correctly spelled"
-      }
-    },
-    {
-      "type": "story",
-      "version": "0.5.1",
-      "content_elements": [{
-        "type": "text",
-        "content": "text",
-        "additonal_properties": {
-          "was": "misspelled"
-        }
-      }]
-    }
-  ],
-  "additional_properties": {
-    "is": "correctly spelled",
-    "additonal_properties": {
-      "is": "permanently misspelled"
-    }
-  },
-  "taxonomy": {
-    "seo_keywords": [ "one", "two" ]
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.2/story-fixture-misspelled-additional-properties.json b/tests/fixtures/transforms/0.5.2/story-fixture-misspelled-additional-properties.json
deleted file mode 100644
index 9e4004c..0000000
--- a/tests/fixtures/transforms/0.5.2/story-fixture-misspelled-additional-properties.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.2",
-  "content_elements": [
-    {
-      "type": "text",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.2"
-      }
-    },{
-      "type": "header",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.2"
-      }
-    },
-    {
-      "type": "image",
-      "version": "0.5.2",
-      "additional_properties": {
-        "was": "correctly spelled"
-      }
-    },
-    {
-      "type": "story",
-      "version": "0.5.2",
-      "content_elements": [{
-        "type": "text",
-        "content": "text",
-        "additonal_properties": {
-          "was": "misspelled"
-        }
-      }]
-    }
-  ],
-  "additional_properties": {
-    "is": "correctly spelled",
-    "additonal_properties": {
-      "is": "permanently misspelled"
-    }
-  },
-  "taxonomy": {
-    "seo_keywords": [ "one", "two" ]
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.3/story-fixture-misspelled-additional-properties.json b/tests/fixtures/transforms/0.5.3/story-fixture-misspelled-additional-properties.json
deleted file mode 100644
index 80a5e4c..0000000
--- a/tests/fixtures/transforms/0.5.3/story-fixture-misspelled-additional-properties.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.3",
-  "content_elements": [
-    {
-      "type": "text",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.3"
-      }
-    },{
-      "type": "header",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.3"
-      }
-    },
-    {
-      "type": "image",
-      "version": "0.5.3",
-      "additional_properties": {
-        "was": "correctly spelled"
-      }
-    },
-    {
-      "type": "story",
-      "version": "0.5.3",
-      "content_elements": [{
-        "type": "text",
-        "content": "text",
-        "additonal_properties": {
-          "was": "misspelled"
-        }
-      }]
-    }
-  ],
-  "additional_properties": {
-    "is": "correctly spelled",
-    "additonal_properties": {
-      "is": "permanently misspelled"
-    }
-  },
-  "taxonomy": {
-    "seo_keywords": [ "one", "two" ]
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.3/story-fixture-promo-items-empty.json b/tests/fixtures/transforms/0.5.3/story-fixture-promo-items-empty.json
deleted file mode 100644
index 14e85eb..0000000
--- a/tests/fixtures/transforms/0.5.3/story-fixture-promo-items-empty.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.3",
-  "promo_items": {
-
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.3/story-fixture-promo-items-no-basic.json b/tests/fixtures/transforms/0.5.3/story-fixture-promo-items-no-basic.json
deleted file mode 100644
index 0452fc6..0000000
--- a/tests/fixtures/transforms/0.5.3/story-fixture-promo-items-no-basic.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.3",
-  "promo_items": {
-    "secondary": {
-      "type": "image",
-      "version": "0.5.3",
-      "url": "http://www.google.com/logo.png"
-    }
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.3/story-fixture-taxonomy-bugs.json b/tests/fixtures/transforms/0.5.3/story-fixture-taxonomy-bugs.json
deleted file mode 100644
index a62ee49..0000000
--- a/tests/fixtures/transforms/0.5.3/story-fixture-taxonomy-bugs.json
+++ /dev/null
@@ -1,68 +0,0 @@
-{
-  "_id": "unique ANS id",
-  "type": "story",
-  "version": "0.5.3",
-  "created_date": "2015-06-24T09:50:50.52Z",
-  "last_updated_date": "2015-06-24T09:50:50.52Z",
-  "copyright": "(c) 2015 The Washington Post, Inc.",
-  "canonical_url": "http://www.washingtonpost.com/local/anesthesiologist-trashes-sedated-patient-jury-orders-her-to-pay-500000/2015/06/23/cae05c00-18f3-11e5-ab92-c75ae6ab94b5_story.html",
-  "short_url": "http://wapo.st/1Crp6bY",
-  "headlines": {
-    "basic": "The default headline for this story"
-  },
-  "description": {
-    "basic": "A Vienna man went in for a colonoscopy and intended to record his doctor"
-  },
-  "taxonomy": {
-    "keywords": [
-      {
-        "keyword": "Anesthesiologist",
-        "score": 0.77,
-        "frequency": 2
-      }
-    ],
-    "tags": [
-      "alpha", "beta", "gamma"
-    ],
-    "sections": [
-      {
-        "name": "Business",
-        "path": "/business",
-        "other": "data",
-        "foo": "bar"
-      },
-      {
-        "title": "Finance",
-        "path": "/business/finance",
-        "parent": "/business"
-      }
-    ]
-  },
-  "publish_date": "2015-06-24T09:49:00.10Z",
-  "status": "published",
-  "display_date": "2015-06-25T09:50:50.52Z",
-  "editor_note": "This URL earlier linked to a post that we’ve unpublished due to its flawed premise.",
-  "content_elements": [
-    {
-      "_id": "123",
-      "type": "text",
-      "content": "this is my first paragraph"
-
-    },
-    {
-      "type": "story",
-      "_id": "456",
-      "taxonomy": {
-        "tags": [ "delta" ],
-        "sections": [
-          {
-            "name": "Section 451",
-            "path": "/451",
-            "other": "misc"
-          }
-        ]
-      }
-    }
-
-  ]
-}
diff --git a/tests/fixtures/transforms/0.5.4/story-fixture-good-planning.json b/tests/fixtures/transforms/0.5.4/story-fixture-good-planning.json
deleted file mode 100644
index 3225c7a..0000000
--- a/tests/fixtures/transforms/0.5.4/story-fixture-good-planning.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.4",
-
-  "planning": {
-    "planning": {
-      "foo": "bar",
-      "scheduling": {
-        "planned_publish_date": "2015-06-24T09:49:00.10Z",
-        "scheduled_publish_date": "2015-06-24T09:49:00.10Z",
-        "will_have_gallery": true,
-        "will_have_graphic": false,
-        "will_have_image": true,
-        "will_have_video": false,
-        "foo": "ack"
-      },
-      "story_length": {
-        "word_count_planned": 2400,
-        "word_count_actual": 3000,
-        "inch_count_planned": 6,
-        "inch_count_actual": 8,
-        "baz": "bat"
-      }
-    }
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.4/story-fixture-misspelled-additional-properties.json b/tests/fixtures/transforms/0.5.4/story-fixture-misspelled-additional-properties.json
deleted file mode 100644
index dc0de51..0000000
--- a/tests/fixtures/transforms/0.5.4/story-fixture-misspelled-additional-properties.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.4",
-  "content_elements": [
-    {
-      "type": "text",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.4"
-      }
-    },{
-      "type": "header",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.4"
-      }
-    },
-    {
-      "type": "image",
-      "version": "0.5.4",
-      "additional_properties": {
-        "was": "correctly spelled"
-      }
-    },
-    {
-      "type": "story",
-      "version": "0.5.4",
-      "content_elements": [{
-        "type": "text",
-        "content": "text",
-        "additonal_properties": {
-          "was": "misspelled"
-        }
-      }]
-    }
-  ],
-  "additional_properties": {
-    "is": "correctly spelled",
-    "additonal_properties": {
-      "is": "permanently misspelled"
-    }
-  },
-  "taxonomy": {
-    "seo_keywords": [ "one", "two" ]
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.5/story-fixture-good-planning.json b/tests/fixtures/transforms/0.5.5/story-fixture-good-planning.json
deleted file mode 100644
index 58ebc02..0000000
--- a/tests/fixtures/transforms/0.5.5/story-fixture-good-planning.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.5",
-
-  "planning": {
-    "planning": {
-      "foo": "bar",
-      "scheduling": {
-        "planned_publish_date": "2015-06-24T09:49:00.10Z",
-        "scheduled_publish_date": "2015-06-24T09:49:00.10Z",
-        "will_have_gallery": true,
-        "will_have_graphic": false,
-        "will_have_image": true,
-        "will_have_video": false,
-        "foo": "ack"
-      },
-      "story_length": {
-        "word_count_planned": 2400,
-        "word_count_actual": 3000,
-        "inch_count_planned": 6,
-        "inch_count_actual": 8,
-        "baz": "bat"
-      }
-    }
-  }
-}
diff --git a/tests/fixtures/transforms/0.5.5/story-fixture-misspelled-additional-properties.json b/tests/fixtures/transforms/0.5.5/story-fixture-misspelled-additional-properties.json
deleted file mode 100644
index bdeb1d4..0000000
--- a/tests/fixtures/transforms/0.5.5/story-fixture-misspelled-additional-properties.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
-  "type": "story",
-  "version": "0.5.5",
-  "content_elements": [
-    {
-      "type": "text",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.5"
-      }
-    },{
-      "type": "header",
-      "content": "Content",
-      "additonal_properties": {
-        "was": "misspelled",
-        "and": "should be corrected",
-        "after": "upverting",
-        "to": "0.5.5"
-      }
-    },
-    {
-      "type": "image",
-      "version": "0.5.5",
-      "additional_properties": {
-        "was": "correctly spelled"
-      }
-    },
-    {
-      "type": "story",
-      "version": "0.5.5",
-      "content_elements": [{
-        "type": "text",
-        "content": "text",
-        "additonal_properties": {
-          "was": "misspelled"
-        }
-      }]
-    }
-  ],
-  "additional_properties": {
-    "is": "correctly spelled",
-    "additonal_properties": {
-      "is": "permanently misspelled"
-    }
-  },
-  "taxonomy": {
-    "seo_keywords": [ "one", "two" ]
-  }
-}
diff --git a/tests/schema-tests-04.js b/tests/schema-tests-04.js
index c50de4a..2f13a35 100644
--- a/tests/schema-tests-04.js
+++ b/tests/schema-tests-04.js
@@ -4,7 +4,7 @@ var should = require('should'),
     dir = require('node-dir'),
     path = require('path'),
     Ajv = require('ajv'),
-    ans = require('../lib/schemas');
+    ans = require('../lib/ans');
 
 var loadedFiles = {};
 var baseDir = path.join(path.dirname(module.filename), '../src/main/resources/schema/ans');
diff --git a/tests/schema-tests-05.js b/tests/schema-tests-05.js
index 2b51e90..380be85 100644
--- a/tests/schema-tests-05.js
+++ b/tests/schema-tests-05.js
@@ -5,9 +5,9 @@ var should = require('should'),
     path = require('path'),
     Ajv = require('ajv'),
     _ = require('lodash'),
-    version = require('../lib/version'),
-    ans = require('../lib/schemas');
+    ans = require('../lib/ans');
 
+var version = ans.version;
 var loadedFiles = {};
 var baseDir = path.join(path.dirname(module.filename), '../src/main/resources/schema/ans');
 var loadedSchemas = {};
diff --git a/tests/transform-tests.js b/tests/transform-tests.js
index a537f7e..82c908e 100644
--- a/tests/transform-tests.js
+++ b/tests/transform-tests.js
@@ -4,12 +4,12 @@ var should = require('should'),
     dir = require('node-dir'),
     path = require('path'),
     Ajv = require('ajv'),
-    ans = require('../lib/schemas'),
+    ans = require('../lib/ans'),
     _ = require('lodash'),
-    current_version = require('../lib/version'),
-    async = require('async'),
-    transforms = require('../lib/transforms');
+    async = require('async');
 
+var current_version = ans.version;
+var transforms = ans.transforms;
 var loadedFiles = {};
 var baseDir = path.join(path.dirname(module.filename), '../src/main/resources/schema/ans');
 var loadedSchemas = {};
@@ -20,6 +20,7 @@ var FIRST_VERSION = '0.5.0';
 var LAST_VERSION = current_version.version;
 
 var fixtures = {};
+var ans_validator = null;
 
 // Helpers
 var validate = function(schemaName, ans, expected) {
@@ -31,25 +32,38 @@ var validate = function(schemaName, ans, expected) {
   //console.log("Fixture: " + fixture);
   if (result !== expected) {
     console.log(ans);
-    console.log(ajv.errors);
+    console.log(result.errors);
   }
   result.should.eql(expected);
 };
 
+var validateAns = function(transformed_ans, original_ans, version, expected) {
+
+  expected = (typeof expected === "undefined") ? [] : expected;
+  var result = ans_validator.getAllContentErrors(transformed_ans, version, "");
+  if (_.difference(result, expected).length !== 0) {
+    console.log("----ORIGINAL: " + JSON.stringify(original_ans, null, 2));
+    console.log("----TRANSFORMED: " + JSON.stringify(transformed_ans, null, 2));
+    console.log(JSON.stringify(result, null, 2));
+  }
+  _.difference(result, expected).length.should.eql(0);
+};
+
 
 var validateTransformAndValidate = function validateTransformAndValidate(name, version, type, transformer) {
   if (_.isNil(fixtures[version][name])) {
     console.log("      Skipping " + name + " for version " + version);
   }
   else {
-    //console.log("Validating existing fixture " + name + " against " + version + type + " before transform...");
-    validate(version + type, fixtures[version][name]);
+    console.log("      Validating " + name + " for version " + version);
+    var original = fixtures[version][name];
+    validateAns(original, null, version);
 
+    console.log("      Transforming and validating " + name);
     var transformed = transformer(fixtures[version][name]);
     var validate_version = transformed.version;
-    //console.log("Transforming " + name + " from " + version + " to " + validate_version);
 
-    validate(validate_version + type, transformed);
+    validateAns(transformed, original, validate_version);
   }
 };
 
@@ -59,6 +73,9 @@ describe("Transformations: ", function() {
   before(function(done) {
     ans.getSchemas(function(err, schemas) {
       loadedSchemas = schemas;
+
+      ans_validator = new ans.AnsValidator(new Ajv({allErrors:true}), loadedSchemas);
+
       var keys = Object.keys(loadedSchemas);
 
       for( var i = 0; i < keys.length; i++) {
@@ -114,9 +131,7 @@ describe("Transformations: ", function() {
 
     it(" validates as version " + LAST_VERSION + " after being upverted from " + FIRST_VERSION, function() {
       _.forEach(fixture_names, function(name, key) {
-        var transformed = transforms.upvert(fixtures[FIRST_VERSION][name], LAST_VERSION);
-
-        validate(LAST_VERSION + '/image.json', transformed);
+        validateTransformAndValidate(name, FIRST_VERSION, '/image.json', function(ans) { return transforms.upvert(ans, LAST_VERSION); });
       });
     });
   });
@@ -136,14 +151,15 @@ describe("Transformations: ", function() {
     it(" validates as version " + LAST_VERSION + " after being upverted from " + FIRST_VERSION, function() {
       _.forEach(fixture_names, function(name, key) {
         var transformed = transforms.upvert(fixtures[FIRST_VERSION][name], LAST_VERSION);
-        validate(LAST_VERSION + '/video.json', transformed);
+        validateTransformAndValidate(name, FIRST_VERSION, '/video.json', function(ans) { return transforms.upvert(ans, LAST_VERSION); });
       });
     });
   });
 
 
   describe("Story ", function() {
-    var fixture_names = ['story-fixture-good', 'story-fixture-references', 'story-fixture-tiny-house', 'story-fixture-table'];
+    var fixture_names = ['story-fixture-good', 'story-fixture-references', 'story-fixture-tiny-house', 'story-fixture-table', 'story-fixture-good-planning',
+                         'story-fixture-references-2', 'story-fixture-references-3', 'story-fixture-misspelled-additional-properties'];
 
     _.forIn(transforms.upverters, function(transformer, version) {
       it("can transform from " + version, function() {
@@ -155,8 +171,9 @@ describe("Transformations: ", function() {
 
     it(" validates as version " + LAST_VERSION + " after being upverted from " + FIRST_VERSION, function() {
       _.forEach(fixture_names, function(name, key) {
-        var result = transforms.upvert(fixtures[FIRST_VERSION][name], LAST_VERSION);
-        validate(LAST_VERSION + '/story.json', result);
+        //var result = transforms.upvert(fixtures[FIRST_VERSION][name], LAST_VERSION);
+        validateTransformAndValidate(name, FIRST_VERSION, '/story.json', function(ans) { return transforms.upvert(ans, LAST_VERSION); });
+        //validate(LAST_VERSION + '/story.json', result);
       });
     });
   });
@@ -189,7 +206,6 @@ describe("Transformations: ", function() {
       result.content_elements[0].should.not.have.property('additonal_properties');
       result.content_elements[0].additional_properties.should.be.Object();
       result.content_elements[0].additional_properties['was'].should.be.eql("misspelled");
-
     });
 
 
@@ -201,8 +217,6 @@ describe("Transformations: ", function() {
       result.content_elements[1].additional_properties['was'].should.be.eql("misspelled");
     });
 
-
-
     it("should be corrected in content_elements[3].content_elements[0]", function() {
       var result = getResult();
       result.content_elements[3].content_elements[0].should.have.property('additional_properties');
@@ -211,13 +225,6 @@ describe("Transformations: ", function() {
       result.content_elements[3].content_elements[0].additional_properties['was'].should.be.eql("misspelled");
     });
 
-
-    it("should be left alone in content_elements[2]", function() {
-      var result = getResult();
-      result.content_elements[2].should.have.property('additional_properties');
-      result.content_elements[2].should.not.have.property('additonal_properties');
-    });
-
     it("should be left alone in additional_properties.additonal_properties", function() {
       var result = getResult();
       result.should.have.property('additional_properties');
diff --git a/tests/validator.js b/tests/validator.js
new file mode 100644
index 0000000..1fe9d81
--- /dev/null
+++ b/tests/validator.js
@@ -0,0 +1,202 @@
+var ans = require('../lib/ans'),
+    Ajv = require('ajv'),
+    should = require('should'),
+    _ = require('lodash'),
+    loaded_schemas = {};
+
+var version = ans.version.version;
+var json_validator = new Ajv({allErrors:true});
+var validator = null;
+var AnsValidator = ans.AnsValidator;
+
+// Setup schemas and validators
+describe("ANS Validator", function() {
+  before(function(done) {
+    ans.getSchemas(function(err, s) {
+      Object.keys(s).forEach(function(key) {
+        var schema = s[key];
+        schema.id = 'https://raw.githubusercontent.com/washingtonpost/ans-schema/master/src/main/resources/schema/ans/' + key;
+        json_validator.addSchema(schema, schema.id);
+      });
+      loaded_schemas = s;
+      validator = new AnsValidator(new Ajv({allErrors:true}), loaded_schemas);
+      done();
+    });
+  });
+
+  describe(" .getAllContentErrors", function() {
+    it("should return no errors for an empty story", function() {
+      var story = { "type": "story", "version": version };
+
+      var errors = validator.getAllContentErrors(story, version, "");
+
+      errors.length.should.eql(0);
+
+    });
+
+    it("should return an error if an invalid property is added", function() {
+      var story = { "type": "story", "version": version,
+                    "foo": "bar" };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(1);
+      errors[0].message.should.eql("should NOT have additional properties");
+    });
+
+    it("should not return an error if a property is added to additional_properties", function() {
+      var story = { "type": "story", "version": version,
+                    "additional_properties": { "foo": "bar" } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(0);
+    });
+
+    it("should return an error if a content element has a bad type", function() {
+      var story = { "type": "story", "version": version,
+                    "content_elements": [ {"type": "foo", "content":"bar"} ] };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(1);
+      errors[0].dataPath.should.eql(".content_elements[0].type");
+    });
+
+    it("should return no errors if elements validate against their own schema", function() {
+      var story = { "type": "story", "version": version,
+                    "content_elements": [ {"type": "text", "content":"Some text"} ] };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(0);
+    });
+
+    it("should return an error if a content element does not validate against its own schema",
+       function() {
+         var story = { "type": "story", "version": version,
+                       "content_elements": [ {"type": "text", "content": "Text", "text":"Some text"} ] };
+         var errors = validator.getAllContentErrors(story, version, "");
+         errors.length.should.eql(1);
+         errors[0].dataPath.should.eql(".content_elements[0]");
+       });
+
+    it("should return an error for each invalid content element", function() {
+      var story = { "type": "story", "version": version,
+                    "content_elements": [ {"type": "text" }, {"type":"image"} ] };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(2);
+    });
+
+    it("should return errors on top-level object and elements", function() {
+      var story = { "type": "story",
+                    "content_elements": [ {"type": "text"} ] };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(2);
+    });
+
+
+    it("should return an error if a promo item has a bad type", function() {
+      var story = { "type": "story", "version": version,
+                    "promo_items": { "basic": {"type": "foo", "version":version} } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(1);
+      errors[0].dataPath.should.eql(".promo_items.basic.type");
+    });
+
+    it("should return no errors if promo items validate against their own schema", function() {
+      var story = { "type": "story", "version": version,
+                    "promo_items": {"basic": {"type": "image", "version":version} } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(0);
+    });
+
+    it("should return an error if a promo item does not validate against its own schema", function() {
+      var story = { "type": "story", "version": version,
+                    "promo_items": {
+                      "basic": { "type": "image", "version": version,
+                                 "foo": "bar" } } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(1);
+      errors[0].dataPath.should.eql(".promo_items.basic");
+    });
+
+
+    it("should return an error if a related content item has a bad type", function() {
+      var story = { "type": "story", "version": version,
+                    "related_content": {
+                      "default": [
+                        { "type": "foo", "version": version } ] } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(1);
+      errors[0].dataPath.should.eql(".related_content.default[0].type");
+    });
+
+
+    it("should return an error if a related content item does not validate against its own schema", function() {
+      var story = { "type": "story", "version": version,
+                    "related_content": {
+                      "default": [
+                        { "type": "image",
+                          "version": version,
+                          "foo": "bar" } ] } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(1);
+      errors[0].dataPath.should.eql(".related_content.default[0]");
+    });
+
+
+    it("should return no errors if related content validates against its own schema", function() {
+      var story = { "type": "story", "version": version,
+                    "related_content": {
+                      "default": [
+                        { "type": "image",
+                          "version": version,
+                          "height": 200 } ] } };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(0);
+    });
+
+
+    it("should return no errors if a reference is included in content_elements", function() {
+      var story = { "type": "story", "version": version,
+                    "content_elements": [{
+                      "type": "reference",
+                      "referent": {
+                        "type": "foo",
+                        "provider": "bar",
+                        "id": "goo"
+                      }
+                    }]
+                  };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.eql(0);
+    });
+
+    it("should return errors if an invalid reference is included in content_elements", function() {
+      var story = { "type": "story", "version": version,
+                    "content_elements": [{
+                      "type": "reference",
+                      "job": "314"
+                    }]
+                  };
+      var errors = validator.getAllContentErrors(story, version, "");
+      errors.length.should.not.eql(0);
+    });
+
+
+  });
+
+
+  describe(".getValidationErrors", function() {
+    it("should return an empty list if an item validates", function() {
+      var errors = validator.getValidationErrors(validator.findSchema(version, 'story'), { "type": "story", "version": version });
+      errors.should.be.Array();
+      errors.length.should.eql(0);
+    });
+
+    it("should return the JSON Schema validator errors if it does not validate", function() {
+      var errors = validator.getValidationErrors(validator.findSchema(version, 'story'), {"type":"story"});
+
+      json_validator.validate(loaded_schemas[version + '/story.json'], {"type":"story"});
+      var expected = json_validator.errors;
+      errors.should.deepEqual(expected);
+    });
+
+  });
+
+
+
+});