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

feat: clone feature toggle API (#1006)

This commit is contained in:
Ivar Conradi Østhus 2021-10-08 09:37:27 +02:00 committed by GitHub
parent b4b222f4c9
commit 3612884501
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 261 additions and 2 deletions

View File

@ -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<void> {
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<ProjectParam, any, FeatureToggleDTO, any>,
req: IAuthRequest<FeatureParams, any, FeatureToggleDTO, any>,
res: Response,
): Promise<void> {
const { projectId } = req.params;

View File

@ -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<FeatureToggle> {
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,

View File

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

View File

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

View File

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