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

feat: bulk archive features (#3286)

Bulk archiving features logic.
This commit is contained in:
Jaanus Sellin 2023-03-14 10:48:29 +02:00 committed by GitHub
parent 1ef0ca4ebf
commit 1746a951c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 214 additions and 4 deletions

View File

@ -274,6 +274,15 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
return this.rowToFeature(row[0]);
}
async batchArchive(names: string[]): Promise<FeatureToggle[]> {
const now = new Date();
const rows = await this.db(TABLE)
.whereIn('name', names)
.update({ archived_at: now })
.returning(FEATURE_COLUMNS);
return rows.map((row) => this.rowToFeature(row));
}
async delete(name: string): Promise<void> {
await this.db(TABLE)
.where({ name }) // Feature toggle must be archived to allow deletion

View File

@ -9,6 +9,7 @@ import {
apiTokensSchema,
applicationSchema,
applicationsSchema,
archiveFeaturesSchema,
changePasswordSchema,
clientApplicationSchema,
clientFeatureSchema,
@ -155,6 +156,7 @@ export const schemas = {
apiTokensSchema,
applicationSchema,
applicationsSchema,
archiveFeaturesSchema,
bulkRegistrationSchema,
bulkMetricsSchema,
changePasswordSchema,

View File

@ -0,0 +1,20 @@
import { FromSchema } from 'json-schema-to-ts';
export const archiveFeaturesSchema = {
$id: '#/components/schemas/archiveFeaturesSchema',
type: 'object',
required: ['features'],
properties: {
features: {
type: 'array',
items: {
type: 'string',
},
},
},
components: {
schemas: {},
},
} as const;
export type ArchiveFeaturesSchema = FromSchema<typeof archiveFeaturesSchema>;

View File

@ -131,3 +131,4 @@ export * from './import-toggles-validate-schema';
export * from './import-toggles-schema';
export * from './stickiness-schema';
export * from './tags-bulk-add-schema';
export * from './archive-features-schema';

View File

@ -13,12 +13,14 @@ import {
UPDATE_FEATURE,
UPDATE_FEATURE_ENVIRONMENT,
UPDATE_FEATURE_STRATEGY,
IFlagResolver,
} from '../../../types';
import { Logger } from '../../../logger';
import { extractUsername } from '../../../util';
import { IAuthRequest } from '../../unleash-types';
import {
AdminFeaturesQuerySchema,
ArchiveFeaturesSchema,
CreateFeatureSchema,
CreateFeatureStrategySchema,
createRequestSchema,
@ -43,6 +45,7 @@ import {
FeatureToggleService,
} from '../../../services';
import { querySchema } from '../../../schema/feature-schema';
import NotFoundError from '../../../error/notfound-error';
interface FeatureStrategyParams {
projectId: string;
@ -72,6 +75,7 @@ export interface IFeatureProjectUserParams extends ProjectParam {
}
const PATH = '/:projectId/features';
const PATH_ARCHIVE = '/:projectId/archive';
const PATH_FEATURE = `${PATH}/:featureName`;
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
const PATH_ENV = `${PATH_FEATURE}/environments/:environment`;
@ -93,6 +97,8 @@ export default class ProjectFeaturesController extends Controller {
private segmentService: SegmentService;
private flagResolver: IFlagResolver;
private readonly logger: Logger;
constructor(
@ -107,6 +113,7 @@ export default class ProjectFeaturesController extends Controller {
this.featureService = featureToggleServiceV2;
this.openApiService = openApiService;
this.segmentService = segmentService;
this.flagResolver = config.flagResolver;
this.logger = config.getLogger('/admin-api/project/features.ts');
this.route({
@ -389,7 +396,7 @@ export default class ProjectFeaturesController extends Controller {
'This endpoint archives the specified feature if the feature belongs to the specified project.',
summary: 'Archive a feature.',
responses: {
200: emptyResponse,
202: emptyResponse,
403: {
description:
'You either do not have the required permissions or used an invalid URL.',
@ -399,6 +406,24 @@ export default class ProjectFeaturesController extends Controller {
}),
],
});
this.route({
method: 'post',
path: PATH_ARCHIVE,
handler: this.archiveFeatures,
permission: DELETE_FEATURE,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'archiveFeatures',
description:
'This endpoint archives the specified features.',
summary: 'Archive a list of features',
requestBody: createRequestSchema('archiveFeaturesSchema'),
responses: { 202: emptyResponse },
}),
],
});
}
async getFeatures(
@ -577,6 +602,22 @@ export default class ProjectFeaturesController extends Controller {
res.status(202).send();
}
async archiveFeatures(
req: IAuthRequest<{ projectId: string }, void, ArchiveFeaturesSchema>,
res: Response,
): Promise<void> {
if (!this.flagResolver.isEnabled('bulkOperations')) {
throw new NotFoundError('Bulk operations are not enabled');
}
const { features } = req.body;
const { projectId } = req.params;
const userName = extractUsername(req);
await this.featureService.archiveToggles(features, userName, projectId);
res.status(202).end();
}
async getFeatureEnvironment(
req: Request<FeatureStrategyParams, any, any, any>,
res: Response<FeatureEnvironmentSchema>,

View File

@ -82,6 +82,7 @@ import {
} from '../types/permissions';
import NoAccessError from '../error/no-access-error';
import { IFeatureProjectUserParams } from '../routes/admin-api/project/project-features';
import { unique } from '../util/unique';
interface IFeatureContext {
featureName: string;
@ -171,6 +172,28 @@ class FeatureToggleService {
this.flagResolver = flagResolver;
}
async validateFeaturesContext(
featureNames: string[],
projectId: string,
): Promise<void> {
const features = await this.featureToggleStore.getAllByNames(
featureNames,
);
const invalidProjects = unique(
features
.map((feature) => feature.project)
.filter((project) => project !== projectId),
);
if (invalidProjects.length > 0) {
throw new InvalidOperationError(
`The operation could not be completed. The features exist, but the provided project ids ("${invalidProjects.join(
',',
)}") does not match the project provided in request URL ("${projectId}").`,
);
}
}
async validateFeatureContext({
featureName,
projectId,
@ -1055,6 +1078,29 @@ class FeatureToggleService {
);
}
async archiveToggles(
featureNames: string[],
createdBy: string,
projectId: string,
): Promise<void> {
await this.validateFeaturesContext(featureNames, projectId);
const features = await this.featureToggleStore.getAllByNames(
featureNames,
);
await this.featureToggleStore.batchArchive(featureNames);
await this.eventStore.batchStore(
features.map(
(feature) =>
new FeatureArchivedEvent({
featureName: feature.name,
createdBy,
project: feature.project,
}),
),
);
}
async updateEnabled(
project: string,
featureName: string,

View File

@ -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;

View File

@ -15,6 +15,7 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
update(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
archive(featureName: string): Promise<FeatureToggle>;
batchArchive(featureNames: string[]): Promise<FeatureToggle[]>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;

2
src/lib/util/unique.ts Normal file
View File

@ -0,0 +1,2 @@
export const unique = <T extends string | number>(items: T[]): T[] =>
Array.from(new Set(items));

View File

@ -43,6 +43,7 @@ process.nextTick(async () => {
projectStatusApi: true,
showProjectApiAccess: true,
projectScopedSegments: true,
bulkOperations: true,
projectScopedStickiness: true,
},
},

View File

@ -25,6 +25,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import supertest from 'supertest';
import { randomId } from '../../../../../lib/util/random-id';
import { DEFAULT_PROJECT } from '../../../../../lib/types';
let app: IUnleashTest;
let db: ITestDb;
@ -32,7 +33,10 @@ const sortOrderFirst = 0;
const sortOrderSecond = 10;
const sortOrderDefault = 9999;
const createFeatureToggle = (featureName: string, project = 'default') => {
const createFeatureToggle = (
featureName: string,
project = DEFAULT_PROJECT,
) => {
return app.request.post(`/api/admin/projects/${project}/features`).send({
name: featureName,
});
@ -92,6 +96,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
bulkOperations: true,
},
},
});
@ -2818,3 +2823,25 @@ test('Can query for two tags at the same time. Tags are ORed together', async ()
expect(res.body.features).toHaveLength(3);
});
});
test('Should be able to bulk archive features', async () => {
const featureName1 = 'archivedFeature1';
const featureName2 = 'archivedFeature2';
await createFeatureToggle(featureName1);
await createFeatureToggle(featureName2);
await app.request
.post(`/api/admin/projects/${DEFAULT_PROJECT}/archive`)
.send({
features: [featureName1, featureName2],
})
.expect(202);
const { body } = await app.request
.get(`/api/admin/archive/features/${DEFAULT_PROJECT}`)
.expect(200);
expect(body).toMatchObject({
features: [{}, { name: featureName1 }, { name: featureName2 }],
});
});

View File

@ -328,6 +328,20 @@ exports[`should serve the OpenAPI spec 1`] = `
},
"type": "object",
},
"archiveFeaturesSchema": {
"properties": {
"features": {
"items": {
"type": "string",
},
"type": "array",
},
},
"required": [
"features",
],
"type": "object",
},
"bulkMetricsSchema": {
"properties": {
"applications": {
@ -5966,6 +5980,42 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/archive": {
"post": {
"description": "This endpoint archives the specified features.",
"operationId": "archiveFeatures",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/archiveFeaturesSchema",
},
},
},
"description": "archiveFeaturesSchema",
"required": true,
},
"responses": {
"202": {
"description": "This response has no body.",
},
},
"summary": "Archive a list of features",
"tags": [
"Features",
],
},
},
"/api/admin/projects/{projectId}/environments": {
"post": {
"operationId": "addEnvironmentToProject",
@ -6167,7 +6217,7 @@ If the provided project does not exist, the list of events will be empty.",
},
],
"responses": {
"200": {
"202": {
"description": "This response has no body.",
},
"401": {

View File

@ -24,6 +24,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
);
}
async batchArchive(featureNames: string[]): Promise<FeatureToggle[]> {
const features = this.features.filter((feature) =>
featureNames.includes(feature.name),
);
for (const feature of features) {
feature.archived = true;
}
return features;
}
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
return this.features.filter(this.getFilterQuery(query)).length;
}