1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-01 00:08:27 +01:00

Merge pull request #183 from Unleash/strategy_def_format

Strategy should use better param description
This commit is contained in:
Ivar Conradi Østhus 2016-12-13 15:52:19 +01:00 committed by GitHub
commit 940f18c869
12 changed files with 244 additions and 61 deletions

View File

@ -12,25 +12,41 @@ Used to fetch all defined strategies and their defined paramters.
"version": 1, "version": 1,
"strategies": [ "strategies": [
{ {
"name": "default", "name": "default",
"description": "Default on/off strategy.", "description": "Default on/off strategy.",
"parametersTemplate": null "parameters": []
}, },
{ {
"name": "ActiveForUserWithEmail", "name": "userWithId",
"description": "A comma separated list of email adresses this feature should be active for.", "description": "Active for userId specified in the comma seperated 'userIds' parameter.",
"parametersTemplate": { "parameters": [
"emails": "string" {
} "name": "userIds",
"type": "list",
"description": "List of unique userIds the feature should be active for.",
"required": true
}
]
}, },
{ {
"name": "Accounts", "name": "gradualRollout",
"description": "Enable for user accounts", "description": "Gradual rollout to logged in users",
"parametersTemplate": { "parameters": [
"Accountname": "string" {
} "name": "percentage",
} "type": "percentage",
]} "description": "How many percent should the new feature be active for.",
"required": false
},
{
"name": "group",
"type": "string",
"description": "Group key to use when hasing the userId. Makes sure that the same user get different value for different groups",
"required": false
}
]
},
]}
``` ```
### Create strategy ### Create strategy
@ -41,12 +57,23 @@ Used to fetch all defined strategies and their defined paramters.
```json ```json
{ {
"name": "ActiveForUserWithEmail", "name": "gradualRollout",
"description": "A comma separated list of email adresses this feature should be active for.", "description": "Gradual rollout to logged in users",
"parametersTemplate": { "parameters": [
"emails": "string" {
} "name": "percentage",
} "type": "percentage",
"description": "How many percent should the new feature be active for.",
"required": false
},
{
"name": "group",
"type": "string",
"description": "Group key to use when hasing the userId. Makes sure that the same user get different value for different groups",
"required": false
}
]
},
``` ```
Used to create a new Strategy. Name must be unique. Used to create a new Strategy. Name is required and must be unique. It is also required to have a parameters array, but it can be empty.

View File

@ -59,11 +59,14 @@ npm test
We use database migrations to track database changes. We use database migrations to track database changes.
### Making a schema change ### Making a schema change
In order to run migrations you will set the environment variable for DATABASE_URL
`export DATABASE_URL=postgres://unleash_user:passord@localhost:5432/unleash`
Use db-migrate to create new migrations file. Use db-migrate to create new migrations file.
```bash ```bash
> ./node_modules/.bin/db-migrate create your-migration-name > npm run db-migrate -- create YOUR-MIGRATION-NAME
``` ```
All migrations requires on `up` and one `down` method. All migrations requires on `up` and one `down` method.
@ -86,6 +89,12 @@ exports.down = function (db, cb) {
}; };
``` ```
Test your migrations:
```bash
> npm run db-migrate -- up
> npm run db-migrate -- down
```
## Publishing / Releasing new packages ## Publishing / Releasing new packages

View File

@ -3,7 +3,7 @@
const { STRATEGY_CREATED, STRATEGY_DELETED } = require('../event-type'); const { STRATEGY_CREATED, STRATEGY_DELETED } = require('../event-type');
const logger = require('../logger'); const logger = require('../logger');
const NotFoundError = require('../error/notfound-error'); const NotFoundError = require('../error/notfound-error');
const STRATEGY_COLUMNS = ['name', 'description', 'parameters_template']; const STRATEGY_COLUMNS = ['name', 'description', 'parameters'];
const TABLE = 'strategies'; const TABLE = 'strategies';
class StrategyStore { class StrategyStore {
@ -44,7 +44,7 @@ class StrategyStore {
return { return {
name: row.name, name: row.name,
description: row.description, description: row.description,
parametersTemplate: row.parameters_template, parameters: row.parameters,
}; };
} }
@ -52,7 +52,7 @@ class StrategyStore {
return { return {
name: data.name, name: data.name,
description: data.description, description: data.description,
parameters_template: data.parametersTemplate // eslint-disable-line parameters: JSON.stringify(data.parameters),
}; };
} }

View File

@ -0,0 +1,20 @@
'use strict';
const joi = require('joi');
const strategySchema = joi.object().keys({
name: joi.string()
.regex(/^[a-zA-Z0-9\\.\\-]{3,30}$/)
.required(),
description: joi.string(),
parameters: joi.array()
.required()
.items(joi.object().keys({
name: joi.string().required(),
type: joi.string().required(),
description: joi.string(),
required: joi.boolean(),
})),
});
module.exports = strategySchema;

View File

@ -1,29 +1,28 @@
'use strict'; 'use strict';
const joi = require('joi');
const eventType = require('../event-type'); const eventType = require('../event-type');
const logger = require('../logger'); const logger = require('../logger');
const NameExistsError = require('../error/name-exists-error'); const NameExistsError = require('../error/name-exists-error');
const ValidationError = require('../error/validation-error.js');
const NotFoundError = require('../error/notfound-error');
const validateRequest = require('../error/validate-request');
const extractUser = require('../extract-user'); const extractUser = require('../extract-user');
const strategySchema = require('./strategy-schema');
const version = 1; const version = 1;
const handleError = (req, res, error) => { const handleError = (req, res, error) => {
switch (error.constructor) { switch (error.name) {
case NotFoundError: case 'NotFoundError':
return res return res
.status(404) .status(404)
.end(); .end();
case NameExistsError: case 'NameExistsError':
return res return res
.status(403) .status(403)
.json([{ msg: `A strategy named '${req.body.name}' already exists.` }]) .json([{ msg: `A strategy named '${req.body.name}' already exists.` }])
.end(); .end();
case ValidationError: case 'ValidationError':
return res return res
.status(400) .status(400)
.json(req.validationErrors()) .json(error)
.end(); .end();
default: default:
logger.error('Could perfom operation', error); logger.error('Could perfom operation', error);
@ -64,14 +63,10 @@ module.exports = function (app, config) {
}); });
app.post('/strategies', (req, res) => { app.post('/strategies', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty(); const data = req.body;
req.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$').matches(/^[0-9a-zA-Z\\.\\-]+$/i); validateInput(data)
const newStrategy = req.body;
validateRequest(req)
.then(validateStrategyName) .then(validateStrategyName)
.then(() => eventStore.store({ .then((newStrategy) => eventStore.store({
type: eventType.STRATEGY_CREATED, type: eventType.STRATEGY_CREATED,
createdBy: extractUser(req), createdBy: extractUser(req),
data: newStrategy, data: newStrategy,
@ -80,11 +75,22 @@ module.exports = function (app, config) {
.catch(error => handleError(req, res, error)); .catch(error => handleError(req, res, error));
}); });
function validateStrategyName (req) { function validateStrategyName (data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
strategyStore.getStrategy(req.body.name) strategyStore.getStrategy(data.name)
.then(() => reject(new NameExistsError('Feature name already exist'))) .then(() => reject(new NameExistsError('Feature name already exist')))
.catch(() => resolve(req)); .catch(() => resolve(data));
});
}
function validateInput (data) {
return new Promise((resolve, reject) => {
joi.validate(data, strategySchema, (err, cleaned) => {
if (err) {
return reject(err);
}
return resolve(cleaned);
});
}); });
} }
}; };

View File

@ -0,0 +1,61 @@
/* eslint camelcase: "off" */
'use strict';
const async = require('async');
exports.up = function (db, callback) {
const populateNewData = (cb) => {
db.all('select name, parameters_template from strategies', (err, results) => {
const updateSQL = results
.map(({ name, parameters_template }) => {
const parameters = [];
Object.keys(parameters_template || {}).forEach(p => {
parameters.push({ name: p, type: parameters_template[p], description: '', required: false });
});
return { name, parameters };
})
.map(strategy => `
UPDATE strategies
SET parameters='${JSON.stringify(strategy.parameters)}'
WHERE name='${strategy.name}';`)
.join('\n');
db.runSql(updateSQL, cb);
});
};
async.series([
db.addColumn.bind(db, 'strategies', 'parameters', { type: 'json' }),
populateNewData.bind(db),
db.removeColumn.bind(db, 'strategies', 'parameters_template'),
], callback);
};
exports.down = function (db, callback) {
const populateOldData = (cb) => {
db.all('select name, parameters from strategies', (err, results) => {
const updateSQL = results
.map(({ name, parameters }) => {
const parameters_template = {};
parameters.forEach(p => {
parameters_template[p.name] = p.type;
});
return { name, parameters_template };
})
.map(strategy => `
UPDATE strategies
SET parameters_template='${JSON.stringify(strategy.parameters_template)}'
WHERE name='${strategy.name}';`)
.join('\n');
db.runSql(updateSQL, cb);
});
};
async.series([
db.addColumn.bind(db, 'strategies', 'parameters_template', { type: 'json' }),
populateOldData.bind(db),
db.removeColumn.bind(db, 'strategies', 'parameters'),
], callback);
};

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
require('db-migrate-shared').log.setLogLevel('error');
const { getInstance } = require('db-migrate'); const { getInstance } = require('db-migrate');
const parseDbUrl = require('parse-database-url'); const parseDbUrl = require('parse-database-url');

View File

@ -37,8 +37,7 @@
"start:dev": "NODE_ENV=development supervisor --ignore ./node_modules/ server.js", "start:dev": "NODE_ENV=development supervisor --ignore ./node_modules/ server.js",
"start:dev:pg": "pg_virtualenv npm run start:dev:pg-chain", "start:dev:pg": "pg_virtualenv npm run start:dev:pg-chain",
"start:dev:pg-chain": "export DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@localhost:$PGPORT/postgres ; db-migrate up && npm run start:dev", "start:dev:pg-chain": "export DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@localhost:$PGPORT/postgres ; db-migrate up && npm run start:dev",
"db-migrate": "db-migrate up", "db-migrate": "db-migrate",
"db-migrate:down": "db-migrate down",
"lint": "eslint lib", "lint": "eslint lib",
"pretest": "npm run lint", "pretest": "npm run lint",
"test": "PORT=4243 ava test lib/*.test.js lib/**/*.test.js", "test": "PORT=4243 ava test lib/*.test.js lib/**/*.test.js",

View File

@ -48,14 +48,14 @@ function createStrategies (stores) {
{ {
name: 'default', name: 'default',
description: 'Default on or off Strategy.', description: 'Default on or off Strategy.',
parametersTemplate: {}, parameters: [],
}, },
{ {
name: 'usersWithEmail', name: 'usersWithEmail',
description: 'Active for users defined in the comma-separated emails-parameter.', description: 'Active for users defined in the comma-separated emails-parameter.',
parametersTemplate: { parameters: [
emails: 'String', { name: 'emails', type: 'string' },
}, ],
}, },
].map(strategy => stores.strategyStore._createStrategy(strategy)); ].map(strategy => stores.strategyStore._createStrategy(strategy));
} }

View File

@ -14,6 +14,9 @@ test.serial('gets all strategies', async (t) => {
.get('/api/strategies') .get('/api/strategies')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => {
t.true(res.body.strategies.length === 2, 'expected to have two strategies');
})
.then(destroy); .then(destroy);
}); });
@ -39,7 +42,7 @@ test.serial('creates a new strategy', async (t) => {
const { request, destroy } = await setupApp('strategy_api_serial'); const { request, destroy } = await setupApp('strategy_api_serial');
return request return request
.post('/api/strategies') .post('/api/strategies')
.send({ name: 'myCustomStrategy', description: 'Best strategy ever.' }) .send({ name: 'myCustomStrategy', description: 'Best strategy ever.', parameters: [] })
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(201) .expect(201)
.then(destroy); .then(destroy);
@ -59,7 +62,7 @@ test.serial('refuses to create a strategy with an existing name', async (t) => {
const { request, destroy } = await setupApp('strategy_api_serial'); const { request, destroy } = await setupApp('strategy_api_serial');
return request return request
.post('/api/strategies') .post('/api/strategies')
.send({ name: 'default' }) .send({ name: 'default', parameters: [] })
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(403) .expect(403)
.then(destroy); .then(destroy);

View File

@ -1,5 +1,6 @@
'use strict'; 'use strict';
const NotFoundError = require('../../../../lib/error/notfound-error');
@ -8,6 +9,14 @@ module.exports = () => {
return { return {
getStrategies: () => Promise.resolve(_strategies), getStrategies: () => Promise.resolve(_strategies),
getStrategy: (name) => {
const strategy = _strategies.find(s => s.name === name);
if (strategy) {
return Promise.resolve(strategy);
} else {
return Promise.reject(new NotFoundError('Not found!'));
}
},
addStrategy: (strat) => _strategies.push(strat), addStrategy: (strat) => _strategies.push(strat),
}; };
}; };

View File

@ -3,31 +3,78 @@
const test = require('ava'); const test = require('ava');
const store = require('./fixtures/store'); const store = require('./fixtures/store');
const supertest = require('supertest'); const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app'); const getApp = require('../../../lib/app');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const eventBus = new EventEmitter(); const eventBus = new EventEmitter();
test.beforeEach(() => { function getSetup () {
logger.setLevel('FATAL'); const base = `/random${Math.round(Math.random() * 1000)}`;
});
test('should add version numbers for /stategies', t => {
const stores = store.createStores(); const stores = store.createStores();
const app = getApp({ const app = getApp({
baseUriPath: '', baseUriPath: base,
stores, stores,
eventBus, eventBus,
}); });
const request = supertest(app); return {
base,
strategyStore: stores.strategyStore,
request: supertest(app),
};
}
test('should add version numbers for /stategies', t => {
const { request, base } = getSetup();
return request return request
.get('/api/strategies') .get(`${base}/api/strategies`)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
t.true(res.body.version === 1); t.true(res.body.version === 1);
}); });
}); });
test('should require a name when creating a new stratey', t => {
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.send({})
.expect(400)
.expect((res) => {
t.true(res.body.name === 'ValidationError');
});
});
test('should require parameters array when creating a new stratey', t => {
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.send({ name: 'TestStrat' })
.expect(400)
.expect((res) => {
t.true(res.body.name === 'ValidationError');
});
});
test('should create a new stratey with empty parameters', () => {
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.send({ name: 'TestStrat', parameters: [] })
.expect(201);
});
test('should not be possible to override name', () => {
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name: 'Testing', parameters: [] });
return request
.post(`${base}/api/strategies`)
.send({ name: 'Testing', parameters: [] })
.expect(403);
});