From a5f1b89b4a47b840cbb9ffbcca14b00f77335385 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Wed, 15 Mar 2023 15:08:08 +0200 Subject: [PATCH] feat: bulk delete features (#3314) --- src/lib/db/feature-toggle-store.ts | 7 ++ src/lib/routes/admin-api/project/index.ts | 2 + .../admin-api/project/project-archive.ts | 72 +++++++++++++++++++ src/lib/services/feature-toggle-service.ts | 57 ++++++++++++++- src/lib/types/events.ts | 4 +- src/lib/types/stores/feature-toggle-store.ts | 1 + .../e2e/api/admin/feature-archive.e2e.test.ts | 38 +++++++++- .../__snapshots__/openapi.e2e.test.ts.snap | 34 +++++++++ .../fixtures/fake-feature-toggle-store.ts | 7 ++ 9 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 src/lib/routes/admin-api/project/project-archive.ts diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index c80e3a3f4bc..bd80180a545 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -301,6 +301,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore { .del(); } + async batchDelete(names: string[]): Promise { + await this.db(TABLE) + .whereIn('name', names) + .whereNotNull('archived_at') + .del(); + } + async revive(name: string): Promise { const row = await this.db(TABLE) .where({ name }) diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index 3531c4b8ec8..19f23ba26e2 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -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'; @@ -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( diff --git a/src/lib/routes/admin-api/project/project-archive.ts b/src/lib/routes/admin-api/project/project-archive.ts new file mode 100644 index 00000000000..21efe55024e --- /dev/null +++ b/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, + ) { + 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, + res: Response, + ): Promise { + 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; diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 53a996a3515..a3c5afeffe8 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -1089,6 +1089,7 @@ class FeatureToggleService { featureNames, ); await this.featureToggleStore.batchArchive(featureNames); + const tags = await this.tagStore.getAllByFeatures(featureNames); await this.eventStore.batchStore( features.map( (feature) => @@ -1096,6 +1097,12 @@ class FeatureToggleService { featureName: feature.name, createdBy, project: feature.project, + tags: tags + .filter((tag) => tag.featureName === feature.name) + .map((tag) => ({ + value: tag.tagValue, + type: tag.tagType, + })), }), ), ); @@ -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) => @@ -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, + })), }), ), ); @@ -1320,6 +1334,43 @@ class FeatureToggleService { ); } + async deleteFeatures( + featureNames: string[], + projectId: string, + createdBy: string, + ): Promise { + 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 { const toggle = await this.featureToggleStore.revive(featureName); diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index e2e8c412c78..e55719d98c3 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -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, @@ -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; diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index 0e944771611..847755febc2 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -20,6 +20,7 @@ export interface IFeatureToggleStore extends Store { featureNames: string[], stale: boolean, ): Promise; + batchDelete(featureNames: string[]): Promise; revive(featureName: string): Promise; getAll(query?: Partial): Promise; getAllByNames(names: string[]): Promise; diff --git a/src/test/e2e/api/admin/feature-archive.e2e.test.ts b/src/test/e2e/api/admin/feature-archive.e2e.test.ts index 3ea36a034c3..e9d51166f41 100644 --- a/src/test/e2e/api/admin/feature-archive.e2e.test.ts +++ b/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'; @@ -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', { @@ -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); + } +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 777073ea10d..78e7eb5a234 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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", diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index e1dc2f37ad4..1ce1e3b2674 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -47,6 +47,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return features; } + async batchDelete(featureNames: string[]): Promise { + this.features = this.features.filter( + (feature) => !featureNames.includes(feature.name), + ); + return Promise.resolve(); + } + async count(query: Partial): Promise { return this.features.filter(this.getFilterQuery(query)).length; }