mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
Feat/variant api (#1119)
Add a new .../:feature/variants API This adds - `GET /api/admin/projects/:projectId/features/:featureName/variants` which returns ```json { version: '1', variants: IVariant[] } ``` - `PATCH /api/admin/projects/:projectId/features/:featureName/variants` which accepts a json patch set and updates the feature's variants field and then returns ```json { version: '1', variants: IVariant[] } ``` - `PUT /api/admin/projects/:projectId/features/:featureName/variants` which accepts a IVariant[] and overwrites the current variants list for the feature defined in :featureName and returns ```json { version: '1', variants: IVariant[] } - This also makes sure the total weight of all variants is == 1000 - Validates that there is at least 1 'variable' variant if there are variants - Validates that 'fix' variants total weight can't exceed 1000 - Adds tests for all these invariants. Co-authored-by: Simon Hornby <simon@getunleash.ai>
This commit is contained in:
parent
3a785bdb0f
commit
6c6001619c
2
.github/workflows/build_doc_prs.yaml
vendored
2
.github/workflows/build_doc_prs.yaml
vendored
@ -11,7 +11,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: setup git config
|
- name: Build docs
|
||||||
run: |
|
run: |
|
||||||
# Build the site
|
# Build the site
|
||||||
cd website && yarn && yarn build
|
cd website && yarn && yarn build
|
||||||
|
@ -167,6 +167,13 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rowToVariants(row: FeaturesTable): IVariant[] {
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundError('No feature toggle found');
|
||||||
|
}
|
||||||
|
return (row.variants as unknown as IVariant[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
dtoToRow(project: string, data: FeatureToggleDTO): FeaturesTable {
|
dtoToRow(project: string, data: FeatureToggleDTO): FeaturesTable {
|
||||||
const row = {
|
const row = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@ -232,6 +239,24 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
|||||||
.returning(FEATURE_COLUMNS);
|
.returning(FEATURE_COLUMNS);
|
||||||
return this.rowToFeature(row[0]);
|
return this.rowToFeature(row[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||||
|
const row = await this.db(TABLE)
|
||||||
|
.select('variants')
|
||||||
|
.where({ name: featureName });
|
||||||
|
return this.rowToVariants(row[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveVariants(
|
||||||
|
featureName: string,
|
||||||
|
newVariants: IVariant[],
|
||||||
|
): Promise<FeatureToggle> {
|
||||||
|
const row = await this.db(TABLE)
|
||||||
|
.update({ variants: JSON.stringify(newVariants) })
|
||||||
|
.where({ name: featureName })
|
||||||
|
.returning(FEATURE_COLUMNS);
|
||||||
|
return this.rowToFeature(row[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = FeatureToggleStore;
|
module.exports = FeatureToggleStore;
|
||||||
|
@ -6,6 +6,7 @@ import ProjectFeaturesController from './features';
|
|||||||
import EnvironmentsController from './environments';
|
import EnvironmentsController from './environments';
|
||||||
import ProjectHealthReport from './health-report';
|
import ProjectHealthReport from './health-report';
|
||||||
import ProjectService from '../../../services/project-service';
|
import ProjectService from '../../../services/project-service';
|
||||||
|
import VariantsController from './variants';
|
||||||
|
|
||||||
export default class ProjectApi extends Controller {
|
export default class ProjectApi extends Controller {
|
||||||
private projectService: ProjectService;
|
private projectService: ProjectService;
|
||||||
@ -17,6 +18,7 @@ export default class ProjectApi extends Controller {
|
|||||||
this.use('/', new ProjectFeaturesController(config, services).router);
|
this.use('/', new ProjectFeaturesController(config, services).router);
|
||||||
this.use('/', new EnvironmentsController(config, services).router);
|
this.use('/', new EnvironmentsController(config, services).router);
|
||||||
this.use('/', new ProjectHealthReport(config, services).router);
|
this.use('/', new ProjectHealthReport(config, services).router);
|
||||||
|
this.use('/', new VariantsController(config, services).router);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjects(req: Request, res: Response): Promise<void> {
|
async getProjects(req: Request, res: Response): Promise<void> {
|
||||||
|
78
src/lib/routes/admin-api/project/variants.ts
Normal file
78
src/lib/routes/admin-api/project/variants.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import FeatureToggleService from '../../../services/feature-toggle-service';
|
||||||
|
import { Logger } from '../../../logger';
|
||||||
|
import Controller from '../../controller';
|
||||||
|
import { IUnleashConfig } from '../../../types/option';
|
||||||
|
import { IUnleashServices } from '../../../types';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { UPDATE_FEATURE } from '../../../types/permissions';
|
||||||
|
import { IVariant } from '../../../types/model';
|
||||||
|
|
||||||
|
const PREFIX = '/:projectId/features/:featureName/variants';
|
||||||
|
|
||||||
|
interface FeatureParams extends ProjectParam {
|
||||||
|
featureName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectParam {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class VariantsController extends Controller {
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private featureService: FeatureToggleService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: IUnleashConfig,
|
||||||
|
{
|
||||||
|
featureToggleService,
|
||||||
|
}: Pick<IUnleashServices, 'featureToggleService'>,
|
||||||
|
) {
|
||||||
|
super(config);
|
||||||
|
this.logger = config.getLogger('admin-api/project/variants.ts');
|
||||||
|
this.featureService = featureToggleService;
|
||||||
|
this.get(PREFIX, this.getVariants);
|
||||||
|
this.patch(PREFIX, this.patchVariants, UPDATE_FEATURE);
|
||||||
|
this.put(PREFIX, this.overwriteVariants, UPDATE_FEATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVariants(
|
||||||
|
req: Request<FeatureParams, any, any, any>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { featureName } = req.params;
|
||||||
|
const variants = await this.featureService.getVariants(featureName);
|
||||||
|
res.status(200).json({ version: '1', variants });
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchVariants(
|
||||||
|
req: Request<FeatureParams, any, Operation[], any>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { featureName } = req.params;
|
||||||
|
const updatedFeature = await this.featureService.updateVariants(
|
||||||
|
featureName,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
res.status(200).json({
|
||||||
|
version: '1',
|
||||||
|
variants: updatedFeature.variants,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async overwriteVariants(
|
||||||
|
req: Request<FeatureParams, any, IVariant[], any>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { featureName } = req.params;
|
||||||
|
const updatedFeature = await this.featureService.saveVariants(
|
||||||
|
featureName,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
|
res.status(200).json({
|
||||||
|
version: '1',
|
||||||
|
variants: updatedFeature.variants,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -42,6 +42,8 @@ export const variantsSchema = joi.object().keys({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const variantsArraySchema = joi.array().min(0).items(variantsSchema);
|
||||||
|
|
||||||
export const featureMetadataSchema = joi
|
export const featureMetadataSchema = joi
|
||||||
.object()
|
.object()
|
||||||
.keys({
|
.keys({
|
||||||
|
@ -5,7 +5,11 @@ import BadDataError from '../error/bad-data-error';
|
|||||||
import NameExistsError from '../error/name-exists-error';
|
import NameExistsError from '../error/name-exists-error';
|
||||||
import InvalidOperationError from '../error/invalid-operation-error';
|
import InvalidOperationError from '../error/invalid-operation-error';
|
||||||
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
|
import { FOREIGN_KEY_VIOLATION } from '../error/db-error';
|
||||||
import { featureMetadataSchema, nameSchema } from '../schema/feature-schema';
|
import {
|
||||||
|
featureMetadataSchema,
|
||||||
|
nameSchema,
|
||||||
|
variantsArraySchema,
|
||||||
|
} from '../schema/feature-schema';
|
||||||
import {
|
import {
|
||||||
FeatureArchivedEvent,
|
FeatureArchivedEvent,
|
||||||
FeatureChangeProjectEvent,
|
FeatureChangeProjectEvent,
|
||||||
@ -40,6 +44,8 @@ import {
|
|||||||
IFeatureStrategy,
|
IFeatureStrategy,
|
||||||
IFeatureToggleQuery,
|
IFeatureToggleQuery,
|
||||||
IStrategyConfig,
|
IStrategyConfig,
|
||||||
|
IVariant,
|
||||||
|
WeightType,
|
||||||
} from '../types/model';
|
} from '../types/model';
|
||||||
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
|
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
|
||||||
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
|
import { IFeatureToggleClientStore } from '../types/stores/feature-toggle-client-store';
|
||||||
@ -389,6 +395,15 @@ class FeatureToggleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/projects/:project/features/:featureName/variants
|
||||||
|
* @param featureName
|
||||||
|
* @return The list of variants
|
||||||
|
*/
|
||||||
|
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||||
|
return this.featureToggleStore.getVariants(featureName);
|
||||||
|
}
|
||||||
|
|
||||||
async getFeatureMetadata(featureName: string): Promise<FeatureToggle> {
|
async getFeatureMetadata(featureName: string): Promise<FeatureToggle> {
|
||||||
return this.featureToggleStore.get(featureName);
|
return this.featureToggleStore.get(featureName);
|
||||||
}
|
}
|
||||||
@ -882,6 +897,63 @@ class FeatureToggleService {
|
|||||||
newProjectId,
|
newProjectId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateVariants(
|
||||||
|
featureName: string,
|
||||||
|
newVariants: Operation[],
|
||||||
|
): Promise<FeatureToggle> {
|
||||||
|
const oldVariants = await this.getVariants(featureName);
|
||||||
|
const { newDocument } = await applyPatch(oldVariants, newVariants);
|
||||||
|
return this.saveVariants(featureName, newDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveVariants(
|
||||||
|
featureName: string,
|
||||||
|
newVariants: IVariant[],
|
||||||
|
): Promise<FeatureToggle> {
|
||||||
|
await variantsArraySchema.validateAsync(newVariants);
|
||||||
|
const fixedVariants = this.fixVariantWeights(newVariants);
|
||||||
|
return this.featureToggleStore.saveVariants(featureName, fixedVariants);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixVariantWeights(variants: IVariant[]): IVariant[] {
|
||||||
|
let variableVariants = variants.filter((x) => {
|
||||||
|
return x.weightType === WeightType.VARIABLE;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (variants.length > 0 && variableVariants.length === 0) {
|
||||||
|
throw new BadDataError(
|
||||||
|
'There must be at least one "variable" variant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fixedVariants = variants.filter((x) => {
|
||||||
|
return x.weightType === WeightType.FIX;
|
||||||
|
});
|
||||||
|
|
||||||
|
let fixedWeights = fixedVariants.reduce((a, v) => a + v.weight, 0);
|
||||||
|
|
||||||
|
if (fixedWeights > 1000) {
|
||||||
|
throw new BadDataError(
|
||||||
|
'The traffic distribution total must equal 100%',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let averageWeight = Math.floor(
|
||||||
|
(1000 - fixedWeights) / variableVariants.length,
|
||||||
|
);
|
||||||
|
let remainder = (1000 - fixedWeights) % variableVariants.length;
|
||||||
|
|
||||||
|
variableVariants = variableVariants.map((x) => {
|
||||||
|
x.weight = averageWeight;
|
||||||
|
if (remainder > 0) {
|
||||||
|
x.weight += 1;
|
||||||
|
remainder--;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
return variableVariants.concat(fixedVariants);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeatureToggleService;
|
export default FeatureToggleService;
|
||||||
|
@ -8,7 +8,10 @@ export interface IConstraint {
|
|||||||
operator: string;
|
operator: string;
|
||||||
values: string[];
|
values: string[];
|
||||||
}
|
}
|
||||||
|
export enum WeightType {
|
||||||
|
VARIABLE = 'variable',
|
||||||
|
FIX = 'fix',
|
||||||
|
}
|
||||||
export interface IStrategyConfig {
|
export interface IStrategyConfig {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { FeatureToggle, FeatureToggleDTO } from '../model';
|
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
|
|
||||||
export interface IFeatureToggleQuery {
|
export interface IFeatureToggleQuery {
|
||||||
@ -16,4 +16,9 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
|||||||
archive(featureName: string): Promise<FeatureToggle>;
|
archive(featureName: string): Promise<FeatureToggle>;
|
||||||
revive(featureName: string): Promise<FeatureToggle>;
|
revive(featureName: string): Promise<FeatureToggle>;
|
||||||
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
|
||||||
|
getVariants(featureName: string): Promise<IVariant[]>;
|
||||||
|
saveVariants(
|
||||||
|
featureName: string,
|
||||||
|
newVariants: IVariant[],
|
||||||
|
): Promise<FeatureToggle>;
|
||||||
}
|
}
|
||||||
|
@ -173,7 +173,7 @@ test('should return toggle summary', async () => {
|
|||||||
|
|
||||||
test('should only include last hour of metrics return toggle summary', async () => {
|
test('should only include last hour of metrics return toggle summary', async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateOneHourAgo = subHours(now, 1);
|
const dateTwoHoursAgo = subHours(now, 2);
|
||||||
const metrics: IClientMetricsEnv[] = [
|
const metrics: IClientMetricsEnv[] = [
|
||||||
{
|
{
|
||||||
featureName: 'demo',
|
featureName: 'demo',
|
||||||
@ -211,7 +211,7 @@ test('should only include last hour of metrics return toggle summary', async ()
|
|||||||
featureName: 'demo',
|
featureName: 'demo',
|
||||||
appName: 'backend-api',
|
appName: 'backend-api',
|
||||||
environment: 'test',
|
environment: 'test',
|
||||||
timestamp: dateOneHourAgo,
|
timestamp: dateTwoHoursAgo,
|
||||||
yes: 55,
|
yes: 55,
|
||||||
no: 55,
|
no: 55,
|
||||||
},
|
},
|
||||||
|
645
src/test/e2e/api/admin/project/variants.e2e.test.ts
Normal file
645
src/test/e2e/api/admin/project/variants.e2e.test.ts
Normal file
@ -0,0 +1,645 @@
|
|||||||
|
import { IUnleashTest, setupApp } from '../../../helpers/test-helper';
|
||||||
|
import dbInit, { ITestDb } from '../../../helpers/database-init';
|
||||||
|
import getLogger from '../../../../fixtures/no-logger';
|
||||||
|
import * as jsonpatch from 'fast-json-patch';
|
||||||
|
import { IVariant, WeightType } from '../../../../../lib/types/model';
|
||||||
|
|
||||||
|
let app: IUnleashTest;
|
||||||
|
let db: ITestDb;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await dbInit('project_feature_variants_api_serial', getLogger);
|
||||||
|
app = await setupApp(db.stores);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can get variants for a feature', async () => {
|
||||||
|
const featureName = 'feature-variants';
|
||||||
|
const variantName = 'fancy-variant';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
name: variantName,
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.version).toBe('1');
|
||||||
|
expect(res.body.variants).toHaveLength(1);
|
||||||
|
expect(res.body.variants[0].name).toBe(variantName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Trying to do operations on a non-existing feature yields 404', async () => {
|
||||||
|
await app.request
|
||||||
|
.get(
|
||||||
|
'/api/admin/projects/default/features/non-existing-feature/variants',
|
||||||
|
)
|
||||||
|
.expect(404);
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
name: 'variant-put-overwrites',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await app.request
|
||||||
|
.put('/api/admin/projects/default/features/${featureName}/variants')
|
||||||
|
.send(variants)
|
||||||
|
.expect(404);
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 700,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
let patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch('/api/admin/projects/default/features/${featureName}/variants')
|
||||||
|
.send(patch)
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can patch variants for a feature and get a response of new variant', async () => {
|
||||||
|
const featureName = 'feature-variants-patch';
|
||||||
|
const variantName = 'fancy-variant-patch';
|
||||||
|
const expectedVariantName = 'not-so-cool-variant-name';
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
name: variantName,
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(variants);
|
||||||
|
variants[0].name = expectedVariantName;
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.version).toBe('1');
|
||||||
|
expect(res.body.variants).toHaveLength(1);
|
||||||
|
expect(res.body.variants[0].name).toBe(expectedVariantName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can add variant for a feature', async () => {
|
||||||
|
const featureName = 'feature-variants-patch-add';
|
||||||
|
const variantName = 'fancy-variant-patch';
|
||||||
|
const expectedVariantName = 'not-so-cool-variant-name';
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
name: variantName,
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(variants);
|
||||||
|
variants.push({
|
||||||
|
name: expectedVariantName,
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.version).toBe('1');
|
||||||
|
expect(res.body.variants).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
res.body.variants.find((x) => x.name === expectedVariantName),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
res.body.variants.find((x) => x.name === variantName),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can remove variant for a feature', async () => {
|
||||||
|
const featureName = 'feature-variants-patch-remove';
|
||||||
|
const variantName = 'fancy-variant-patch';
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
name: variantName,
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(variants);
|
||||||
|
variants.pop();
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.version).toBe('1');
|
||||||
|
expect(res.body.variants).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT overwrites current variant on feature', async () => {
|
||||||
|
const featureName = 'variant-put-overwrites';
|
||||||
|
const variantName = 'overwriting-for-fun';
|
||||||
|
const variants = [
|
||||||
|
{
|
||||||
|
name: variantName,
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 1000,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [
|
||||||
|
{
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 250,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'variant2',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 375,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'variant3',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 450,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await app.request
|
||||||
|
.put(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(newVariants)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.variants).toHaveLength(3);
|
||||||
|
});
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.variants).toHaveLength(3);
|
||||||
|
expect(res.body.variants.reduce((a, v) => a + v.weight, 0)).toEqual(
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUTing an invalid variant throws 400 exception', async () => {
|
||||||
|
const featureName = 'variants-validation-feature';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidJson = [
|
||||||
|
{
|
||||||
|
name: 'variant',
|
||||||
|
weight: 500,
|
||||||
|
weightType: 'party',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await app.request
|
||||||
|
.put(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(invalidJson)
|
||||||
|
.expect(400)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.details).toHaveLength(1);
|
||||||
|
expect(res.body.details[0].message).toMatch(
|
||||||
|
/.*weightType\" must be one of/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Invalid variant in PATCH also throws 400 exception', async () => {
|
||||||
|
const featureName = 'patch-validation-feature';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidPatch = `[{
|
||||||
|
"op": "add",
|
||||||
|
"path": "/1",
|
||||||
|
"value": {
|
||||||
|
"name": "not-so-cool-variant-name",
|
||||||
|
"stickiness": "default",
|
||||||
|
"weight": 2000,
|
||||||
|
"weightType": "variable"
|
||||||
|
}
|
||||||
|
}]`;
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.send(invalidPatch)
|
||||||
|
.expect(400)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.details).toHaveLength(1);
|
||||||
|
expect(res.body.details[0].message).toMatch(
|
||||||
|
/.*weight\" must be less than or equal to 1000/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATCHING with all variable weightTypes forces weights to sum to no less than 1000 minus the number of variable variants', async () => {
|
||||||
|
const featureName = 'variants-validation-with-all-variable-weights';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 700,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
let patch = jsonpatch.generate(observer);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.variants).toHaveLength(1);
|
||||||
|
expect(res.body.variants[0].weight).toEqual(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant2',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 700,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
patch = jsonpatch.generate(observer);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.variants).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
res.body.variants.every((x) => x.weight === 500),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant3',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 700,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
patch = jsonpatch.generate(observer);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
res.body.variants.sort((v, other) => other.weight - v.weight);
|
||||||
|
expect(res.body.variants).toHaveLength(3);
|
||||||
|
expect(res.body.variants[0].weight).toBe(334);
|
||||||
|
expect(res.body.variants[1].weight).toBe(333);
|
||||||
|
expect(res.body.variants[2].weight).toBe(333);
|
||||||
|
});
|
||||||
|
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant4',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 700,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
patch = jsonpatch.generate(observer);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.variants).toHaveLength(4);
|
||||||
|
expect(
|
||||||
|
res.body.variants.every((x) => x.weight === 250),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PATCHING with no variable variants fails with 400', async () => {
|
||||||
|
const featureName = 'variants-validation-with-no-variable-weights';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 900,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(400)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.details).toHaveLength(1);
|
||||||
|
expect(res.body.details[0].message).toEqual(
|
||||||
|
'There must be at least one "variable" variant',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Patching with a fixed variant and variable variants splits remaining weight among variable variants', async () => {
|
||||||
|
const featureName = 'variants-fixed-and-variable';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 900,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant2',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 20,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant3',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 123,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant4',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 123,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant5',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 123,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant6',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 123,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant7',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 123,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
let body = res.body;
|
||||||
|
expect(body.variants).toHaveLength(7);
|
||||||
|
expect(
|
||||||
|
body.variants.reduce((total, v) => total + v.weight, 0),
|
||||||
|
).toEqual(1000);
|
||||||
|
body.variants.sort((a, b) => b.weight - a.weight);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant1').weight,
|
||||||
|
).toEqual(900);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant2').weight,
|
||||||
|
).toEqual(17);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant3').weight,
|
||||||
|
).toEqual(17);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant4').weight,
|
||||||
|
).toEqual(17);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant5').weight,
|
||||||
|
).toEqual(17);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant6').weight,
|
||||||
|
).toEqual(16);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant7').weight,
|
||||||
|
).toEqual(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Multiple fixed variants gets added together to decide how much weight variable variants should get', async () => {
|
||||||
|
const featureName = 'variants-multiple-fixed-and-variable';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 600,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant2',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 350,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant3',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 350,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200);
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
let body = res.body;
|
||||||
|
expect(body.variants).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant3').weight,
|
||||||
|
).toEqual(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('If sum of fixed variant weight exceed 1000 fails with 400', async () => {
|
||||||
|
const featureName = 'variants-fixed-weight-over-1000';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 900,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant2',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 900,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant3',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 350,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(400)
|
||||||
|
.expect((res) => {
|
||||||
|
expect(res.body.details).toHaveLength(1);
|
||||||
|
expect(res.body.details[0].message).toEqual(
|
||||||
|
'The traffic distribution total must equal 100%',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('If sum of fixed variant weight equals 1000 variable variants gets weight 0', async () => {
|
||||||
|
const featureName = 'variants-fixed-weight-equals-1000-no-variable-weight';
|
||||||
|
await db.stores.featureToggleStore.create('default', {
|
||||||
|
name: featureName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newVariants: IVariant[] = [];
|
||||||
|
|
||||||
|
const observer = jsonpatch.observe(newVariants);
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant1',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 900,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant2',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 100,
|
||||||
|
weightType: WeightType.FIX,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant3',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 350,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
newVariants.push({
|
||||||
|
name: 'variant4',
|
||||||
|
stickiness: 'default',
|
||||||
|
weight: 350,
|
||||||
|
weightType: WeightType.VARIABLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patch = jsonpatch.generate(observer);
|
||||||
|
await app.request
|
||||||
|
.patch(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.send(patch)
|
||||||
|
.expect(200);
|
||||||
|
await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}/variants`)
|
||||||
|
.expect(200)
|
||||||
|
.expect((res) => {
|
||||||
|
let body = res.body;
|
||||||
|
expect(body.variants).toHaveLength(4);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant3').weight,
|
||||||
|
).toEqual(0);
|
||||||
|
expect(
|
||||||
|
body.variants.find((v) => v.name === 'variant4').weight,
|
||||||
|
).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
20
src/test/fixtures/fake-feature-toggle-store.ts
vendored
20
src/test/fixtures/fake-feature-toggle-store.ts
vendored
@ -2,7 +2,11 @@ import {
|
|||||||
IFeatureToggleQuery,
|
IFeatureToggleQuery,
|
||||||
IFeatureToggleStore,
|
IFeatureToggleStore,
|
||||||
} from '../../lib/types/stores/feature-toggle-store';
|
} from '../../lib/types/stores/feature-toggle-store';
|
||||||
import { FeatureToggle, FeatureToggleDTO } from '../../lib/types/model';
|
import {
|
||||||
|
FeatureToggle,
|
||||||
|
FeatureToggleDTO,
|
||||||
|
IVariant,
|
||||||
|
} from '../../lib/types/model';
|
||||||
import NotFoundError from '../../lib/error/notfound-error';
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
|
|
||||||
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
||||||
@ -123,4 +127,18 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVariants(featureName: string): Promise<IVariant[]> {
|
||||||
|
const feature = await this.get(featureName);
|
||||||
|
return feature.variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveVariants(
|
||||||
|
featureName: string,
|
||||||
|
newVariants: IVariant[],
|
||||||
|
): Promise<FeatureToggle> {
|
||||||
|
const feature = await this.get(featureName);
|
||||||
|
feature.variants = newVariants;
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ title: /api/admin/projects/:projectId
|
|||||||
In this document we will guide you on how you can work with feature toggles and their configuration. Please remember the following details:
|
In this document we will guide you on how you can work with feature toggles and their configuration. Please remember the following details:
|
||||||
|
|
||||||
- All feature toggles exists _inside a project_.
|
- All feature toggles exists _inside a project_.
|
||||||
- A feature toggles exists _across all environments_.
|
- A feature toggle exists _across all environments_.
|
||||||
- A feature toggle can take different configuration, activation strategies, per environment.
|
- A feature toggle can take different configuration, activation strategies, per environment.
|
||||||
|
|
||||||
TODO: Need to explain the following in a bit more details:
|
TODO: Need to explain the following in a bit more details:
|
||||||
@ -505,4 +505,99 @@ Transfer-Encoding: chunked
|
|||||||
|
|
||||||
Possible Errors:
|
Possible Errors:
|
||||||
|
|
||||||
- _409 Conflict_ - You can not enable the environment before it has strategies.
|
- _409 Conflict_ - You can not enable the environment before it has strategies.
|
||||||
|
|
||||||
|
## Feature Variants
|
||||||
|
|
||||||
|
### Put variants for Feature Toggle {#update-variants}
|
||||||
|
|
||||||
|
This overwrites the current variants for the feature toggle specified in the :featureName parameter.
|
||||||
|
The backend will validate the input for the following invariants
|
||||||
|
|
||||||
|
* If there are variants, there needs to be at least one variant with `weightType: variable`
|
||||||
|
* The sum of the weights of variants with `weightType: fix` must be below 1000 (< 1000)
|
||||||
|
|
||||||
|
The backend will also distribute remaining weight up to 1000 after adding the variants with `weightType: fix` together amongst the variants of `weightType: variable`
|
||||||
|
|
||||||
|
**Example Query**
|
||||||
|
```bash
|
||||||
|
echo '[
|
||||||
|
{
|
||||||
|
"name": "variant1",
|
||||||
|
"weightType": "fix",
|
||||||
|
"weight": 650
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "variant2",
|
||||||
|
"weightType": "variable",
|
||||||
|
"weight": 123
|
||||||
|
}
|
||||||
|
]' | \
|
||||||
|
http PUT http://localhost:4242/api/admin/projects/default/features/demo/variants Authorization:$KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example response:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
Connection: keep-alive
|
||||||
|
Date: Tue, 23 Nov 2021 08:46:32 GMT
|
||||||
|
Keep-Alive: timeout=60
|
||||||
|
Transfer-Encoding: chunked
|
||||||
|
Content-Type: application/json; charset=utf-8
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "variant2",
|
||||||
|
"weightType": "variable",
|
||||||
|
"weight": 350
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "variant1",
|
||||||
|
"weightType": "fix",
|
||||||
|
"weight": 650
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PATCH variants for a feature toggle
|
||||||
|
|
||||||
|
**Example Query**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo '[{"op": "add", "path": "/1", "value": {
|
||||||
|
"name": "new-variant",
|
||||||
|
"weightType": "fix",
|
||||||
|
"weight": 200
|
||||||
|
}}]' | \
|
||||||
|
http PATCH http://localhost:4242/api/admin/projects/default/features/demo/variants Authorization:$KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
** Example Response **
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"variants": [
|
||||||
|
{
|
||||||
|
"name": "variant2",
|
||||||
|
"weightType": "variable",
|
||||||
|
"weight": 150
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "new-variant",
|
||||||
|
"weightType": "fix",
|
||||||
|
"weight": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "variant1",
|
||||||
|
"weightType": "fix",
|
||||||
|
"weight": 650
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user