From 6c6001619ce2fbcb4664f9346b1729d4974186db Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Wed, 24 Nov 2021 13:08:04 +0100 Subject: [PATCH] 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 --- .github/workflows/build_doc_prs.yaml | 2 +- src/lib/db/feature-toggle-store.ts | 25 + src/lib/routes/admin-api/project/index.ts | 2 + src/lib/routes/admin-api/project/variants.ts | 78 +++ src/lib/schema/feature-schema.ts | 2 + src/lib/services/feature-toggle-service.ts | 74 +- src/lib/types/model.ts | 5 +- src/lib/types/stores/feature-toggle-store.ts | 7 +- .../e2e/api/admin/client-metrics.e2e.test.ts | 4 +- .../api/admin/project/variants.e2e.test.ts | 645 ++++++++++++++++++ .../fixtures/fake-feature-toggle-store.ts | 20 +- .../docs/api/admin/feature-toggles-api-v2.md | 99 ++- 12 files changed, 954 insertions(+), 9 deletions(-) create mode 100644 src/lib/routes/admin-api/project/variants.ts create mode 100644 src/test/e2e/api/admin/project/variants.e2e.test.ts diff --git a/.github/workflows/build_doc_prs.yaml b/.github/workflows/build_doc_prs.yaml index c332f67003..88a3ac5fa1 100644 --- a/.github/workflows/build_doc_prs.yaml +++ b/.github/workflows/build_doc_prs.yaml @@ -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 diff --git a/src/lib/db/feature-toggle-store.ts b/src/lib/db/feature-toggle-store.ts index 7d4414195f..a9d5bb0fbc 100644 --- a/src/lib/db/feature-toggle-store.ts +++ b/src/lib/db/feature-toggle-store.ts @@ -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 { + const row = await this.db(TABLE) + .select('variants') + .where({ name: featureName }); + return this.rowToVariants(row[0]); + } + + async saveVariants( + featureName: string, + newVariants: IVariant[], + ): Promise { + 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; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index 22f982fbe7..10f54fe765 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -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 { diff --git a/src/lib/routes/admin-api/project/variants.ts b/src/lib/routes/admin-api/project/variants.ts new file mode 100644 index 0000000000..cc88c303ff --- /dev/null +++ b/src/lib/routes/admin-api/project/variants.ts @@ -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, + ) { + 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, + res: Response, + ): Promise { + const { featureName } = req.params; + const variants = await this.featureService.getVariants(featureName); + res.status(200).json({ version: '1', variants }); + } + + async patchVariants( + req: Request, + res: Response, + ): Promise { + 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, + res: Response, + ): Promise { + const { featureName } = req.params; + const updatedFeature = await this.featureService.saveVariants( + featureName, + req.body, + ); + res.status(200).json({ + version: '1', + variants: updatedFeature.variants, + }); + } +} diff --git a/src/lib/schema/feature-schema.ts b/src/lib/schema/feature-schema.ts index 8a1b14cd54..4ea4e1b6c5 100644 --- a/src/lib/schema/feature-schema.ts +++ b/src/lib/schema/feature-schema.ts @@ -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({ diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 5aae84b36d..2dcd774823 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -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 { + return this.featureToggleStore.getVariants(featureName); + } + async getFeatureMetadata(featureName: string): Promise { return this.featureToggleStore.get(featureName); } @@ -882,6 +897,63 @@ class FeatureToggleService { newProjectId, ); } + + async updateVariants( + featureName: string, + newVariants: Operation[], + ): Promise { + const oldVariants = await this.getVariants(featureName); + const { newDocument } = await applyPatch(oldVariants, newVariants); + return this.saveVariants(featureName, newDocument); + } + + async saveVariants( + featureName: string, + newVariants: IVariant[], + ): Promise { + 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; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index f19a8c9746..495beb746d 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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; diff --git a/src/lib/types/stores/feature-toggle-store.ts b/src/lib/types/stores/feature-toggle-store.ts index edbd732653..f950813fd0 100644 --- a/src/lib/types/stores/feature-toggle-store.ts +++ b/src/lib/types/stores/feature-toggle-store.ts @@ -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 { archive(featureName: string): Promise; revive(featureName: string): Promise; getAll(query?: Partial): Promise; + getVariants(featureName: string): Promise; + saveVariants( + featureName: string, + newVariants: IVariant[], + ): Promise; } diff --git a/src/test/e2e/api/admin/client-metrics.e2e.test.ts b/src/test/e2e/api/admin/client-metrics.e2e.test.ts index f9f2ad7680..61ddef37fb 100644 --- a/src/test/e2e/api/admin/client-metrics.e2e.test.ts +++ b/src/test/e2e/api/admin/client-metrics.e2e.test.ts @@ -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, }, diff --git a/src/test/e2e/api/admin/project/variants.e2e.test.ts b/src/test/e2e/api/admin/project/variants.e2e.test.ts new file mode 100644 index 0000000000..de39df76d0 --- /dev/null +++ b/src/test/e2e/api/admin/project/variants.e2e.test.ts @@ -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); + }); +}); diff --git a/src/test/fixtures/fake-feature-toggle-store.ts b/src/test/fixtures/fake-feature-toggle-store.ts index 9b3926e09e..143168314c 100644 --- a/src/test/fixtures/fake-feature-toggle-store.ts +++ b/src/test/fixtures/fake-feature-toggle-store.ts @@ -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 { + const feature = await this.get(featureName); + return feature.variants; + } + + async saveVariants( + featureName: string, + newVariants: IVariant[], + ): Promise { + const feature = await this.get(featureName); + feature.variants = newVariants; + return feature; + } } diff --git a/website/docs/api/admin/feature-toggles-api-v2.md b/website/docs/api/admin/feature-toggles-api-v2.md index 41cdfcd855..3785a6a027 100644 --- a/website/docs/api/admin/feature-toggles-api-v2.md +++ b/website/docs/api/admin/feature-toggles-api-v2.md @@ -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. \ No newline at end of file +- _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 + } + ] +} +``` +