1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

feat: add project and environment columns to events (#942)

* feat: add project and environment columns to events

* Added events for feature_strategy update

* fix duplicate test key for dbInit

* Fix argument list for toggleService calls in tests
This commit is contained in:
Christopher Kolstad 2021-09-20 12:13:38 +02:00 committed by GitHub
parent 37d6c4886a
commit f85f66d4f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 241 additions and 37 deletions

View File

@ -12,6 +12,8 @@ const EVENT_COLUMNS = [
'created_at',
'data',
'tags',
'project',
'environment',
];
export interface IEventTable {
@ -20,6 +22,8 @@ export interface IEventTable {
created_by: string;
created_at: Date;
data: any;
project?: string;
environment?: string;
tags: [];
}
@ -126,6 +130,19 @@ class EventStore extends EventEmitter implements IEventStore {
}
}
async getEventsFilterByProject(project: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where({ project })
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
} catch (err) {
return [];
}
}
rowToEvent(row: IEventTable): IEvent {
return {
id: row.id,
@ -134,6 +151,8 @@ class EventStore extends EventEmitter implements IEventStore {
createdAt: row.created_at,
data: row.data,
tags: row.tags || [],
project: row.project,
environment: row.environment,
};
}
@ -143,6 +162,8 @@ class EventStore extends EventEmitter implements IEventStore {
created_by: e.createdBy,
data: e.data,
tags: JSON.stringify(e.tags),
project: e.project,
environment: e.environment,
};
}
}

View File

@ -24,7 +24,14 @@ export default class EventController extends Controller {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async getEvents(req, res): Promise<void> {
const events = await this.eventService.getEvents();
let events;
if (req.query?.project) {
events = await this.eventService.getEventsForProject(
req.query.project,
);
} else {
events = await this.eventService.getEvents();
}
eventDiffer.addDiffs(events);
res.json({ version, events });
}

View File

@ -174,6 +174,7 @@ class FeatureController extends Controller {
s,
createdFeature.project,
createdFeature.name,
userName,
),
),
);
@ -219,6 +220,7 @@ class FeatureController extends Controller {
s,
projectId,
featureName,
userName,
),
),
);

View File

@ -220,14 +220,16 @@ export default class ProjectFeaturesController extends Controller {
}
async addStrategy(
req: Request<FeatureStrategyParams, any, IStrategyConfig, any>,
req: IAuthRequest<FeatureStrategyParams, any, IStrategyConfig, any>,
res: Response,
): Promise<void> {
const { projectId, featureName, environment } = req.params;
const userName = extractUsername(req);
const featureStrategy = await this.featureService.createStrategy(
req.body,
projectId,
featureName,
userName,
environment,
);
res.status(200).json(featureStrategy);
@ -248,34 +250,42 @@ export default class ProjectFeaturesController extends Controller {
}
async updateStrategy(
req: Request<StrategyIdParams, any, StrategyUpdateBody, any>,
req: IAuthRequest<StrategyIdParams, any, StrategyUpdateBody, any>,
res: Response,
): Promise<void> {
const { strategyId } = req.params;
const { strategyId, environment, projectId } = req.params;
const userName = extractUsername(req);
const updatedStrategy = await this.featureService.updateStrategy(
strategyId,
environment,
projectId,
userName,
req.body,
);
res.status(200).json(updatedStrategy);
}
async patchStrategy(
req: Request<StrategyIdParams, any, Operation[], any>,
req: IAuthRequest<StrategyIdParams, any, Operation[], any>,
res: Response,
): Promise<void> {
const { strategyId } = req.params;
const { strategyId, projectId, environment } = req.params;
const userName = extractUsername(req);
const patch = req.body;
const strategy = await this.featureService.getStrategy(strategyId);
const { newDocument } = applyPatch(strategy, patch);
const updatedStrategy = await this.featureService.updateStrategy(
strategyId,
environment,
projectId,
userName,
newDocument,
);
res.status(200).json(updatedStrategy);
}
async getStrategy(
req: Request<StrategyIdParams, any, any, any>,
req: IAuthRequest<StrategyIdParams, any, any, any>,
res: Response,
): Promise<void> {
this.logger.info('Getting strategy');
@ -286,18 +296,25 @@ export default class ProjectFeaturesController extends Controller {
}
async deleteStrategy(
req: Request<StrategyIdParams, any, any, any>,
req: IAuthRequest<StrategyIdParams, any, any, any>,
res: Response,
): Promise<void> {
this.logger.info('Deleting strategy');
const { environment, projectId } = req.params;
const userName = extractUsername(req);
const { strategyId } = req.params;
this.logger.info(strategyId);
const strategy = await this.featureService.deleteStrategy(strategyId);
const strategy = await this.featureService.deleteStrategy(
strategyId,
userName,
projectId,
environment,
);
res.status(200).json(strategy);
}
async updateStrategyParameter(
req: Request<
req: IAuthRequest<
StrategyIdParams,
any,
{ name: string; value: string | number },
@ -305,7 +322,8 @@ export default class ProjectFeaturesController extends Controller {
>,
res: Response,
): Promise<void> {
const { strategyId } = req.params;
const { strategyId, environment, projectId } = req.params;
const userName = extractUsername(req);
const { name, value } = req.body;
const updatedStrategy =
@ -313,6 +331,9 @@ export default class ProjectFeaturesController extends Controller {
strategyId,
name,
value,
userName,
projectId,
environment,
);
res.status(200).json(updatedStrategy);
}

View File

@ -28,6 +28,10 @@ export default class EventService {
(e: IEvent) => e.type !== FEATURE_METADATA_UPDATED,
);
}
async getEventsForProject(project: string): Promise<IEvent[]> {
return this.eventStore.getEventsFilterByProject(project);
}
}
module.exports = EventService;

View File

@ -10,11 +10,14 @@ import {
FEATURE_ARCHIVED,
FEATURE_CREATED,
FEATURE_DELETED,
FEATURE_METADATA_UPDATED,
FEATURE_REVIVED,
FEATURE_STALE_OFF,
FEATURE_STALE_ON,
FEATURE_STRATEGY_ADD,
FEATURE_STRATEGY_REMOVE,
FEATURE_STRATEGY_UPDATE,
FEATURE_UPDATED,
FEATURE_METADATA_UPDATED,
} from '../types/events';
import { GLOBAL_ENV } from '../types/environment';
import NotFoundError from '../error/notfound-error';
@ -88,16 +91,11 @@ class FeatureToggleServiceV2 {
this.featureEnvironmentStore = featureEnvironmentStore;
}
/*
TODO after 4.1.0 release:
- add FEATURE_STRATEGY_ADD event
- add FEATURE_STRATEGY_REMOVE event
- add FEATURE_STRATEGY_UPDATE event
*/
async createStrategy(
strategyConfig: Omit<IStrategyConfig, 'id'>,
projectId: string,
featureName: string,
userName: string,
environment: string = GLOBAL_ENV,
): Promise<IStrategyConfig> {
try {
@ -111,12 +109,20 @@ class FeatureToggleServiceV2 {
featureName,
environment,
});
return {
const data = {
id: newFeatureStrategy.id,
name: newFeatureStrategy.strategyName,
constraints: newFeatureStrategy.constraints,
parameters: newFeatureStrategy.parameters,
};
await this.eventStore.store({
type: FEATURE_STRATEGY_ADD,
project: projectId,
createdBy: userName,
environment,
data,
});
return data;
} catch (e) {
if (e.code === FOREIGN_KEY_VIOLATION) {
throw new BadDataError(
@ -126,6 +132,12 @@ class FeatureToggleServiceV2 {
throw e;
}
}
/*
TODO after 4.1.0 release:
- add FEATURE_STRATEGY_ADD event
- add FEATURE_STRATEGY_REMOVE event
- add FEATURE_STRATEGY_UPDATE event
*/
/**
* PUT /api/admin/projects/:projectId/features/:featureName/strategies/:strategyId ?
@ -139,6 +151,9 @@ class FeatureToggleServiceV2 {
// TODO: verify projectId is not changed from URL!
async updateStrategy(
id: string,
environment: string,
project: string,
userName: string,
updates: Partial<IFeatureStrategy>,
): Promise<IStrategyConfig> {
const existingStrategy = await this.featureStrategiesStore.get(id);
@ -147,12 +162,20 @@ class FeatureToggleServiceV2 {
id,
updates,
);
return {
const data = {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
};
await this.eventStore.store({
type: FEATURE_STRATEGY_UPDATE,
project,
environment,
createdBy: userName,
data,
});
return data;
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
@ -162,6 +185,9 @@ class FeatureToggleServiceV2 {
id: string,
name: string,
value: string | number,
userName: string,
project: string,
environment: string,
): Promise<IStrategyConfig> {
const existingStrategy = await this.featureStrategiesStore.get(id);
if (existingStrategy.id === id) {
@ -170,12 +196,20 @@ class FeatureToggleServiceV2 {
id,
existingStrategy,
);
return {
const data = {
id: strategy.id,
name: strategy.strategyName,
constraints: strategy.constraints || [],
parameters: strategy.parameters,
};
await this.eventStore.store({
type: FEATURE_STRATEGY_UPDATE,
project,
environment,
createdBy: userName,
data,
});
return data;
}
throw new NotFoundError(`Could not find strategy with id ${id}`);
}
@ -188,8 +222,22 @@ class FeatureToggleServiceV2 {
* @param id
* @param updates
*/
async deleteStrategy(id: string): Promise<void> {
return this.featureStrategiesStore.delete(id);
async deleteStrategy(
id: string,
userName: string,
project: string = 'default',
environment: string = GLOBAL_ENV,
): Promise<void> {
await this.featureStrategiesStore.delete(id);
await this.eventStore.store({
type: FEATURE_STRATEGY_REMOVE,
project,
environment,
createdBy: userName,
data: {
id,
},
});
}
async getStrategiesForEnvironment(
@ -309,6 +357,7 @@ class FeatureToggleServiceV2 {
await this.eventStore.store({
type: FEATURE_CREATED,
createdBy: userName,
project: projectId,
data,
});
@ -341,6 +390,7 @@ class FeatureToggleServiceV2 {
type: FEATURE_METADATA_UPDATED,
createdBy: userName,
data: featureToggle,
project: projectId,
tags,
});
return featureToggle;
@ -455,12 +505,13 @@ class FeatureToggleServiceV2 {
createdBy: userName,
data,
tags,
project: feature.project,
});
return feature;
}
async archiveToggle(name: string, userName: string): Promise<void> {
await this.featureToggleStore.get(name);
const feature = await this.featureToggleStore.get(name);
await this.featureToggleStore.archive(name);
const tags =
(await this.featureTagStore.getAllTagsForFeature(name)) || [];
@ -468,6 +519,7 @@ class FeatureToggleServiceV2 {
type: FEATURE_ARCHIVED,
createdBy: userName,
data: { name },
project: feature.project,
tags,
});
}
@ -514,6 +566,8 @@ class FeatureToggleServiceV2 {
createdBy: userName,
data,
tags,
project: projectId,
environment,
});
return feature;
}
@ -583,6 +637,7 @@ class FeatureToggleServiceV2 {
type: event || FEATURE_UPDATED,
createdBy: userName,
data,
project: data.project,
tags,
});
return feature;
@ -612,6 +667,7 @@ class FeatureToggleServiceV2 {
type: FEATURE_REVIVED,
createdBy: userName,
data,
project: data.project,
tags,
});
}

View File

@ -131,6 +131,7 @@ export default class ProjectService {
type: PROJECT_CREATED,
createdBy: getCreatedBy(user),
data,
project: newProject.id,
});
return data;
@ -146,6 +147,7 @@ export default class ProjectService {
type: PROJECT_UPDATED,
createdBy: getCreatedBy(user),
data: project,
project: project.id,
});
}
@ -211,10 +213,11 @@ export default class ProjectService {
await this.eventStore.store({
type: PROJECT_DELETED,
createdBy: getCreatedBy(user),
project: id,
data: { id },
});
this.accessService.removeDefaultProjectRoles(user, id);
await this.accessService.removeDefaultProjectRoles(user, id);
}
async validateId(id: string): Promise<boolean> {

View File

@ -9,6 +9,9 @@ export const FEATURE_REVIVED = 'feature-revived';
export const FEATURE_IMPORT = 'feature-import';
export const FEATURE_TAGGED = 'feature-tagged';
export const FEATURE_TAG_IMPORT = 'feature-tag-import';
export const FEATURE_STRATEGY_UPDATE = 'feature-strategy-update';
export const FEATURE_STRATEGY_ADD = 'feature-strategy-add';
export const FEATURE_STRATEGY_REMOVE = 'feature-strategy-remove';
export const DROP_FEATURE_TAGS = 'drop-feature-tags';
export const FEATURE_UNTAGGED = 'feature-untagged';
export const FEATURE_STALE_ON = 'feature-stale-on';

View File

@ -197,6 +197,8 @@ export interface IAddonConfig {
export interface ICreateEvent {
type: string;
createdBy: string;
project?: string;
environment?: string;
data?: any;
tags?: ITag[];
}

View File

@ -7,4 +7,5 @@ export interface IEventStore extends Store<IEvent, number>, EventEmitter {
batchStore(events: ICreateEvent[]): Promise<void>;
getEvents(): Promise<IEvent[]>;
getEventsFilterByType(name: string): Promise<IEvent[]>;
getEventsFilterByProject(project: string): Promise<IEvent[]>;
}

View File

@ -0,0 +1,29 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
ALTER TABLE events
ADD COLUMN project TEXT;
ALTER TABLE events
ADD COLUMN environment TEXT;
CREATE INDEX events_project_idx ON events(project);
CREATE INDEX events_environment_idx ON events(environment);
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
DROP INDEX events_environment_idx;
DROP INDEX events_project_idx;
ALTER TABLE events
DROP COLUMN environment;
ALTER TABLE events
DROP COLUMN project;
`,
cb,
);
};

View File

@ -1,13 +1,17 @@
import { setupApp } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { FEATURE_CREATED } from '../../../../lib/types/events';
import { IEventStore } from '../../../../lib/types/stores/event-store';
let app;
let db;
let app: IUnleashTest;
let db: ITestDb;
let eventStore: IEventStore;
beforeAll(async () => {
db = await dbInit('event_api_serial', getLogger);
app = await setupApp(db.stores);
eventStore = db.stores.eventStore;
});
afterAll(async () => {
@ -30,3 +34,29 @@ test('returns events given a name', async () => {
.expect('Content-Type', /json/)
.expect(200);
});
test('Can filter by project', async () => {
await eventStore.store({
type: FEATURE_CREATED,
project: 'something-else',
data: { id: 'some-other-feature' },
tags: [],
createdBy: 'test-user',
environment: 'test',
});
await eventStore.store({
type: FEATURE_CREATED,
project: 'default',
data: { id: 'feature' },
tags: [],
createdBy: 'test-user',
environment: 'test',
});
await app.request
.get('/api/admin/events?project=default')
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].data.id).toEqual('feature');
});
});

View File

@ -32,6 +32,7 @@ beforeAll(async () => {
strategy,
projectId,
toggle.name,
username,
);
};
@ -273,6 +274,7 @@ test('can not toggle of feature that does not exist', async () => {
test('can toggle a feature that does exist', async () => {
expect.assertions(0);
const featureName = 'existing.feature';
const username = 'toggle-feature';
const feature =
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
@ -285,6 +287,7 @@ test('can toggle a feature that does exist', async () => {
defaultStrategy,
'default',
featureName,
username,
);
return app.request
.post(`/api/admin/features/${feature.name}/toggle`)

View File

@ -57,6 +57,7 @@ beforeAll(async () => {
},
project,
feature1,
username,
);
await featureToggleServiceV2.createStrategy(
{
@ -66,6 +67,7 @@ beforeAll(async () => {
},
project,
feature1,
username,
environment,
);
@ -85,6 +87,7 @@ beforeAll(async () => {
},
project,
feature2,
username,
environment,
);
@ -104,6 +107,7 @@ beforeAll(async () => {
},
project2,
feature3,
username,
environment,
);
});
@ -135,7 +139,7 @@ test('returns feature toggle with :global: config', async () => {
});
});
test('returns feature toggle with :global: config', async () => {
test('returns feature toggle with testing environment config', async () => {
const token = await apiTokenService.createApiToken({
type: ApiTokenType.CLIENT,
username,

View File

@ -27,6 +27,8 @@ afterAll(async () => {
});
test('Should create feature toggle strategy configuration', async () => {
const projectId = 'default';
const username = 'feature-toggle';
const config: Omit<IStrategyConfig, 'id'> = {
name: 'default',
constraints: [],
@ -43,8 +45,9 @@ test('Should create feature toggle strategy configuration', async () => {
const createdConfig = await service.createStrategy(
config,
'default',
projectId,
'Demo',
username,
);
expect(createdConfig.name).toEqual('default');
@ -52,6 +55,8 @@ test('Should create feature toggle strategy configuration', async () => {
});
test('Should be able to update existing strategy configuration', async () => {
const projectId = 'default';
const username = 'existing-strategy';
const config: Omit<IStrategyConfig, 'id'> = {
name: 'default',
constraints: [],
@ -59,7 +64,7 @@ test('Should be able to update existing strategy configuration', async () => {
};
await service.createFeatureToggle(
'default',
projectId,
{
name: 'update-existing-strategy',
},
@ -70,11 +75,18 @@ test('Should be able to update existing strategy configuration', async () => {
config,
'default',
'update-existing-strategy',
username,
);
expect(createdConfig.name).toEqual('default');
const updatedConfig = await service.updateStrategy(createdConfig.id, {
parameters: { b2b: true },
});
const updatedConfig = await service.updateStrategy(
createdConfig.id,
GLOBAL_ENV,
projectId,
username,
{
parameters: { b2b: true },
},
);
expect(createdConfig.id).toEqual(updatedConfig.id);
expect(updatedConfig.parameters).toEqual({ b2b: true });
});
@ -96,7 +108,7 @@ test('Should include legacy props in event log when updating strategy configurat
userName,
);
await service.createStrategy(config, 'default', featureName);
await service.createStrategy(config, 'default', featureName, userName);
await service.updateEnabled(
'default',
featureName,
@ -112,6 +124,7 @@ test('Should include legacy props in event log when updating strategy configurat
});
test('Should be able to get strategy by id', async () => {
const userName = 'strategy';
const config: Omit<IStrategyConfig, 'id'> = {
name: 'default',
constraints: [],
@ -130,6 +143,7 @@ test('Should be able to get strategy by id', async () => {
config,
'default',
'Demo',
userName,
);
const fetchedConfig = await service.getStrategy(createdConfig.id);
expect(fetchedConfig).toEqual(createdConfig);

View File

@ -10,7 +10,7 @@ let userStore: IUserStore;
let currentUser;
beforeAll(async () => {
db = await dbInit('project_store_serial', getLogger);
db = await dbInit('user_feedback_store', getLogger);
stores = db.stores;
userFeedbackStore = stores.userFeedbackStore;
userStore = stores.userStore;

View File

@ -57,6 +57,10 @@ class FakeEventStore extends EventEmitter implements IEventStore {
async getEventsFilterByType(type: string): Promise<IEvent[]> {
return this.events.filter((e) => e.type === type);
}
async getEventsFilterByProject(project: string): Promise<IEvent[]> {
return this.events.filter((e) => e.project === project);
}
}
module.exports = FakeEventStore;