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:
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