Skip to content

Commit

Permalink
feat: webhooks (#20129)
Browse files Browse the repository at this point in the history
* feat: webhooks

* feat: ignore entry.publish findOne

* chore: type entry

* feat: refactor publication methods to not trigger entry.delete webhook (#20136)

* feat: refactor publication methods

* fix: remove unnecessary chain op in db query

* feat: emit document. as webhook

* feat: register  legacy events

* feat: only trigger entry webhooks

* feat: rename webhook to event

* feat: event manager

* fix: repository

* feat: webhook tests

* fix: api tests

* fix: non localized fields api test

* fix: refactor event hub

* fix: eventhub type

* fix: event hub unit test

* fix: remove all subscribers

* fix: webhook api test
  • Loading branch information
Marc-Roig committed May 3, 2024
1 parent 86372bc commit 23fa63e
Show file tree
Hide file tree
Showing 11 changed files with 420 additions and 76 deletions.
28 changes: 12 additions & 16 deletions packages/core/core/src/services/__tests__/event-hub.test.ts
Expand Up @@ -83,42 +83,38 @@ describe('Event Hub', () => {
});

it('removes all subscribers on destroy()', async () => {
const { subscribe, on, emit, destroy } = createEventHub();
const eventHub = createEventHub();

const fn = jest.fn();
const fn2 = jest.fn();
subscribe(fn);
on('my-event', fn2);
eventHub.subscribe(fn);
eventHub.on('my-event', fn2);

await emit('my-event');
await eventHub.emit('my-event');
expect(fn).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();

destroy();
eventHub.destroy();

// Subscribers are removed
await emit('my-event');
await eventHub.emit('my-event');
expect(fn).toHaveBeenCalledTimes(1);
expect(fn2).toHaveBeenCalledTimes(1);
});

it('removes all subscribers on removeAllListeners()', async () => {
const { subscribe, on, emit, removeAllListeners } = createEventHub();
it('removes all subscribers on removeAllSubscribers()', async () => {
const eventHub = createEventHub();

const fn = jest.fn();
const fn2 = jest.fn();
subscribe(fn);
on('my-event', fn2);
eventHub.subscribe(fn);

await emit('my-event');
await eventHub.emit('my-event');
expect(fn).toHaveBeenCalled();
expect(fn2).toHaveBeenCalled();

removeAllListeners();
eventHub.removeAllSubscribers();

// Subscribers are removed
await emit('my-event');
await eventHub.emit('my-event');
expect(fn).toHaveBeenCalledTimes(1);
expect(fn2).toHaveBeenCalledTimes(1);
});
});
4 changes: 3 additions & 1 deletion packages/core/core/src/services/document-service/entries.ts
Expand Up @@ -48,9 +48,11 @@ const createEntriesService = (uid: UID.ContentType) => {
async function deleteEntry(id: number) {
const componentsToDelete = await components.getComponents(uid, { id });

await strapi.db.query(uid).delete({ where: { id } });
const deletedEntry = await strapi.db.query(uid).delete({ where: { id } });

await components.deleteComponents(uid, componentsToDelete as any, { loadComponents: false });

return deletedEntry;
}

async function updateEntry(entryToUpdate: any, params = {} as any) {
Expand Down
62 changes: 62 additions & 0 deletions packages/core/core/src/services/document-service/events.ts
@@ -0,0 +1,62 @@
import { UID, Utils, Modules, Core } from '@strapi/types';
import { sanitize } from '@strapi/utils';

import { getDeepPopulate } from './utils/populate';

const EVENTS = {
ENTRY_CREATE: 'entry.create',
ENTRY_UPDATE: 'entry.update',
ENTRY_DELETE: 'entry.delete',
ENTRY_PUBLISH: 'entry.publish',
ENTRY_UNPUBLISH: 'entry.unpublish',
ENTRY_DRAFT_DISCARD: 'entry.draft-discard',
};

type EventName = Utils.Object.Values<typeof EVENTS>;

/**
* Manager to trigger entry related events
*
* It will populate the entry if it is not a delete event.
* So the event payload will contain the full entry.
*/
const createEventManager = (strapi: Core.Strapi, uid: UID.Schema) => {
const populate = getDeepPopulate(uid, {});
const model = strapi.getModel(uid);

const emitEvent = async (eventName: EventName, entry: Modules.Documents.AnyDocument) => {
// There is no need to populate the entry if it has been deleted
let populatedEntry = entry;
if (![EVENTS.ENTRY_DELETE, EVENTS.ENTRY_UNPUBLISH].includes(eventName)) {
populatedEntry = await strapi.db.query(uid).findOne({ where: { id: entry.id }, populate });
}

const sanitizedEntry = await sanitize.sanitizers.defaultSanitizeOutput(
{
schema: model,
getModel: (uid) => strapi.getModel(uid as UID.Schema),
},
populatedEntry
);

await strapi.eventHub.emit(eventName, {
model: model.modelName,
uid: model.uid,
entry: sanitizedEntry,
});
};

return {
/**
* strapi.db.query might reuse the transaction used in the doc service request,
* so this is executed after that transaction is committed.
*/
emitEvent(eventName: EventName, entry: Modules.Documents.AnyDocument) {
strapi.db.transaction(({ onCommit }) => {
onCommit(() => emitEvent(eventName, entry));
});
},
};
};

export { createEventManager };
4 changes: 3 additions & 1 deletion packages/core/core/src/services/document-service/index.ts
Expand Up @@ -19,9 +19,11 @@ import { transformData } from './transform/data';
*
*/
export const createDocumentService = (strapi: Core.Strapi): Modules.Documents.Service => {
// Cache the repositories (one per content type)
const repositories = new Map<string, Modules.Documents.ServiceInstance>();
const middlewares = createMiddlewareManager();

// Manager to handle document service middlewares
const middlewares = createMiddlewareManager();
middlewares.use(databaseErrorsMiddleware);

const factory = function factory(uid: UID.ContentType) {
Expand Down
118 changes: 74 additions & 44 deletions packages/core/core/src/services/document-service/repository.ts
@@ -1,4 +1,4 @@
import { omit, assoc, merge } from 'lodash/fp';
import { omit, assoc, merge, curry } from 'lodash/fp';

import { async, contentTypes as contentTypesUtils } from '@strapi/utils';

Expand All @@ -13,13 +13,17 @@ import { createDocumentId } from '../../utils/transform-content-types-to-models'
import { getDeepPopulate } from './utils/populate';
import { transformParamsToQuery } from './transform/query';
import { transformParamsDocumentId } from './transform/id-transform';
import { createEventManager } from './events';

export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
const contentType = strapi.contentType(uid);
const hasDraftAndPublish = contentTypesUtils.hasDraftAndPublish(contentType);

const entries = createEntriesService(uid);

const eventManager = createEventManager(strapi, uid);
const emitEvent = curry(eventManager.emitEvent);

async function findMany(params = {} as any) {
const query = await async.pipe(
DP.defaultToDraft,
Expand Down Expand Up @@ -81,9 +85,13 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
const entriesToDelete = await strapi.db.query(uid).findMany(query);

// Delete all matched entries and its components
await async.map(entriesToDelete, (entryToDelete: any) => entries.delete(entryToDelete.id));
const deletedEntries = await async.map(entriesToDelete, (entryToDelete: any) =>
entries.delete(entryToDelete.id)
);

return { documentId, entries: entriesToDelete };
entriesToDelete.forEach(emitEvent('entry.delete'));

return { documentId, entries: deletedEntries };
}

async function create(opts = {} as any) {
Expand All @@ -99,6 +107,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {

const doc = await entries.create(queryParams);

emitEvent('entry.create', doc);

if (hasDraftAndPublish && params.status === 'published') {
return publish({
...params,
Expand All @@ -119,7 +129,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
)(params);

// Get deep populate
const entriesToClone = await strapi.db?.query(uid).findMany({
const entriesToClone = await strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
Expand All @@ -142,6 +152,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
)
);

clonedEntries.forEach(emitEvent('entry.create'));

return { documentId: clonedEntries.at(0)?.documentId, entries: clonedEntries };
}

Expand Down Expand Up @@ -171,6 +183,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
let updatedDraft = null;
if (entryToUpdate) {
updatedDraft = await entries.update(entryToUpdate, queryParams);
emitEvent('entry.update', updatedDraft);
}

if (!updatedDraft) {
Expand All @@ -183,6 +196,7 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
...queryParams,
data: { ...queryParams.data, documentId },
});
emitEvent('entry.create', updatedDraft);
}
}

Expand Down Expand Up @@ -216,45 +230,54 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
i18n.multiLocaleToLookup(contentType)
)(params);

await deleteDocument({
...queryParams,
documentId,
lookup: { ...queryParams?.lookup, publishedAt: { $ne: null } },
});

// Get deep populate
const draftsToPublish = await strapi.db?.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: null,
},
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
});
const [draftsToPublish, publishedToDelete] = await Promise.all([
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: null, // Ignore lookup
},
// Populate relations, media, compos and dz
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
}),
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: { $ne: null },
},
select: ['id'],
}),
]);

// Delete all published versions
await async.map(publishedToDelete, (entry: any) => entries.delete(entry.id));

// Transform draft entry data and create published versions
const publishedEntries = await async.map(draftsToPublish, (draft: unknown) =>
entries.publish(draft, queryParams)
);

publishedEntries.forEach(emitEvent('entry.publish'));
return { documentId, entries: publishedEntries };
}

async function unpublish(opts = {} as any) {
const { documentId, ...params } = opts;

const queryParams = await async.pipe(
const query = await async.pipe(
i18n.defaultLocale(contentType),
i18n.multiLocaleToLookup(contentType)
i18n.multiLocaleToLookup(contentType),
transformParamsToQuery(uid),
(query) => assoc('where', { ...query.where, documentId, publishedAt: { $ne: null } }, query)
)(params);

const { entries } = await deleteDocument({
...params,
documentId,
lookup: { ...queryParams?.lookup, publishedAt: { $ne: null } },
});
// Delete all published versions
const versionsToDelete = await strapi.db.query(uid).findMany(query);
await async.map(versionsToDelete, (entry: any) => entries.delete(entry.id));

return { documentId, entries };
versionsToDelete.forEach(emitEvent('entry.unpublish'));
return { documentId, entries: versionsToDelete };
}

async function discardDraft(opts = {} as any) {
Expand All @@ -265,28 +288,35 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
i18n.multiLocaleToLookup(contentType)
)(params);

// Delete all drafts that match query
await deleteDocument({
...queryParams,
documentId,
lookup: { ...queryParams?.lookup, publishedAt: null },
});

// Get deep populate of published versions
const entriesToDraft = await strapi.db?.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: { $ne: null },
},
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
});
const [versionsToDraft, versionsToDelete] = await Promise.all([
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: { $ne: null },
},
// Populate relations, media, compos and dz
populate: getDeepPopulate(uid, { relationalFields: ['documentId', 'locale'] }),
}),
strapi.db.query(uid).findMany({
where: {
...queryParams?.lookup,
documentId,
publishedAt: null,
},
select: ['id'],
}),
]);

// Delete all drafts
await async.map(versionsToDelete, (entry: any) => entries.delete(entry.id));

// Transform published entry data and create draft versions
const draftEntries = await async.map(entriesToDraft, (entry: any) =>
const draftEntries = await async.map(versionsToDraft, (entry: any) =>
entries.discardDraft(entry, queryParams)
);

draftEntries.forEach(emitEvent('entry.draft-discard'));
return { documentId, entries: draftEntries };
}

Expand Down

0 comments on commit 23fa63e

Please sign in to comment.