1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

Merge pull request #693 from Unleash/feat-690-toggle-filtering

feat: #690 toggle filtering
This commit is contained in:
Christopher Kolstad 2021-01-26 14:35:15 +01:00 committed by GitHub
commit 00c7d634d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 600 additions and 199 deletions

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
## 3.11
- feat: Add support for filtering toggles on tags, projects or namePrefix (#690)
## 3.10.1 ## 3.10.1
- fix: remove fields from /api/client/features respnse (#692) - fix: remove fields from /api/client/features respnse (#692)

View File

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

View File

@ -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` `GET: http://unleash.host.com/api/client/features/:featureName`

View File

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

View File

@ -16,16 +16,15 @@ const FEATURE_COLUMNS = [
'created_at', 'created_at',
'last_seen_at', 'last_seen_at',
]; ];
const TABLE = 'features';
const FEATURE_COLUMNS_CLIENT = [ const mapperToColumnNames = {
'name', createdAt: 'created_at',
'type', lastSeenAt: 'last_seen_at',
'enabled', };
'stale', const TABLE = 'features';
'strategies', const FEATURE_TAG_COLUMNS = ['feature_name', 'tag_type', 'tag_value'];
'variants', const FEATURE_TAG_FILTER_COLUMNS = ['tag_type', 'tag_value'];
]; const FEATURE_TAG_TABLE = 'feature_tag';
class FeatureToggleStore { class FeatureToggleStore {
constructor(db, eventBus, getLogger) { constructor(db, eventBus, getLogger) {
@ -38,43 +37,36 @@ class FeatureToggleStore {
}); });
} }
async getFeatures() { async getFeatures(query = {}, fields = FEATURE_COLUMNS) {
const stopTimer = this.timer('getAll'); const stopTimer = this.timer('getAll');
const queryFields = fields.map(f =>
const rows = await this.db mapperToColumnNames[f] ? mapperToColumnNames[f] : f,
.select(FEATURE_COLUMNS) );
let baseQuery = this.db
.select(queryFields)
.from(TABLE) .from(TABLE)
.where({ archived: 0 }) .where({ archived: 0 })
.orderBy('name', 'asc'); .orderBy('name', 'asc');
if (query) {
stopTimer(); if (query.tag) {
const tagQuery = this.db
return rows.map(this.rowToFeature); .from('feature_tag')
} .select('feature_name')
.whereIn(FEATURE_TAG_FILTER_COLUMNS, query.tag);
async getFeaturesClient() { baseQuery = baseQuery.whereIn('name', tagQuery);
const stopTimer = this.timer('getAllClient'); }
if (query.project) {
const rows = await this.db baseQuery = baseQuery.whereIn('project', query.project);
.select(FEATURE_COLUMNS_CLIENT) }
.from(TABLE) if (query.namePrefix) {
.where({ archived: 0 }) baseQuery = baseQuery.where(
.orderBy('name', 'asc'); 'name',
'like',
stopTimer(); `${query.namePrefix}%`,
);
return rows.map(this.rowToFeature); }
} }
const rows = await baseQuery;
async getClientFeatures() {
const stopTimer = this.timer('getAll');
const rows = await this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
.where({ archived: 0 })
.orderBy('name', 'asc');
stopTimer(); stopTimer();
return rows.map(this.rowToFeature); return rows.map(this.rowToFeature);
@ -233,6 +225,66 @@ class FeatureToggleStore {
this.logger.error('Could not drop features, error: ', err); 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; module.exports = FeatureToggleStore;

View File

@ -14,7 +14,6 @@ const SettingStore = require('./setting-store');
const UserStore = require('./user-store'); const UserStore = require('./user-store');
const ProjectStore = require('./project-store'); const ProjectStore = require('./project-store');
const TagStore = require('./tag-store'); const TagStore = require('./tag-store');
const FeatureTagStore = require('./feature-tag-store');
const TagTypeStore = require('./tag-type-store'); const TagTypeStore = require('./tag-type-store');
module.exports.createStores = (config, eventBus) => { module.exports.createStores = (config, eventBus) => {
@ -51,6 +50,5 @@ module.exports.createStores = (config, eventBus) => {
projectStore: new ProjectStore(db, getLogger), projectStore: new ProjectStore(db, getLogger),
tagStore: new TagStore(db, eventBus, getLogger), tagStore: new TagStore(db, eventBus, getLogger),
tagTypeStore: new TagTypeStore(db, eventBus, getLogger), tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
featureTagStore: new FeatureTagStore(db, eventBus, getLogger),
}; };
}; };

View File

@ -9,6 +9,18 @@ const {
} = require('../../permissions'); } = require('../../permissions');
const version = 1; const version = 1;
const fields = [
'name',
'description',
'type',
'project',
'enabled',
'stale',
'strategies',
'variants',
'createdAt',
'lastSeenAt',
];
class FeatureController extends Controller { class FeatureController extends Controller {
constructor(config, { featureToggleService }) { constructor(config, { featureToggleService }) {
@ -37,7 +49,10 @@ class FeatureController extends Controller {
} }
async getAllToggles(req, res) { async getAllToggles(req, res) {
const features = await this.featureService.getFeatures(); const features = await this.featureService.getFeatures(
req.query,
fields,
);
res.json({ version, features }); res.json({ version, features });
} }

View File

@ -31,7 +31,6 @@ function getSetup() {
base, base,
perms, perms,
featureToggleStore: stores.featureToggleStore, featureToggleStore: stores.featureToggleStore,
featureTagStore: stores.featureTagStore,
request: supertest(app), 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 => { test('should be able to get tags for feature', t => {
t.plan(1); t.plan(1);
const { const { request, featureToggleStore, base, perms } = getSetup();
request,
featureToggleStore,
featureTagStore,
base,
perms,
} = getSetup();
perms.withPermissions(UPDATE_FEATURE); perms.withPermissions(UPDATE_FEATURE);
featureToggleStore.createFeature({ featureToggleStore.createFeature({
@ -398,7 +391,7 @@ test('should be able to get tags for feature', t => {
strategies: [{ name: 'default' }], strategies: [{ name: 'default' }],
}); });
featureTagStore.tagFeature('toggle.disabled', { featureToggleStore.tagFeature('toggle.disabled', {
value: 'TeamGreen', value: 'TeamGreen',
type: 'simple', 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'); 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');
});
});

View File

@ -1,10 +1,18 @@
'use strict'; 'use strict';
const Controller = require('../controller'); const Controller = require('../controller');
const { filter } = require('./util');
const version = 1; const version = 1;
const FEATURE_COLUMNS_CLIENT = [
'name',
'type',
'enabled',
'stale',
'strategies',
'variants',
];
class FeatureController extends Controller { class FeatureController extends Controller {
constructor({ featureToggleService }, getLogger) { constructor({ featureToggleService }, getLogger) {
super(); super();
@ -15,11 +23,10 @@ class FeatureController extends Controller {
} }
async getAll(req, res) { async getAll(req, res) {
const nameFilter = filter('name', req.query.namePrefix); const features = await this.toggleService.getFeatures(
req.query,
const allFeatureToggles = await this.toggleService.getFeaturesClient(); FEATURE_COLUMNS_CLIENT,
const features = nameFilter(allFeatureToggles); );
res.json({ version, features }); res.json({ version, features });
} }

View File

@ -72,7 +72,22 @@ test('support name prefix', t => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect(res => { .expect(res => {
t.true(res.body.features.length === 2); t.is(res.body.features.length, 2);
t.true(res.body.features[1].name === 'b_test2'); 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');
}); });
}); });

View File

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

View File

@ -88,4 +88,24 @@ const featureSchema = joi
}) })
.options({ allowUnknown: false, stripUnknown: true, abortEarly: false }); .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 };

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
const test = require('ava'); const test = require('ava');
const { featureSchema } = require('./feature-schema'); const { featureSchema, querySchema } = require('./feature-schema');
test('should require URL firendly name', t => { test('should require URL firendly name', t => {
const toggle = { 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', '"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',
);
});

View File

@ -1,5 +1,5 @@
const { FEATURE_TAGGED, FEATURE_UNTAGGED } = require('../event-type'); 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 { tagSchema } = require('./tag-schema');
const NameExistsError = require('../error/name-exists-error'); const NameExistsError = require('../error/name-exists-error');
const NotFoundError = require('../error/notfound-error'); const NotFoundError = require('../error/notfound-error');
@ -12,23 +12,40 @@ const {
} = require('../event-type'); } = require('../event-type');
class FeatureToggleService { class FeatureToggleService {
constructor( constructor({ featureToggleStore, tagStore, eventStore }, { getLogger }) {
{ featureToggleStore, featureTagStore, tagStore, eventStore },
{ getLogger },
) {
this.featureToggleStore = featureToggleStore; this.featureToggleStore = featureToggleStore;
this.tagStore = tagStore; this.tagStore = tagStore;
this.eventStore = eventStore; this.eventStore = eventStore;
this.featureTagStore = featureTagStore;
this.logger = getLogger('services/feature-toggle-service.js'); this.logger = getLogger('services/feature-toggle-service.js');
} }
async getFeatures() { async getFeatures(query, fields) {
return this.featureToggleStore.getFeatures(); const preppedQuery = await this.prepQuery(query);
return this.featureToggleStore.getFeatures(preppedQuery, fields);
} }
async getFeaturesClient() { paramToArray(param) {
return this.featureToggleStore.getFeaturesClient(); 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() { async getArchivedFeatures() {
@ -92,14 +109,14 @@ class FeatureToggleService {
/** Tag releated */ /** Tag releated */
async listTags(featureName) { async listTags(featureName) {
return this.featureTagStore.getAllTagsForFeature(featureName); return this.featureToggleStore.getAllTagsForFeature(featureName);
} }
async addTag(featureName, tag, userName) { async addTag(featureName, tag, userName) {
await nameSchema.validateAsync({ name: featureName }); await nameSchema.validateAsync({ name: featureName });
const validatedTag = await tagSchema.validateAsync(tag); const validatedTag = await tagSchema.validateAsync(tag);
await this.createTagIfNeeded(validatedTag, userName); await this.createTagIfNeeded(validatedTag, userName);
await this.featureTagStore.tagFeature(featureName, validatedTag); await this.featureToggleStore.tagFeature(featureName, validatedTag);
await this.eventStore.store({ await this.eventStore.store({
type: FEATURE_TAGGED, type: FEATURE_TAGGED,
createdBy: userName, createdBy: userName,
@ -129,7 +146,7 @@ class FeatureToggleService {
} }
async removeTag(featureName, tag, userName) { async removeTag(featureName, tag, userName) {
await this.featureTagStore.untagFeature(featureName, tag); await this.featureToggleStore.untagFeature(featureName, tag);
await this.eventStore.store({ await this.eventStore.store({
type: FEATURE_UNTAGGED, type: FEATURE_UNTAGGED,
createdBy: userName, createdBy: userName,

View File

@ -362,3 +362,115 @@ test.serial('can untag feature', async t => {
t.is(res.body.tags.length, 0); 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');
});
});

View File

@ -47,7 +47,7 @@ test.serial('gets a feature by name', async t => {
.expect(200); .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); t.plan(0);
const request = await setupApp(stores); const request = await setupApp(stores);
return request return request
@ -55,3 +55,66 @@ test.serial('cant get feature that dose not exist', async t => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(404); .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');
});
});

View File

@ -76,7 +76,7 @@ async function setupDatabase(stores) {
await Promise.all(createApplications(stores.clientApplicationsStore)); await Promise.all(createApplications(stores.clientApplicationsStore));
await Promise.all(createProjects(stores.projectStore)); await Promise.all(createProjects(stores.projectStore));
await Promise.all(createTagTypes(stores.tagTypeStore)); 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) { module.exports = async function init(databaseSchema = 'test', getLogger) {

View File

@ -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] || [];
},
};
};

View File

@ -3,6 +3,8 @@
module.exports = () => { module.exports = () => {
const _features = []; const _features = [];
const _archive = []; const _archive = [];
const _featureTags = {};
return { return {
getFeature: name => { getFeature: name => {
const toggle = _features.find(f => f.name === name); const toggle = _features.find(f => f.name === name);
@ -29,8 +31,6 @@ module.exports = () => {
); );
_features.push(updatedFeature); _features.push(updatedFeature);
}, },
getFeatures: () => Promise.resolve(_features),
getFeaturesClient: () => Promise.resolve(_features),
createFeature: feature => _features.push(feature), createFeature: feature => _features.push(feature),
getArchivedFeatures: () => Promise.resolve(_archive), getArchivedFeatures: () => Promise.resolve(_archive),
addArchivedFeature: feature => _archive.push(feature), addArchivedFeature: feature => _archive.push(feature),
@ -55,5 +55,51 @@ module.exports = () => {
_archive.splice(0, _archive.length); _archive.splice(0, _archive.length);
}, },
importFeature: feat => Promise.resolve(_features.push(feat)), 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] || [];
},
}; };
}; };

View File

@ -4,7 +4,6 @@ const ClientMetricsStore = require('./fake-metrics-store');
const clientInstanceStore = require('./fake-client-instance-store'); const clientInstanceStore = require('./fake-client-instance-store');
const clientApplicationsStore = require('./fake-client-applications-store'); const clientApplicationsStore = require('./fake-client-applications-store');
const featureToggleStore = require('./fake-feature-toggle-store'); const featureToggleStore = require('./fake-feature-toggle-store');
const featureTagStore = require('./fake-feature-tag-store');
const tagStore = require('./fake-tag-store'); const tagStore = require('./fake-tag-store');
const eventStore = require('./fake-event-store'); const eventStore = require('./fake-event-store');
const strategyStore = require('./fake-strategies-store'); const strategyStore = require('./fake-strategies-store');
@ -25,7 +24,6 @@ module.exports = {
clientMetricsStore: new ClientMetricsStore(), clientMetricsStore: new ClientMetricsStore(),
clientInstanceStore: clientInstanceStore(), clientInstanceStore: clientInstanceStore(),
featureToggleStore: featureToggleStore(), featureToggleStore: featureToggleStore(),
featureTagStore: featureTagStore(),
tagStore: tagStore(), tagStore: tagStore(),
eventStore: eventStore(), eventStore: eventStore(),
strategyStore: strategyStore(), strategyStore: strategyStore(),