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
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: setup git config
|
||||
- name: Build docs
|
||||
run: |
|
||||
# Build the site
|
||||
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 {
|
||||
const row = {
|
||||
name: data.name,
|
||||
@ -232,6 +239,24 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
|
||||
.returning(FEATURE_COLUMNS);
|
||||
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;
|
||||
|
@ -6,6 +6,7 @@ import ProjectFeaturesController from './features';
|
||||
import EnvironmentsController from './environments';
|
||||
import ProjectHealthReport from './health-report';
|
||||
import ProjectService from '../../../services/project-service';
|
||||
import VariantsController from './variants';
|
||||
|
||||
export default class ProjectApi extends Controller {
|
||||
private projectService: ProjectService;
|
||||
@ -17,6 +18,7 @@ export default class ProjectApi extends Controller {
|
||||
this.use('/', new ProjectFeaturesController(config, services).router);
|
||||
this.use('/', new EnvironmentsController(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> {
|
||||
|
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
|
||||
.object()
|
||||
.keys({
|
||||
|
@ -5,7 +5,11 @@ import BadDataError from '../error/bad-data-error';
|
||||
import NameExistsError from '../error/name-exists-error';
|
||||
import InvalidOperationError from '../error/invalid-operation-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 {
|
||||
FeatureArchivedEvent,
|
||||
FeatureChangeProjectEvent,
|
||||
@ -40,6 +44,8 @@ import {
|
||||
IFeatureStrategy,
|
||||
IFeatureToggleQuery,
|
||||
IStrategyConfig,
|
||||
IVariant,
|
||||
WeightType,
|
||||
} from '../types/model';
|
||||
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-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> {
|
||||
return this.featureToggleStore.get(featureName);
|
||||
}
|
||||
@ -882,6 +897,63 @@ class FeatureToggleService {
|
||||
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;
|
||||
|
@ -8,7 +8,10 @@ export interface IConstraint {
|
||||
operator: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export enum WeightType {
|
||||
VARIABLE = 'variable',
|
||||
FIX = 'fix',
|
||||
}
|
||||
export interface IStrategyConfig {
|
||||
id?: string;
|
||||
name: string;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FeatureToggle, FeatureToggleDTO } from '../model';
|
||||
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../model';
|
||||
import { Store } from './store';
|
||||
|
||||
export interface IFeatureToggleQuery {
|
||||
@ -16,4 +16,9 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
|
||||
archive(featureName: string): Promise<FeatureToggle>;
|
||||
revive(featureName: string): 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 () => {
|
||||
const now = new Date();
|
||||
const dateOneHourAgo = subHours(now, 1);
|
||||
const dateTwoHoursAgo = subHours(now, 2);
|
||||
const metrics: IClientMetricsEnv[] = [
|
||||
{
|
||||
featureName: 'demo',
|
||||
@ -211,7 +211,7 @@ test('should only include last hour of metrics return toggle summary', async ()
|
||||
featureName: 'demo',
|
||||
appName: 'backend-api',
|
||||
environment: 'test',
|
||||
timestamp: dateOneHourAgo,
|
||||
timestamp: dateTwoHoursAgo,
|
||||
yes: 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,
|
||||
IFeatureToggleStore,
|
||||
} 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';
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
|
||||
TODO: Need to explain the following in a bit more details:
|
||||
@ -505,4 +505,99 @@ Transfer-Encoding: chunked
|
||||
|
||||
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