From 6c813ab0661edfceb2e5589866d0f2889db9aa05 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Wed, 15 Mar 2023 08:37:06 +0200 Subject: [PATCH] feat: bulk stale features (#3311) --- src/lib/db/feature-toggle-store.ts | 11 ++++ src/lib/openapi/index.ts | 6 +- ...res-schema.ts => batch-features-schema.ts} | 6 +- src/lib/openapi/spec/batch-stale-schema.ts | 23 +++++++ src/lib/openapi/spec/index.ts | 2 +- .../admin-api/project/project-features.ts | 47 ++++++++++++-- src/lib/services/feature-toggle-service.ts | 31 ++++++++++ src/lib/types/events.ts | 2 +- src/lib/types/stores/feature-toggle-store.ts | 4 ++ .../api/admin/project/features.e2e.test.ts | 23 +++++++ .../__snapshots__/openapi.e2e.test.ts.snap | 62 +++++++++++++++++-- .../fixtures/fake-feature-toggle-store.ts | 13 ++++ 12 files changed, 215 insertions(+), 15 deletions(-) rename src/lib/openapi/spec/{archive-features-schema.ts => batch-features-schema.ts} (64%) create mode 100644 src/lib/openapi/spec/batch-stale-schema.ts diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index 9fd2267ebd..c80e3a3f4b 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -283,6 +283,17 @@ export default class FeatureToggleStore implements IFeatureToggleStore { return rows.map((row) => this.rowToFeature(row)); } + async batchStale( + names: string[], + stale: boolean, + ): Promise { + const rows = await this.db(TABLE) + .whereIn('name', names) + .update({ stale }) + .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 7129377724..ef65833c57 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -9,7 +9,7 @@ import { apiTokensSchema, applicationSchema, applicationsSchema, - archiveFeaturesSchema, + batchFeaturesSchema, changePasswordSchema, clientApplicationSchema, clientFeatureSchema, @@ -144,6 +144,7 @@ import { bulkRegistrationSchema } from './spec/bulk-registration-schema'; import { bulkMetricsSchema } from './spec/bulk-metrics-schema'; import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema'; import { updateTagsSchema } from './spec/update-tags-schema'; +import { batchStaleSchema } from './spec/batch-stale-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -156,7 +157,8 @@ export const schemas = { apiTokensSchema, applicationSchema, applicationsSchema, - archiveFeaturesSchema, + batchFeaturesSchema, + batchStaleSchema, bulkRegistrationSchema, bulkMetricsSchema, changePasswordSchema, diff --git a/src/lib/openapi/spec/archive-features-schema.ts b/src/lib/openapi/spec/batch-features-schema.ts similarity index 64% rename from src/lib/openapi/spec/archive-features-schema.ts rename to src/lib/openapi/spec/batch-features-schema.ts index 1314169d50..3b233ac085 100644 --- a/src/lib/openapi/spec/archive-features-schema.ts +++ b/src/lib/openapi/spec/batch-features-schema.ts @@ -1,7 +1,7 @@ import { FromSchema } from 'json-schema-to-ts'; -export const archiveFeaturesSchema = { - $id: '#/components/schemas/archiveFeaturesSchema', +export const batchFeaturesSchema = { + $id: '#/components/schemas/batchFeaturesSchema', type: 'object', required: ['features'], properties: { @@ -17,4 +17,4 @@ export const archiveFeaturesSchema = { }, } as const; -export type ArchiveFeaturesSchema = FromSchema; +export type BatchFeaturesSchema = FromSchema; diff --git a/src/lib/openapi/spec/batch-stale-schema.ts b/src/lib/openapi/spec/batch-stale-schema.ts new file mode 100644 index 0000000000..cc1dc38ec6 --- /dev/null +++ b/src/lib/openapi/spec/batch-stale-schema.ts @@ -0,0 +1,23 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const batchStaleSchema = { + $id: '#/components/schemas/batchStaleSchema', + type: 'object', + required: ['features', 'stale'], + properties: { + features: { + type: 'array', + items: { + type: 'string', + }, + }, + stale: { + type: 'boolean', + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type BatchStaleSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index eacb283abd..9ef7eba386 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -131,4 +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'; +export * from './batch-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 83d9ea8107..169b52d1a6 100644 --- a/src/lib/routes/admin-api/project/project-features.ts +++ b/src/lib/routes/admin-api/project/project-features.ts @@ -20,7 +20,7 @@ import { extractUsername } from '../../../util'; import { IAuthRequest } from '../../unleash-types'; import { AdminFeaturesQuerySchema, - ArchiveFeaturesSchema, + BatchFeaturesSchema, CreateFeatureSchema, CreateFeatureStrategySchema, createRequestSchema, @@ -46,6 +46,7 @@ import { } from '../../../services'; import { querySchema } from '../../../schema/feature-schema'; import NotFoundError from '../../../error/notfound-error'; +import { BatchStaleSchema } from '../../../openapi/spec/batch-stale-schema'; interface FeatureStrategyParams { projectId: string; @@ -76,6 +77,7 @@ export interface IFeatureProjectUserParams extends ProjectParam { const PATH = '/:projectId/features'; const PATH_ARCHIVE = '/:projectId/archive'; +const PATH_STALE = '/:projectId/stale'; const PATH_FEATURE = `${PATH}/:featureName`; const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`; const PATH_ENV = `${PATH_FEATURE}/environments/:environment`; @@ -418,8 +420,24 @@ export default class ProjectFeaturesController extends Controller { operationId: 'archiveFeatures', description: 'This endpoint archives the specified features.', - summary: 'Archive a list of features', - requestBody: createRequestSchema('archiveFeaturesSchema'), + summary: 'Archives a list of features', + requestBody: createRequestSchema('batchFeaturesSchema'), + responses: { 202: emptyResponse }, + }), + ], + }); + this.route({ + method: 'post', + path: PATH_STALE, + handler: this.staleFeatures, + permission: UPDATE_FEATURE, + middleware: [ + openApiService.validPath({ + tags: ['Features'], + operationId: 'staleFeatures', + description: 'This endpoint stales the specified features.', + summary: 'Stales a list of features', + requestBody: createRequestSchema('batchStaleSchema'), responses: { 202: emptyResponse }, }), ], @@ -603,7 +621,7 @@ export default class ProjectFeaturesController extends Controller { } async archiveFeatures( - req: IAuthRequest<{ projectId: string }, void, ArchiveFeaturesSchema>, + req: IAuthRequest<{ projectId: string }, void, BatchFeaturesSchema>, res: Response, ): Promise { if (!this.flagResolver.isEnabled('bulkOperations')) { @@ -618,6 +636,27 @@ export default class ProjectFeaturesController extends Controller { res.status(202).end(); } + async staleFeatures( + req: IAuthRequest<{ projectId: string }, void, BatchStaleSchema>, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('bulkOperations')) { + throw new NotFoundError('Bulk operations are not enabled'); + } + + const { features, stale } = req.body; + const { projectId } = req.params; + const userName = extractUsername(req); + + await this.featureService.setToggleStaleness( + features, + stale, + 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 cbd4c477e5..53a996a351 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -1101,6 +1101,37 @@ class FeatureToggleService { ); } + async setToggleStaleness( + featureNames: string[], + stale: boolean, + createdBy: string, + projectId: string, + ): Promise { + await this.validateFeaturesContext(featureNames, projectId); + + const features = await this.featureToggleStore.getAllByNames( + featureNames, + ); + const relevantFeatures = features.filter( + (feature) => feature.stale !== stale, + ); + await this.featureToggleStore.batchStale( + relevantFeatures.map((feature) => feature.name), + stale, + ); + await this.eventStore.batchStore( + relevantFeatures.map( + (feature) => + new FeatureStaleEvent({ + stale: stale, + project: projectId, + featureName: feature.name, + createdBy, + }), + ), + ); + } + async updateEnabled( project: string, featureName: string, diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 8e756e8a01..e2e8c412c7 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, diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index a5c6de63bb..0e94477161 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -16,6 +16,10 @@ export interface IFeatureToggleStore extends Store { update(project: string, data: FeatureToggleDTO): Promise; archive(featureName: string): Promise; batchArchive(featureNames: string[]): Promise; + batchStale( + featureNames: string[], + stale: boolean, + ): Promise; revive(featureName: string): Promise; getAll(query?: Partial): Promise; getAllByNames(names: string[]): Promise; 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 540dc89857..dc66e1a0f0 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -2845,3 +2845,26 @@ test('Should be able to bulk archive features', async () => { features: [{}, { name: featureName1 }, { name: featureName2 }], }); }); + +test('Should batch stale features', async () => { + const staledFeatureName1 = 'staledFeature1'; + const staledFeatureName2 = 'staledFeature2'; + + await createFeatureToggle(staledFeatureName1); + await createFeatureToggle(staledFeatureName2); + + await app.request + .post(`/api/admin/projects/${DEFAULT_PROJECT}/stale`) + .send({ + features: [staledFeatureName1, staledFeatureName2], + stale: true, + }) + .expect(202); + + const { body } = await app.request + .get( + `/api/admin/projects/${DEFAULT_PROJECT}/features/${staledFeatureName1}`, + ) + .expect(200); + expect(body.stale).toBeTruthy(); +}); 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 a0214401b8..80068eb061 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,7 +328,7 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "type": "object", }, - "archiveFeaturesSchema": { + "batchFeaturesSchema": { "properties": { "features": { "items": { @@ -342,6 +342,24 @@ exports[`should serve the OpenAPI spec 1`] = ` ], "type": "object", }, + "batchStaleSchema": { + "properties": { + "features": { + "items": { + "type": "string", + }, + "type": "array", + }, + "stale": { + "type": "boolean", + }, + }, + "required": [ + "features", + "stale", + ], + "type": "object", + }, "bulkMetricsSchema": { "properties": { "applications": { @@ -5998,11 +6016,11 @@ If the provided project does not exist, the list of events will be empty.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/archiveFeaturesSchema", + "$ref": "#/components/schemas/batchFeaturesSchema", }, }, }, - "description": "archiveFeaturesSchema", + "description": "batchFeaturesSchema", "required": true, }, "responses": { @@ -6010,7 +6028,7 @@ If the provided project does not exist, the list of events will be empty.", "description": "This response has no body.", }, }, - "summary": "Archive a list of features", + "summary": "Archives a list of features", "tags": [ "Features", ], @@ -7396,6 +7414,42 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/projects/{projectId}/stale": { + "post": { + "description": "This endpoint stales the specified features.", + "operationId": "staleFeatures", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/batchStaleSchema", + }, + }, + }, + "description": "batchStaleSchema", + "required": true, + }, + "responses": { + "202": { + "description": "This response has no body.", + }, + }, + "summary": "Stales a list of features", + "tags": [ + "Features", + ], + }, + }, "/api/admin/projects/{projectId}/stickiness": { "get": { "operationId": "getProjectDefaultStickiness", diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index c897a85eb4..e1dc2f37ad 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -34,6 +34,19 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore { return features; } + async batchStale( + featureNames: string[], + stale: boolean, + ): Promise { + const features = this.features.filter((feature) => + featureNames.includes(feature.name), + ); + for (const feature of features) { + feature.stale = stale; + } + return features; + } + async count(query: Partial): Promise { return this.features.filter(this.getFilterQuery(query)).length; }