diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index 868fd881c6..999523931c 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -45,6 +45,7 @@ interface StrategyUpdateBody { const PATH = '/:projectId/features'; const PATH_FEATURE = `${PATH}/:featureName`; +const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`; const PATH_ENV = `${PATH_FEATURE}/environments/:environment`; const PATH_STRATEGIES = `${PATH_ENV}/strategies`; const PATH_STRATEGY = `${PATH_STRATEGIES}/:strategyId`; @@ -82,6 +83,8 @@ export default class ProjectFeaturesController extends Controller { this.get(PATH, this.getFeatures); this.post(PATH, this.createFeature, CREATE_FEATURE); + this.post(PATH_FEATURE_CLONE, this.cloneFeature, CREATE_FEATURE); + this.get(PATH_FEATURE, this.getFeature); this.put(PATH_FEATURE, this.updateFeature); this.patch(PATH_FEATURE, this.patchFeature); @@ -99,8 +102,30 @@ export default class ProjectFeaturesController extends Controller { res.json({ version: 1, features }); } + async cloneFeature( + req: IAuthRequest< + FeatureParams, + any, + { name: string; replaceGroupId: boolean }, + any + >, + res: Response, + ): Promise { + const { projectId, featureName } = req.params; + const { name, replaceGroupId } = req.body; + const userName = extractUsername(req); + const created = await this.featureService.cloneFeatureToggle( + featureName, + projectId, + name, + replaceGroupId, + userName, + ); + res.status(201).json(created); + } + async createFeature( - req: IAuthRequest, + req: IAuthRequest, res: Response, ): Promise { const { projectId } = req.params; diff --git a/src/lib/services/feature-toggle-service-v2.ts b/src/lib/services/feature-toggle-service-v2.ts index 5fed05ce1a..ac434e3dc6 100644 --- a/src/lib/services/feature-toggle-service-v2.ts +++ b/src/lib/services/feature-toggle-service-v2.ts @@ -35,6 +35,7 @@ import { FeatureToggleDTO, FeatureToggleWithEnvironment, FeatureToggleWithEnvironmentLegacy, + IEnvironmentDetail, IFeatureEnvironmentInfo, IFeatureOverview, IFeatureStrategy, @@ -372,6 +373,57 @@ class FeatureToggleServiceV2 { throw new NotFoundError(`Project with id ${projectId} does not exist`); } + async cloneFeatureToggle( + featureName: string, + projectId: string, + newFeatureName: string, + replaceGroupId: boolean = true, + userName: string, + ): Promise { + this.logger.info( + `${userName} clones feature toggle ${featureName} to ${newFeatureName}`, + ); + await this.validateName(newFeatureName); + + const cToggle = + await this.featureStrategiesStore.getFeatureToggleWithEnvs( + featureName, + ); + + const newToggle = { ...cToggle, name: newFeatureName }; + + // Create feature toggle + const created = await this.createFeatureToggle( + projectId, + newToggle, + userName, + ); + + const createStrategies = []; + newToggle.environments.forEach((e: IEnvironmentDetail) => + e.strategies.forEach((s: IStrategyConfig) => { + if (replaceGroupId && s.parameters.hasOwnProperty('groupId')) { + //@ts-ignore + s.parameters.groupId = newFeatureName; + } + delete s.id; + createStrategies.push( + this.createStrategy( + s, + projectId, + newFeatureName, + userName, + e.name, + ), + ); + }), + ); + + // Create strategies + await Promise.allSettled(createStrategies); + return created; + } + async updateFeatureToggle( projectId: string, updatedFeature: FeatureToggleDTO, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 71ece8cc8f..c1956be47c 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -13,7 +13,7 @@ export interface IStrategyConfig { id?: string; name: string; constraints: IConstraint[]; - parameters: Object; + parameters: object; sortOrder?: number; } export interface IFeatureStrategy { @@ -37,6 +37,7 @@ export interface FeatureToggleDTO { variants?: IVariant[]; createdAt?: Date; } + export interface FeatureToggle extends FeatureToggleDTO { project: string; lastSeenAt?: Date; diff --git a/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts similarity index 90% rename from src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts rename to src/test/e2e/api/admin/project/features.e2e.test.ts index 24e19f7083..01cff4f5c0 100644 --- a/src/test/e2e/api/admin/project/feature.strategy.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -1303,3 +1303,132 @@ test('Deleting strategy for feature environment should not disable that environm expect(res.body.enabled).toBeTruthy(); }); }); + +test('should clone feature toggle without strategies', async () => { + const envName = 'some-env-3'; + const featureName = 'feature.toggle.base'; + const cloneName = 'feature.toggle.clone'; + const type = 'eExperiment'; + const description = 'Lorem ipsum...'; + + // Create environment + await db.stores.environmentStore.create({ + name: envName, + type: 'production', + }); + // Connect environment to project + await app.request + .post('/api/admin/projects/default/environments') + .send({ + environment: envName, + }) + .expect(200); + + await app.request + .post('/api/admin/projects/default/features') + .send({ name: featureName, description, type }) + .expect(201); + await app.request + .post(`/api/admin/projects/default/features/${featureName}/clone`) + .send({ name: cloneName, replaceGroupId: false }) + .expect(201); + await app.request + .get(`/api/admin/projects/default/features/${cloneName}`) + .expect(200) + .expect((res) => { + expect(res.body.name).toBe(cloneName); + expect(res.body.type).toBe(type); + expect(res.body.project).toBe('default'); + expect(res.body.description).toBe(description); + }); +}); + +test('should clone feature toggle WITH strategies', async () => { + const envName = 'some-env-4'; + const featureName = 'feature.toggle.base.2'; + const cloneName = 'feature.toggle.clone.2'; + const type = 'eExperiment'; + const description = 'Lorem ipsum...'; + + // Create environment + await db.stores.environmentStore.create({ + name: envName, + type: 'production', + }); + // Connect environment to project + await app.request + .post('/api/admin/projects/default/environments') + .send({ + environment: envName, + }) + .expect(200); + + await app.request + .post('/api/admin/projects/default/features') + .send({ name: featureName, description, type }) + .expect(201); + await app.request + .post( + `/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`, + ) + .send({ + name: 'flexibleRollout', + parameters: { + groupId: featureName, + }, + }) + .expect(200); + + await app.request + .post(`/api/admin/projects/default/features/${featureName}/clone`) + .send({ name: cloneName }) + .expect(201); + await app.request + .get(`/api/admin/projects/default/features/${cloneName}`) + .expect(200) + .expect((res) => { + expect(res.body.name).toBe(cloneName); + expect(res.body.type).toBe(type); + expect(res.body.project).toBe('default'); + expect(res.body.description).toBe(description); + + const env = res.body.environments.find((e) => e.name === envName); + expect(env.strategies).toHaveLength(1); + expect(env.strategies[0].parameters.groupId).toBe(cloneName); + }); +}); + +test('should clone feature toggle without replacing groupId', async () => { + const envName = 'default'; + const featureName = 'feature.toggle.base.3'; + const cloneName = 'feature.toggle.clone.3'; + + await app.request + .post('/api/admin/projects/default/features') + .send({ name: featureName }) + .expect(201); + await app.request + .post( + `/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`, + ) + .send({ + name: 'flexibleRollout', + parameters: { + groupId: featureName, + }, + }) + .expect(200); + + await app.request + .post(`/api/admin/projects/default/features/${featureName}/clone`) + .send({ name: cloneName, replaceGroupId: false }) + .expect(201); + await app.request + .get(`/api/admin/projects/default/features/${cloneName}`) + .expect(200) + .expect((res) => { + const env = res.body.environments.find((e) => e.name === envName); + expect(env.strategies).toHaveLength(1); + expect(env.strategies[0].parameters.groupId).toBe(featureName); + }); +}); diff --git a/websitev2/docs/api/admin/feature-toggles-api-v2.md b/websitev2/docs/api/admin/feature-toggles-api-v2.md index e90c0895ff..41cdfcd855 100644 --- a/websitev2/docs/api/admin/feature-toggles-api-v2.md +++ b/websitev2/docs/api/admin/feature-toggles-api-v2.md @@ -308,6 +308,58 @@ Some fields is not possible to change via this endpoint: - lastSeen +### Clone Feature Toggle {#create-toggle} + +`http://localhost:4242/api/admin/projects/:projectId/features/:featureName/clone` + +This endpoint will accept HTTP POST request to clone an existing feature toggle with all strategies and variants. It is not possible to clone archived feature toggles. The newly created feature toggle will be disabled for all environments. + +**Example Query** + +```sh +echo '{ "name": "newName" }' | http POST http://localhost:4242/api/admin/projects/default/features/Demo/clone Authorization:$KEY` +``` + + +**Example response:** + +```json +HTTP/1.1 201 Created +Access-Control-Allow-Origin: * +Connection: keep-alive +Content-Length: 260 +Content-Type: application/json; charset=utf-8 +Date: Wed, 06 Oct 2021 20:04:39 GMT +ETag: W/"104-joC/gdjtJ29jZMxj91lIzR42Pmo" +Keep-Alive: timeout=60 +Vary: Accept-Encoding + +{ + "createdAt": "2021-09-29T10:22:28.523Z", + "description": "Some useful description", + "lastSeenAt": null, + "name": "DemoNew", + "project": "default", + "stale": false, + "type": "release", + "variants": [ + { + "name": "blue", + "overrides": [], + "stickiness": "default", + "weight": 1000, + "weightType": "variable" + } + ] +} + +``` + +Possible Errors: + +- _409 Conflict_ - A toggle with that name already exists + + ### Archive Feature Toggle {#archive-toggle} `http://localhost:4242/api/admin/projects/:projectId/features/:featureName`