1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: api supports context fields (#564)

* feat: api supports context fields

* fix: typo for cotnext group in event-differ
This commit is contained in:
Ivar Conradi Østhus 2020-02-27 21:26:18 +01:00 committed by GitHub
parent caab4434dd
commit 7a410508cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 665 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,11 @@
]
}
],
"contextFields": [
{ "name": "environment" },
{ "name": "userId" },
{ "name": "appNam" }
],
"applications": [
{
"appName": "demo-app-1",

24
test/fixtures/fake-context-store.js vendored Normal file
View File

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

View File

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