refactor(core): Reorganize webhook related components under src/webhooks (no-changelog) (#10296)
This commit is contained in:
@@ -1,98 +0,0 @@
|
||||
import type { CacheService } from '@/services/cache/cache.service';
|
||||
import type { OrchestrationService } from '@/services/orchestration.service';
|
||||
import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service';
|
||||
import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
describe('TestWebhookRegistrationsService', () => {
|
||||
const cacheService = mock<CacheService>();
|
||||
const registrations = new TestWebhookRegistrationsService(
|
||||
cacheService,
|
||||
mock<OrchestrationService>({ isMultiMainSetupEnabled: false }),
|
||||
);
|
||||
|
||||
const registration = mock<TestWebhookRegistration>({
|
||||
webhook: { httpMethod: 'GET', path: 'hello', webhookId: undefined },
|
||||
});
|
||||
|
||||
const webhookKey = 'GET|hello';
|
||||
const cacheKey = 'test-webhooks';
|
||||
|
||||
describe('register()', () => {
|
||||
test('should register a test webhook registration', async () => {
|
||||
await registrations.register(registration);
|
||||
|
||||
expect(cacheService.setHash).toHaveBeenCalledWith(cacheKey, { [webhookKey]: registration });
|
||||
});
|
||||
|
||||
test('should skip setting TTL in single-main setup', async () => {
|
||||
await registrations.register(registration);
|
||||
|
||||
expect(cacheService.expire).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregister()', () => {
|
||||
test('should deregister a test webhook registration', async () => {
|
||||
await registrations.register(registration);
|
||||
|
||||
await registrations.deregister(webhookKey);
|
||||
|
||||
expect(cacheService.deleteFromHash).toHaveBeenCalledWith(cacheKey, webhookKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
test('should retrieve a test webhook registration', async () => {
|
||||
cacheService.getHashValue.mockResolvedValueOnce(registration);
|
||||
|
||||
const promise = registrations.get(webhookKey);
|
||||
|
||||
await expect(promise).resolves.toBe(registration);
|
||||
});
|
||||
|
||||
test('should return undefined if no such test webhook registration was found', async () => {
|
||||
cacheService.getHashValue.mockResolvedValueOnce(undefined);
|
||||
|
||||
const promise = registrations.get(webhookKey);
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllKeys()', () => {
|
||||
test('should retrieve all test webhook registration keys', async () => {
|
||||
cacheService.getHash.mockResolvedValueOnce({ [webhookKey]: registration });
|
||||
|
||||
const result = await registrations.getAllKeys();
|
||||
|
||||
expect(result).toEqual([webhookKey]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllRegistrations()', () => {
|
||||
test('should retrieve all test webhook registrations', async () => {
|
||||
cacheService.getHash.mockResolvedValueOnce({ [webhookKey]: registration });
|
||||
|
||||
const result = await registrations.getAllRegistrations();
|
||||
|
||||
expect(result).toEqual([registration]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregisterAll()', () => {
|
||||
test('should deregister all test webhook registrations', async () => {
|
||||
await registrations.deregisterAll();
|
||||
|
||||
expect(cacheService.delete).toHaveBeenCalledWith(cacheKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toKey()', () => {
|
||||
test('should convert a test webhook registration to a key', () => {
|
||||
const result = registrations.toKey(registration.webhook);
|
||||
|
||||
expect(result).toBe(webhookKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import config from '@/config';
|
||||
import { WebhookRepository } from '@db/repositories/webhook.repository';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { WebhookService } from '@/services/webhook.service';
|
||||
import { WebhookEntity } from '@db/entities/WebhookEntity';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) =>
|
||||
Object.assign(new WebhookEntity(), {
|
||||
method,
|
||||
webhookPath: path,
|
||||
webhookId,
|
||||
pathSegments,
|
||||
}) as WebhookEntity;
|
||||
|
||||
describe('WebhookService', () => {
|
||||
const webhookRepository = mockInstance(WebhookRepository);
|
||||
const cacheService = mockInstance(CacheService);
|
||||
const webhookService = new WebhookService(webhookRepository, cacheService);
|
||||
|
||||
beforeEach(() => {
|
||||
config.load(config.default);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
[true, false].forEach((isCacheEnabled) => {
|
||||
const tag = '[' + ['cache', isCacheEnabled ? 'enabled' : 'disabled'].join(' ') + ']';
|
||||
|
||||
describe(`findWebhook() - static case ${tag}`, () => {
|
||||
test('should return the webhook if found', async () => {
|
||||
const method = 'GET';
|
||||
const path = 'user/profile';
|
||||
const mockWebhook = createWebhook(method, path);
|
||||
|
||||
webhookRepository.findOneBy.mockResolvedValue(mockWebhook);
|
||||
|
||||
const returnedWebhook = await webhookService.findWebhook(method, path);
|
||||
|
||||
expect(returnedWebhook).toBe(mockWebhook);
|
||||
});
|
||||
|
||||
test('should return null if not found', async () => {
|
||||
webhookRepository.findOneBy.mockResolvedValue(null); // static
|
||||
webhookRepository.findBy.mockResolvedValue([]);
|
||||
|
||||
const returnValue = await webhookService.findWebhook('GET', 'user/profile');
|
||||
|
||||
expect(returnValue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(`findWebhook() - dynamic case ${tag}`, () => {
|
||||
test('should return the webhook if found', async () => {
|
||||
const method = 'GET';
|
||||
const webhookId = uuid();
|
||||
const path = 'user/:id/posts';
|
||||
const mockWebhook = createWebhook(method, path, webhookId, 3);
|
||||
|
||||
webhookRepository.findOneBy.mockResolvedValue(null); // static
|
||||
webhookRepository.findBy.mockResolvedValue([mockWebhook]); // dynamic
|
||||
|
||||
const returnedWebhook = await webhookService.findWebhook(
|
||||
method,
|
||||
[webhookId, 'user/123/posts'].join('/'),
|
||||
);
|
||||
|
||||
expect(returnedWebhook).toBe(mockWebhook);
|
||||
});
|
||||
|
||||
test('should handle subset dynamic path case', async () => {
|
||||
const method1 = 'GET';
|
||||
const webhookId1 = uuid();
|
||||
const path1 = 'user/:id/posts';
|
||||
const mockWebhook1 = createWebhook(method1, path1, webhookId1, 3);
|
||||
|
||||
const method2 = 'GET';
|
||||
const webhookId2 = uuid();
|
||||
const path2 = 'user/:id/posts/:postId/comments';
|
||||
const mockWebhook2 = createWebhook(method2, path2, webhookId2, 3);
|
||||
|
||||
webhookRepository.findOneBy.mockResolvedValue(null); // static
|
||||
webhookRepository.findBy.mockResolvedValue([mockWebhook1, mockWebhook2]); // dynamic
|
||||
|
||||
const fullPath1 = [webhookId1, 'user/123/posts'].join('/');
|
||||
const returnedWebhook1 = await webhookService.findWebhook(method1, fullPath1);
|
||||
|
||||
const fullPath2 = [webhookId1, 'user/123/posts/456/comments'].join('/');
|
||||
const returnedWebhook2 = await webhookService.findWebhook(method2, fullPath2);
|
||||
|
||||
expect(returnedWebhook1).toBe(mockWebhook1);
|
||||
expect(returnedWebhook2).toBe(mockWebhook2);
|
||||
});
|
||||
|
||||
test('should handle single-segment dynamic path case', async () => {
|
||||
const method1 = 'GET';
|
||||
const webhookId1 = uuid();
|
||||
const path1 = ':var';
|
||||
const mockWebhook1 = createWebhook(method1, path1, webhookId1, 3);
|
||||
|
||||
const method2 = 'GET';
|
||||
const webhookId2 = uuid();
|
||||
const path2 = 'user/:id/posts/:postId/comments';
|
||||
const mockWebhook2 = createWebhook(method2, path2, webhookId2, 3);
|
||||
|
||||
webhookRepository.findOneBy.mockResolvedValue(null); // static
|
||||
webhookRepository.findBy.mockResolvedValue([mockWebhook1, mockWebhook2]); // dynamic
|
||||
|
||||
const fullPath = [webhookId1, 'user/123/posts/456'].join('/');
|
||||
const returnedWebhook = await webhookService.findWebhook(method1, fullPath);
|
||||
|
||||
expect(returnedWebhook).toBe(mockWebhook1);
|
||||
});
|
||||
|
||||
test('should return null if not found', async () => {
|
||||
const fullPath = [uuid(), 'user/:id/posts'].join('/');
|
||||
|
||||
webhookRepository.findOneBy.mockResolvedValue(null); // static
|
||||
webhookRepository.findBy.mockResolvedValue([]); // dynamic
|
||||
|
||||
const returnValue = await webhookService.findWebhook('GET', fullPath);
|
||||
|
||||
expect(returnValue).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWebhookMethods()', () => {
|
||||
test('should return all methods for webhook', async () => {
|
||||
const path = 'user/profile';
|
||||
|
||||
webhookRepository.find.mockResolvedValue([
|
||||
createWebhook('GET', path),
|
||||
createWebhook('POST', path),
|
||||
createWebhook('PUT', path),
|
||||
createWebhook('PATCH', path),
|
||||
]);
|
||||
|
||||
const returnedMethods = await webhookService.getWebhookMethods(path);
|
||||
|
||||
expect(returnedMethods).toEqual(['GET', 'POST', 'PUT', 'PATCH']);
|
||||
});
|
||||
|
||||
test('should return empty array if no webhooks found', async () => {
|
||||
webhookRepository.find.mockResolvedValue([]);
|
||||
|
||||
const returnedMethods = await webhookService.getWebhookMethods('user/profile');
|
||||
|
||||
expect(returnedMethods).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteWorkflowWebhooks()', () => {
|
||||
test('should delete all webhooks of the workflow', async () => {
|
||||
const mockWorkflowWebhooks = [
|
||||
createWebhook('PUT', 'users'),
|
||||
createWebhook('GET', 'user/:id'),
|
||||
createWebhook('POST', ':var'),
|
||||
];
|
||||
|
||||
webhookRepository.findBy.mockResolvedValue(mockWorkflowWebhooks);
|
||||
|
||||
const workflowId = uuid();
|
||||
|
||||
await webhookService.deleteWorkflowWebhooks(workflowId);
|
||||
|
||||
expect(webhookRepository.remove).toHaveBeenCalledWith(mockWorkflowWebhooks);
|
||||
});
|
||||
|
||||
test('should not delete any webhooks if none found', async () => {
|
||||
webhookRepository.findBy.mockResolvedValue([]);
|
||||
|
||||
const workflowId = uuid();
|
||||
|
||||
await webhookService.deleteWorkflowWebhooks(workflowId);
|
||||
|
||||
expect(webhookRepository.remove).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWebhook()', () => {
|
||||
test('should create the webhook', async () => {
|
||||
const mockWebhook = createWebhook('GET', 'user/:id');
|
||||
|
||||
await webhookService.storeWebhook(mockWebhook);
|
||||
|
||||
expect(webhookRepository.insert).toHaveBeenCalledWith(mockWebhook);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { License } from '@/License';
|
||||
import { Logger } from '@/Logger';
|
||||
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
|
||||
import { Push } from '@/push';
|
||||
import { TestWebhooks } from '@/TestWebhooks';
|
||||
import { TestWebhooks } from '@/webhooks/TestWebhooks';
|
||||
import { OrchestrationService } from '@/services/orchestration.service';
|
||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import { CommunityPackagesService } from '@/services/communityPackages.service';
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Service } from 'typedi';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import type { IWebhookData } from 'n8n-workflow';
|
||||
import type { IWorkflowDb } from '@/Interfaces';
|
||||
import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants';
|
||||
import { OrchestrationService } from './orchestration.service';
|
||||
|
||||
export type TestWebhookRegistration = {
|
||||
pushRef?: string;
|
||||
workflowEntity: IWorkflowDb;
|
||||
destinationNode?: string;
|
||||
webhook: IWebhookData;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class TestWebhookRegistrationsService {
|
||||
constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly orchestrationService: OrchestrationService,
|
||||
) {}
|
||||
|
||||
private readonly cacheKey = 'test-webhooks';
|
||||
|
||||
async register(registration: TestWebhookRegistration) {
|
||||
const hashKey = this.toKey(registration.webhook);
|
||||
|
||||
await this.cacheService.setHash(this.cacheKey, { [hashKey]: registration });
|
||||
|
||||
if (!this.orchestrationService.isMultiMainSetupEnabled) return;
|
||||
|
||||
/**
|
||||
* Multi-main setup: In a manual webhook execution, the main process that
|
||||
* handles a webhook might not be the same as the main process that created
|
||||
* the webhook. If so, after the test webhook has been successfully executed,
|
||||
* the handler process commands the creator process to clear its test webhooks.
|
||||
* We set a TTL on the key so that it is cleared even on creator process crash,
|
||||
* with an additional buffer to ensure this safeguard expiration will not delete
|
||||
* the key before the regular test webhook timeout fetches the key to delete it.
|
||||
*/
|
||||
const ttl = TEST_WEBHOOK_TIMEOUT + TEST_WEBHOOK_TIMEOUT_BUFFER;
|
||||
|
||||
await this.cacheService.expire(this.cacheKey, ttl);
|
||||
}
|
||||
|
||||
async deregister(arg: IWebhookData | string) {
|
||||
if (typeof arg === 'string') {
|
||||
await this.cacheService.deleteFromHash(this.cacheKey, arg);
|
||||
} else {
|
||||
const hashKey = this.toKey(arg);
|
||||
await this.cacheService.deleteFromHash(this.cacheKey, hashKey);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string) {
|
||||
return await this.cacheService.getHashValue<TestWebhookRegistration>(this.cacheKey, key);
|
||||
}
|
||||
|
||||
async getAllKeys() {
|
||||
const hash = await this.cacheService.getHash<TestWebhookRegistration>(this.cacheKey);
|
||||
|
||||
if (!hash) return [];
|
||||
|
||||
return Object.keys(hash);
|
||||
}
|
||||
|
||||
async getAllRegistrations() {
|
||||
const hash = await this.cacheService.getHash<TestWebhookRegistration>(this.cacheKey);
|
||||
|
||||
if (!hash) return [];
|
||||
|
||||
return Object.values(hash);
|
||||
}
|
||||
|
||||
async deregisterAll() {
|
||||
await this.cacheService.delete(this.cacheKey);
|
||||
}
|
||||
|
||||
toKey(webhook: Pick<IWebhookData, 'webhookId' | 'httpMethod' | 'path'>) {
|
||||
const { webhookId, httpMethod, path: webhookPath } = webhook;
|
||||
|
||||
if (!webhookId) return [httpMethod, webhookPath].join('|');
|
||||
|
||||
let path = webhookPath;
|
||||
|
||||
if (path.startsWith(webhookId)) {
|
||||
const cutFromIndex = path.indexOf('/') + 1;
|
||||
|
||||
path = path.slice(cutFromIndex);
|
||||
}
|
||||
|
||||
return [httpMethod, webhookId, path.split('/').length].join('|');
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { WebhookRepository } from '@db/repositories/webhook.repository';
|
||||
import { Service } from 'typedi';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import type { WebhookEntity } from '@db/entities/WebhookEntity';
|
||||
import type { IHttpRequestMethods } from 'n8n-workflow';
|
||||
|
||||
type Method = NonNullable<IHttpRequestMethods>;
|
||||
|
||||
@Service()
|
||||
export class WebhookService {
|
||||
constructor(
|
||||
private webhookRepository: WebhookRepository,
|
||||
private cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async populateCache() {
|
||||
const allWebhooks = await this.webhookRepository.find();
|
||||
|
||||
if (!allWebhooks) return;
|
||||
|
||||
void this.cacheService.setMany(allWebhooks.map((w) => [w.cacheKey, w]));
|
||||
}
|
||||
|
||||
private async findCached(method: Method, path: string) {
|
||||
const cacheKey = `webhook:${method}-${path}`;
|
||||
|
||||
const cachedWebhook = await this.cacheService.get(cacheKey);
|
||||
|
||||
if (cachedWebhook) return this.webhookRepository.create(cachedWebhook);
|
||||
|
||||
let dbWebhook = await this.findStaticWebhook(method, path);
|
||||
|
||||
if (dbWebhook === null) {
|
||||
dbWebhook = await this.findDynamicWebhook(method, path);
|
||||
}
|
||||
|
||||
void this.cacheService.set(cacheKey, dbWebhook);
|
||||
|
||||
return dbWebhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching webhook with zero dynamic path segments, e.g. `<uuid>` or `user/profile`.
|
||||
*/
|
||||
private async findStaticWebhook(method: Method, path: string) {
|
||||
return await this.webhookRepository.findOneBy({ webhookPath: path, method });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a matching webhook with one or more dynamic path segments, e.g. `<uuid>/user/:id/posts`.
|
||||
* It is mandatory for dynamic webhooks to have `<uuid>/` at the base.
|
||||
*/
|
||||
private async findDynamicWebhook(method: Method, path: string) {
|
||||
const [uuidSegment, ...otherSegments] = path.split('/');
|
||||
|
||||
const dynamicWebhooks = await this.webhookRepository.findBy({
|
||||
webhookId: uuidSegment,
|
||||
method,
|
||||
pathLength: otherSegments.length,
|
||||
});
|
||||
|
||||
if (dynamicWebhooks.length === 0) return null;
|
||||
|
||||
const requestSegments = new Set(otherSegments);
|
||||
|
||||
const { webhook } = dynamicWebhooks.reduce<{
|
||||
webhook: WebhookEntity | null;
|
||||
maxMatches: number;
|
||||
}>(
|
||||
(acc, dw) => {
|
||||
const allStaticSegmentsMatch = dw.staticSegments.every((s) => requestSegments.has(s));
|
||||
|
||||
if (allStaticSegmentsMatch && dw.staticSegments.length > acc.maxMatches) {
|
||||
acc.maxMatches = dw.staticSegments.length;
|
||||
acc.webhook = dw;
|
||||
return acc;
|
||||
} else if (dw.staticSegments.length === 0 && !acc.webhook) {
|
||||
acc.webhook = dw; // edge case: if path is `:var`, match on anything
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ webhook: null, maxMatches: 0 },
|
||||
);
|
||||
|
||||
return webhook;
|
||||
}
|
||||
|
||||
async findWebhook(method: Method, path: string) {
|
||||
return await this.findCached(method, path);
|
||||
}
|
||||
|
||||
async storeWebhook(webhook: WebhookEntity) {
|
||||
void this.cacheService.set(webhook.cacheKey, webhook);
|
||||
|
||||
return await this.webhookRepository.insert(webhook);
|
||||
}
|
||||
|
||||
createWebhook(data: Partial<WebhookEntity>) {
|
||||
return this.webhookRepository.create(data);
|
||||
}
|
||||
|
||||
async deleteWorkflowWebhooks(workflowId: string) {
|
||||
const webhooks = await this.webhookRepository.findBy({ workflowId });
|
||||
|
||||
return await this.deleteWebhooks(webhooks);
|
||||
}
|
||||
|
||||
private async deleteWebhooks(webhooks: WebhookEntity[]) {
|
||||
void this.cacheService.deleteMany(webhooks.map((w) => w.cacheKey));
|
||||
|
||||
return await this.webhookRepository.remove(webhooks);
|
||||
}
|
||||
|
||||
async getWebhookMethods(path: string) {
|
||||
return await this.webhookRepository
|
||||
.find({ select: ['method'], where: { webhookPath: path } })
|
||||
.then((rows) => rows.map((r) => r.method));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user