Skip to content

Commit

Permalink
feat: bulk delete features (#3314)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus committed Mar 15, 2023
1 parent ac1be47 commit a5f1b89
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 7 deletions.
7 changes: 7 additions & 0 deletions src/lib/db/feature-toggle-store.ts
Expand Up @@ -301,6 +301,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.del();
}

async batchDelete(names: string[]): Promise<void> {
await this.db(TABLE)
.whereIn('name', names)
.whereNotNull('archived_at')
.del();
}

async revive(name: string): Promise<FeatureToggle> {
const row = await this.db(TABLE)
.where({ name })
Expand Down
2 changes: 2 additions & 0 deletions src/lib/routes/admin-api/project/index.ts
Expand Up @@ -26,6 +26,7 @@ import {
import { IArchivedQuery, IProjectParam } from '../../../types/model';
import { ProjectApiTokenController } from './api-token';
import { SettingService } from '../../../services';
import ProjectArchiveController from './project-archive';

const STICKINESS_KEY = 'stickiness';
const DEFAULT_STICKINESS = 'default';
Expand Down Expand Up @@ -114,6 +115,7 @@ export default class ProjectApi extends Controller {
this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(config, services).router);
this.use('/', new ProjectApiTokenController(config, services).router);
this.use('/', new ProjectArchiveController(config, services).router);
}

async getProjects(
Expand Down
72 changes: 72 additions & 0 deletions src/lib/routes/admin-api/project/project-archive.ts
@@ -0,0 +1,72 @@
import { Response } from 'express';
import { IUnleashConfig } from '../../../types/option';
import { IFlagResolver, IProjectParam, IUnleashServices } from '../../../types';
import { Logger } from '../../../logger';
import { extractUsername } from '../../../util/extract-user';
import { DELETE_FEATURE } from '../../../types/permissions';
import FeatureToggleService from '../../../services/feature-toggle-service';
import { IAuthRequest } from '../../unleash-types';
import { OpenApiService } from '../../../services/openapi-service';
import { emptyResponse } from '../../../openapi/util/standard-responses';
import { BatchFeaturesSchema, createRequestSchema } from '../../../openapi';
import NotFoundError from '../../../error/notfound-error';
import Controller from '../../controller';

const PATH = '/:projectId/archive';
const PATH_DELETE = `${PATH}/delete`;

export default class ProjectArchiveController extends Controller {
private readonly logger: Logger;

private featureService: FeatureToggleService;

private openApiService: OpenApiService;

private flagResolver: IFlagResolver;

constructor(
config: IUnleashConfig,
{
featureToggleServiceV2,
openApiService,
}: Pick<IUnleashServices, 'featureToggleServiceV2' | 'openApiService'>,
) {
super(config);
this.logger = config.getLogger('/admin-api/archive.js');
this.featureService = featureToggleServiceV2;
this.openApiService = openApiService;
this.flagResolver = config.flagResolver;

this.route({
method: 'post',
path: PATH_DELETE,
acceptAnyContentType: true,
handler: this.deleteFeatures,
permission: DELETE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Archive'],
operationId: 'deleteFeatures',
requestBody: createRequestSchema('batchFeaturesSchema'),
responses: { 200: emptyResponse },
}),
],
});
}

async deleteFeatures(
req: IAuthRequest<IProjectParam, any, BatchFeaturesSchema>,
res: Response<void>,
): Promise<void> {
if (!this.flagResolver.isEnabled('bulkOperations')) {
throw new NotFoundError('Bulk operations are not enabled');
}
const { projectId } = req.params;
const { features } = req.body;
const user = extractUsername(req);
await this.featureService.deleteFeatures(features, projectId, user);
res.status(200).end();
}
}

module.exports = ProjectArchiveController;
57 changes: 54 additions & 3 deletions src/lib/services/feature-toggle-service.ts
Expand Up @@ -1089,13 +1089,20 @@ class FeatureToggleService {
featureNames,
);
await this.featureToggleStore.batchArchive(featureNames);
const tags = await this.tagStore.getAllByFeatures(featureNames);
await this.eventStore.batchStore(
features.map(
(feature) =>
new FeatureArchivedEvent({
featureName: feature.name,
createdBy,
project: feature.project,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,
})),
}),
),
);
Expand All @@ -1115,10 +1122,11 @@ class FeatureToggleService {
const relevantFeatures = features.filter(
(feature) => feature.stale !== stale,
);
await this.featureToggleStore.batchStale(
relevantFeatures.map((feature) => feature.name),
stale,
const relevantFeatureNames = relevantFeatures.map(
(feature) => feature.name,
);
await this.featureToggleStore.batchStale(relevantFeatureNames, stale);
const tags = await this.tagStore.getAllByFeatures(relevantFeatureNames);
await this.eventStore.batchStore(
relevantFeatures.map(
(feature) =>
Expand All @@ -1127,6 +1135,12 @@ class FeatureToggleService {
project: projectId,
featureName: feature.name,
createdBy,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,
})),
}),
),
);
Expand Down Expand Up @@ -1320,6 +1334,43 @@ class FeatureToggleService {
);
}

async deleteFeatures(
featureNames: string[],
projectId: string,
createdBy: string,
): Promise<void> {
await this.validateFeaturesContext(featureNames, projectId);

const features = await this.featureToggleStore.getAllByNames(
featureNames,
);
const eligibleFeatures = features.filter(
(toggle) => toggle.archivedAt !== null,
);
const eligibleFeatureNames = eligibleFeatures.map(
(toggle) => toggle.name,
);
const tags = await this.tagStore.getAllByFeatures(eligibleFeatureNames);
await this.featureToggleStore.batchDelete(eligibleFeatureNames);
await this.eventStore.batchStore(
eligibleFeatures.map(
(feature) =>
new FeatureDeletedEvent({
featureName: feature.name,
createdBy,
project: feature.project,
preData: feature,
tags: tags
.filter((tag) => tag.featureName === feature.name)
.map((tag) => ({
value: tag.tagValue,
type: tag.tagType,
})),
}),
),
);
}

// TODO: add project id.
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
const toggle = await this.featureToggleStore.revive(featureName);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/types/events.ts
Expand Up @@ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent {
project: string;
featureName: string;
createdBy: string | IUser;
tags?: ITag[];
tags: ITag[];
}) {
super(
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
Expand Down Expand Up @@ -330,7 +330,7 @@ export class FeatureArchivedEvent extends BaseEvent {
project: string;
featureName: string;
createdBy: string | IUser;
tags?: ITag[];
tags: ITag[];
}) {
super(FEATURE_ARCHIVED, p.createdBy, p.tags);
const { project, featureName } = p;
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/stores/feature-toggle-store.ts
Expand Up @@ -20,6 +20,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
featureNames: string[],
stale: boolean,
): Promise<FeatureToggle[]>;
batchDelete(featureNames: string[]): Promise<void>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
Expand Down
38 changes: 36 additions & 2 deletions src/test/e2e/api/admin/feature-archive.e2e.test.ts
@@ -1,4 +1,4 @@
import { setupApp } from '../../helpers/test-helper';
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';

Expand All @@ -7,7 +7,14 @@ let db;

beforeAll(async () => {
db = await dbInit('archive_serial', getLogger);
app = await setupApp(db.stores);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
bulkOperations: true,
},
},
});
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
Expand Down Expand Up @@ -183,3 +190,30 @@ test('Deleting an unarchived toggle should not take effect', async () => {
.set('Content-Type', 'application/json')
.expect(409); // because it still exists
});

test('can bulk delete features and recreate after', async () => {
const features = ['first.bulk.issue', 'second.bulk.issue'];
for (const feature of features) {
await app.request
.post('/api/admin/features')
.send({
name: feature,
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201);
await app.request.delete(`/api/admin/features/${feature}`).expect(200);
}
await app.request
.post('/api/admin/projects/default/archive/delete')
.send({ features })
.expect(200);
for (const feature of features) {
await app.request
.post('/api/admin/features/validate')
.send({ name: feature })
.set('Content-Type', 'application/json')
.expect(200);
}
});
34 changes: 34 additions & 0 deletions src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
Expand Up @@ -6044,6 +6044,40 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/archive/delete": {
"post": {
"operationId": "deleteFeatures",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/batchFeaturesSchema",
},
},
},
"description": "batchFeaturesSchema",
"required": true,
},
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Archive",
],
},
},
"/api/admin/projects/{projectId}/environments": {
"post": {
"operationId": "addEnvironmentToProject",
Expand Down
7 changes: 7 additions & 0 deletions src/test/fixtures/fake-feature-toggle-store.ts
Expand Up @@ -47,6 +47,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return features;
}

async batchDelete(featureNames: string[]): Promise<void> {
this.features = this.features.filter(
(feature) => !featureNames.includes(feature.name),
);
return Promise.resolve();
}

async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
return this.features.filter(this.getFilterQuery(query)).length;
}
Expand Down

0 comments on commit a5f1b89

Please sign in to comment.