etnbrd/flx-compiler

View on GitHub
test-set/timbits-master/lib/timbits.js

Summary

Maintainability
F
4 days
Test Coverage

// load required modules
var fs = require('fs')
  , path = require('path')
  , querystring = require('querystring')
  , url = require('url')
  , winston = require('winston')
  , express = require('express')
  , hogan = require('hogan.js')
  , request = require('request');

// default configuration
var config = {
  appName: 'Timbits',
  base: '',
  port: 5678,
  home: process.cwd(),
  maxAge: 60,       // default widget output cache time
  engine: 'hjs',    // default view engine
  discovery: true,  // support automatic discovery via /timbits/json
  help: true,       // allow automatic help pages at /timbits/help and /[name]/help
  test: true,       // allow automatic test pages at /timbits/test and /[name]/test
  json: true,       // allow built in json view at /[name]/json
  jsonp: true       // allow jsonp calls via /[name]/json?callback=
};

// retrieve list of matching files in a folder
function filteredFiles(folder, pattern) {
  var files = [];
    
  if (fs.existsSync(folder)){
    fs.readdirSync(folder).forEach(function(file) {
      if (file.match(pattern) != null)
        files.push(file);
    });
  }
  return files;
}

// automagically load timbits found in the ./timbits folder
function loadTimbits(callback) {

  var folder = path.join(config.home, "/timbits");
  var files = filteredFiles(folder, /\.(coffee|js)$/);
  var pending = files.length;
  
  files.forEach(function(file) {
    var name = file.substring(0, file.lastIndexOf("."));
    timbits.add(name, require(path.join(folder, file)), function() {
      pending--;
      if (pending === 0) callback();
    });
  });
}

// automagically load views for a given timbit
function loadViews(timbit) {
  timbit.views = [];
  
  var pattern = new RegExp('\.' + timbits.app.settings['view engine'] + '$')
    , folder = path.join(config.home, 'views', timbit.viewBase);
    
  if (fs.existsSync(folder)) {
   var files = fs.readdirSync(folder);
   files.forEach(function(file) {
     timbit.views.push(file.replace(pattern, ''));
   });
  }
  
  // We will attempt the default view anyway and hope the timbit knows what it is doing.
  if (timbit.views.length === 0) timbit.views.push(timbit.defaultView);
}

// return a list of possible test values
function getTestValues(values, alltests) {
  if (values != null && values.length != null && values.length !== 0) {
    if (alltests)
      return values;
    else
      return values.slice(0, 1);
  } else {
    return [];
  }
}

// compile built in templates
function compileTemplate(name) {
  var filename = path.join(__dirname, "templates", name + '.hjs');
  var contents = fs.readFileSync(filename);
  return hogan.compile(contents.toString());
}

// generates the allowed methods
function allowedMethods(methods) {
  // default values
  var methodsAllowed = { 'GET': true, 'POST': false, 'PUT': false, 'HEAD': false, 'DELETE': false  };
  // check and override if one of the default methods
  for (var key in methods) {
    var newKey = key.toUpperCase();
    if ( methodsAllowed[newKey]!=undefined) {
      methodsAllowed[newKey] = Boolean(methods[key]);
    }
  }
  return methodsAllowed;
}

var timbits = this;
this.box = {};
this.pantry = require('pantry');
this.templates = {
  help: compileTemplate('help'),
  timbitHelp: compileTemplate('timbit-help'),
  test: compileTemplate('test'),
};

this.getLogLevel = function() {
  switch (process.env.NODE_ENV) {
    case 'production':
      return 'info';
    case 'test':
      return 'error';
    default:
      return 'silly';
  }
};

//added winston object for logging.
this.log = new (winston.Logger)({
  transports: [
    new (winston.transports.Console)({
      colorize: true,
      timestamp: true,
      level: this.getLogLevel()
    })
  ]
});

// creates, configures, and returns a standard express app
this.serve = function(options) {
    
  /* configure options */
  for (var key in options) {
    value = options[key];
    config[key] = value;
  }
  
  /* configure express app */
  var app = timbits.app = express();
   
  app.set('views', "" + config.home + "/views");
  app.set('view engine', config.engine);
  app.set('jsonp callback', config.jsonp);
  
  app.use(express.favicon());
  
  // disable request logging for tests (avoid console clutter)
  if (app.get('env') === 'development')   
    app.use(express.logger('dev'));
    
  app.use(express.compress());
  app.use(express.bodyParser());
  app.use(express.cookieParser());
  app.use(express.static(path.join(config.home, "public")));
  app.use(express.static(path.join(__dirname, "../resources")));
  app.use(express.errorHandler());
  
  // redirect root to help page
  if (config.help) {
    app.all(config.base + "/", function(req, res) {
      res.redirect(config.base + "/timbits/help");
    });
  }
  
  // route json discovery
  app.get(config.base + "/timbits/json", function(req, res) {
    if (config.discovery) {
      res.json(timbits.box);
    } else {
      res.send(404, "Automatic Discovery has been disabled");
    }
  });
  
  // route help page
  app.get(config.base + "/timbits/help", function(req, res) {
    if (config.help) {
      var context = {title: 'Timbits Help', timbits: []};
      for(var key in timbits.box) {
        context.timbits.push(key);
      }
      res.send(timbits.templates.help.render(context));
    } else {
      res.send(404, "Automatic Help has been disabled");
    }
  });
  
  // route master test page
  app.get(config.base + '/timbits/test/:which?', function(req, res) {
    if (config.test) {
      var alltests = (req.params.which === 'all')
        , all_results = []
        , pending = Object.keys(timbits.box).length;
      
      if (pending) {
        for (name in timbits.box) {
          var timbit = timbits.box[name];
          timbit.test('http://' + req.headers.host, alltests, function(results) {
            results.forEach(function(result) {
              all_results.push(result);
            });
            if (--pending === 0) {
              var passed = 0, failed = 0;
              all_results.forEach(function(result) {
                if (result.passed) passed++; else failed++;
              });
              res.send(timbits.templates.test.render({
                title: 'Testing Summary: all timbits',
                passed: passed,
                failed: failed,
                results: all_results         
              }));              
            }
          });
        }
      } else {
        res.send(ck.render(views.test, {}));
      }
    } else {
      res.send(404, "Automatic Test has been disabled");
    }
  });
  
  // automagically load timbits
  loadTimbits(function() {
    try {
       timbits.server = app.listen(process.env.PORT || process.env.C9_PORT || config.port);
       timbits.log.info("Timbits server listening on port " + timbits.server.address().port + " in " + app.settings.env + " mode");
    } catch (err) {
       timbits.log.error("Server could not start on port " + (process.env.PORT || process.env.C9_PORT || config.port) + ". (" + err + ")");
       console.log("\nPress Ctrl+C to Exit");
       process.exit(1);
    }
  });
  
  return app;  
};

// use the 'add' method to place a timbit in the box
this.add = function(name, timbit, callback) {  
  timbits.log.info("Placing " + name + " in the box");
  timbits.box[name] = timbit;
  
  timbit.name = name;
  if (timbit.viewBase == null) timbit.viewBase = name;
  if (timbit.defaultView == null) timbit.defaultView = 'default';
  if (timbit.maxAge == null) timbit.maxAge = config.maxAge;
  timbit.methods = allowedMethods( (timbit.methods == null) ? {} : timbit.methods );
  
  loadViews(timbit);
  
  // route timbit help
  timbits.app.get(config.base + "/" + name + "/help", function(req, res) {
    if (config.help)
      res.send(timbits.templates.timbitHelp.render(timbit));
    else
      res.send(404, "Automatic Help has been disabled");
  });
  
  // route timbit testing
  timbits.app.get(config.base + "/" + name + "/test/:which?", function(req, res) {
    var alltests;
    if (config.test) {
      alltests = req.params.which === 'all';
      timbit.test("http://" + req.headers.host, alltests, function(results) {
        var passed = 0, failed = 0;
        results.forEach(function(result) {
          if (result.passed) passed++; else failed++;
        });
        res.send(timbits.templates.test.render({
          title: 'Testing Summary: ' + timbit.name,
          passed: passed,
          failed: failed,
          results: results         
        }));
      });
    } else {
      res.send(404, "Automatic Test has been disabled");
    }
  });
  
  // main timbit route
  timbits.app.all(config.base + '/' + name + '/:view?', function(req, res) {
    
    // test if the method used is in the allowed methods, 
    if ( timbit.methods[req.method] == undefined || timbit.methods[req.method] == false ) {
      res.send(405, "Method Not Allowed");
      return;
    } 
    
    // set view name to default view if not specified
    if (req.params.view == null) req.params.view = timbit.defaultView;
    
    // initialize current request context
    var context = {
      name: timbit.name,
      view: timbit.viewBase + '/' + req.params.view,
      maxAge:  timbit.maxAge
    };
    
    // add query string parameters to context
    for (var key in req.query) {
      var has_alias = false;
      
      if (context[key] == null && req.query[key] != null && req.query[key] !== '') {
        
        // handle aliased parameters
        for (var p in timbit.params) {
          if (timbit.params[p] === key) {
            has_alias = true;
            context[p] = req.query[key];
          }
        }
        
        // no alias found
        if (!has_alias)
          context[key] = req.query[key];
      }
    }
    
    // validate request
    for (var key in timbit.params) {
      var param = timbit.params[key];
        
      // if parameter isn't specified, use default value
      if (context[key] == null) context[key] = param.default;
      
      // test provided value based on type
      var value = context[key];
      if (value != null) {
        
        // default parameter type is String
        if (param.type == null) param.type = 'String';
        
        switch (param.type.toLowerCase()) {
          
          case 'number':
            context[key] = Number(value);
            if (isNaN(context[key]))
              throw value + ' is not a valid Number for ' + key;
            break;
            
          case 'boolean':
            switch (value.toLowerCase()) {
              case 'true':
                context[key] = true;
                break;
              case 'false':
                context[key]= false;
                break;
              default:
                throw value + ' is not a valid value for ' + key + '.  Must be true of false';
            }
            break;
            
          case 'date':
            context[key] = Date.parse(value);
            if (isNaN(context[key]))
              throw value + ' is not a valid date for ' + key;
            break;
            
        }
      }
      
      if (param.required && value == null) {
        throw key + " is a required parameter";
      }
      if (value != null && param.strict && param.values.indexOf(value) === -1) {
        throw value + " is not a valid value for " + key + ".  Must be one of [" + (param.values.join()) + "]";
      }
      if (value instanceof Array && !param.multiple) {
        throw key + " must be a single value";
      }
      
     }
     
     // with context created, it's time to consume this timbit
     timbit.eat(req, res, context);
  });
  
  // update example urls if base vpath specified
  if (config.base != null && timbit.examples != null) {
    timbit.examples.forEach(function(example) {
      example.href = config.base + example.href;
    });
  }
  
  // callback after timbit has been loaded
  callback();
  
};

// prototype for Timbit
var Timbit = this.Timbit = function() {};

Timbit.prototype.render = function(req, res, context) {

  // add caching headers
  res.setHeader("Cache-Control", "max-age=" + context.maxAge);
  res.setHeader("Edge-Control", "!no-store, max-age=" + context.maxAge);
  
  if (/^(\w+|(\w+-)+\w+)\/json$/.test(context.view)) {
    if (config.json)
      res.json(context);
    else
      res.send(404, "JSON view has been disabled");
  } else {
    res.render(context.view, context, function(err, str) {
      if (err) {
        timbits.log.error("Error rendering view " + context.view);
        req.next(err);
      } else {
        if (context.callback != null)
          res.json(str);
        else
          res.send(str);
      }
    });
  }
};

Timbit.prototype.fetch = function(req, res, context, options, callback) {

  // use built in render method by default
  if (callback == null) callback = this.render;
  
  var name = options.name || 'data';
  timbits.pantry.fetch(options, function(error, results) {
    if (error) {
      timbits.log.error("Error fetching resource '" + options.uri);
      req.next(error);
    } else {
      if (context[name] != null) {
        if (Object.prototype.toString.call(context[name][0]) === "[object Array]")
          context[name].push(results);
        else
          context[name] = [context[name], results];
      } else {
        context[name] = results;
      }
      callback(req, res, context);
    }
  });
};

Timbit.prototype.eat = function(req, res, context) {
  this.render(req, res, context);
};


Timbit.prototype.generateTests = function(alltests) {
  
  // create combination of required parameters
  var required = [];
  for (var name in this.params) {
    var param = this.params[name];
    if (param.required) {
      var temp = [];
      getTestValues(param.values, alltests).forEach(function(value) {
        if (required.length === 0)
          temp.push(name + "=" + value);
        else
          required.forEach(function(item) {
            temp.push(item + "&" + name + "=" + value);
          });      
      });
      required = temp;
    }
  }
  
  // create list of possible queries using required and optional parameters
  var queries = [];
  required.forEach(function(item) {
    queries.push(item)
  });
  
  // only include optional parameters if all tests are requested
  if (alltests) {
    for (var name in this.params) {
      var param = this.params[name];
      if (!param.required) {
        getTestValues(param.values, alltests).forEach(function(value) {
          if (required.length === 0)
            queries.push(name + "=" + value);
          else
            required.forEach(function(item) {
              queries.push(item + "&" + name + "=" + value);
            });
        });
      }
    }
  }
  
  //create list of testable paths using available views and quiries
  var hrefs = [];
  var name = this.name
  this.views.forEach(function(view) {
    if (queries.length)
      queries.forEach(function(query) {
        hrefs.push("/" + name + "/" + view + "?" + query);
      });
    else
      hrefs.push("/" + name + "/" + view);
  });
  
  return hrefs;
};

Timbit.prototype.test = function(host, alltests, callback) {
  
  // generate dynamic list of test urls
  var tests = this.generateTests(alltests);
  
  // multiplier for  tests total based on allowed methods, determine which methods are used
  var testsLength = 1;
  var getMethod = this.methods["GET"];
  var postMethod = this.methods["POST"];
  if (getMethod && postMethod)
    testsLength = 2;
  
  // add examples to list of tests
  if (this.examples) {
    this.examples.forEach(function(example) {
      tests.push(example.href);
    });
  }
  
  // run each test
  var results = [];
  var name = this.name;
  
  tests.forEach(function(href) {
    if (getMethod) {
      request(host + href, function(error, response, body) {
        error = error || (response.statusCode === 200 ? '' : body);
        results.push({
          timbit: name,
          href: href,
          error: error,
          status: response.statusCode,
          passed: response.statusCode === 200,
          failed: response.statusCode !== 200
       });
      if (results.length === (tests.length * testsLength))
        return callback(results);
      });
    }
    if (postMethod) {
      request.post(host + href, function(error, response, body) {
        error = error || (response.statusCode === 200 ? '' : body);
        results.push({
          timbit: name,
          href: href,
          error: error,
          status: response.statusCode,
          passed: response.statusCode === 200,
          failed: response.statusCode !== 200
       });
      if (results.length === (tests.length * testsLength))
        return callback(results);
      });
    }
  });
  
};

Timbit.prototype.paramsAsArray = function() {
  var array = [];
  for (var key in this.params) {
    array.push({key: key, value: this.params[key]});
  }
  return array;
}