1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

Add Tags and tag types

- First iteration of api for tags and tag-types
- Documentation in place
- Adds three new tables
   - tag_types
   - tags
   - feature_tag
- Tagging a feature is adding a row in the feature_tag
  join table

* #665

Co-authored-by: Simen Bekkhus <sbekkhus91@gmail.com>
Co-authored-by: Ivar Conradi Østhus <ivarconr@gmail.com>
This commit is contained in:
Christopher Kolstad 2021-01-04 10:29:33 +01:00
parent a26dea661d
commit 43801f1f13
No known key found for this signature in database
GPG Key ID: 319DE53FE911815A
32 changed files with 2662 additions and 932 deletions

View File

@ -38,6 +38,13 @@ This endpoint is the one all admin ui should use to fetch all available feature
"name": "variant2",
"weight": 50
}
],
"tags": [
{
"id": 1,
"type": "simple",
"value": "TeamRed"
}
]
},
{
@ -59,7 +66,8 @@ This endpoint is the one all admin ui should use to fetch all available feature
}
}
],
"variants": []
"variants": [],
"tags": []
}
]
}
@ -82,7 +90,8 @@ Used to fetch details about a specific featureToggle. This is mostly provded to
"parameters": {}
}
],
"variants": []
"variants": [],
"tags": []
}
```
@ -144,6 +153,47 @@ Used by the admin dashboard to update a feature toggles. The name has to match a
Returns 200-respose if the feature toggle was updated successfully.
### Tag a Feature Toggle
`POST https://unleash.host.com/api/admin/features/:featureName/tags`
Used to tag a feature
If the tuple (type, value) does not already exist, it will be added to the list of tags. Then Unleash will add a relation between the feature name and the tag.
**Body:**
```json
{
"type": "simple",
"value": "Team-Green"
}
```
## Success
- Returns _201-CREATED_ if the feature was tagged successfully
- Creates the tag if needed, then connects the tag to the existing feature
## Failures
- Returns _404-NOT-FOUND_ if the `type` was not found
### Remove a tag from a Feature Toggle
`DELETE https://unleash.host.com/api/admin/features/:featureName/tags/:type/:value`
Removes the specified tag from the `(type, value)` tuple from the Feature Toggle's list of tags.
## Success
- Returns _200-OK_
## Failures
- Returns 404 if the tag does not exist
- Returns 500 if the database could not be reached
### Archive a Feature Toggle
`DELETE: http://unleash.host.com/api/admin/features/:toggleName`
@ -174,7 +224,8 @@ None
"parameters": {}
}
],
"variants": []
"variants": [],
"tags": []
}
```
@ -203,7 +254,8 @@ None
"parameters": {}
}
],
"variants": []
"variants": [],
"tags": []
}
```
@ -232,7 +284,8 @@ None
"parameters": {}
}
],
"variants": []
"variants": [],
"tags": []
}
```
@ -261,7 +314,8 @@ None
"parameters": {}
}
],
"variants": []
"variants": [],
"tags": []
}
```
@ -292,6 +346,7 @@ Used to fetch list of archived feature toggles
}
],
"variants": [],
"tags": [],
"strategy": "default",
"parameters": {}
}

179
docs/api/admin/tags-api.md Normal file
View File

@ -0,0 +1,179 @@
---
id: tags
title: /api/admin/tags
---
> In order to access the admin API endpoints you need to identify yourself. If you are using the `insecure` authentication method, you may use [basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) to identify yourself.
### Create a new tag
`POST https://unleash.host.com/api/admin/tags`
Creates a new tag without connecting it to any other object, can be helpful to build an autocomplete list.
**Body**
```json
{
"value": "MyTag",
"type": "simple"
}
```
### Notes
- `type` must exist in tag-types
### List tags
`GET https://unleash.host.com/api/admin/tags`
This endpoint is the one all admin UIs should use to fetch all available tags from the _unleash_server_. The response returns all tags.
**Example response:**
```json
{
"version": 1,
"tags": [
{
"value": "Team-Red",
"type": "simple"
},
{
"value": "Team-Green",
"type": "simple"
},
{
"value": "DecemberExperiment",
"type": "simple"
},
{
"value": "#team-alert-channel",
"type": "slack"
}
]
}
```
### List tags by type
`GET: https://unleash.host.com/api/admin/tags/:type`
Lists all tags of `:type`. If none exist, returns the empty list
**Example response to query for https://unleash.host.com/api/admin/tags/simple**
```json
{
"version": 1,
"tags": [
{
"value": "Team-Red",
"type": "simple"
},
{
"value": "Team-Green",
"type": "simple"
},
{
"value": "DecemberExperiment",
"type": "simple"
}
]
}
```
### Get a single tag
`GET https://unleash.host.com/api/admin/tags/:type/:value`
Gets the tag defined by the `type, value` tuple
### Delete a tag
`DELETE https://unleash.host.com/api/admin/tags/:type/:value`
Deletes the tag defined by the `type, value` tuple; all features tagged with this tag will lose the tag.
### Fetching Tag types
`GET: https://unleash.host.com/api/admin/tag-types`
Used to fetch all types the server knows about. This endpoint is the one all admin UI should use to fetch all available tag types from the _unleash-server_. The response returns all tag types. Any server will have _at least_ one configured tag type (the `simple` type). A tag type will be a map of `type`, `description`, `icon`
**Example response:**
```json
{
"version": 1,
"tagTypes": [
{
"name": "simple",
"description": "Arbitrary tags. Used to simplify filtering of features",
"icon": "#"
}
]
}
```
### Get a single tag type
`GET: https://unleash.host.com/api/admin/tag-types/simple`
Used to fetch details about a specific tag-type. This is mostly provded to make it easy to debug the API and should not be used by the client implementations.
**Example response:**
```json
{
"version": 1,
"tagType": {
"name": "simple",
"description": "Some description",
"icon": "Some icon",
"createdAt": "2021-01-07T10:00:00Z"
}
}
```
### Create a new tag type
`POST: https://unleash.host.com/api/admin/tag-types`
Used to register a new tag type. This endpoint should be used to inform the server about a new type of tags.
**Body:**
```json
{
"name": "tagtype",
"description": "Purpose of tag type",
"icon": "Either an URL to icon or a simple prefix string for tag"
}
```
**Notes:**
- if `name` is not unique, will return 409 CONFLICT, if you'd like to update an existing tag through admin-api look at [Update tag type](#Update-tag-type).
Returns 201-CREATED if the tag type was created successfully
### Update tag type
`PUT: https://unleash.host.com/api/admin/tag-types/:typeName`
**Body:**
```json
{
"description": "New description",
"icon": "New icon"
}
```
### Deleting a tag type
`DELETE: https://unleash.host.com/api/admin/tag-types/:typeName`
Returns 200 if the type was not in use and the type was deleted. If the type was in use, will return a _409 CONFLICT_

26
docs/tags.md Normal file
View File

@ -0,0 +1,26 @@
---
id: tags
title: Tagging Features
---
> This feature was introduced in Unleash vx.y.z
Do you want to filter your features to avoid having to see all features belonging to other teams than your own? Do you want to write a plugin that only gets notified about changes to features that your plugin knows how to handle?
### Say hello to Typed tags
Unleash supports tagging features with an arbitrary number of tags. This eases filtering the list of tags to only those features that are tagged with the tag you're interested in.
#### How does it work?
Unleash will allow users to tag any feature with any number of tags. When viewing a feature, the UI will/may display all tags connected to that feature.
When adding a new tag, a dropdown will show you which type of tag you're about to add. Our first type; `simple` are meant to be used for filtering features. Show only features that have a tag of `MyTeam`.
#### Tag types
Types can be anything, and their purpose is to add some semantics to the tag itself.
Some tag types will be defined by plugins (e.g. the slack plugin can define the slack-type, to make it easy to specify which slack channels to post updates for a specific feature toggle to).
Other tags can be defined by the user to give semantic logic to the management of the UI. It could be that you want to use tag functionality to specify which products a feature toggle belongs to, or to which teams.

9
lib/command-type.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
TAG_FEATURE: 'tag-feature',
UNTAG_FEATURE: 'untag-feature',
CREATE_TAG: 'create-tag',
DELETE_TAG: 'delete-tag',
CREATE_TAG_TYPE: 'create-tag-type',
DELETE_TAG_TYPE: 'delete-tag-type',
UPDATE_TAG_TYPE: 'update-tag-type',
};

218
lib/db/feature-tag-store.js Normal file
View File

@ -0,0 +1,218 @@
'use strict';
const metricsHelper = require('../metrics-helper');
const { DB_TIME } = require('../events');
const {
TAG_CREATED,
TAG_DELETED,
FEATURE_TAGGED,
FEATURE_UNTAGGED,
} = require('../event-type');
const { CREATE_TAG, DELETE_TAG } = require('../command-type');
const NotFoundError = require('../error/notfound-error');
const COLUMNS = ['type', 'value'];
const FEATURE_TAG_COLUMNS = ['feature_name', 'tag_type', 'tag_value'];
const TABLE = 'tags';
const FEATURE_TAG_TABLE = 'feature_tag';
class FeatureTagStore {
constructor(db, eventStore, eventBus, getLogger) {
this.db = db;
this.eventStore = eventStore;
this.logger = getLogger('feature-tag-store.js');
this.timer = action =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'tag',
action,
});
eventStore.on(CREATE_TAG, event => this._createTag(event.data));
eventStore.on(DELETE_TAG, event => this._deleteTag(event.data));
}
async getTags() {
const stopTimer = this.timer('getTags');
const rows = await this.db.select(COLUMNS).from(TABLE);
stopTimer();
return rows.map(this.rowToTag);
}
async getAllOfType(type) {
const stopTimer = this.timer('getAllOfType');
const rows = await this.db
.select(COLUMNS)
.from(TABLE)
.where({ type });
stopTimer();
return rows.map(this.rowToTag());
}
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 getTagByTypeAndValue(type, value) {
const stopTimer = this.timer('getTagByTypeAndValue');
const row = await this.db
.first(COLUMNS)
.from(TABLE)
.where({ type, value });
stopTimer();
if (!row) {
throw new NotFoundError('Could not find this tag');
}
return this.rowToTag(row);
}
async hasTag(tag) {
const stopTimer = this.timer('hasTag');
const row = await this.db
.first(COLUMNS)
.from(TABLE)
.where({
type: tag.type,
value: tag.value,
});
stopTimer();
if (!row) {
throw new NotFoundError('No tag found');
}
return this.rowToTag(row);
}
async tagFeature(event) {
const stopTimer = this.timer('tagFeature');
const tag = this.eventDataToRow(event);
try {
await this.hasTag(tag);
} catch (err) {
if (err instanceof NotFoundError) {
this.logger.info(`Tag ${tag} did not exist. Creating.`);
await this._createTag(tag);
} else {
this.logger.debug('Already existed');
}
}
await this.db(FEATURE_TAG_TABLE)
.insert({
feature_name: event.featureName,
tag_type: tag.type,
tag_value: tag.value,
})
.onConflict(['feature_name', 'tag_type', 'tag_value'])
.ignore();
stopTimer();
await this.eventStore.store({
type: FEATURE_TAGGED,
createdBy: event.createdBy || 'unleash-system',
data: {
featureName: event.featureName,
tag,
},
});
return tag;
}
async untagFeature(event) {
const stopTimer = this.timer('untagFeature');
try {
await this.db(FEATURE_TAG_TABLE)
.where({
feature_name: event.featureName,
tag_type: event.tagType,
tag_value: event.tagValue,
})
.delete();
await this.eventStore.store({
type: FEATURE_UNTAGGED,
createdBy: event.createdBy || 'unleash-system',
data: {
featureName: event.featureName,
tag: {
value: event.tagValue,
type: event.tagType,
},
},
});
} catch (err) {
this.logger.error(err);
}
stopTimer();
}
async _createTag(event) {
const stopTimer = this.timer('createTag');
await this.db(TABLE)
.insert(this.eventDataToRow(event))
.onConflict(['type', 'value'])
.ignore();
await this.eventStore.store({
type: TAG_CREATED,
createdBy: event.createdBy || 'unleash-system',
data: {
value: event.value,
type: event.type,
},
});
stopTimer();
return { value: event.value, type: event.type };
}
async _deleteTag(event) {
const stopTimer = this.timer('deleteTag');
const tag = this.eventDataToRow(event);
try {
await this.db(TABLE)
.where(tag)
.del();
await this.eventStore.store({
type: TAG_DELETED,
createdBy: event.createdBy || 'unleash-system',
data: tag,
});
} catch (err) {
this.logger.error('Could not delete tag, 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;
}
eventDataToRow(event) {
return {
value: event.value,
type: event.type,
};
}
}
module.exports = FeatureTagStore;

View File

@ -30,7 +30,6 @@ class FeatureToggleStore {
constructor(db, eventStore, eventBus, getLogger) {
this.db = db;
this.logger = getLogger('feature-toggle-store.js');
this.timer = action =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-toggle',

View File

@ -13,6 +13,8 @@ const ContextFieldStore = require('./context-field-store');
const SettingStore = require('./setting-store');
const UserStore = require('./user-store');
const ProjectStore = require('./project-store');
const FeatureTagStore = require('./feature-tag-store');
const TagTypeStore = require('./tag-type-store');
module.exports.createStores = (config, eventBus) => {
const { getLogger } = config;
@ -51,5 +53,12 @@ module.exports.createStores = (config, eventBus) => {
settingStore: new SettingStore(db, getLogger),
userStore: new UserStore(db, getLogger),
projectStore: new ProjectStore(db, getLogger),
featureTagStore: new FeatureTagStore(
db,
eventStore,
eventBus,
getLogger,
),
tagTypeStore: new TagTypeStore(db, eventStore, eventBus, getLogger),
};
};

128
lib/db/tag-type-store.js Normal file
View File

@ -0,0 +1,128 @@
'use strict';
const metricsHelper = require('../metrics-helper');
const {
CREATE_TAG_TYPE,
DELETE_TAG_TYPE,
UPDATE_TAG_TYPE,
} = require('../command-type');
const { TAG_TYPE_CREATED, TAG_TYPE_DELETED } = require('../event-type');
const { DB_TIME } = require('../events');
const NotFoundError = require('../error/notfound-error');
const COLUMNS = ['name', 'description', 'icon'];
const TABLE = 'tag_types';
class TagTypeStore {
constructor(db, eventStore, eventBus, getLogger) {
this.db = db;
this.eventStore = eventStore;
this.logger = getLogger('tag-type-store.js');
this.timer = action =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'tag-type',
action,
});
eventStore.on(CREATE_TAG_TYPE, event =>
this._createTagType(event.data),
);
eventStore.on(DELETE_TAG_TYPE, event =>
this._deleteTagType(event.data),
);
eventStore.on(UPDATE_TAG_TYPE, event =>
this._updateTagType(event.data),
);
}
async getAll() {
const stopTimer = this.timer('getTagTypes');
const rows = await this.db.select(COLUMNS).from(TABLE);
stopTimer();
return rows.map(this.rowToTagType);
}
async getTagType(name) {
const stopTimer = this.timer('getTagTypeByName');
return this.db
.first(COLUMNS)
.from(TABLE)
.where({ name })
.then(row => {
stopTimer();
if (!row) {
throw new NotFoundError('Could not find tag-type');
} else {
return this.rowToTagType(row);
}
});
}
async _createTagType(event) {
const stopTimer = this.timer('createTagType');
try {
const data = this.eventDataToRow(event);
await this.db(TABLE).insert(data);
await this.eventStore.store({
type: TAG_TYPE_CREATED,
createdBy: event.createdBy || 'unleash-system',
data,
});
} catch (err) {
this.logger.error('Could not insert tag type, error: ', err);
}
stopTimer();
}
async _updateTagType(event) {
const stopTimer = this.timer('updateTagType');
try {
const { description, icon } = this.eventDataToRow(event);
await this.db(TABLE)
.where({ name: event.name })
.update({ description, icon });
stopTimer();
} catch (err) {
this.logger.error('Could not update tag type, error: ', err);
stopTimer();
}
}
async _deleteTagType(event) {
const stopTimer = this.timer('deleteTagType');
try {
const data = this.eventDataToRow(event);
await this.db(TABLE)
.where({
name: data.name,
})
.del();
await this.eventStore.store({
type: TAG_TYPE_DELETED,
createdBy: event.createdBy || 'unleash-system',
data,
});
} catch (err) {
this.logger.error('Could not delete tag, error: ', err);
}
stopTimer();
}
rowToTagType(row) {
return {
name: row.name,
description: row.description,
icon: row.icon,
};
}
eventDataToRow(event) {
return {
name: event.name.toLowerCase(),
description: event.description,
icon: event.icon,
};
}
}
module.exports = TagTypeStore;

View File

@ -14,6 +14,8 @@ const {
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_IMPORT,
FEATURE_TAGGED,
FEATURE_UNTAGGED,
DROP_FEATURES,
CONTEXT_FIELD_CREATED,
CONTEXT_FIELD_UPDATED,
@ -21,6 +23,10 @@ const {
PROJECT_CREATED,
PROJECT_UPDATED,
PROJECT_DELETED,
TAG_CREATED,
TAG_DELETED,
TAG_TYPE_CREATED,
TAG_TYPE_DELETED,
} = require('./event-type');
const strategyTypes = [
@ -37,6 +43,8 @@ const featureTypes = [
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_IMPORT,
FEATURE_TAGGED,
FEATURE_UNTAGGED,
DROP_FEATURES,
];
@ -46,6 +54,10 @@ const contextTypes = [
CONTEXT_FIELD_UPDATED,
];
const tagTypes = [TAG_CREATED, TAG_DELETED];
const tagTypeTypes = [TAG_TYPE_CREATED, TAG_TYPE_DELETED];
const projectTypes = [PROJECT_CREATED, PROJECT_UPDATED, PROJECT_DELETED];
function baseTypeFor(event) {
@ -61,6 +73,12 @@ function baseTypeFor(event) {
if (projectTypes.indexOf(event.type) !== -1) {
return 'project';
}
if (tagTypes.indexOf(event.type) !== -1) {
return 'tag';
}
if (tagTypeTypes.indexOf(event.type) !== -1) {
return 'tag-type';
}
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
}

View File

@ -6,6 +6,8 @@ module.exports = {
FEATURE_ARCHIVED: 'feature-archived',
FEATURE_REVIVED: 'feature-revived',
FEATURE_IMPORT: 'feature-import',
FEATURE_TAGGED: 'feature-tagged',
FEATURE_UNTAGGED: 'feature-untagged',
DROP_FEATURES: 'drop-features',
STRATEGY_CREATED: 'strategy-created',
STRATEGY_DELETED: 'strategy-deleted',
@ -18,4 +20,8 @@ module.exports = {
PROJECT_CREATED: 'project-created',
PROJECT_UPDATED: 'project-updated',
PROJECT_DELETED: 'project-deleted',
TAG_CREATED: 'tag-created',
TAG_DELETED: 'tag-deleted',
TAG_TYPE_CREATED: 'tag-type-created',
TAG_TYPE_DELETED: 'tag-type-deleted',
};

View File

@ -21,6 +21,12 @@
},
"context": {
"uri": "/api/admin/context"
},
"tags": {
"uri": "/api/admin/tags"
},
"tag-types": {
"uri": "/api/admin/tag-types"
}
}
}

View File

@ -14,6 +14,7 @@ const {
CREATE_FEATURE,
} = require('../../permissions');
const { featureShema, nameSchema } = require('./feature-schema');
const { tagSchema } = require('./tag-schema');
const version = 1;
@ -21,6 +22,7 @@ class FeatureController extends Controller {
constructor(config) {
super(config);
this.featureToggleStore = config.stores.featureToggleStore;
this.featureTagStore = config.stores.featureTagStore;
this.eventStore = config.stores.eventStore;
this.logger = config.getLogger('/admin-api/feature.js');
@ -35,6 +37,13 @@ class FeatureController extends Controller {
this.post('/:featureName/toggle/off', this.toggleOff, UPDATE_FEATURE);
this.post('/:featureName/stale/on', this.staleOn, UPDATE_FEATURE);
this.post('/:featureName/stale/off', this.staleOff, UPDATE_FEATURE);
this.get('/:featureName/tags', this.listTags, UPDATE_FEATURE);
this.post('/:featureName/tags', this.addTag, UPDATE_FEATURE);
this.delete(
'/:featureName/tags/:tagType/:tagValue',
this.removeTag,
UPDATE_FEATURE,
);
}
async getAllToggles(req, res) {
@ -52,13 +61,49 @@ class FeatureController extends Controller {
}
}
async listTags(req, res) {
const name = req.params.featureName;
const tags = await this.featureTagStore.getAllTagsForFeature(name);
res.json({ version, tags });
}
async addTag(req, res) {
const { featureName } = req.params;
const userName = extractUser(req);
try {
await nameSchema.validateAsync({ name: featureName });
const { value, type } = await tagSchema.validateAsync(req.body);
const tag = await this.featureTagStore.tagFeature({
value,
type,
featureName,
createdBy: userName,
});
res.status(201).json(tag);
} catch (err) {
handleErrors(res, this.logger, err);
}
}
async removeTag(req, res) {
const { featureName, tagType, tagValue } = req.params;
const userName = extractUser(req);
await this.featureTagStore.untagFeature({
featureName,
tagType,
tagValue,
createdBy: userName,
});
res.status(200).end();
}
async validate(req, res) {
const { name } = req.body;
try {
await nameSchema.validateAsync({ name });
await this.validateUniqueName(name);
res.status(201).end();
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}

View File

@ -28,6 +28,7 @@ function getSetup() {
base,
perms,
featureToggleStore: stores.featureToggleStore,
featureTagStore: stores.featureTagStore,
request: supertest(app),
};
}
@ -99,7 +100,7 @@ test('should be allowed to use new toggle name', t => {
.post(`${base}/api/admin/features/validate`)
.send({ name: 'new.name' })
.set('Content-Type', 'application/json')
.expect(201);
.expect(200);
});
test('should be allowed to have variants="null"', t => {
@ -358,3 +359,79 @@ test('should toggle', t => {
t.true(res.body.enabled === true);
});
});
test('should be able to add tag for feature', t => {
t.plan(1);
const { request, featureToggleStore, base, perms } = getSetup();
perms.withPermissions(UPDATE_FEATURE);
featureToggleStore.addFeature({
name: 'toggle.disabled',
enabled: false,
strategies: [{ name: 'default' }],
});
return request
.post(`${base}/api/admin/features/toggle.disabled/tags`)
.send({
value: 'TeamRed',
type: 'simple',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect(res => {
t.is(res.body.value, 'TeamRed');
});
});
test('should be able to get tags for feature', t => {
t.plan(1);
const {
request,
featureToggleStore,
featureTagStore,
base,
perms,
} = getSetup();
perms.withPermissions(UPDATE_FEATURE);
featureToggleStore.addFeature({
name: 'toggle.disabled',
enabled: false,
strategies: [{ name: 'default' }],
});
featureTagStore.tagFeature({
featureName: 'toggle.disabled',
value: 'TeamGreen',
type: 'simple',
});
return request
.get(`${base}/api/admin/features/toggle.disabled/tags`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tags.length, 1);
});
});
test('Invalid tag for feature should be rejected', t => {
t.plan(1);
const { request, featureToggleStore, base, perms } = getSetup();
perms.withPermissions(UPDATE_FEATURE);
featureToggleStore.addFeature({
name: 'toggle.disabled',
enabled: false,
strategies: [{ name: 'default' }],
});
return request
.post(`${base}/api/admin/features/toggle.disabled/tags`)
.send({
type: 'not url safe',
value: 'some crazy value',
})
.set('Content-Type', 'application/json')
.expect(400)
.expect(res => {
t.is(res.body.details[0].message, '"type" must be URL friendly');
});
});

View File

@ -11,6 +11,8 @@ const UserController = require('./user');
const ConfigController = require('./config');
const ContextController = require('./context');
const StateController = require('./state');
const TagController = require('./tag');
const TagTypeController = require('./tag-type');
const apiDef = require('./api-def.json');
class AdminApi extends Controller {
@ -49,6 +51,11 @@ class AdminApi extends Controller {
new ContextController(config, services).router,
);
this.app.use('/state', new StateController(config, services).router);
this.app.use('/tags', new TagController(config, services).router);
this.app.use(
'/tag-types',
new TagTypeController(config, services).router,
);
}
index(req, res) {

View File

@ -0,0 +1,25 @@
'use strict';
const joi = require('joi');
const { customJoi } = require('./util');
const tagSchema = joi
.object()
.keys({
value: joi
.string()
.min(2)
.max(50),
type: customJoi
.isUrlFriendly()
.min(2)
.max(50)
.default('simple'),
})
.options({
allowUnknown: false,
stripUnknown: true,
abortEarly: false,
});
module.exports = { tagSchema };

View File

@ -0,0 +1,14 @@
'use strict';
const test = require('ava');
const { tagSchema } = require('./tag-schema');
test('should require url friendly type if defined', t => {
const tag = {
value: 'io`dasd',
type: 'io`dasd',
};
const { error } = tagSchema.validate(tag);
t.deepEqual(error.details[0].message, '"type" must be URL friendly');
});

View File

@ -0,0 +1,23 @@
'use strict';
const joi = require('joi');
const { customJoi } = require('./util');
const tagTypeSchema = joi
.object()
.keys({
name: customJoi
.isUrlFriendly()
.min(2)
.max(50)
.required(),
description: joi.string().allow(''),
icon: joi.string().allow(''),
})
.options({
allowUnknown: false,
stripUnknown: true,
abortEarly: false,
});
module.exports = { tagTypeSchema };

View File

@ -0,0 +1,35 @@
'use strict';
const test = require('ava');
const { tagTypeSchema } = require('./tag-type-schema');
test('should require a URLFriendly name but allow empty description and icon', t => {
const simpleTagType = {
name: 'io with space',
};
const { error } = tagTypeSchema.validate(simpleTagType);
t.deepEqual(error.details[0].message, '"name" must be URL friendly');
});
test('should require a stringy description and icon', t => {
const tagType = {
name: 'url-safe',
description: 515,
icon: 123,
};
const { error } = tagTypeSchema.validate(tagType);
t.deepEqual(error.details[0].message, '"description" must be a string');
t.deepEqual(error.details[1].message, '"icon" must be a string');
});
test('Should validate if all requirements are fulfilled', t => {
const validTagType = {
name: 'url-safe',
description: 'some string',
icon: '#',
};
const { error } = tagTypeSchema.validate(validTagType);
t.is(error, undefined);
});

View File

@ -0,0 +1,127 @@
'use strict';
const Controller = require('../controller');
const { tagTypeSchema } = require('./tag-type-schema');
const {
UPDATE_TAG_TYPE,
CREATE_TAG_TYPE,
DELETE_TAG_TYPE,
} = require('../../command-type');
const { UPDATE_FEATURE } = require('../../permissions');
const { handleErrors } = require('./util');
const extractUsername = require('../../extract-user');
const NameExistsError = require('../../error/name-exists-error');
const version = 1;
class TagTypeController extends Controller {
constructor(config) {
super(config);
this.tagTypeStore = config.stores.tagTypeStore;
this.eventStore = config.stores.eventStore;
this.logger = config.getLogger('/admin-api/tag-type.js');
this.get('/', this.getTagTypes);
this.post('/', this.createTagType, UPDATE_FEATURE);
this.post('/validate', this.validate);
this.get('/:name', this.getTagType);
this.put('/:name', this.updateTagType, UPDATE_FEATURE);
this.delete('/:name', this.deleteTagType, UPDATE_FEATURE);
}
async getTagTypes(req, res) {
const tagTypes = await this.tagTypeStore.getAll();
res.json({ version, tagTypes });
}
async validateUniqueName(name) {
let msg;
try {
await this.tagTypeStore.getTagType(name);
msg = `A Tag type with name: [${name}] already exist`;
} catch (error) {
// No conflict, everything ok!
return;
}
// Intentional throw here!
throw new NameExistsError(msg);
}
async validate(req, res) {
const { name, description, icon } = req.body;
try {
await tagTypeSchema.validateAsync({ name, description, icon });
await this.validateUniqueName(name);
res.status(200).json({ valid: true });
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async createTagType(req, res) {
const userName = extractUsername(req);
try {
const data = await tagTypeSchema.validateAsync(req.body);
data.name = data.name.toLowerCase();
await this.validateUniqueName(data.name);
await this.eventStore.store({
type: CREATE_TAG_TYPE,
createdBy: userName,
data,
});
res.status(201).json(data);
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async updateTagType(req, res) {
const { description, icon } = req.body;
const { name } = req.params;
const userName = extractUsername(req);
try {
const data = await tagTypeSchema.validateAsync({
description,
icon,
name,
});
await this.eventStore.store({
type: UPDATE_TAG_TYPE,
createdBy: userName,
data,
});
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async getTagType(req, res) {
const { name } = req.params;
try {
const tagType = await this.tagTypeStore.getTagType(name);
res.json({ version, tagType });
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async deleteTagType(req, res) {
const { name } = req.params;
const userName = extractUsername(req);
try {
await this.eventStore.store({
type: DELETE_TAG_TYPE,
createdBy: userName,
data: { name },
});
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
}
}
module.exports = TagTypeController;

View File

@ -0,0 +1,85 @@
'use strict';
const Controller = require('../controller');
const { tagSchema } = require('./tag-schema');
const { CREATE_TAG, DELETE_TAG } = require('../../command-type');
const { UPDATE_FEATURE } = require('../../permissions');
const { handleErrors } = require('./util');
const extractUsername = require('../../extract-user');
const version = 1;
class TagController extends Controller {
constructor(config) {
super(config);
this.featureTagStore = config.stores.featureTagStore;
this.eventStore = config.stores.eventStore;
this.logger = config.getLogger('/admin-api/tag.js');
this.get('/', this.getTags);
this.post('/', this.createTag, UPDATE_FEATURE);
this.get('/:type', this.getTagsByType);
this.get('/:type/:value', this.getTagByTypeAndValue);
this.delete('/:type/:value', this.deleteTag, UPDATE_FEATURE);
}
async getTags(req, res) {
const tags = await this.featureTagStore.getTags();
res.json({ version, tags });
}
async getTagsByType(req, res) {
const tags = await this.featureTagStore.getAllOfType(req.params.type);
res.json({ version, tags });
}
async getTagByTypeAndValue(req, res) {
const { type, value } = req.params;
try {
const tag = await this.featureTagStore.getTagByTypeAndValue(
type,
value,
);
res.json({ version, tag });
} catch (err) {
handleErrors(res, this.logger, err);
}
}
async createTag(req, res) {
const userName = extractUsername(req);
try {
const data = await tagSchema.validateAsync(req.body);
await this.eventStore.store({
type: CREATE_TAG,
createdBy: userName,
data,
});
res.status(201).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
}
async deleteTag(req, res) {
const { type, value } = req.params;
const userName = extractUsername(req);
try {
await this.eventStore.store({
type: DELETE_TAG,
createdBy: userName || 'unleash-system',
data: {
type,
value,
},
});
res.status(200).end();
} catch (error) {
handleErrors(res, this.logger, error);
}
}
}
module.exports = TagController;

View File

@ -0,0 +1,147 @@
'use strict';
const test = require('ava');
const supertest = require('supertest');
const { EventEmitter } = require('events');
const store = require('../../../test/fixtures/store');
const permissions = require('../../../test/fixtures/permissions');
const getLogger = require('../../../test/fixtures/no-logger');
const getApp = require('../../app');
const eventBus = new EventEmitter();
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const perms = permissions();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
extendedPermissions: true,
preRouterHook: perms.hook,
getLogger,
});
return {
base,
perms,
featureTagStore: stores.featureTagStore,
request: supertest(app),
};
}
test('should get empty getTags via admin', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin/tags`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.tags.length === 0);
});
});
test('should get all tags added', t => {
t.plan(1);
const { request, featureTagStore, base } = getSetup();
featureTagStore.addTag({
type: 'simple',
value: 'TeamGreen',
});
return request
.get(`${base}/api/admin/tags`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.tags.length === 1);
});
});
test('should be able to get single tag by type and value', t => {
t.plan(1);
const { request, featureTagStore, base } = getSetup();
featureTagStore.addTag({
id: 1,
type: 'simple',
value: 'TeamRed',
});
return request
.get(`${base}/api/admin/tags/simple/TeamRed`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tag.value, 'TeamRed');
});
});
test('trying to get non-existing tag should not be found', t => {
const { request, featureTagStore, base } = getSetup();
featureTagStore.addTag({
id: 1,
type: 'simple',
value: 'TeamRed',
});
return request.get(`${base}/api/admin/tags/id/1125`).expect(res => {
t.is(res.status, 404);
});
});
test('trying to get non-existing tag by name and type should not be found', t => {
const { request, base } = getSetup();
return request.get(`${base}/api/admin/tags/simple/TeamRed`).expect(res => {
t.is(res.status, 404);
});
});
test('should be able to delete a tag', t => {
t.plan(1);
const { request, featureTagStore, base } = getSetup();
featureTagStore.addTag({
type: 'simple',
value: 'TeamGreen',
});
featureTagStore.removeTag({ type: 'simple', value: 'TeamGreen' });
return request
.get(`${base}/api/admin/tags`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.tags.length === 0);
});
});
test('should get empty tags of type', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin/tags/simple`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tags.length, 0);
});
});
test('should be able to filter by type', t => {
const { request, base, featureTagStore } = getSetup();
featureTagStore.addTag({
id: 1,
value: 'TeamRed',
type: 'simple',
});
featureTagStore.addTag({
id: 2,
value: 'TeamGreen',
type: 'slack',
});
return request
.get(`${base}/api/admin/tags/simple`)
.expect(200)
.expect('Content-Type', /json/)
.expect(res => {
t.is(res.body.tags.length, 1);
t.is(res.body.tags[0].value, 'TeamRed');
});
});

View File

@ -48,4 +48,4 @@ const handleErrors = (res, logger, error) => {
}
};
module.exports = { nameType, handleErrors };
module.exports = { customJoi, nameType, handleErrors };

View File

@ -0,0 +1,41 @@
exports.up = function(db, cb) {
db.runSql(
`CREATE TABLE IF NOT EXISTS tag_types
(
name text PRIMARY KEY NOT NULL,
description text,
icon text,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tags
(
type text not null references tag_types (name) ON DELETE CASCADE,
value text,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
PRIMARY KEY (type, value)
);
CREATE TABLE IF NOT EXISTS feature_tag
(
feature_name varchar(255) not null references features (name) ON DELETE CASCADE,
tag_type text not null,
tag_value text not null,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
UNIQUE (feature_name, tag_type, tag_value),
FOREIGN KEY (tag_type, tag_value) REFERENCES tags(type, value) ON DELETE CASCADE
);
INSERT INTO tag_types(name, description, icon)
VALUES ('simple', 'Used to simplify filtering of features', '#');
`,
cb,
);
};
exports.down = function(db, cb) {
db.runSql(
`DROP TABLE feature_tag;
DROP TABLE tags;
DROP TABLE tag_types;
`,
cb,
);
};

View File

@ -29,7 +29,7 @@
"license": "Apache-2.0",
"main": "./lib/server-impl.js",
"bin": {
"unleash": "./bin/unleash.js"
"unleash": "bin/unleash.js"
},
"scripts": {
"start": "node server.js",
@ -78,7 +78,7 @@
"helmet": "^4.1.0",
"joi": "^17.3.0",
"js-yaml": "^3.14.0",
"knex": "0.21.5",
"knex": "0.21.15",
"log4js": "^6.0.0",
"mime": "^2.4.2",
"moment": "^2.24.0",

View File

@ -259,7 +259,8 @@ test.serial('creates new feature toggle without type', async t => {
enabled: false,
strategies: [{ name: 'default' }],
});
await request.get('/api/admin/features/com.test.noType').expect(res => {
await new Promise(r => setTimeout(r, 200));
return request.get('/api/admin/features/com.test.noType').expect(res => {
t.is(res.body.type, 'release');
});
});
@ -273,7 +274,90 @@ test.serial('creates new feature toggle with type', async t => {
enabled: false,
strategies: [{ name: 'default' }],
});
await request.get('/api/admin/features/com.test.withType').expect(res => {
await new Promise(r => setTimeout(r, 200));
return request.get('/api/admin/features/com.test.withType').expect(res => {
t.is(res.body.type, 'killswitch');
});
});
test.serial('tags feature with new tag', async t => {
t.plan(1);
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/test.feature/tags')
.send({
value: 'TeamGreen',
type: 'simple',
})
.set('Content-Type', 'application/json');
await new Promise(r => setTimeout(r, 200));
return request.get('/api/admin/features/test.feature/tags').expect(res => {
t.is(res.body.tags[0].value, 'TeamGreen');
});
});
test.serial(
'tagging a feature with an already existing tag should be a noop',
async t => {
t.plan(1);
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/test.feature/tags').send({
value: 'TeamGreen',
type: 'simple',
});
await request.post('/api/admin/features/test.feature/tags').send({
value: 'TeamGreen',
type: 'simple',
});
await new Promise(r => setTimeout(r, 200));
return request
.get('/api/admin/features/test.feature/tags')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tags.length, 1);
});
},
);
test.serial('can untag feature', async t => {
t.plan(1);
const request = await setupApp(stores);
await request.post('/api/admin/features').send({
name: 'test.feature',
type: 'killswitch',
enabled: true,
strategies: [{ name: 'default' }],
});
const tag = { value: 'TeamGreen', type: 'simple' };
await request
.post('/api/admin/features/test.feature/tags')
.send(tag)
.expect(201);
await new Promise(r => setTimeout(r, 50));
await request
.delete(
`/api/admin/features/test.feature/tags/${tag.type}/${tag.value}`,
)
.expect(200);
await new Promise(r => setTimeout(r, 50));
return request
.get('/api/admin/features/test.feature/tags')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tags.length, 0);
});
});

View File

@ -0,0 +1,203 @@
'use strict';
const test = require('ava');
const dbInit = require('../../helpers/database-init');
const { setupApp } = require('../../helpers/test-helper');
const getLogger = require('../../../fixtures/no-logger');
let stores;
test.before(async () => {
const db = await dbInit('tag_types_api_serial', getLogger);
stores = db.stores;
});
test.after(async () => {
await stores.db.destroy();
});
test.serial('returns list of tag-types', async t => {
t.plan(1);
const request = await setupApp(stores);
return request
.get('/api/admin/tag-types')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tagTypes.length, 1);
});
});
test.serial('gets a tag-type by name', async t => {
t.plan(1);
const request = await setupApp(stores);
return request
.get('/api/admin/tag-types/simple')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tagType.name, 'simple');
});
});
test.serial('querying a tag-type that does not exist yields 404', async t => {
t.plan(0);
const request = await setupApp(stores);
return request.get('/api/admin/tag-types/non-existent').expect(404);
});
test.serial('Can create a new tag type', async t => {
const request = await setupApp(stores);
await request.post('/api/admin/tag-types').send({
name: 'slack',
description:
'Tag your feature toggles with slack channel to post updates for toggle to',
icon:
'http://icons.iconarchive.com/icons/papirus-team/papirus-apps/32/slack-icon.png',
});
await new Promise(r => setTimeout(r, 200));
return request
.get('/api/admin/tag-types/slack')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(
res.body.tagType.icon,
'http://icons.iconarchive.com/icons/papirus-team/papirus-apps/32/slack-icon.png',
);
});
});
test.serial('Invalid tag types gets rejected', async t => {
const request = await setupApp(stores);
await request
.post('/api/admin/tag-types')
.send({
name: 'Something url unsafe',
description: 'A fancy description',
icon: 'NoIcon',
})
.set('Content-Type', 'application/json')
.expect(400)
.expect(res => {
t.is(res.body.details[0].message, '"name" must be URL friendly');
});
});
test.serial('Can update a tag types description and icon', async t => {
const request = await setupApp(stores);
await request.get('/api/admin/tag-types/simple').expect(200);
await request
.put('/api/admin/tag-types/simple')
.send({
description: 'new description',
icon: '$',
})
.expect(200);
await new Promise(r => setTimeout(r, 200));
return request
.get('/api/admin/tag-types/simple')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tagType.icon, '$');
});
});
test.serial('Invalid updates gets rejected', async t => {
const request = await setupApp(stores);
await request.get('/api/admin/tag-types/simple').expect(200);
await request
.put('/api/admin/tag-types/simple')
.send({
description: 15125,
icon: 125,
})
.expect(400)
.expect(res => {
t.is(res.body.details[0].message, '"description" must be a string');
t.is(res.body.details[1].message, '"icon" must be a string');
});
});
test.serial(
'Validation of tag-types returns 200 for valid tag-types',
async t => {
const request = await setupApp(stores);
await request
.post('/api/admin/tag-types/validate')
.send({
name: 'something',
description: 'A fancy description',
icon: 'NoIcon',
})
.set('Content-Type', 'application/json')
.expect(200)
.expect(res => {
t.is(res.body.valid, true);
});
},
);
test.serial('Invalid tag-types get refused by validator', async t => {
const request = await setupApp(stores);
await request
.post('/api/admin/tag-types/validate')
.send({
name: 'Something url unsafe',
description: 'A fancy description',
icon: 'NoIcon',
})
.set('Content-Type', 'application/json')
.expect(400)
.expect(res => {
t.is(res.body.details[0].message, '"name" must be URL friendly');
});
});
test.serial('Can delete tag type', async t => {
t.plan(0);
const request = await setupApp(stores);
await request
.delete('/api/admin/tag-types/simple')
.set('Content-Type', 'application/json')
.expect(200);
await new Promise(r => setTimeout(r, 50));
return request.get('/api/admin/tag-types/simple').expect(404);
});
test.serial('Non unique tag-types gets rejected', async t => {
const request = await setupApp(stores);
await request
.post('/api/admin/tag-types')
.send({
name: 'my-tag-type',
description: 'Will be accepted first time',
icon: 'T',
})
.set('Content-Type', 'application/json')
.expect(201);
await new Promise(r => setTimeout(r, 50));
return request
.post('/api/admin/tag-types')
.send({
name: 'my-tag-type',
description: 'Will not be accepted this time',
icon: 'T',
})
.set('Content-Type', 'application/json')
.expect(res => {
t.is(res.status, 409);
});
});
test.serial('Only required argument should be name', async t => {
const request = await setupApp(stores);
const name = 'some-tag-type';
return request
.post('/api/admin/tag-types')
.send({ name, description: '' })
.set('Content-Type', 'application/json')
.expect(201)
.expect(res => {
t.is(res.body.name, name);
});
});

View File

@ -0,0 +1,112 @@
'use strict';
const test = require('ava');
const dbInit = require('../../helpers/database-init');
const { setupApp } = require('../../helpers/test-helper');
const getLogger = require('../../../fixtures/no-logger');
let stores;
test.before(async () => {
const db = await dbInit('tag_api_serial', getLogger);
stores = db.stores;
});
test.after(async () => {
await stores.db.destroy();
});
test.serial('returns list of tags', async t => {
const request = await setupApp(stores);
request
.post('/api/admin/tags')
.send({
value: 'Tester',
type: 'simple',
})
.set('Content-Type', 'application/json');
return request
.get('/api/admin/tags')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tags.length, 1);
});
});
test.serial('gets a tag by type and value', async t => {
const request = await setupApp(stores);
request
.post('/api/admin/tags')
.send({
value: 'Tester',
type: 'simple',
})
.set('Content-Type', 'application/json');
return request
.get('/api/admin/tags/simple/Tester')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tag.value, 'Tester');
});
});
test.serial('cannot get tag that does not exist', async t => {
t.plan(1);
const request = await setupApp(stores);
return request.get('/api/admin/tags/simple/12158091').expect(res => {
t.is(res.status, 404);
});
});
test.serial('Can create a tag', async t => {
const request = await setupApp(stores);
return request
.post('/api/admin/tags')
.send({
id: 1,
value: 'TeamRed',
type: 'simple',
})
.expect(res => {
t.is(res.status, 201);
});
});
test.serial('Can validate a tag', async t => {
const request = await setupApp(stores);
return request
.post('/api/admin/tags')
.send({
value: 124,
type: 'not url friendly',
})
.expect('Content-Type', /json/)
.expect(400)
.expect(res => {
t.is(res.body.details.length, 2);
t.is(res.body.details[0].message, '"value" must be a string');
t.is(res.body.details[1].message, '"type" must be URL friendly');
});
});
test.serial('Can delete a tag', async t => {
const request = await setupApp(stores);
await request
.delete('/api/admin/tags/simple/Tester')
.set('Content-Type', 'application/json')
.expect(200);
await new Promise(r => setTimeout(r, 50));
return request
.get('/api/admin/tags')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(
res.body.tags.indexOf(
tag => tag.value === 'Tester' && tag.type === 'simple',
),
-1,
);
});
});

View File

@ -25,6 +25,8 @@ async function resetDatabase(stores) {
stores.db('context_fields').del(),
stores.db('users').del(),
stores.db('projects').del(),
stores.db('tags').del(),
stores.db('tag_types').del(),
]);
}
@ -52,16 +54,29 @@ function createFeatures(store) {
return dbState.features.map(f => store._createFeature(f));
}
async function setupDatabase(stores) {
const updates = [];
updates.push(...createStrategies(stores.strategyStore));
updates.push(...createContextFields(stores.contextFieldStore));
updates.push(...createFeatures(stores.featureToggleStore));
updates.push(...createClientInstance(stores.clientInstanceStore));
updates.push(...createApplications(stores.clientApplicationsStore));
updates.push(...createProjects(stores.projectStore));
function tagFeatures(store) {
return dbState.features.map(f =>
store.tagFeature({
featureName: f.name,
value: 'Tester',
type: 'simple',
}),
);
}
await Promise.all(updates);
function createTagTypes(store) {
return dbState.tag_types.map(t => store._createTagType(t));
}
async function setupDatabase(stores) {
await Promise.all(createStrategies(stores.strategyStore));
await Promise.all(createContextFields(stores.contextFieldStore));
await Promise.all(createFeatures(stores.featureToggleStore));
await Promise.all(createClientInstance(stores.clientInstanceStore));
await Promise.all(createApplications(stores.clientApplicationsStore));
await Promise.all(createProjects(stores.projectStore));
await Promise.all(createTagTypes(stores.tagTypeStore));
await Promise.all(tagFeatures(stores.featureTagStore));
}
module.exports = async function init(databaseSchema = 'test', getLogger) {

View File

@ -155,5 +155,12 @@
"id": "default",
"name": "Default"
}
],
"tag_types": [
{
"name": "simple",
"description": "Arbitrary tags. Used to simplify filtering of features",
"icon": "#"
}
]
}

55
test/fixtures/fake-feature-tag-store.js vendored Normal file
View File

@ -0,0 +1,55 @@
'use strict';
const NotFoundError = require('../../lib/error/notfound-error');
module.exports = () => {
const _tags = [];
const _featureTags = {};
return {
getAllOfType: type => {
const tags = _tags.filter(t => t.type === type);
return Promise.resolve(tags);
},
addTag: tag => {
_tags.push({ value: tag.value, type: tag.type });
},
removeTag: tag => {
_tags.splice(
_tags.indexOf(
t => t.value === tag.value && t.type === tag.type,
),
1,
);
},
getTags: () => Promise.resolve(_tags),
getTagByTypeAndValue: (type, value) => {
const tag = _tags.find(t => t.type === type && t.value === value);
if (tag) {
return Promise.resolve(tag);
}
return Promise.reject(new NotFoundError('Could not find tag'));
},
tagFeature: event => {
_featureTags[event.featureName] =
_featureTags[event.featureName] || [];
const tag = {
value: event.value,
type: event.type,
};
_featureTags[event.featureName].push(tag);
return 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,6 +4,7 @@ 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 eventStore = require('./fake-event-store');
const strategyStore = require('./fake-strategies-store');
const contextFieldStore = require('./fake-context-store');
@ -23,6 +24,7 @@ module.exports = {
clientMetricsStore: new ClientMetricsStore(),
clientInstanceStore: clientInstanceStore(),
featureToggleStore: featureToggleStore(),
featureTagStore: featureTagStore(),
eventStore: eventStore(),
strategyStore: strategyStore(),
contextFieldStore: contextFieldStore(),

1791
yarn.lock

File diff suppressed because it is too large Load Diff