diff --git a/lib/db/context-field-store.js b/lib/db/context-field-store.js new file mode 100644 index 0000000000..c3686d5f9e --- /dev/null +++ b/lib/db/context-field-store.js @@ -0,0 +1,122 @@ +'use strict'; + +const { + CONTEXT_FIELD_CREATED, + CONTEXT_FIELD_UPDATED, + CONTEXT_FIELD_DELETED, +} = require('../event-type'); + +const COLUMNS = [ + 'name', + 'description', + 'sort_order', + 'legal_values', + 'created_at', +]; +const TABLE = 'context_fields'; + +const mapRow = row => ({ + name: row.name, + description: row.description, + sortOrder: row.sort_order, + legalValues: row.legal_values ? row.legal_values.split(',') : undefined, + createdAt: row.created_at, +}); + +class ContextFieldStore { + constructor(db, customContextFields, eventStore, getLogger) { + this.db = db; + this.logger = getLogger('context-field-store.js'); + this._createFromConfig(customContextFields); + + eventStore.on(CONTEXT_FIELD_CREATED, event => + this._createContextField(event.data) + ); + eventStore.on(CONTEXT_FIELD_UPDATED, event => + this._updateContextField(event.data) + ); + eventStore.on(CONTEXT_FIELD_DELETED, event => { + this._deleteContextField(event.data); + }); + } + + async _createFromConfig(customContextFields) { + if (customContextFields) { + this.logger.info( + 'Create custom context fields', + customContextFields + ); + const conextFields = await this.getAll(); + customContextFields + .filter(c => !conextFields.some(cf => cf.name === c.name)) + .forEach(async field => { + try { + await this._createContextField(field); + } catch (e) { + this.logger.error(e); + } + }); + } + } + + fieldToRow(data) { + return { + name: data.name, + description: data.description, + sort_order: data.sortOrder, // eslint-disable-line + legal_values: data.legalValues ? data.legalValues.join(',') : null, // eslint-disable-line + updated_at: data.createdAt, // eslint-disable-line + }; + } + + getAll() { + return this.db + .select(COLUMNS) + .from(TABLE) + .orderBy('name', 'asc') + .map(mapRow); + } + + get(name) { + return this.db + .first(COLUMNS) + .from(TABLE) + .where({ name }) + .then(mapRow); + } + + _createContextField(contextField) { + console.log('insert', contextField); + return this.db(TABLE) + .insert(this.fieldToRow(contextField)) + .catch(err => + this.logger.error('Could not insert contextField, error: ', err) + ); + } + + _updateContextField(data) { + return this.db(TABLE) + .where({ name: data.name }) + .update(this.fieldToRow(data)) + .catch(err => + this.logger.error( + 'Could not update context field, error: ', + err + ) + ); + } + + _deleteContextField({ name }) { + return this.db(TABLE) + .where({ name }) + .del() + .catch(err => { + this.logger.error( + 'Could not delete context field, error: ', + err + ); + }); + } +} + +module.exports = ContextFieldStore; diff --git a/lib/db/index.js b/lib/db/index.js index 94ceb67efe..e782adf4f5 100644 --- a/lib/db/index.js +++ b/lib/db/index.js @@ -8,6 +8,7 @@ const ClientInstanceStore = require('./client-instance-store'); const ClientMetricsDb = require('./client-metrics-db'); const ClientMetricsStore = require('./client-metrics-store'); const ClientApplicationsStore = require('./client-applications-store'); +const ContextFieldStore = require('./context-field-store'); module.exports.createStores = (config, eventBus) => { const getLogger = config.getLogger; @@ -31,5 +32,11 @@ module.exports.createStores = (config, eventBus) => { eventBus, getLogger ), + contextFieldStore: new ContextFieldStore( + db, + config.customContextFields, + eventStore, + getLogger + ), }; }; diff --git a/lib/event-differ.js b/lib/event-differ.js index 917c014150..27f8613721 100644 --- a/lib/event-differ.js +++ b/lib/event-differ.js @@ -12,6 +12,9 @@ const { FEATURE_REVIVED, FEATURE_IMPORT, DROP_FEATURES, + CONTEXT_FIELD_CREATED, + CONTEXT_FIELD_UPDATED, + CONTEXT_FIELD_DELETED, } = require('./event-type'); const diff = require('deep-diff').diff; @@ -32,11 +35,19 @@ const featureTypes = [ DROP_FEATURES, ]; +const contextTypes = [ + CONTEXT_FIELD_CREATED, + CONTEXT_FIELD_DELETED, + CONTEXT_FIELD_UPDATED, +]; + function baseTypeFor(event) { if (featureTypes.indexOf(event.type) !== -1) { return 'features'; } else if (strategyTypes.indexOf(event.type) !== -1) { return 'strategies'; + } else if (contextTypes.indexOf(event.type) !== -1) { + return 'context'; } throw new Error(`unknown event type: ${JSON.stringify(event)}`); } diff --git a/lib/event-type.js b/lib/event-type.js index 95d2d6fe86..368613100a 100644 --- a/lib/event-type.js +++ b/lib/event-type.js @@ -12,4 +12,7 @@ module.exports = { STRATEGY_UPDATED: 'strategy-updated', STRATEGY_IMPORT: 'strategy-import', DROP_STRATEGIES: 'drop-strategies', + CONTEXT_FIELD_CREATED: 'context-field-created', + CONTEXT_FIELD_UPDATED: 'context-field-updated', + CONTEXT_FIELD_DELETED: 'context-field-deleted', }; diff --git a/lib/permissions.js b/lib/permissions.js index 4c3bfe3e96..beb965eeb3 100644 --- a/lib/permissions.js +++ b/lib/permissions.js @@ -8,6 +8,9 @@ const CREATE_STRATEGY = 'CREATE_STRATEGY'; const UPDATE_STRATEGY = 'UPDATE_STRATEGY'; const DELETE_STRATEGY = 'DELETE_STRATEGY'; const UPDATE_APPLICATION = 'UPDATE_APPLICATION'; +const CREATE_CONTEXT_FIELD = 'CREATE_CONTEXT_FIELD'; +const UPDATE_CONTEXT_FIELD = 'UPDATE_CONTEXT_FIELD'; +const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD'; module.exports = { ADMIN, @@ -18,4 +21,7 @@ module.exports = { UPDATE_STRATEGY, DELETE_STRATEGY, UPDATE_APPLICATION, + CREATE_CONTEXT_FIELD, + UPDATE_CONTEXT_FIELD, + DELETE_CONTEXT_FIELD, }; diff --git a/lib/routes/admin-api/context-schema.js b/lib/routes/admin-api/context-schema.js new file mode 100644 index 0000000000..36e6c8db33 --- /dev/null +++ b/lib/routes/admin-api/context-schema.js @@ -0,0 +1,26 @@ +'use strict'; + +const joi = require('@hapi/joi'); +const { nameType } = require('./util'); + +const nameSchema = joi.object().keys({ name: nameType }); + +const contextSchema = joi + .object() + .keys({ + name: nameType, + description: joi + .string() + .allow('') + .allow(null) + .optional(), + legalValues: joi + .array() + .allow(null) + .unique() + .optional() + .items(joi.string()), + }) + .options({ allowUnknown: false, stripUnknown: true }); + +module.exports = { contextSchema, nameSchema }; diff --git a/lib/routes/admin-api/context.js b/lib/routes/admin-api/context.js index 46f45e9bfa..2d95911731 100644 --- a/lib/routes/admin-api/context.js +++ b/lib/routes/admin-api/context.js @@ -2,26 +2,144 @@ const Controller = require('../controller'); -const builtInContextFields = [ - { name: 'environment' }, - { name: 'userId' }, - { name: 'appName' }, -]; +const { contextSchema, nameSchema } = require('./context-schema'); +const NameExistsError = require('../../error/name-exists-error'); +const { handleErrors } = require('./util'); +const extractUser = require('../../extract-user'); + +const { + CONTEXT_FIELD_CREATED, + CONTEXT_FIELD_UPDATED, + CONTEXT_FIELD_DELETED, +} = require('../../event-type'); + +const { + CREATE_CONTEXT_FIELD, + UPDATE_CONTEXT_FIELD, + DELETE_CONTEXT_FIELD, +} = require('../../permissions'); class ContextController extends Controller { constructor(config) { super(config); - this.contextFields = builtInContextFields.concat( - config.customContextFields - ); + this.logger = config.getLogger('/admin-api/feature.js'); + this.eventStore = config.stores.eventStore; + this.store = config.stores.contextFieldStore; + this.get('/', this.getContextFields); + this.post('/', this.createContextField, CREATE_CONTEXT_FIELD); + this.get('/:contextField', this.getContextField); + this.put( + '/:contextField', + this.updateContextField, + UPDATE_CONTEXT_FIELD + ); + this.delete( + '/:contextField', + this.deleteContextField, + DELETE_CONTEXT_FIELD + ); + this.post('/validate', this.validate); } - getContextFields(req, res) { + async getContextFields(req, res) { + const fields = await this.store.getAll(); res.status(200) - .json(this.contextFields) + .json(fields) .end(); } + + async getContextField(req, res) { + try { + const name = req.params.contextField; + const contextField = await this.store.get(name); + res.json(contextField).end(); + } catch (err) { + res.status(404).json({ error: 'Could not find context field' }); + } + } + + async createContextField(req, res) { + const name = req.body.name; + const userName = extractUser(req); + + try { + await this.validateUniqueName(name); + const value = await contextSchema.validateAsync(req.body); + await this.eventStore.store({ + type: CONTEXT_FIELD_CREATED, + createdBy: userName, + data: value, + }); + res.status(201).end(); + } catch (error) { + handleErrors(res, this.logger, error); + } + } + + async updateContextField(req, res) { + const name = req.params.contextField; + const userName = extractUser(req); + const updatedContextField = req.body; + + updatedContextField.name = name; + + try { + await this.store.get(name); + + await contextSchema.validateAsync(updatedContextField); + await this.eventStore.store({ + type: CONTEXT_FIELD_UPDATED, + createdBy: userName, + data: updatedContextField, + }); + res.status(200).end(); + } catch (error) { + handleErrors(res, this.logger, error); + } + } + + async deleteContextField(req, res) { + const name = req.params.contextField; + + try { + await this.store.get(name); + await this.eventStore.store({ + type: CONTEXT_FIELD_DELETED, + createdBy: extractUser(req), + data: { name }, + }); + res.status(200).end(); + } catch (error) { + handleErrors(res, this.logger, error); + } + } + + async validateUniqueName(name) { + let msg; + try { + await this.store.get(name); + msg = 'A context field with that name already exist'; + } catch (error) { + // No conflict, everything ok! + return; + } + + // Interntional throw here! + throw new NameExistsError(msg); + } + + async validate(req, res) { + const name = req.body.name; + + try { + await nameSchema.validateAsync({ name }); + await this.validateUniqueName(name); + res.status(200).end(); + } catch (error) { + handleErrors(res, this.logger, error); + } + } } module.exports = ContextController; diff --git a/lib/routes/admin-api/context.test.js b/lib/routes/admin-api/context.test.js index 827fb91f09..988a09568d 100644 --- a/lib/routes/admin-api/context.test.js +++ b/lib/routes/admin-api/context.test.js @@ -27,7 +27,7 @@ function getSetup() { }; } -test('should get context definition', t => { +test('should get all context definitions', t => { t.plan(2); const { request, base } = getSetup(); return request @@ -35,8 +35,140 @@ test('should get context definition', t => { .expect('Content-Type', /json/) .expect(200) .expect(res => { - t.true(res.body.length === 4); + t.true(res.body.length === 3); const envField = res.body.find(c => c.name === 'environment'); t.true(envField.name === 'environment'); }); }); + +test('should get context definition', t => { + t.plan(1); + const { request, base } = getSetup(); + return request + .get(`${base}/api/admin/context/userId`) + .expect('Content-Type', /json/) + .expect(200) + .expect(res => { + t.is(res.body.name, 'userId'); + }); +}); + +test('should be allowed to use new context field name', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context/validate`) + .send({ name: 'new.name' }) + .set('Content-Type', 'application/json') + .expect(200); +}); + +test('should not be allowed reuse context field name', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context/validate`) + .send({ name: 'environment' }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test('should create a context field', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context`) + .send({ name: 'fancy', description: 'Bla bla' }) + .set('Content-Type', 'application/json') + .expect(201); +}); + +test('should create a context field with legal values', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context`) + .send({ + name: 'page', + description: 'Bla bla', + legalValues: ['blue', 'red'], + }) + .set('Content-Type', 'application/json') + .expect(201); +}); + +test('should require name when creating a context field', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context`) + .send({ description: 'Bla bla' }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test('should not create a context field with existing name', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context`) + .send({ name: 'userId', description: 'Bla bla' }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test('should not create a context field with duplicate legal values', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .post(`${base}/api/admin/context`) + .send({ + name: 'page', + description: 'Bla bla', + legalValues: ['blue', 'blue'], + }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test('should update a context field with new legal values', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .put(`${base}/api/admin/context/environment`) + .send({ + name: 'environment', + description: 'Used target application envrionments', + legalValues: ['local', 'stage', 'production'], + }) + .set('Content-Type', 'application/json') + .expect(200); +}); + +test('should not delete a unknown context field', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .delete(`${base}/api/admin/context/unknown`) + .set('Content-Type', 'application/json') + .expect(404); +}); + +test('should delete a context field', t => { + t.plan(0); + const { request, base } = getSetup(); + + return request + .delete(`${base}/api/admin/context/appName`) + .set('Content-Type', 'application/json') + .expect(200); +}); diff --git a/migrations/20200102184820-create-context-fields.js b/migrations/20200102184820-create-context-fields.js new file mode 100644 index 0000000000..49d3abf168 --- /dev/null +++ b/migrations/20200102184820-create-context-fields.js @@ -0,0 +1,37 @@ +/* eslint camelcase: "off" */ +'use strict'; + +const async = require('async'); + +exports.up = function(db, cb) { + async.series( + [ + db.createTable.bind(db, 'context_fields', { + name: { + type: 'string', + length: 255, + primaryKey: true, + notNull: true, + }, + description: { type: 'text' }, + sort_order: { type: 'int', defaultValue: 10 }, + legal_values: { type: 'text' }, + created_at: { type: 'timestamp', defaultValue: 'now()' }, + updated_at: { type: 'timestamp', defaultValue: 'now()' }, + }), + db.runSql.bind( + db, + ` + INSERT INTO context_fields(name, description, sort_order) VALUES('environment', 'Allows you to constrain on application environment', 0); + INSERT INTO context_fields(name, description, sort_order) VALUES('userId', 'Allows you to constrain on userId', 1); + INSERT INTO context_fields(name, description, sort_order) VALUES('appName', 'Allows you to constrain on application name', 2); + ` + ), + ], + cb + ); +}; + +exports.down = function(db, cb) { + return db.dropTable('context_fields', cb); +}; diff --git a/test/e2e/api/admin/context.e2e.test.js b/test/e2e/api/admin/context.e2e.test.js new file mode 100644 index 0000000000..94854224e0 --- /dev/null +++ b/test/e2e/api/admin/context.e2e.test.js @@ -0,0 +1,154 @@ +'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('context_api_serial', getLogger); + stores = db.stores; +}); + +test.after(async () => { + await stores.db.destroy(); +}); + +test.serial('gets all context fields', async t => { + t.plan(1); + const request = await setupApp(stores); + return request + .get('/api/admin/context') + .expect('Content-Type', /json/) + .expect(200) + .expect(res => { + t.is(res.body.length, 3, 'expected to have three context fields'); + }); +}); + +test.serial('get the context field', async t => { + t.plan(1); + const request = await setupApp(stores); + return request + .get('/api/admin/context/environment') + .expect('Content-Type', /json/) + .expect(200) + .expect(res => { + t.is(res.body.name, 'environment'); + }); +}); + +test.serial('should create context field', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context') + .send({ + name: 'country', + description: 'A Country', + }) + .set('Content-Type', 'application/json') + .expect(201); +}); + +test.serial('should create context field with legalValues', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context') + .send({ + name: 'region', + description: 'A region', + legalValues: ['north', 'south'], + }) + .set('Content-Type', 'application/json') + .expect(201); +}); + +test.serial('should update context field with legalValues', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .put('/api/admin/context/environment') + .send({ + name: 'environment', + description: 'Updated description', + legalValues: ['dev', 'prod'], + }) + .set('Content-Type', 'application/json') + .expect(200); +}); + +test.serial('should not create context field when name is missing', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context') + .send({ + description: 'A Country', + }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test.serial( + 'refuses to create a context field with an existing name', + async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context') + .send({ name: 'userId' }) + .set('Content-Type', 'application/json') + .expect(400); + } +); + +test.serial('should delete context field', async t => { + t.plan(0); + const request = await setupApp(stores); + return request.delete('/api/admin/context/userId').expect(200); +}); + +test.serial('refuses to create a context not url-friendly name', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context') + .send({ name: 'not very nice' }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test.serial('should validate name to ok', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context/validate') + .send({ name: 'newField' }) + .set('Content-Type', 'application/json') + .expect(200); +}); + +test.serial('should validate name to not ok', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context/validate') + .send({ name: 'environment' }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test.serial('should validate name to not ok for non url-friendly', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/context/validate') + .send({ name: 'not url friendly' }) + .set('Content-Type', 'application/json') + .expect(400); +}); diff --git a/test/e2e/helpers/database-init.js b/test/e2e/helpers/database-init.js index 668134d5a5..015fdf475b 100644 --- a/test/e2e/helpers/database-init.js +++ b/test/e2e/helpers/database-init.js @@ -21,12 +21,14 @@ async function resetDatabase(stores) { stores.db('features').del(), stores.db('client_applications').del(), stores.db('client_instances').del(), + stores.db('context_fields').del(), ]); } 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)); @@ -38,6 +40,10 @@ function createStrategies(store) { return dbState.strategies.map(s => store._createStrategy(s)); } +function createContextFields(store) { + return dbState.contextFields.map(c => store._createContextField(c)); +} + function createApplications(store) { return dbState.applications.map(a => store.upsert(a)); } diff --git a/test/e2e/helpers/database.json b/test/e2e/helpers/database.json index 576a44e105..7fd6e96db9 100644 --- a/test/e2e/helpers/database.json +++ b/test/e2e/helpers/database.json @@ -16,6 +16,11 @@ ] } ], + "contextFields": [ + { "name": "environment" }, + { "name": "userId" }, + { "name": "appNam" } + ], "applications": [ { "appName": "demo-app-1", diff --git a/test/fixtures/fake-context-store.js b/test/fixtures/fake-context-store.js new file mode 100644 index 0000000000..67f3ec7c83 --- /dev/null +++ b/test/fixtures/fake-context-store.js @@ -0,0 +1,24 @@ +'use strict'; + +const NotFoundError = require('../../lib/error/notfound-error'); + +module.exports = () => { + const _contextFields = [ + { name: 'environment' }, + { name: 'userId' }, + { name: 'appName' }, + ]; + + return { + getAll: () => Promise.resolve(_contextFields), + get: name => { + const field = _contextFields.find(c => c.name === name); + if (field) { + return Promise.resolve(field); + } else { + return Promise.reject(NotFoundError); + } + }, + create: contextField => _contextFields.push(contextField), + }; +}; diff --git a/test/fixtures/store.js b/test/fixtures/store.js index fb1083398e..a1a1a46377 100644 --- a/test/fixtures/store.js +++ b/test/fixtures/store.js @@ -6,6 +6,7 @@ const clientApplicationsStore = require('./fake-client-applications-store'); const featureToggleStore = require('./fake-feature-toggle-store'); const eventStore = require('./fake-event-store'); const strategyStore = require('./fake-strategies-store'); +const contextFieldStore = require('./fake-context-store'); module.exports = { createStores: () => { @@ -23,6 +24,7 @@ module.exports = { featureToggleStore: featureToggleStore(), eventStore: eventStore(), strategyStore: strategyStore(), + contextFieldStore: contextFieldStore(), }; }, };