Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: webhooks #20129

Merged
merged 22 commits into from May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
15 changes: 12 additions & 3 deletions packages/core/core/src/services/event-hub.ts
Expand Up @@ -11,6 +11,7 @@ export interface EventHub {
destroy(): EventHub;
removeListener(eventName: string, listener: Listener): void;
removeAllListeners(): EventHub;
removeAllSubscribers(): EventHub;
addListener(eventName: string, listener: Listener): () => void;
}

Expand Down Expand Up @@ -82,8 +83,8 @@ export default function createEventHub(): EventHub {
},

destroy() {
listeners.clear();
subscribers.length = 0;
this.removeAllListeners();
this.removeAllSubscribers();
return this;
},

Expand All @@ -92,7 +93,15 @@ export default function createEventHub(): EventHub {
},

removeAllListeners() {
return eventHub.destroy();
listeners.clear();
return this;
},

removeAllSubscribers() {
subscribers.length = 0;
alexandrebodin marked this conversation as resolved.
Show resolved Hide resolved
// Re-add the default subscriber
subscribers.push(defaultSubscriber);
return this;
},

addListener(eventName, listener) {
Expand Down