mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
feat: bulk delete features (#3314)
This commit is contained in:
parent
ac1be475e4
commit
a5f1b89b4a
@ -301,6 +301,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
.del();
|
.del();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchDelete(names: string[]): Promise<void> {
|
||||||
|
await this.db(TABLE)
|
||||||
|
.whereIn('name', names)
|
||||||
|
.whereNotNull('archived_at')
|
||||||
|
.del();
|
||||||
|
}
|
||||||
|
|
||||||
async revive(name: string): Promise<FeatureToggle> {
|
async revive(name: string): Promise<FeatureToggle> {
|
||||||
const row = await this.db(TABLE)
|
const row = await this.db(TABLE)
|
||||||
.where({ name })
|
.where({ name })
|
||||||
|
@ -26,6 +26,7 @@ import {
|
|||||||
import { IArchivedQuery, IProjectParam } from '../../../types/model';
|
import { IArchivedQuery, IProjectParam } from '../../../types/model';
|
||||||
import { ProjectApiTokenController } from './api-token';
|
import { ProjectApiTokenController } from './api-token';
|
||||||
import { SettingService } from '../../../services';
|
import { SettingService } from '../../../services';
|
||||||
|
import ProjectArchiveController from './project-archive';
|
||||||
|
|
||||||
const STICKINESS_KEY = 'stickiness';
|
const STICKINESS_KEY = 'stickiness';
|
||||||
const DEFAULT_STICKINESS = 'default';
|
const DEFAULT_STICKINESS = 'default';
|
||||||
@ -114,6 +115,7 @@ export default class ProjectApi extends Controller {
|
|||||||
this.use('/', new ProjectHealthReport(config, services).router);
|
this.use('/', new ProjectHealthReport(config, services).router);
|
||||||
this.use('/', new VariantsController(config, services).router);
|
this.use('/', new VariantsController(config, services).router);
|
||||||
this.use('/', new ProjectApiTokenController(config, services).router);
|
this.use('/', new ProjectApiTokenController(config, services).router);
|
||||||
|
this.use('/', new ProjectArchiveController(config, services).router);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(
|
async getProjects(
|
||||||
|
72
src/lib/routes/admin-api/project/project-archive.ts
Normal file
72
src/lib/routes/admin-api/project/project-archive.ts
Normal file
@ -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;
|
@ -1089,6 +1089,7 @@ class FeatureToggleService {
|
|||||||
featureNames,
|
featureNames,
|
||||||
);
|
);
|
||||||
await this.featureToggleStore.batchArchive(featureNames);
|
await this.featureToggleStore.batchArchive(featureNames);
|
||||||
|
const tags = await this.tagStore.getAllByFeatures(featureNames);
|
||||||
await this.eventStore.batchStore(
|
await this.eventStore.batchStore(
|
||||||
features.map(
|
features.map(
|
||||||
(feature) =>
|
(feature) =>
|
||||||
@ -1096,6 +1097,12 @@ class FeatureToggleService {
|
|||||||
featureName: feature.name,
|
featureName: feature.name,
|
||||||
createdBy,
|
createdBy,
|
||||||
project: feature.project,
|
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(
|
const relevantFeatures = features.filter(
|
||||||
(feature) => feature.stale !== stale,
|
(feature) => feature.stale !== stale,
|
||||||
);
|
);
|
||||||
await this.featureToggleStore.batchStale(
|
const relevantFeatureNames = relevantFeatures.map(
|
||||||
relevantFeatures.map((feature) => feature.name),
|
(feature) => feature.name,
|
||||||
stale,
|
|
||||||
);
|
);
|
||||||
|
await this.featureToggleStore.batchStale(relevantFeatureNames, stale);
|
||||||
|
const tags = await this.tagStore.getAllByFeatures(relevantFeatureNames);
|
||||||
await this.eventStore.batchStore(
|
await this.eventStore.batchStore(
|
||||||
relevantFeatures.map(
|
relevantFeatures.map(
|
||||||
(feature) =>
|
(feature) =>
|
||||||
@ -1127,6 +1135,12 @@ class FeatureToggleService {
|
|||||||
project: projectId,
|
project: projectId,
|
||||||
featureName: feature.name,
|
featureName: feature.name,
|
||||||
createdBy,
|
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<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.
|
// TODO: add project id.
|
||||||
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
|
async reviveToggle(featureName: string, createdBy: string): Promise<void> {
|
||||||
const toggle = await this.featureToggleStore.revive(featureName);
|
const toggle = await this.featureToggleStore.revive(featureName);
|
||||||
|
@ -163,7 +163,7 @@ export class FeatureStaleEvent extends BaseEvent {
|
|||||||
project: string;
|
project: string;
|
||||||
featureName: string;
|
featureName: string;
|
||||||
createdBy: string | IUser;
|
createdBy: string | IUser;
|
||||||
tags?: ITag[];
|
tags: ITag[];
|
||||||
}) {
|
}) {
|
||||||
super(
|
super(
|
||||||
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
|
p.stale ? FEATURE_STALE_ON : FEATURE_STALE_OFF,
|
||||||
@ -330,7 +330,7 @@ export class FeatureArchivedEvent extends BaseEvent {
|
|||||||
project: string;
|
project: string;
|
||||||
featureName: string;
|
featureName: string;
|
||||||
createdBy: string | IUser;
|
createdBy: string | IUser;
|
||||||
tags?: ITag[];
|
tags: ITag[];
|
||||||
}) {
|
}) {
|
||||||
super(FEATURE_ARCHIVED, p.createdBy, p.tags);
|
super(FEATURE_ARCHIVED, p.createdBy, p.tags);
|
||||||
const { project, featureName } = p;
|
const { project, featureName } = p;
|
||||||
|
@ -20,6 +20,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
featureNames: string[],
|
featureNames: string[],
|
||||||
stale: boolean,
|
stale: boolean,
|
||||||
): Promise<FeatureToggle[]>;
|
): Promise<FeatureToggle[]>;
|
||||||
|
batchDelete(featureNames: string[]): Promise<void>;
|
||||||
revive(featureName: string): Promise<FeatureToggle>;
|
revive(featureName: string): Promise<FeatureToggle>;
|
||||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||||
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { setupApp } from '../../helpers/test-helper';
|
import { setupAppWithCustomConfig } from '../../helpers/test-helper';
|
||||||
import dbInit from '../../helpers/database-init';
|
import dbInit from '../../helpers/database-init';
|
||||||
import getLogger from '../../../fixtures/no-logger';
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
|
|
||||||
@ -7,7 +7,14 @@ let db;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('archive_serial', getLogger);
|
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(
|
await app.services.featureToggleServiceV2.createFeatureToggle(
|
||||||
'default',
|
'default',
|
||||||
{
|
{
|
||||||
@ -183,3 +190,30 @@ test('Deleting an unarchived toggle should not take effect', async () => {
|
|||||||
.set('Content-Type', 'application/json')
|
.set('Content-Type', 'application/json')
|
||||||
.expect(409); // because it still exists
|
.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -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": {
|
"/api/admin/projects/{projectId}/environments": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "addEnvironmentToProject",
|
"operationId": "addEnvironmentToProject",
|
||||||
|
@ -47,6 +47,13 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
return features;
|
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> {
|
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
|
||||||
return this.features.filter(this.getFilterQuery(query)).length;
|
return this.features.filter(this.getFilterQuery(query)).length;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user