0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

Content API v2 date formatting (#10095)

closes #10065 

- Added UTC offset to dates returned by Content API
- Added test checking new format is compatible with Admin API
- Refactored output serializer mapping logic
This commit is contained in:
Naz Gargol 2018-11-06 16:12:12 +01:00 committed by GitHub
parent 7b38986316
commit ec03b3cfc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 488 additions and 132 deletions

View file

@ -1,5 +1,5 @@
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:pages'); const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:pages');
const url = require('./utils/url'); const mapper = require('./utils/mapper');
module.exports = { module.exports = {
all(models, apiConfig, frame) { all(models, apiConfig, frame) {
@ -7,7 +7,7 @@ module.exports = {
if (models.meta) { if (models.meta) {
frame.response = { frame.response = {
pages: models.data.map(model => url.forPost(model.id, model.toJSON(frame.options), frame.options)), pages: models.data.map(model => mapper.mapPost(model, frame)),
meta: models.meta meta: models.meta
}; };
@ -15,7 +15,7 @@ module.exports = {
} }
frame.response = { frame.response = {
pages: [url.forPost(models.id, models.toJSON(frame.options), frame.options)] pages: [mapper.mapPost(models, frame)]
}; };
debug(frame.response); debug(frame.response);

View file

@ -1,5 +1,5 @@
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:posts'); const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:posts');
const url = require('./utils/url'); const mapper = require('./utils/mapper');
module.exports = { module.exports = {
all(models, apiConfig, frame) { all(models, apiConfig, frame) {
@ -12,7 +12,7 @@ module.exports = {
if (models.meta) { if (models.meta) {
frame.response = { frame.response = {
posts: models.data.map(model => url.forPost(model.id, model.toJSON(frame.options), frame.options)), posts: models.data.map(model => mapper.mapPost(model, frame)),
meta: models.meta meta: models.meta
}; };
@ -21,7 +21,7 @@ module.exports = {
} }
frame.response = { frame.response = {
posts: [url.forPost(models.id, models.toJSON(frame.options), frame.options)] posts: [mapper.mapPost(models, frame)]
}; };
debug(frame.response); debug(frame.response);

View file

@ -1,5 +1,5 @@
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:tags'); const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:tags');
const url = require('./utils/url'); const mapper = require('./utils/mapper');
module.exports = { module.exports = {
all(models, apiConfig, frame) { all(models, apiConfig, frame) {
@ -11,7 +11,7 @@ module.exports = {
if (models.meta) { if (models.meta) {
frame.response = { frame.response = {
tags: models.data.map(model => url.forTag(model.id, model.toJSON(frame.options), frame.options)), tags: models.data.map(model => mapper.mapTag(model, frame)),
meta: models.meta meta: models.meta
}; };
@ -19,7 +19,7 @@ module.exports = {
} }
frame.response = { frame.response = {
tags: [url.forTag(models.id, models.toJSON(frame.options), frame.options)] tags: [mapper.mapTag(models, frame)]
}; };
debug(frame.response); debug(frame.response);

View file

@ -1,13 +1,13 @@
const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:users'); const debug = require('ghost-ignition').debug('api:v2:utils:serializers:output:users');
const common = require('../../../../../lib/common'); const common = require('../../../../../lib/common');
const url = require('./utils/url'); const mapper = require('./utils/mapper');
module.exports = { module.exports = {
browse(models, apiConfig, frame) { browse(models, apiConfig, frame) {
debug('browse'); debug('browse');
frame.response = { frame.response = {
users: models.data.map(model => url.forUser(model.id, model.toJSON(frame.options), frame.options)), users: models.data.map(model => mapper.mapUser(model, frame)),
meta: models.meta meta: models.meta
}; };
@ -18,7 +18,7 @@ module.exports = {
debug('read'); debug('read');
frame.response = { frame.response = {
users: [url.forUser(model.id, model.toJSON(frame.options), frame.options)] users: [mapper.mapUser(model, frame)]
}; };
debug(frame.response); debug(frame.response);

View file

@ -0,0 +1,32 @@
const moment = require('moment-timezone');
const settingsCache = require('../../../../../../services/settings/cache');
const format = (date) => {
return moment(date)
.tz(settingsCache.get('active_timezone'))
.toISOString(true);
};
const forPost = (attrs) => {
['created_at', 'updated_at', 'published_at'].forEach((field) => {
if (attrs[field]) {
attrs[field] = format(attrs[field]);
}
});
return attrs;
};
const forTag = (attrs) => {
['created_at', 'updated_at'].forEach((field) => {
if (attrs[field]) {
attrs[field] = format(attrs[field]);
}
});
return attrs;
};
module.exports.format = format;
module.exports.forPost = forPost;
module.exports.forTag = forTag;

View file

@ -0,0 +1,61 @@
const utils = require('../../../index');
const url = require('./url');
const date = require('./date');
const mapPost = (model, frame) => {
const jsonModel = model.toJSON(frame.options);
url.forPost(model.id, jsonModel, frame.options);
if (utils.isContentAPI(frame)) {
date.forPost(jsonModel);
}
if (frame.options && frame.options.withRelated) {
frame.options.withRelated.forEach((relation) => {
// @NOTE: this block also decorates primary_tag/primary_author objects as they
// are being passed by reference in tags/authors. Might be refactored into more explicit call
// in the future, but is good enough for current use-case
if (relation === 'tags' && jsonModel.tags) {
jsonModel.tags = jsonModel.tags.map(tag => url.forTag(tag.id, tag));
if (utils.isContentAPI(frame)) {
jsonModel.tags = jsonModel.tags.map(tag => date.forTag(tag));
}
}
if (relation === 'author' && jsonModel.author) {
jsonModel.author = url.forUser(jsonModel.author.id, jsonModel.author);
}
if (relation === 'authors' && jsonModel.authors) {
jsonModel.authors = jsonModel.authors.map(author => url.forUser(author.id, author));
}
});
}
return jsonModel;
};
const mapUser = (model, frame) => {
const jsonModel = model.toJSON(frame.options);
url.forUser(model.id, jsonModel);
return jsonModel;
};
const mapTag = (model, frame) => {
const jsonModel = model.toJSON(frame.options);
url.forTag(model.id, jsonModel);
if (utils.isContentAPI(frame)) {
date.forTag(jsonModel);
}
return jsonModel;
};
module.exports.mapPost = mapPost;
module.exports.mapUser = mapUser;
module.exports.mapTag = mapTag;

View file

@ -36,25 +36,6 @@ const forPost = (id, attrs, options) => {
delete attrs.url; delete attrs.url;
} }
if (options && options.withRelated) {
options.withRelated.forEach((relation) => {
// @NOTE: this block also decorates primary_tag/primary_author objects as they
// are being passed by reference in tags/authors. Might be refactored into more explicit call
// in the future, but is good enough for current use-case
if (relation === 'tags' && attrs.tags) {
attrs.tags = attrs.tags.map(tag => forTag(tag.id, tag));
}
if (relation === 'author' && attrs.author) {
attrs.author = forUser(attrs.author.id, attrs.author, options);
}
if (relation === 'authors' && attrs.authors) {
attrs.authors = attrs.authors.map(author => forUser(author.id, author, options));
}
});
}
return attrs; return attrs;
}; };

View file

@ -387,7 +387,6 @@ describe('Posts API V2', function () {
res.body.posts[0].title.should.eql(post.title); res.body.posts[0].title.should.eql(post.title);
res.body.posts[0].status.should.eql(post.status); res.body.posts[0].status.should.eql(post.status);
res.body.posts[0].published_at.should.eql('2016-05-30T07:00:00.000Z'); res.body.posts[0].published_at.should.eql('2016-05-30T07:00:00.000Z');
res.body.posts[0].published_at = '2016-05-30T09:00:00.000Z';
res.body.posts[0].created_at.should.not.eql(post.created_at.toISOString()); res.body.posts[0].created_at.should.not.eql(post.created_at.toISOString());
res.body.posts[0].updated_at.should.not.eql(post.updated_at.toISOString()); res.body.posts[0].updated_at.should.not.eql(post.updated_at.toISOString());
res.body.posts[0].updated_by.should.not.eql(post.updated_by); res.body.posts[0].updated_by.should.not.eql(post.updated_by);
@ -395,10 +394,13 @@ describe('Posts API V2', function () {
}); });
}); });
it('published post', function () { it('published post with response timestamps in UTC format respecting original UTC offset', function () {
const post = { const post = {
posts: [{ posts: [{
status: 'published' status: 'published',
published_at: '2016-05-31T07:00:00.000+06:00',
created_at: '2016-05-30T03:00:00.000Z',
updated_at: '2016-05-30T07:00:00.000'
}] }]
}; };
@ -413,6 +415,10 @@ describe('Posts API V2', function () {
testUtils.API.checkResponse(res.body.posts[0], 'post'); testUtils.API.checkResponse(res.body.posts[0], 'post');
res.body.posts[0].status.should.eql('published'); res.body.posts[0].status.should.eql('published');
res.headers['x-cache-invalidate'].should.eql('/*'); res.headers['x-cache-invalidate'].should.eql('/*');
res.body.posts[0].published_at.should.eql('2016-05-31T01:00:00.000Z');
res.body.posts[0].created_at.should.eql('2016-05-30T03:00:00.000Z');
res.body.posts[0].updated_at.should.eql('2016-05-30T07:00:00.000Z');
}); });
}); });
}); });

View file

@ -0,0 +1,54 @@
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../../../../../utils');
const mapper = require('../../../../../../../server/api/v2/utils/serializers/output/utils/mapper');
const serializers = require('../../../../../../../server/api/v2/utils/serializers');
const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/pages', () => {
let pageModel;
beforeEach(() => {
pageModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
};
sandbox.stub(mapper, 'mapPost').returns({});
});
afterEach(() => {
sandbox.restore();
});
it('calls the mapper', () => {
const apiConfig = {};
const frame = {
options: {
withRelated: ['tags', 'authors'],
context: {
private: false
}
}
};
const ctrlResponse = {
data: [
pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
page: true
})),
pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id2',
page: true
}))
],
meta: {}
};
serializers.output.pages.all(ctrlResponse, apiConfig, frame);
mapper.mapPost.callCount.should.equal(2);
mapper.mapPost.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
});
});

View file

@ -1,121 +1,48 @@
const should = require('should'); const should = require('should');
const sinon = require('sinon'); const sinon = require('sinon');
const testUtils = require('../../../../../../utils'); const testUtils = require('../../../../../../utils');
const urlService = require('../../../../../../../server/services/url'); const mapper = require('../../../../../../../server/api/v2/utils/serializers/output/utils/mapper');
const serializers = require('../../../../../../../server/api/v2/utils/serializers'); const serializers = require('../../../../../../../server/api/v2/utils/serializers');
const sandbox = sinon.sandbox.create(); const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/posts', function () { describe('Unit: v2/utils/serializers/output/posts', () => {
let postModel; let postModel;
beforeEach(function () { beforeEach(() => {
postModel = (data) => { postModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)}); return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
}; };
sandbox.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId'); sandbox.stub(mapper, 'mapPost').returns({});
sandbox.stub(urlService.utils, 'urlFor').returns('urlFor');
sandbox.stub(urlService.utils, 'makeAbsoluteUrls').returns({html: sandbox.stub()});
}); });
afterEach(function () { afterEach(() => {
sandbox.restore(); sandbox.restore();
}); });
describe('Ensure absolute urls are returned by default', function () { it('calls the mapper', () => {
it('meta & models & relations', function () {
const apiConfig = {};
const frame = {
options: {
withRelated: ['tags', 'authors']
}
};
const ctrlResponse = {
data: [
postModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value',
tags: [{
id: 'id3',
feature_image: 'value'
}],
authors: [{
id: 'id4',
name: 'Ghosty'
}]
})),
postModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id2',
html: '<img href=/content/test.jpf'
}))
],
meta: {}
};
serializers.output.posts.all(ctrlResponse, apiConfig, frame);
frame.response.posts[0].hasOwnProperty('url').should.be.true();
frame.response.posts[0].tags[0].hasOwnProperty('url').should.be.true();
frame.response.posts[0].authors[0].hasOwnProperty('url').should.be.true();
frame.response.posts[1].hasOwnProperty('url').should.be.true();
urlService.utils.urlFor.callCount.should.eql(4);
urlService.utils.urlFor.getCall(0).args.should.eql(['image', {image: 'value'}, true]);
urlService.utils.urlFor.getCall(1).args.should.eql(['home', true]);
urlService.utils.urlFor.getCall(2).args.should.eql(['image', {image: 'value'}, true]);
urlService.utils.urlFor.getCall(3).args.should.eql(['home', true]);
urlService.utils.makeAbsoluteUrls.callCount.should.eql(2);
urlService.utils.makeAbsoluteUrls.getCall(0).args.should.eql([
'## markdown',
'urlFor',
'getUrlByResourceId',
{assetsOnly: true}
]);
urlService.utils.makeAbsoluteUrls.getCall(1).args.should.eql([
'<img href=/content/test.jpf',
'urlFor',
'getUrlByResourceId',
{assetsOnly: true}
]);
urlService.getUrlByResourceId.callCount.should.eql(4);
urlService.getUrlByResourceId.getCall(0).args.should.eql(['id1', {absolute: true}]);
urlService.getUrlByResourceId.getCall(1).args.should.eql(['id3', {absolute: true}]);
urlService.getUrlByResourceId.getCall(2).args.should.eql(['id4', {absolute: true}]);
urlService.getUrlByResourceId.getCall(3).args.should.eql(['id2', {absolute: true}]);
});
it('absolute_urls = true', function () {
const apiConfig = {}; const apiConfig = {};
const frame = { const frame = {
options: { options: {
withRelated: ['tags', 'authors'], withRelated: ['tags', 'authors'],
absolute_urls: true context: {
private: false
}
} }
}; };
const ctrlResponse = { const ctrlResponse = {
data: [ data: [
postModel(testUtils.DataGenerator.forKnex.createPost({ postModel(testUtils.DataGenerator.forKnex.createPost({})),
id: 'id2', postModel(testUtils.DataGenerator.forKnex.createPost({}))
html: '<img href=/content/test.jpf'
}))
], ],
meta: {} meta: {}
}; };
serializers.output.posts.all(ctrlResponse, apiConfig, frame); serializers.output.pages.all(ctrlResponse, apiConfig, frame);
urlService.utils.makeAbsoluteUrls.callCount.should.eql(1);
urlService.utils.makeAbsoluteUrls.getCall(0).args.should.eql([ mapper.mapPost.callCount.should.equal(2);
'<img href=/content/test.jpf', mapper.mapPost.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
'urlFor',
'getUrlByResourceId',
{assetsOnly: false}
]);
});
}); });
}); });

View file

@ -0,0 +1,65 @@
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../../../../../utils');
const mapper = require('../../../../../../../server/api/v2/utils/serializers/output/utils/mapper');
const serializers = require('../../../../../../../server/api/v2/utils/serializers');
const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/tags', () => {
let tagModel;
beforeEach(() => {
tagModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
};
sandbox.stub(mapper, 'mapTag').returns({});
});
afterEach(() => {
sandbox.restore();
});
it('calls the mapper when single tag present', () => {
const apiConfig = {};
const frame = {
options: {
context: {
public: true
}
}
};
const ctrlResponse = tagModel(testUtils.DataGenerator.forKnex.createTag());
serializers.output.tags.all(ctrlResponse, apiConfig, frame);
mapper.mapTag.callCount.should.equal(1);
mapper.mapTag.getCall(0).args.should.eql([ctrlResponse, frame]);
});
it('calls the mapper with multiple tags', () => {
const apiConfig = {};
const frame = {
options: {
context: {
public: true
}
}
};
const ctrlResponse = tagModel({
data: [
testUtils.DataGenerator.forKnex.createTag(),
testUtils.DataGenerator.forKnex.createTag()
],
meta: {}
});
serializers.output.tags.all(ctrlResponse, apiConfig, frame);
mapper.mapTag.callCount.should.equal(2);
mapper.mapTag.getCall(0).args.should.eql([ctrlResponse.data[0], frame]);
});
});

View file

@ -0,0 +1,26 @@
const should = require('should');
const sinon = require('sinon');
const settingsCache = require('../../../../../../../../server/services/settings/cache');
const dateUtil = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/date');
const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/utils/date', () => {
afterEach(() => {
sandbox.restore();
});
it('creates date strings in ISO 8601 format with UTC offset', () => {
const timezone = 'Europe/Oslo';
const testDates = [
{in: '2014-01-01T01:28:58.593Z', out: '2014-01-01T02:28:58.593+01:00'},
{in:'2014-12-31T23:28:58.123Z', out: '2015-01-01T00:28:58.123+01:00'},
{in:'2014-03-01T01:28:58.593Z', out: '2014-03-01T02:28:58.593+01:00'}
];
sandbox.stub(settingsCache, 'get').returns(timezone);
testDates.forEach((date) => {
dateUtil.format(date.in).should.equal(date.out);
});
});
});

View file

@ -0,0 +1,149 @@
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../../../../../../utils');
const dateUtil = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/date');
const urlUtil = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/url');
const mapper = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/mapper');
const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/utils/mapper', () => {
beforeEach(() => {
sandbox.stub(dateUtil, 'forPost').returns({});
sandbox.stub(dateUtil, 'forTag').returns({});
sandbox.stub(urlUtil, 'forPost').returns({});
sandbox.stub(urlUtil, 'forTag').returns({});
sandbox.stub(urlUtil, 'forUser').returns({});
});
afterEach(() => {
sandbox.restore();
});
describe('mapPost', () => {
let postModel;
beforeEach(() => {
postModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
};
});
it('calls mapper on relations', () => {
const frame = {
options: {
withRelated: ['tags', 'authors'],
context: {
public: true
}
}
};
const post = postModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value',
page: true,
tags: [{
id: 'id3',
feature_image: 'value'
}],
authors: [{
id: 'id4',
name: 'Ghosty'
}]
}));
mapper.mapPost(post, frame);
dateUtil.forPost.callCount.should.equal(1);
dateUtil.forTag.callCount.should.equal(1);
urlUtil.forPost.callCount.should.equal(1);
urlUtil.forTag.callCount.should.equal(1);
urlUtil.forUser.callCount.should.equal(1);
urlUtil.forTag.getCall(0).args.should.eql(['id3', {id: 'id3', feature_image: 'value'}]);
urlUtil.forUser.getCall(0).args.should.eql(['id4', {name: 'Ghosty', id: 'id4'}]);
});
});
describe('mapUser', () => {
let userModel;
beforeEach(() => {
userModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
};
});
it('calls utils', () => {
const frame = {
options: {}
};
const user = userModel(testUtils.DataGenerator.forKnex.createUser({
id: 'id1',
name: 'Ghosty'
}));
mapper.mapUser(user, frame);
urlUtil.forUser.callCount.should.equal(1);
urlUtil.forUser.getCall(0).args.should.eql(['id1', user]);
});
});
describe('mapTag', () => {
let tagModel;
beforeEach(() => {
tagModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
};
});
it('calls utils', () => {
const frame = {
options: {
context: {
public: true
}
},
};
const tag = tagModel(testUtils.DataGenerator.forKnex.createTag({
id: 'id3',
feature_image: 'value'
}));
mapper.mapTag(tag, frame);
urlUtil.forTag.callCount.should.equal(1);
dateUtil.forTag.callCount.should.equal(1);
urlUtil.forTag.getCall(0).args.should.eql(['id3', tag]);
dateUtil.forTag.getCall(0).args.should.eql([tag]);
});
it('does not call date formatter in private context', () => {
const frame = {
options: {
context: {
public: false
}
},
};
const tag = tagModel(testUtils.DataGenerator.forKnex.createTag({
id: 'id3',
feature_image: 'value'
}));
mapper.mapTag(tag, frame);
dateUtil.forTag.callCount.should.equal(0);
});
});
});

View file

@ -0,0 +1,55 @@
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../../../../../../utils');
const urlService = require('../../../../../../../../server/services/url');
const urlUtil = require('../../../../../../../../server/api/v2/utils/serializers/output/utils/url');
const sandbox = sinon.sandbox.create();
describe('Unit: v2/utils/serializers/output/utils/url', () => {
beforeEach(() => {
sandbox.stub(urlService, 'getUrlByResourceId').returns('getUrlByResourceId');
sandbox.stub(urlService.utils, 'urlFor').returns('urlFor');
sandbox.stub(urlService.utils, 'makeAbsoluteUrls').returns({html: sandbox.stub()});
});
afterEach(() => {
sandbox.restore();
});
describe('Ensure calls url service', () => {
let pageModel;
beforeEach(() => {
pageModel = (data) => {
return Object.assign(data, {toJSON: sandbox.stub().returns(data)});
};
});
it('meta & models & relations', () => {
const post = pageModel(testUtils.DataGenerator.forKnex.createPost({
id: 'id1',
feature_image: 'value',
}));
urlUtil.forPost(post.id, post, {});
post.hasOwnProperty('url').should.be.true();
urlService.utils.urlFor.callCount.should.eql(2);
urlService.utils.urlFor.getCall(0).args.should.eql(['image', {image: 'value'}, true]);
urlService.utils.urlFor.getCall(1).args.should.eql(['home', true]);
urlService.utils.makeAbsoluteUrls.callCount.should.eql(1);
urlService.utils.makeAbsoluteUrls.getCall(0).args.should.eql([
'## markdown',
'urlFor',
'getUrlByResourceId',
{assetsOnly: true}
]);
urlService.getUrlByResourceId.callCount.should.eql(1);
urlService.getUrlByResourceId.getCall(0).args.should.eql(['id1', {absolute: true}]);
});
});
});