diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 01a1f9b..3e90991 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -23,3 +23,12 @@ export interface StructuredResponse { data: T; raw_data?: R; } + +export interface WebhookPayload { + body?: unknown; + query?: unknown; + params?: unknown; + resp?: unknown; +} + +export type WebhookTriggerType = 'request' | 'response'; diff --git a/src/plugin/webhook.ts b/src/plugin/webhook.ts new file mode 100644 index 0000000..2c97e25 --- /dev/null +++ b/src/plugin/webhook.ts @@ -0,0 +1,75 @@ +import {FastifyInstance, FastifyRequest} from 'fastify'; +import fp from 'fastify-plugin'; + +import {WebhookPayload, WebhookTriggerType} from '@/interfaces'; + +async function makeWebhookCall( + url: string, + payload: WebhookPayload, + logger: FastifyInstance['log'], +): Promise { + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Rocket-Webhook': 'true', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + logger.warn(`Webhook call returned status ${response.status} for ${url}`); + } else { + logger.info(`Webhook call successful for ${url}`); + } + } catch (error) { + logger.error({err: error}, `Webhook call failed for ${url}`); + } +} + +export default fp( + async (fastify: FastifyInstance) => { + async function callWebhook( + trigger: WebhookTriggerType, + request: FastifyRequest, + payload: unknown, + ): Promise { + const apiIdentifier = request.routeOptions?.config?.apiIdentifier; + if (!apiIdentifier) { + fastify.log.debug( + 'No apiIdentifier found on request route options config', + ); + return; + } + + const webhookConfigs = + fastify.appConfig.apis?.[apiIdentifier]?.webhooks ?? null; + if (!webhookConfigs?.length) return; + + const isRequestTrigger = trigger === 'request'; + + for (const config of webhookConfigs) { + const dataToSend = config.data; + const webhookPayload: WebhookPayload = { + body: dataToSend.includes('body') ? request.body : undefined, + query: dataToSend.includes('query') ? request.query : undefined, + params: dataToSend.includes('params') ? request.params : undefined, + resp: dataToSend.includes('resp') ? payload : undefined, + }; + const shouldCall = isRequestTrigger + ? config.triggerOnRequest + : config.triggerOnResponse; + + if (shouldCall) { + await makeWebhookCall(config.url, webhookPayload, fastify.log); + } + } + } + + fastify.decorate('callWebhook', callWebhook); + }, + { + name: 'webhook-plugin', + }, +); diff --git a/src/routes/aggregate/aggregate.ts b/src/routes/aggregate/aggregate.ts index 284698f..efbf5b2 100644 --- a/src/routes/aggregate/aggregate.ts +++ b/src/routes/aggregate/aggregate.ts @@ -11,7 +11,6 @@ import { import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register AGGREGATE routes for fields with supportedAggregation. @@ -42,7 +41,6 @@ export function registerAggregateRoutes( const apiIdentifier = `aggregateAPIs->${model.name}->${field.name}->getAggregation`; // extract the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled @@ -87,16 +85,10 @@ export function registerAggregateRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/routes/custom-queries/custom-queries.ts b/src/routes/custom-queries/custom-queries.ts index 3224b69..2dd0c33 100644 --- a/src/routes/custom-queries/custom-queries.ts +++ b/src/routes/custom-queries/custom-queries.ts @@ -8,7 +8,6 @@ import { import {AppConfig, DataType} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; -import {callWebhook} from '@/utils/webhook'; type ParamSource = { body?: Record; @@ -94,7 +93,6 @@ export function registerCustomQueryRoutes( const apiIdentifier = `customAPIs->customQueries->all->${cq.name}`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true // if the api level auth is enabled, or if the app level auth is enabled @@ -255,10 +253,10 @@ export function registerCustomQueryRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook('response', webhookConfig, request, payload, app.log); + await app.callWebhook('response', request, payload); }, handler: async (request: FastifyRequest, reply: FastifyReply) => { // extract the body, path, and query parameters from the request diff --git a/src/routes/operations/delete.ts b/src/routes/operations/delete.ts index 597d443..9d1f4a0 100644 --- a/src/routes/operations/delete.ts +++ b/src/routes/operations/delete.ts @@ -9,7 +9,6 @@ import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register DELETE routes for deletable fields. @@ -41,7 +40,6 @@ export function registerDeleteRoutes( const apiIdentifier = `modelAPIs->${model.name}->${field.name}->delete`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true @@ -82,16 +80,10 @@ export function registerDeleteRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/routes/operations/edit.ts b/src/routes/operations/edit.ts index c1e9b60..a90b1c1 100644 --- a/src/routes/operations/edit.ts +++ b/src/routes/operations/edit.ts @@ -11,7 +11,6 @@ import {AppConfig, ModelBody} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register EDIT routes for editable fields. @@ -43,7 +42,6 @@ export function registerEditRoutes( const apiIdentifier = `modelAPIs->${model.name}->${field.name}->edit`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true @@ -249,16 +247,10 @@ export function registerEditRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, handleEditRequest, @@ -288,16 +280,10 @@ export function registerEditRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, handleEditRequest, diff --git a/src/routes/operations/get-all.ts b/src/routes/operations/get-all.ts index 9cfcf13..2f0bf0d 100644 --- a/src/routes/operations/get-all.ts +++ b/src/routes/operations/get-all.ts @@ -13,7 +13,6 @@ import {AppConfig, ModelConfig} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register GET_ALL routes for listing records (table-level). @@ -39,7 +38,6 @@ export function registerGetAllRoutes( const apiIdentifier = `modelAPIs->${model.name}->all->getAll`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true @@ -79,16 +77,10 @@ export function registerGetAllRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/routes/operations/index-route.ts b/src/routes/operations/index-route.ts index 7981d5e..01645a0 100644 --- a/src/routes/operations/index-route.ts +++ b/src/routes/operations/index-route.ts @@ -14,7 +14,6 @@ import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register INDEX routes for indexed fields. @@ -51,7 +50,6 @@ export function registerIndexRoutes( const apiIdentifier = `modelAPIs->${model.name}->${field.name}->index`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true @@ -90,16 +88,10 @@ export function registerIndexRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/routes/operations/post.ts b/src/routes/operations/post.ts index 919ac22..5420f63 100644 --- a/src/routes/operations/post.ts +++ b/src/routes/operations/post.ts @@ -10,7 +10,6 @@ import {AppConfig, ModelBody, ModelConfig} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register POST routes for creating records (table-level). @@ -31,7 +30,6 @@ export function registerPostRoutes( const apiIdentifier = `modelAPIs->${model.name}->all->insert`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true @@ -74,16 +72,10 @@ export function registerPostRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, async ( diff --git a/src/routes/operations/search.ts b/src/routes/operations/search.ts index a6c0e69..bcb36f1 100644 --- a/src/routes/operations/search.ts +++ b/src/routes/operations/search.ts @@ -13,7 +13,6 @@ import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; import {enforceSSP} from '@/utils/ssp'; import {capitalizeFirstLetter} from '@/utils/string'; -import {callWebhook} from '@/utils/webhook'; /** * Register SEARCH routes for searchable fields. @@ -43,7 +42,6 @@ export function registerSearchRoutes( const apiIdentifier = `modelAPIs->${model.name}->${field.name}->search`; // extracting the api configs based on the api identifier - const webhookConfig = config.apis?.[apiIdentifier]?.webhooks ?? null; const sspConfig = config.apis?.[apiIdentifier]?.ssp ?? []; // calculating the authroization based on auth flag, it can be true @@ -52,6 +50,7 @@ export function registerSearchRoutes( config.apis?.[apiIdentifier]?.authorization ?? config.auth?.enableAuth ?? false; + // defining the primary search query parameter // for example if the field is "name", this will create "name_search" query parameter const schema: Record = generateSchema( @@ -85,16 +84,10 @@ export function registerSearchRoutes( enforceSSP(sspConfig, request); }, preHandler: async request => { - await callWebhook('request', webhookConfig, request, null, app.log); + await app.callWebhook('request', request, null); }, onSend: async (request, _, payload) => { - await callWebhook( - 'response', - webhookConfig, - request, - payload, - app.log, - ); + await app.callWebhook('response', request, payload); }, }, async (request: FastifyRequest, reply: FastifyReply) => { diff --git a/src/server.ts b/src/server.ts index 19ee6ab..9831a77 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import dbPlugin from '@/plugin/database'; import rateLimitPlugin from '@/plugin/rate-limit'; import redisPlugin from '@/plugin/redis'; import responsePlugin from '@/plugin/response'; +import webhookPlugin from '@/plugin/webhook'; import {registerRoutes} from '@/routes'; import {registerChangePasswordRoute} from '@/routes/auth/change-password'; @@ -134,6 +135,7 @@ export async function startServer( } await app.register(responsePlugin); + await app.register(webhookPlugin); if (config.auth) { await app.register(authPlugin); } diff --git a/src/types/fastify.d.ts b/src/types/fastify.d.ts index e8a25eb..9d8c6a9 100644 --- a/src/types/fastify.d.ts +++ b/src/types/fastify.d.ts @@ -30,5 +30,10 @@ declare module 'fastify' { redis?: Redis; jwt: import('@fastify/jwt').JWT; appConfig: AppConfig; + callWebhook: ( + trigger: 'request' | 'response', + request: import('fastify').FastifyRequest, + payload: unknown, + ) => Promise; } } diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts deleted file mode 100644 index d13076a..0000000 --- a/src/utils/webhook.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {FastifyInstance, FastifyRequest} from 'fastify'; - -import {WebhookConfig} from '@/interfaces/config'; - -interface WebhookPayload { - body?: unknown; - query?: unknown; - params?: unknown; - resp?: unknown; -} - -type WebhookTriggerType = 'request' | 'response'; - -async function makeWebhookCall( - url: string, - payload: WebhookPayload, - logger: FastifyInstance['log'], -): Promise { - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Rocket-Webhook': 'true', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - logger.warn(`Webhook call returned status ${response.status} for ${url}`); - } else { - logger.info(`Webhook call successful for ${url}`); - } - } catch (error) { - logger.error({err: error}, `Webhook call failed for ${url}`); - } -} - -export async function callWebhook( - trigger: WebhookTriggerType, - webhookConfigs: WebhookConfig[] | null, - request: FastifyRequest, - payload: unknown, - logger: FastifyInstance['log'], -): Promise { - if (!webhookConfigs?.length) return; - const isRequestTrigger = trigger === 'request'; - - for (const config of webhookConfigs) { - const dataToSend = config.data; - const webhookPayload: WebhookPayload = { - body: dataToSend.includes('body') ? request.body : undefined, - query: dataToSend.includes('query') ? request.query : undefined, - params: dataToSend.includes('params') ? request.params : undefined, - resp: dataToSend.includes('resp') ? payload : undefined, - }; - const shouldCall = isRequestTrigger - ? config.triggerOnRequest - : config.triggerOnResponse; - - if (shouldCall) { - await makeWebhookCall(config.url, webhookPayload, logger); - } - } -} diff --git a/tests/helpers/test-app.ts b/tests/helpers/test-app.ts index cd46b87..dd22c91 100644 --- a/tests/helpers/test-app.ts +++ b/tests/helpers/test-app.ts @@ -3,6 +3,7 @@ import Fastify, {FastifyInstance} from 'fastify'; import authPlugin from '@/plugin/auth'; import databasePlugin from '@/plugin/database'; import responsePlugin from '@/plugin/response'; +import webhookPlugin from '@/plugin/webhook'; import {registerRoutes} from '@/routes'; @@ -47,10 +48,6 @@ export async function createTestApp( customAPIs?: CustomAPIConfig, auth?: AuthConfig, ): Promise { - const fastify = Fastify(); - await fastify.register(databasePlugin, dbConfig); - await fastify.register(responsePlugin); - await fastify.register(authPlugin); const appConfig: AppConfig = { application: {logLevel: 'error'}, swagger: { @@ -65,6 +62,14 @@ export async function createTestApp( auth, }; + const fastify = Fastify(); + fastify.appConfig = appConfig; + + await fastify.register(databasePlugin, dbConfig); + await fastify.register(responsePlugin); + await fastify.register(webhookPlugin); + await fastify.register(authPlugin); + if (models.length > 0 || apis || customAPIs) { registerRoutes(fastify, appConfig); } diff --git a/tests/plugin/webhook.test.ts b/tests/plugin/webhook.test.ts new file mode 100644 index 0000000..1f5e810 --- /dev/null +++ b/tests/plugin/webhook.test.ts @@ -0,0 +1,381 @@ +import Fastify, {FastifyRequest} from 'fastify'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import webhookPlugin from '@/plugin/webhook'; + +import {AppConfig, WebhookConfig} from '@/interfaces/config'; + +describe('webhook plugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('webhook plugin decorates fastify with callWebhook', async () => { + const app = Fastify(); + app.appConfig = {application: {logLevel: 'silent'}} as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + expect(app.hasDecorator('callWebhook')).toBe(true); + }); + + it('should not call webhook if apiIdentifier is missing', async () => { + const app = Fastify(); + app.appConfig = {application: {logLevel: 'silent'}} as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: {}, + }, + } as unknown as FastifyRequest; + + const debugSpy = vi.spyOn(app.log, 'debug'); + + await app.callWebhook('request', mockRequest, null); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('No apiIdentifier found'), + ); + }); + + it('should not call webhook if no webhooks are configured for the API', async () => { + const app = Fastify(); + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: [], + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + await app.callWebhook('request', mockRequest, null); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should call webhook on request trigger', async () => { + const app = Fastify(); + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook', + data: [], + triggerOnRequest: true, + triggerOnResponse: false, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + body: {name: 'John'}, + } as unknown as FastifyRequest; + + const fetchMock = global.fetch as ReturnType; + fetchMock.mockResolvedValueOnce({ok: true}); + + const infoSpy = vi.spyOn(app.log, 'info'); + + await app.callWebhook('request', mockRequest, null); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://example.com/webhook', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Rocket-Webhook': 'true', + }), + }), + ); + + expect(infoSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Webhook call successful for https://example.com/webhook', + ), + ); + }); + + it('should call webhook on response trigger with payload', async () => { + const app = Fastify(); + const responsePayload = {success: true, data: {id: 1}}; + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook-resp', + data: ['resp'], + triggerOnRequest: false, + triggerOnResponse: true, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + body: null, + } as unknown as FastifyRequest; + + const fetchMock = global.fetch as ReturnType; + fetchMock.mockResolvedValueOnce({ok: true}); + + await app.callWebhook('response', mockRequest, responsePayload); + + expect(global.fetch).toHaveBeenCalled(); + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + expect(body.resp).toEqual(responsePayload); + expect(body.body).toBeUndefined(); + }); + + it('should skip webhook with triggerOnRequest false for request trigger', async () => { + const app = Fastify(); + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook', + data: [], + triggerOnRequest: false, + triggerOnResponse: true, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + await app.callWebhook('request', mockRequest, null); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should skip webhook with triggerOnResponse false for response trigger', async () => { + const app = Fastify(); + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook', + data: [], + triggerOnRequest: true, + triggerOnResponse: false, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + await app.callWebhook('response', mockRequest, null); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should include only requested data fields in webhook payload', async () => { + const app = Fastify(); + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook', + data: ['body', 'query', 'params', 'resp'], + triggerOnRequest: true, + triggerOnResponse: true, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + body: {id: 123}, + query: {active: 'true'}, + params: {tenant: 'default'}, + } as unknown as FastifyRequest; + + const fetchMock = global.fetch as ReturnType; + fetchMock.mockResolvedValueOnce({ok: true}); + + const responsePayload = {ok: true}; + + await app.callWebhook('request', mockRequest, responsePayload); + + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + + expect(body).toEqual({ + body: {id: 123}, + query: {active: 'true'}, + params: {tenant: 'default'}, + resp: responsePayload, + }); + }); + + it('should log warn on non-ok status response', async () => { + const app = Fastify(); + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook', + data: [], + triggerOnRequest: true, + triggerOnResponse: false, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + const fetchMock = global.fetch as ReturnType; + fetchMock.mockResolvedValueOnce({ok: false, status: 500}); + + const warnSpy = vi.spyOn(app.log, 'warn'); + + await app.callWebhook('request', mockRequest, null); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('returned status 500'), + ); + }); + + it('should handle fetch errors gracefully', async () => { + const app = Fastify(); + const webhookConfigs: WebhookConfig[] = [ + { + url: 'https://example.com/webhook', + data: [], + triggerOnRequest: true, + triggerOnResponse: false, + }, + ]; + + app.appConfig = { + application: {logLevel: 'silent'}, + apis: { + 'test-api': { + webhooks: webhookConfigs, + }, + }, + } as unknown as AppConfig; + await app.register(webhookPlugin); + await app.ready(); + + const mockRequest = { + routeOptions: { + config: { + apiIdentifier: 'test-api', + }, + }, + } as unknown as FastifyRequest; + + const fetchMock = global.fetch as ReturnType; + const error = new Error('Network error'); + fetchMock.mockRejectedValueOnce(error); + + const errorSpy = vi.spyOn(app.log, 'error'); + + await app.callWebhook('request', mockRequest, null); + + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({err: error}), + expect.stringContaining('failed for https://example.com/webhook'), + ); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 82b8aec..878d237 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -214,7 +214,7 @@ describe('Server', () => { it('should register plugins and routes', async () => { await runStart('dev', false, true); - expect(mockApp.register).toHaveBeenCalledTimes(4); + expect(mockApp.register).toHaveBeenCalledTimes(5); expect(migrateDatabase).toHaveBeenCalledWith(mockConfig); expect(registerRoutes).toHaveBeenCalledWith(mockApp, mockConfig); @@ -232,7 +232,7 @@ describe('Server', () => { } as unknown as AppConfig; await startServer(disabledSwaggerConfig, 3000, 'prod'); - expect(mockApp.register).toHaveBeenCalledTimes(2); + expect(mockApp.register).toHaveBeenCalledTimes(3); }); it('should not register routes if models are missing/empty', async () => { diff --git a/tests/utils/webhook.test.ts b/tests/utils/webhook.test.ts deleted file mode 100644 index 9883a68..0000000 --- a/tests/utils/webhook.test.ts +++ /dev/null @@ -1,488 +0,0 @@ -import {FastifyRequest} from 'fastify'; -import {Logger} from 'pino'; -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; - -import {WebhookConfig} from '@/interfaces/config'; - -import {callWebhook} from '@/utils/webhook'; - -describe('webhook utils', () => { - const mockLogger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as unknown as Logger; - - const mockRequest = { - body: {name: 'John', age: 30}, - query: {filter: 'active'}, - params: {id: '123'}, - } as unknown as FastifyRequest; - - beforeEach(() => { - vi.clearAllMocks(); - global.fetch = vi.fn(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - describe('callWebhook', () => { - it('should not call webhook if webhookConfigs is null', async () => { - await callWebhook('request', null, mockRequest, null, mockLogger); - expect(mockLogger.info).not.toHaveBeenCalled(); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('should not call webhook if webhookConfigs is empty', async () => { - await callWebhook('request', [], mockRequest, null, mockLogger); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('should call webhook on request trigger', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - expect(global.fetch).toHaveBeenCalledWith( - 'https://example.com/webhook', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - 'X-Rocket-Webhook': 'true', - }), - }), - ); - - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Webhook call successful'), - ); - }); - - it('should call webhook on response trigger', async () => { - const responsePayload = {success: true, data: []}; - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['resp'], - triggerOnRequest: false, - triggerOnResponse: true, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'response', - webhookConfig, - mockRequest, - responsePayload, - mockLogger, - ); - - expect(global.fetch).toHaveBeenCalled(); - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - expect(body.resp).toEqual(responsePayload); - }); - - it('should skip webhook with triggerOnRequest false for request trigger', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: [], - triggerOnRequest: false, - triggerOnResponse: true, - }, - ]; - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('should skip webhook with triggerOnResponse false for response trigger', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - await callWebhook( - 'response', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('should handle multiple webhooks', async () => { - const webhookConfigs: WebhookConfig[] = [ - { - url: 'https://example.com/webhook1', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - { - url: 'https://example.com/webhook2', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValue({ok: true}); - - await callWebhook( - 'request', - webhookConfigs, - mockRequest, - null, - mockLogger, - ); - - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - - it('should include request data in webhook payload', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: undefined, - query: undefined, - params: undefined, - resp: undefined, - }); - }); - - it('should send only specified data fields when data array contains specific fields', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['body', 'resp'], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - {result: 'success'}, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: mockRequest.body, - query: undefined, - params: undefined, - resp: {result: 'success'}, - }); - }); - - it('should send only body when data array contains only body', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['body'], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: mockRequest.body, - query: undefined, - params: undefined, - resp: undefined, - }); - }); - - it('should send only query when data array contains only query', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['query'], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: undefined, - query: mockRequest.query, - params: undefined, - resp: undefined, - }); - }); - - it('should send only params when data array contains only params', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['params'], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: undefined, - query: undefined, - params: mockRequest.params, - resp: undefined, - }); - }); - - it('should send only resp when data array contains only resp', async () => { - const responsePayload = {success: true, message: 'Created'}; - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['resp'], - triggerOnRequest: false, - triggerOnResponse: true, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'response', - webhookConfig, - mockRequest, - responsePayload, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: undefined, - query: undefined, - params: undefined, - resp: responsePayload, - }); - }); - - it('should send all data fields when data array contains all field names', async () => { - const responsePayload = {success: true}; - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['body', 'query', 'params', 'resp'], - triggerOnRequest: false, - triggerOnResponse: true, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'response', - webhookConfig, - mockRequest, - responsePayload, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: mockRequest.body, - query: mockRequest.query, - params: mockRequest.params, - resp: responsePayload, - }); - }); - - it('should handle combination of body and query', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: ['body', 'query'], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: true}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const body = JSON.parse(callArgs[1].body); - - expect(body).toEqual({ - body: mockRequest.body, - query: mockRequest.query, - params: undefined, - resp: undefined, - }); - }); - - it('should log warn on non-ok response status', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const fetchMock = global.fetch as ReturnType; - fetchMock.mockResolvedValueOnce({ok: false, status: 500}); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - expect(mockLogger.warn).toHaveBeenCalled(); - expect(mockLogger.info).not.toHaveBeenCalled(); - }); - - it('should handle fetch errors gracefully', async () => { - const webhookConfig: WebhookConfig[] = [ - { - url: 'https://example.com/webhook', - data: [], - triggerOnRequest: true, - triggerOnResponse: false, - }, - ]; - - const error = new Error('Network error'); - const fetchMock = global.fetch as ReturnType; - fetchMock.mockRejectedValueOnce(error); - - await callWebhook( - 'request', - webhookConfig, - mockRequest, - null, - mockLogger, - ); - - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); -});