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:
Rishabh Garg 2018-10-19 00:01:30 +05:30 committed by GitHub
parent 45b8e6b66a
commit 8ad951d7f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 167 additions and 100 deletions

View File

@ -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);
}
};

View File

@ -0,0 +1,9 @@
module.exports = {
get listen() {
return require('./listen');
},
get trigger() {
return require('./trigger');
}
};

View File

@ -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;

View File

@ -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;

View File

@ -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());

View File

@ -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();
};

View File

@ -73,5 +73,9 @@ module.exports = {
get urlRedirects() {
return require('./url-redirects');
},
get emitEvents() {
return require('./emit-events');
}
};

View File

@ -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();
});
});