mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Merge pull request #693 from Unleash/feat-690-toggle-filtering
feat: #690 toggle filtering
This commit is contained in:
		
						commit
						00c7d634d2
					
				| @ -1,5 +1,9 @@ | ||||
| # Changelog | ||||
| 
 | ||||
| ## 3.11 | ||||
| 
 | ||||
| - feat: Add support for filtering toggles on tags, projects or namePrefix (#690) | ||||
| 
 | ||||
| ## 3.10.1 | ||||
| 
 | ||||
| - fix: remove fields from /api/client/features respnse (#692) | ||||
|  | ||||
| @ -73,6 +73,28 @@ This endpoint is the one all admin ui should use to fetch all available feature | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| #### Filter feature toggles | ||||
| 
 | ||||
| Supports three params for now | ||||
| 
 | ||||
| - `tag` - filters for features tagged with tag | ||||
| - `project` - filters for features belonging to project | ||||
| - `namePrefix` - filters for features beginning with prefix | ||||
| 
 | ||||
| For `tag` and `project` performs OR filtering if multiple arguments | ||||
| 
 | ||||
| To filter for any feature tagged with a `simple` tag with value `taga` or a `simple` tag with value `tagb` use | ||||
| 
 | ||||
| `GET https://unleash.host.com/api/admin/features?tag[]=simple:taga&tag[]simple:tagb` | ||||
| 
 | ||||
| To filter for any feature belonging to project `myproject` use | ||||
| 
 | ||||
| `GET https://unleash.host.com/api/admin/features?project=myproject` | ||||
| 
 | ||||
| Response format is the same as `api/admin/features` | ||||
| 
 | ||||
| ### Fetch specific feature toggle | ||||
| 
 | ||||
| `GET: http://unleash.host.com/api/admin/features/:featureName` | ||||
| 
 | ||||
| Used to fetch details about a specific featureToggle. This is mostly provded to make it easy to debug the API and should not be used by the client implementations. | ||||
|  | ||||
| @ -68,7 +68,27 @@ This endpoint should never return anything besides a valid _20X or 304-response_ | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| You may limit the response by sending a `namePrefix` query-parameter. | ||||
| #### Filter feature toggles | ||||
| 
 | ||||
| Supports three params for now | ||||
| 
 | ||||
| - `tag` - filters for features tagged with tag | ||||
| - `project` - filters for features belonging to project | ||||
| - `namePrefix` - filters for features beginning with prefix | ||||
| 
 | ||||
| For `tag` and `project` performs OR filtering if multiple arguments | ||||
| 
 | ||||
| To filter for any feature tagged with a `simple` tag with value `taga` or a `simple` tag with value `tagb` use | ||||
| 
 | ||||
| `GET https://unleash.host.com/api/client/features?tag[]=simple:taga&tag[]simple:tagb` | ||||
| 
 | ||||
| To filter for any feature belonging to project `myproject` use | ||||
| 
 | ||||
| `GET https://unleash.host.com/api/client/features?project=myproject` | ||||
| 
 | ||||
| Response format is the same as `api/client/features` | ||||
| 
 | ||||
| ### Get specific feature toggle | ||||
| 
 | ||||
| `GET: http://unleash.host.com/api/client/features/:featureName` | ||||
| 
 | ||||
|  | ||||
| @ -1,82 +0,0 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| const metricsHelper = require('../metrics-helper'); | ||||
| const { DB_TIME } = require('../events'); | ||||
| 
 | ||||
| const FEATURE_TAG_COLUMNS = ['feature_name', 'tag_type', 'tag_value']; | ||||
| const FEATURE_TAG_TABLE = 'feature_tag'; | ||||
| 
 | ||||
| class FeatureTagStore { | ||||
|     constructor(db, eventBus, getLogger) { | ||||
|         this.db = db; | ||||
|         this.logger = getLogger('feature-tag-store.js'); | ||||
| 
 | ||||
|         this.timer = action => | ||||
|             metricsHelper.wrapTimer(eventBus, DB_TIME, { | ||||
|                 store: 'tag', | ||||
|                 action, | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     async getAllTagsForFeature(featureName) { | ||||
|         const stopTimer = this.timer('getAllForFeature'); | ||||
|         const rows = await this.db | ||||
|             .select(FEATURE_TAG_COLUMNS) | ||||
|             .from(FEATURE_TAG_TABLE) | ||||
|             .where({ feature_name: featureName }); | ||||
|         stopTimer(); | ||||
|         return rows.map(this.featureTagRowToTag); | ||||
|     } | ||||
| 
 | ||||
|     async tagFeature(featureName, tag) { | ||||
|         const stopTimer = this.timer('tagFeature'); | ||||
|         await this.db(FEATURE_TAG_TABLE) | ||||
|             .insert(this.featureAndTagToRow(featureName, tag)) | ||||
|             .onConflict(['feature_name', 'tag_type', 'tag_value']) | ||||
|             .ignore(); | ||||
|         stopTimer(); | ||||
|         return tag; | ||||
|     } | ||||
| 
 | ||||
|     async untagFeature(featureName, tag) { | ||||
|         const stopTimer = this.timer('untagFeature'); | ||||
|         try { | ||||
|             await this.db(FEATURE_TAG_TABLE) | ||||
|                 .where(this.featureAndTagToRow(featureName, tag)) | ||||
|                 .delete(); | ||||
|         } catch (err) { | ||||
|             this.logger.error(err); | ||||
|         } | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     rowToTag(row) { | ||||
|         if (row) { | ||||
|             return { | ||||
|                 value: row.value, | ||||
|                 type: row.type, | ||||
|             }; | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     featureTagRowToTag(row) { | ||||
|         if (row) { | ||||
|             return { | ||||
|                 value: row.tag_value, | ||||
|                 type: row.tag_type, | ||||
|             }; | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     featureAndTagToRow(featureName, { type, value }) { | ||||
|         return { | ||||
|             feature_name: featureName, | ||||
|             tag_type: type, | ||||
|             tag_value: value, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = FeatureTagStore; | ||||
| @ -16,16 +16,15 @@ const FEATURE_COLUMNS = [ | ||||
|     'created_at', | ||||
|     'last_seen_at', | ||||
| ]; | ||||
| const TABLE = 'features'; | ||||
| 
 | ||||
| const FEATURE_COLUMNS_CLIENT = [ | ||||
|     'name', | ||||
|     'type', | ||||
|     'enabled', | ||||
|     'stale', | ||||
|     'strategies', | ||||
|     'variants', | ||||
| ]; | ||||
| const mapperToColumnNames = { | ||||
|     createdAt: 'created_at', | ||||
|     lastSeenAt: 'last_seen_at', | ||||
| }; | ||||
| const TABLE = 'features'; | ||||
| const FEATURE_TAG_COLUMNS = ['feature_name', 'tag_type', 'tag_value']; | ||||
| const FEATURE_TAG_FILTER_COLUMNS = ['tag_type', 'tag_value']; | ||||
| const FEATURE_TAG_TABLE = 'feature_tag'; | ||||
| 
 | ||||
| class FeatureToggleStore { | ||||
|     constructor(db, eventBus, getLogger) { | ||||
| @ -38,43 +37,36 @@ class FeatureToggleStore { | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     async getFeatures() { | ||||
|     async getFeatures(query = {}, fields = FEATURE_COLUMNS) { | ||||
|         const stopTimer = this.timer('getAll'); | ||||
| 
 | ||||
|         const rows = await this.db | ||||
|             .select(FEATURE_COLUMNS) | ||||
|         const queryFields = fields.map(f => | ||||
|             mapperToColumnNames[f] ? mapperToColumnNames[f] : f, | ||||
|         ); | ||||
|         let baseQuery = this.db | ||||
|             .select(queryFields) | ||||
|             .from(TABLE) | ||||
|             .where({ archived: 0 }) | ||||
|             .orderBy('name', 'asc'); | ||||
| 
 | ||||
|         stopTimer(); | ||||
| 
 | ||||
|         return rows.map(this.rowToFeature); | ||||
|     } | ||||
| 
 | ||||
|     async getFeaturesClient() { | ||||
|         const stopTimer = this.timer('getAllClient'); | ||||
| 
 | ||||
|         const rows = await this.db | ||||
|             .select(FEATURE_COLUMNS_CLIENT) | ||||
|             .from(TABLE) | ||||
|             .where({ archived: 0 }) | ||||
|             .orderBy('name', 'asc'); | ||||
| 
 | ||||
|         stopTimer(); | ||||
| 
 | ||||
|         return rows.map(this.rowToFeature); | ||||
|     } | ||||
| 
 | ||||
|     async getClientFeatures() { | ||||
|         const stopTimer = this.timer('getAll'); | ||||
| 
 | ||||
|         const rows = await this.db | ||||
|             .select(FEATURE_COLUMNS) | ||||
|             .from(TABLE) | ||||
|             .where({ archived: 0 }) | ||||
|             .orderBy('name', 'asc'); | ||||
| 
 | ||||
|         if (query) { | ||||
|             if (query.tag) { | ||||
|                 const tagQuery = this.db | ||||
|                     .from('feature_tag') | ||||
|                     .select('feature_name') | ||||
|                     .whereIn(FEATURE_TAG_FILTER_COLUMNS, query.tag); | ||||
|                 baseQuery = baseQuery.whereIn('name', tagQuery); | ||||
|             } | ||||
|             if (query.project) { | ||||
|                 baseQuery = baseQuery.whereIn('project', query.project); | ||||
|             } | ||||
|             if (query.namePrefix) { | ||||
|                 baseQuery = baseQuery.where( | ||||
|                     'name', | ||||
|                     'like', | ||||
|                     `${query.namePrefix}%`, | ||||
|                 ); | ||||
|             } | ||||
|         } | ||||
|         const rows = await baseQuery; | ||||
|         stopTimer(); | ||||
| 
 | ||||
|         return rows.map(this.rowToFeature); | ||||
| @ -233,6 +225,66 @@ class FeatureToggleStore { | ||||
|             this.logger.error('Could not drop features, error: ', err); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async getAllTagsForFeature(featureName) { | ||||
|         const stopTimer = this.timer('getAllForFeature'); | ||||
|         const rows = await this.db | ||||
|             .select(FEATURE_TAG_COLUMNS) | ||||
|             .from(FEATURE_TAG_TABLE) | ||||
|             .where({ feature_name: featureName }); | ||||
|         stopTimer(); | ||||
|         return rows.map(this.featureTagRowToTag); | ||||
|     } | ||||
| 
 | ||||
|     async tagFeature(featureName, tag) { | ||||
|         const stopTimer = this.timer('tagFeature'); | ||||
|         await this.db(FEATURE_TAG_TABLE) | ||||
|             .insert(this.featureAndTagToRow(featureName, tag)) | ||||
|             .onConflict(['feature_name', 'tag_type', 'tag_value']) | ||||
|             .ignore(); | ||||
|         stopTimer(); | ||||
|         return tag; | ||||
|     } | ||||
| 
 | ||||
|     async untagFeature(featureName, tag) { | ||||
|         const stopTimer = this.timer('untagFeature'); | ||||
|         try { | ||||
|             await this.db(FEATURE_TAG_TABLE) | ||||
|                 .where(this.featureAndTagToRow(featureName, tag)) | ||||
|                 .delete(); | ||||
|         } catch (err) { | ||||
|             this.logger.error(err); | ||||
|         } | ||||
|         stopTimer(); | ||||
|     } | ||||
| 
 | ||||
|     rowToTag(row) { | ||||
|         if (row) { | ||||
|             return { | ||||
|                 value: row.value, | ||||
|                 type: row.type, | ||||
|             }; | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     featureTagRowToTag(row) { | ||||
|         if (row) { | ||||
|             return { | ||||
|                 value: row.tag_value, | ||||
|                 type: row.tag_type, | ||||
|             }; | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     featureAndTagToRow(featureName, { type, value }) { | ||||
|         return { | ||||
|             feature_name: featureName, | ||||
|             tag_type: type, | ||||
|             tag_value: value, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| module.exports = FeatureToggleStore; | ||||
|  | ||||
| @ -14,7 +14,6 @@ const SettingStore = require('./setting-store'); | ||||
| const UserStore = require('./user-store'); | ||||
| const ProjectStore = require('./project-store'); | ||||
| const TagStore = require('./tag-store'); | ||||
| const FeatureTagStore = require('./feature-tag-store'); | ||||
| const TagTypeStore = require('./tag-type-store'); | ||||
| 
 | ||||
| module.exports.createStores = (config, eventBus) => { | ||||
| @ -51,6 +50,5 @@ module.exports.createStores = (config, eventBus) => { | ||||
|         projectStore: new ProjectStore(db, getLogger), | ||||
|         tagStore: new TagStore(db, eventBus, getLogger), | ||||
|         tagTypeStore: new TagTypeStore(db, eventBus, getLogger), | ||||
|         featureTagStore: new FeatureTagStore(db, eventBus, getLogger), | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| @ -9,6 +9,18 @@ const { | ||||
| } = require('../../permissions'); | ||||
| 
 | ||||
| const version = 1; | ||||
| const fields = [ | ||||
|     'name', | ||||
|     'description', | ||||
|     'type', | ||||
|     'project', | ||||
|     'enabled', | ||||
|     'stale', | ||||
|     'strategies', | ||||
|     'variants', | ||||
|     'createdAt', | ||||
|     'lastSeenAt', | ||||
| ]; | ||||
| 
 | ||||
| class FeatureController extends Controller { | ||||
|     constructor(config, { featureToggleService }) { | ||||
| @ -37,7 +49,10 @@ class FeatureController extends Controller { | ||||
|     } | ||||
| 
 | ||||
|     async getAllToggles(req, res) { | ||||
|         const features = await this.featureService.getFeatures(); | ||||
|         const features = await this.featureService.getFeatures( | ||||
|             req.query, | ||||
|             fields, | ||||
|         ); | ||||
|         res.json({ version, features }); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -31,7 +31,6 @@ function getSetup() { | ||||
|         base, | ||||
|         perms, | ||||
|         featureToggleStore: stores.featureToggleStore, | ||||
|         featureTagStore: stores.featureTagStore, | ||||
|         request: supertest(app), | ||||
|     }; | ||||
| } | ||||
| @ -383,13 +382,7 @@ test('should be able to add tag for feature', t => { | ||||
| }); | ||||
| test('should be able to get tags for feature', t => { | ||||
|     t.plan(1); | ||||
|     const { | ||||
|         request, | ||||
|         featureToggleStore, | ||||
|         featureTagStore, | ||||
|         base, | ||||
|         perms, | ||||
|     } = getSetup(); | ||||
|     const { request, featureToggleStore, base, perms } = getSetup(); | ||||
|     perms.withPermissions(UPDATE_FEATURE); | ||||
| 
 | ||||
|     featureToggleStore.createFeature({ | ||||
| @ -398,7 +391,7 @@ test('should be able to get tags for feature', t => { | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
| 
 | ||||
|     featureTagStore.tagFeature('toggle.disabled', { | ||||
|     featureToggleStore.tagFeature('toggle.disabled', { | ||||
|         value: 'TeamGreen', | ||||
|         type: 'simple', | ||||
|     }); | ||||
| @ -434,3 +427,99 @@ test('Invalid tag for feature should be rejected', t => { | ||||
|             t.is(res.body.details[0].message, '"type" must be URL friendly'); | ||||
|         }); | ||||
| }); | ||||
| 
 | ||||
| test('Should be able to filter on tag', t => { | ||||
|     t.plan(2); | ||||
|     const { request, featureToggleStore, base, perms } = getSetup(); | ||||
|     perms.withPermissions(UPDATE_FEATURE); | ||||
| 
 | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'toggle.tagged', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'toggle.untagged', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
| 
 | ||||
|     featureToggleStore.tagFeature('toggle.tagged', { | ||||
|         type: 'simple', | ||||
|         value: 'mytag', | ||||
|     }); | ||||
|     return request | ||||
|         .get(`${base}/api/admin/features?tag=simple:mytag`) | ||||
|         .expect(200) | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 1); | ||||
|             t.is(res.body.features[0].name, 'toggle.tagged'); | ||||
|         }); | ||||
| }); | ||||
| 
 | ||||
| test('Should be able to filter on name prefix', t => { | ||||
|     t.plan(3); | ||||
|     const { request, featureToggleStore, base, perms } = getSetup(); | ||||
|     perms.withPermissions(UPDATE_FEATURE); | ||||
| 
 | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'a_team.toggle', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'a_tag.toggle', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'b_tag.toggle', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
| 
 | ||||
|     return request | ||||
|         .get(`${base}/api/admin/features?namePrefix=a_`) | ||||
|         .expect(200) | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 2); | ||||
|             t.is(res.body.features[0].name, 'a_team.toggle'); | ||||
|             t.is(res.body.features[1].name, 'a_tag.toggle'); | ||||
|         }); | ||||
| }); | ||||
| 
 | ||||
| test('Should be able to filter on project', t => { | ||||
|     t.plan(3); | ||||
|     const { request, featureToggleStore, base, perms } = getSetup(); | ||||
|     perms.withPermissions(UPDATE_FEATURE); | ||||
| 
 | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'a_team.toggle', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|         project: 'projecta', | ||||
|     }); | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'a_tag.toggle', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|         project: 'projecta', | ||||
|     }); | ||||
|     featureToggleStore.createFeature({ | ||||
|         name: 'b_tag.toggle', | ||||
|         enabled: false, | ||||
|         strategies: [{ name: 'default' }], | ||||
|         project: 'projectb', | ||||
|     }); | ||||
|     return request | ||||
|         .get(`${base}/api/admin/features?project=projecta`) | ||||
|         .expect(200) | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 2); | ||||
|             t.is(res.body.features[0].name, 'a_team.toggle'); | ||||
|             t.is(res.body.features[1].name, 'a_tag.toggle'); | ||||
|         }); | ||||
| }); | ||||
|  | ||||
| @ -1,10 +1,18 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| const Controller = require('../controller'); | ||||
| const { filter } = require('./util'); | ||||
| 
 | ||||
| const version = 1; | ||||
| 
 | ||||
| const FEATURE_COLUMNS_CLIENT = [ | ||||
|     'name', | ||||
|     'type', | ||||
|     'enabled', | ||||
|     'stale', | ||||
|     'strategies', | ||||
|     'variants', | ||||
| ]; | ||||
| 
 | ||||
| class FeatureController extends Controller { | ||||
|     constructor({ featureToggleService }, getLogger) { | ||||
|         super(); | ||||
| @ -15,11 +23,10 @@ class FeatureController extends Controller { | ||||
|     } | ||||
| 
 | ||||
|     async getAll(req, res) { | ||||
|         const nameFilter = filter('name', req.query.namePrefix); | ||||
| 
 | ||||
|         const allFeatureToggles = await this.toggleService.getFeaturesClient(); | ||||
|         const features = nameFilter(allFeatureToggles); | ||||
| 
 | ||||
|         const features = await this.toggleService.getFeatures( | ||||
|             req.query, | ||||
|             FEATURE_COLUMNS_CLIENT, | ||||
|         ); | ||||
|         res.json({ version, features }); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -72,7 +72,22 @@ test('support name prefix', t => { | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.true(res.body.features.length === 2); | ||||
|             t.true(res.body.features[1].name === 'b_test2'); | ||||
|             t.is(res.body.features.length, 2); | ||||
|             t.is(res.body.features[1].name, 'b_test2'); | ||||
|         }); | ||||
| }); | ||||
| 
 | ||||
| test('support filtering on project', t => { | ||||
|     t.plan(2); | ||||
|     const { request, featureToggleStore, base } = getSetup(); | ||||
|     featureToggleStore.createFeature({ name: 'a_test1', project: 'projecta' }); | ||||
|     featureToggleStore.createFeature({ name: 'b_test2', project: 'projectb' }); | ||||
|     return request | ||||
|         .get(`${base}/api/client/features?project=projecta`) | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 1); | ||||
|             t.is(res.body.features[0].name, 'a_test1'); | ||||
|         }); | ||||
| }); | ||||
|  | ||||
| @ -1,10 +0,0 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| const filter = (key, value) => { | ||||
|     if (!key || !value) return array => array; | ||||
|     return array => array.filter(item => item[key].startsWith(value)); | ||||
| }; | ||||
| 
 | ||||
| module.exports = { | ||||
|     filter, | ||||
| }; | ||||
| @ -88,4 +88,24 @@ const featureSchema = joi | ||||
|     }) | ||||
|     .options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); | ||||
| 
 | ||||
| module.exports = { featureSchema, strategiesSchema, nameSchema }; | ||||
| const querySchema = joi | ||||
|     .object() | ||||
|     .keys({ | ||||
|         tag: joi | ||||
|             .array() | ||||
|             .allow(null) | ||||
|             .items(joi.string().pattern(/\w+:.+/, { name: 'tag' })) | ||||
|             .optional(), | ||||
|         project: joi | ||||
|             .array() | ||||
|             .allow(null) | ||||
|             .items(joi.string().alphanum()) | ||||
|             .optional(), | ||||
|         namePrefix: joi | ||||
|             .string() | ||||
|             .allow(null) | ||||
|             .optional(), | ||||
|     }) | ||||
|     .options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); | ||||
| 
 | ||||
| module.exports = { featureSchema, strategiesSchema, nameSchema, querySchema }; | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| const test = require('ava'); | ||||
| const { featureSchema } = require('./feature-schema'); | ||||
| const { featureSchema, querySchema } = require('./feature-schema'); | ||||
| 
 | ||||
| test('should require URL firendly name', t => { | ||||
|     const toggle = { | ||||
| @ -227,3 +227,41 @@ test('should not accept empty list of constraint values', t => { | ||||
|         '"strategies[0].constraints[0].values" must contain at least 1 items', | ||||
|     ); | ||||
| }); | ||||
| 
 | ||||
| test('Filter queries should accept a list of tag values', t => { | ||||
|     const query = { | ||||
|         tag: ['simple:valuea', 'simple:valueb'], | ||||
|     }; | ||||
|     const { value } = querySchema.validate(query); | ||||
|     t.deepEqual(value, { tag: ['simple:valuea', 'simple:valueb'] }); | ||||
| }); | ||||
| 
 | ||||
| test('Filter queries should reject tag values with missing type prefix', t => { | ||||
|     const query = { | ||||
|         tag: ['simple', 'simple'], | ||||
|     }; | ||||
|     const { error } = querySchema.validate(query); | ||||
|     t.deepEqual( | ||||
|         error.details[0].message, | ||||
|         '"tag[0]" with value "simple" fails to match the tag pattern', | ||||
|     ); | ||||
| }); | ||||
| 
 | ||||
| test('Filter queries should allow project names', t => { | ||||
|     const query = { | ||||
|         project: ['projecta'], | ||||
|     }; | ||||
|     const { value } = querySchema.validate(query); | ||||
|     t.deepEqual(value, { project: ['projecta'] }); | ||||
| }); | ||||
| 
 | ||||
| test('Filter queries should reject project names that are not alphanum', t => { | ||||
|     const query = { | ||||
|         project: ['project name with space'], | ||||
|     }; | ||||
|     const { error } = querySchema.validate(query); | ||||
|     t.deepEqual( | ||||
|         error.details[0].message, | ||||
|         '"project[0]" must only contain alpha-numeric characters', | ||||
|     ); | ||||
| }); | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| const { FEATURE_TAGGED, FEATURE_UNTAGGED } = require('../event-type'); | ||||
| const { featureSchema, nameSchema } = require('./feature-schema'); | ||||
| const { featureSchema, nameSchema, querySchema } = require('./feature-schema'); | ||||
| const { tagSchema } = require('./tag-schema'); | ||||
| const NameExistsError = require('../error/name-exists-error'); | ||||
| const NotFoundError = require('../error/notfound-error'); | ||||
| @ -12,23 +12,40 @@ const { | ||||
| } = require('../event-type'); | ||||
| 
 | ||||
| class FeatureToggleService { | ||||
|     constructor( | ||||
|         { featureToggleStore, featureTagStore, tagStore, eventStore }, | ||||
|         { getLogger }, | ||||
|     ) { | ||||
|     constructor({ featureToggleStore, tagStore, eventStore }, { getLogger }) { | ||||
|         this.featureToggleStore = featureToggleStore; | ||||
|         this.tagStore = tagStore; | ||||
|         this.eventStore = eventStore; | ||||
|         this.featureTagStore = featureTagStore; | ||||
|         this.logger = getLogger('services/feature-toggle-service.js'); | ||||
|     } | ||||
| 
 | ||||
|     async getFeatures() { | ||||
|         return this.featureToggleStore.getFeatures(); | ||||
|     async getFeatures(query, fields) { | ||||
|         const preppedQuery = await this.prepQuery(query); | ||||
|         return this.featureToggleStore.getFeatures(preppedQuery, fields); | ||||
|     } | ||||
| 
 | ||||
|     async getFeaturesClient() { | ||||
|         return this.featureToggleStore.getFeaturesClient(); | ||||
|     paramToArray(param) { | ||||
|         if (!param) { | ||||
|             return param; | ||||
|         } | ||||
|         return Array.isArray(param) ? param : [param]; | ||||
|     } | ||||
| 
 | ||||
|     async prepQuery({ tag, project, namePrefix }) { | ||||
|         if (!tag && !project && !namePrefix) { | ||||
|             return null; | ||||
|         } | ||||
|         const tagQuery = this.paramToArray(tag); | ||||
|         const projectQuery = this.paramToArray(project); | ||||
|         const query = await querySchema.validateAsync({ | ||||
|             tag: tagQuery, | ||||
|             project: projectQuery, | ||||
|             namePrefix, | ||||
|         }); | ||||
|         if (query.tag) { | ||||
|             query.tag = query.tag.map(q => q.split(':')); | ||||
|         } | ||||
|         return query; | ||||
|     } | ||||
| 
 | ||||
|     async getArchivedFeatures() { | ||||
| @ -92,14 +109,14 @@ class FeatureToggleService { | ||||
| 
 | ||||
|     /** Tag releated  */ | ||||
|     async listTags(featureName) { | ||||
|         return this.featureTagStore.getAllTagsForFeature(featureName); | ||||
|         return this.featureToggleStore.getAllTagsForFeature(featureName); | ||||
|     } | ||||
| 
 | ||||
|     async addTag(featureName, tag, userName) { | ||||
|         await nameSchema.validateAsync({ name: featureName }); | ||||
|         const validatedTag = await tagSchema.validateAsync(tag); | ||||
|         await this.createTagIfNeeded(validatedTag, userName); | ||||
|         await this.featureTagStore.tagFeature(featureName, validatedTag); | ||||
|         await this.featureToggleStore.tagFeature(featureName, validatedTag); | ||||
|         await this.eventStore.store({ | ||||
|             type: FEATURE_TAGGED, | ||||
|             createdBy: userName, | ||||
| @ -129,7 +146,7 @@ class FeatureToggleService { | ||||
|     } | ||||
| 
 | ||||
|     async removeTag(featureName, tag, userName) { | ||||
|         await this.featureTagStore.untagFeature(featureName, tag); | ||||
|         await this.featureToggleStore.untagFeature(featureName, tag); | ||||
|         await this.eventStore.store({ | ||||
|             type: FEATURE_UNTAGGED, | ||||
|             createdBy: userName, | ||||
|  | ||||
| @ -362,3 +362,115 @@ test.serial('can untag feature', async t => { | ||||
|             t.is(res.body.tags.length, 0); | ||||
|         }); | ||||
| }); | ||||
| 
 | ||||
| test.serial('Can get features tagged by tag', async t => { | ||||
|     t.plan(2); | ||||
|     const request = await setupApp(stores); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature2', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     const tag = { value: 'Crazy', type: 'simple' }; | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature/tags') | ||||
|         .send(tag) | ||||
|         .expect(201); | ||||
|     return request | ||||
|         .get('/api/admin/features?tag=simple:Crazy') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 1); | ||||
|             t.is(res.body.features[0].name, 'test.feature'); | ||||
|         }); | ||||
| }); | ||||
| test.serial('Can query for multiple tags using OR', async t => { | ||||
|     t.plan(2); | ||||
|     const request = await setupApp(stores); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature2', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     const tag = { value: 'Crazy', type: 'simple' }; | ||||
|     const tag2 = { value: 'tagb', type: 'simple' }; | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature/tags') | ||||
|         .send(tag) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature2/tags') | ||||
|         .send(tag2) | ||||
|         .expect(201); | ||||
|     return request | ||||
|         .get('/api/admin/features?tag[]=simple:Crazy&tag[]=simple:tagb') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 2); | ||||
|             t.is(res.body.features[0].name, 'test.feature'); | ||||
|         }); | ||||
| }); | ||||
| test.serial('Querying with multiple filters ANDs the filters', async t => { | ||||
|     const request = await setupApp(stores); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature2', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'notestprefix.feature3', | ||||
|         type: 'release', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     const tag = { value: 'Crazy', type: 'simple' }; | ||||
|     const tag2 = { value: 'tagb', type: 'simple' }; | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature/tags') | ||||
|         .send(tag) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature2/tags') | ||||
|         .send(tag2) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .post('/api/admin/features/notestprefix.feature3/tags') | ||||
|         .send(tag) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .get('/api/admin/features?tag=simple:Crazy') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => t.is(res.body.features.length, 2)); | ||||
|     await request | ||||
|         .get('/api/admin/features?namePrefix=test&tag=simple:Crazy') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 1); | ||||
|             t.is(res.body.features[0].name, 'test.feature'); | ||||
|         }); | ||||
| }); | ||||
|  | ||||
| @ -47,7 +47,7 @@ test.serial('gets a feature by name', async t => { | ||||
|         .expect(200); | ||||
| }); | ||||
| 
 | ||||
| test.serial('cant get feature that dose not exist', async t => { | ||||
| test.serial('cant get feature that does not exist', async t => { | ||||
|     t.plan(0); | ||||
|     const request = await setupApp(stores); | ||||
|     return request | ||||
| @ -55,3 +55,66 @@ test.serial('cant get feature that dose not exist', async t => { | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(404); | ||||
| }); | ||||
| 
 | ||||
| test.serial('Can filter features by namePrefix', async t => { | ||||
|     t.plan(2); | ||||
|     const request = await setupApp(stores); | ||||
|     return request | ||||
|         .get('/api/client/features?namePrefix=feature.') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 1); | ||||
|             t.is(res.body.features[0].name, 'feature.with.variants'); | ||||
|         }); | ||||
| }); | ||||
| 
 | ||||
| test.serial('Can use multiple filters', async t => { | ||||
|     t.plan(3); | ||||
|     const request = await setupApp(stores); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'test.feature2', | ||||
|         type: 'killswitch', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     await request.post('/api/admin/features').send({ | ||||
|         name: 'notestprefix.feature3', | ||||
|         type: 'release', | ||||
|         enabled: true, | ||||
|         strategies: [{ name: 'default' }], | ||||
|     }); | ||||
|     const tag = { value: 'Crazy', type: 'simple' }; | ||||
|     const tag2 = { value: 'tagb', type: 'simple' }; | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature/tags') | ||||
|         .send(tag) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .post('/api/admin/features/test.feature2/tags') | ||||
|         .send(tag2) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .post('/api/admin/features/notestprefix.feature3/tags') | ||||
|         .send(tag) | ||||
|         .expect(201); | ||||
|     await request | ||||
|         .get('/api/client/features?tag=simple:Crazy') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => t.is(res.body.features.length, 2)); | ||||
|     await request | ||||
|         .get('/api/client/features?namePrefix=test&tag=simple:Crazy') | ||||
|         .expect('Content-Type', /json/) | ||||
|         .expect(200) | ||||
|         .expect(res => { | ||||
|             t.is(res.body.features.length, 1); | ||||
|             t.is(res.body.features[0].name, 'test.feature'); | ||||
|         }); | ||||
| }); | ||||
|  | ||||
| @ -76,7 +76,7 @@ async function setupDatabase(stores) { | ||||
|     await Promise.all(createApplications(stores.clientApplicationsStore)); | ||||
|     await Promise.all(createProjects(stores.projectStore)); | ||||
|     await Promise.all(createTagTypes(stores.tagTypeStore)); | ||||
|     await tagFeatures(stores.tagStore, stores.featureTagStore); | ||||
|     await tagFeatures(stores.tagStore, stores.featureToggleStore); | ||||
| } | ||||
| 
 | ||||
| module.exports = async function init(databaseSchema = 'test', getLogger) { | ||||
|  | ||||
							
								
								
									
										23
									
								
								test/fixtures/fake-feature-tag-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								test/fixtures/fake-feature-tag-store.js
									
									
									
									
										vendored
									
									
								
							| @ -1,23 +0,0 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| module.exports = () => { | ||||
|     const _featureTags = {}; | ||||
|     return { | ||||
|         tagFeature: (featureName, tag) => { | ||||
|             _featureTags[featureName] = _featureTags[featureName] || []; | ||||
|             _featureTags[featureName].push(tag); | ||||
|         }, | ||||
|         untagFeature: event => { | ||||
|             const tags = _featureTags[event.featureName]; | ||||
|             _featureTags[event.featureName] = tags.splice( | ||||
|                 tags.indexOf( | ||||
|                     t => t.type === event.type && t.value === event.value, | ||||
|                 ), | ||||
|                 1, | ||||
|             ); | ||||
|         }, | ||||
|         getAllTagsForFeature: featureName => { | ||||
|             return _featureTags[featureName] || []; | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
							
								
								
									
										50
									
								
								test/fixtures/fake-feature-toggle-store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										50
									
								
								test/fixtures/fake-feature-toggle-store.js
									
									
									
									
										vendored
									
									
								
							| @ -3,6 +3,8 @@ | ||||
| module.exports = () => { | ||||
|     const _features = []; | ||||
|     const _archive = []; | ||||
|     const _featureTags = {}; | ||||
| 
 | ||||
|     return { | ||||
|         getFeature: name => { | ||||
|             const toggle = _features.find(f => f.name === name); | ||||
| @ -29,8 +31,6 @@ module.exports = () => { | ||||
|             ); | ||||
|             _features.push(updatedFeature); | ||||
|         }, | ||||
|         getFeatures: () => Promise.resolve(_features), | ||||
|         getFeaturesClient: () => Promise.resolve(_features), | ||||
|         createFeature: feature => _features.push(feature), | ||||
|         getArchivedFeatures: () => Promise.resolve(_archive), | ||||
|         addArchivedFeature: feature => _archive.push(feature), | ||||
| @ -55,5 +55,51 @@ module.exports = () => { | ||||
|             _archive.splice(0, _archive.length); | ||||
|         }, | ||||
|         importFeature: feat => Promise.resolve(_features.push(feat)), | ||||
|         getFeatures: query => { | ||||
|             if (query) { | ||||
|                 const activeQueryKeys = Object.keys(query).filter( | ||||
|                     t => query[t], | ||||
|                 ); | ||||
|                 const filtered = _features.filter(feature => { | ||||
|                     return activeQueryKeys.every(key => { | ||||
|                         if (key === 'namePrefix') { | ||||
|                             return feature.name.indexOf(query[key]) > -1; | ||||
|                         } | ||||
|                         if (key === 'tag') { | ||||
|                             return query[key].some(tag => { | ||||
|                                 return ( | ||||
|                                     _featureTags[feature.name] && | ||||
|                                     _featureTags[feature.name].some(t => { | ||||
|                                         return ( | ||||
|                                             t.type === tag[0] && | ||||
|                                             t.value === tag[1] | ||||
|                                         ); | ||||
|                                     }) | ||||
|                                 ); | ||||
|                             }); | ||||
|                         } | ||||
|                         return query[key].some(v => v === feature[key]); | ||||
|                     }); | ||||
|                 }); | ||||
|                 return Promise.resolve(filtered); | ||||
|             } | ||||
|             return Promise.resolve(_features); | ||||
|         }, | ||||
|         tagFeature: (featureName, tag) => { | ||||
|             _featureTags[featureName] = _featureTags[featureName] || []; | ||||
|             _featureTags[featureName].push(tag); | ||||
|         }, | ||||
|         untagFeature: event => { | ||||
|             const tags = _featureTags[event.featureName]; | ||||
|             _featureTags[event.featureName] = tags.splice( | ||||
|                 tags.indexOf( | ||||
|                     t => t.type === event.type && t.value === event.value, | ||||
|                 ), | ||||
|                 1, | ||||
|             ); | ||||
|         }, | ||||
|         getAllTagsForFeature: featureName => { | ||||
|             return _featureTags[featureName] || []; | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										2
									
								
								test/fixtures/store.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								test/fixtures/store.js
									
									
									
									
										vendored
									
									
								
							| @ -4,7 +4,6 @@ const ClientMetricsStore = require('./fake-metrics-store'); | ||||
| const clientInstanceStore = require('./fake-client-instance-store'); | ||||
| const clientApplicationsStore = require('./fake-client-applications-store'); | ||||
| const featureToggleStore = require('./fake-feature-toggle-store'); | ||||
| const featureTagStore = require('./fake-feature-tag-store'); | ||||
| const tagStore = require('./fake-tag-store'); | ||||
| const eventStore = require('./fake-event-store'); | ||||
| const strategyStore = require('./fake-strategies-store'); | ||||
| @ -25,7 +24,6 @@ module.exports = { | ||||
|             clientMetricsStore: new ClientMetricsStore(), | ||||
|             clientInstanceStore: clientInstanceStore(), | ||||
|             featureToggleStore: featureToggleStore(), | ||||
|             featureTagStore: featureTagStore(), | ||||
|             tagStore: tagStore(), | ||||
|             eventStore: eventStore(), | ||||
|             strategyStore: strategyStore(), | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user