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 3 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
8 changes: 7 additions & 1 deletion packages/core/core/src/services/document-service/index.ts
Expand Up @@ -3,6 +3,7 @@ import type { Core, Modules, UID } from '@strapi/types';
import { createMiddlewareManager, databaseErrorsMiddleware } from './middlewares';
import { createContentTypeRepository } from './repository';
import { transformData } from './transform/data';
import { registerEntryWebhooks } from './webhooks';

/**
* Repository to :
Expand All @@ -19,9 +20,14 @@ 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();

// Register the document service webhooks
registerEntryWebhooks(strapi);

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

const factory = function factory(uid: UID.ContentType) {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/core/src/services/document-service/repository.ts
Expand Up @@ -13,6 +13,7 @@ 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 { emitWebhook } from './webhooks';

export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
const contentType = strapi.contentType(uid);
Expand Down Expand Up @@ -83,6 +84,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
// Delete all matched entries and its components
await async.map(entriesToDelete, (entryToDelete: any) => entries.delete(entryToDelete.id));

entriesToDelete.forEach(emitWebhook(uid, 'entry.delete'));

return { deletedEntries: entriesToDelete };
}

Expand All @@ -99,6 +102,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {

const doc = await entries.create(queryParams);

emitWebhook(uid, 'entry.create', doc);

if (hasDraftAndPublish && params.status === 'published') {
return publish({
...params,
Expand Down Expand Up @@ -142,6 +147,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
)
);

clonedEntries.forEach(emitWebhook(uid, 'entry.create'));

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

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

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

Expand Down Expand Up @@ -237,6 +246,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
entries.publish(draft, queryParams)
);

versions.forEach(emitWebhook(uid, 'entry.publish'));

return { versions };
}

Expand All @@ -254,6 +265,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
lookup: { ...queryParams?.lookup, publishedAt: { $ne: null } },
});

deletedEntries.forEach(emitWebhook(uid, 'entry.unpublish'));

return { versions: deletedEntries };
}

Expand Down Expand Up @@ -287,6 +300,8 @@ export const createContentTypeRepository: RepositoryFactoryMethod = (uid) => {
entries.discardDraft(entry, queryParams)
);

draftEntries.forEach(emitWebhook(uid, 'entry.draft-discard'));

return { versions: draftEntries };
}

Expand Down
82 changes: 82 additions & 0 deletions packages/core/core/src/services/document-service/webhooks.ts
@@ -0,0 +1,82 @@
import { curry } from 'lodash/fp';

import { UID, Schema, Utils, Core, Modules } from '@strapi/types';
import { sanitize } from '@strapi/utils';

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

const ALLOWED_WEBHOOK_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 WebhookEvent = Utils.Object.Values<typeof ALLOWED_WEBHOOK_EVENTS>;

const sanitizeEntry = async (
model: Schema.ContentType<any> | Schema.Component<any>,
entry: Modules.Documents.AnyDocument
) => {
return sanitize.sanitizers.defaultSanitizeOutput(
{
schema: model,
getModel(uid) {
return strapi.getModel(uid as UID.Schema);
},
},
entry
);
};

/**
* Registers the content events that will be emitted using the document service.
*/
const registerEntryWebhooks = (strapi: Core.Strapi) => {
Marc-Roig marked this conversation as resolved.
Show resolved Hide resolved
Object.entries(ALLOWED_WEBHOOK_EVENTS).forEach(([key, value]) => {
strapi.get('webhookStore').addAllowedEvent(key, value);
});
};

/**
* Triggers a webhook event.
*
* It will populate the entry if it is not a delete event.
* So the webhook payload will contain the full entry.
*/
const emitWebhook = async (
Marc-Roig marked this conversation as resolved.
Show resolved Hide resolved
uid: UID.Schema,
eventName: WebhookEvent,
entry: Modules.Documents.AnyDocument
) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I find odd about this function is that it's not webhook specific. What it technically does is just emit an event, which affects webhooks as much as audit logs or any potential other listener.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should prevent any type of event being userd in this function? it could easily be an if case

const populate = getDeepPopulate(uid, {});
const model = strapi.getModel(uid);

const emitEvent = async () => {
// There is no need to populate the entry if it has been deleted
let populatedEntry = entry;
if (eventName !== 'entry.delete' && eventName !== 'entry.unpublish') {
populatedEntry = await strapi.db.query(uid).findOne({ where: { id: entry.id }, populate });
}

const sanitizedEntry = await sanitizeEntry(model, populatedEntry);

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

/**
* strapi.db.query might reuse the transaction used in the doc service request,
* so this is executed after that transaction is committed.
*/
strapi.db.transaction(async ({ onCommit }) => onCommit(emitEvent));
};

const curriedEmitWebhook = curry(emitWebhook);

export { registerEntryWebhooks, curriedEmitWebhook as emitWebhook };