Webhook / Integration Event bus (#499)
* First bash at an event bus for webhooks and integrations * Refactoring * poc * Revert too wide ranging changes Move to two-queues
This commit is contained in:
@ -12,6 +12,7 @@ REDIS_URL=redis://redis:6379
|
|||||||
URL=http://localhost:3000
|
URL=http://localhost:3000
|
||||||
DEPLOYMENT=hosted
|
DEPLOYMENT=hosted
|
||||||
ENABLE_UPDATES=true
|
ENABLE_UPDATES=true
|
||||||
|
DEBUG=sql,cache,presenters,events
|
||||||
|
|
||||||
# Third party credentials (required)
|
# Third party credentials (required)
|
||||||
SLACK_KEY=71315967491.XXXXXXXXXX
|
SLACK_KEY=71315967491.XXXXXXXXXX
|
||||||
|
@ -39,7 +39,7 @@ In development you can quickly get an environment running using Docker by follow
|
|||||||
To enable debugging statements, set the following env vars:
|
To enable debugging statements, set the following env vars:
|
||||||
|
|
||||||
```
|
```
|
||||||
DEBUG=sql,cache,presenters
|
DEBUG=sql,cache,presenters,events
|
||||||
```
|
```
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
@ -48,7 +48,7 @@ class Settings extends Component {
|
|||||||
name: this.name,
|
name: this.name,
|
||||||
avatarUrl: this.avatarUrl,
|
avatarUrl: this.avatarUrl,
|
||||||
});
|
});
|
||||||
invariant(res && res.data, 'Document list not available');
|
invariant(res && res.data, 'User response not available');
|
||||||
const { data } = res;
|
const { data } = res;
|
||||||
runInAction('Settings#handleSubmit', () => {
|
runInAction('Settings#handleSubmit', () => {
|
||||||
this.props.auth.user = data;
|
this.props.auth.user = data;
|
||||||
@ -56,7 +56,7 @@ class Settings extends Component {
|
|||||||
this.timeout = setTimeout(() => (this.isUpdated = false), 2500);
|
this.timeout = setTimeout(() => (this.isUpdated = false), 2500);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.props.errors.add('Failed to load documents');
|
this.props.errors.add('Failed to update user');
|
||||||
} finally {
|
} finally {
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
}
|
}
|
||||||
@ -153,7 +153,7 @@ const AvatarContainer = styled(Flex)`
|
|||||||
&:hover div {
|
&:hover div {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(0, 0, 0, 0.75);
|
||||||
color: #ffffff;
|
color: ${color.white};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -165,4 +165,4 @@ const StyledInput = styled(Input)`
|
|||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('auth', 'errors', 'auth')(Settings);
|
export default inject('auth', 'errors')(Settings);
|
||||||
|
@ -16,7 +16,7 @@ import HelpText from 'components/HelpText';
|
|||||||
import Subheading from 'components/Subheading';
|
import Subheading from 'components/Subheading';
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Settings extends Component {
|
class Tokens extends Component {
|
||||||
@observable name: string = '';
|
@observable name: string = '';
|
||||||
props: {
|
props: {
|
||||||
settings: SettingsStore,
|
settings: SettingsStore,
|
||||||
@ -96,4 +96,4 @@ const Table = styled.table`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject('settings')(Settings);
|
export default inject('settings')(Tokens);
|
||||||
|
46
server/events.js
Normal file
46
server/events.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// @flow
|
||||||
|
import Queue from 'bull';
|
||||||
|
import debug from 'debug';
|
||||||
|
import services from '../services';
|
||||||
|
import Document from './models/Document';
|
||||||
|
import Collection from './models/Collection';
|
||||||
|
|
||||||
|
type DocumentEvent = {
|
||||||
|
name: 'documents.create',
|
||||||
|
model: Document,
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollectionEvent = {
|
||||||
|
name: 'collections.create',
|
||||||
|
model: Collection,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Event = DocumentEvent | CollectionEvent;
|
||||||
|
|
||||||
|
const log = debug('events');
|
||||||
|
const globalEventsQueue = new Queue('global events', process.env.REDIS_URL);
|
||||||
|
const serviceEventsQueue = new Queue('service events', process.env.REDIS_URL);
|
||||||
|
|
||||||
|
// this queue processes global events and hands them off to service hooks
|
||||||
|
globalEventsQueue.process(async function(job) {
|
||||||
|
const names = Object.keys(services);
|
||||||
|
names.forEach(name => {
|
||||||
|
const service = services[name];
|
||||||
|
if (service.on) {
|
||||||
|
serviceEventsQueue.add({ service: name, ...job.data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// this queue processes an individual event for a specific service
|
||||||
|
serviceEventsQueue.process(async function(job) {
|
||||||
|
const event = job.data;
|
||||||
|
const service = services[event.service];
|
||||||
|
|
||||||
|
if (service.on) {
|
||||||
|
log(`Triggering ${event.name} for ${service.name}`);
|
||||||
|
service.on(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default globalEventsQueue;
|
@ -1,11 +1,12 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import _ from 'lodash';
|
||||||
import slug from 'slug';
|
import slug from 'slug';
|
||||||
import randomstring from 'randomstring';
|
import randomstring from 'randomstring';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import { asyncLock } from '../redis';
|
import { asyncLock } from '../redis';
|
||||||
|
import events from '../events';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
import Event from './Event';
|
import Event from './Event';
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
// $FlowIssue invalid flow-typed
|
// $FlowIssue invalid flow-typed
|
||||||
slug.defaults.mode = 'rfc3986';
|
slug.defaults.mode = 'rfc3986';
|
||||||
@ -93,6 +94,20 @@ Collection.associate = models => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
|
||||||
|
Collection.addHook('afterCreate', model =>
|
||||||
|
events.add({ name: 'collections.create', model })
|
||||||
|
);
|
||||||
|
|
||||||
|
Collection.addHook('afterDestroy', model =>
|
||||||
|
events.add({ name: 'collections.delete', model })
|
||||||
|
);
|
||||||
|
|
||||||
|
Collection.addHook('afterUpdate', model =>
|
||||||
|
events.add({ name: 'collections.update', model })
|
||||||
|
);
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
Collection.prototype.getUrl = function() {
|
Collection.prototype.getUrl = function() {
|
||||||
|
@ -7,6 +7,7 @@ import Plain from 'slate-plain-serializer';
|
|||||||
|
|
||||||
import isUUID from 'validator/lib/isUUID';
|
import isUUID from 'validator/lib/isUUID';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
|
import events from '../events';
|
||||||
import parseTitle from '../../shared/utils/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle';
|
||||||
import Revision from './Revision';
|
import Revision from './Revision';
|
||||||
|
|
||||||
@ -204,6 +205,20 @@ Document.searchForUser = async (
|
|||||||
return _.sortBy(documents, doc => ids.indexOf(doc.id));
|
return _.sortBy(documents, doc => ids.indexOf(doc.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
|
||||||
|
Document.addHook('afterCreate', model =>
|
||||||
|
events.add({ name: 'documents.create', model })
|
||||||
|
);
|
||||||
|
|
||||||
|
Document.addHook('afterDestroy', model =>
|
||||||
|
events.add({ name: 'documents.delete', model })
|
||||||
|
);
|
||||||
|
|
||||||
|
Document.addHook('afterUpdate', model =>
|
||||||
|
events.add({ name: 'documents.update', model })
|
||||||
|
);
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
Document.prototype.getSummary = function() {
|
Document.prototype.getSummary = function() {
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
// @flow
|
|
||||||
const Slack = {
|
|
||||||
id: 'slack',
|
|
||||||
name: 'Slack',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Slack;
|
|
22
services/index.js
Normal file
22
services/index.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// @flow
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const services = {};
|
||||||
|
|
||||||
|
fs
|
||||||
|
.readdirSync(__dirname)
|
||||||
|
.filter(file => file.indexOf('.') !== 0 && file !== path.basename(__filename))
|
||||||
|
.forEach(name => {
|
||||||
|
const servicePath = path.join(__dirname, name);
|
||||||
|
// $FlowIssue
|
||||||
|
const pkg = require(path.join(servicePath, 'package.json'));
|
||||||
|
// $FlowIssue
|
||||||
|
const hooks = require(servicePath).default;
|
||||||
|
services[pkg.name] = {
|
||||||
|
...pkg,
|
||||||
|
...hooks,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default services;
|
10
services/slack/index.js
Normal file
10
services/slack/index.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// @flow
|
||||||
|
import type { Event } from '../../server/events';
|
||||||
|
|
||||||
|
const Slack = {
|
||||||
|
on: (event: Event) => {
|
||||||
|
console.log(`Slack service received ${event.name}, id: ${event.model.id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Slack;
|
4
services/slack/package.json
Normal file
4
services/slack/package.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "slack",
|
||||||
|
"description": "Hook up your Slack to your Outline."
|
||||||
|
}
|
Reference in New Issue
Block a user