From e555118cb19238c8fb063d6b0be036b049f3fda6 Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Fri, 22 Jan 2021 13:39:42 +0100 Subject: [PATCH] feat: Add filterquery support for toggles - For now supports - tag - project - namePrefix fixes: #690 --- CHANGELOG.md | 4 + docs/api/admin/feature-toggles-api.md | 22 ++++ docs/api/client/feature-toggles-api.md | 22 +++- lib/db/feature-tag-store.js | 82 ------------- lib/db/feature-toggle-store.js | 136 ++++++++++++++------- lib/db/index.js | 2 - lib/routes/admin-api/feature.js | 17 ++- lib/routes/admin-api/feature.test.js | 107 ++++++++++++++-- lib/routes/client-api/feature.js | 19 ++- lib/routes/client-api/feature.test.js | 19 ++- lib/routes/client-api/util.js | 10 -- lib/services/feature-schema.js | 22 +++- lib/services/feature-schema.test.js | 40 +++++- lib/services/feature-toggle-service.js | 43 +++++-- test/e2e/api/admin/feature.e2e.test.js | 112 +++++++++++++++++ test/e2e/api/client/feature.e2e.test.js | 65 +++++++++- test/e2e/helpers/database-init.js | 2 +- test/fixtures/fake-feature-tag-store.js | 23 ---- test/fixtures/fake-feature-toggle-store.js | 50 +++++++- test/fixtures/store.js | 2 - 20 files changed, 600 insertions(+), 199 deletions(-) delete mode 100644 lib/db/feature-tag-store.js delete mode 100644 lib/routes/client-api/util.js delete mode 100644 test/fixtures/fake-feature-tag-store.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e4935549..78712c9529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/api/admin/feature-toggles-api.md b/docs/api/admin/feature-toggles-api.md index 5d4bbcfa30..de308597bc 100644 --- a/docs/api/admin/feature-toggles-api.md +++ b/docs/api/admin/feature-toggles-api.md @@ -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. diff --git a/docs/api/client/feature-toggles-api.md b/docs/api/client/feature-toggles-api.md index 81146cdd71..bc99b8caaf 100644 --- a/docs/api/client/feature-toggles-api.md +++ b/docs/api/client/feature-toggles-api.md @@ -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` diff --git a/lib/db/feature-tag-store.js b/lib/db/feature-tag-store.js deleted file mode 100644 index cc4aaffa24..0000000000 --- a/lib/db/feature-tag-store.js +++ /dev/null @@ -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; diff --git a/lib/db/feature-toggle-store.js b/lib/db/feature-toggle-store.js index b18b562802..6e12697210 100644 --- a/lib/db/feature-toggle-store.js +++ b/lib/db/feature-toggle-store.js @@ -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; diff --git a/lib/db/index.js b/lib/db/index.js index 5cc48e800b..310f89a200 100644 --- a/lib/db/index.js +++ b/lib/db/index.js @@ -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), }; }; diff --git a/lib/routes/admin-api/feature.js b/lib/routes/admin-api/feature.js index 070c4de7ea..bf751e9537 100644 --- a/lib/routes/admin-api/feature.js +++ b/lib/routes/admin-api/feature.js @@ -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 }); } diff --git a/lib/routes/admin-api/feature.test.js b/lib/routes/admin-api/feature.test.js index 099a41f62d..3e75e4d34c 100644 --- a/lib/routes/admin-api/feature.test.js +++ b/lib/routes/admin-api/feature.test.js @@ -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'); + }); +}); diff --git a/lib/routes/client-api/feature.js b/lib/routes/client-api/feature.js index 08a49b5a08..f2d30634cf 100644 --- a/lib/routes/client-api/feature.js +++ b/lib/routes/client-api/feature.js @@ -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 }); } diff --git a/lib/routes/client-api/feature.test.js b/lib/routes/client-api/feature.test.js index aab50bea56..b3222f7caf 100644 --- a/lib/routes/client-api/feature.test.js +++ b/lib/routes/client-api/feature.test.js @@ -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'); }); }); diff --git a/lib/routes/client-api/util.js b/lib/routes/client-api/util.js deleted file mode 100644 index 3fbe38a9d1..0000000000 --- a/lib/routes/client-api/util.js +++ /dev/null @@ -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, -}; diff --git a/lib/services/feature-schema.js b/lib/services/feature-schema.js index 78b8376e8d..1b77ccb0e2 100644 --- a/lib/services/feature-schema.js +++ b/lib/services/feature-schema.js @@ -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 }; diff --git a/lib/services/feature-schema.test.js b/lib/services/feature-schema.test.js index d3f1c70bfd..9ee31f61cf 100644 --- a/lib/services/feature-schema.test.js +++ b/lib/services/feature-schema.test.js @@ -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', + ); +}); diff --git a/lib/services/feature-toggle-service.js b/lib/services/feature-toggle-service.js index aeba89a431..85a3b5dc18 100644 --- a/lib/services/feature-toggle-service.js +++ b/lib/services/feature-toggle-service.js @@ -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, diff --git a/test/e2e/api/admin/feature.e2e.test.js b/test/e2e/api/admin/feature.e2e.test.js index 5beff85b43..dce380f733 100644 --- a/test/e2e/api/admin/feature.e2e.test.js +++ b/test/e2e/api/admin/feature.e2e.test.js @@ -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'); + }); +}); diff --git a/test/e2e/api/client/feature.e2e.test.js b/test/e2e/api/client/feature.e2e.test.js index a6373b6052..4417b66908 100644 --- a/test/e2e/api/client/feature.e2e.test.js +++ b/test/e2e/api/client/feature.e2e.test.js @@ -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'); + }); +}); diff --git a/test/e2e/helpers/database-init.js b/test/e2e/helpers/database-init.js index 8165683fd6..c4430a5e25 100644 --- a/test/e2e/helpers/database-init.js +++ b/test/e2e/helpers/database-init.js @@ -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) { diff --git a/test/fixtures/fake-feature-tag-store.js b/test/fixtures/fake-feature-tag-store.js deleted file mode 100644 index e0b9a9f989..0000000000 --- a/test/fixtures/fake-feature-tag-store.js +++ /dev/null @@ -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] || []; - }, - }; -}; diff --git a/test/fixtures/fake-feature-toggle-store.js b/test/fixtures/fake-feature-toggle-store.js index 67d8477646..5bca38c3c9 100644 --- a/test/fixtures/fake-feature-toggle-store.js +++ b/test/fixtures/fake-feature-toggle-store.js @@ -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] || []; + }, }; }; diff --git a/test/fixtures/store.js b/test/fixtures/store.js index fdd4509dad..536fd48e0e 100644 --- a/test/fixtures/store.js +++ b/test/fixtures/store.js @@ -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(),