diff --git a/core/server/helpers/get.js b/core/server/helpers/get.js new file mode 100644 index 0000000000..affd733c02 --- /dev/null +++ b/core/server/helpers/get.js @@ -0,0 +1,101 @@ +// # Get Helper +// Usage: `{{#get "posts" limit="5"}}`, `{{#get "tags" limit="all"}}` +// Fetches data from the API +var _ = require('lodash'), + hbs = require('express-hbs'), + Promise = require('bluebird'), + errors = require('../errors'), + api = require('../api'), + resources, + get; + +// Endpoints that the helper is able to access +resources = ['posts', 'tags', 'users']; + +/** + * ## Is Browse + * Is this a Browse request or a Read request? + * @param {Object} context + * @param {Object} options + * @returns {boolean} + */ +function isBrowse(context, options) { + var browse = true; + + if (options.id || options.slug) { + browse = false; + } + + return browse; +} + +/** + * ## Parse Options + * Ensure options passed in make sense + * + * @param {Object} data + * @param {Object} options + * @returns {*} + */ +function parseOptions(data, options) { + if (_.isArray(options.tag)) { + options.tag = _.pluck(options.tag, 'slug').join(','); + } + + if (_.isObject(options.author)) { + options.author = options.author.slug; + } + + return options; +} + +/** + * ## Get + * @param {Object} context + * @param {Object} options + * @returns {Promise} + */ +get = function get(context, options) { + options = options || {}; + options.hash = options.hash || {}; + options.data = options.data || {}; + + var self = this, + data = hbs.handlebars.createFrame(options.data), + apiOptions = _.omit(options.hash, 'context'), + apiMethod; + + if (!options.fn) { + data.error = 'Get helper must be called as a block'; + errors.logWarn(data.error); + return Promise.resolve(); + } + + if (!_.contains(resources, context)) { + data.error = 'Invalid resource given to get helper'; + errors.logWarn(data.error); + return Promise.resolve(options.inverse(self, {data: data})); + } + + // Determine if this is a read or browse + apiMethod = isBrowse(context, apiOptions) ? api[context].browse : api[context].read; + // Parse the options we're going to pass to the API + apiOptions = parseOptions(this, apiOptions); + + return apiMethod(apiOptions).then(function success(result) { + result = _.merge(self, result); + if (_.isEmpty(result[context])) { + return options.inverse(self, {data: data}); + } + + return options.fn(result, { + data: data, + blockParams: [result[context]] + }); + }).catch(function error(err) { + data.error = err.message; + return options.inverse(self, {data: data}); + }); +}; + +module.exports = get; diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index 5f75a3c97d..3fae2935d7 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -21,6 +21,7 @@ coreHelpers.date = require('./date'); coreHelpers.encode = require('./encode'); coreHelpers.excerpt = require('./excerpt'); coreHelpers.foreach = require('./foreach'); +coreHelpers.get = require('./get'); coreHelpers.ghost_foot = require('./ghost_foot'); coreHelpers.ghost_head = require('./ghost_head'); coreHelpers.image = require('./image'); @@ -117,6 +118,7 @@ registerHelpers = function (adminHbs) { registerAsyncThemeHelper('post_class', coreHelpers.post_class); registerAsyncThemeHelper('next_post', coreHelpers.next_post); registerAsyncThemeHelper('prev_post', coreHelpers.prev_post); + registerAsyncThemeHelper('get', coreHelpers.get); // Register admin helpers registerAdminHelper('asset', coreHelpers.asset); diff --git a/core/test/unit/server_helpers/get_spec.js b/core/test/unit/server_helpers/get_spec.js new file mode 100644 index 0000000000..899cb18fbf --- /dev/null +++ b/core/test/unit/server_helpers/get_spec.js @@ -0,0 +1,312 @@ +/*globals describe, before, beforeEach, afterEach, it*/ +/*jshint expr:true*/ +var should = require('should'), + sinon = require('sinon'), + hbs = require('express-hbs'), + Promise = require('bluebird'), + utils = require('./utils'), + +// Stuff we are testing + handlebars = hbs.handlebars, + helpers = require('../../../server/helpers'), + api = require('../../../server/api'); + +describe('{{#get}} helper', function () { + var sandbox; + + before(function () { + utils.loadHelpers(); + }); + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('has loaded get block helper', function () { + should.exist(handlebars.helpers.get); + }); + + describe('posts', function () { + var testPostsArr = [ + {id: 1, title: 'Test Post 1', author: 'cameron'}, + {id: 2, title: 'Test Post 2', author: 'cameron', featured: true}, + {id: 3, title: 'Test Post 3', tags: [{slug: 'test'}]}, + {id: 4, title: 'Test Post 4'} + ]; + beforeEach(function () { + var browseStub = sandbox.stub(api.posts, 'browse'), + readStub = sandbox.stub(api.posts, 'read'); + + browseStub.returns(new Promise.resolve({posts: testPostsArr})); + browseStub.withArgs({limit: '3'}).returns(new Promise.resolve({posts: testPostsArr.slice(0, 3)})); + browseStub.withArgs({limit: '1'}).returns(new Promise.resolve({posts: testPostsArr.slice(0, 1)})); + browseStub.withArgs({tag: 'test'}).returns(new Promise.resolve({posts: testPostsArr.slice(2, 3)})); + browseStub.withArgs({tag: 'none'}).returns(new Promise.resolve({posts: []})); + browseStub.withArgs({author: 'cameron'}).returns(new Promise.resolve({posts: testPostsArr.slice(0, 2)})); + browseStub.withArgs({featured: 'true'}).returns(new Promise.resolve({posts: testPostsArr.slice(2, 3)})); + readStub.withArgs({id: '2'}).returns(new Promise.resolve({posts: testPostsArr.slice(1, 2)})); + }); + + it('should handle default browse posts call', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {}, fn: fn, inverse: inverse} + ).then(function () { + fn.called.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.eql(testPostsArr); + fn.firstCall.args[0].posts.should.have.lengthOf(4); + inverse.called.should.be.false; + + done(); + }).catch(done); + }); + + it('should handle browse posts call with limit 3', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {limit: '3'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(3); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 3)); + inverse.called.should.be.false; + + done(); + }).catch(done); + }); + + it('should handle browse posts call with limit 1', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {limit: '1'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(1); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 1)); + inverse.called.should.be.false; + + done(); + }).catch(done); + }); + + it('should handle browse posts call with limit 1', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {limit: '1'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(1); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 1)); + inverse.called.should.be.false; + + done(); + }).catch(done); + }); + + it('should handle browse post call with explicit tag', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {tag: 'test'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(1); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(2, 3)); + inverse.called.should.be.false; + done(); + }).catch(done); + }); + + it('should handle browse post call with relative tag', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {tag: [{slug: 'test'}]}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(1); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(2, 3)); + inverse.called.should.be.false; + done(); + }).catch(done); + }); + + it('should handle browse post call with explicit author', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {author: 'cameron'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(2); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 2)); + inverse.called.should.be.false; + done(); + }).catch(done); + }); + + it('should handle browse post call with relative author', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {author: {slug: 'cameron'}}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(2); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(0, 2)); + inverse.called.should.be.false; + done(); + }).catch(done); + }); + + it('should handle browse post call with featured:true', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {featured: 'true'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(1); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(2, 3)); + inverse.called.should.be.false; + done(); + }).catch(done); + }); + + it('should handle read post by id call', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {id: '2'}, fn: fn, inverse: inverse} + ).then(function () { + fn.calledOnce.should.be.true; + fn.firstCall.args[0].should.be.an.Object.with.property('posts'); + fn.firstCall.args[0].posts.should.have.lengthOf(1); + fn.firstCall.args[0].posts.should.eql(testPostsArr.slice(1, 2)); + inverse.called.should.be.false; + + done(); + }).catch(done); + }); + + it('should handle empty result set', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {tag: 'none'}, fn: fn, inverse: inverse} + ).then(function () { + fn.called.should.be.false; + inverse.calledOnce.should.be.true; + inverse.firstCall.args[1].should.be.an.Object.and.have.property('data'); + inverse.firstCall.args[1].data.should.be.an.Object.and.not.have.property('error'); + + done(); + }).catch(done); + }); + }); + + describe('general error handling', function () { + it('should return an error for an unknown resource', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'magic', + {hash: {}, fn: fn, inverse: inverse} + ).then(function () { + fn.called.should.be.false; + inverse.calledOnce.should.be.true; + inverse.firstCall.args[1].should.be.an.Object.and.have.property('data'); + inverse.firstCall.args[1].data.should.be.an.Object.and.have.property('error'); + inverse.firstCall.args[1].data.error.should.eql('Invalid resource given to get helper'); + + done(); + }).catch(done); + }); + + it('should handle error from the API', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts', + {hash: {tag: 'thing!'}, fn: fn, inverse: inverse} + ).then(function () { + fn.called.should.be.false; + inverse.calledOnce.should.be.true; + inverse.firstCall.args[1].should.be.an.Object.and.have.property('data'); + inverse.firstCall.args[1].data.should.be.an.Object.and.have.property('error'); + inverse.firstCall.args[1].data.error.should.match(/^Validation/); + + done(); + }).catch(done); + }); + + it('should show warning for call without any options', function (done) { + var fn = sinon.spy(), + inverse = sinon.spy(); + + helpers.get.call( + {}, + 'posts' + ).then(function () { + fn.called.should.be.false; + inverse.called.should.be.false; + + done(); + }).catch(done); + }); + }); +});