1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00
unleash.unleash/src/lib/services/addon-service.ts
Nuno Góis 521cc24a22
feat: add more events in integrations (#4815)
https://linear.app/unleash/issue/2-1253/add-support-for-more-events-in-the-slack-app-integration

Adds support for a lot more events in our integrations. Here is how the
full list looks like:

- ADDON_CONFIG_CREATED
- ADDON_CONFIG_DELETED
- ADDON_CONFIG_UPDATED
- API_TOKEN_CREATED
- API_TOKEN_DELETED
- CHANGE_ADDED
- CHANGE_DISCARDED
- CHANGE_EDITED
- CHANGE_REQUEST_APPLIED
- CHANGE_REQUEST_APPROVAL_ADDED
- CHANGE_REQUEST_APPROVED
- CHANGE_REQUEST_CANCELLED
- CHANGE_REQUEST_CREATED
- CHANGE_REQUEST_DISCARDED
- CHANGE_REQUEST_REJECTED
- CHANGE_REQUEST_SENT_TO_REVIEW
- CONTEXT_FIELD_CREATED
- CONTEXT_FIELD_DELETED
- CONTEXT_FIELD_UPDATED
- FEATURE_ARCHIVED
- FEATURE_CREATED
- FEATURE_DELETED
- FEATURE_ENVIRONMENT_DISABLED
- FEATURE_ENVIRONMENT_ENABLED
- FEATURE_ENVIRONMENT_VARIANTS_UPDATED
- FEATURE_METADATA_UPDATED
- FEATURE_POTENTIALLY_STALE_ON
- FEATURE_PROJECT_CHANGE
- FEATURE_REVIVED
- FEATURE_STALE_OFF
- FEATURE_STALE_ON
- FEATURE_STRATEGY_ADD
- FEATURE_STRATEGY_REMOVE
- FEATURE_STRATEGY_UPDATE
- FEATURE_TAGGED
- FEATURE_UNTAGGED
- GROUP_CREATED
- GROUP_DELETED
- GROUP_UPDATED
- PROJECT_CREATED
- PROJECT_DELETED
- SEGMENT_CREATED
- SEGMENT_DELETED
- SEGMENT_UPDATED
- SERVICE_ACCOUNT_CREATED
- SERVICE_ACCOUNT_DELETED
- SERVICE_ACCOUNT_UPDATED
- USER_CREATED
- USER_DELETED
- USER_UPDATED

I added the events that I thought were relevant based on my own
discretion. Know of any event we should add? Let me know and I'll add it
🙂

For now I only added these events to the new Slack App integration, but
we can add them to the other integrations as well since they are now
supported.

The event formatter was refactored and changed quite a bit in order to
make it easier to maintain and add new events in the future. As a
result, events are now posted with different text. Do we consider this a
breaking change? If so, I can keep the old event formatter around,
create a new one and only use it for the new Slack App integration.

I noticed we don't have good 404 behaviors in the UI for things that are
deleted in the meantime, that's why I avoided some links to specific
resources (like feature strategies, integration configurations, etc),
but we could add them later if we improve this.

This PR also tries to add some consistency to the the way we log events.
2023-09-29 16:11:59 +01:00

313 lines
11 KiB
TypeScript

import memoizee from 'memoizee';
import { ValidationError } from 'joi';
import { getAddons, IAddonProviders } from '../addons';
import * as events from '../types/events';
import { addonSchema } from './addon-schema';
import NameExistsError from '../error/name-exists-error';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { Logger } from '../logger';
import TagTypeService from './tag-type-service';
import { IAddon, IAddonDto, IAddonStore } from '../types/stores/addon-store';
import { IUnleashStores, IUnleashConfig } from '../types';
import { IAddonDefinition } from '../types/model';
import { minutesToMilliseconds } from 'date-fns';
import EventService from './event-service';
import { omitKeys } from '../util';
const SUPPORTED_EVENTS = Object.keys(events).map((k) => events[k]);
const MASKED_VALUE = '*****';
const WILDCARD_OPTION = '*';
interface ISensitiveParams {
[key: string]: string[];
}
export default class AddonService {
addonStore: IAddonStore;
featureToggleStore: IFeatureToggleStore;
logger: Logger;
tagTypeService: TagTypeService;
eventService: EventService;
addonProviders: IAddonProviders;
sensitiveParams: ISensitiveParams;
fetchAddonConfigs: (() => Promise<IAddon[]>) &
memoizee.Memoized<() => Promise<IAddon[]>>;
constructor(
{
addonStore,
featureToggleStore,
}: Pick<IUnleashStores, 'addonStore' | 'featureToggleStore'>,
{
getLogger,
server,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'server' | 'flagResolver'>,
tagTypeService: TagTypeService,
eventService: EventService,
addons?: IAddonProviders,
) {
this.addonStore = addonStore;
this.featureToggleStore = featureToggleStore;
this.logger = getLogger('services/addon-service.js');
this.tagTypeService = tagTypeService;
this.eventService = eventService;
this.addonProviders =
addons ||
getAddons({
getLogger,
unleashUrl: server.unleashUrl,
flagResolver,
});
this.sensitiveParams = this.loadSensitiveParams(this.addonProviders);
if (addonStore) {
this.registerEventHandler();
}
// Memoized private function
this.fetchAddonConfigs = memoizee(
async () => addonStore.getAll({ enabled: true }),
{
promise: true,
maxAge: minutesToMilliseconds(1),
},
);
}
loadSensitiveParams(addonProviders: IAddonProviders): ISensitiveParams {
const providerDefinitions = Object.values(addonProviders).map(
(p) => p.definition,
);
return providerDefinitions.reduce((obj, definition) => {
const sensitiveParams = definition.parameters
.filter((p) => p.sensitive)
.map((p) => p.name);
const o = { ...obj };
o[definition.name] = sensitiveParams;
return o;
}, {});
}
registerEventHandler(): void {
SUPPORTED_EVENTS.forEach((eventName) =>
this.eventService.onEvent(eventName, this.handleEvent(eventName)),
);
}
handleEvent(eventName: string): (IEvent) => void {
const { addonProviders } = this;
return (event) => {
this.fetchAddonConfigs().then((addonInstances) => {
addonInstances
.filter((addon) => addon.events.includes(eventName))
.filter(
(addon) =>
!event.project ||
!addon.projects ||
addon.projects.length === 0 ||
addon.projects[0] === WILDCARD_OPTION ||
addon.projects.includes(event.project),
)
.filter(
(addon) =>
!event.environment ||
!addon.environments ||
addon.environments.length === 0 ||
addon.environments[0] === WILDCARD_OPTION ||
addon.environments.includes(event.environment),
)
.filter((addon) => addonProviders[addon.provider])
.forEach((addon) =>
addonProviders[addon.provider].handleEvent(
event,
addon.parameters,
),
);
});
};
}
// Should be used by the controller.
async getAddons(): Promise<IAddon[]> {
const addonConfigs = await this.addonStore.getAll();
return addonConfigs.map((a) => this.filterSensitiveFields(a));
}
filterSensitiveFields(addonConfig: IAddon): IAddon {
const { sensitiveParams } = this;
const a = { ...addonConfig };
a.parameters = Object.keys(a.parameters).reduce((obj, paramKey) => {
const o = { ...obj };
if (sensitiveParams[a.provider].includes(paramKey)) {
o[paramKey] = MASKED_VALUE;
} else {
o[paramKey] = a.parameters[paramKey];
}
return o;
}, {});
return a;
}
async getAddon(id: number): Promise<IAddon> {
const addonConfig = await this.addonStore.get(id);
return this.filterSensitiveFields(addonConfig);
}
getProviderDefinitions(): IAddonDefinition[] {
const { addonProviders } = this;
return Object.values(addonProviders).map((p) => p.definition);
}
async addTagTypes(providerName: string): Promise<void> {
const provider = this.addonProviders[providerName];
if (provider) {
const tagTypes = provider.definition.tagTypes || [];
const createTags = tagTypes.map(async (tagType) => {
try {
await this.tagTypeService.validateUnique(tagType.name);
await this.tagTypeService.createTagType(
tagType,
providerName,
);
} catch (err) {
if (!(err instanceof NameExistsError)) {
this.logger.error(err);
}
}
});
await Promise.all(createTags);
}
return Promise.resolve();
}
async createAddon(data: IAddonDto, userName: string): Promise<IAddon> {
const addonConfig = await addonSchema.validateAsync(data);
await this.validateKnownProvider(addonConfig);
await this.validateRequiredParameters(addonConfig);
const createdAddon = await this.addonStore.insert(addonConfig);
await this.addTagTypes(createdAddon.provider);
this.logger.info(
`User ${userName} created addon ${addonConfig.provider}`,
);
await this.eventService.storeEvent({
type: events.ADDON_CONFIG_CREATED,
createdBy: userName,
data: omitKeys(createdAddon, 'parameters'),
});
return createdAddon;
}
async updateAddon(
id: number,
data: IAddonDto,
userName: string,
): Promise<IAddon> {
const existingConfig = await this.addonStore.get(id); // because getting an early 404 here makes more sense
const addonConfig = await addonSchema.validateAsync(data);
await this.validateKnownProvider(addonConfig);
await this.validateRequiredParameters(addonConfig);
if (this.sensitiveParams[addonConfig.provider].length > 0) {
addonConfig.parameters = Object.keys(addonConfig.parameters).reduce(
(params, key) => {
const o = { ...params };
if (addonConfig.parameters[key] === MASKED_VALUE) {
o[key] = existingConfig.parameters[key];
} else {
o[key] = addonConfig.parameters[key];
}
return o;
},
{},
);
}
const result = await this.addonStore.update(id, addonConfig);
await this.eventService.storeEvent({
type: events.ADDON_CONFIG_UPDATED,
createdBy: userName,
preData: omitKeys(existingConfig, 'parameters'),
data: omitKeys(result, 'parameters'),
});
this.logger.info(`User ${userName} updated addon ${id}`);
return result;
}
async removeAddon(id: number, userName: string): Promise<void> {
const existingConfig = await this.addonStore.get(id);
await this.addonStore.delete(id);
await this.eventService.storeEvent({
type: events.ADDON_CONFIG_DELETED,
createdBy: userName,
preData: omitKeys(existingConfig, 'parameters'),
});
this.logger.info(`User ${userName} removed addon ${id}`);
}
async validateKnownProvider(config: Partial<IAddonDto>): Promise<boolean> {
if (!config.provider) {
throw new ValidationError(
'No addon provider supplied. The property was either missing or an empty value.',
[],
undefined,
);
}
const p = this.addonProviders[config.provider];
if (!p) {
throw new ValidationError(
`Unknown addon provider ${config.provider}`,
[],
undefined,
);
} else {
return true;
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async validateRequiredParameters({
provider,
parameters,
}): Promise<boolean> {
const providerDefinition = this.addonProviders[provider].definition;
const requiredParamsMissing = providerDefinition.parameters
.filter((p) => p.required)
.map((p) => p.name)
.filter(
(requiredParam) =>
!Object.keys(parameters).includes(requiredParam),
);
if (requiredParamsMissing.length > 0) {
throw new ValidationError(
`Missing required parameters: ${requiredParamsMissing.join(
',',
)} `,
[],
undefined,
);
}
return true;
}
destroy(): void {
Object.values(this.addonProviders).forEach((addon) =>
addon.destroy?.(),
);
}
}