Add include parameter for posts API

closes #2609
- added include parameter to api.posts.*
- changed toJSON to omit objects that are not included
- added include parameter to admin
- added include parameter to frontend.js
- updated tests
- removed duplicate code from posts model

**Known Issue:** It is not possible to attach a tag using an ID.
This commit is contained in:
Sebastian Gierlinger 2014-04-27 18:58:34 +02:00
parent d089ddfe87
commit 1e62400465
13 changed files with 182 additions and 73 deletions

View File

@ -49,6 +49,7 @@
if (method === 'create' || method === 'update') {
options.data = JSON.stringify({posts: [this.attributes]});
options.contentType = 'application/json';
options.url = model.url() + '?include=tags';
}
return Backbone.Model.prototype.sync.apply(this, arguments);

View File

@ -3,7 +3,7 @@
'use strict';
Ghost.Models.Themes = Backbone.Model.extend({
url: Ghost.paths.apiRoot + '/themes'
url: Ghost.paths.apiRoot + '/themes/'
});
}());

View File

@ -36,7 +36,7 @@
blog: function () {
var posts = new Ghost.Collections.Posts();
NProgress.start();
posts.fetch({ data: { status: 'all', staticPages: 'all'} }).then(function () {
posts.fetch({ data: { status: 'all', staticPages: 'all', include: 'author'} }).then(function () {
Ghost.currentView = new Ghost.Views.Blog({ el: '#main', collection: posts });
NProgress.done();
});
@ -63,7 +63,7 @@
post.urlRoot = Ghost.paths.apiRoot + '/posts';
if (id) {
post.id = id;
post.fetch({ data: {status: 'all'}}).then(function () {
post.fetch({ data: {status: 'all', include: 'tags'}}).then(function () {
Ghost.currentView = new Ghost.Views.Editor({ el: '#main', model: post });
});
} else {

View File

@ -3,7 +3,9 @@ var when = require('when'),
dataProvider = require('../models'),
canThis = require('../permissions').canThis,
filteredUserAttributes = require('./users').filteredAttributes,
posts;
posts,
allowedIncludes = ['created_by', 'updated_by', 'published_by', 'author', 'tags', 'fields'];
function checkPostData(postData) {
if (_.isEmpty(postData) || _.isEmpty(postData.posts) || _.isEmpty(postData.posts[0])) {
@ -12,6 +14,19 @@ function checkPostData(postData) {
return when.resolve(postData);
}
function prepareInclude(include) {
var index;
include = _.intersection(include.split(","), allowedIncludes);
index = include.indexOf('author');
if (index !== -1) {
include[index] = 'author_id';
}
return include;
}
// ## Posts
posts = {
@ -24,13 +39,20 @@ posts = {
if (!this.user) {
options.status = 'published';
}
if (options.include) {
options.include = prepareInclude(options.include);
}
// **returns:** a promise for a page of posts in a json object
return dataProvider.Post.findPage(options).then(function (result) {
var i = 0,
omitted = result;
for (i = 0; i < omitted.posts.length; i = i + 1) {
omitted.posts[i].author = _.omit(omitted.posts[i].author, filteredUserAttributes);
if (!_.isNumber(omitted.posts[i].author)) {
omitted.posts[i].author = _.omit(omitted.posts[i].author, filteredUserAttributes);
}
}
return omitted;
});
@ -39,6 +61,7 @@ posts = {
// #### Read
// **takes:** an identifier (id or slug?)
read: function read(options) {
var include;
options = options || {};
// only published posts if no user is present
@ -46,15 +69,23 @@ posts = {
options.status = 'published';
}
if (options.include) {
include = prepareInclude(options.include);
delete options.include;
}
// **returns:** a promise for a single post in a json object
return dataProvider.Post.findOne(options).then(function (result) {
return dataProvider.Post.findOne(options, {include: include}).then(function (result) {
var omitted;
if (result) {
omitted = result.toJSON();
omitted.author = _.omit(omitted.author, filteredUserAttributes);
if (!_.isNumber(omitted.author)) {
omitted.author = _.omit(omitted.author, filteredUserAttributes);
}
return { posts: [ omitted ]};
}
return when.reject({code: 404, message: 'Post not found'});
});
@ -64,21 +95,30 @@ posts = {
// **takes:** a json object with all the properties which should be updated
edit: function edit(postData) {
// **returns:** a promise for the resulting post in a json object
var self = this;
var self = this,
include;
return canThis(self.user).edit.post(postData.id).then(function () {
return checkPostData(postData).then(function (checkedPostData) {
return dataProvider.Post.edit(checkedPostData.posts[0], {user: self.user});
if (postData.include) {
include = prepareInclude(postData.include);
}
return dataProvider.Post.edit(checkedPostData.posts[0], {user: self.user, include: include});
}).then(function (result) {
if (result) {
var omitted = result.toJSON();
omitted.author = _.omit(omitted.author, filteredUserAttributes);
if (!_.isNumber(omitted.author)) {
omitted.author = _.omit(omitted.author, filteredUserAttributes);
}
// If previously was not published and now is, signal the change
if (result.updated('status') !== result.get('status')) {
omitted.statusChanged = true;
}
return { posts: [ omitted ]};
}
return when.reject({code: 404, message: 'Post not found'});
});
}, function () {
@ -96,7 +136,9 @@ posts = {
return dataProvider.Post.add(checkedPostData.posts[0], {user: self.user});
}).then(function (result) {
var omitted = result.toJSON();
omitted.author = _.omit(omitted.author, filteredUserAttributes);
if (!_.isNumber(omitted.author)) {
omitted.author = _.omit(omitted.author, filteredUserAttributes);
}
if (omitted.status === 'published') {
// When creating a new post that is published right now, signal the change
omitted.statusChanged = true;
@ -134,7 +176,6 @@ posts = {
},
// #### Generate slug
// **takes:** a string to generate the slug from
generateSlug: function generateSlug(args) {

View File

@ -28,7 +28,7 @@ function getPostPage(options) {
if (!isNaN(postsPerPage) && postsPerPage > 0) {
options.limit = postsPerPage;
}
options.include = 'author,tags,fields';
return api.posts.browse(options);
}).then(function (page) {
@ -162,6 +162,8 @@ frontendControllers = {
// Sanitize params we're going to use to lookup the post.
var postLookup = _.pick(permalink.params, 'slug', 'id');
// Add author, tag and fields
postLookup.include = 'author,tags,fields';
// Query database to find post
return api.posts.read(postLookup);
@ -270,6 +272,8 @@ frontendControllers = {
if (pageParam) { options.page = pageParam; }
if (tagParam) { options.tag = tagParam; }
options.include = 'author,tags,fields';
return api.posts.browse(options).then(function (page) {
var title = result[0].value.value,

View File

@ -1,13 +1,13 @@
var Bookshelf = require('bookshelf'),
when = require('when'),
moment = require('moment'),
_ = require('lodash'),
uuid = require('node-uuid'),
config = require('../config'),
unidecode = require('unidecode'),
sanitize = require('validator').sanitize,
schema = require('../data/schema'),
validation = require('../data/validation'),
var Bookshelf = require('bookshelf'),
when = require('when'),
moment = require('moment'),
_ = require('lodash'),
uuid = require('node-uuid'),
config = require('../config'),
unidecode = require('unidecode'),
sanitize = require('validator').sanitize,
schema = require('../data/schema'),
validation = require('../data/validation'),
ghostBookshelf;
@ -15,7 +15,6 @@ var Bookshelf = require('bookshelf'),
ghostBookshelf = Bookshelf.ghost = Bookshelf.initialize(config().database);
ghostBookshelf.client = config().database.client;
// The Base Model which other Ghost objects will inherit from,
// including some convenience functions as static properties on the model.
ghostBookshelf.Model = ghostBookshelf.Model.extend({
@ -34,7 +33,14 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
},
initialize: function () {
var self = this;
var self = this,
options = arguments[1] || {};
// make options include available for toJSON()
if (options.include) {
this.include = _.clone(options.include);
}
this.on('creating', this.creating, this);
this.on('saving', function (model, attributes, options) {
return when(self.saving(model, attributes, options)).then(function () {
@ -98,15 +104,25 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
toJSON: function (options) {
var attrs = _.extend({}, this.attributes),
relations = this.relations;
self = this;
if (options && options.shallow) {
return attrs;
}
_.each(relations, function (relation, key) {
if (options && options.idOnly) {
return attrs.id;
}
_.each(this.relations, function (relation, key) {
if (key.substring(0, 7) !== '_pivot_') {
attrs[key] = relation.toJSON ? relation.toJSON() : relation;
// if include is set, expand to full object
// toMany relationships are included with ids if not expanded
if (_.contains(self.include, key)) {
attrs[key] = relation.toJSON();
} else if (relation.hasOwnProperty('length')) {
attrs[key] = relation.toJSON({idOnly: true});
}
}
});
@ -135,7 +151,14 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
*/
findAll: function (options) {
options = options || {};
return ghostBookshelf.Collection.forge([], {model: this}).fetch(options);
return ghostBookshelf.Collection.forge([], {model: this}).fetch(options).then(function (result) {
if (options.include) {
_.each(result.models, function (item) {
item.include = options.include;
});
}
return result;
});
},
browse: function () {
@ -149,7 +172,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
*/
findOne: function (args, options) {
options = options || {};
return this.forge(args).fetch(options);
return this.forge(args, {include: options.include}).fetch(options);
},
read: function () {

View File

@ -10,7 +10,6 @@ var _ = require('lodash'),
Tag = require('./tag').Tag,
Tags = require('./tag').Tags,
ghostBookshelf = require('./base'),
validation = require('../data/validation'),
xmlrpc = require('../xmlrpc'),
Post,
@ -29,22 +28,15 @@ Post = ghostBookshelf.Model.extend({
initialize: function () {
var self = this;
this.on('creating', this.creating, this);
ghostBookshelf.Model.prototype.initialize.apply(this, arguments);
this.on('saved', function (model, attributes, options) {
if (model.get('status') === 'published') {
xmlrpc.ping(model.attributes);
}
return self.updateTags(model, attributes, options);
});
this.on('saving', function (model, attributes, options) {
return when(self.saving(model, attributes, options)).then(function () {
return self.validate(model, attributes, options);
});
});
},
validate: function () {
validation.validateSchema(this.tableName, this.toJSON());
},
saving: function (newPage, attr, options) {
@ -57,13 +49,16 @@ Post = ghostBookshelf.Model.extend({
// keep tags for 'saved' event and deduplicate upper/lowercase tags
tagsToCheck = this.get('tags');
this.myTags = [];
_.each(tagsToCheck, function (item) {
for (i = 0; i < self.myTags.length; i = i + 1) {
if (self.myTags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
return;
if (_.isObject(self.myTags)) {
for (i = 0; i < self.myTags.length; i = i + 1) {
if (self.myTags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) {
return;
}
}
self.myTags.push(item);
}
self.myTags.push(item);
});
ghostBookshelf.Model.prototype.saving.call(this, newPage, attr, options);
@ -201,10 +196,22 @@ Post = ghostBookshelf.Model.extend({
},
// Relations
author: function () {
author_id: function () {
return this.belongsTo(User, 'author_id');
},
created_by: function () {
return this.belongsTo(User, 'created_by');
},
updated_by: function () {
return this.belongsTo(User, 'updated_by');
},
published_by: function () {
return this.belongsTo(User, 'published_by');
},
tags: function () {
return this.belongsToMany(Tag);
},
@ -228,8 +235,7 @@ Post = ghostBookshelf.Model.extend({
// Extends base model findAll to eager-fetch author and user relationships.
findAll: function (options) {
options = options || {};
options.withRelated = [ 'author', 'tags', 'fields' ];
options.withRelated = _.union([ 'tags', 'fields' ], options.include);
return ghostBookshelf.Model.findAll.call(this, options);
},
@ -246,7 +252,9 @@ Post = ghostBookshelf.Model.extend({
delete args.status;
}
options.withRelated = [ 'author', 'tags', 'fields' ];
// Add related objects
options.withRelated = _.union([ 'tags', 'fields' ], options.include);
return ghostBookshelf.Model.findOne.call(this, args, options);
},
@ -273,7 +281,7 @@ Post = ghostBookshelf.Model.extend({
findPage: function (opts) {
var postCollection = Posts.forge(),
tagInstance = opts.tag !== undefined ? Tag.forge({slug: opts.tag}) : false,
permittedOptions = ['page', 'limit', 'status', 'staticPages'];
permittedOptions = ['page', 'limit', 'status', 'staticPages', 'include'];
// sanitize opts so we are not automatically passing through any and all
// query strings to Bookshelf / Knex. Although the API requires auth, we
@ -311,8 +319,8 @@ Post = ghostBookshelf.Model.extend({
postCollection.query('where', opts.where);
}
// Fetch related models
opts.withRelated = [ 'author', 'tags', 'fields' ];
// Add related objects
opts.withRelated = _.union([ 'tags', 'fields' ], opts.include);
// If a query param for a tag is attached
// we need to fetch the tag model to find its id
@ -338,7 +346,6 @@ Post = ghostBookshelf.Model.extend({
.query('join', 'posts_tags', 'posts_tags.post_id', '=', 'posts.id')
.query('where', 'posts_tags.tag_id', '=', tagInstance.id);
}
return postCollection
.query('limit', opts.limit)
.query('offset', opts.limit * (opts.page - 1))
@ -384,6 +391,12 @@ Post = ghostBookshelf.Model.extend({
pagination['next'] = null;
pagination['prev'] = null;
if (opts.include) {
_.each(postCollection.models, function (item) {
item.include = opts.include;
});
}
data['posts'] = postCollection.toJSON();
data['meta'] = meta;
meta['pagination'] = pagination;
@ -441,7 +454,6 @@ Post = ghostBookshelf.Model.extend({
edit: function (editedPost, options) {
var self = this;
options = options || {};
return ghostBookshelf.Model.edit.call(this, editedPost, options).then(function (post) {
if (post) {
return self.findOne({status: 'all', id: post.id}, options)

View File

@ -17,7 +17,7 @@ CasperTest.begin("Content screen is correct", 22, function suite(test) {
casper.thenClick('.js-publish-button');
casper.waitForResource(/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
test.assertExists('.notification-success', 'got success notification');
});
@ -97,7 +97,7 @@ CasperTest.begin('Content list shows correct post status', 8, function testStati
test.assert(false, 'publish button did not change to published');
});
casper.waitForResource(/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
test.assertExists('.notification-success', 'got success notification');
});
@ -159,7 +159,7 @@ CasperTest.begin('Preview shows correct header for published post', 7, function
test.assert(false, 'publish button did not change to published');
});
casper.waitForResource(/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
test.assertExists('.notification-success', 'got success notification');
});
@ -196,7 +196,7 @@ CasperTest.begin('Delete post modal', 9, function testDeleteModal(test) {
casper.thenClick('.js-publish-button');
casper.waitForResource(/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
test.assertExists('.notification-success', 'got success notification');
});

View File

@ -33,7 +33,7 @@ CasperTest.begin("Ghost editor is correct", 10, function suite(test) {
casper.thenClick('.js-publish-button');
casper.waitForResource(/\/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
var urlRegExp = new RegExp("^" + escapedUrl + "ghost\/editor\/[0-9]*");
test.assertUrlMatch(urlRegExp, 'got an id on our URL');
test.assertExists('.notification-success', 'got success notification');
@ -376,7 +376,7 @@ CasperTest.begin('Publish menu - existing post', 22, function suite(test) {
// Create a post in draft status
casper.thenClick('.js-publish-button');
casper.waitForResource(/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
var urlRegExp = new RegExp("^" + escapedUrl + "ghost\/editor\/[0-9]*");
test.assertUrlMatch(urlRegExp, 'got an id on our URL');
});
@ -413,7 +413,7 @@ CasperTest.begin('Publish menu - existing post', 22, function suite(test) {
// Publish the post
casper.thenClick('.js-publish-button');
casper.waitForResource(/posts\/$/, function checkPostWasCreated() {
casper.waitForResource(/posts\/\?include=tags$/, function checkPostWasCreated() {
var urlRegExp = new RegExp("^" + escapedUrl + "ghost\/editor\/[0-9]*");
test.assertUrlMatch(urlRegExp, 'got an id on our URL');
});

View File

@ -18,7 +18,7 @@ CasperTest.begin("Ghost edit draft flow works correctly", 8, function suite(test
});
casper.thenClick('.js-publish-button');
casper.waitForResource(/posts\/$/);
casper.waitForResource(/posts\/\?include=tags$/);
casper.waitForSelector('.notification-success', function onSuccess() {
test.assert(true, 'Got success notification');
@ -43,7 +43,7 @@ CasperTest.begin("Ghost edit draft flow works correctly", 8, function suite(test
});
casper.thenClick('.js-publish-button');
casper.waitForResource(/posts\/[0-9]+\/$/);
casper.waitForResource(/posts\/[0-9]+\/\?include=tags$/);
casper.waitForSelector('.notification-success', function onSuccess() {
test.assert(true, 'Got success notification');

View File

@ -210,6 +210,33 @@ describe('Post API', function () {
jsonResponse.posts[0].page.should.eql(0);
_.isBoolean(jsonResponse.posts[0].featured).should.eql(true);
_.isBoolean(jsonResponse.posts[0].page).should.eql(true);
jsonResponse.posts[0].author.should.be.a.Number;
jsonResponse.posts[0].created_by.should.be.a.Number;
jsonResponse.posts[0].tags[0].should.be.a.Number;
done();
});
});
it('can retrieve a post with author, created_by, and tags', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/?include=author,tags,created_by'))
.end(function (err, res) {
if (err) {
return done(err);
}
res.should.have.status(200);
should.not.exist(res.headers['x-cache-invalidate']);
res.should.be.json;
var jsonResponse = res.body;
jsonResponse.should.exist;
jsonResponse.posts.should.exist;
testUtils.API.checkResponse(jsonResponse.posts[0], 'post');
jsonResponse.posts[0].page.should.eql(0);
jsonResponse.posts[0].author.should.be.an.Object;
testUtils.API.checkResponse(jsonResponse.posts[0].author, 'user');
jsonResponse.posts[0].tags[0].should.be.an.Object;
testUtils.API.checkResponse(jsonResponse.posts[0].tags[0], 'tag');
done();
});
});
@ -358,7 +385,7 @@ describe('Post API', function () {
// ## edit
describe('Edit', function () {
it('can edit a post', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
request.get(testUtils.API.getApiQuery('posts/1/?include=tags'))
.end(function (err, res) {
if (err) {
return done(err);
@ -391,7 +418,7 @@ describe('Post API', function () {
});
it('can change a post to a static page', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
request.get(testUtils.API.getApiQuery('posts/1/?include=tags'))
.end(function (err, res) {
if (err) {
return done(err);
@ -482,7 +509,7 @@ describe('Post API', function () {
});
it('published_at = null', function (done) {
request.get(testUtils.API.getApiQuery('posts/1/'))
request.get(testUtils.API.getApiQuery('posts/1/?include=tags'))
.end(function (err, res) {
if (err) {
return done(err);
@ -703,7 +730,7 @@ describe('Post API', function () {
});
it('Can edit a post', function (done) {
request.get(testUtils.API.getApiQuery('posts/2/'))
request.get(testUtils.API.getApiQuery('posts/2/?include=tags'))
.end(function (err, res) {
if (err) {
return done(err);

View File

@ -44,7 +44,8 @@ describe('DB API', function () {
}).then(function () {
TagsAPI.browse().then(function (results) {
should.exist(results);
results.length.should.equal(0);
should.exist(results.tags);
results.tags.length.should.equal(0);
});
}).then(function () {
PostAPI.browse().then(function (results) {

View File

@ -65,10 +65,10 @@ describe('Post Model', function () {
}).then(null, done);
});
it('can findAll, returning author, user and field data', function (done) {
it('can findAll, returning author and field data', function (done) {
var firstPost;
PostModel.findAll({}).then(function (results) {
PostModel.findAll({include: ['author_id', 'fields']}).then(function (results) {
should.exist(results);
results.length.should.be.above(0);
firstPost = results.models[0].toJSON();
@ -83,10 +83,10 @@ describe('Post Model', function () {
}, done);
});
it('can findOne, returning author, user and field data', function (done) {
it('can findOne, returning author and field data', function (done) {
var firstPost;
PostModel.findOne({}).then(function (result) {
// TODO: should take author :-/
PostModel.findOne({}, {include: ['author_id', 'fields']}).then(function (result) {
should.exist(result);
firstPost = result.toJSON();