Skip to content

Commit

Permalink
feat: Added import & export through stateService #395
Browse files Browse the repository at this point in the history
TODO: tests
  • Loading branch information
Kouzukii committed Mar 13, 2019
1 parent 7e0913b commit efe4a7e
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 13 deletions.
11 changes: 10 additions & 1 deletion lib/db/event-store.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const { DROP_FEATURES } = require('../event-type');
const { EventEmitter } = require('events');

const EVENT_COLUMNS = ['id', 'type', 'created_by', 'created_at', 'data'];
Expand All @@ -14,7 +15,7 @@ class EventStore extends EventEmitter {
return this.db('events')
.insert({
type: event.type,
created_by: event.createdBy, // eslint-disable-line
created_by: event.createdBy, // eslint-disable-line
data: event.data,
})
.then(() => this.emit(event.type, event));
Expand All @@ -35,6 +36,14 @@ class EventStore extends EventEmitter {
.from('events')
.limit(100)
.whereRaw("data ->> 'name' = ?", [name])
.andWhere(
'id',
'>=',
this.db
.select(this.db.raw('coalesce(max(id),0) as id'))
.from('events')
.where({ type: DROP_FEATURES })
)
.orderBy('created_at', 'desc')
.map(this.rowToEvent);
}
Expand Down
27 changes: 27 additions & 0 deletions lib/db/feature-toggle-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const {
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_IMPORT,
DROP_FEATURES,
} = require('../event-type');
const logger = require('../logger')('client-toggle-store.js');
const NotFoundError = require('../error/notfound-error');
Expand Down Expand Up @@ -33,6 +35,8 @@ class FeatureToggleStore {
eventStore.on(FEATURE_REVIVED, event =>
this._reviveFeature(event.data)
);
eventStore.on(FEATURE_IMPORT, event => this._importFeature(event.data));
eventStore.on(DROP_FEATURES, () => this._dropFeatures());
}

getFeatures() {
Expand Down Expand Up @@ -137,6 +141,29 @@ class FeatureToggleStore {
logger.error('Could not archive feature, error was: ', err)
);
}

_importFeature(data) {
data = this.eventDataToRow(data);
return this.db
.raw(`? ON CONFLICT (name) DO ?`, [
this.db(TABLE).insert(data),
this.db
.queryBuilder()
.update(data)
.update('archived', 0),
])
.catch(err =>
logger.error('Could not import feature, error was: ', err)
);
}

_dropFeatures() {
return this.db(TABLE)
.delete()
.catch(err =>
logger.error('Could not drop features, error was: ', err)
);
}
}

module.exports = FeatureToggleStore;
47 changes: 39 additions & 8 deletions lib/db/strategy-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const {
STRATEGY_CREATED,
STRATEGY_DELETED,
STRATEGY_UPDATED,
STRATEGY_IMPORT,
DROP_STRATEGIES,
} = require('../event-type');
const logger = require('../logger')('strategy-store.js');
const NotFoundError = require('../error/notfound-error');
Expand All @@ -19,14 +21,13 @@ class StrategyStore {
eventStore.on(STRATEGY_UPDATED, event =>
this._updateStrategy(event.data)
);
eventStore.on(STRATEGY_DELETED, event => {
db(TABLE)
.where('name', event.data.name)
.del()
.catch(err => {
logger.error('Could not delete strategy, error was: ', err);
});
});
eventStore.on(STRATEGY_DELETED, event =>
this._deleteStrategy(event.data)
);
eventStore.on(STRATEGY_IMPORT, event =>
this._importStrategy(event.data)
);
eventStore.on(DROP_STRATEGIES, () => this._dropStrategies());
}

getStrategies() {
Expand Down Expand Up @@ -81,6 +82,36 @@ class StrategyStore {
logger.error('Could not update strategy, error was: ', err)
);
}

_deleteStrategy({ name }) {
return this.db(TABLE)
.where({ name })
.del()
.catch(err => {
logger.error('Could not delete strategy, error was: ', err);
});
}

_importStrategy(data) {
data = this.eventDataToRow(data);
return this.db
.raw(`? ON CONFLICT (name) DO ?`, [
this.db(TABLE).insert(data),
this.db.queryBuilder().update(data).where(`${TABLE}.built_in`, 0), // eslint-disable-line
])
.catch(err =>
logger.error('Could not import strategy, error was: ', err)
);
}

_dropStrategies() {
return this.db(TABLE)
.where({ built_in: 0 }) // eslint-disable-line
.delete()
.catch(err =>
logger.error('Could not drop strategies, error was: ', err)
);
}
}

module.exports = StrategyStore;
14 changes: 13 additions & 1 deletion lib/event-differ.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,32 @@ const {
STRATEGY_CREATED,
STRATEGY_DELETED,
STRATEGY_UPDATED,
STRATEGY_IMPORT,
DROP_STRATEGIES,
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_IMPORT,
DROP_FEATURES,
} = require('./event-type');
const diff = require('deep-diff').diff;

const strategyTypes = [STRATEGY_CREATED, STRATEGY_DELETED, STRATEGY_UPDATED];
const strategyTypes = [
STRATEGY_CREATED,
STRATEGY_DELETED,
STRATEGY_UPDATED,
STRATEGY_IMPORT,
DROP_STRATEGIES,
];

const featureTypes = [
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
FEATURE_IMPORT,
DROP_FEATURES,
];

function baseTypeFor(event) {
Expand Down
4 changes: 4 additions & 0 deletions lib/event-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ module.exports = {
FEATURE_UPDATED: 'feature-updated',
FEATURE_ARCHIVED: 'feature-archived',
FEATURE_REVIVED: 'feature-revived',
FEATURE_IMPORT: 'feature-import',
DROP_FEATURES: 'drop-features',
STRATEGY_CREATED: 'strategy-created',
STRATEGY_DELETED: 'strategy-deleted',
STRATEGY_UPDATED: 'strategy-updated',
STRATEGY_IMPORT: 'strategy-import',
DROP_STRATEGIES: 'drop-strategies',
};
2 changes: 2 additions & 0 deletions lib/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const DEFAULT_OPTIONS = {
secret: 'UNLEASH-SECRET',
sessionAge: THIRTY_DAYS,
adminAuthentication: 'unsecure',
importFile: undefined,
dropBeforeImport: false,
};

module.exports = {
Expand Down
3 changes: 3 additions & 0 deletions lib/routes/admin-api/api-def.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
},
"metrics": {
"uri": "/api/admin/metrics"
},
"state": {
"uri": "/api/admin/state"
}
}
}
2 changes: 2 additions & 0 deletions lib/routes/admin-api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const EventController = require('./event.js');
const StrategyController = require('./strategy');
const MetricsController = require('./metrics');
const UserController = require('./user');
const StateController = require('./state');
const apiDef = require('./api-def.json');

class AdminApi extends Controller {
Expand All @@ -20,6 +21,7 @@ class AdminApi extends Controller {
this.app.use('/events', new EventController(config).router);
this.app.use('/metrics', new MetricsController(config).router);
this.app.use('/user', new UserController(config).router);
this.app.use('/state', new StateController(config).router);
}

index(req, res) {
Expand Down
80 changes: 80 additions & 0 deletions lib/routes/admin-api/state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use strict';

const Controller = require('../controller');
const { ADMIN } = require('../../permissions');
const extractUser = require('../../extract-user');
const { handleErrors } = require('./util');
const YAML = require('js-yaml');
const moment = require('moment');
const multer = require('multer');
const upload = multer({ limits: { fileSize: 5242880 } });

class ImportController extends Controller {
constructor(config) {
super(config);
this.fileupload('/import', upload.single('file'), this.import, ADMIN);
this.get('/export', this.export, ADMIN);
}

async import(req, res) {
const userName = extractUser(req);
const { drop } = req.query;

let data;
if (req.file) {
if (req.file.mimetype === 'text/yaml') {
data = YAML.safeLoad(req.file.buffer);
} else {
data = JSON.parse(req.file.buffer);
}
} else {
data = req.body;
}

try {
await this.config.stateService.import({
data,
userName,
dropBeforeImport: drop,
});
res.sendStatus(202);
} catch (err) {
handleErrors(res, err);
}
}

async export(req, res) {
const { format } = req.query;

let strategies = 'strategies' in req.query;
let featureToggles = 'features' in req.query;

if (!strategies && !featureToggles) {
strategies = true;
featureToggles = true;
}

try {
const data = await this.config.stateService.export({
strategies,
featureToggles,
});
const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
if (format === 'yaml') {
if ('download' in req.query) {
res.attachment(`export-${timestamp}.yml`);
}
res.type('yaml').send(YAML.safeDump(data));
} else {
if ('download' in req.query) {
res.attachment(`export-${timestamp}.json`);
}
res.json(data);
}
} catch (err) {
handleErrors(res, err);
}
}
}

module.exports = ImportController;
9 changes: 9 additions & 0 deletions lib/routes/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ class Controller {
);
}

fileupload(path, filehandler, handler, permission) {
this.app.post(
path,
checkPermission(this.config, permission),
filehandler,
handler.bind(this)
);
}

use(path, router) {
this.app.use(path, router);
}
Expand Down
24 changes: 21 additions & 3 deletions lib/server-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ const getApp = require('./app');
const { startMonitoring } = require('./metrics');
const { createStores } = require('./db');
const { createOptions } = require('./options');
const StateService = require('./state-service');
const User = require('./user');
const AuthenticationRequired = require('./authentication-required');

function createApp(options) {
async function createApp(options) {
// Database dependecies (statefull)
const stores = createStores(options);
const eventBus = new EventEmitter();
Expand All @@ -35,12 +36,29 @@ function createApp(options) {
stores.clientMetricsStore
);

const stateService = new StateService(config);
config.stateService = stateService;
if (config.importFile) {
await stateService.importFile({
importFile: config.importFile,
dropBeforeImport: config.dropBeforeImport,
userName: 'importer',
});
}

const server = app.listen({ port: options.port, host: options.host }, () =>
logger.info(`Unleash started on port ${server.address().port}`)
);

return new Promise((resolve, reject) => {
server.on('listening', () => resolve({ app, server, eventBus }));
return await new Promise((resolve, reject) => {
server.on('listening', () =>
resolve({
app,
server,
eventBus,
stateService,
})
);
server.on('error', reject);
});
}
Expand Down

0 comments on commit efe4a7e

Please sign in to comment.