From 1746a951c2d747703a953233958c551c7b1a4aad Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Tue, 14 Mar 2023 10:48:29 +0200 Subject: [PATCH] feat: bulk archive features (#3286) Bulk archiving features logic. --- src/lib/db/feature-toggle-store.ts | 9 ++++ src/lib/openapi/index.ts | 2 + .../openapi/spec/archive-features-schema.ts | 20 +++++++ src/lib/openapi/spec/index.ts | 1 + .../admin-api/project/project-features.ts | 43 ++++++++++++++- src/lib/services/feature-toggle-service.ts | 46 ++++++++++++++++ src/lib/types/events.ts | 2 +- src/lib/types/stores/feature-toggle-store.ts | 1 + src/lib/util/unique.ts | 2 + src/server-dev.ts | 1 + .../api/admin/project/features.e2e.test.ts | 29 ++++++++++- .../__snapshots__/openapi.e2e.test.ts.snap | 52 ++++++++++++++++++- .../fixtures/fake-feature-toggle-store.ts | 10 ++++ 13 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 src/lib/openapi/spec/archive-features-schema.ts create mode 100644 src/lib/util/unique.ts diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index fd37322acf..9fd2267ebd 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -274,6 +274,15 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return this.rowToFeature(row[0]); } + async batchArchive(names: string[]): Promise { + 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 { await this.db(TABLE) .where({ name }) // Feature toggle must be archived to allow deletion diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index cc858afca7..7129377724 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/archive-features-schema.ts b/src/lib/openapi/spec/archive-features-schema.ts new file mode 100644 index 0000000000..1314169d50 --- /dev/null +++ b/src/lib/openapi/spec/archive-features-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index ee6456373f..eacb283abd 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/routes/admin-api/project/project-features.ts b/src/lib/routes/admin-api/project/project-features.ts index 57da03ea0a..83d9ea8107 100644 --- a/src/lib/routes/admin-api/project/project-features.ts +++ b/src/lib/routes/admin-api/project/project-features.ts @@ -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 { + 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, res: Response, diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 5eddd6d76d..cbd4c477e5 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -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 { + 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 { + 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, diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index e55719d98c..8e756e8a01 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -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 71ba8637b9..a5c6de63bb 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -15,6 +15,7 @@ export interface IFeatureToggleStore extends Store { create(project: string, data: FeatureToggleDTO): Promise; update(project: string, data: FeatureToggleDTO): Promise; archive(featureName: string): Promise; + batchArchive(featureNames: string[]): Promise; revive(featureName: string): Promise; getAll(query?: Partial): Promise; getAllByNames(names: string[]): Promise; diff --git a/src/lib/util/unique.ts b/src/lib/util/unique.ts new file mode 100644 index 0000000000..5b2f1509e6 --- /dev/null +++ b/src/lib/util/unique.ts @@ -0,0 +1,2 @@ +export const unique = (items: T[]): T[] => + Array.from(new Set(items)); diff --git a/src/server-dev.ts b/src/server-dev.ts index 6d8fd8d703..065ead6646 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -43,6 +43,7 @@ process.nextTick(async () => { projectStatusApi: true, showProjectApiAccess: true, projectScopedSegments: true, + bulkOperations: true, projectScopedStickiness: true, }, }, diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index 5bac0ede8b..540dc89857 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -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 }], + }); +}); 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 6704015741..a0214401b8 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 @@ -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": { diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index 095098acea..c897a85eb4 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -24,6 +24,16 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { ); } + async batchArchive(featureNames: string[]): Promise { + const features = this.features.filter((feature) => + featureNames.includes(feature.name), + ); + for (const feature of features) { + feature.archived = true; + } + return features; + } + async count(query: Partial): Promise { return this.features.filter(this.getFilterQuery(query)).length; }