diff --git a/docs/api/admin/feature-toggles-api.md b/docs/api/admin/feature-toggles-api.md index 4c6cfb4dde1..395ea5c024a 100644 --- a/docs/api/admin/feature-toggles-api.md +++ b/docs/api/admin/feature-toggles-api.md @@ -18,6 +18,7 @@ This endpoint is the one all admin ui should use to fetch all available feature { "name": "Feature.A", "description": "lorem ipsum", + "type": "release", "enabled": false, "strategies": [ { @@ -68,6 +69,7 @@ Used to fetch details about a specific featureToggle. This is mostly provded to { "name": "Feature.A", "description": "lorem ipsum..", + "type": "release", "enabled": false, "strategies": [ { @@ -89,6 +91,7 @@ Used to fetch details about a specific featureToggle. This is mostly provded to { "name": "Feature.A", "description": "lorem ipsum..", + "type": "release", "enabled": false, "strategies": [ { @@ -99,7 +102,12 @@ Used to fetch details about a specific featureToggle. This is mostly provded to } ``` -Used by the admin-dashboard to create a new feature toggles. The name **must be unique**, otherwise you will get a _403-response_. +Used by the admin-dashboard to create a new feature toggles. + +**Notes:** + +- _name_ **must be globally unique**, otherwise you will get a _403-response_. +- _type_ is optional. If not defined it defaults to `release` Returns 200-respose if the feature toggle was created successfully. @@ -113,6 +121,7 @@ Returns 200-respose if the feature toggle was created successfully. { "name": "Feature.A", "description": "lorem ipsum..", + "type": "release", "enabled": false, "strategies": [ { @@ -150,6 +159,7 @@ None { "name": "Feature.A", "description": "lorem ipsum..", + "type": "release", "enabled": true, "strategies": [ { @@ -177,6 +187,7 @@ None { "name": "Feature.A", "description": "lorem ipsum..", + "type": "release", "enabled": false, "strategies": [ { @@ -205,6 +216,7 @@ Used to fetch list of archived feature toggles { "name": "Feature.A", "description": "lorem ipsum", + "type": "release", "enabled": false, "strategies": [ { diff --git a/docs/api/admin/feature-types-api.md b/docs/api/admin/feature-types-api.md new file mode 100644 index 00000000000..146d6cc76d5 --- /dev/null +++ b/docs/api/admin/feature-types-api.md @@ -0,0 +1,50 @@ +--- +id: events +title: /api/admin/feature-types +--- + +# Feature Types API + +`GET: http://unleash.host.com/api/admin/feature-types` + +Used to fetch all feature types defined in the unleash system. + +**Response** + +```json +{ + "version": 1, + "types": [ + { + "id": "release", + "name": "Release", + "description": "Used to enable trunk-based development for teams practicing Continuous Delivery.", + "lifetimeDays": 40 + }, + { + "id": "experiment", + "name": "Experiment", + "description": "Used to perform multivariate or A/B testing.", + "lifetimeDays": 40 + }, + { + "id": "ops", + "name": "Operational", + "description": "Used to control operational aspects of the system behavior.", + "lifetimeDays": 7 + }, + { + "id": "killswitch", + "name": "Kill switch", + "description": "Used to to gracefully degrade system functionality.", + "lifetimeDays": null + }, + { + "id": "permission", + "name": "Permission", + "description": "Used to change the features or product experience that certain users receive.", + "lifetimeDays": null + } + ] +} +``` diff --git a/docs/api/client/feature-toggles-api.md b/docs/api/client/feature-toggles-api.md index d61e6407d4d..286ac188ef5 100644 --- a/docs/api/client/feature-toggles-api.md +++ b/docs/api/client/feature-toggles-api.md @@ -27,6 +27,7 @@ This endpoint should never return anything besides a valid _20X or 304-response_ { "name": "Feature.A", "description": "lorem ipsum", + "type": "release", "enabled": false, "strategies": [ { @@ -39,6 +40,7 @@ This endpoint should never return anything besides a valid _20X or 304-response_ }, { "name": "Feature.B", + "type": "killswitch", "description": "lorem ipsum", "enabled": true, "strategies": [ @@ -76,6 +78,7 @@ Used to fetch details about a specific feature toggle. This is mainly provided t { "name": "Feature.A", "description": "lorem ipsum..", + "type": "release", "enabled": false, "strategies": [ { diff --git a/docs/database-schema.md b/docs/database-schema.md index bd2c6b0d975..f3964847f29 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -9,21 +9,21 @@ This document describes our current database schema used in PostgreSQL. We use d Used by db-migrate module to keep track of migrations. -| NAME | TYPE | SIZE | NULLABLE | COLUMN_DEF | -| ------ | --------- | ---- | -------- | -------------------------------------- | -| id | serial | 10 | 0 | nextval('migrations_id_seq'::regclass) | -| name | varchar | 255 | 0 | (null) | -| run_on | timestamp | 29 | 0 | (null) | +| NAME | TYPE | SIZE | NULLABLE | COLUMN_DEF | +| --- | --- | --- | --- | --- | +| id | serial | 10 | 0 | nextval('migrations_id_seq'::regclass) | +| name | varchar | 255 | 0 | (null) | +| run_on | timestamp | 29 | 0 | (null) | ## Table: _events_ -| NAME | TYPE | SIZE | NULLABLE | COLUMN_DEF | -| ---------- | --------- | ---------- | -------- | ---------------------------------- | -| id | serial | 10 | 0 | nextval('events_id_seq'::regclass) | -| created_at | timestamp | 29 | 1 | now() | -| type | varchar | 255 | 0 | (null) | -| created_by | varchar | 255 | 0 | (null) | -| data | json | 2147483647 | 1 | (null) | +| NAME | TYPE | SIZE | NULLABLE | COLUMN_DEF | +| --- | --- | --- | --- | --- | +| id | serial | 10 | 0 | nextval('events_id_seq'::regclass) | +| created_at | timestamp | 29 | 1 | now() | +| type | varchar | 255 | 0 | (null) | +| created_by | varchar | 255 | 0 | (null) | +| data | json | 2147483647 | 1 | (null) | ## Table: _strategies_ @@ -36,14 +36,15 @@ Used by db-migrate module to keep track of migrations. ## Table: _features_ -| **NAME** | **TYPE** | **SIZE** | **NULLABLE** | **COLUMN_DEF** | **COMMENT** | -| ----------- | --------- | ---------- | ------------ | -------------- | ----------- | -| created_at | timestamp | 29 | 1 | now() | | -| name | varchar | 255 | 0 | (null) | | -| enabled | int4 | 10 | 1 | 0 | | -| description | text | 2147483647 | 1 | (null) | | -| archived | int4 | 10 | 1 | 0 | | -| strategies | json | 2147483647 | 1 | (null) | | +| **NAME** | **TYPE** | **SIZE** | **NULLABLE** | **COLUMN_DEF** | **COMMENT** | +| --- | --- | --- | --- | --- | --- | +| created_at | timestamp | 29 | 1 | now() | | +| name | varchar | 255 | 0 | (null) | | +| enabled | int4 | 10 | 1 | 0 | | +| description | text | 2147483647 | 1 | (null) | | +| archived | int4 | 10 | 1 | 0 | | +| strategies | json | 2147483647 | 1 | (null) | | +| type | varchar | 2147483647 | 1 | release | | ## Table: _client_strategies_ @@ -65,8 +66,17 @@ Used by db-migrate module to keep track of migrations. ## Table: _client_metrics_ -| COLUMN_NAME | TYPE_NAME | COLUMN_SIZE | NULLABLE | COLUMN_DEF | -| ----------- | --------- | ----------- | -------- | ------------------------------------------ | -| id | serial | 10 | 0 | nextval('client_metrics_id_seq'::regclass) | -| created_at | timestamp | 29 | 1 | now() | -| metrics | json | 2147483647 | 1 | (null) | +| COLUMN_NAME | TYPE_NAME | COLUMN_SIZE | NULLABLE | COLUMN_DEF | +| --- | --- | --- | --- | --- | +| id | serial | 10 | 0 | nextval('client_metrics_id_seq'::regclass) | +| created_at | timestamp | 29 | 1 | now() | +| metrics | json | 2147483647 | 1 | (null) | + +## Table: _feature_types_ + +| COLUMN_NAME | TYPE_NAME | COLUMN_SIZE | NULLABLE | COLUMN_DEF | +| ------------- | --------- | ----------- | -------- | ---------- | +| id | varchar | 255 | 0 | (null) | +| name | varchar | | 0 | (null) | +| description | varchar | | 1 | (null) | +| lifetime_days | integer | | 1 | (null) | diff --git a/lib/db/feature-toggle-store.js b/lib/db/feature-toggle-store.js index 6e0407146b6..65229eda4a3 100644 --- a/lib/db/feature-toggle-store.js +++ b/lib/db/feature-toggle-store.js @@ -13,6 +13,7 @@ const NotFoundError = require('../error/notfound-error'); const FEATURE_COLUMNS = [ 'name', 'description', + 'type', 'enabled', 'strategies', 'variants', @@ -97,6 +98,7 @@ class FeatureToggleStore { return { name: row.name, description: row.description, + type: row.type, enabled: row.enabled > 0, strategies: row.strategies, variants: row.variants, @@ -108,6 +110,7 @@ class FeatureToggleStore { return { name: data.name, description: data.description, + type: data.type, enabled: data.enabled ? 1 : 0, archived: data.archived ? 1 : 0, strategies: JSON.stringify(data.strategies), diff --git a/lib/db/feature-type-store.js b/lib/db/feature-type-store.js new file mode 100644 index 00000000000..33005fb8e2f --- /dev/null +++ b/lib/db/feature-type-store.js @@ -0,0 +1,29 @@ +'use strict'; + +const COLUMNS = ['id', 'name', 'description', 'lifetime_days']; +const TABLE = 'feature_types'; + +class FeatureToggleStore { + constructor(db, getLogger) { + this.db = db; + this.getLogger = getLogger('feature-type-store.js'); + } + + getAll() { + return this.db + .select(COLUMNS) + .from(TABLE) + .map(this.rowToFeatureType); + } + + rowToFeatureType(row) { + return { + id: row.id, + name: row.name, + description: row.description, + lifetimeDays: row.lifetime_days, + }; + } +} + +module.exports = FeatureToggleStore; diff --git a/lib/db/index.js b/lib/db/index.js index fcb32ecd7ce..ee55cfd3766 100644 --- a/lib/db/index.js +++ b/lib/db/index.js @@ -3,6 +3,7 @@ const { createDb } = require('./db-pool'); const EventStore = require('./event-store'); const FeatureToggleStore = require('./feature-toggle-store'); +const FeatureTypeStore = require('./feature-type-store'); const StrategyStore = require('./strategy-store'); const ClientInstanceStore = require('./client-instance-store'); const ClientMetricsDb = require('./client-metrics-db'); @@ -22,6 +23,7 @@ module.exports.createStores = (config, eventBus) => { db, eventStore, featureToggleStore: new FeatureToggleStore(db, eventStore, getLogger), + featureTypeStore: new FeatureTypeStore(db, getLogger), strategyStore: new StrategyStore(db, eventStore, getLogger), clientApplicationsStore: new ClientApplicationsStore( db, diff --git a/lib/routes/admin-api/feature-schema.js b/lib/routes/admin-api/feature-schema.js index b70dcfe1e25..f965f8feab5 100644 --- a/lib/routes/admin-api/feature-schema.js +++ b/lib/routes/admin-api/feature-schema.js @@ -54,6 +54,7 @@ const featureShema = joi .keys({ name: nameType, enabled: joi.boolean().default(false), + type: joi.string().default('release'), description: joi .string() .allow('') diff --git a/lib/routes/admin-api/feature-schema.test.js b/lib/routes/admin-api/feature-schema.test.js index 84545e8660f..751ced02143 100644 --- a/lib/routes/admin-api/feature-schema.test.js +++ b/lib/routes/admin-api/feature-schema.test.js @@ -17,6 +17,7 @@ test('should require URL firendly name', t => { test('should be valid toggle name', t => { const toggle = { name: 'app.name', + type: 'release', enabled: false, strategies: [{ name: 'default' }], }; @@ -28,6 +29,7 @@ test('should be valid toggle name', t => { test('should strip extra variant fields', t => { const toggle = { name: 'app.name', + type: 'release', enabled: false, strategies: [{ name: 'default' }], variants: [ @@ -47,6 +49,7 @@ test('should strip extra variant fields', t => { test('should allow weightType=fix', t => { const toggle = { name: 'app.name', + type: 'release', enabled: false, strategies: [{ name: 'default' }], variants: [ @@ -65,6 +68,7 @@ test('should allow weightType=fix', t => { test('should disallow weightType=unknown', t => { const toggle = { name: 'app.name', + type: 'release', enabled: false, strategies: [{ name: 'default' }], variants: [ @@ -86,6 +90,7 @@ test('should disallow weightType=unknown', t => { test('should be possible to define variant overrides', t => { const toggle = { name: 'app.name', + type: 'release', enabled: false, strategies: [{ name: 'default' }], variants: [ @@ -112,6 +117,7 @@ test('variant overrides must have corect shape', async t => { t.plan(1); const toggle = { name: 'app.name', + type: 'release', enabled: false, strategies: [{ name: 'default' }], variants: [ @@ -139,6 +145,7 @@ test('variant overrides must have corect shape', async t => { test('should keep constraints', t => { const toggle = { name: 'app.constraints', + type: 'release', enabled: false, strategies: [ { diff --git a/lib/routes/admin-api/feature-type.js b/lib/routes/admin-api/feature-type.js new file mode 100644 index 00000000000..0cc46f24950 --- /dev/null +++ b/lib/routes/admin-api/feature-type.js @@ -0,0 +1,22 @@ +'use strict'; + +const Controller = require('../controller'); + +const version = 1; + +class FeatureTypeController extends Controller { + constructor(config) { + super(config); + this.featureTypeStore = config.stores.featureTypeStore; + this.logger = config.getLogger('/admin-api/feature-type.js'); + + this.get('/', this.getAllFeatureTypes); + } + + async getAllFeatureTypes(req, res) { + const types = await this.featureTypeStore.getAll(); + res.json({ version, types }); + } +} + +module.exports = FeatureTypeController; diff --git a/lib/routes/admin-api/index.js b/lib/routes/admin-api/index.js index deb9f69c75a..b87af7bd27a 100644 --- a/lib/routes/admin-api/index.js +++ b/lib/routes/admin-api/index.js @@ -2,6 +2,7 @@ const Controller = require('../controller'); const FeatureController = require('./feature.js'); +const FeatureTypeController = require('./feature-type.js'); const ArchiveController = require('./archive.js'); const EventController = require('./event.js'); const StrategyController = require('./strategy'); @@ -18,6 +19,10 @@ class AdminApi extends Controller { this.app.get('/', this.index); this.app.use('/features', new FeatureController(config).router); + this.app.use( + '/feature-types', + new FeatureTypeController(config).router, + ); this.app.use('/archive', new ArchiveController(config).router); this.app.use('/strategies', new StrategyController(config).router); this.app.use('/events', new EventController(config).router); diff --git a/migrations/20200805091409-add-feature-toggle-type.js b/migrations/20200805091409-add-feature-toggle-type.js new file mode 100644 index 00000000000..6dc9587b989 --- /dev/null +++ b/migrations/20200805091409-add-feature-toggle-type.js @@ -0,0 +1,38 @@ +/* eslint camelcase: "off" */ + +'use strict'; + +const async = require('async'); + +exports.up = function(db, cb) { + async.series( + [ + db.createTable.bind(db, 'feature_types', { + id: { + type: 'string', + length: 255, + primaryKey: true, + notNull: true, + }, + name: { type: 'string', notNull: true }, + description: { type: 'string' }, + lifetime_days: { type: 'int' }, + }), + db.runSql.bind( + db, + ` + INSERT INTO feature_types(id, name, description, lifetime_days) VALUES('release', 'Release', 'Used to enable trunk-based development for teams practicing Continuous Delivery.', 40); + INSERT INTO feature_types(id, name, description, lifetime_days) VALUES('experiment', 'Experiment', 'Used to perform multivariate or A/B testing.', 40); + INSERT INTO feature_types(id, name, description, lifetime_days) VALUES('operational', 'Operational', 'Used to control operational aspects of the system behavior.', 7); + INSERT INTO feature_types(id, name, description, lifetime_days) VALUES('kill-switch', 'Kill switch', 'Used to to gracefully degrade system functionality.', null); + INSERT INTO feature_types(id, name, description, lifetime_days) VALUES('permission', 'Permission', 'Used to change the features or product experience that certain users receive.', null); + `, + ), + ], + cb, + ); +}; + +exports.down = function(db, cb) { + return db.dropTable('feature_types', cb); +}; diff --git a/migrations/20200805094311-add-feature-type-to-features.js b/migrations/20200805094311-add-feature-type-to-features.js new file mode 100644 index 00000000000..13c6fbeeab0 --- /dev/null +++ b/migrations/20200805094311-add-feature-type-to-features.js @@ -0,0 +1,17 @@ +'use strict'; + +exports.up = function(db, cb) { + return db.addColumn( + 'features', + 'type', + { + type: 'string', + defaultValue: 'release', + }, + cb, + ); +}; + +exports.down = function(db, cb) { + return db.removeColumn('features', 'type', cb); +}; diff --git a/package.json b/package.json index 571a6a7d08d..0a7e1f8dc8c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "prom-client": "^12.0.0", "response-time": "^2.3.2", "serve-favicon": "^2.5.0", - "unleash-frontend": "3.4.0", + "unleash-frontend": "3.4.1-0", "yargs": "^15.1.0" }, "devDependencies": { diff --git a/test/e2e/api/admin/feature.e2e.test.js b/test/e2e/api/admin/feature.e2e.test.js index 29bdfa53d0b..f9838df7734 100644 --- a/test/e2e/api/admin/feature.e2e.test.js +++ b/test/e2e/api/admin/feature.e2e.test.js @@ -250,3 +250,30 @@ test.serial('creates new feature toggle with variant overrides', async t => { .set('Content-Type', 'application/json') .expect(201); }); + +test.serial('creates new feature toggle without type', async t => { + t.plan(1); + const request = await setupApp(stores); + await request.post('/api/admin/features').send({ + name: 'com.test.noType', + enabled: false, + strategies: [{ name: 'default' }], + }); + await request.get('/api/admin/features/com.test.noType').expect(res => { + t.is(res.body.type, 'release'); + }); +}); + +test.serial('creates new feature toggle with type', async t => { + t.plan(1); + const request = await setupApp(stores); + await request.post('/api/admin/features').send({ + name: 'com.test.withType', + type: 'killswitch', + enabled: false, + strategies: [{ name: 'default' }], + }); + await request.get('/api/admin/features/com.test.withType').expect(res => { + t.is(res.body.type, 'killswitch'); + }); +}); diff --git a/test/fixtures/fake-feature-toggle-store.js b/test/fixtures/fake-feature-toggle-store.js index ee4c581eb77..c7f10569284 100644 --- a/test/fixtures/fake-feature-toggle-store.js +++ b/test/fixtures/fake-feature-toggle-store.js @@ -9,7 +9,7 @@ module.exports = () => { if (toggle) { return Promise.resolve(toggle); } - return Promise.reject(); + return Promise.reject(new Error('could not find toggle')); }, hasFeature: name => { const toggle = _features.find(f => f.name === name); diff --git a/yarn.lock b/yarn.lock index 887bd7e4452..c5b36569338 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5554,10 +5554,10 @@ universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" -unleash-frontend@3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.4.0.tgz#6df17f56904dab59e7c99765d5443e7716aa0734" - integrity sha512-ZHzaPSoBKZGyp+Bneo2vBhTUbc34aOBh6hxyjZm3v/ol7PpTt6D3opoIVe18UElLOAZft2Kjq2Gtv/1gvJx6pg== +unleash-frontend@3.4.1-0: + version "3.4.1-0" + resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.4.1-0.tgz#480731a753059ad4b38c98b9a5f014eaa44d912b" + integrity sha512-skx+SlOFPHcrrQlY5UhNV6stxblUNaB0mGbZfbh7CdIuWMs1Y+KxVoorTqDaHV0zhyxKQvngzZOd696VnMxA4w== unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0"