1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

chore: register integration events in webhooks (#7621)

https://linear.app/unleash/issue/2-2450/register-integration-events-webhook

Registers integration events in the **Webhook** integration.

Even though this touches a lot of files, most of it is preparation for
the next steps. The only actual implementation of registering
integration events is in the **Webhook** integration. The rest will
follow on separate PRs.

Here's an example of how this looks like in the database table:

```json
{
  "id": 7,
  "integration_id": 2,
  "created_at": "2024-07-18T18:11:11.376348+01:00",
  "state": "failed",
  "state_details": "Webhook request failed with status code: ECONNREFUSED",
  "event": {
    "id": 130,
    "data": null,
    "tags": [],
    "type": "feature-environment-enabled",
    "preData": null,
    "project": "default",
    "createdAt": "2024-07-18T17:11:10.821Z",
    "createdBy": "admin",
    "environment": "development",
    "featureName": "test",
    "createdByUserId": 1
  },
  "details": {
    "url": "http://localhost:1337",
    "body": "{ \"id\": 130, \"type\": \"feature-environment-enabled\", \"createdBy\": \"admin\", \"createdAt\": \"2024-07-18T17: 11: 10.821Z\", \"createdByUserId\": 1, \"data\": null, \"preData\": null, \"tags\": [], \"featureName\": \"test\", \"project\": \"default\", \"environment\": \"development\" }"
  }
}
```
This commit is contained in:
Nuno Góis 2024-07-19 10:07:52 +01:00 committed by GitHub
parent 3db1159304
commit 0869e39603
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 495 additions and 313 deletions

View File

@ -2,28 +2,32 @@ import nock from 'nock';
import noLogger from '../../test/fixtures/no-logger';
import SlackAddon from './slack';
import type { IAddonConfig, IFlagResolver } from '../types';
import type { IntegrationEventsService } from '../services';
beforeEach(() => {
nock.disableNetConnect();
});
const url = 'https://test.some.com';
const ARGS: IAddonConfig = {
getLogger: noLogger,
unleashUrl: url,
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
test('Does not retry if request succeeds', async () => {
const url = 'https://test.some.com';
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: url,
});
const addon = new SlackAddon(ARGS);
nock(url).get('/').reply(201);
const res = await addon.fetchRetry(url);
expect(res.ok).toBe(true);
});
test('Retries once, and succeeds', async () => {
const url = 'https://test.some.com';
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: url,
});
const addon = new SlackAddon(ARGS);
nock(url).get('/').replyWithError('testing retry');
nock(url).get('/').reply(200);
const res = await addon.fetchRetry(url);
@ -32,22 +36,14 @@ test('Retries once, and succeeds', async () => {
});
test('Does not throw if response is error', async () => {
const url = 'https://test.some.com';
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: url,
});
const addon = new SlackAddon(ARGS);
nock(url).get('/').twice().replyWithError('testing retry');
const res = await addon.fetchRetry(url);
expect(res.ok).toBe(false);
});
test('Supports custom number of retries', async () => {
const url = 'https://test.some.com';
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: url,
});
const addon = new SlackAddon(ARGS);
let retries = 0;
nock(url).get('/').twice().replyWithError('testing retry');
nock(url).get('/').reply(201);

View File

@ -1,9 +1,11 @@
import fetch from 'make-fetch-happen';
import { addonDefinitionSchema } from './addon-schema';
import type { IUnleashConfig } from '../types/option';
import type { Logger } from '../logger';
import type { IAddonDefinition } from '../types/model';
import type { IAddonConfig, IAddonDefinition } from '../types/model';
import type { IEvent } from '../types/events';
import type { IntegrationEventsService } from '../features/integration-events/integration-events-service';
import type { IntegrationEventWriteModel } from '../features/integration-events/integration-events-store';
import type { IFlagResolver } from '../types';
export default abstract class Addon {
logger: Logger;
@ -12,9 +14,13 @@ export default abstract class Addon {
_definition: IAddonDefinition;
integrationEventsService: IntegrationEventsService;
flagResolver: IFlagResolver;
constructor(
definition: IAddonDefinition,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
{ getLogger, integrationEventsService, flagResolver }: IAddonConfig,
) {
this.logger = getLogger(`addon/${definition.name}`);
const { error } = addonDefinitionSchema.validate(definition);
@ -27,6 +33,8 @@ export default abstract class Addon {
}
this._name = definition.name;
this._definition = definition;
this.integrationEventsService = integrationEventsService;
this.flagResolver = flagResolver;
}
get name(): string {
@ -60,13 +68,25 @@ export default abstract class Addon {
} status code ${e.code}`,
e,
);
res = { statusCode: e.code, ok: false };
res = { status: e.code, ok: false };
}
return res;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
abstract handleEvent(event: IEvent, parameters: any): Promise<void>;
abstract handleEvent(
event: IEvent,
parameters: any,
integrationId: number,
): Promise<void>;
async registerEvent(
integrationEvent: IntegrationEventWriteModel,
): Promise<void> {
if (this.flagResolver.isEnabled('integrationEvents')) {
await this.integrationEventsService.registerEvent(integrationEvent);
}
}
destroy?(): void;
}

View File

@ -9,9 +9,19 @@ import type { Logger } from '../logger';
import DatadogAddon from './datadog';
import noLogger from '../../test/fixtures/no-logger';
import type { IAddonConfig, IFlagResolver } from '../types';
import type { IntegrationEventsService } from '../services';
let fetchRetryCalls: any[] = [];
const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = {
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
jest.mock(
'./addon',
() =>
@ -32,14 +42,15 @@ jest.mock(
});
return Promise.resolve({ status: 200 });
}
async registerEvent(_) {
return Promise.resolve();
}
},
);
test('Should call datadog webhook', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
@ -59,17 +70,14 @@ test('Should call datadog webhook', async () => {
apiKey: 'fakeKey',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call datadog webhook for archived toggle', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -87,17 +95,14 @@ test('Should call datadog webhook for archived toggle', async () => {
apiKey: 'fakeKey',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call datadog webhook for archived toggle with project info', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -116,17 +121,14 @@ test('Should call datadog webhook for archived toggle with project info', async
apiKey: 'fakeKey',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call datadog webhook for toggled environment', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -146,7 +148,7 @@ test('Should call datadog webhook for toggled environment', async () => {
apiKey: 'fakeKey',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
@ -154,10 +156,7 @@ test('Should call datadog webhook for toggled environment', async () => {
});
test('Should include customHeaders in headers when calling service', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -177,7 +176,7 @@ test('Should include customHeaders in headers when calling service', async () =>
apiKey: 'fakeKey',
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
@ -186,10 +185,7 @@ test('Should include customHeaders in headers when calling service', async () =>
});
test('Should not include source_type_name when included in the config', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -210,7 +206,7 @@ test('Should not include source_type_name when included in the config', async ()
sourceTypeName: 'my-custom-source-type',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(
@ -221,10 +217,7 @@ test('Should not include source_type_name when included in the config', async ()
});
test('Should call datadog webhook with JSON when template set', async () => {
const addon = new DatadogAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new DatadogAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
@ -246,7 +239,7 @@ test('Should call datadog webhook with JSON when template set', async () => {
'{\n "event": "{{event.type}}",\n "createdBy": "{{event.createdBy}}"\n}',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();

View File

@ -39,6 +39,7 @@ export default class DatadogAddon extends Addon {
async handleEvent(
event: IEvent,
parameters: IDatadogParameters,
integrationId: number,
): Promise<void> {
const {
url = 'https://api.datadoghq.com/api/v1/events',

View File

@ -4,26 +4,21 @@ import TeamsAddon from './teams';
import DatadogAddon from './datadog';
import NewRelicAddon from './new-relic';
import type Addon from './addon';
import type { LogProvider } from '../logger';
import SlackAppAddon from './slack-app';
import type { IFlagResolver } from '../types';
import type { IAddonConfig } from '../types';
export interface IAddonProviders {
[key: string]: Addon;
}
export const getAddons: (args: {
getLogger: LogProvider;
unleashUrl: string;
flagResolver: IFlagResolver;
}) => IAddonProviders = ({ getLogger, unleashUrl, flagResolver }) => {
export const getAddons: (args: IAddonConfig) => IAddonProviders = (args) => {
const addons: Addon[] = [
new Webhook({ getLogger }),
new SlackAddon({ getLogger, unleashUrl }),
new SlackAppAddon({ getLogger, unleashUrl }),
new TeamsAddon({ getLogger, unleashUrl }),
new DatadogAddon({ getLogger, unleashUrl }),
new NewRelicAddon({ getLogger, unleashUrl }),
new Webhook(args),
new SlackAddon(args),
new SlackAppAddon(args),
new TeamsAddon(args),
new DatadogAddon(args),
new NewRelicAddon(args),
];
return addons.reduce((map, addon) => {

View File

@ -2,6 +2,8 @@ import {
FEATURE_ARCHIVED,
FEATURE_CREATED,
FEATURE_ENVIRONMENT_DISABLED,
type IFlagResolver,
type IAddonConfig,
type IEvent,
} from '../types';
import type { Logger } from '../logger';
@ -11,11 +13,20 @@ import NewRelicAddon, { type INewRelicParameters } from './new-relic';
import noLogger from '../../test/fixtures/no-logger';
import { gunzip } from 'node:zlib';
import { promisify } from 'util';
import type { IntegrationEventsService } from '../services';
const asyncGunzip = promisify(gunzip);
let fetchRetryCalls: any[] = [];
const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = {
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
jest.mock(
'./addon',
() =>
@ -36,6 +47,10 @@ jest.mock(
});
return Promise.resolve({ status: 200 });
}
async registerEvent(_) {
return Promise.resolve();
}
},
);
@ -59,12 +74,9 @@ const defaultEvent = {
} as IEvent;
const makeAddHandleEvent = (event: IEvent, parameters: INewRelicParameters) => {
const addon = new NewRelicAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new NewRelicAddon(ARGS);
return () => addon.handleEvent(event, parameters);
return () => addon.handleEvent(event, parameters, INTEGRATION_ID);
};
test.each([

View File

@ -44,6 +44,7 @@ export default class NewRelicAddon extends Addon {
async handleEvent(
event: IEvent,
parameters: INewRelicParameters,
integrationId: number,
): Promise<void> {
const { url, licenseKey, customHeaders, bodyTemplate } = parameters;
const context = {

View File

@ -1,10 +1,32 @@
import { type IEvent, FEATURE_ENVIRONMENT_ENABLED } from '../types/events';
import SlackAppAddon from './slack-app';
import { type ChatPostMessageArguments, ErrorCode } from '@slack/web-api';
import { SYSTEM_USER_ID } from '../types';
import {
type IAddonConfig,
type IFlagResolver,
SYSTEM_USER_ID,
} from '../types';
import type { IntegrationEventsService } from '../services';
const slackApiCalls: ChatPostMessageArguments[] = [];
const loggerMock = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
};
const getLogger = jest.fn(() => loggerMock);
const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = {
getLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
let postMessage = jest.fn().mockImplementation((options) => {
slackApiCalls.push(options);
return Promise.resolve();
@ -28,14 +50,6 @@ jest.mock('@slack/web-api', () => ({
describe('SlackAppAddon', () => {
let addon: SlackAppAddon;
const accessToken = 'test-access-token';
const loggerMock = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
};
const getLogger = jest.fn(() => loggerMock);
const mockError = {
code: ErrorCode.PlatformError,
data: {
@ -64,10 +78,7 @@ describe('SlackAppAddon', () => {
jest.useFakeTimers();
slackApiCalls.length = 0;
postMessage.mockClear();
addon = new SlackAppAddon({
getLogger,
unleashUrl: 'http://some-url.com',
});
addon = new SlackAppAddon(ARGS);
});
afterEach(() => {
@ -75,20 +86,28 @@ describe('SlackAppAddon', () => {
});
it('should post message when feature is toggled', async () => {
await addon.handleEvent(event, {
accessToken,
defaultChannels: 'general',
});
await addon.handleEvent(
event,
{
accessToken,
defaultChannels: 'general',
},
INTEGRATION_ID,
);
expect(slackApiCalls.length).toBe(1);
expect(slackApiCalls[0].channel).toBe('general');
});
it('should post to all channels in defaultChannels', async () => {
await addon.handleEvent(event, {
accessToken,
defaultChannels: 'general, another-channel-1',
});
await addon.handleEvent(
event,
{
accessToken,
defaultChannels: 'general, another-channel-1',
},
INTEGRATION_ID,
);
expect(slackApiCalls.length).toBe(2);
expect(slackApiCalls[0].channel).toBe('general');
@ -104,10 +123,14 @@ describe('SlackAppAddon', () => {
],
};
await addon.handleEvent(eventWith2Tags, {
accessToken,
defaultChannels: '',
});
await addon.handleEvent(
eventWith2Tags,
{
accessToken,
defaultChannels: '',
},
INTEGRATION_ID,
);
expect(slackApiCalls.length).toBe(2);
expect(slackApiCalls[0].channel).toBe('general');
@ -123,10 +146,14 @@ describe('SlackAppAddon', () => {
],
};
await addon.handleEvent(eventWith2Tags, {
accessToken,
defaultChannels: 'another-channel-1, another-channel-2',
});
await addon.handleEvent(
eventWith2Tags,
{
accessToken,
defaultChannels: 'another-channel-1, another-channel-2',
},
INTEGRATION_ID,
);
expect(slackApiCalls.length).toBe(3);
expect(slackApiCalls[0].channel).toBe('general');
@ -135,10 +162,14 @@ describe('SlackAppAddon', () => {
});
it('should not post a message if there are no tagged channels and no defaultChannels', async () => {
await addon.handleEvent(event, {
accessToken,
defaultChannels: '',
});
await addon.handleEvent(
event,
{
accessToken,
defaultChannels: '',
},
INTEGRATION_ID,
);
expect(slackApiCalls.length).toBe(0);
});
@ -146,10 +177,14 @@ describe('SlackAppAddon', () => {
it('should log error when an API call fails', async () => {
postMessage = jest.fn().mockRejectedValue(mockError);
await addon.handleEvent(event, {
accessToken,
defaultChannels: 'general',
});
await addon.handleEvent(
event,
{
accessToken,
defaultChannels: 'general',
},
INTEGRATION_ID,
);
expect(loggerMock.warn).toHaveBeenCalledWith(
`Error handling event ${event.type}. A platform error occurred: ${JSON.stringify(mockError.data)}`,
@ -173,10 +208,14 @@ describe('SlackAppAddon', () => {
.mockResolvedValueOnce({ ok: true })
.mockRejectedValueOnce(mockError);
await addon.handleEvent(eventWith3Tags, {
accessToken,
defaultChannels: '',
});
await addon.handleEvent(
eventWith3Tags,
{
accessToken,
defaultChannels: '',
},
INTEGRATION_ID,
);
expect(postMessage).toHaveBeenCalledTimes(3);
expect(loggerMock.warn).toHaveBeenCalledWith(

View File

@ -44,6 +44,7 @@ export default class SlackAppAddon extends Addon {
async handleEvent(
event: IEvent,
parameters: ISlackAppAddonParameters,
integrationId: number,
): Promise<void> {
try {
const { accessToken, defaultChannels } = parameters;

View File

@ -9,10 +9,23 @@ import type { Logger } from '../logger';
import SlackAddon from './slack';
import noLogger from '../../test/fixtures/no-logger';
import { SYSTEM_USER_ID } from '../types';
import {
type IAddonConfig,
type IFlagResolver,
SYSTEM_USER_ID,
} from '../types';
import type { IntegrationEventsService } from '../services';
let fetchRetryCalls: any[] = [];
const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = {
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
jest.mock(
'./addon',
() =>
@ -33,14 +46,15 @@ jest.mock(
});
return Promise.resolve({ status: 200 });
}
async registerEvent(_) {
return Promise.resolve();
}
},
);
test('Should call slack webhook', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
@ -62,17 +76,14 @@ test('Should call slack webhook', async () => {
defaultChannel: 'general',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call slack webhook for archived toggle', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -90,17 +101,14 @@ test('Should call slack webhook for archived toggle', async () => {
defaultChannel: 'general',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call slack webhook for archived toggle with project info', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -119,17 +127,14 @@ test('Should call slack webhook for archived toggle with project info', async ()
defaultChannel: 'general',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test(`Should call webhook for toggled environment`, async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -149,7 +154,7 @@ test(`Should call webhook for toggled environment`, async () => {
defaultChannel: 'general',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
@ -157,10 +162,7 @@ test(`Should call webhook for toggled environment`, async () => {
});
test('Should use default channel', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 3,
createdAt: new Date(),
@ -180,7 +182,7 @@ test('Should use default channel', async () => {
defaultChannel: 'some-channel',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
const req = JSON.parse(fetchRetryCalls[0].options.body);
@ -188,10 +190,7 @@ test('Should use default channel', async () => {
});
test('Should override default channel with data from tag', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 4,
createdAt: new Date(),
@ -217,7 +216,7 @@ test('Should override default channel with data from tag', async () => {
defaultChannel: 'some-channel',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
const req = JSON.parse(fetchRetryCalls[0].options.body);
@ -225,10 +224,7 @@ test('Should override default channel with data from tag', async () => {
});
test('Should post to all channels in tags', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 5,
createdAt: new Date(),
@ -258,7 +254,7 @@ test('Should post to all channels in tags', async () => {
defaultChannel: 'some-channel',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
const req1 = JSON.parse(fetchRetryCalls[0].options.body);
const req2 = JSON.parse(fetchRetryCalls[1].options.body);
@ -269,10 +265,7 @@ test('Should post to all channels in tags', async () => {
});
test('Should include custom headers from parameters in call to service', async () => {
const addon = new SlackAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new SlackAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -293,7 +286,7 @@ test('Should include custom headers from parameters in call to service', async (
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);

View File

@ -32,6 +32,7 @@ export default class SlackAddon extends Addon {
async handleEvent(
event: IEvent,
parameters: ISlackAddonParameters,
integrationId: number,
): Promise<void> {
const {
url,

View File

@ -10,10 +10,23 @@ import {
import TeamsAddon from './teams';
import noLogger from '../../test/fixtures/no-logger';
import { SYSTEM_USER_ID } from '../types';
import {
type IAddonConfig,
type IFlagResolver,
SYSTEM_USER_ID,
} from '../types';
import type { IntegrationEventsService } from '../services';
let fetchRetryCalls: any[];
const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = {
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
jest.mock(
'./addon',
() =>
@ -34,14 +47,15 @@ jest.mock(
});
return Promise.resolve({ status: 200 });
}
async registerEvent(_) {
return Promise.resolve();
}
},
);
test('Should call teams webhook', async () => {
const addon = new TeamsAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new TeamsAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
@ -60,17 +74,14 @@ test('Should call teams webhook', async () => {
url: 'http://hooks.office.com',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call teams webhook for archived toggle', async () => {
const addon = new TeamsAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new TeamsAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
@ -87,17 +98,14 @@ test('Should call teams webhook for archived toggle', async () => {
url: 'http://hooks.office.com',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test('Should call teams webhook for archived toggle with project info', async () => {
const addon = new TeamsAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new TeamsAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
@ -115,17 +123,14 @@ test('Should call teams webhook for archived toggle with project info', async ()
url: 'http://hooks.office.com',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatchSnapshot();
});
test(`Should call teams webhook for toggled environment`, async () => {
const addon = new TeamsAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new TeamsAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -144,7 +149,7 @@ test(`Should call teams webhook for toggled environment`, async () => {
url: 'http://hooks.slack.com',
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);
@ -152,10 +157,7 @@ test(`Should call teams webhook for toggled environment`, async () => {
});
test('Should include custom headers in call to teams', async () => {
const addon = new TeamsAddon({
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
});
const addon = new TeamsAddon(ARGS);
const event: IEvent = {
id: 2,
createdAt: new Date(),
@ -175,7 +177,7 @@ test('Should include custom headers in call to teams', async () => {
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
};
await addon.handleEvent(event, parameters);
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls).toHaveLength(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toMatch(/disabled/);

View File

@ -24,6 +24,7 @@ export default class TeamsAddon extends Addon {
async handleEvent(
event: IEvent,
parameters: ITeamsParameters,
integrationId: number,
): Promise<void> {
const { url, customHeaders } = parameters;
const { createdBy } = event;

View File

@ -5,9 +5,24 @@ import { FEATURE_CREATED, type IEvent } from '../types/events';
import WebhookAddon from './webhook';
import noLogger from '../../test/fixtures/no-logger';
import { SYSTEM_USER_ID } from '../types';
import {
type IAddonConfig,
type IFlagResolver,
serializeDates,
SYSTEM_USER_ID,
} from '../types';
import type { IntegrationEventsService } from '../services';
let fetchRetryCalls: any[] = [];
const registerEventMock = jest.fn();
const INTEGRATION_ID = 1337;
const ARGS: IAddonConfig = {
getLogger: noLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
jest.mock(
'./addon',
@ -27,127 +42,182 @@ jest.mock(
retries,
backoff,
});
return Promise.resolve({ status: 200 });
return Promise.resolve({ ok: true, status: 200 });
}
async registerEvent(event) {
return registerEventMock(event);
}
},
);
test('Should handle event without "bodyTemplate"', () => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
describe('Webhook integration', () => {
beforeEach(() => {
registerEventMock.mockClear();
});
const parameters = {
url: 'http://test.webhook.com',
};
test('Should handle event without "bodyTemplate"', () => {
const addon = new WebhookAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
addon.handleEvent(event, parameters);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
});
test('Should format event with "bodyTemplate"', () => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
};
addon.handleEvent(event, parameters);
const call = fetchRetryCalls[0];
expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url);
expect(call.options.headers['Content-Type']).toBe('text/plain');
expect(call.options.body).toBe('feature-created on toggle some-toggle');
});
test('Should format event with "authorization"', () => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
authorization: 'API KEY 123abc',
};
addon.handleEvent(event, parameters);
const call = fetchRetryCalls[0];
expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url);
expect(call.options.headers.Authorization).toBe(parameters.authorization);
expect(call.options.body).toBe('feature-created on toggle some-toggle');
});
test('Should handle custom headers', async () => {
const addon = new WebhookAddon({ getLogger: noLogger });
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
authorization: 'API KEY 123abc',
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
};
addon.handleEvent(event, parameters);
const call = fetchRetryCalls[0];
expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url);
expect(call.options.headers.Authorization).toBe(parameters.authorization);
expect(call.options.headers.MY_CUSTOM_HEADER).toBe('MY_CUSTOM_VALUE');
expect(call.options.body).toBe('feature-created on toggle some-toggle');
const parameters = {
url: 'http://test.webhook.com',
};
addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(fetchRetryCalls.length).toBe(1);
expect(fetchRetryCalls[0].url).toBe(parameters.url);
expect(fetchRetryCalls[0].options.body).toBe(JSON.stringify(event));
});
test('Should format event with "bodyTemplate"', () => {
const addon = new WebhookAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
};
addon.handleEvent(event, parameters, INTEGRATION_ID);
const call = fetchRetryCalls[0];
expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url);
expect(call.options.headers['Content-Type']).toBe('text/plain');
expect(call.options.body).toBe('feature-created on toggle some-toggle');
});
test('Should format event with "authorization"', () => {
const addon = new WebhookAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
authorization: 'API KEY 123abc',
};
addon.handleEvent(event, parameters, INTEGRATION_ID);
const call = fetchRetryCalls[0];
expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url);
expect(call.options.headers.Authorization).toBe(
parameters.authorization,
);
expect(call.options.body).toBe('feature-created on toggle some-toggle');
});
test('Should handle custom headers', async () => {
const addon = new WebhookAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
authorization: 'API KEY 123abc',
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
};
addon.handleEvent(event, parameters, INTEGRATION_ID);
const call = fetchRetryCalls[0];
expect(fetchRetryCalls.length).toBe(1);
expect(call.url).toBe(parameters.url);
expect(call.options.headers.Authorization).toBe(
parameters.authorization,
);
expect(call.options.headers.MY_CUSTOM_HEADER).toBe('MY_CUSTOM_VALUE');
expect(call.options.body).toBe('feature-created on toggle some-toggle');
});
test('Should call registerEvent', async () => {
const addon = new WebhookAddon(ARGS);
const event: IEvent = {
id: 1,
createdAt: new Date(),
createdByUserId: SYSTEM_USER_ID,
type: FEATURE_CREATED,
createdBy: 'some@user.com',
featureName: 'some-toggle',
data: {
name: 'some-toggle',
enabled: false,
strategies: [{ name: 'default' }],
},
};
const parameters = {
url: 'http://test.webhook.com/plain',
bodyTemplate: '{{event.type}} on toggle {{event.data.name}}',
contentType: 'text/plain',
authorization: 'API KEY 123abc',
customHeaders: `{ "MY_CUSTOM_HEADER": "MY_CUSTOM_VALUE" }`,
};
await addon.handleEvent(event, parameters, INTEGRATION_ID);
expect(registerEventMock).toHaveBeenCalledTimes(1);
expect(registerEventMock).toHaveBeenCalledWith({
integrationId: INTEGRATION_ID,
state: 'success',
stateDetails:
'Webhook request was successful with status code: 200',
event: serializeDates(event),
details: {
url: parameters.url,
contentType: parameters.contentType,
body: 'feature-created on toggle some-toggle',
},
});
});
});

View File

@ -1,8 +1,9 @@
import Mustache from 'mustache';
import Addon from './addon';
import definition from './webhook-definition';
import type { LogProvider } from '../logger';
import type { IEvent } from '../types/events';
import { type IAddonConfig, serializeDates } from '../types';
import type { IntegrationEventState } from '../features/integration-events/integration-events-store';
interface IParameters {
url: string;
@ -13,11 +14,18 @@ interface IParameters {
}
export default class Webhook extends Addon {
constructor(args: { getLogger: LogProvider }) {
constructor(args: IAddonConfig) {
super(definition, args);
}
async handleEvent(event: IEvent, parameters: IParameters): Promise<void> {
async handleEvent(
event: IEvent,
parameters: IParameters,
integrationId: number,
): Promise<void> {
let state: IntegrationEventState = 'success';
let stateDetails = '';
const { url, bodyTemplate, contentType, authorization, customHeaders } =
parameters;
const context = {
@ -39,9 +47,10 @@ export default class Webhook extends Addon {
try {
extraHeaders = JSON.parse(customHeaders);
} catch (e) {
this.logger.warn(
`Could not parse the json in the customHeaders parameter. [${customHeaders}]`,
);
state = 'successWithErrors';
stateDetails =
'Could not parse the JSON in the customHeaders parameter.';
this.logger.warn(stateDetails);
}
}
const requestOpts = {
@ -58,5 +67,24 @@ export default class Webhook extends Addon {
this.logger.info(
`Handled event "${event.type}". Status code: ${res.status}`,
);
if (res.ok) {
stateDetails = `Webhook request was successful with status code: ${res.status}`;
} else {
state = 'failed';
stateDetails = `Webhook request failed with status code: ${res.status}`;
}
this.registerEvent({
integrationId,
state,
stateDetails,
event: serializeDates(event),
details: {
url,
contentType,
body,
},
});
}
}

View File

@ -7,6 +7,8 @@ export type IntegrationEventWriteModel = Omit<
'id' | 'createdAt'
>;
export type IntegrationEventState = IntegrationEventWriteModel['state'];
export class IntegrationEventsStore extends CRUDStore<
IntegrationEventSchema,
IntegrationEventWriteModel

View File

@ -1,6 +1,6 @@
import Addon from '../addons/addon';
import getLogger from '../../test/fixtures/no-logger';
import type { IAddonDefinition } from '../types/model';
import type { IAddonConfig, IAddonDefinition } from '../types/model';
import {
FEATURE_ARCHIVED,
FEATURE_CREATED,
@ -8,6 +8,14 @@ import {
FEATURE_UPDATED,
type IEvent,
} from '../types/events';
import type { IFlagResolver, IntegrationEventsService } from '../internals';
const ARGS: IAddonConfig = {
getLogger,
unleashUrl: 'http://some-url.com',
integrationEventsService: {} as IntegrationEventsService,
flagResolver: {} as IFlagResolver,
};
const definition: IAddonDefinition = {
name: 'simple',
@ -57,7 +65,7 @@ export default class SimpleAddon extends Addon {
events: any[];
constructor() {
super(definition, { getLogger });
super(definition, ARGS);
this.events = [];
}

View File

@ -15,8 +15,9 @@ import type { IAddonDto } from '../types/stores/addon-store';
import SimpleAddon from './addon-service-test-simple-addon';
import type { IAddonProviders } from '../addons';
import EventService from '../features/events/event-service';
import { SYSTEM_USER, TEST_AUDIT_USER } from '../types';
import { type IFlagResolver, SYSTEM_USER, TEST_AUDIT_USER } from '../types';
import EventEmitter from 'node:events';
import { IntegrationEventsService } from '../internals';
const MASKED_VALUE = '*****';
@ -35,6 +36,10 @@ function getSetup() {
{ getLogger },
eventService,
);
const integrationEventsService = new IntegrationEventsService(stores, {
getLogger,
flagResolver: {} as IFlagResolver,
});
addonProvider = { simple: new SimpleAddon() };
return {
@ -47,6 +52,7 @@ function getSetup() {
},
tagTypeService,
eventService,
integrationEventsService,
addonProvider,
),
eventService,

View File

@ -67,6 +67,7 @@ export default class AddonService {
}: Pick<IUnleashConfig, 'getLogger' | 'server' | 'flagResolver'>,
tagTypeService: TagTypeService,
eventService: EventService,
integrationEventsService,
addons?: IAddonProviders,
) {
this.addonStore = addonStore;
@ -80,6 +81,7 @@ export default class AddonService {
getAddons({
getLogger,
unleashUrl: server.unleashUrl,
integrationEventsService,
flagResolver,
});
this.sensitiveParams = this.loadSensitiveParams(this.addonProviders);
@ -145,6 +147,7 @@ export default class AddonService {
addonProviders[addon.provider].handleEvent(
event,
addon.parameters,
addon.id,
),
);
});

View File

@ -201,6 +201,7 @@ export const createServices = (
config,
tagTypeService,
eventService,
integrationEventsService,
);
const sessionService = new SessionService(stores, config);
const settingService = new SettingService(stores, config, eventService);

View File

@ -7,6 +7,8 @@ import type { IProjectStats } from '../features/project/project-service';
import type { CreateFeatureStrategySchema } from '../openapi';
import type { ProjectEnvironment } from '../features/project/project-store-type';
import type { FeatureSearchEnvironmentSchema } from '../openapi/spec/feature-search-environment-schema';
import type { IntegrationEventsService } from '../features/integration-events/integration-events-service';
import type { IFlagResolver } from './experimental';
export type Operator = (typeof ALL_OPERATORS)[number];
@ -376,6 +378,8 @@ export interface IAddonAlert {
export interface IAddonConfig {
getLogger: LogProvider;
unleashUrl: string;
integrationEventsService: IntegrationEventsService;
flagResolver: IFlagResolver;
}
export interface IUserWithRole {

View File

@ -7,7 +7,7 @@ import { type IUnleashStores, TEST_AUDIT_USER } from '../../../lib/types';
import SimpleAddon from '../../../lib/services/addon-service-test-simple-addon';
import TagTypeService from '../../../lib/features/tag-type/tag-type-service';
import { FEATURE_CREATED } from '../../../lib/types/events';
import { EventService } from '../../../lib/services';
import { EventService, IntegrationEventsService } from '../../../lib/services';
const addonProvider = { simple: new SimpleAddon() };
@ -24,11 +24,16 @@ beforeAll(async () => {
stores = db.stores;
const eventService = new EventService(stores, config);
const tagTypeService = new TagTypeService(stores, config, eventService);
const integrationEventsService = new IntegrationEventsService(
stores,
config,
);
addonService = new AddonService(
stores,
config,
tagTypeService,
eventService,
integrationEventsService,
addonProvider,
);
});