Skip to content

Commit

Permalink
Continuing to work on the plug-in model ("resolvers" and "parsers") for
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesMessinger committed Mar 29, 2016
1 parent 1f42601 commit eef72c0
Show file tree
Hide file tree
Showing 11 changed files with 227 additions and 286 deletions.
41 changes: 21 additions & 20 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
var Promise = require('./util/promise'),
Options = require('./options'),
$Refs = require('./refs'),
$Ref = require('./ref'),
read = require('./read'),
parse = require('./parse'),
resolve = require('./resolve'),
bundle = require('./bundle'),
dereference = require('./dereference'),
Expand Down Expand Up @@ -34,6 +33,7 @@ function $RefParser() {
* The resolved JSON references
*
* @type {$Refs}
* @readonly
*/
this.$refs = new $Refs();
}
Expand Down Expand Up @@ -65,39 +65,40 @@ $RefParser.parse = function(schema, options, callback) {
*/
$RefParser.prototype.parse = function(schema, options, callback) {
var args = normalizeArgs(arguments);

if (!args.schema || typeof args.schema !== 'string' || typeof args.schema !== 'object') {
var err = ono('Expected a file path, URL, or object. Got %s', args.schema);
return maybe(args.callback, Promise.reject(err));
}

var promise;
this.$refs = new $Refs();

if (args.schema && typeof args.schema === 'object') {
if (typeof args.schema === 'object') {
// The schema is an object, not a path/url
this.$refs._basePath = '';
var $ref = new $Ref(this.$refs, this.$refs._basePath);
$ref.value = args.schema;
promise = Promise.resolve({$ref: $ref});
this.$refs._add('', args.schema);
promise = Promise.resolve(args.schema);
}
else {
if (!args.schema || typeof args.schema !== 'string') {
var err = ono('Expected a file path, URL, or object. Got %s', args.schema);
return maybe(args.callback, Promise.reject(err));
}

// Resolve the absolute path of the schema
args.schema = url.localPathToUrl(args.schema);
args.schema = url.resolve(url.cwd(), args.schema);
this.$refs._basePath = url.stripHash(args.schema);
var path = args.schema;
path = url.localPathToUrl(path);
path = url.resolve(url.cwd(), path);
this.$refs._basePath = url.stripHash(path);

// Read the schema file/url
promise = read(args.schema, this.$refs, args.options);
// Parse the schema file/url
promise = parse(path, this.$refs, args.options);
}

var me = this;
return promise
.then(function(result) {
var value = result.$ref.value;
if (!value || typeof value !== 'object' || Buffer.isBuffer(value)) {
throw ono.syntax('"%s" is not a valid JSON Schema', me.$refs._basePath || value);
if (!result || typeof result !== 'object' || Buffer.isBuffer(result)) {
throw ono.syntax('"%s" is not a valid JSON Schema', me.$refs._basePath || result);
}
else {
me.schema = value;
me.schema = result;
return maybe(args.callback, Promise.resolve(me.schema));
}
})
Expand Down
176 changes: 103 additions & 73 deletions lib/parse.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,133 @@
'use strict';

var ono = require('ono'),
debug = require('./util/debug'),
url = require('./util/url'),
Promise = require('../util/promise');
var ono = require('ono'),
debug = require('./util/debug'),
url = require('./util/url'),
plugins = require('./util/plugins'),
Promise = require('./util/promise');

module.exports = parse;

/**
* Parses the given data according to the given options.
* Reads and parses the specified file path or URL.
*
* @param {*} data - The data to be parsed
* @param {string} path - The file path or URL that `data` came from
* @param {string} path - This path MUST already be resolved, since `read` doesn't know the resolution context
* @param {$Refs} $refs
* @param {$RefParserOptions} options
*
* @returns {Promise<string|Buffer|object>}
* @returns {Promise}
* The promise resolves with the parsed file contents, NOT the raw (Buffer) contents.
*/
function parse(data, path, options) {
function parse(path, $refs, options) {
try {
// Remove the URL fragment, if any
path = url.stripHash(path);

// Add a new $Ref for this file, even though we don't have the value yet.
// This ensures that we don't simultaneously read & parse the same file multiple times
var $ref = $refs._add(path);

// This "file object" will be passed to all resolvers and parsers.
var file = {url: path};

// Read the file and then parse the data
return readFile(file, options)
.then(function(resolver) {
$ref.pathType = resolver.plugin.name;
file.data = resolver.result;
return parseFile(file, options);
})
.then(function(parser) {
$ref.value = parser.result;
return parser.result;
});
}
catch (e) {
return Promise.reject(e);
}
}

/**
* Reads the given file, using the configured resolver plugins
*
* @param {object} file - An object containing information about the referenced file
* @param {string} file.url - The full URL of the referenced file
* @param {$RefParserOptions} options
*
* @returns {Promise}
* The promise resolves with the raw file contents and the resolver that was used.
*/
function readFile(file, options) {
return new Promise(function(resolve, reject) {
debug('Parsing %s', path);
var parsers = getSortedParsers(path, options);
util.runOrderedFunctions(parsers, data, path, options).then(onParsed, onError);
debug('Resolving %s', file.url);

function onParsed(parser) {
if (!parser.fn.empty && isEmpty(parser.result)) {
reject(ono.syntax('Error parsing "%s" as %s. \nParsed value is empty', path, parser.name));
}
else {
resolve(parser.result);
}
}
// Find the resolvers that can read this file
var resolvers = plugins.all(options.resolve);
resolvers = plugins.filter(resolvers, 'canRead', file);

// Run the resolvers, in order, until one of them succeeds
plugins.sort(resolvers);
plugins.run(resolvers, 'read', file)
.then(resolve, onError);

function onError(err) {
if (err) {
reject(ono.syntax(err, 'Error parsing %s', path));
// Throw the original error, if it's one of our own (user-friendly) errors.
// Otherwise, throw a generic, friendly error.
if (err && !(err instanceof SyntaxError)) {
reject(err);
}
else {
reject(ono.syntax('Unable to parse %s', path));
reject(ono.syntax('Unable to resolve $ref pointer "%s"', file.url));
}
}
});
}

/**
* Returns the parsers for the given file, sorted by how likely they are to successfully
* parse the file. For example, a ".yaml" file is more likely to be successfully parsed
* by the YAML parser than by the JSON, Text, or Binary parsers. Whereas a ".jpg" file
* is more likely to be successfully parsed by the Binary parser.
* Parses the given file's contents, using the configured parser plugins.
*
* @param {string} path - The file path or URL that `data` came from
* @param {object} file - An object containing information about the referenced file
* @param {string} file.url - The full URL of the referenced file
* @param {*} file.data - The file contents. This will be whatever data type was returned by the resolver
* @param {$RefParserOptions} options
* @returns {{name: string, order: number, score: number, fn: function}[]}
*/
function getSortedParsers(path, options) {
var ext = url.extname(path);
var bestScore = 3;

return util.getOrderedFunctions(options.parse)
// Score each parser
.map(function(parser) {
var scoredParser = {
score: score(path, ext, parser.fn.ext),
order: parser.order,
name: parser.name,
fn: parser.fn,
};
bestScore = Math.min(scoredParser.score, bestScore);
return scoredParser;
})

// Only return parsers that scored 1 (exact match) or 2 (pattern match).
// If NONE of the parsers matched, then return ALL of them
.filter(function(parser) { return bestScore === 3 || parser.score < 3; })

// Sort by score (exact matches come first)
.sort(function(a, b) { return a.score - b.score; });
}

/**
* Returns a "score", based on how well the given file matches the given patterns.
*
* @param {string} path - The full file path or URL
* @param {string} ext - The file extension
* @param {array} patterns - An array of file extensions (strings) or RegExp patterns
* @returns {number} - 1 = exact match, 2 = pattern match, 3 = no match
* @returns {Promise}
* The promise resolves with the parsed file contents and the parser that was used.
*/
function score(path, ext, patterns) {
var bestScore = 3;
patterns = Array.isArray(patterns) ? patterns : [patterns];
for (var i = 0; i < patterns.length; i++) {
var pattern = patterns[i];
if (pattern === ext) {
return 1;
function parseFile(file, options) {
return new Promise(function(resolve, reject) {
debug('Parsing %s', file.url);

// Find the parsers that can read this file type.
// If none of the parsers are an exact match for this file, then we'll try ALL of them.
// This handles situations where the file IS a supported type, just with an unknown extension.
var allParsers = plugins.all(options.parse);
var filteredParsers = plugins.filter(options.parse, 'canParse', file);
var parsers = filteredParsers.length > 0 ? filteredParsers : allParsers;

// Run the parsers, in order, until one of them succeeds
plugins.sort(parsers);
plugins.run(parsers, 'parse', file)
.then(onParsed, onError);

function onParsed(parser) {
if (!parser.plugin.allowEmpty && isEmpty(parser.result)) {
reject(ono.syntax('Error parsing "%s" as %s. \nParsed value is empty', file.url, parser.plugin.name));
}
else {
resolve(parser);
}
}
if (pattern instanceof RegExp && pattern.test(path)) {
bestScore = Math.min(bestScore, 2);

function onError(err) {
if (err) {
reject(ono.syntax(err, 'Error parsing %s', file.url));
}
else {
reject(ono.syntax('Unable to parse %s', file.url));
}
}
}
return bestScore;
});
}

/**
Expand All @@ -107,7 +137,7 @@ function score(path, ext, patterns) {
* @returns {boolean}
*/
function isEmpty(value) {
return !value ||
return value === undefined ||
(typeof value === 'object' && Object.keys(value).length === 0) ||
(typeof value === 'string' && value.trim().length === 0) ||
(Buffer.isBuffer(value) && value.length === 0);
Expand Down
24 changes: 12 additions & 12 deletions lib/parsers/binary.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,32 @@ module.exports = {
* Parsers that return false will be skipped, UNLESS all parsers returned false, in which case
* every parser will be tried.
*
* @param {object} info - An object containing information about the referenced file
* @param {string} info.url - The full URL of the referenced file
* @param {*} info.data - The file contents. This will be whatever data type was returned by the resolver
* @param {object} file - An object containing information about the referenced file
* @param {string} file.url - The full URL of the referenced file
* @param {*} file.data - The file contents. This will be whatever data type was returned by the resolver
* @returns {boolean}
*/
canParse: function isBinary(info) {
canParse: function isBinary(file) {
// Use this parser if the file is a Buffer, and has a known binary extension
return Buffer.isBuffer(info.data) && BINARY_REGEXP.test(info.url);
return Buffer.isBuffer(file.data) && BINARY_REGEXP.test(file.url);
},

/**
* Parses the given data as a Buffer (byte array).
*
* @param {object} info - An object containing information about the referenced file
* @param {string} info.url - The full URL of the referenced file
* @param {*} info.data - The file contents. This will be whatever data type was returned by the resolver
* @param {object} file - An object containing information about the referenced file
* @param {string} file.url - The full URL of the referenced file
* @param {*} file.data - The file contents. This will be whatever data type was returned by the resolver
* @returns {Promise<Buffer>}
*/
parse: function parseBinary(info) {
parse: function parseBinary(file) {
return new Promise(function(resolve, reject) {
if (Buffer.isBuffer(info.data)) {
resolve(info.data);
if (Buffer.isBuffer(file.data)) {
resolve(file.data);
}
else {
// This will reject if data is anything other than a string or typed array
resolve(new Buffer(info.data));
resolve(new Buffer(file.data));
}
});
}
Expand Down
20 changes: 10 additions & 10 deletions lib/parsers/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,28 @@ module.exports = {
* Parsers that return false will be skipped, UNLESS all parsers returned false, in which case
* every parser will be tried.
*
* @param {object} info - An object containing information about the referenced file
* @param {string} info.url - The full URL of the referenced file
* @param {*} info.data - The file contents. This will be whatever data type was returned by the resolver
* @param {object} file - An object containing information about the referenced file
* @param {string} file.url - The full URL of the referenced file
* @param {*} file.data - The file contents. This will be whatever data type was returned by the resolver
* @returns {boolean}
*/
canParse: function isJSON(info) {
canParse: function isJSON(file) {
// If the file explicitly has a ".json" extension, then use this parser.
// Otherwise, allow other parsers to give it a shot.
return JSON_REGEXP.test(info.url);
return JSON_REGEXP.test(file.url);
},

/**
* Parses the given file as JSON
*
* @param {object} info - An object containing information about the referenced file
* @param {string} info.url - The full URL of the referenced file
* @param {*} info.data - The file contents. This will be whatever data type was returned by the resolver
* @param {object} file - An object containing information about the referenced file
* @param {string} file.url - The full URL of the referenced file
* @param {*} file.data - The file contents. This will be whatever data type was returned by the resolver
* @returns {Promise}
*/
parse: function parseJSON(info) {
parse: function parseJSON(file) {
return new Promise(function(resolve, reject) {
var data = info.data;
var data = file.data;
if (Buffer.isBuffer(data)) {
data = data.toString();
}
Expand Down
Loading

0 comments on commit eef72c0

Please sign in to comment.