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();
|
||||
}
|
||||
|
||||
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 })
|
||||
|
@ -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(
|
||||
|
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,
|
||||
);
|
||||
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<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);
|
||||
|
@ -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;
|
||||
|
@ -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[]>;
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user