1
0
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:
Christopher Kolstad 2021-11-24 13:08:04 +01:00 committed by GitHub
parent 3a785bdb0f
commit 6c6001619c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 954 additions and 9 deletions

View File

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

View File

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

View File

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

View 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,
});
}
}

View File

@ -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({

View File

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

View File

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

View File

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

View File

@ -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,
}, },

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

View File

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

View File

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