From 5e8ce8f358a6c046b3dffc9a57532de1d83eca66 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 10 Sep 2020 13:46:47 -0400 Subject: [PATCH 1/9] Add event emitter to api --- api/package-lock.json | 5 +++++ api/package.json | 1 + api/src/emitter.ts | 5 +++++ 3 files changed, 11 insertions(+) create mode 100644 api/src/emitter.ts diff --git a/api/package-lock.json b/api/package-lock.json index 6df0e51aac..4bba55a5a5 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -27875,6 +27875,11 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" }, + "eventemitter2": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz", + "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==" + }, "events": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", diff --git a/api/package.json b/api/package.json index c77302432e..fe430d85a4 100644 --- a/api/package.json +++ b/api/package.json @@ -80,6 +80,7 @@ "cookie-parser": "^1.4.5", "cors": "^2.8.5", "dotenv": "^8.2.0", + "eventemitter2": "^6.4.3", "execa": "^4.0.3", "exif-reader": "^1.0.3", "express": "^4.17.1", diff --git a/api/src/emitter.ts b/api/src/emitter.ts new file mode 100644 index 0000000000..a0a4563949 --- /dev/null +++ b/api/src/emitter.ts @@ -0,0 +1,5 @@ +import { EventEmitter2 } from 'eventemitter2'; + +const emitter = new EventEmitter2({ wildcard: true }); + +export default emitter; From a046af7a5cd6351e26151c8af06b2e657be01d4a Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 10 Sep 2020 13:47:02 -0400 Subject: [PATCH 2/9] Emit item crud --- api/src/services/items.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/api/src/services/items.ts b/api/src/services/items.ts index c716eca737..ce716e5edd 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -14,6 +14,7 @@ import { } from '../types'; import Knex from 'knex'; import cache from '../cache'; +import emitter from '../emitter'; import PayloadService from './payload'; import AuthorizationService from './authorization'; @@ -150,6 +151,13 @@ export default class ItemsService implements AbstractService { await cache.clear(); } + emitter.emitAsync(`item.create.${this.collection}`, { + collection: this.collection, + item: primaryKeys, + action: 'create', + payload: payloads, + }); + return primaryKeys; }); @@ -311,6 +319,13 @@ export default class ItemsService implements AbstractService { await cache.clear(); } + emitter.emitAsync(`item.update.${this.collection}`, { + collection: this.collection, + item: key, + action: 'update', + payload, + }); + return key; } @@ -373,6 +388,12 @@ export default class ItemsService implements AbstractService { await cache.clear(); } + emitter.emitAsync(`item.delete.${this.collection}`, { + collection: this.collection, + item: key, + action: 'delete', + }); + return key; } From 9546dbb834f7f64d5ee7067f3fbe71e3ad593c40 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 10 Sep 2020 14:00:28 -0400 Subject: [PATCH 3/9] Dynamically register / fire webhooks --- api/src/app.ts | 6 +++ api/src/controllers/webhooks.ts | 14 +++-- api/src/database/seeds/system.yaml | 22 +++++++- api/src/emitter.ts | 2 +- api/src/services/webhooks.ts | 83 +++++++++++++++++++++++++++++- api/src/types/index.ts | 1 + api/src/types/webhooks.ts | 10 ++++ 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 api/src/types/webhooks.ts diff --git a/api/src/app.ts b/api/src/app.ts index cef1c0a8eb..d8ea0c0745 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -36,10 +36,12 @@ import webhooksRouter from './controllers/webhooks'; import notFoundHandler from './controllers/not-found'; import sanitizeQuery from './middleware/sanitize-query'; +import WebhooksService from './services/webhooks'; validateEnv(['KEY', 'SECRET']); const app = express(); + app.disable('x-powered-by'); app.set('trust proxy', true); @@ -99,4 +101,8 @@ app.use(respond); app.use(notFoundHandler); app.use(errorHandler); +// Register all webhooks +const webhooksService = new WebhooksService(); +webhooksService.register(); + export default app; diff --git a/api/src/controllers/webhooks.ts b/api/src/controllers/webhooks.ts index f13c09136f..9f224a3dba 100644 --- a/api/src/controllers/webhooks.ts +++ b/api/src/controllers/webhooks.ts @@ -13,7 +13,8 @@ router.post( const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; - }), + return next(); + }) ); router.get( @@ -26,7 +27,8 @@ router.get( const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery); res.locals.payload = { data: records || null, meta }; - }), + return next(); + }) ); router.get( @@ -36,7 +38,8 @@ router.get( const record = await service.readByKey(req.params.pk, req.sanitizedQuery); res.locals.payload = { data: record || null }; - }), + return next(); + }) ); router.patch( @@ -47,7 +50,8 @@ router.patch( const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; - }), + return next(); + }) ); router.delete( @@ -57,7 +61,7 @@ router.delete( await service.delete(req.params.pk); return next(); - }), + }) ); export default router; diff --git a/api/src/database/seeds/system.yaml b/api/src/database/seeds/system.yaml index 968cddfbc9..1a7b519696 100644 --- a/api/src/database/seeds/system.yaml +++ b/api/src/database/seeds/system.yaml @@ -520,7 +520,27 @@ tables: directus_webhooks: id: increments: true - # TBD + name: + type: string + length: 255 + method: + type: string + length: 10 + url: + type: string + length: 255 + status: + type: string + length: 10 + data: + type: boolean + default: false + actions: + type: string + length: 100 + collections: + type: string + length: 255 rows: directus_collections: diff --git a/api/src/emitter.ts b/api/src/emitter.ts index a0a4563949..b6a984e212 100644 --- a/api/src/emitter.ts +++ b/api/src/emitter.ts @@ -1,5 +1,5 @@ import { EventEmitter2 } from 'eventemitter2'; -const emitter = new EventEmitter2({ wildcard: true }); +const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true }); export default emitter; diff --git a/api/src/services/webhooks.ts b/api/src/services/webhooks.ts index f4b962f759..f16ecf20f1 100644 --- a/api/src/services/webhooks.ts +++ b/api/src/services/webhooks.ts @@ -1,8 +1,89 @@ import ItemsService from './items'; -import { AbstractServiceOptions } from '../types'; +import { Item, PrimaryKey, AbstractServiceOptions } from '../types'; +import emitter from '../emitter'; +import { ListenerFn } from 'eventemitter2'; +import { Webhook } from '../types'; +import axios from 'axios'; +import logger from '../logger'; + +let registered: { event: string; handler: ListenerFn }[] = []; export default class WebhooksService extends ItemsService { constructor(options?: AbstractServiceOptions) { super('directus_webhooks', options); } + + async register() { + this.unregister(); + + const webhooks = await this.knex + .select('*') + .from('directus_webhooks') + .where({ status: 'active' }); + + for (const webhook of webhooks) { + if (webhook.actions === '*') { + if (webhook.collections === '*') { + const event = 'item.*.*'; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } else { + for (const collection of webhook.collections.split(',')) { + const event = `item.*.${collection}`; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } + } + } else { + for (const action of webhook.actions.split(',')) { + if (webhook.collections === '*') { + const event = `item.${action}.*`; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } else { + for (const collection of webhook.collections.split(',')) { + const event = `item.${action}.${collection}`; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } + } + } + } + } + } + + unregister() { + for (const { event, handler } of registered) { + emitter.off(event, handler); + } + + registered = []; + } + + createHandler(webhook: Webhook): ListenerFn { + return async (data) => { + try { + await axios({ + url: webhook.url, + method: webhook.method, + data: webhook.data ? data : null, + }); + } catch (error) { + logger.warn(`Webhook "${webhook.name}" (id: ${webhook.id}) failed`); + logger.warn(error); + } + }; + } + + async create(data: Partial[]): Promise; + async create(data: Partial): Promise; + async create(data: Partial | Partial[]): Promise { + const result = await super.create(data); + await this.register(); + return result; + } } diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 1543d0e6e8..52ca062a11 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -12,3 +12,4 @@ export * from './query'; export * from './relation'; export * from './services'; export * from './sessions'; +export * from './webhooks'; diff --git a/api/src/types/webhooks.ts b/api/src/types/webhooks.ts new file mode 100644 index 0000000000..21922a0903 --- /dev/null +++ b/api/src/types/webhooks.ts @@ -0,0 +1,10 @@ +export type Webhook = { + id: number; + name: string; + method: 'GET' | 'POST'; + url: string; + status: 'active' | 'inactive'; + data: boolean; + actions: string; + collections: string; +}; From 587d325301892ab0693f785af8db152fe5cafd42 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 10 Sep 2020 14:44:52 -0400 Subject: [PATCH 4/9] Re-init webhooks correclty --- api/src/services/fields.ts | 2 ++ api/src/services/webhooks.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index f655654186..3cafb703f6 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -234,6 +234,8 @@ export default class FieldsService { const type = field.type as 'float' | 'decimal'; /** @todo add precision and scale support */ column = table[type](field.field /* precision, scale */); + } else if (field.type === 'csv') { + column = table.string(field.field); } else { column = table[field.type](field.field); } diff --git a/api/src/services/webhooks.ts b/api/src/services/webhooks.ts index f16ecf20f1..bd0c29388d 100644 --- a/api/src/services/webhooks.ts +++ b/api/src/services/webhooks.ts @@ -83,7 +83,33 @@ export default class WebhooksService extends ItemsService { async create(data: Partial): Promise; async create(data: Partial | Partial[]): Promise { const result = await super.create(data); + await this.register(); + + return result; + } + + update(data: Partial, keys: PrimaryKey[]): Promise; + update(data: Partial, key: PrimaryKey): Promise; + update(data: Partial[]): Promise; + async update( + data: Partial | Partial[], + key?: PrimaryKey | PrimaryKey[] + ): Promise { + const result = await super.update(data, key as any); + + await this.register(); + + return result; + } + + delete(key: PrimaryKey): Promise; + delete(keys: PrimaryKey[]): Promise; + async delete(key: PrimaryKey | PrimaryKey[]): Promise { + const result = await super.delete(key as any); + + await this.register(); + return result; } } From 6f201e72b0f4f4439708c5bd500b5e3a94d3ade8 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 10 Sep 2020 14:44:55 -0400 Subject: [PATCH 5/9] Add webhooks browse --- app/src/lang/en-US/index.json | 3 + app/src/modules/collections/index.ts | 8 + .../settings/routes/webhooks/browse.vue | 153 +++++++++++++++++- app/src/types/fields.ts | 1 + .../get-default-interface-for-type.ts | 1 + 5 files changed, 160 insertions(+), 6 deletions(-) diff --git a/app/src/lang/en-US/index.json b/app/src/lang/en-US/index.json index 28c5cc9972..6ce943dc18 100644 --- a/app/src/lang/en-US/index.json +++ b/app/src/lang/en-US/index.json @@ -459,6 +459,9 @@ "user_count": "No Users | One User | {count} Users", "no_users_copy": "There are no users in this role yet.", + "webhooks_count": "No Webhooks | One Webhook | {count} Webhooks", + "no_webhooks_copy": "There are no webhooks yet.", + "all_items": "All Items", "no_collections": "No Collections", diff --git a/app/src/modules/collections/index.ts b/app/src/modules/collections/index.ts index b32b6d7c33..785dec032c 100644 --- a/app/src/modules/collections/index.ts +++ b/app/src/modules/collections/index.ts @@ -32,6 +32,14 @@ const checkForSystem: NavigationGuard = (to, from, next) => { } } + if (to.params.collection === 'directus_webhooks') { + if (to.params.primaryKey) { + return next(`/settings/webhooks/${to.params.primaryKey}`); + } else { + return next('/settings/webhooks'); + } + } + return next(); }; diff --git a/app/src/modules/settings/routes/webhooks/browse.vue b/app/src/modules/settings/routes/webhooks/browse.vue index 2e9b58f4ee..acf688e878 100644 --- a/app/src/modules/settings/routes/webhooks/browse.vue +++ b/app/src/modules/settings/routes/webhooks/browse.vue @@ -12,9 +12,78 @@ -
- Pre-Release: Feature not yet available -
+ + + + + + + diff --git a/app/src/types/fields.ts b/app/src/types/fields.ts index 46bf69b359..acbf689417 100644 --- a/app/src/types/fields.ts +++ b/app/src/types/fields.ts @@ -23,6 +23,7 @@ export const types = [ 'timestamp', 'binary', 'uuid', + 'csv', 'unknown', ] as const; diff --git a/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts b/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts index 7753595c10..448e045369 100644 --- a/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts +++ b/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts @@ -17,6 +17,7 @@ const defaultInterfaceMap: Record = { timestamp: 'datetime', uuid: 'text-input', unknown: 'text-input', + csv: 'tags' }; /** From ce5cc907752f1ebd0ec9ffc70fdda8b406aab1ca Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Thu, 10 Sep 2020 14:58:08 -0400 Subject: [PATCH 6/9] Add collections interface --- api/src/database/seeds/system.yaml | 2 +- app/src/interfaces/collection/collection.vue | 45 +++++++++++++++++++ app/src/interfaces/collection/index.ts | 29 ++++++++++++ .../interfaces/collections/collections.vue | 4 +- app/src/interfaces/collections/index.ts | 4 +- app/src/lang/en-US/interfaces.json | 5 +++ 6 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 app/src/interfaces/collection/collection.vue create mode 100644 app/src/interfaces/collection/index.ts diff --git a/api/src/database/seeds/system.yaml b/api/src/database/seeds/system.yaml index 1a7b519696..e18ceee482 100644 --- a/api/src/database/seeds/system.yaml +++ b/api/src/database/seeds/system.yaml @@ -901,7 +901,7 @@ rows: field: collection type: string system: - interface: collections + interface: collection width: full special: json sort: 10 diff --git a/app/src/interfaces/collection/collection.vue b/app/src/interfaces/collection/collection.vue new file mode 100644 index 0000000000..b6265bee3f --- /dev/null +++ b/app/src/interfaces/collection/collection.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/src/interfaces/collection/index.ts b/app/src/interfaces/collection/index.ts new file mode 100644 index 0000000000..0bf3265f0d --- /dev/null +++ b/app/src/interfaces/collection/index.ts @@ -0,0 +1,29 @@ +import { defineInterface } from '@/interfaces/define'; +import InterfaceCollection from './collection.vue'; + +export default defineInterface(({ i18n }) => ({ + id: 'collections', + name: i18n.t('interfaces.collection.collection'), + description: i18n.t('interfaces.collection.description'), + icon: 'featured_play_list', + component: InterfaceCollection, + types: ['string'], + options: [ + { + field: 'includeSystem', + name: i18n.t('system'), + type: 'boolean', + meta: { + width: 'half', + interface: 'toggle', + options: { + label: i18n.t('interfaces.collection.include_system_collections'), + }, + }, + schema: { + default_value: false, + }, + }, + ], + recommendedDisplays: ['collection'], +})); diff --git a/app/src/interfaces/collections/collections.vue b/app/src/interfaces/collections/collections.vue index b6265bee3f..60563684b0 100644 --- a/app/src/interfaces/collections/collections.vue +++ b/app/src/interfaces/collections/collections.vue @@ -1,5 +1,5 @@