mirror of https://github.com/TryGhost/Ghost.git
Added new site.changed event and webhook trigger service (#10019)
refs #9942 * Added new middleware to trigger events * Refactored webhooks service - added new trigger service, moved listen service to its own file - started listening to new site.changed event - cleaned up trigger service to work with new webhook fields - cleaned up tests - removed redundant trigger method in v0.1 controller
This commit is contained in:
parent
45b8e6b66a
commit
8ad951d7f3
|
@ -7,52 +7,10 @@ const Promise = require('bluebird'),
|
|||
localUtils = require('./utils'),
|
||||
models = require('../../models'),
|
||||
common = require('../../lib/common'),
|
||||
request = require('../../lib/request'),
|
||||
docName = 'webhooks';
|
||||
|
||||
let webhooks;
|
||||
|
||||
function makeRequest(webhook, payload, options) {
|
||||
let event = webhook.get('event'),
|
||||
targetUrl = webhook.get('target_url'),
|
||||
webhookId = webhook.get('id'),
|
||||
reqPayload = JSON.stringify(payload);
|
||||
|
||||
common.logging.info('webhook.trigger', event, targetUrl);
|
||||
|
||||
request(targetUrl, {
|
||||
body: reqPayload,
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(reqPayload),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 2 * 1000,
|
||||
retries: 5
|
||||
}).catch((err) => {
|
||||
// when a webhook responds with a 410 Gone response we should remove the hook
|
||||
if (err.statusCode === 410) {
|
||||
common.logging.info('webhook.destroy (410 response)', event, targetUrl);
|
||||
return models.Webhook.destroy({id: webhookId}, options);
|
||||
}
|
||||
|
||||
common.logging.error(new common.errors.GhostError({
|
||||
err: err,
|
||||
context: {
|
||||
id: webhookId,
|
||||
event: event,
|
||||
target_url: targetUrl,
|
||||
payload: payload
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function makeRequests(webhooksCollection, payload, options) {
|
||||
_.each(webhooksCollection.models, (webhook) => {
|
||||
makeRequest(webhook, payload, options);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ## Webhook API Methods
|
||||
*
|
||||
|
@ -130,21 +88,6 @@ webhooks = {
|
|||
];
|
||||
|
||||
// Pipeline calls each task passing the result of one to be the arguments for the next
|
||||
return pipeline(tasks, options);
|
||||
},
|
||||
|
||||
trigger(event, payload, options) {
|
||||
let tasks;
|
||||
|
||||
function doQuery(options) {
|
||||
return models.Webhook.findAllByEvent(event, options);
|
||||
}
|
||||
|
||||
tasks = [
|
||||
doQuery,
|
||||
_.partialRight(makeRequests, payload, options)
|
||||
];
|
||||
|
||||
return pipeline(tasks, options);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
get listen() {
|
||||
return require('./listen');
|
||||
},
|
||||
|
||||
get trigger() {
|
||||
return require('./trigger');
|
||||
}
|
||||
};
|
|
@ -1,7 +1,7 @@
|
|||
var _ = require('lodash'),
|
||||
common = require('../lib/common'),
|
||||
api = require('../api'),
|
||||
modelAttrs;
|
||||
const _ = require('lodash');
|
||||
const common = require('../../lib/common');
|
||||
const webhooks = require('./index');
|
||||
let modelAttrs;
|
||||
|
||||
// TODO: this can be removed once all events pass a .toJSON object through
|
||||
modelAttrs = {
|
||||
|
@ -11,15 +11,15 @@ modelAttrs = {
|
|||
// TODO: this works for basic models but we eventually want a full API response
|
||||
// with embedded models (?include=tags) and so on
|
||||
function generatePayload(event, model) {
|
||||
var modelName = event.split('.')[0],
|
||||
pluralModelName = modelName + 's',
|
||||
action = event.split('.')[1],
|
||||
payload = {},
|
||||
data;
|
||||
const modelName = event.split('.')[0];
|
||||
const pluralModelName = modelName + 's';
|
||||
const action = event.split('.')[1];
|
||||
const payload = {};
|
||||
let data;
|
||||
|
||||
if (action === 'deleted') {
|
||||
data = {};
|
||||
modelAttrs[modelName].forEach(function (key) {
|
||||
modelAttrs[modelName].forEach((key) => {
|
||||
if (model._previousAttributes[key] !== undefined) {
|
||||
data[key] = model._previousAttributes[key];
|
||||
}
|
||||
|
@ -34,14 +34,17 @@ function generatePayload(event, model) {
|
|||
}
|
||||
|
||||
function listener(event, model, options) {
|
||||
var payload = generatePayload(event, model);
|
||||
let payload = {};
|
||||
if (model) {
|
||||
payload = generatePayload(event, model);
|
||||
}
|
||||
|
||||
// avoid triggering webhooks when importing
|
||||
if (options && options.importing) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.webhooks.trigger(event, payload, options);
|
||||
webhooks.trigger(event, payload, options);
|
||||
}
|
||||
|
||||
// TODO: use a wildcard with the new event emitter or use the webhooks API to
|
||||
|
@ -49,9 +52,8 @@ function listener(event, model, options) {
|
|||
function listen() {
|
||||
common.events.on('subscriber.added', _.partial(listener, 'subscriber.added'));
|
||||
common.events.on('subscriber.deleted', _.partial(listener, 'subscriber.deleted'));
|
||||
common.events.on('site.changed', _.partial(listener, 'site.changed'));
|
||||
}
|
||||
|
||||
// Public API
|
||||
module.exports = {
|
||||
listen: listen
|
||||
};
|
||||
module.exports = listen;
|
|
@ -0,0 +1,76 @@
|
|||
const _ = require('lodash');
|
||||
const common = require('../../lib/common');
|
||||
const models = require('../../models');
|
||||
const pipeline = require('../../../server/lib/promise/pipeline');
|
||||
const request = require('../../../server/lib/request');
|
||||
|
||||
function updateWebhookTriggerData(id, data) {
|
||||
models.Webhook.edit(data, {id: id}).catch(() => {
|
||||
common.logging.warn(`Unable to update last_triggered for webhook: ${id}`);
|
||||
});
|
||||
}
|
||||
|
||||
function makeRequests(webhooksCollection, payload, options) {
|
||||
_.each(webhooksCollection.models, (webhook) => {
|
||||
const event = webhook.get('event');
|
||||
const targetUrl = webhook.get('target_url');
|
||||
const webhookId = webhook.get('id');
|
||||
const reqPayload = JSON.stringify(payload);
|
||||
|
||||
common.logging.info('webhook.trigger', event, targetUrl);
|
||||
const triggeredAt = Date.now();
|
||||
request(targetUrl, {
|
||||
body: reqPayload,
|
||||
headers: {
|
||||
'Content-Length': Buffer.byteLength(reqPayload),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 2 * 1000,
|
||||
retries: 5
|
||||
}).then((res) => {
|
||||
updateWebhookTriggerData(webhookId, {
|
||||
last_triggered_at: triggeredAt,
|
||||
last_triggered_status: res.statusCode
|
||||
});
|
||||
}).catch((err) => {
|
||||
// when a webhook responds with a 410 Gone response we should remove the hook
|
||||
if (err.statusCode === 410) {
|
||||
common.logging.info('webhook.destroy (410 response)', event, targetUrl);
|
||||
return models.Webhook.destroy({id: webhookId}, options).catch(() => {
|
||||
common.logging.warn(`Unable to destroy webhook ${webhookId}`);
|
||||
});
|
||||
}
|
||||
updateWebhookTriggerData(webhookId, {
|
||||
last_triggered_at: triggeredAt,
|
||||
last_triggered_status: err.statusCode
|
||||
});
|
||||
|
||||
common.logging.error(new common.errors.GhostError({
|
||||
err: err,
|
||||
context: {
|
||||
id: webhookId,
|
||||
event: event,
|
||||
target_url: targetUrl,
|
||||
payload: payload
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function trigger(event, payload, options) {
|
||||
let tasks;
|
||||
|
||||
function doQuery(options) {
|
||||
return models.Webhook.findAllByEvent(event, options);
|
||||
}
|
||||
|
||||
tasks = [
|
||||
doQuery,
|
||||
_.partialRight(makeRequests, payload, options)
|
||||
];
|
||||
|
||||
return pipeline(tasks, options);
|
||||
}
|
||||
|
||||
module.exports = trigger;
|
|
@ -28,6 +28,9 @@ module.exports = function setupApiApp() {
|
|||
// API shouldn't be cached
|
||||
apiApp.use(shared.middlewares.cacheControl('private'));
|
||||
|
||||
// Register event emmiter on req/res to trigger cache invalidation webhook event
|
||||
apiApp.use(shared.middlewares.emitEvents);
|
||||
|
||||
// Routing
|
||||
apiApp.use(routes());
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
const common = require('../../../lib/common');
|
||||
const INVALIDATE_ALL = '/*';
|
||||
|
||||
module.exports = function emitEvents(req, res, next) {
|
||||
res.on('finish', function triggerEvents() {
|
||||
if (res.get('X-Cache-Invalidate') === INVALIDATE_ALL) {
|
||||
common.events.emit('site.changed');
|
||||
}
|
||||
|
||||
res.removeListener('finish', triggerEvents);
|
||||
});
|
||||
next();
|
||||
};
|
|
@ -73,5 +73,9 @@ module.exports = {
|
|||
|
||||
get urlRedirects() {
|
||||
return require('./url-redirects');
|
||||
},
|
||||
|
||||
get emitEvents() {
|
||||
return require('./emit-events');
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
var _ = require('lodash'),
|
||||
should = require('should'),
|
||||
sinon = require('sinon'),
|
||||
rewire = require('rewire'),
|
||||
testUtils = require('../../utils'),
|
||||
|
||||
const _ = require('lodash');
|
||||
const should = require('should');
|
||||
const sinon = require('sinon');
|
||||
const rewire = require('rewire');
|
||||
const testUtils = require('../../utils');
|
||||
const common = require('../../../server/lib/common');
|
||||
// Stuff we test
|
||||
webhooks = rewire('../../../server/services/webhooks'),
|
||||
common = require('../../../server/lib/common'),
|
||||
const webhooks = {
|
||||
listen: rewire('../../../server/services/webhooks/listen'),
|
||||
trigger: rewire('../../../server/services/webhooks/trigger')
|
||||
};
|
||||
|
||||
sandbox = sinon.sandbox.create();
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
describe('Webhooks', function () {
|
||||
var eventStub;
|
||||
|
@ -23,30 +25,28 @@ describe('Webhooks', function () {
|
|||
|
||||
it('listen() should initialise events correctly', function () {
|
||||
webhooks.listen();
|
||||
eventStub.calledTwice.should.be.true();
|
||||
eventStub.calledThrice.should.be.true();
|
||||
});
|
||||
|
||||
it('listener() with "subscriber.added" event calls api.webhooks.trigger with toJSONified model', function () {
|
||||
it('listener() with "subscriber.added" event calls webhooks.trigger with toJSONified model', function () {
|
||||
var testSubscriber = _.clone(testUtils.DataGenerator.Content.subscribers[0]),
|
||||
testModel = {
|
||||
toJSON: function () {
|
||||
return testSubscriber;
|
||||
}
|
||||
},
|
||||
apiStub = {
|
||||
webhooks: {
|
||||
trigger: sandbox.stub()
|
||||
}
|
||||
webhooksStub = {
|
||||
trigger: sandbox.stub()
|
||||
},
|
||||
resetWebhooks = webhooks.__set__('api', apiStub),
|
||||
listener = webhooks.__get__('listener'),
|
||||
resetWebhooks = webhooks.listen.__set__('webhooks', webhooksStub),
|
||||
listener = webhooks.listen.__get__('listener'),
|
||||
triggerArgs;
|
||||
|
||||
listener('subscriber.added', testModel);
|
||||
|
||||
apiStub.webhooks.trigger.calledOnce.should.be.true();
|
||||
webhooksStub.trigger.calledOnce.should.be.true();
|
||||
|
||||
triggerArgs = apiStub.webhooks.trigger.getCall(0).args;
|
||||
triggerArgs = webhooksStub.trigger.getCall(0).args;
|
||||
triggerArgs[0].should.eql('subscriber.added');
|
||||
triggerArgs[1].should.deepEqual({
|
||||
subscribers: [testSubscriber]
|
||||
|
@ -55,25 +55,23 @@ describe('Webhooks', function () {
|
|||
resetWebhooks();
|
||||
});
|
||||
|
||||
it('listener() with "subscriber.deleted" event calls api.webhooks.trigger with _previousAttributes values', function () {
|
||||
it('listener() with "subscriber.deleted" event calls webhooks.trigger with _previousAttributes values', function () {
|
||||
var testSubscriber = _.clone(testUtils.DataGenerator.Content.subscribers[1]),
|
||||
testModel = {
|
||||
_previousAttributes: testSubscriber
|
||||
},
|
||||
apiStub = {
|
||||
webhooks: {
|
||||
trigger: sandbox.stub()
|
||||
}
|
||||
webhooksStub = {
|
||||
trigger: sandbox.stub()
|
||||
},
|
||||
resetWebhooks = webhooks.__set__('api', apiStub),
|
||||
listener = webhooks.__get__('listener'),
|
||||
resetWebhooks = webhooks.listen.__set__('webhooks', webhooksStub),
|
||||
listener = webhooks.listen.__get__('listener'),
|
||||
triggerArgs;
|
||||
|
||||
listener('subscriber.deleted', testModel);
|
||||
|
||||
apiStub.webhooks.trigger.calledOnce.should.be.true();
|
||||
webhooksStub.trigger.calledOnce.should.be.true();
|
||||
|
||||
triggerArgs = apiStub.webhooks.trigger.getCall(0).args;
|
||||
triggerArgs = webhooksStub.trigger.getCall(0).args;
|
||||
triggerArgs[0].should.eql('subscriber.deleted');
|
||||
triggerArgs[1].should.deepEqual({
|
||||
subscribers: [testSubscriber]
|
||||
|
@ -81,4 +79,23 @@ describe('Webhooks', function () {
|
|||
|
||||
resetWebhooks();
|
||||
});
|
||||
|
||||
it('listener() with "site.changed" event calls webhooks.trigger ', function () {
|
||||
const webhooksStub = {
|
||||
trigger: sandbox.stub()
|
||||
};
|
||||
const resetWebhooks = webhooks.listen.__set__('webhooks', webhooksStub);
|
||||
const listener = webhooks.listen.__get__('listener');
|
||||
let triggerArgs;
|
||||
|
||||
listener('site.changed');
|
||||
|
||||
webhooksStub.trigger.calledOnce.should.be.true();
|
||||
|
||||
triggerArgs = webhooksStub.trigger.getCall(0).args;
|
||||
triggerArgs[0].should.eql('site.changed');
|
||||
triggerArgs[1].should.eql({});
|
||||
|
||||
resetWebhooks();
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue