diff options
Diffstat (limited to 'app')
26 files changed, 1499 insertions, 509 deletions
diff --git a/app/index.js b/app/index.js index b312eb1..c0d2ff3 100644 --- a/app/index.js +++ b/app/index.js @@ -13,7 +13,9 @@ var OKResource = require('okresource') var OKTemplate = require('oktemplate'); var OKServer = require('okserver'); var OKSchema = require('okschema'); -var OKImageService = require('okservices').OKImageService; +var OKS3Service = require('okservices/oks3'); +var OKTwitterService = require('okservices/oktwitter') +var OKWebhookService = require('okservices/okwebhook') require('dotenv').load(); @@ -27,72 +29,103 @@ function OKCMS(options) { var app = express(); app.enable('strict routing'); + app.disable('x-powered-by'); + + var schemaConfig = options.schemas || {}; + var resourceConfig = options.resources || []; + var viewConfig = options.views || { + '/': { template: 'index' } + }; + var serviceConfig = options.services || {}; + var adminConfig = options.admin || {} var root = this._root = options.root || 'public'; - var adminConfig = options.admin || {}; + var templateRoot = options.templateRoot || 'templates'; + + var adminPath = this._adminPath = adminConfig.path || '/admin'; var adminRoot = this._adminRoot = adminConfig.root || path.join(__dirname, '../themes/okadmin/public'); - var adminPath = this._adminPath = adminConfig.path || '/_admin' - var templateRoot = options.templateRoot || 'templates'; - var adminTemplateRoot = options.templateRoot || + var adminTemplateRoot = adminConfig.templateRoot || path.join(__dirname, '../themes/okadmin/templates'); - // Set metadata defaults - // TODO Abstract this out somewhere else - var meta = { - type: 'meta', - get: function() { - return Q.promise(function(resolve, reject) { - db.getMeta().then(function(metadata) { - resolve(assign({}, { - static: '' - }, metadata)); - }).fail(reject); - }); - } - }; + var debug = !!options.debug; + var production = !!options.production; - var adminMeta ={ - type: 'meta', - get: function() { - return Q.promise(function(resolve, reject) { - db.getMeta().then(function(metadata) { - resolve(assign({}, { - static: withoutTrailingSlash(adminPath) - }, metadata)); - }).fail(reject); - }); - } + var metaUser = options.meta || {}; + var metaDefault = { + project: 'OKCMS', + production: production, + debug: debug }; - var schemaConfig = options.schemas || {}; - var resourceConfig = options.resources || []; - var viewConfig = options.views || { - '/': { template: 'index' } - }; - var serviceConfig = options.services || {}; + var meta = assign({ + static: '' + }, metaDefault, metaUser); - var templateProvider = this._templateProvider = - new OKTemplate({root: templateRoot}); - var adminTemplateProvider = this._adminTemplateProvider = - new OKTemplate({root: adminTemplateRoot}); + var adminMeta = assign({ + static: withoutTrailingSlash(adminPath), + services: options.services, + }, metaDefault, metaUser); + + var templateProvider = this._templateProvider = new OKTemplate({ + root: templateRoot, + debug: debug + }); + var adminTemplateProvider = this._adminTemplateProvider = new OKTemplate({ + root: adminTemplateRoot, + debug: debug + }); - var db = new OKDB(options.db || 'fs'); var schemas = this._schemas = this._createSchemas(schemaConfig); + var db = new OKDB({ + db: options.db || 'fs', + schemas: schemas + }); var resourceCache = this._resourceCache = this._createResources(resourceConfig, db, schemas); - + this._resolveForeignKeys(resourceCache) + var errorHandler = createErrorHandlerProducer( + templateProvider, adminTemplateProvider, debug); // Create view instances from config var views = this._views = - this._createViews(viewConfig, db, meta, resourceCache, templateProvider); + this._createViews(viewConfig, db, meta, resourceCache, templateProvider, + errorHandler); var adminViews = this._adminViews = - this._createAdminViews(adminPath, app, express, resourceConfig, - resourceCache, adminTemplateProvider, adminMeta); + this._createAdminViews(adminConfig, adminPath, app, express, resourceConfig, + resourceCache, adminTemplateProvider, adminMeta, + errorHandler); // Create services - var imageService = OKImageService({ - express: express, - s3: serviceConfig.s3, + var services = {} + Object.keys(serviceConfig).forEach(function(key){ + var config = serviceConfig[key] + switch (key) { + case 's3': + services.s3 = OKS3Service({ + express: express, + s3: config, + }); + break + case 'twitter': + services.twitter = OKTwitterService({ + express: express, + credentials: config, + }); + break + case 'webhook': + services.webhook = OKWebhookService({ + express: express, + config: config, + }); + break + default: + services[key] = config.lib({ + db: resourceCache, + express: express, + config: config, + }); + break + } }); var server = this._server = new OKServer({ @@ -104,9 +137,8 @@ function OKCMS(options) { root: root, adminRoot: adminRoot, adminPath: adminPath, - services: { - image: imageService - } + services: services, + errorHandler: errorHandler }); } @@ -119,6 +151,11 @@ OKCMS.prototype._createSchemas = function(schemaConfig) { schemaConfig = schemaConfig || {}; return Object.keys(schemaConfig).reduce(function(cache, key) { var spec = schemaConfig[key]; + // All resources have an autoincrementing index so we can order them suckas + // TODO Screw the __ prefix, just consider 'index' a reserved word + spec.__index = {type: 'meta', autoincrement: true}; + // All resources have a dateCreated field + spec.dateCreated = {type: 'meta'}; cache[key] = OKSchema(spec); return cache; }, {}); @@ -130,8 +167,8 @@ OKCMS.prototype._createResources = function(resourceConfig, db, schemaCache) { var type = config.type; var schema = schemaCache[type]; if (!schema) - throw new Error('Resource config references nonexistent schema'); - var resource = OKResource({ + throw new Error('Resource config references nonexistent schema ' + type); + var resource = new OKResource({ type: type, db: db, schema: schema @@ -146,8 +183,23 @@ OKCMS.prototype._createResources = function(resourceConfig, db, schemaCache) { return ResourceCache(resources); }; +OKCMS.prototype._resolveForeignKeys = function(resourceCache) { + resourceCache.forEach(function(resource) { + Object.keys(resource.foreignKeys).forEach(function(field) { + var foreignKeyType = resource.foreignKeys[field] + var keyedResource = resourceCache.get(foreignKeyType) + if (!keyedResource) { + throw new Error(format( + "Foreign key field '%s' in '%s' resource references unknown" + + "resource of type '%s'", field, resource.type, foreignKeyType)) + } + resource._linkForeignKey(field, resourceCache.get(foreignKeyType)) + }) + }) +} + OKCMS.prototype._createViews = function(viewConfig, db, - meta, resourceCache, templateProvider) { + meta, resourceCache, templateProvider, errorHandler) { viewConfig = viewConfig || {}; var self = this; var createQueries = this._createQueries.bind(this); @@ -158,15 +210,15 @@ OKCMS.prototype._createViews = function(viewConfig, db, if (!template) { throw new Error(format('No template named "%s" found', templateName)); } - var queryConfig = config.data || []; - var queries = createQueries(queryConfig, resourceCache); + var queries = createQueries(config.data, resourceCache); // Don't forget to add that trailing slash if the user forgot cache[withTrailingSlash(route)] = OKView({ mount: 'get', // User defined views are read only route: route, template: template, queries: queries, - meta: meta + meta: meta, + errorHandler: errorHandler }); return cache; }, {}); @@ -187,8 +239,8 @@ OKCMS.prototype._createViews = function(viewConfig, db, } }; -OKCMS.prototype._createAdminViews = function(path, app, express, - resourceConfig, resourceCache, templateProvider, meta) { +OKCMS.prototype._createAdminViews = function(adminConfig, path, app, express, + resourceConfig, resourceCache, templateProvider, meta, errorHandler) { var views = {}; var withTrail = withTrailingSlash(path); var withoutTrail = withoutTrailingSlash(path); @@ -212,13 +264,16 @@ OKCMS.prototype._createAdminViews = function(path, app, express, resourceConfig: resourceConfig, resourceCache: resourceCache, templateProvider: templateProvider, - meta: meta + meta: meta, + errorHandler: errorHandler, + dashboardConfig: adminConfig.dashboard || {} }); return views; }; OKCMS.prototype._createQueries = function(queryConfig, resourceCache) { - queryConfig = queryConfig || {}; + if (!queryConfig) + return []; if (!queryConfig.length) queryConfig = [queryConfig]; return queryConfig.map(function(config) { @@ -230,7 +285,11 @@ OKCMS.prototype._createQueries = function(queryConfig, resourceCache) { var query = config.query || '*'; return new OKQuery({ resource: resource, - query: query + query: query, + as: config.as, + sortBy: config.sortBy, + descending: config.descending, + groupBy: config.groupBy }); }); }; @@ -248,7 +307,7 @@ function ResourceCache(resources) { throw new Error('Undefined resource given to ResourceCache'); if (resource.bound) { cache[resource.type] = resource.parent; - cache[resource.type + ':' + resource.id] = resource; + cache[resource.type + ':' + resource.getID()] = resource; } else { cache[resource.type] = resource; } @@ -264,6 +323,54 @@ ResourceCache.prototype.get = function(type, id) { } }; +ResourceCache.prototype.forEach = function(cb) { + cb = cb || function() {} + Object.keys(this._cache).forEach(function(key) { + cb(this._cache[key]) + }.bind(this)) +} + +/** + * Higher order function implementing customizable error handling + */ +function createErrorHandlerProducer(templateProvider, adminTemplateProvider, debugMode) { + + var template404 = templateProvider.getTemplate('404') || + adminTemplateProvider.getTemplate('404'); + var template5xx = templateProvider.getTemplate('5xx') || + adminTemplateProvider.getTemplate('5xx'); + + if (!template404 || !template5xx) + throw new Error('No error templates provided by admin theme or user') + + return debugMode ? createDebugHandler : createErrorHandler; + + function createErrorHandler(req, res, status) { + return function handleError(err) { + switch (status) { + case 404: + template404.render().then(function(rendered) { + res.status(status); + res.send(rendered); + }); + break; + default: + template5xx.render().then(function(rendered) { + res.status(status); + res.send(rendered); + }); + } + + }; + } + + function createDebugHandler(req, res, status) { + return function handleError(err) { + res.send(err.stack); + }; + } +} + module.exports = { createApp: function(options) { diff --git a/app/node_modules/okadminview/index.js b/app/node_modules/okadminview/index.js index 987fe51..3a9056f 100644 --- a/app/node_modules/okadminview/index.js +++ b/app/node_modules/okadminview/index.js @@ -1,10 +1,25 @@ -var assign = require('object-assign') +var assign = require('object-assign'); +var cloneDeep = require('lodash.clonedeep'); var bodyParser = require('body-parser'); var methodOverride = require('method-override'); +var session = require('express-session'); +var flash = require('connect-flash'); +var passport = require('passport'); +var DigestStrategy = require('passport-http').DigestStrategy; var Q = require('q'); var pluralize = require('pluralize'); var OKQuery = require('okquery'); +// Configure auth +passport.use(new DigestStrategy({qop: 'auth'}, + function authenticate(username, done) { + if (!process.env.OK_USER || !process.env.OK_PASS) { + return done(new Error('No user or pass configured on server')); + } else { + return done(null, process.env.OK_USER, process.env.OK_PASS); + } +})); + /** * OKAdminView! */ @@ -21,13 +36,20 @@ function OKAdminView(options) { if (!options.templateProvider) throw new Error('No templateProvider provided to OKAdminView'); if (!options.meta) - throw new Error('No meta query provided to OKAdminView'); + throw new Error('No metadata provided to OKAdminView'); + if (!options.errorHandler) + throw new Error('No error handler provided to OKAdminView'); + + var dashboardConfig = options.dashboardConfig || {} + var dashboardResourceConfig = dashboardConfig.resources || {} var app = options.app; var express = options.express; var meta = options.meta; var resourceCache = this._resourceCache = options.resourceCache; - var resourceConfig = this._resourceConfig = options.resourceConfig; + var resourceConfig = this._resourceConfig = cloneDeep(options.resourceConfig); var provider = options.templateProvider; + var error = this._error = options.errorHandler; + // Load templates var templates = this._templates = ['index', 'resource', 'resource_new'].reduce(function(cache, name) { @@ -37,6 +59,7 @@ function OKAdminView(options) { cache[name] = template; return cache; }, {}); + // OKAdmin middleware is a router, so mounts on 'use' Object.defineProperty(this, 'mount', { value: 'use', @@ -51,16 +74,32 @@ function OKAdminView(options) { var config = resourceConfig[key]; var type = config.type; var staticData = config.static || {}; + // Get parent level resource var resource = resourceCache.get(config.type); if (!resource) throw new Error('Something weird is going on'); - var id = staticData[resource.idField]; + var id = resource.getID(staticData); + // Check to see if there's a more specific instance resource = resourceCache.get(type, id) || resource; + var dashConf = dashboardResourceConfig[type] || {} + var groupBy = dashConf.groupBy + var sortBy = dashConf.sortBy + var descending = dashConf.descending if (resource.bound) { - // Resource instances implement the query API - return OKQuery({resource: resource});; + return OKQuery({ + resource: resource, + groupBy: groupBy, + sortBy: sortBy, + descending: descending + }) } else { - return OKQuery({resource: resource, query: config.query}) + return OKQuery({ + resource: resource, + query: config.query, + groupBy: groupBy, + sortBy: sortBy, + descending: descending + }) } }); @@ -71,6 +110,14 @@ function OKAdminView(options) { strict: app.get('strict routing') }); + // Enable basic sessions for flash messages + router.use(session({ + secret: 'okadmin', + resave: false, + saveUninitialized: false + })); + // Enable flash messaging + router.use(flash()); // Parse form data router.use(bodyParser.urlencoded({extended: true})); // HTML forms only support POST and GET methods @@ -85,27 +132,34 @@ function OKAdminView(options) { } })); + // This should really be mounted on the router, but can't be due to + // https://github.com/jaredhanson/passport-http/pull/16 + app.use('/admin/', passport.initialize()); + app.all('/admin/(:path*)?', passport.authenticate('digest', { + session: false + })); + router.get('/', function readIndex(req, res, next) { - fetchIndexTemplateData(meta, indexQueries).then(function(data) { - view.renderIndex(req, res, data); - }).fail(errorHandler(req, res)); + fetchIndexTemplateData(meta, indexQueries, dashboardConfig).then(function(data) { + view.renderIndex(req, res, assign(data, { + success: req.flash('success'), + errors: req.flash('errors') + })); + }).fail(error(req, res, 500)); }); - router.get('/:type/new/', function createResourceView(req, res, next) { + router.get('/:type/__new__/', function createResourceView(req, res, next) { var type = req.params.type || ''; var resource = resourceCache.get(type); if (!resource) { - errorHandler(req, res)(new Error('No such resource ' + type)); + error(req, res, 404)(new Error('No such resource ' + type)); } else { - meta.get().then(function(metadata) { - view.renderResourceNew(req, res, { - meta: metadata, - resource: { - type: resource.type, - spec: resource.spec - } - }); - }).fail(errorHandler(req, res)); + fetchNewTemplateData(meta, resource, transformData).then(function(data) { + view.renderResourceNew(req, res, assign(data, { + success: req.flash('success'), + errors: req.flash('errors'), + })) + }).fail(error(req, res, 500)) } }); @@ -113,22 +167,28 @@ function OKAdminView(options) { var type = req.params.type || ''; var id = req.params.id || ''; var resource = resourceCache.get(type, id); - if (!resource) { - errorHandler(req, res)(new Error('No such resource')); - } else { - var query = OKQuery({ - resource: resource, - query: id - }); - fetchResourceTemplateData(meta, query, getResourceTemplateData).then(function(data) { - if (!data) { - resourceMissingHandler(req, res)() - } else { - view.renderResource(req, res, data); - } - }).fail(errorHandler(req, res)); - } - + var query = OKQuery({ + resource: resource, + query: id + }); + fetchResourceTemplateData(meta, query, transformData) + .then(function(data) { + if (!data) { + resourceMissingHandler(req, res)() + } else { + view.renderResource(req, res, assign(data, { + JSON: JSON, + success: req.flash('success'), + errors: req.flash('errors') + })); + } + }).fail(function(err) { + if (err.message === 'No resource data') { + error(req, res, 404)(new Error('No such resource')); + } else { + error(req, res, 500)(err); + } + }); }); router.post('/:type/', function createResource(req, res, next) { @@ -136,19 +196,47 @@ function OKAdminView(options) { var resource = resourceCache.get(type); var data = req.body; if (!resource) { - errorHandler(req, res)(new Error('No such resource ' + type)); + error(req, res, 400)(new Error('No such resource ' + type)); } else { - meta.get().then(function(metadata) { - try { - resource.assertValid(data); - resource.create(data).then(function(created) { - res.redirect(303, data[resource.idField]); - }).fail(errorHandler(req, res)); - } catch (errors) { - var templateData = getResourceTemplateData(metadata, resource, data); - view.renderResource(req, res, assign(templateData, {errors: errors})); - } - }).fail(errorHandler(req, res));; + try { + resource.assertValid(data); + resource.create(data).then(function(created) { + req.flash('success', {action: 'create'}); + res.redirect(303, resource.getID(data)); + }).fail(error(req, res, 500)); + } catch (errors) { + var spec = resource.spec + var templateData = transformData(meta, spec, resource, data); + view.renderResource(req, res, assign(templateData, {errors: errors})); + } + } + }); + + router.put('/:type/__batch__/', function putBatch(req, res, next) { + var type = req.params.type; + var body = req.body || {}; + var resourcesJSON = body[type]; + var resource = resourceCache.get(type); + if (!resourcesJSON || !resourcesJSON.length) { + error(req, res, 400)(new Error('Bad request')); + } else if (!resource) { + error(req, res, 404)(new Error('No such resource')); + } else { + try { + var ids = []; + var resourcesParsed = resourcesJSON.map(function(resourceJSON) { + var data = JSON.parse(resourceJSON); + ids.push(resource.getID(data)); + return data; + }); + } catch (e) { + error(req, res, 500)(new Error('Resource batch contains invalid JSON')); + return; + } + resource.updateBatch(ids, resourcesParsed).then(function(results) { + req.flash('success', {action: 'batch_update'}); + res.redirect(303, '../..'); + }).fail(error(req, res, 500)); } }); @@ -158,20 +246,33 @@ function OKAdminView(options) { var data = req.body; var resource = resourceCache.get(type, id); if (!resource) { - errorHandler(req, res)(new Error('No such resource ' + type)); + error(req, res, 400)(new Error('No such resource ' + type)); } else { - // TODO Prob should make metadata synchronous... - meta.get().then(function(metadata) { - try { - resource.assertValid(data); - resource.update(id, data).then(function(updated) { - res.redirect(303, '../' + updated[resource.idField]); - }).fail(errorHandler(req, res)); - } catch (errors) { - var templateData = getResourceTemplateData(metadata, resource, data); - view.renderResource(req, res, assign(templateData, {errors: errors})); - } - }).fail(errorHandler(req, res)); + try { + resource.assertValid(data); + resource.update(id, data).then(function(updated) { + req.flash('success', {action: 'update'}); + res.redirect(303, '../' + resource.getID(updated)); + }).fail(error(req, res, 500)); + } catch (errors) { + var spec = resource.spec + var templateData = transformData(meta, spec, resource, data); + view.renderResource(req, res, assign(templateData, {errors: errors})); + } + } + }); + + router.delete('/:type/:id/', function deleteResource(req, res, next) { + var type = req.params.type; + var id = req.params.id; + var resource = resourceCache.get(type, id); + if (!resource) { + error(req, res, 500)(new Error('No such resource ' + type)); + } else { + resource.destroy(id).then(function() { + req.flash('success', {action: 'delete'}); + res.redirect(303, '../..'); + }).fail(error(req, res, 500)); } }); @@ -180,21 +281,27 @@ function OKAdminView(options) { } /** - * Get template data for a single resource + * Yields formatted template data for a single resource */ -function getResourceTemplateData(meta, resource, data) { +function transformData(meta, spec, resource, data) { meta = meta || {}; resource = resource || {}; data = data || {}; - var spec = Object.keys(resource.spec).reduce(function(cache, prop) { + var spec = Object.keys(spec).reduce(function(cache, prop) { var value = data[prop]; - cache[prop].value = value; + var propSpec = cache[prop]; + // Decorate spec with actual resource values + propSpec.value = value; + // Some fields should not be shown to the user + if (propSpec.type === 'meta' || propSpec.static) { + propSpec.hidden = true; + } return cache; - }, resource.spec); + }, spec); return { meta: meta, resource: { - id: data[resource.idField], + id: resource.getID(data), type: resource.type, spec: spec } @@ -209,102 +316,128 @@ OKAdminView.prototype.renderIndex = function(req, res, data) { data = data || {}; this._templates['index'].render(data).then(function(rendered) { res.send(rendered); - }).fail(errorHandler(req, res)); + }).fail(this._error(req, res, 500)); }; OKAdminView.prototype.renderResource = function(req, res, data) { data = data || {}; this._templates['resource'].render(data).then(function(rendered) { res.send(rendered); - }).fail(errorHandler(req, res)); + }).fail(this._error(req, res, 500)); }; OKAdminView.prototype.renderResourceNew = function(req, res, data) { data = data || {meta: {}, resource: {}}; this._templates['resource_new'].render(data).then(function(rendered) { res.send(rendered); - }).fail(errorHandler(req, res)); + }).fail(this._error(req, res, 500)); }; /** * Annotate template data with schema info */ -function fetchIndexTemplateData(meta, queries) { - return Q.Promise(function(resolve, reject) { - Q.all([meta.get()].concat(queries.map(function(query) { +function fetchIndexTemplateData(meta, queries, dashboardConfig) { + var resourceConfig = dashboardConfig.resources || {} + + return Q.promise(function(resolve, reject) { + Q.all(queries.map(function(query) { return query.get(); - }))).then(function(results) { - var meta = results.shift(); - var resources = results.reduce(function(cache, result, i) { - if (!result) - return cache; + })).then(function(results) { + var templateData = results.reduce(function(acc, result, i) { + result = result.length ? result : [result] var resource = queries[i].resource; - // We want the raw object spec - var spec = resource.spec; - var key = pluralize(resource.type); - if (!cache[key]) { - cache[key] = { + var key = pluralize(resource.type) + if (acc[key]) { + acc[key].data = acc[key].data.concat(result) + } else { + // We want the raw object spec + var spec = resource.spec; + var dashConf = resourceConfig[resource.type] || {} + var groupBy = dashConf.groupBy + var descending = dashConf.descending + acc[key] = { type: resource.type, spec: spec, - data: [] - }; - } - - if (result.length) { - result.forEach(addToCache) - } else { - addToCache(result); - } - - function addToCache(data) { - // Report id to template under standard name - data.id = data[resource.idField]; - cache[key].data.push(data); + data: result, + groupBy: groupBy, + descending: descending, + } } - - return cache; - }, {}); - + return acc + }, {}) resolve({ meta: meta, - resources: resources - }); - }).fail(reject); + resources: templateData + }) + }).fail(reject) }); } +function fetchNewTemplateData(meta, resource, transformFn) { + return Q.promise(function(resolve, reject) { + if (!resource.hasForeignKey) { + done({spec: resource.spec, resource: resource}) + } else { + fetchForeignKeyOptions(resource).then(done).fail(reject) + } + + function done(results) { + resolve(transformFn(meta, results.spec, results.resource, {})) + } + }) +} + /** * Annotate template data with schema info */ -function fetchResourceTemplateData(meta, query, fn) { - fn = fn || function(m, r, d) { return {meta: m, resource: d}; }; - return Q.Promise(function(resolve, reject) { - meta.get().then(function(metadata) { - query.get().then(function(data) { - var resource = query.resource; - resolve(fn(metadata, resource, data)); - }).fail(reject); +function fetchResourceTemplateData(meta, query, transformFn) { + return Q.promise(function(resolve, reject) { + query.get().then(function(data) { + if (!data) + return reject(new Error('No resource data')) + + var resource = query.resource + + if (resource.hasForeignKey) { + fetchForeignKeyOptions(resource).then(done).fail(reject) + } else { + done({spec: resource.spec, resource: resource}) + } + + function done(results) { + resolve(transformFn(meta, results.spec, results.resource, data)) + } }).fail(reject) - }); + }) } -/** - * TODO Real error handling - */ -function errorHandler(req, res) { - return function(err) { - res.send(err.stack); - }; -} +function fetchForeignKeyOptions(resource) { + var promises = Object.keys(resource.foreignKeys) + .map(fetchOptionsForKey) + var spec = resource.spec -/** - * TODO Real 404 handling - */ -function resourceMissingHandler(req, res) { - return function() { - res.status(404); - res.send('404'); + return Q.all(promises).then(done) + + function done() { + return Q.promise(function(resolve, reject) { + resolve({spec: spec, resource: resource}) + }) + } + + function fetchOptionsForKey(field) { + var relatedResourceType = resource.foreignKeys[field] + return resource.related(relatedResourceType).then(fillOptions) + + function fillOptions(results) { + return Q.promise(function(resolve, reject) { + spec[field].options = results.map(function(result) { + return result.id + }) + resolve() + }) + } } } + module.exports = OKAdminView; diff --git a/app/node_modules/okadminview/package.json b/app/node_modules/okadminview/package.json index 4832db1..bdaefc5 100644 --- a/app/node_modules/okadminview/package.json +++ b/app/node_modules/okadminview/package.json @@ -10,8 +10,13 @@ "license": "None", "dependencies": { "body-parser": "^1.12.2", + "connect-flash": "^0.1.1", + "express-session": "^1.11.1", + "lodash.clonedeep": "^3.0.0", "method-override": "^2.3.2", "object-assign": "^2.0.0", + "passport": "^0.2.1", + "passport-http": "^0.2.2", "pluralize": "^1.1.2", "q": "^1.2.0" } diff --git a/app/node_modules/okdb/index.js b/app/node_modules/okdb/index.js index 79ce1eb..ad8d9a7 100644 --- a/app/node_modules/okdb/index.js +++ b/app/node_modules/okdb/index.js @@ -1,6 +1,10 @@ -var Q = require('q'); +var assign = require('object-assign') +var cloneDeep = require('lodash.clonedeep'); +var isobject = require('lodash.isobject'); var format = require('util').format; var low = require('lowdb'); +var Q = require('q'); + low.mixin(low.mixin(require('underscore-db'))); /** @@ -10,14 +14,14 @@ low.mixin(low.mixin(require('underscore-db'))); function OKDB(options) { if (!(this instanceof OKDB)) return new OKDB(options); options = options || {}; - var type; + var db; if (typeof options === 'string') - type = options; + db = options; else - type = options.type; - if (!type) - throw new Error('No DB type provided'); - switch (type) { + db = options.db; + if (!db) + throw new Error('No DB db provided to OKDB'); + switch (db) { case 'fs': return FSDB(options); default: @@ -27,81 +31,186 @@ function OKDB(options) { /** * DB implementation backed by a JSON file. - * TODO Incomplete */ function FSDB(options) { if (!(this instanceof FSDB)) return new FSDB(options); options = options || {}; + if (!options.schemas) + throw new Error('No schemas provided to FSDB') + this._schemas = options.schemas; var name = options.name || 'db'; var filename = name + '.json'; this._db = low(filename); } -FSDB.prototype._resolve = function(data, error) { - return Q.Promise(function resolvePromise(resolve, reject) { - if (error) { - reject(error); - } else { - resolve(data); - } - }); +FSDB.prototype.get = function(collection, id) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + + var query = getQuery(schema, id); + if (!query) + return resolve(null, new Error('Bad query')); + + var result = this._db(collection).find(query); + return resolve(result ? cloneDeep(result) : result); }; -FSDB.prototype.get = function(collection, query) { - if (!query) { - return this._resolve(null, new Error('No query given')); +/** + * Add a new document to the DB + */ +FSDB.prototype.insert = function(collection, data) { + var schema = this._schemas[collection]; + var wrapped = this._db(collection); + if (!schema) + return resolve(null, new Error('No such collection type')); + // Get detached, clone deep, data sleep, beep beep + data = cloneDeep(data); + // Auto-increment fields + data = autoincrement(wrapped, schema, data); + // Record date created + // TODO Should this meta prop logic be moved out of the DB? + data.dateCreated = new Date().toUTCString(); + var result = wrapped.chain().push(data).last().value(); + + if (result) { + return resolve(cloneDeep(result)); } else { - var data = this._db(collection).find(query); - return this._resolve(data); + return resolve(null, new Error('Problem inserting document')); } }; -FSDB.prototype.put = function(collection, query, data) { - data = data || {}; - if (!query) { - return this._resolve(null, new Error('No query given')); - } else if (this._db(collection).find(query)) { - var updated = this._db(collection) - .chain() - .find(query) - .assign(data) - .value(); - return this._resolve(updated); +/** + * Update an existing document in the DB + */ +FSDB.prototype.update = function(collection, id, data) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + + var query = getQuery(schema, id); + var wrapped = this._db(collection); + var chain = wrapped.chain().find(query); + + if (!chain.value()) { + return resolve(null, new Error('Cannot update nonexistent entry')); + } + + var result = chain.assign(cloneDeep(data)).value(); + if (result) { + return resolve(cloneDeep(result)); } else { - return this._resolve(null, new Error('Cannot update nonexistent entry')); + return resolve(null, new Error('Problem updating document')); } }; -FSDB.prototype.create = function(collection, data) { - var created = this._db(collection) - .chain() - .push(data) - .last() - .value(); - return this._resolve(created); -}; +/** + * TODO Should be atomic ¯\_(ツ)_/¯ + */ +FSDB.prototype.updateBatch = function(collection, ids, datas) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + if (!ids || !ids.length || !datas || !datas.length || + ids.length !== datas.length) { + return resolve(null, new Error('Bad input')); + } -FSDB.prototype.remove = function(collection, id, data) { - throw new Error('Not implemented!'); + var doc = this._db(collection); + var results = ids.map(function(id, i) { + return doc.chain().find(getQuery(schema, id)).assign(datas[i]).value(); + }); + + return resolve(results); }; -FSDB.prototype.find = function(collection, query) { - if (!collection || !query) { - return this._resolve(null, new Error('Bad input')); +FSDB.prototype.remove = function(collection, id) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + + var query = getQuery(schema, id); + var result = this._db(collection).removeWhere(query); + + if (result) { + // Don't need to clone this ref, since it's removed anyway + return resolve(result); } else { - var data = this._db(collection).find(query); - return this._resolve(data); + return resolve(null, new Error('Cannot remove nonexistent entry')); } }; +FSDB.prototype.sortBy = function(collection, prop, descend) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + if (!prop) + return resolve(null, new Error('Bad input')); + + var result = this._db(collection).sortByOrder([prop], [!descend]); + return resolve(result); +}; + +FSDB.prototype.find = function(collection, query) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + if (!query) + return resolve(null, new Error('Bad input')); + + var result = this._db(collection).find(query); + + return resolve(cloneDeep(result)); +}; + +FSDB.prototype.all = function(collection) { + var schema = this._schemas[collection]; + if (!schema) + return resolve(null, new Error('No such collection type')); + + var data = this._db(collection).value(); + return resolve(cloneDeep(data || [])); +}; + FSDB.prototype.getMeta = function() { var data = this._db('meta').first(); - return this._resolve(data || {}); + return resolve(data || {}); }; -FSDB.prototype.getAll = function(collection) { - var data = this._db(collection).toArray(); - return this._resolve(data || []); +/** + * Function implementing DB auto increment support + * Naive implementation, assumes DB is relatively small. + */ +function autoincrement(wrapper, schema, data) { + return schema.autoIncrementFields.reduce(function(data, field) { + var last = wrapper.chain().sortByOrder([field], [true]).last().value(); + var index = last ? last[field] : -1; + var incremented = {}; + incremented[field] = (parseInt(index) + 1); + return assign(data, incremented); + }, data); +} + +function getQuery(schema, id) { + if (schema && id) { + var query = {}; + query[schema.idField] = id; + return query; + } +} + +/** + * Helper function to create promises for DB data + */ +function resolve(data, error) { + return Q.promise(function resolvePromise(resolve, reject) { + if (error) { + reject(error); + } else { + resolve(data); + } + }); }; + module.exports = OKDB; diff --git a/app/node_modules/okdb/package.json b/app/node_modules/okdb/package.json index 659422e..5184cb6 100644 --- a/app/node_modules/okdb/package.json +++ b/app/node_modules/okdb/package.json @@ -9,7 +9,10 @@ "author": "OKFocus", "license": "None", "dependencies": { + "lodash.clonedeep": "^3.0.0", + "lodash.isobject": "^3.0.1", "lowdb": "^0.7.2", + "object-assign": "^2.0.0", "q": "^1.2.0", "underscore-db": "^0.8.1" } diff --git a/app/node_modules/okquery/index.js b/app/node_modules/okquery/index.js index 33a49c4..d4cb905 100644 --- a/app/node_modules/okquery/index.js +++ b/app/node_modules/okquery/index.js @@ -1,3 +1,4 @@ +var cloneDeep = require('lodash.clonedeep'); var assign = require('object-assign'); var isobject = require('lodash.isobject'); var Q = require('q'); @@ -16,6 +17,14 @@ function OKQuery(options) { var resource = options.resource; var type = resource.type; var query = options.query || '*'; + // Ensure immutability + if (isobject(query)) + query = cloneDeep(query); + + // Queries are ordered by index by default + var sortField = options.sortBy || '__index'; + // TODO Make descending by default + var descending = options.descending || false; Object.defineProperty(this, 'resource', { value: resource, @@ -29,8 +38,23 @@ function OKQuery(options) { enumerable: true }); + Object.defineProperty(this, 'as', { + value: options.as, + writable: false, + enumerable: true + }); + + Object.defineProperty(this, 'groupBy', { + value: options.groupBy, + writable: false, + enumerable: true + }) + this.get = createQuery(resource, query, { - default: options.default + default: options.default, + sortField: sortField, + descending : descending, + groupBy: options.groupBy }); } @@ -43,18 +67,24 @@ function createQuery(resource, query, options) { } else if (isDynamic(query)) { query = queryDynamic(resource); } else if (isSet(query)) { - query = queryAll(resource); + query = queryAll(resource, options.sortField, options.descending); } else { query = querySingle(resource, query); } if (options.default) { query = withDefault(query, options.default); } + if (options.groupBy) { + query = withGrouping(query, options.groupBy) + } return query; } function queryComplex(resource, query) { var dynamicProp; + // Query is an object specifying key value pairs against which + // to match DB entries. Iterate through and check if any of the values + // is unbound e.g. :id var notDynamic = Object.keys(query).every(function(prop) { var matcher = query[prop]; if (isDynamic(matcher)) { @@ -67,15 +97,15 @@ function queryComplex(resource, query) { if (notDynamic) { return function() { - console.log('get it!', query) return resource.find(query); } } else { return function(id) { + // Bind the dynamic property to its value + // and add the pair to the query var dynamicQuery = {}; dynamicQuery[dynamicProp] = id; var query = assign({}, query, dynamicQuery); - console.log('get it!', query) return resource.find(query); } } @@ -87,9 +117,9 @@ function queryDynamic(resource) { }; } -function queryAll(resource) { +function queryAll(resource, sortField, descending) { return function() { - return resource.all(); + return resource.sortBy(sortField, descending); }; } @@ -105,6 +135,37 @@ function queryBound(resource) { }; } +/** + * Transform the query such that the results are grouped by the + * given field + */ +function withGrouping(queryFn, groupField) { + return function() { + return Q.Promise(function(resolve, reject) { + queryFn().then(function(data) { + data = data || [] + if (typeof data.length === 'undefined') { + data = [data] + } + var result = {} + result[groupField] = data.reduce(reduceToGroups, {}) + resolve(result) + }, reject) + }) + } + + function reduceToGroups(acc, data) { + var groupName = data[groupField] + if (groupName) { + if (!acc[groupName]) { + acc[groupName] = [] + } + acc[groupName].push(data) + } + return acc + } +} + function withDefault(queryFn, resultDefault) { return function() { return Q.Promise(function(resolve, reject) { diff --git a/app/node_modules/okquery/package.json b/app/node_modules/okquery/package.json index 606d45b..5ba9dd5 100644 --- a/app/node_modules/okquery/package.json +++ b/app/node_modules/okquery/package.json @@ -9,6 +9,7 @@ "author": "OKFocus", "license": "None", "dependencies": { + "lodash.clonedeep": "^3.0.0", "lodash.isobject": "^3.0.1", "object-assign": "^2.0.0", "q": "^1.2.0" diff --git a/app/node_modules/okresource/index.js b/app/node_modules/okresource/index.js index 94d8cfb..c1f2509 100644 --- a/app/node_modules/okresource/index.js +++ b/app/node_modules/okresource/index.js @@ -1,4 +1,6 @@ +var format = require('util').format var assign = require('object-assign'); +var cloneDeep = require('lodash.clonedeep'); var Q = require('q'); /** @@ -18,27 +20,44 @@ function OKResource(options) { throw new Error('No DB provided to OKResource'); var schema = options.schema; - // Iterate through spec to find field which will act as the - // resource id in da db. - var idField = Object.keys(schema.spec).reduce(function(idField, prop) { - var spec = schema.spec[prop]; - if (spec.id) - idField = prop; - return idField; - // If schema has a prop called 'id', default to that one - }, schema.spec.id && 'id'); - if (!idField) - throw new Error('Bad schema: no ID field'); + var spec = schema.spec; var type = options.type; + var hasForeignKey = false this._db = options.db; this._schema = schema; + var foreignKeys = Object.keys(spec).reduce(function(acc, field) { + var fieldSpec = spec[field] + if (fieldSpec.type === 'foreign-key') { + hasForeignKey = true + acc[field] = fieldSpec.key + } + return acc + }, {}) + + // Will store references to other resources referenced via foreign keys + this._foreignKeyedResources = {} + // Define properties which are part of the API + + // Should be treated as read-only + Object.defineProperty(this, 'foreignKeys', { + get: function() { + return foreignKeys + } + }) + + Object.defineProperty(this, 'hasForeignKey', { + get: function() { + return hasForeignKey + } + }) Object.defineProperty(this, 'spec', { - value: schema.spec, - writable: false, + get: function() { + return schema.spec; + }, enumerable: true }); @@ -48,12 +67,6 @@ function OKResource(options) { enumerable: true }); - Object.defineProperty(this, 'idField', { - value: idField, - writable: false, - enumerable: true - }); - // Whether this resource represents a specific data point // or a whole class of data Object.defineProperty(this, 'bound', { @@ -63,6 +76,30 @@ function OKResource(options) { }); } +OKResource.prototype._linkForeignKey = function(field, resource) { + this._foreignKeyedResources[field] = resource +} + +/** + * Fetch all related resources for the given field + */ +OKResource.prototype.related = function(field) { + var resource = this._foreignKeyedResources[field] + return Q.promise(function(resolve, reject) { + if (!resource) { + return error(reject, new Error(format( + "No related resource for field '%s'", field))) + } + resource.all().then(resolve).fail(reject) + }) + + function error(reject, err) { + setTimeout(function() { + reject(err) + }, 0) + } +} + /** * Throws an error if data does not conform to schema */ @@ -71,31 +108,34 @@ OKResource.prototype.assertValid = function(data) { }; OKResource.prototype.all = function() { - return this._db.getAll(this.type); + return this._db.all(this.type); }; -OKResource.prototype.create = function(data) { +OKResource.prototype.getID = function(data) { data = data || {}; + return data[this._schema.idField]; +}; + +OKResource.prototype.create = function(data) { var type = this.type; var db = this._db; - var id = data[this.idField]; return Q.promise(function(resolve, reject) { - if (!id) { - reject(new Error('Data does not contain ID property')); + if (!data) { + reject(new Error('No data provided')); } else { - db.create(type, data).then(resolve).fail(reject); + db.insert(type, data).then(resolve).fail(reject); } }); }; -OKResource.prototype.destroy = function(data) { - data = data || {}; - var id = data[this.idField]; +OKResource.prototype.destroy = function(id) { + var db = this._db; + var type = this.type; return Q.promise(function(resolve, reject) { if (!id) { - reject(new Error('Data does not contain ID property')); + reject(new Error('No ID provided')); } else { - this._db.remove(this.type, data.id, data).then(resolve).fail(reject); + db.remove(type, id).then(resolve).fail(reject); } }); }; @@ -106,7 +146,7 @@ OKResource.prototype.find = function(query) { var type = this.type; return Q.promise(function(resolve, reject) { if (!query) { - throw new Error('No query given'); + throw new Error('No query provided'); } else { db.find(type, query).then(resolve).fail(reject); } @@ -116,53 +156,65 @@ OKResource.prototype.find = function(query) { OKResource.prototype.get = function(id) { var db = this._db; var type = this.type; - var idField = this.idField; return Q.promise(function(resolve, reject) { if (!id) { - throw new Error('No ID given'); + throw new Error('No ID provided'); } else { - // We have the id, but we still need - // to resolve which field is the id field - // to match - var query = {}; - query[idField] = id; - db.get(type, query).then(resolve).fail(reject); + db.get(type, id).then(resolve).fail(reject); } }); }; OKResource.prototype.update = function(id, data) { - data = data || {}; var db = this._db; var type = this.type; - var idField = this.idField; return Q.promise(function(resolve, reject) { if (!id) { reject(new Error('No resource ID provided')); + } else if (!data) { + reject(new Error('No data provided')); } else { - var query = {}; - query[idField] = id; - db.put(type, query, data).then(resolve).fail(reject);; + db.update(type, id, data).then(resolve).fail(reject);; } }); }; +OKResource.prototype.updateBatch = function(ids, datas) { + var self = this; + var db = this._db; + var type = this.type; + return Q.promise(function(resolve, reject) { + if (!ids || !ids.length || !datas || !datas.length || + ids.length !== datas.length) { + reject(new Error('Bad input')); + } else { + db.updateBatch(type, ids, datas).then(resolve).fail(reject); + } + }); +} + + +/** + * Get all documents in collection sorted by property, + * optionally in descending order + */ +OKResource.prototype.sortBy = function(prop, descend) { + return this._db.sortBy(this.type, prop, descend); +}; + OKResource.prototype.updateOrCreate = function(id, data) { data = data || {}; var type = this.type; var db = this._db; - var idField = this.idField; - var query = {}; - query[idField] = id; return Q.promise(function(resolve, reject) { if (!id) { - reject(new Error('No resource ID provided')); + reject(new Error('No ID provided')); } else { - db.get(type, query).then(function(persisted) { + db.get(type, id).then(function(persisted) { if (persisted) { - db.put(type, query, data).then(resolve).fail(reject); + db.update(type, id, data).then(resolve).fail(reject); } else { - db.create(type, data).then(resolve).fail(reject); + db.insert(type, data).then(resolve).fail(reject); } }).fail(reject); } @@ -179,6 +231,9 @@ OKResource.prototype.instance = function(options) { }); }; +/** + * TODO This class is such bullshit. Refactor out + */ function OKResourceInstance(resource, options) { if (!(this instanceof OKResourceInstance)) return new OKResourceInstance(options); // Only support static data instances for now @@ -190,12 +245,16 @@ function OKResourceInstance(resource, options) { // conceptually at all times since they are derived from app // configuration, but may not actually be present // in the database and need custom logic to handle this. - var staticData = assign({}, options.static); - var id = staticData[resource.idField]; + var staticData = cloneDeep(options.static); + var id = resource.getID(staticData); if (!id) throw new Error( 'Cannot create static OKResourceInstance without an ID field'); + this.getID = function() { + return id; + }; + /** * Ensure that static data is provided on get */ @@ -204,9 +263,9 @@ function OKResourceInstance(resource, options) { resource.get(id).then(function(data) { // Note the assign call. Don't expose private references! if (data) { - resolve(assign({}, data, staticData)); + resolve(assign(data, cloneDeep(staticData))); } else { - resolve(assign({}, staticData)); + resolve(cloneDeep(staticData)); } }).fail(reject); }); @@ -274,6 +333,18 @@ function OKResourceInstance(resource, options) { resource.assertValid(data); }; + Object.defineProperty(this, 'foreignKeys', { + get: function() { + return [] + } + }) + + Object.defineProperty(this, 'hasForeignKey', { + get: function() { + return false + } + }) + Object.defineProperty(this, 'parent', { value: resource, writable: false, @@ -281,14 +352,9 @@ function OKResourceInstance(resource, options) { }); Object.defineProperty(this, 'spec', { - value: resource.spec, - writable: false, - enumerable: true - }); - - Object.defineProperty(this, 'id', { - value: id, - writable: false, + get: function() { + return resource.spec + }, enumerable: true }); @@ -298,23 +364,11 @@ function OKResourceInstance(resource, options) { enumerable: true }); - Object.defineProperty(this, 'idField', { - value: resource.idField, - writable: false, - enumerable: true - }); - Object.defineProperty(this, 'bound', { value: true, writable: false, enumerable: true }); - - Object.defineProperty(this, 'class', { - value: resource, - writable: false, - enumerable: true - }); } module.exports = OKResource; diff --git a/app/node_modules/okresource/package.json b/app/node_modules/okresource/package.json index 7f19c9b..7b1dfbb 100644 --- a/app/node_modules/okresource/package.json +++ b/app/node_modules/okresource/package.json @@ -9,6 +9,7 @@ "author": "OKFocus", "license": "None", "dependencies": { + "lodash.clonedeep": "^3.0.0", "object-assign": "^2.0.0", "q": "^1.2.0" } diff --git a/app/node_modules/okschema/index.js b/app/node_modules/okschema/index.js index 8871a99..0048fc5 100644 --- a/app/node_modules/okschema/index.js +++ b/app/node_modules/okschema/index.js @@ -1,4 +1,4 @@ -var assign = require('object-assign'); +var cloneDeep = require('lodash.clonedeep'); var mschema = require('mschema'); var v = require('validator'); @@ -41,36 +41,99 @@ var types = { } }, 'captioned-image-list': { + isArray: true, parent: [{ uri: { type: 'string' }, // TODO Implement URI type caption: { type: 'string' } }], - assertValid: function(spec, value) { - var message; - var actual; - if (!value || !value.length) { - throw [{ - message: 'Not an array', - expected: JSON.stringify(this.parent), - actual: value - }]; - } - } + assertValid: function(spec, value) {} + }, + 'gallery': { + isArray: true, + parent: [{ + uri: { type: 'string' }, // TODO Implement URI type + caption: { type: 'string' } + }], + assertValid: function(spec, value) {} + }, + // Special type for resource meta information + 'meta': { + parent: 'string', + assertValid: function(spec, value) {} + }, + 'link-list': { + isArray: true, + parent: [{ + uri: { type: 'string' }, + text: { type: 'string' } + }], + assertValid: function(spec, value) {} + }, + 'date': { + parent: 'string', + assertValid: function(spec, value) {} + }, + 'flag': { + parent: 'boolean', + assertValid: function(spec, value) {} + }, + 'foreign-key': { + parent: 'enum', + assertValid: function(spec, value) {} + }, + 'media-list': { + isArray: true, + parent: [], + assertValid: function(spec, value) {} + }, + 'media': { + isArray: true, + parent: [], + assertValid: function(spec, value) {} + }, + 'double-captioned-image-list': { + isArray: true, + parent: [], + assertValid: function(spec, value) {} + }, + 'triple-captioned-image-list': { + isArray: true, + parent: [], + assertValid: function(spec, value) {} + }, +} + +/* +function checkArrayLength (spec, value) { + var message; + var actual; + if (!value || !value.length) { + throw [{ + message: 'Not an array', + expected: JSON.stringify(this.parent), + actual: value + }]; } } +*/ + /** * OKSchema! * Meant as a thin wrapper around some existing schema validation * module, mostly to allow for the extension of types. + * + * NOTE: Currently just assumes spec is valid. If you give a bad spec + * strange things may or may not happen */ function OKSchema(spec) { if (!(this instanceof OKSchema)) return new OKSchema(spec); if (!spec) throw new Error('No spec provided to OKSchema'); - spec = assign({}, spec); + spec = cloneDeep(spec); + var specKeys = Object.keys(spec); // Cache the mschema version of our spec - this._mschemaSpec = Object.keys(spec).reduce(function(cache, prop) { + this._mschemaSpec = specKeys.reduce(function(cache, prop) { // If custom type, return its parent spec var type = spec[prop].type; if (types[type]) { @@ -82,25 +145,104 @@ function OKSchema(spec) { return cache; }, {}); + // Find ID field + var idField; + specKeys.every(function(prop) { + if (prop === 'id' || spec[prop].id) { + idField = prop; + return false; + } else { + return true; + } + }); + + // Register autoincrement fields + // NOTE Does not work for nested fields + var autoIncrementFields = specKeys.reduce(function(arr, prop) { + var specProp = spec[prop]; + if (specProp.autoincrement) { + arr.push(prop); + } + return arr; + }, []); + Object.defineProperty(this, 'spec', { - value: spec, - writable: false + get: function() { + return cloneDeep(spec); + }, + enumerable: true + }); + + Object.defineProperty(this, 'idField', { + value: idField, + writable: true, + enumerable: true }); + + Object.defineProperty(this, 'autoIncrementFields',{ + get: function() { + return cloneDeep(autoIncrementFields); + }, + enumerable: true + }); +} + +OKSchema.prototype.fixMissingLists = function(data) { + var spec = this.spec; + + // The qs body-parser module does not have a way to represent + // empty lists. If you delete all elements from a list, + // check against the spec so we know to replace with an empty list. + Object.keys(spec).forEach(function(prop){ + var type = spec[prop].type; + if (types[type] && types[type].isArray && ! data[prop]) { + data[prop] = [] + } + }) +} + +OKSchema.prototype.fixIndexField = function(data) { + // Likewise numbers always come in as strings. The field used to sort + // records, __index, is of type "meta", so the parseFloat in + // assertValid (below) never fires and we end up with sorting issues. + if (data.__index && typeof data.__index == "string") { + var __index = parseInt(data.__index) + if (! isNaN(__index)) { + data.__index = __index + } + } } OKSchema.prototype.assertValid = function(data) { data = data || {}; var spec = this.spec; + // Run through custom validators, they'll throw if invalid Object.keys(data).forEach(function(prop) { var type = spec[prop].type; - if (types[type]) { + + // Check if it's a number/boolean and try to cast it + // otherwise pass and let mschema handle + if (type === 'number') { + try { + data[prop] = parseFloat(data[prop]); + } catch (err) {} + } else if (type === 'flag') { + data[prop] = data[prop] == "true" ? true : false + } else if (types[type]) { types[type].assertValid(spec[prop], data[prop]); } }); var result = mschema.validate(data, this.toMschema()); - if (!result.valid) + if (!result.valid) { throw result.errors; + } + + // Fix various issues with our data, having to do + // with use of the "qs" body-parser module. + // TODO: just send JSON? + this.fixMissingLists(data) + this.fixIndexField(data) }; /** diff --git a/app/node_modules/okschema/package.json b/app/node_modules/okschema/package.json index 21214fa..21a7c67 100644 --- a/app/node_modules/okschema/package.json +++ b/app/node_modules/okschema/package.json @@ -9,8 +9,8 @@ "author": "OKFocus", "license": "None", "dependencies": { + "lodash.clonedeep": "^3.0.0", "mschema": "^0.5.5", - "object-assign": "^2.0.0", "validator": "^3.37.0" } } diff --git a/app/node_modules/okserver/index.js b/app/node_modules/okserver/index.js index 1645eaa..a89676f 100644 --- a/app/node_modules/okserver/index.js +++ b/app/node_modules/okserver/index.js @@ -16,6 +16,8 @@ function OKServer(options) { throw new Error('No admin root directory provided to OKServer'); if (!options.adminPath) throw new Error('No admin path provided to OKServer'); + if (!options.errorHandler) + throw new Error('No error handler provided to OKServer'); var root = options.root; var adminRoot = options.adminRoot; var adminPath = options.adminPath; @@ -25,6 +27,7 @@ function OKServer(options) { var router = express.Router({ strict: app.get('strict routing') }); + var error = options.errorHandler; var services = options.services || {}; Object.keys(views) // Sort such that more general routes are matched last @@ -49,11 +52,8 @@ function OKServer(options) { * other middleware. */ - // Intercept favicon requests and 404 for now - app.use('/favicon.ico', function(req, res) { - res.status(404) - return res.send(''); - }); + // Disable x-powered-by express header + app.disable('x-powered-by') // Serve user static files app.use(express.static(root)); // Serve admin interface static files @@ -61,22 +61,16 @@ function OKServer(options) { // Application router app.use(router); // Add services - if (services.image) { - app.use('/_services/image', services.image.middleware()); - } + Object.keys(services).forEach(function(key){ + app.use('/_services/' + key, services[key].middleware()); + }) // Make sure this lady is last. Checks whether the desired // route has a trailing-slash counterpart and redirects there app.use(slash()); - - /** - * Create a handler which redirect all requests to - * the same route with a trailing slash appended - */ - function redirect(routeNoSlash) { - return function(req, res) { - res.redirect(301, routeNoSlash + '/'); - } - } + // Otherwise it's a 404 + app.use(function(req, res) { + error(req, res, 404)(new Error('No matching route')); + }); } OKServer.prototype.listen = function listen(port) { diff --git a/app/node_modules/okservices/index.js b/app/node_modules/okservices/index.js deleted file mode 100644 index 46f7ffd..0000000 --- a/app/node_modules/okservices/index.js +++ /dev/null @@ -1,41 +0,0 @@ -var skipper = require('skipper'); - -function OKImageService(options) { - if (!(this instanceof OKImageService)) return new OKImageService(options); - options = options || {}; - if (!options.express) - throw new Error('Express not provided to OKImageService'); - if (!options.s3) - throw new Error('S3 configuration not provided to OKImageService'); - var express = options.express; - - var router = express.Router(); - - router.use(skipper()); - - router.post('/', function(req, res) { - // req should have a method `file` on it which is - // provided by skipper. Use that to do AWS stuff - req.file('image').upload({ - adapter: require('skipper-s3'), - key: options.s3.key, - secret: options.s3.secret, - bucket: options.s3.bucket, - headers: { - 'x-amz-acl': 'public-read' - } - }, function (err, uploadedFiles) { - res.json(uploadedFiles); - }); - }); - - this._middleware = router; -} - -OKImageService.prototype.middleware = function() { - return this._middleware; -}; - -module.exports = { - OKImageService: OKImageService -}; diff --git a/app/node_modules/okservices/install.sh b/app/node_modules/okservices/install.sh new file mode 100755 index 0000000..5ffd898 --- /dev/null +++ b/app/node_modules/okservices/install.sh @@ -0,0 +1 @@ +for i in ok* ; do cd $i ; npm install; cd .. ; done ; cd ../.. diff --git a/app/node_modules/okservices/oks3/index.js b/app/node_modules/okservices/oks3/index.js new file mode 100644 index 0000000..cc40b71 --- /dev/null +++ b/app/node_modules/okservices/oks3/index.js @@ -0,0 +1,145 @@ +var upload = require("./upload") +var multer = require('multer') + +// Hack to prevent this god-forsaken module from crashing our shit +var d = require('domain').create() +d.on('error', function (err) { + console.log(err) + console.error('Stupid error in S3 upload. Upload probably prematurely canceled') +}) + +function OKS3(options) { + if (!(this instanceof OKS3)) return new OKS3(options); + options = options || {}; + if (!options.express) + throw new Error('Express not provided to OKS3'); + if (!options.s3) + throw new Error('S3 configuration not provided to OKS3'); + + if (!options.s3.image) options.s3.image = {} + if (!options.s3.audio) options.s3.audio = {} + if (!options.s3.video) options.s3.video = {} + + // Make sure maxbytes property is there - it can be a number, + // or zero/undefined (for no maximum upload size) + if (options.s3.maxbytes) { + if (! ('maxbytes' in options.s3.image)) + options.s3.image.maxbytes = options.s3.maxbytes + if (! ('maxbytes' in options.s3.video)) + options.s3.video.maxbytes = options.s3.maxbytes + if (! ('maxbytes' in options.s3.audio)) + options.s3.audio.maxbytes = options.s3.maxbytes + } + if (typeof options.s3.image.allowed !== "boolean") + options.s3.image.allowed = true + if (typeof options.s3.video.allowed !== "boolean") + options.s3.video.allowed = false + if (typeof options.s3.audio.allowed !== "boolean") + options.s3.audio.allowed = false + + upload.init({ + key: options.s3.key, + secret: options.s3.secret, + bucket: options.s3.bucket, + }) + + var express = options.express; + + var router = express.Router(); + + var mult = multer() + + router.post('/image', mult.single('image'), function(req, res) { + d.run(function () { + + if (! options.s3.image.allowed) { + return res.status(500).json({ error: "Image uploading not permitted" }) + } + + upload.put({ + file: req.file, + preserveFilename: options.s3.image.preserveFilename, + dirname: options.s3.dirname, + types: { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + }, + unacceptable: function(err){ + res.json({ error: err }) + }, + success: function(url){ + res.json({ url: url }) + } + }) + + }); + }); + + router.post('/audio', mult.single('audio'), function(req, res) { + d.run(function () { + + if (! options.s3.image.allowed) { + return res.status(500).json({ error: "Audio uploading not permitted" }) + } + + upload.put({ + file: req.file, + preserveFilename: options.s3.audio.preserveFilename, + dirname: options.s3.dirname, + types: { + 'audio/mp3': 'mp3', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/flac': 'flac', + }, + unacceptable: function(err){ + res.json({ error: err }) + }, + success: function(url){ + res.json({ url: url }) + } + }) + + }); + }); + + router.post('/video', mult.single('video'), function(req, res) { + d.run(function () { + + if (! options.s3.image.allowed) { + return res.status(500).json({ error: "Video uploading not permitted" }) + } + + upload.put({ + file: req.file, + preserveFilename: options.s3.video.preserveFilename, + dirname: options.s3.dirname, + types: { + 'video/mp4': 'mp4', + 'video/webm': 'webm', + }, + unacceptable: function(err){ + res.json({ error: err }) + }, + success: function(url){ + res.json({ url: url }) + } + }) + + }); + }); + + function preserveFilename (stream, cb){ + cb(null, stream.filename) + } + + this._middleware = router; +} + +OKS3.prototype.middleware = function() { + return this._middleware; +}; + +module.exports = OKS3 diff --git a/app/node_modules/okservices/oks3/package.json b/app/node_modules/okservices/oks3/package.json new file mode 100644 index 0000000..61da414 --- /dev/null +++ b/app/node_modules/okservices/oks3/package.json @@ -0,0 +1,16 @@ +{ + "name": "oks3", + "version": "1.0.0", + "description": "s3 wassup", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "OKFocus", + "license": "None", + "dependencies": { + "knox": "^0.9.2", + "multer": "^1.1.0", + "node-uuid": "^1.4.7" + } +} diff --git a/app/node_modules/okservices/oks3/upload.js b/app/node_modules/okservices/oks3/upload.js new file mode 100644 index 0000000..517d5f7 --- /dev/null +++ b/app/node_modules/okservices/oks3/upload.js @@ -0,0 +1,79 @@ + +var knox = require('knox') +var uuid = require('node-uuid') + +var s3 + +var acceptableuploadTypes = { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', +} + +module.exports = {} + +module.exports.init = function (opt){ + s3 = knox.createClient({ + key: opt.key, + secret: opt.secret, + bucket: opt.bucket, + }) +} + +module.exports.put = function (opt) { + var filename + var err + var now = new Date() + + var file = opt.file + + var types = opt.types || acceptableuploadTypes + var extension = types[file.mimetype] + + if (opt.preserveFilename) { + filename = file.originalname + } + else { + filename = uuid.v1() + "." + extension; + } + + var remote_path = "/" + opt.dirname + "/" + filename + + if (! extension) { + err = "Unacceptable filetype." + } + else if (opt.maxSize && file.size > opt.maxSize) { + err = "File too large. Uploads can be a maximum of " + opt.maxSize + " bytes." + } + + if (err) { + console.error(">>>", err) + opt.unacceptable && opt.unacceptable(err) + return + } + + opt.acceptable && opt.acceptable(err) + + // console.log("upload >", remote_path) + s3.putBuffer(file.buffer, remote_path, { + 'Content-Length': file.size, + 'Content-Type': file.mimetype, + 'x-amz-acl': 'public-read' + }, function(err, s3res) { + if (err || s3res.statusCode !== 200) { + console.error(err); + if (s3res && s3res.resume) { + s3res.resume() + } + return; + } + + var file_url = s3res.url || s3res.req.url + + opt.success && opt.success(file_url) + }).on('error', function(err, s3res){ + console.error(err) + s3res && s3res.resume && s3res.resume() + }) +} diff --git a/app/node_modules/okservices/oktwitter/Readme.md b/app/node_modules/okservices/oktwitter/Readme.md new file mode 100644 index 0000000..def73db --- /dev/null +++ b/app/node_modules/okservices/oktwitter/Readme.md @@ -0,0 +1,6 @@ +# oktwitter + +## Service to allow auth with Twitter API + +Requests to this service proxy to the twitter API, adding proper auth +credentials along the way diff --git a/app/node_modules/okservices/oktwitter/index.js b/app/node_modules/okservices/oktwitter/index.js new file mode 100644 index 0000000..ec4945d --- /dev/null +++ b/app/node_modules/okservices/oktwitter/index.js @@ -0,0 +1,48 @@ +var Twit = require('twit') + +/** + * Proxy to Twitter API adding auth creds + * TODO Technically can be abused by anyone right now. + * Should add some sort of same origin policy. + */ +function OKTwitter (options) { + if (!(this instanceof OKTwitter)) return new OKTwitter(options) + options = options || {} + if (!options.express) + throw new Error('Express not provided to OKTwitter'); + if (!options.credentials) + throw new Error('Twitter credentials not provided to OKTwitter'); + + var express = options.express + var router = express.Router() + var creds = options.credentials + var twitter = new Twit({ + consumer_key: creds.consumerKey, + consumer_secret: creds.consumerSecret, + access_token: creds.accessToken, + access_token_secret: creds.accessTokenSecret, + }) + + router.get('*', function (req, res) { + twitter.get(req.path.slice(1), req.query, function (err, data) { + if (err) { + res.status(err.statusCode) + res.send(err.twitterReply) + } else { + res.json(data) + } + }) + }) + + router.post('*', function (req, res) { + throw new Error('Twitter POST requests not implemented') + }) + + this._router = router +} + +OKTwitter.prototype.middleware = function () { + return this._router +} + +module.exports = OKTwitter diff --git a/app/node_modules/okservices/oktwitter/package.json b/app/node_modules/okservices/oktwitter/package.json new file mode 100644 index 0000000..ddee2f9 --- /dev/null +++ b/app/node_modules/okservices/oktwitter/package.json @@ -0,0 +1,13 @@ +{ + "name": "oktwitter", + "version": "1.0.0", + "description": "Allows auth to Twitter API", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "OKFocus", + "dependencies": { + "twit": "^2.1.1" + } +} diff --git a/app/node_modules/okservices/okwebhook/index.js b/app/node_modules/okservices/okwebhook/index.js new file mode 100644 index 0000000..d04e662 --- /dev/null +++ b/app/node_modules/okservices/okwebhook/index.js @@ -0,0 +1,83 @@ + +/** + * Service which will listen for a Github webhook, fired on push. + * This service can be used to rebuild / restart the app automatically + * when new code is pushed. + */ + +var crypto = require('crypto') +var exec = require('child_process').exec +var path = require('path') + +function OKWebhook (options) { + if (!(this instanceof OKWebhook)) return new OKWebhook(options) + options = options || {} + if (!options.express) + throw new Error('Express not provided to OKWebhook'); + if (!options.config) + throw new Error('Configuration not provided to OKWebhook'); + if (options.config.active && !options.config.secret) + throw new Error('Github secret not provided to OKWebhook'); + if (options.config.active && !options.config.command) + throw new Error('Build command not provided to OKWebhook'); + + var express = options.express + var router = express.Router() + var config = options.config + + var secret = config.secret + var command = config.command + + router.get('/', function (req, res) { + res.send('GET not supported') + }) + + router.post('/', getBody, function (req, res) { + if (!config.active) + return + console.log("OKWebhook received push") + var event = req.headers['x-github-event'] + if (event !== "push") { + return res.sendStatus(500) + } + var sig = req.headers['x-hub-signature'].split('=')[1] + var text = req.rawBody + var hash = crypto.createHmac('sha1', secret).update(text).digest('hex') + if (hash !== sig) { + return res.sendStatus(500) + } + res.sendStatus(200) + var cwd = path.dirname(command) + exec(command, { cwd: cwd }, function(err, stdout, stderr){ + // may not fire if process was restarted.. + console.log(process.env) + console.log(stdout) + }) + }) + + function getBody (req, res, next) { + req.rawBody = '' + // req.setEncoding('utf8') + + req.on('data', function(chunk) { + req.rawBody += chunk + }) + + req.on('end', function() { + try { + req.body = JSON.parse(req.rawBody) + } catch (e) { + return res.sendStatus(500) + } + next() + }) + } + + this._router = router +} + +OKWebhook.prototype.middleware = function () { + return this._router +} + +module.exports = OKWebhook diff --git a/app/node_modules/okservices/okwebhook/package.json b/app/node_modules/okservices/okwebhook/package.json new file mode 100644 index 0000000..0436f01 --- /dev/null +++ b/app/node_modules/okservices/okwebhook/package.json @@ -0,0 +1,11 @@ +{ + "name": "okwebhook", + "version": "1.0.0", + "description": "webhook to receive pushes from github", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "okfocus <frontdesk@okfoc.us>", + "license": "LNT" +} diff --git a/app/node_modules/okservices/package.json b/app/node_modules/okservices/package.json index f27247b..6231499 100644 --- a/app/node_modules/okservices/package.json +++ b/app/node_modules/okservices/package.json @@ -4,12 +4,8 @@ "description": "providing very good services", "main": "index.js", "scripts": { + "postinstall": "./install.sh", "test": "echo \"Error: no test specified\" && exit 1" }, - "author": "OKFocus", - "license": "None", - "dependencies": { - "skipper": "^0.5.5", - "skipper-s3": "^0.5.5" - } + "author": "OKFocus" } diff --git a/app/node_modules/oktemplate/index.js b/app/node_modules/oktemplate/index.js index a37f78e..ae9d1b1 100644 --- a/app/node_modules/oktemplate/index.js +++ b/app/node_modules/oktemplate/index.js @@ -4,6 +4,7 @@ var path = require('path'); var glob = require('glob'); var stringify = require('json-to-html'); var Liquid = require('liquid-node'); +var chokidar = require('chokidar'); /** * Define any custom liquid filters here. @@ -14,13 +15,24 @@ var filters = { * Return a string formatted version of a JSON object. * Useful for quick debugging of template data. */ - stringify: function(obj) { + prettify: function(obj) { try { return '<pre>' + stringify(obj) + '</pre>'; } catch (e) { + return 'Error prettifying'; + } + }, + + /** + * Serialize Javascript objects into a JSON string + */ + stringify: function(obj) { + try { + return JSON.stringify(obj); + } catch (e) { return 'Error stringifying'; } - } + }, }; @@ -29,19 +41,56 @@ var filters = { */ function OKTemplateRepo(options) { options = options || {}; + var self = this; var root = this._root = options.root || 'templates'; - var ext = 'liquid'; + // TODO Support more templates? + var ext = this._ext = 'liquid'; var cache = this._cache = {}; var engine = this._engine = new Liquid.Engine; + var debug = options.debug; + var globString = this._globString = root + '/*.' + ext engine.registerFilters(filters); engine.fileSystem = new Liquid.LocalFileSystem(root, ext); + this._populateCache(engine, cache, ext); + + if (debug) { + var watcher = chokidar.watch(globString); + watcher.on('change', reloadTemplate); + watcher.on('add', reloadTemplate); + } + + function reloadTemplate(path) { + self._loadTemplate(path); + } } OKTemplateRepo.prototype.getTemplate = function getTemplate(name) { return this._cache[name]; } +OKTemplateRepo.prototype._loadTemplate = function loadTemplate(filePath) { + var engine = this._engine + var name = path.basename(filePath, '.' + this._ext); + var templateString = fs.readFileSync(filePath, {encoding: 'UTF8'}); + if (!this._cache[name]) + this._cache[name] = {}; + + var template = this._cache[name] + template.name = name; + template.templateString = templateString; + template.render = render + + function render(data) { + return Q.promise(function(resolve, reject) { + // TODO Not sure if this caches parsed templates behind the scenes? + engine.parseAndRender(templateString, data) + .then(resolve) + .catch(reject); + }); + } +} + /** * Go through our template dir and read the template files * into memory as strings. @@ -49,22 +98,9 @@ OKTemplateRepo.prototype.getTemplate = function getTemplate(name) { */ OKTemplateRepo.prototype._populateCache = function _populateCache(engine, cache, ext) { var self = this; - var files = glob.sync(this._root + '/*.' + ext); - files.forEach(function eachFile(file) { - var name = path.basename(file, '.' + ext); - var templateString = fs.readFileSync(file, {encoding: 'UTF8'}); - cache[name] = { - name: name, - templateString: templateString, - render: function(data) { - return Q.promise(function(resolve, reject) { - // TODO Not sure if this caches parsed templates behind the scenes? - engine.parseAndRender(templateString, data) - .then(resolve) - .catch(reject); - }); - } - } + var files = glob.sync(this._globString); + files.forEach(function eachFile(path) { + self._loadTemplate(path); }); } diff --git a/app/node_modules/oktemplate/package.json b/app/node_modules/oktemplate/package.json index 70e94e3..61d90e8 100644 --- a/app/node_modules/oktemplate/package.json +++ b/app/node_modules/oktemplate/package.json @@ -10,6 +10,7 @@ "license": "None", "dependencies": { "bluebird": "^2.9.21", + "chokidar": "^1.0.3", "glob": "^5.0.3", "json-to-html": "^0.1.2", "liquid-node": "^2.5.0", diff --git a/app/node_modules/okview/index.js b/app/node_modules/okview/index.js index 6eebe6e..5f99d59 100644 --- a/app/node_modules/okview/index.js +++ b/app/node_modules/okview/index.js @@ -21,11 +21,14 @@ function OKView(options) { if (!options.template) throw new Error('No template provided to OKView.'); if (!options.meta) - throw new Error('No meta resource provided to OKView'); + throw new Error('No metadata provided to OKView'); if (!options.route) throw new Error('No route provided to OKView'); + if (!options.errorHandler) + throw new Error('No error handler provided to OKView'); var route = options.route; var mount = options.mount || 'get'; + var error = this._error = options.errorHandler; this._template = options.template; var meta = this._meta = options.meta; var queries = this._queries = options.queries || []; @@ -47,16 +50,9 @@ function OKView(options) { enumerable: true }); - this._middleware = createMiddleware(this); - this._fetchTemplateData = unbound ? fetchUnbound : fetchBound; - - function fetchUnbound(id) { - return fetchTemplateData(meta, queries, id) - } - - function fetchBound() { - return fetchTemplateData(meta, queries); - } + this._middleware = unbound + ? unboundMiddleware(this, meta, queries, error) + : boundMiddleware(this, meta, queries, error); } OKView.prototype.middleware = function() { @@ -66,39 +62,20 @@ OKView.prototype.middleware = function() { OKView.prototype.render = function(req, res, data) { this._template.render(data).then(function(html) { res.send(html); - }).fail(errorHandler(req, res, data)); -}; - -OKView.prototype.fetchTemplateData = function() { - return this._fetchTemplateData.apply(this, arguments); + }).fail(this._error(req, res, 500)); }; /** - * Unbound views need different middleware to resolve requests - */ -function createMiddleware(view) { - if (view.unbound) { - return unboundMiddleware(view); - } else { - return boundMiddleware(view); - } -} - -// Note that these middleware do not call next -// and should thus always be added at the end of the -// middleware chain. - -/** * Creates middleware for a view which does not * yet have a resource id associated with it */ -function unboundMiddleware(view) { +function unboundMiddleware(view, meta, queries, error) { var paramName = getParamName(view.route); return function(req, res, next) { var id = req.params[paramName]; - view.fetchTemplateData(id).then(function(data) { + fetchTemplateData(meta, queries, id).then(function(data) { view.render(req, res, data); - }).fail(errorHandler(req, res, next)); + }).fail(failHandler(req, res, error)); }; } @@ -106,20 +83,22 @@ function unboundMiddleware(view) { * Creates middleware for a view which already * has a resource id associated with it */ -function boundMiddleware(view) { +function boundMiddleware(view, meta, queries, error) { return function(req, res, next) { - view.fetchTemplateData().then(function(data) { + fetchTemplateData(meta, queries).then(function(data) { view.render(req, res, data); - }).fail(errorHandler(req, res, next)); + }).fail(failHandler(req, res, error)); }; } -/** - * TODO BS error handling for now - */ -function errorHandler(req, res, next) { - return function(err) { - res.send(err.stack); +function failHandler(req, res, error) { + return function (err) { + // TODO Use custom exception type + if (err.message === 'No resource found') { + error(req, res, 404)(err); + } else { + error(req, res, 500)(err); + } } } @@ -138,49 +117,56 @@ function getParamName(route) { * and returns a promise for an object merging all queried * data, pluralizing keys where necessary. * - * Lil bit convoluted, sorry. + * Pretty convoluted, sorry. */ function fetchTemplateData(meta, queries, id) { + // If there's only one query, we assume it is for a single + // resource and will resolve errors if no data is found + var single = queries && queries.length === 1; return Q.promise(function(resolve, reject) { - return Q.all( - [meta.get()].concat(queries.map(function(query) { - return query.get(id); - }))) - .then(function(results) { - var metadata = results.shift(); - var normalized = results.reduce(function(cache, result, i) { - // Could be just some rogue request - if (!result) { - return cache; - } - var resource = queries[i].resource; - var type = queries[i].type; - var manyResult = isarray(result); - // Inform template of ID in generic field - if (manyResult) { - result = result.map(function(data) { - return assign({}, data, {id: data[resource.idField]}) - }); - } else { - result = assign({}, result, {id: result[resource.idField]}); - } - // If we have a lot of results for a certain type, - // we pluralize the key and yield an array of results - if (cache[type] || manyResult) { - var plural = pluralize(type); - delete cache[type]; - cache[plural] = []; - if (manyResult) { - cache[plural] = cache[plural].concat(result); + return Q.all(queries.map(function(query) { + return query.get(id); + })).then(function(results) { + if (single && !results[0]) { + reject(new Error('No resource found')); + } else { + var normalized = results.reduce(function(cache, result, i) { + // Could be just some rogue request + if (!result) { + return cache; + } + var query = queries[i] + var resource = query.resource; + var type = query.as || query.type; + var manyResult = isarray(result); + var groupBy = query.groupBy + // If we have a lot of results for a certain type, + // we pluralize the key and yield an array of results + if (cache[type] || manyResult || groupBy) { + var plural = pluralize(type); + delete cache[type]; + cache[plural] = []; + // Pluralize grouped field + if (query.groupBy) { + result = Object.keys(result).reduce(function(acc, key) { + acc[pluralize(key)] = result[key] + return acc + }, {}) + } + if (manyResult) { + cache[plural] = cache[plural].concat(result); + } else if (groupBy) { + cache[plural] = result + } else { + cache[plural].push(result); + } } else { - cache[plural].push(result); + cache[type] = result; } - } else { - cache[type] = result; - } - return cache; - }, {meta: metadata}); - resolve(normalized); + return cache; + }, {meta: meta}); + resolve(normalized); + } }).fail(reject); }); } |
