Skip to content

Commit

Permalink
Refactored state-service, schemas, added e2e tests #395
Browse files Browse the repository at this point in the history
  • Loading branch information
Kouzukii authored and ivarconr committed Feb 20, 2020
1 parent 9065c5e commit a06d2c0
Show file tree
Hide file tree
Showing 15 changed files with 410 additions and 92 deletions.
2 changes: 2 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"ecmaVersion": "2017"
},
"rules": {
"no-param-reassign": "error",
"no-return-await": "error",
"max-nested-callbacks": "off",
"new-cap": [
"error",
Expand Down
87 changes: 87 additions & 0 deletions docs/import-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
id: import_export
title: Import & Export
---

Unleash supports import and export of feature-toggles and strategies at startup and during runtime. The import mechanism will guarantee that all imported features will be non-archived, as well as updates to strategies and features are included in the event history.

All import mechanisms support a `drop` parameter which will clean the database before import (all strategies and features will be removed).\
**You should never use this in production environments.**

_since v3.3.0_

## Runtime import & export

### State Service

Unleash returns a StateService when started, you can use this to import and export data at any time.

```javascript
const unleash = require('unleash-server');

unleash.start({...})
.then(async ({ stateService }) => {
const exportedData = await stateService.export({includeStrategies: false, includeFeatureToggles: true});
await stateService.import({data: exportedData, userName: 'import', dropBeforeImport: false});
await stateService.importFile({file: 'exported-data.yml', userName: 'import', dropBeforeImport: true})
});
```

If you want the database to be cleaned before import (all strategies and features will be removed), set the `dropBeforeImport` parameter.\
**You should never use this in production environments.**

### API Export

The api endpoint `/api/admin/state/export` will export feature-toggles and strategies as json by default.\
You can customize the export with queryparameters:

| Parameter | Default | Description |
| -------------- | ------- | --------------------------------------------------- |
| format | `json` | Export format, either `json` or `yaml` |
| download | `false` | If the exported data should be downloaded as a file |
| featureToggles | `true` | Include feature-toggles in the exported data |
| strategies | `true` | Include strategies in the exported data |

For example if you want to download all feature-toggles as yaml:

```
/api/admin/state/export?format=yaml&featureToggles=1&download=1
```

### API Import

You can import feature-toggles and strategies by POSTing to the `/api/admin/state/import` endpoint (keep in mind this will require authentication).\
You can either send the data as JSON in the POST-body or send a `file` parameter with `multipart/form-data` (YAML files are also accepted here).

If you want the database to be cleaned before import (all strategies and features will be removed), specify a `drop` query parameter.\
**You should never use this in production environments.**

Example usage:

```
POST /api/admin/state/import
{
"features": [
{
"name": "a-feature-toggle",
"enabled": true,
"description": "#1 feature-toggle"
}
]
}
```

## Startup import

### Import files via config parameter

You can import a json or yaml file via the configuration option `importFile`.

Example usage: `unleash-server --databaseUrl ... --importFile export.yml`.

If you want the database to be cleaned before import (all strategies and features will be removed), specify the `dropBeforeImport` option.\
**You should never use this in production environments.**

Example usage: `unleash-server --databaseUrl ... --importFile export.yml --dropBeforeImport`.

These options can also be passed into the `unleash.start()` entrypoint.
16 changes: 7 additions & 9 deletions lib/db/feature-toggle-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,13 @@ class FeatureToggleStore {
}

_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),
])
const rowData = this.eventDataToRow(data);
return this.db(TABLE)
.where({ name: rowData.name })
.update(rowData)
.then(result =>
result === 0 ? this.db(TABLE).insert(rowData) : result
)
.catch(err =>
logger.error('Could not import feature, error was: ', err)
);
Expand Down
33 changes: 27 additions & 6 deletions lib/db/strategy-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ class StrategyStore {
.map(this.rowToStrategy);
}

getEditableStrategies() {
return this.db
.select(STRATEGY_COLUMNS)
.from(TABLE)
.where({ built_in: 0 }) // eslint-disable-line
.orderBy('name', 'asc')
.map(this.rowToEditableStrategy);
}

getStrategy(name) {
return this.db
.first(STRATEGY_COLUMNS)
Expand All @@ -58,6 +67,17 @@ class StrategyStore {
};
}

rowToEditableStrategy(row) {
if (!row) {
throw new NotFoundError('No strategy found');
}
return {
name: row.name,
description: row.description,
parameters: row.parameters,
};
}

eventDataToRow(data) {
return {
name: data.name,
Expand Down Expand Up @@ -93,12 +113,13 @@ class StrategyStore {
}

_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
])
const rowData = this.eventDataToRow(data);
return this.db(TABLE)
.where({ name: rowData.name, built_in: 0 }) // eslint-disable-line
.update(rowData)
.then(result =>
result === 0 ? this.db(TABLE).insert(rowData) : result
)
.catch(err =>
logger.error('Could not import strategy, error was: ', err)
);
Expand Down
10 changes: 7 additions & 3 deletions lib/routes/admin-api/feature-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,22 @@ const featureShema = joi
.keys({
name: nameType,
enabled: joi.boolean().default(false),
description: joi.string(),
description: joi
.string()
.allow('')
.allow(null)
.optional(),
strategies: joi
.array()
.required()
.min(1)
.items(strategiesSchema),
variants: joi
.array()
.allow(null)
.unique((a, b) => a.name === b.name)
.optional()
.items(variantsSchema)
.allow(null),
.items(variantsSchema),
})
.options({ allowUnknown: false, stripUnknown: true });

Expand Down
24 changes: 13 additions & 11 deletions lib/routes/admin-api/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const moment = require('moment');
const multer = require('multer');
const upload = multer({ limits: { fileSize: 5242880 } });

class ImportController extends Controller {
class StateController extends Controller {
constructor(config) {
super(config);
this.fileupload('/import', upload.single('file'), this.import, ADMIN);
Expand Down Expand Up @@ -46,27 +46,29 @@ class ImportController extends Controller {
async export(req, res) {
const { format } = req.query;

let strategies = 'strategies' in req.query;
let featureToggles = 'features' in req.query;
const downloadFile = Boolean(req.query.download);
let includeStrategies = Boolean(req.query.strategies);
let includeFeatureToggles = Boolean(req.query.featureToggles);

if (!strategies && !featureToggles) {
strategies = true;
featureToggles = true;
// if neither is passed as query argument, export both
if (!includeStrategies && !includeFeatureToggles) {
includeStrategies = true;
includeFeatureToggles = true;
}

try {
const data = await this.config.stateService.export({
strategies,
featureToggles,
includeStrategies,
includeFeatureToggles,
});
const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
if (format === 'yaml') {
if ('download' in req.query) {
if (downloadFile) {
res.attachment(`export-${timestamp}.yml`);
}
res.type('yaml').send(YAML.safeDump(data));
} else {
if ('download' in req.query) {
if (downloadFile) {
res.attachment(`export-${timestamp}.json`);
}
res.json(data);
Expand All @@ -77,4 +79,4 @@ class ImportController extends Controller {
}
}

module.exports = ImportController;
module.exports = StateController;
12 changes: 10 additions & 2 deletions lib/routes/admin-api/strategy-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,23 @@ const { nameType } = require('./util');
const strategySchema = joi.object().keys({
name: nameType,
editable: joi.boolean().default(true),
description: joi.string(),
description: joi
.string()
.allow(null)
.allow('')
.optional(),
parameters: joi
.array()
.required()
.items(
joi.object().keys({
name: joi.string().required(),
type: joi.string().required(),
description: joi.string().allow(''),
description: joi
.string()
.allow(null)
.allow('')
.optional(),
required: joi.boolean(),
})
),
Expand Down
6 changes: 3 additions & 3 deletions lib/server-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,17 @@ async function createApp(options) {
config.stateService = stateService;
if (config.importFile) {
await stateService.importFile({
importFile: config.importFile,
file: config.importFile,
dropBeforeImport: config.dropBeforeImport,
userName: 'importer',
userName: 'import',
});
}

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

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

0 comments on commit a06d2c0

Please sign in to comment.