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
## 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)

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

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',
'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;

View File

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

View File

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

View File

@ -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');
});
});

View File

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

View File

@ -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');
});
});

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 });
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';
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',
);
});

View File

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

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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) {

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 = () => {
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] || [];
},
};
};

View File

@ -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(),