1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-13 11:17:26 +02:00

Merge pull request #227 from Unleash/refactor-admin-api-routes

Refactor admin api routes
This commit is contained in:
Sveinung Røsaker 2017-06-28 12:38:41 +02:00 committed by GitHub
commit cae55e6031
100 changed files with 7243 additions and 1692 deletions

View File

@ -1,12 +1,22 @@
{
"extends": [
"finn",
"finn/node"
"finn/node",
"finn-prettier"
],
"parserOptions": {
"ecmaVersion": "2017"
},
"rules": {
"max-nested-callbacks": "off"
"max-nested-callbacks": "off",
"new-cap": [
"error",
{
"capIsNewExceptions": [
"Router",
"Mitm"
]
}
]
}
}

View File

@ -1,5 +1,12 @@
# Changelog
## [Unreleased]
- moved api endpoints to /api/admin/* and /api/client/*
- refactored all routes to use a standalone router per file
- removed v.1 legacy data support
- removed v.1 legacy /features endpoint
- added prettier and upgraded eslint
## 2.2.0
- Expose hooks in main export #223

View File

@ -1,6 +1,6 @@
# Events API
`GET: http://unleash.host.com/api/events`
`GET: http://unleash.host.com/api/admin/events`
Used to fetch all changes in the unleash system.
@ -37,4 +37,4 @@ Defined event types:
}
]
}
```
```

View File

@ -1,29 +1,16 @@
### Fetching Feature Toggles
`GET: http://unleash.host.com/api/features`
`GET: http://unleash.host.com/api/admin/features`
**HEADERS:**
* UNLEASH-APPNAME: appName
* UNLEASH-INSTANCEID: instanceId
This endpoint is the one all clients should use to fetch all available feature toggles
This endpoint is the one all admin ui should use to fetch all available feature toggles
from the _unleash-server_. The response returns all active feature toggles and their
current strategy configuration. A feature toggle will have _at least_ one configured strategy.
A strategy will have a `name` and `parameters` map.
> _Note:_ Clients should perfer the `strategies` property.
> Legacy properties (`strategy` & `parameters`) will be kept until **version 2** of the format.
This endpoint should never return anything besides a valid *20X or 304-response*. It will also
include a `Etag`-header. The value of this header can be used by clients as the value of
the `If-None-Match`-header in the request to prevent a data transfer if the clients already
has the latest response locally.
**Example response:**
```json
{
"version": 1,
"version": 2,
"features": [
{
"name": "Feature.A",
@ -34,9 +21,7 @@ has the latest response locally.
"name": "default",
"parameters": {}
}
],
"strategy": "default",
"parameters": {}
]
},
{
"name": "Feature.B",
@ -55,23 +40,17 @@ has the latest response locally.
"percentage": "10"
}
}
],
"strategy": "ActiveForUserWithId",
"parameters": {
"userIdList": "123,221,998"
}
]
}
]
}
```
`GET: http://unleash.host.com/api/features/:featureName`
`GET: http://unleash.host.com/api/admin/features/:featureName`
Used to fetch details about a specific featureToggle. This is mostly provded to make it easy to
debug the API and should not be used by the client implementations.
> _Notice_: You will not get a version property when fetching a specific feature toggle by name.
```json
{
"name": "Feature.A",
@ -82,16 +61,14 @@ debug the API and should not be used by the client implementations.
"name": "default",
"parameters": {}
}
],
"strategy": "default",
"parameters": {}
]
}
```
### Create a new Feature Toggle
`POST: http://unleash.host.com/api/features/`
`POST: http://unleash.host.com/api/admin/features/`
**Body:**
```json
@ -115,7 +92,7 @@ Returns 200-respose if the feature toggle was created successfully.
### Update a Feature Toggle
`PUT: http://unleash.host.com/api/features/:toggleName`
`PUT: http://unleash.host.com/api/admin/features/:toggleName`
**Body:**
```json
@ -139,7 +116,7 @@ Returns 200-respose if the feature toggle was updated successfully.
### Archive a Feature Toggle
`DELETE: http://unleash.host.com/api/features/:toggleName`
`DELETE: http://unleash.host.com/api/admin/features/:toggleName`
Used to archive a feature toggle. A feature toggle can never be totally be deleted,
but can be archived. This is a design decision to make sure that a old feature
@ -149,7 +126,7 @@ toggle suddenly reappears becuase someone else re-using the same name.
### Fetch archived toggles
`GET http://unleash.host.com/api/archive/features`
`GET http://unleash.host.com/api/admin/archive/features`
Used to fetch list of archived feature toggles
@ -177,7 +154,7 @@ Used to fetch list of archived feature toggles
### Revive feature toggle
`POST http://unleash.host.com/api/archive/revive`
`POST http://unleash.host.com/api/admin/archive/revive`
**Body:**
```json

View File

@ -1,61 +1,9 @@
# This document describes the client metrics endpoints
### Client registration
`POST: http://unleash.host.com/api/client/register`
Register a client instance with the unleash server. The client should send all fields specified.
```json
{
"appName": "appName",
"instanceId": "instanceId",
"strategies": ["default", "some-strategy-1"],
"started": "2016-11-03T07:16:43.572Z",
"interval": 10000,
}
```
**Fields:**
* **appName** - Name of the application seen by unleash-server
* **instanceId** - Instance id for this application (typically hostname, podId or similar)
* **strategies** - List of strategies implemented by this application
* **started** - When this client started. Should be reported as UTC time.
* **interval** - At wich interval will this client be expected to send metrics?
### Send metrics
`POST http://unleash.host.com/api/client/metrics`
Register a metrics payload with a timed bucket.
```json
{
"appName": "appName",
"instanceId": "instanceId",
"bucket": {
"start": "2016-11-03T07:16:43.572Z",
"stop": "2016-11-03T07:16:53.572Z",
"toggles": {
"toggle-name-1": {
"yes": 123,
"no": 321
},
"toggle-name-2": {
"yes": 111,
"no": 0
}
}
},
}
```
# This document describes the metrics endpoint for admin ui
### Seen-toggles
`GET http://unleash.host.com/api/client/seen-toggles`
`GET http://unleash.host.com/api/admin/seen-toggles`
This enpoints returns a list of applications and what toogles
unleash has seend for each application. It will only guarantee
@ -96,7 +44,7 @@ will in most cases remember seen-toggles for applications longer
### Feature-Toggles metrics
`GET http://unleash.host.com/api/client/metrics/feature-toggles`
`GET http://unleash.host.com/api/admin/metrics/feature-toggles`
This endpoint gives _last minute_ and _last hour_ metrics for all active toggles. This is based on
metrics reported by client applications. Yes is the number of times a given feature toggle
@ -147,7 +95,7 @@ was evaluated to enabled in a client applcation, and no is the number it avaluat
### Applications
`GET http://unleash.host.com/api/client/applications`
`GET http://unleash.host.com/api/admin/applications`
This endpoint returns a list of known applications (seen the last two days) and
a link to follow for more datails.
@ -165,7 +113,7 @@ a link to follow for more datails.
],
"createdAt": "2016-12-09T14:56:36.730Z",
"links": {
"appDetails": "/api/client/applications/another"
"appDetails": "/api/admin/applications/another"
}
},
{
@ -177,7 +125,7 @@ a link to follow for more datails.
],
"createdAt": "2016-12-09T14:56:36.730Z",
"links": {
"appDetails": "/api/client/applications/bow"
"appDetails": "/api/admin/applications/bow"
}
}
]
@ -188,14 +136,14 @@ a link to follow for more datails.
You can also specify the query param: _strategyName_, which will return all applications
implementing the given strategy.
`GET http://unleash.host.com/api/client/applications?strategyName=someStrategyName`
`GET http://unleash.host.com/api/admin/applications?strategyName=someStrategyName`
### Application Details
`GET http://unleash.host.com/api/client/applications/:appName`
`GET http://unleash.host.com/api/admin/applications/:appName`
This endpoint gives insight into details about a client applcation, such as instances,
strategies implemented and seen toogles.
@ -239,7 +187,7 @@ strategies implemented and seen toogles.
### Seen applications
`GET http://unleash.host.com/api//client/seen-apps`
`GET http://unleash.host.com/api/admin/seen-apps`
This endpoint gives insight into details about application seen per feature toggle.

View File

@ -1,7 +1,7 @@
## Strategies API
### Fetch Strategies
`GET: http://unleash.host.com/api/strategies`
`GET: http://unleash.host.com/api/admin/strategies`
Used to fetch all defined strategies and their defined paramters.
@ -51,7 +51,7 @@ Used to fetch all defined strategies and their defined paramters.
### Create strategy
`POST: http://unleash.host.com/api/strategies`
`POST: http://unleash.host.com/api/admin/strategies`
**Body**
@ -81,7 +81,7 @@ Used to create a new Strategy. Name is required and must be unique. It is also r
### Update strategy
`PUT: http://unleash.host.com/api/strategies/:name`
`PUT: http://unleash.host.com/api/admin/strategies/:name`
**Body**

View File

@ -0,0 +1,89 @@
### Fetching Feature Toggles
`GET: http://unleash.host.com/api/client/features`
**HEADERS:**
* UNLEASH-APPNAME: appName
* UNLEASH-INSTANCEID: instanceId
This endpoint is the one all clients should use to fetch all available feature toggles
from the _unleash-server_. The response returns all active feature toggles and their
current strategy configuration. A feature toggle will have _at least_ one configured strategy.
A strategy will have a `name` and `parameters` map.
> _Note:_ Clients should perfer the `strategies` property.
> Legacy properties (`strategy` & `parameters`) will be kept until **version 2** of the format.
This endpoint should never return anything besides a valid *20X or 304-response*. It will also
include a `Etag`-header. The value of this header can be used by clients as the value of
the `If-None-Match`-header in the request to prevent a data transfer if the clients already
has the latest response locally.
**Example response:**
```json
{
"version": 1,
"features": [
{
"name": "Feature.A",
"description": "lorem ipsum",
"enabled": false,
"strategies": [
{
"name": "default",
"parameters": {}
}
],
"strategy": "default",
"parameters": {}
},
{
"name": "Feature.B",
"description": "lorem ipsum",
"enabled": true,
"strategies": [
{
"name": "ActiveForUserWithId",
"parameters": {
"userIdList": "123,221,998"
}
},
{
"name": "GradualRolloutRandom",
"parameters": {
"percentage": "10"
}
}
],
"strategy": "ActiveForUserWithId",
"parameters": {
"userIdList": "123,221,998"
}
}
]
}
```
`GET: http://unleash.host.com/api/client/features/:featureName`
Used to fetch details about a specific featureToggle. This is mostly provded to make it easy to
debug the API and should not be used by the client implementations.
> _Notice_: You will not get a version property when fetching a specific feature toggle by name.
```json
{
"name": "Feature.A",
"description": "lorem ipsum..",
"enabled": false,
"strategies": [
{
"name": "default",
"parameters": {}
}
],
"strategy": "default",
"parameters": {}
}
```

View File

@ -0,0 +1,53 @@
# This document describes the client metrics endpoints
### Client registration
`POST: http://unleash.host.com/api/client/register`
Register a client instance with the unleash server. The client should send all fields specified.
```json
{
"appName": "appName",
"instanceId": "instanceId",
"strategies": ["default", "some-strategy-1"],
"started": "2016-11-03T07:16:43.572Z",
"interval": 10000,
}
```
**Fields:**
* **appName** - Name of the application seen by unleash-server
* **instanceId** - Instance id for this application (typically hostname, podId or similar)
* **strategies** - List of strategies implemented by this application
* **started** - When this client started. Should be reported as UTC time.
* **interval** - At wich interval will this client be expected to send metrics?
### Send metrics
`POST http://unleash.host.com/api/client/metrics`
Register a metrics payload with a timed bucket.
```json
{
"appName": "appName",
"instanceId": "instanceId",
"bucket": {
"start": "2016-11-03T07:16:43.572Z",
"stop": "2016-11-03T07:16:53.572Z",
"toggles": {
"toggle-name-1": {
"yes": 123,
"no": 321
},
"toggle-name-2": {
"yes": 111,
"no": 0
}
}
},
}
```

View File

@ -1,14 +1,19 @@
# API Documentation
Version: 1.0
## Client API
This describes the API provided to unleash-clients.
**Contents:**
* [Feature Toggles API](feature-toggles-api.md)
* [Strategies API](strategies-api.md)
* [Events API](events-api.md)
* [Metrics API](metrics-api.md)
* [Feature Toggles API](client/feature-toggles-api.md)
* [Metrics API](client/metrics-api.md)
Others:
* [Internal Backstage API](internal-backstage-api.ms)
## Admin API (internal)
The interal API used by the Admin UI (unleash-frontend):
* [Feature Toggles API](admin/feature-toggles-api.md)
* [Strategies API](admin/strategies-api.md)
* [Events API](admin/events-api.md)
* [Metrics API](admin/metrics-api.md)
## System API's
* [Internal Backstage API](internal-backstage-api.ms)

View File

@ -14,7 +14,7 @@ const errorHandler = require('errorhandler');
const { REQUEST_TIME } = require('./events');
module.exports = function (config) {
module.exports = function(config) {
const app = express();
const baseUriPath = config.baseUriPath || '';
@ -34,10 +34,17 @@ module.exports = function (config) {
app.use(favicon(path.join(publicFolder, 'favicon.ico')));
}
app.use(responseTime((req, res, time) => {
const timingInfo = { path: req.path, method: req.method, statusCode: res.statusCode, time };
config.eventBus.emit(REQUEST_TIME, timingInfo);
}));
app.use(
responseTime((req, res, time) => {
const timingInfo = {
path: req.path,
method: req.method,
statusCode: res.statusCode,
time,
};
config.eventBus.emit(REQUEST_TIME, timingInfo);
})
);
app.use(validator([]));
@ -48,10 +55,12 @@ module.exports = function (config) {
app.use(bodyParser.json({ strict: false }));
if (config.enableRequestLogger) {
app.use(log4js.connectLogger(logger, {
format: ':status :method :url :response-timems',
level: 'auto', // 3XX=WARN, 4xx/5xx=ERROR
}));
app.use(
log4js.connectLogger(logger, {
format: ':status :method :url :response-timems',
level: 'auto', // 3XX=WARN, 4xx/5xx=ERROR
})
);
}
if (typeof config.preRouterHook === 'function') {
@ -59,14 +68,11 @@ module.exports = function (config) {
}
// Setup API routes
const apiRouter = express.Router(); // eslint-disable-line new-cap
routes.createAPI(apiRouter, config);
app.use(`${baseUriPath}/api/`, apiRouter);
// Setup deprecated routes
const router = express.Router(); // eslint-disable-line new-cap
routes.createLegacy(router, config);
app.use(baseUriPath, router);
const middleware = routes.router(config);
if (!middleware) {
throw new Error('Routes invalid');
}
app.use(`${baseUriPath}/`, middleware);
if (process.env.NODE_ENV !== 'production') {
app.use(errorHandler());

View File

@ -1,11 +1,11 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const express = require('express');
const proxyquire = require('proxyquire');
const getApp = proxyquire('./app', {
'./routes': {
createAPI: () => {},
createLegacy: () => {},
router: () => express.Router(),
},
});
@ -16,16 +16,20 @@ test('should not throw when valid config', t => {
test('should call preHook', t => {
let called = 0;
getApp({ preHook: () => {
called++;
} });
getApp({
preHook: () => {
called++;
},
});
t.true(called === 1);
});
test('should call preRouterHook', t => {
let called = 0;
getApp({ preRouterHook: () => {
called++;
} });
getApp({
preRouterHook: () => {
called++;
},
});
t.true(called === 1);
});

View File

@ -10,7 +10,7 @@ const { EventEmitter } = require('events');
const appName = 'appName';
const instanceId = 'instanceId';
test('should work without state', (t) => {
test('should work without state', t => {
const store = new EventEmitter();
const metrics = new UnleashClientMetrics(store);
@ -20,7 +20,7 @@ test('should work without state', (t) => {
metrics.destroy();
});
test.cb('data should expire', (t) => {
test.cb('data should expire', t => {
const clock = sinon.useFakeTimers();
const store = new EventEmitter();
@ -84,9 +84,14 @@ test('should listen to metrics from store', t => {
t.truthy(metrics.apps[appName].count === 123);
t.truthy(metrics.globalCount === 123);
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, { yes: 123, no: 0 });
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, { yes: 123, no: 0 });
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, {
yes: 123,
no: 0,
});
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, {
yes: 123,
no: 0,
});
metrics.addPayload({
appName,
@ -104,8 +109,14 @@ test('should listen to metrics from store', t => {
});
t.truthy(metrics.globalCount === 143);
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, { yes: 133, no: 10 });
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, { yes: 133, no: 10 });
t.deepEqual(metrics.getTogglesMetrics().lastHour.toggleX, {
yes: 133,
no: 10,
});
t.deepEqual(metrics.getTogglesMetrics().lastMinute.toggleX, {
yes: 133,
no: 10,
});
metrics.destroy();
});
@ -146,7 +157,6 @@ test('should build up list of seend toggles when new metrics arrives', t => {
metrics.destroy();
});
test('should handle a lot of toggles', t => {
const store = new EventEmitter();
const metrics = new UnleashClientMetrics(store);
@ -244,7 +254,6 @@ test('should have correct values for lastMinute', t => {
clock.restore();
});
test('should have correct values for lastHour', t => {
const clock = sinon.useFakeTimers();

View File

@ -4,7 +4,7 @@ const Projection = require('./projection.js');
const TTLList = require('./ttl-list.js');
module.exports = class UnleashClientMetrics {
constructor (clientMetricsStore) {
constructor(clientMetricsStore) {
this.globalCount = 0;
this.apps = {};
@ -21,20 +21,26 @@ module.exports = class UnleashClientMetrics {
expireAmount: 1,
});
this.lastHourList.on('expire', (toggles) => {
this.lastHourList.on('expire', toggles => {
Object.keys(toggles).forEach(toggleName => {
this.lastHourProjection.substract(toggleName, toggles[toggleName]);
this.lastHourProjection.substract(
toggleName,
toggles[toggleName]
);
});
});
this.lastMinuteList.on('expire', (toggles) => {
this.lastMinuteList.on('expire', toggles => {
Object.keys(toggles).forEach(toggleName => {
this.lastMinuteProjection.substract(toggleName, toggles[toggleName]);
this.lastMinuteProjection.substract(
toggleName,
toggles[toggleName]
);
});
});
clientMetricsStore.on('metrics', (m) => this.addPayload(m));
clientMetricsStore.on('metrics', m => this.addPayload(m));
}
getAppsWithToggles () {
getAppsWithToggles() {
const apps = [];
Object.keys(this.apps).forEach(appName => {
const seenToggles = Object.keys(this.apps[appName].seenToggles);
@ -43,14 +49,18 @@ module.exports = class UnleashClientMetrics {
});
return apps;
}
getSeenTogglesByAppName (appName) {
return this.apps[appName] ? Object.keys(this.apps[appName].seenToggles) : [];
getSeenTogglesByAppName(appName) {
return this.apps[appName]
? Object.keys(this.apps[appName].seenToggles)
: [];
}
getSeenAppsPerToggle () {
getSeenAppsPerToggle() {
const toggles = {};
Object.keys(this.apps).forEach(appName => {
Object.keys(this.apps[appName].seenToggles).forEach((seenToggleName) => {
Object.keys(
this.apps[appName].seenToggles
).forEach(seenToggleName => {
if (!toggles[seenToggleName]) {
toggles[seenToggleName] = [];
}
@ -60,36 +70,39 @@ module.exports = class UnleashClientMetrics {
return toggles;
}
getTogglesMetrics () {
getTogglesMetrics() {
return {
lastHour: this.lastHourProjection.getProjection(),
lastMinute: this.lastMinuteProjection.getProjection(),
};
}
addPayload (data) {
addPayload(data) {
const { appName, bucket } = data;
const app = this.getApp(appName);
this.addBucket(app, bucket);
}
getApp (appName) {
this.apps[appName] = this.apps[appName] || { seenToggles: {}, count: 0 };
getApp(appName) {
this.apps[appName] = this.apps[appName] || {
seenToggles: {},
count: 0,
};
return this.apps[appName];
}
addBucket (app, bucket) {
addBucket(app, bucket) {
let count = 0;
// TODO stop should be createdAt
const { stop, toggles } = bucket;
const toggleNames = Object.keys(toggles);
toggleNames.forEach((n) => {
toggleNames.forEach(n => {
const entry = toggles[n];
this.lastHourProjection.add(n, entry);
this.lastMinuteProjection.add(n, entry);
count += (entry.yes + entry.no);
count += entry.yes + entry.no;
});
this.lastHourList.add(toggles, stop);
@ -100,13 +113,13 @@ module.exports = class UnleashClientMetrics {
this.addSeenToggles(app, toggleNames);
}
addSeenToggles (app, toggleNames) {
addSeenToggles(app, toggleNames) {
toggleNames.forEach(t => {
app.seenToggles[t] = true;
});
}
destroy () {
destroy() {
this.lastHourList.destroy();
this.lastMinuteList.destroy();
}

View File

@ -3,12 +3,12 @@
const { EventEmitter } = require('events');
class Node {
constructor (value) {
constructor(value) {
this.value = value;
this.next = null;
}
link (next) {
link(next) {
this.next = next;
next.prev = this;
return this;
@ -16,13 +16,13 @@ class Node {
}
module.exports = class List extends EventEmitter {
constructor () {
constructor() {
super();
this.start = null;
this.tail = null;
}
add (obj) {
add(obj) {
const node = new Node(obj);
if (this.start) {
this.start = node.link(this.start);
@ -33,7 +33,7 @@ module.exports = class List extends EventEmitter {
return node;
}
iterate (fn) {
iterate(fn) {
if (!this.start) {
return;
}
@ -48,7 +48,7 @@ module.exports = class List extends EventEmitter {
}
}
iterateReverse (fn) {
iterateReverse(fn) {
if (!this.tail) {
return;
}
@ -63,7 +63,7 @@ module.exports = class List extends EventEmitter {
}
}
reverseRemoveUntilTrue (fn) {
reverseRemoveUntilTrue(fn) {
if (!this.tail) {
return;
}
@ -95,7 +95,7 @@ module.exports = class List extends EventEmitter {
}
}
toArray () {
toArray() {
const result = [];
if (this.start) {

View File

@ -1,9 +1,9 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const List = require('./list');
function getList () {
function getList() {
const list = new List();
list.add(1);
list.add(2);
@ -15,10 +15,10 @@ function getList () {
return list;
}
test('should emit "evicted" events for objects leaving list', (t) => {
test('should emit "evicted" events for objects leaving list', t => {
const list = getList();
const evictedList = [];
list.on('evicted', (value) => {
list.on('evicted', value => {
evictedList.push(value);
});
@ -43,7 +43,7 @@ test('should emit "evicted" events for objects leaving list', (t) => {
t.true(evictedList.length === 8);
});
test('list should be able remove until given value', (t) => {
test('list should be able remove until given value', t => {
const list = getList();
t.true(list.toArray().length === 7);
@ -58,7 +58,7 @@ test('list should be able remove until given value', (t) => {
t.true(list.toArray().length === 3);
});
test('list can be cleared and re-add entries', (t) => {
test('list can be cleared and re-add entries', t => {
const list = getList();
list.add(8);
@ -77,7 +77,7 @@ test('list can be cleared and re-add entries', (t) => {
t.true(list.toArray().length === 3);
});
test('should not iterate empty list ', (t) => {
test('should not iterate empty list ', t => {
const list = new List();
let iterateCount = 0;
@ -87,8 +87,7 @@ test('should not iterate empty list ', (t) => {
t.true(iterateCount === 0);
});
test('should iterate', (t) => {
test('should iterate', t => {
const list = getList();
let iterateCount = 0;
@ -102,7 +101,7 @@ test('should iterate', (t) => {
t.true(iterateCount === 4);
});
test('should reverse iterate', (t) => {
test('should reverse iterate', t => {
const list = getList();
let iterateCount = 0;
@ -116,7 +115,7 @@ test('should reverse iterate', (t) => {
t.true(iterateCount === 5);
});
test('should not reverse iterate empty list', (t) => {
test('should not reverse iterate empty list', t => {
const list = new List();
let iterateCount = 0;

View File

@ -1,15 +1,15 @@
'use strict';
module.exports = class Projection {
constructor () {
constructor() {
this.store = {};
}
getProjection () {
getProjection() {
return this.store;
}
add (name, countObj) {
add(name, countObj) {
if (this.store[name]) {
this.store[name].yes += countObj.yes;
this.store[name].no += countObj.no;
@ -21,7 +21,7 @@ module.exports = class Projection {
}
}
substract (name, countObj) {
substract(name, countObj) {
if (this.store[name]) {
this.store[name].yes -= countObj.yes;
this.store[name].no -= countObj.no;

View File

@ -6,11 +6,9 @@ const moment = require('moment');
// this list must have entries with sorted ttl range
module.exports = class TTLList extends EventEmitter {
constructor ({
interval = 1000,
expireAmount = 1,
expireType = 'hours',
} = {}) {
constructor(
{ interval = 1000, expireAmount = 1, expireType = 'hours' } = {}
) {
super();
this.interval = interval;
this.expireAmount = expireAmount;
@ -24,7 +22,7 @@ module.exports = class TTLList extends EventEmitter {
this.startTimer();
}
startTimer () {
startTimer() {
if (this.list) {
this.timer = setTimeout(() => {
if (this.list) {
@ -35,7 +33,7 @@ module.exports = class TTLList extends EventEmitter {
}
}
add (value, timestamp = new Date()) {
add(value, timestamp = new Date()) {
const ttl = moment(timestamp).add(this.expireAmount, this.expireType);
if (moment().isBefore(ttl)) {
this.list.add({ ttl, value });
@ -44,13 +42,15 @@ module.exports = class TTLList extends EventEmitter {
}
}
timedCheck () {
timedCheck() {
const now = moment();
this.list.reverseRemoveUntilTrue(({ value }) => now.isBefore(value.ttl));
this.list.reverseRemoveUntilTrue(({ value }) =>
now.isBefore(value.ttl)
);
this.startTimer();
}
destroy () {
destroy() {
// https://github.com/nodejs/node/issues/9561
// clearTimeout(this.timer);
// this.timer = null;

View File

@ -1,18 +1,18 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const TTLList = require('./ttl-list');
const moment = require('moment');
const sinon = require('sinon');
test.cb('should emit expire', (t) => {
test.cb('should emit expire', t => {
const list = new TTLList({
interval: 20,
expireAmount: 10,
expireType: 'milliseconds',
});
list.on('expire', (entry) => {
list.on('expire', entry => {
list.destroy();
t.true(entry.n === 1);
t.end();
@ -21,7 +21,7 @@ test.cb('should emit expire', (t) => {
list.add({ n: 1 });
});
test.cb('should slice off list', (t) => {
test.cb('should slice off list', t => {
const clock = sinon.useFakeTimers();
const list = new TTLList({
@ -30,7 +30,6 @@ test.cb('should slice off list', (t) => {
expireType: 'milliseconds',
});
list.add({ n: '1' }, moment().add(1, 'milliseconds'));
list.add({ n: '2' }, moment().add(50, 'milliseconds'));
list.add({ n: '3' }, moment().add(200, 'milliseconds'));
@ -38,7 +37,7 @@ test.cb('should slice off list', (t) => {
const expired = [];
list.on('expire', (entry) => {
list.on('expire', entry => {
// console.timeEnd(entry.n);
expired.push(entry);
});

View File

@ -1,44 +0,0 @@
'use strict';
function addOldFields (feature) {
const modifiedFeature = Object.assign({}, feature);
if (!feature.strategies) {
modifiedFeature.strategies = [];
return modifiedFeature;
}
if (feature.strategies[0]) {
modifiedFeature.strategy = feature.strategies[0].name;
modifiedFeature.parameters = Object.assign({}, feature.strategies[0].parameters);
}
return modifiedFeature;
}
function isOldFomrat (feature) {
return feature.strategy && !feature.strategies;
}
function toNewFormat (feature) {
if (isOldFomrat(feature)) {
return {
name: feature.name,
description: feature.description,
enabled: feature.enabled,
strategies: [
{
name: feature.strategy,
parameters: Object.assign({}, feature.parameters),
},
],
};
} else {
return {
name: feature.name,
description: feature.description,
enabled: feature.enabled,
strategies: feature.strategies,
createdAt: feature.createdAt,
};
}
}
module.exports = { addOldFields, toNewFormat };

View File

@ -1,78 +0,0 @@
'use strict';
const test = require('ava');
const mapper = require('./legacy-feature-mapper');
test('adds old fields to feature', t => {
const feature = {
name: 'test',
enabled: 0,
strategies: [{
name: 'default',
parameters: {
val: 'bar',
},
}],
};
const mappedFeature = mapper.addOldFields(feature);
t.true(mappedFeature.name === feature.name);
t.true(mappedFeature.enabled === feature.enabled);
t.true(mappedFeature.strategy === feature.strategies[0].name);
t.true(mappedFeature.parameters !== feature.strategies[0].parameters);
t.deepEqual(mappedFeature.parameters, feature.strategies[0].parameters);
});
test('adds old fields to feature handles missing strategies field', t => {
const feature = {
name: 'test',
enabled: 0,
};
const mappedFeature = mapper.addOldFields(feature);
t.true(mappedFeature.name === feature.name);
t.true(mappedFeature.enabled === feature.enabled);
t.true(mappedFeature.strategies.length === 0);
});
test('transforms fields to new format', t => {
const feature = {
name: 'test',
enabled: 0,
strategy: 'default',
parameters: {
val: 'bar',
},
};
const mappedFeature = mapper.toNewFormat(feature);
t.true(mappedFeature.name === feature.name);
t.true(mappedFeature.enabled === feature.enabled);
t.true(mappedFeature.strategies.length === 1);
t.true(mappedFeature.strategies[0].name === feature.strategy);
t.deepEqual(mappedFeature.strategies[0].parameters, feature.parameters);
t.true(mappedFeature.strategy === undefined);
t.true(mappedFeature.parameters === undefined);
});
test('should not transform if it already is the new format', t => {
const feature = {
name: 'test',
enabled: 0,
description: 'test',
createdAt: new Date(),
strategies: [{
name: 'default',
parameters: {
val: 'bar',
},
}],
};
const mappedFeature = mapper.toNewFormat(feature);
t.deepEqual(mappedFeature, feature);
});

View File

@ -1,10 +1,19 @@
/* eslint camelcase:off */
'use strict';
const COLUMNS = ['app_name', 'created_at', 'updated_at', 'description', 'strategies', 'url', 'color', 'icon'];
const COLUMNS = [
'app_name',
'created_at',
'updated_at',
'description',
'strategies',
'url',
'color',
'icon',
];
const TABLE = 'client_applications';
const mapRow = (row) => ({
const mapRow = row => ({
appName: row.app_name,
createdAt: row.created_at,
updatedAt: row.updated_at,
@ -25,24 +34,23 @@ const remapRow = (input, old = {}) => ({
strategies: JSON.stringify(input.strategies || old.strategies),
});
class ClientApplicationsDb {
constructor (db) {
constructor(db) {
this.db = db;
}
updateRow (details, prev) {
updateRow(details, prev) {
details.updatedAt = 'now()';
return this.db(TABLE)
.where('app_name', details.appName)
.update(remapRow(details, prev));
}
insertNewRow (details) {
insertNewRow(details) {
return this.db(TABLE).insert(remapRow(details));
}
upsert (data) {
upsert(data) {
if (!data) {
throw new Error('Missing data to add / update');
}
@ -58,20 +66,17 @@ class ClientApplicationsDb {
});
}
getAll () {
return this.db
.select(COLUMNS)
.from(TABLE)
.map(mapRow);
getAll() {
return this.db.select(COLUMNS).from(TABLE).map(mapRow);
}
getApplication (appName) {
getApplication(appName) {
return this.db
.select(COLUMNS)
.where('app_name', appName)
.from(TABLE)
.map(mapRow)
.then(list => list[0]);
.select(COLUMNS)
.where('app_name', appName)
.from(TABLE)
.map(mapRow)
.then(list => list[0]);
}
/**
@ -83,19 +88,21 @@ class ClientApplicationsDb {
* ) as foo
* WHERE foo.strategyName = '"other"';
*/
getAppsForStrategy (strategyName) {
getAppsForStrategy(strategyName) {
return this.db
.select(COLUMNS)
.from(TABLE)
.map(mapRow)
.then(apps => apps
.filter(app => app.strategies.includes(strategyName)));
.then(apps =>
apps.filter(app => app.strategies.includes(strategyName))
);
}
getApplications (filter) {
return filter && filter.strategyName ?
this.getAppsForStrategy(filter.strategyName) : this.getAll();
getApplications(filter) {
return filter && filter.strategyName
? this.getAppsForStrategy(filter.strategyName)
: this.getAll();
}
};
}
module.exports = ClientApplicationsDb;

View File

@ -2,10 +2,16 @@
'use strict';
const logger = require('../logger');
const COLUMNS = ['app_name', 'instance_id', 'client_ip', 'last_seen', 'created_at'];
const COLUMNS = [
'app_name',
'instance_id',
'client_ip',
'last_seen',
'created_at',
];
const TABLE = 'client_instances';
const mapRow = (row) => ({
const mapRow = row => ({
appName: row.app_name,
instanceId: row.instance_id,
clientIp: row.client_ip,
@ -19,21 +25,23 @@ const mapRow = (row) => ({
// });
class ClientInstanceStore {
constructor (db) {
constructor(db) {
this.db = db;
setTimeout(() => this._removeInstancesOlderThanTwoDays(), 10).unref();
setInterval(() => this._removeInstancesOlderThanTwoDays(), 24 * 61 * 60 * 1000).unref();
setInterval(
() => this._removeInstancesOlderThanTwoDays(),
24 * 61 * 60 * 1000
).unref();
}
_removeInstancesOlderThanTwoDays () {
_removeInstancesOlderThanTwoDays() {
this.db(TABLE)
.whereRaw('created_at < now() - interval \'2 days\'')
.whereRaw("created_at < now() - interval '2 days'")
.del()
.then((res) => res > 0 && logger.info(`Deleted ${res} instances`));
.then(res => res > 0 && logger.info(`Deleted ${res} instances`));
}
updateRow (details) {
updateRow(details) {
return this.db(TABLE)
.where('app_name', details.appName)
.where('instance_id', details.instanceId)
@ -43,7 +51,7 @@ class ClientInstanceStore {
});
}
insertNewRow (details) {
insertNewRow(details) {
return this.db(TABLE).insert({
app_name: details.appName,
instance_id: details.instanceId,
@ -51,7 +59,7 @@ class ClientInstanceStore {
});
}
insert (details) {
insert(details) {
return this.db(TABLE)
.count('*')
.where('app_name', details.appName)
@ -66,7 +74,7 @@ class ClientInstanceStore {
});
}
getAll () {
getAll() {
return this.db
.select(COLUMNS)
.from(TABLE)
@ -74,7 +82,7 @@ class ClientInstanceStore {
.map(mapRow);
}
getByAppName (appName) {
getByAppName(appName) {
return this.db
.select()
.from(TABLE)
@ -83,7 +91,7 @@ class ClientInstanceStore {
.map(mapRow);
}
getApplications () {
getApplications() {
return this.db
.distinct('app_name')
.select(['app_name'])
@ -91,6 +99,6 @@ class ClientInstanceStore {
.orderBy('app_name', 'desc')
.map(mapRow);
}
};
}
module.exports = ClientInstanceStore;

View File

@ -5,46 +5,49 @@ const logger = require('../logger');
const METRICS_COLUMNS = ['id', 'created_at', 'metrics'];
const TABLE = 'client_metrics';
const mapRow = (row) => ({
const mapRow = row => ({
id: row.id,
createdAt: row.created_at,
metrics: row.metrics,
});
class ClientMetricsDb {
constructor (db) {
constructor(db) {
this.db = db;
// Clear old metrics regulary
setTimeout(() => this.removeMetricsOlderThanOneHour(), 10).unref();
setInterval(() => this.removeMetricsOlderThanOneHour(), 60 * 1000).unref();
setInterval(
() => this.removeMetricsOlderThanOneHour(),
60 * 1000
).unref();
}
removeMetricsOlderThanOneHour () {
removeMetricsOlderThanOneHour() {
this.db(TABLE)
.whereRaw('created_at < now() - interval \'1 hour\'')
.whereRaw("created_at < now() - interval '1 hour'")
.del()
.then((res) => res > 0 && logger.info(`Deleted ${res} metrics`));
.then(res => res > 0 && logger.info(`Deleted ${res} metrics`));
}
// Insert new client metrics
insert (metrics) {
insert(metrics) {
return this.db(TABLE).insert({ metrics });
}
// Used at startup to load all metrics last week into memory!
getMetricsLastHour () {
getMetricsLastHour() {
return this.db
.select(METRICS_COLUMNS)
.from(TABLE)
.limit(2000)
.whereRaw('created_at > now() - interval \'1 hour\'')
.whereRaw("created_at > now() - interval '1 hour'")
.orderBy('created_at', 'asc')
.map(mapRow);
}
// Used to poll for new metrics
getNewMetrics (lastKnownId) {
getNewMetrics(lastKnownId) {
return this.db
.select(METRICS_COLUMNS)
.from(TABLE)
@ -53,6 +56,6 @@ class ClientMetricsDb {
.orderBy('created_at', 'asc')
.map(mapRow);
}
};
}
module.exports = ClientMetricsDb;

View File

@ -7,31 +7,32 @@ const { EventEmitter } = require('events');
const TEN_SECONDS = 10 * 1000;
class ClientMetricsStore extends EventEmitter {
constructor (metricsDb, pollInterval = TEN_SECONDS) {
constructor(metricsDb, pollInterval = TEN_SECONDS) {
super();
this.metricsDb = metricsDb;
this.highestIdSeen = 0;
// Build internal state
metricsDb.getMetricsLastHour()
.then((metrics) => this._emitMetrics(metrics))
metricsDb
.getMetricsLastHour()
.then(metrics => this._emitMetrics(metrics))
.then(() => this._startPoller(pollInterval))
.then(() => this.emit('ready'))
.catch((err) => logger.error(err));
.catch(err => logger.error(err));
}
_startPoller (pollInterval) {
_startPoller(pollInterval) {
this.timer = setInterval(() => this._fetchNewAndEmit(), pollInterval);
this.timer.unref();
}
_fetchNewAndEmit () {
this.metricsDb.getNewMetrics(this.highestIdSeen)
.then((metrics) => this._emitMetrics(metrics));
_fetchNewAndEmit() {
this.metricsDb
.getNewMetrics(this.highestIdSeen)
.then(metrics => this._emitMetrics(metrics));
}
_emitMetrics (metrics) {
_emitMetrics(metrics) {
if (metrics && metrics.length > 0) {
this.highestIdSeen = metrics[metrics.length - 1].id;
metrics.forEach(m => this.emit('metrics', m.metrics));
@ -39,15 +40,15 @@ class ClientMetricsStore extends EventEmitter {
}
// Insert new client metrics
insert (metrics) {
insert(metrics) {
return this.metricsDb.insert(metrics);
}
destroy () {
destroy() {
try {
clearInterval(this.timer);
} catch (e) {}
}
};
}
module.exports = ClientMetricsStore;

View File

@ -4,33 +4,31 @@ const { test } = require('ava');
const ClientMetricStore = require('./client-metrics-store');
const sinon = require('sinon');
function getMockDb () {
function getMockDb() {
const list = [
{ id: 4, metrics: { appName: 'test' } },
{ id: 3, metrics: { appName: 'test' } },
{ id: 2, metrics: { appName: 'test' } },
];
return {
getMetricsLastHour () {
getMetricsLastHour() {
return Promise.resolve([{ id: 1, metrics: { appName: 'test' } }]);
},
getNewMetrics () {
getNewMetrics() {
return Promise.resolve([list.pop() || { id: 0 }]);
},
};
}
test.cb('should call database on startup', (t) => {
test.cb('should call database on startup', t => {
const mock = getMockDb();
const store = new ClientMetricStore(mock);
t.plan(2);
store.on('metrics', (metrics) => {
store.on('metrics', metrics => {
t.true(store.highestIdSeen === 1);
t.true(metrics.appName === 'test');
store.destroy();
@ -39,15 +37,14 @@ test.cb('should call database on startup', (t) => {
});
});
test.cb('should poll for updates', (t) => {
test.cb('should poll for updates', t => {
const clock = sinon.useFakeTimers();
const mock = getMockDb();
const store = new ClientMetricStore(mock, 100);
const metrics = [];
store.on('metrics', (m) => metrics.push(m));
store.on('metrics', m => metrics.push(m));
t.true(metrics.length === 0);
@ -63,4 +60,3 @@ test.cb('should poll for updates', (t) => {
});
});
});

View File

@ -2,7 +2,12 @@
const knex = require('knex');
module.exports.createDb = function ({ databaseUrl, poolMin = 2, poolMax = 20, databaseSchema = 'public' }) {
module.exports.createDb = function({
databaseUrl,
poolMin = 2,
poolMax = 20,
databaseSchema = 'public',
}) {
const db = knex({
client: 'pg',
connection: databaseUrl,

View File

@ -5,22 +5,22 @@ const { EventEmitter } = require('events');
const EVENT_COLUMNS = ['id', 'type', 'created_by', 'created_at', 'data'];
class EventStore extends EventEmitter {
constructor (db) {
constructor(db) {
super();
this.db = db;
}
store (event) {
return this.db('events').insert({
type: event.type,
store(event) {
return this.db('events')
.insert({
type: event.type,
created_by: event.createdBy, // eslint-disable-line
data: event.data,
})
.then(() => this.emit(event.type, event));
data: event.data,
})
.then(() => this.emit(event.type, event));
}
getEvents () {
getEvents() {
return this.db
.select(EVENT_COLUMNS)
.from('events')
@ -29,17 +29,17 @@ class EventStore extends EventEmitter {
.map(this.rowToEvent);
}
getEventsFilterByName (name) {
getEventsFilterByName(name) {
return this.db
.select(EVENT_COLUMNS)
.from('events')
.limit(100)
.whereRaw('data ->> \'name\' = ?', [name])
.orderBy('created_at', 'desc')
.map(this.rowToEvent);
.select(EVENT_COLUMNS)
.from('events')
.limit(100)
.whereRaw("data ->> 'name' = ?", [name])
.orderBy('created_at', 'desc')
.map(this.rowToEvent);
}
rowToEvent (row) {
rowToEvent(row) {
return {
id: row.id,
type: row.type,
@ -48,7 +48,6 @@ class EventStore extends EventEmitter {
data: row.data,
};
}
};
}
module.exports = EventStore;

View File

@ -1,21 +1,40 @@
'use strict';
const { FEATURE_CREATED, FEATURE_UPDATED, FEATURE_ARCHIVED, FEATURE_REVIVED } = require('../event-type');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
FEATURE_REVIVED,
} = require('../event-type');
const logger = require('../logger');
const NotFoundError = require('../error/notfound-error');
const FEATURE_COLUMNS = ['name', 'description', 'enabled', 'strategies', 'created_at'];
const FEATURE_COLUMNS = [
'name',
'description',
'enabled',
'strategies',
'created_at',
];
const TABLE = 'features';
class FeatureToggleStore {
constructor (db, eventStore) {
constructor(db, eventStore) {
this.db = db;
eventStore.on(FEATURE_CREATED, event => this._createFeature(event.data));
eventStore.on(FEATURE_UPDATED, event => this._updateFeature(event.data));
eventStore.on(FEATURE_ARCHIVED, event => this._archiveFeature(event.data));
eventStore.on(FEATURE_REVIVED, event => this._reviveFeature(event.data));
eventStore.on(FEATURE_CREATED, event =>
this._createFeature(event.data)
);
eventStore.on(FEATURE_UPDATED, event =>
this._updateFeature(event.data)
);
eventStore.on(FEATURE_ARCHIVED, event =>
this._archiveFeature(event.data)
);
eventStore.on(FEATURE_REVIVED, event =>
this._reviveFeature(event.data)
);
}
getFeatures () {
getFeatures() {
return this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
@ -24,7 +43,7 @@ class FeatureToggleStore {
.map(this.rowToFeature);
}
getFeature (name) {
getFeature(name) {
return this.db
.first(FEATURE_COLUMNS)
.from(TABLE)
@ -32,7 +51,7 @@ class FeatureToggleStore {
.then(this.rowToFeature);
}
getArchivedFeatures () {
getArchivedFeatures() {
return this.db
.select(FEATURE_COLUMNS)
.from(TABLE)
@ -41,7 +60,7 @@ class FeatureToggleStore {
.map(this.rowToFeature);
}
rowToFeature (row) {
rowToFeature(row) {
if (!row) {
throw new NotFoundError('No feature toggle found');
}
@ -54,7 +73,7 @@ class FeatureToggleStore {
};
}
eventDataToRow (data) {
eventDataToRow(data) {
return {
name: data.name,
description: data.description,
@ -64,32 +83,40 @@ class FeatureToggleStore {
};
}
_createFeature (data) {
_createFeature(data) {
return this.db(TABLE)
.insert(this.eventDataToRow(data))
.catch(err => logger.error('Could not insert feature, error was: ', err));
.catch(err =>
logger.error('Could not insert feature, error was: ', err)
);
}
_updateFeature (data) {
_updateFeature(data) {
return this.db(TABLE)
.where({ name: data.name })
.update(this.eventDataToRow(data))
.catch(err => logger.error('Could not update feature, error was: ', err));
.catch(err =>
logger.error('Could not update feature, error was: ', err)
);
}
_archiveFeature ({ name }) {
_archiveFeature({ name }) {
return this.db(TABLE)
.where({ name })
.update({ archived: 1 })
.catch(err => logger.error('Could not archive feature, error was: ', err));
.catch(err =>
logger.error('Could not archive feature, error was: ', err)
);
}
_reviveFeature ({ name }) {
_reviveFeature({ name }) {
return this.db(TABLE)
.where({ name })
.update({ archived: 0, enabled: 0 })
.catch(err => logger.error('Could not archive feature, error was: ', err));
.catch(err =>
logger.error('Could not archive feature, error was: ', err)
);
}
};
}
module.exports = FeatureToggleStore;

View File

@ -9,7 +9,7 @@ const ClientMetricsDb = require('./client-metrics-db');
const ClientMetricsStore = require('./client-metrics-store');
const ClientApplicationsStore = require('./client-applications-store');
module.exports.createStores = (config) => {
module.exports.createStores = config => {
const db = createDb(config);
const eventStore = new EventStore(db);
const clientMetricsDb = new ClientMetricsDb(db);

View File

@ -1,27 +1,32 @@
'use strict';
const { STRATEGY_CREATED, STRATEGY_DELETED, STRATEGY_UPDATED } = require('../event-type');
const {
STRATEGY_CREATED,
STRATEGY_DELETED,
STRATEGY_UPDATED,
} = require('../event-type');
const logger = require('../logger');
const NotFoundError = require('../error/notfound-error');
const STRATEGY_COLUMNS = ['name', 'description', 'parameters'];
const TABLE = 'strategies';
class StrategyStore {
constructor (db, eventStore) {
constructor(db, eventStore) {
this.db = db;
eventStore.on(STRATEGY_CREATED, event => this._createStrategy(event.data));
eventStore.on(STRATEGY_UPDATED, event => this._updateStrategy(event.data));
eventStore.on(STRATEGY_CREATED, event =>
this._createStrategy(event.data)
);
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);
});
db(TABLE).where('name', event.data.name).del().catch(err => {
logger.error('Could not delete strategy, error was: ', err);
});
});
}
getStrategies () {
getStrategies() {
return this.db
.select(STRATEGY_COLUMNS)
.from(TABLE)
@ -29,7 +34,7 @@ class StrategyStore {
.map(this.rowToStrategy);
}
getStrategy (name) {
getStrategy(name) {
return this.db
.first(STRATEGY_COLUMNS)
.from(TABLE)
@ -37,7 +42,7 @@ class StrategyStore {
.then(this.rowToStrategy);
}
rowToStrategy (row) {
rowToStrategy(row) {
if (!row) {
throw new NotFoundError('No strategy found');
}
@ -49,7 +54,7 @@ class StrategyStore {
};
}
eventDataToRow (data) {
eventDataToRow(data) {
return {
name: data.name,
description: data.description,
@ -57,19 +62,22 @@ class StrategyStore {
};
}
_createStrategy (data) {
_createStrategy(data) {
this.db(TABLE)
.insert(this.eventDataToRow(data))
.catch(err => logger.error('Could not insert strategy, error was: ', err));
.catch(err =>
logger.error('Could not insert strategy, error was: ', err)
);
}
_updateStrategy (data) {
_updateStrategy(data) {
this.db(TABLE)
.where({ name: data.name })
.update(this.eventDataToRow(data))
.catch(err => logger.error('Could not update strategy, error was: ', err));
.catch(err =>
logger.error('Could not update strategy, error was: ', err)
);
}
};
}
module.exports = StrategyStore;

View File

@ -1,7 +1,7 @@
'use strict';
class NameExistsError extends Error {
constructor (message) {
constructor(message) {
super();
Error.captureStackTrace(this, this.constructor);

View File

@ -1,7 +1,7 @@
'use strict';
class NotFoundError extends Error {
constructor (message) {
constructor(message) {
super();
Error.captureStackTrace(this, this.constructor);

View File

@ -2,7 +2,7 @@
const ValidationError = require('./validation-error');
function validateRequest (req) {
function validateRequest(req) {
return new Promise((resolve, reject) => {
if (req.validationErrors()) {
reject(new ValidationError('Invalid syntax'));

View File

@ -1,7 +1,7 @@
'use strict';
class ValidationError extends Error {
constructor (message) {
constructor(message) {
super();
Error.captureStackTrace(this, this.constructor);

View File

@ -11,11 +11,7 @@ const {
} = 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];
const featureTypes = [
FEATURE_CREATED,
@ -24,7 +20,7 @@ const featureTypes = [
FEATURE_REVIVED,
];
function baseTypeFor (event) {
function baseTypeFor(event) {
if (featureTypes.indexOf(event.type) !== -1) {
return 'features';
} else if (strategyTypes.indexOf(event.type) !== -1) {
@ -33,14 +29,15 @@ function baseTypeFor (event) {
throw new Error(`unknown event type: ${JSON.stringify(event)}`);
}
function groupByBaseTypeAndName (events) {
function groupByBaseTypeAndName(events) {
const groups = {};
events.forEach(event => {
const baseType = baseTypeFor(event);
groups[baseType] = groups[baseType] || {};
groups[baseType][event.data.name] = groups[baseType][event.data.name] || [];
groups[baseType][event.data.name] =
groups[baseType][event.data.name] || [];
groups[baseType][event.data.name].push(event);
});
@ -48,7 +45,7 @@ function groupByBaseTypeAndName (events) {
return groups;
}
function eachConsecutiveEvent (events, callback) {
function eachConsecutiveEvent(events, callback) {
const groups = groupByBaseTypeAndName(events);
Object.keys(groups).forEach(baseType => {
@ -70,7 +67,7 @@ function eachConsecutiveEvent (events, callback) {
});
}
function addDiffs (events) {
function addDiffs(events) {
eachConsecutiveEvent(events, (left, right) => {
if (right) {
left.diffs = diff(right.data, left.data);
@ -81,7 +78,6 @@ function addDiffs (events) {
});
}
module.exports = {
addDiffs,
};

View File

@ -1,6 +1,6 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const eventDiffer = require('./event-differ');
const { FEATURE_CREATED, FEATURE_UPDATED } = require('./event-type');
const logger = require('./logger');
@ -27,11 +27,23 @@ test('diffs a feature-update event', t => {
const events = [
{
type: FEATURE_UPDATED,
data: { name: feature, description: desc, strategy: 'default', enabled: true, parameters: { value: 2 } },
data: {
name: feature,
description: desc,
strategy: 'default',
enabled: true,
parameters: { value: 2 },
},
},
{
type: FEATURE_CREATED,
data: { name: feature, description: desc, strategy: 'default', enabled: false, parameters: { value: 1 } },
data: {
name: feature,
description: desc,
strategy: 'default',
enabled: false,
parameters: { value: 1 },
},
},
];
@ -57,19 +69,43 @@ test('diffs only against features with the same name', t => {
const events = [
{
type: FEATURE_UPDATED,
data: { name: 'bar', description: 'desc', strategy: 'default', enabled: true, parameters: {} },
data: {
name: 'bar',
description: 'desc',
strategy: 'default',
enabled: true,
parameters: {},
},
},
{
type: FEATURE_UPDATED,
data: { name: 'foo', description: 'desc', strategy: 'default', enabled: false, parameters: {} },
data: {
name: 'foo',
description: 'desc',
strategy: 'default',
enabled: false,
parameters: {},
},
},
{
type: FEATURE_CREATED,
data: { name: 'bar', description: 'desc', strategy: 'default', enabled: false, parameters: {} },
data: {
name: 'bar',
description: 'desc',
strategy: 'default',
enabled: false,
parameters: {},
},
},
{
type: FEATURE_CREATED,
data: { name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {} },
data: {
name: 'foo',
description: 'desc',
strategy: 'default',
enabled: true,
parameters: {},
},
},
];
@ -85,11 +121,23 @@ test('sets an empty array of diffs if nothing was changed', t => {
const events = [
{
type: FEATURE_UPDATED,
data: { name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {} },
data: {
name: 'foo',
description: 'desc',
strategy: 'default',
enabled: true,
parameters: {},
},
},
{
type: FEATURE_CREATED,
data: { name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {} },
data: {
name: 'foo',
description: 'desc',
strategy: 'default',
enabled: true,
parameters: {},
},
},
];
@ -101,11 +149,16 @@ test('sets diffs to null if there was nothing to diff against', t => {
const events = [
{
type: FEATURE_UPDATED,
data: { name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {} },
data: {
name: 'foo',
description: 'desc',
strategy: 'default',
enabled: true,
parameters: {},
},
},
];
eventDiffer.addDiffs(events);
t.true(events[0].diffs === null);
});

View File

@ -1,6 +1,6 @@
'use strict';
function extractUsername (req) {
function extractUsername(req) {
return req.cookies.username || 'unknown';
}
module.exports = extractUsername;

View File

@ -2,9 +2,7 @@
const log4js = require('log4js');
log4js.configure({
appenders: [
{ type: 'console' },
],
appenders: [{ type: 'console' }],
});
const logger = log4js.getLogger('unleash');

View File

@ -9,9 +9,14 @@ exports.startMonitoring = (enable, eventBus) => {
const client = require('prom-client');
const requestDuration = new client.Summary('http_request_duration_milliseconds', 'App response time', ['path', 'method', 'status'], {
percentiles: [0.1, 0.5, 0.9, 0.99],
});
const requestDuration = new client.Summary(
'http_request_duration_milliseconds',
'App response time',
['path', 'method', 'status'],
{
percentiles: [0.1, 0.5, 0.9, 0.99],
}
);
eventBus.on(events.REQUEST_TIME, ({ path, method, time, statusCode }) => {
requestDuration.labels(path, method, statusCode).observe(time);

View File

@ -1,6 +1,6 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
const { REQUEST_TIME } = require('./events');
@ -9,8 +9,16 @@ const prometheusRegister = require('prom-client/lib/register');
test('should collect metrics for requests', t => {
startMonitoring(true, eventBus);
eventBus.emit(REQUEST_TIME, { path: 'somePath', method: 'GET', statusCode: 200, time: 1337 });
eventBus.emit(REQUEST_TIME, {
path: 'somePath',
method: 'GET',
statusCode: 200,
time: 1337,
});
const metrics = prometheusRegister.metrics();
t.regex(metrics, /http_request_duration_milliseconds{quantile="0.99",status="200",method="GET",path="somePath"} 1337/);
t.regex(
metrics,
/http_request_duration_milliseconds{quantile="0.99",status="200",method="GET",path="somePath"} 1337/
);
});

View File

@ -14,16 +14,19 @@ const DEFAULT_OPTIONS = {
};
module.exports = {
createOptions: (opts) => {
createOptions: opts => {
const options = Object.assign({}, DEFAULT_OPTIONS, opts);
// If we are running in development we should assume local db
if (isDev() && !options.databaseUrl) {
options.databaseUrl = 'postgres://unleash_user:passord@localhost:5432/unleash';
options.databaseUrl =
'postgres://unleash_user:passord@localhost:5432/unleash';
}
if (!options.databaseUrl) {
throw new Error('You must either pass databaseUrl option or set environemnt variable DATABASE_URL');
throw new Error(
'You must either pass databaseUrl option or set environemnt variable DATABASE_URL'
);
}
return options;
},

View File

@ -1,6 +1,6 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
delete process.env.DATABASE_URL;
@ -18,7 +18,10 @@ test('should set default databaseUrl for develpment', t => {
const options = createOptions({});
t.true(options.databaseUrl === 'postgres://unleash_user:passord@localhost:5432/unleash');
t.true(
options.databaseUrl ===
'postgres://unleash_user:passord@localhost:5432/unleash'
);
});
test('should not override provided options', t => {

View File

View File

@ -0,0 +1,39 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should return list of client applications', t => {
t.plan(1);
const { request } = getSetup();
return request
.get('/api/admin/metrics/applications')
.expect(200)
.expect(res => {
t.true(res.body.applications.length === 0);
});
});

View File

@ -0,0 +1,46 @@
'use strict';
const { Router } = require('express');
const logger = require('../../logger');
const { FEATURE_REVIVED } = require('../../event-type');
const ValidationError = require('../../error/validation-error');
const validateRequest = require('../../error/validate-request');
const handleErrors = (req, res, error) => {
switch (error.constructor) {
case ValidationError:
return res.status(400).json(req.validationErrors()).end();
default:
logger.error('Server failed executing request', error);
return res.status(500).end();
}
};
module.exports.router = function(config) {
const { featureToggleStore, eventStore } = config.stores;
const router = Router();
router.get('/features', (req, res) => {
featureToggleStore.getArchivedFeatures().then(archivedFeatures => {
res.json({ features: archivedFeatures });
});
});
router.post('/revive/:name', (req, res) => {
req.checkParams('name', 'Name is required').notEmpty();
validateRequest(req)
.then(() =>
eventStore.store({
type: FEATURE_REVIVED,
createdBy: req.connection.remoteAddress,
data: { name: req.params.name },
})
)
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
return router;
};

View File

@ -0,0 +1,5 @@
'use strict';
const { test } = require('ava');
test.todo('should unit test archive');

View File

@ -1,19 +1,22 @@
'use strict';
const eventDiffer = require('../event-differ');
const { Router } = require('express');
const eventDiffer = require('../../event-differ');
const version = 1;
module.exports = function (app, config) {
module.exports.router = function(config) {
const { eventStore } = config.stores;
const router = Router();
app.get('/events', (req, res) => {
router.get('/', (req, res) => {
eventStore.getEvents().then(events => {
eventDiffer.addDiffs(events);
res.json({ version, events });
});
});
app.get('/events/:name', (req, res) => {
router.get('/:name', (req, res) => {
const toggleName = req.params.name;
eventStore.getEventsFilterByName(toggleName).then(events => {
if (events) {
@ -27,4 +30,6 @@ module.exports = function (app, config) {
}
});
});
return router;
};

View File

@ -0,0 +1,39 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
});
return { base, eventStore: stores.eventStore, request: supertest(app) };
}
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('should get empty events list via admin', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin/events`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.events.length === 0);
});
});

View File

@ -0,0 +1,196 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const logger = require('../../logger');
const {
FEATURE_CREATED,
FEATURE_UPDATED,
FEATURE_ARCHIVED,
} = require('../../event-type');
const NameExistsError = require('../../error/name-exists-error');
const NotFoundError = require('../../error/notfound-error');
const ValidationError = require('../../error/validation-error.js');
const validateRequest = require('../../error/validate-request');
const extractUser = require('../../extract-user');
const handleErrors = (req, res, error) => {
logger.warn('Error creating or updating feature', error);
switch (error.constructor) {
case NotFoundError:
return res.status(404).end();
case NameExistsError:
return res
.status(403)
.json([
{
msg:
'A feature with this name already exists. Try re-activating it from the archive.',
},
])
.end();
case ValidationError:
return res.status(400).json(req.validationErrors()).end();
default:
logger.error('Server failed executing request', error);
return res.status(500).end();
}
};
const strategiesSchema = joi.object().keys({
name: joi.string().regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/).required(),
parameters: joi.object(),
});
function validateStrategy(featureToggle) {
return new Promise((resolve, reject) => {
if (
!featureToggle.strategies ||
featureToggle.strategies.length === 0
) {
return reject(
new ValidationError('You must define at least one strategy')
);
}
featureToggle.strategies = featureToggle.strategies.map(
strategyConfig => {
const result = joi.validate(strategyConfig, strategiesSchema);
if (result.error) {
throw result.error;
}
return result.value;
}
);
return resolve(featureToggle);
});
}
const version = 1;
module.exports.router = function(config) {
const { featureToggleStore, eventStore } = config.stores;
const router = Router();
router.get('/', (req, res) => {
featureToggleStore
.getFeatures()
.then(features => res.json({ version, features }));
});
router.get('/:featureName', (req, res) => {
featureToggleStore
.getFeature(req.params.featureName)
.then(feature => res.json(feature).end())
.catch(() =>
res.status(404).json({ error: 'Could not find feature' })
);
});
function validateUniqueName(req) {
return new Promise((resolve, reject) => {
featureToggleStore
.getFeature(req.body.name)
.then(() =>
reject(new NameExistsError('Feature name already exist'))
)
.catch(() => resolve(req));
});
}
router.post('/validate', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req
.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$')
.matches(/^[0-9a-zA-Z\\.\\-]+$/i);
validateRequest(req)
.then(validateUniqueName)
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
router.post('/', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req
.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$')
.matches(/^[0-9a-zA-Z\\.\\-]+$/i);
const userName = extractUser(req);
validateRequest(req)
.then(validateUniqueName)
.then(_req => _req.body)
.then(validateStrategy)
.then(featureToggle =>
eventStore.store({
type: FEATURE_CREATED,
createdBy: userName,
data: featureToggle,
})
)
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
router.put('/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
const updatedFeature = req.body;
updatedFeature.name = featureName;
featureToggleStore
.getFeature(featureName)
.then(() => validateStrategy(updatedFeature))
.then(() =>
eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: updatedFeature,
})
)
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
router.post('/:featureName/toggle', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore
.getFeature(featureName)
.then(feature => {
feature.enabled = !feature.enabled;
return eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: feature,
});
})
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
router.delete('/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore
.getFeature(featureName)
.then(() =>
eventStore.store({
type: FEATURE_ARCHIVED,
createdBy: userName,
data: {
name: featureName,
},
})
)
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
return router;
};

View File

@ -1,19 +1,19 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
@ -29,60 +29,74 @@ function getSetup () {
};
}
test('should get empty getFeatures', t => {
test('should get empty getFeatures via admin', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/features`)
.get(`${base}/api/admin/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.features.length === 0);
});
});
test('should get one getFeature', t => {
t.plan(1);
const { request, featureToggleStore, base } = getSetup();
featureToggleStore.addFeature({ name: 'test_', strategies: [{ name: 'default_' }] });
featureToggleStore.addFeature({
name: 'test_',
strategies: [{ name: 'default_' }],
});
return request
.get(`${base}/features`)
.get(`${base}/api/admin/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.features.length === 1);
});
});
test('should add version numbers for /features', t => {
t.plan(1);
const { request, featureToggleStore, base } = getSetup();
featureToggleStore.addFeature({ name: 'test2', strategies: [{ name: 'default' }] });
featureToggleStore.addFeature({
name: 'test2',
strategies: [{ name: 'default' }],
});
return request
.get(`${base}/features`)
.get(`${base}/api/admin/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.version === 1);
});
});
test('should require at least one strategy when creating a feature toggle', t => {
t.plan(0);
const { request, base } = getSetup();
return request
.post(`${base}/features`)
.post(`${base}/api/admin/features`)
.send({ name: 'sample.missing.strategy' })
.set('Content-Type', 'application/json')
.expect(400)
.expect(400);
});
test('should require at least one strategy when updating a feature toggle', t => {
t.plan(0);
const { request, featureToggleStore, base } = getSetup();
featureToggleStore.addFeature({ name: 'ts', strategies: [{ name: 'default' }] });
featureToggleStore.addFeature({
name: 'ts',
strategies: [{ name: 'default' }],
});
return request
.put(`${base}/features/ts`)
.put(`${base}/api/admin/features/ts`)
.send({ name: 'ts' })
.set('Content-Type', 'application/json')
.expect(400)
.expect(400);
});

View File

@ -0,0 +1,38 @@
'use strict';
const { Router } = require('express');
const features = require('./feature.js');
const featureArchive = require('./archive.js');
const events = require('./event.js');
const strategies = require('./strategy');
const metrics = require('./metrics');
const apiDef = {
version: 2,
links: {
'feature-toggles': { uri: '/api/admin/features' },
'feature-archive': { uri: '/api/admin/archive' },
strategies: { uri: '/api/admin/strategies' },
events: { uri: '/api/admin/events' },
metrics: { uri: '/api/admin/metrics' },
},
};
exports.apiDef = apiDef;
exports.router = config => {
const router = Router();
router.get('/', (req, res) => {
res.json(apiDef);
});
router.use('/features', features.router(config));
router.use('/archive', featureArchive.router(config));
router.use('/strategies', strategies.router(config));
router.use('/events', events.router(config));
router.use('/metrics', metrics.router(config));
return router;
};

View File

@ -0,0 +1,137 @@
'use strict';
const { Router } = require('express');
const logger = require('../../logger');
const ClientMetrics = require('../../client-metrics');
const { catchLogAndSendErrorResponse } = require('./route-utils');
exports.router = function(config) {
const {
clientMetricsStore,
clientInstanceStore,
clientApplicationsStore,
strategyStore,
featureToggleStore,
} = config.stores;
const metrics = new ClientMetrics(clientMetricsStore);
const router = Router();
router.get('/seen-toggles', (req, res) => {
const seenAppToggles = metrics.getAppsWithToggles();
res.json(seenAppToggles);
});
router.get('/seen-apps', (req, res) => {
const seenApps = metrics.getSeenAppsPerToggle();
clientApplicationsStore
.getApplications()
.then(toLookup)
.then(metaData => {
Object.keys(seenApps).forEach(key => {
seenApps[key] = seenApps[key].map(entry => {
if (metaData[entry.appName]) {
return Object.assign(
{},
entry,
metaData[entry.appName]
);
}
return entry;
});
});
res.json(seenApps);
});
});
router.get('/feature-toggles', (req, res) => {
res.json(metrics.getTogglesMetrics());
});
router.get('/feature-toggles/:name', (req, res) => {
const name = req.params.name;
const data = metrics.getTogglesMetrics();
const lastHour = data.lastHour[name] || {};
const lastMinute = data.lastMinute[name] || {};
res.json({
lastHour,
lastMinute,
});
});
router.post('/applications/:appName', (req, res) => {
const input = Object.assign({}, req.body, {
appName: req.params.appName,
});
clientApplicationsStore
.upsert(input)
.then(() => res.status(202).end())
.catch(e => {
logger.error(e);
res.status(500).end();
});
});
function toLookup(metaData) {
return metaData.reduce((result, entry) => {
result[entry.appName] = entry;
return result;
}, {});
}
router.get('/applications/', (req, res) => {
clientApplicationsStore
.getApplications(req.query)
.then(applications => res.json({ applications }))
.catch(err => catchLogAndSendErrorResponse(err, res));
});
router.get('/applications/:appName', (req, res) => {
const appName = req.params.appName;
const seenToggles = metrics.getSeenTogglesByAppName(appName);
Promise.all([
clientApplicationsStore.getApplication(appName),
clientInstanceStore.getByAppName(appName),
strategyStore.getStrategies(),
featureToggleStore.getFeatures(),
])
.then(([application, instances, strategies, features]) => {
const appDetails = {
appName: application.appName,
createdAt: application.createdAt,
description: application.description,
url: application.url,
color: application.color,
icon: application.icon,
strategies: application.strategies.map(name => {
const found = strategies.find(
feature => feature.name === name
);
if (found) {
return found;
}
return { name, notFound: true };
}),
instances,
seenToggles: seenToggles.map(name => {
const found = features.find(
feature => feature.name === name
);
if (found) {
return found;
}
return { name, notFound: true };
}),
links: {
self: `/api/applications/${application.appName}`,
},
};
res.json(appDetails);
})
.catch(err => catchLogAndSendErrorResponse(err, res));
});
return router;
};

View File

@ -0,0 +1,100 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should return seen toggles even when there is nothing', t => {
t.plan(1);
const { request } = getSetup();
return request
.get('/api/admin/metrics/seen-toggles')
.expect(200)
.expect(res => {
t.true(res.body.length === 0);
});
});
test('should return list of seen-toggles per app', t => {
t.plan(3);
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 },
},
},
});
return request
.get('/api/admin/metrics/seen-toggles')
.expect(200)
.expect(res => {
const seenAppsWithToggles = res.body;
t.true(seenAppsWithToggles.length === 1);
t.true(seenAppsWithToggles[0].appName === appName);
t.true(seenAppsWithToggles[0].seenToggles.length === 2);
});
});
test('should return feature-toggles metrics even when there is nothing', t => {
t.plan(0);
const { request } = getSetup();
return request.get('/api/admin/metrics/feature-toggles').expect(200);
});
test('should return metrics for all toggles', t => {
t.plan(2);
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 },
},
},
});
return request
.get('/api/admin/metrics/feature-toggles')
.expect(200)
.expect(res => {
const metrics = res.body;
t.true(metrics.lastHour !== undefined);
t.true(metrics.lastMinute !== undefined);
});
});

View File

@ -1,6 +1,6 @@
'use strict';
const logger = require('../logger');
const logger = require('../../logger');
const catchLogAndSendErrorResponse = (err, res) => {
logger.error(err);

View File

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

View File

@ -0,0 +1,131 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const eventType = require('../../event-type');
const logger = require('../../logger');
const NameExistsError = require('../../error/name-exists-error');
const extractUser = require('../../extract-user');
const strategySchema = require('./strategy-schema');
const version = 1;
const handleError = (req, res, error) => {
logger.warn('Error creating or updating strategy', error);
switch (error.name) {
case 'NotFoundError':
return res.status(404).end();
case 'NameExistsError':
return res
.status(403)
.json([
{
msg: `A strategy named '${req.body
.name}' already exists.`,
},
])
.end();
case 'ValidationError':
return res.status(400).json(error).end();
default:
logger.error('Could perfom operation', error);
return res.status(500).end();
}
};
exports.router = function(config) {
const { strategyStore, eventStore } = config.stores;
const router = Router();
router.get('/', (req, res) => {
strategyStore.getStrategies().then(strategies => {
res.json({ version, strategies });
});
});
router.get('/:name', (req, res) => {
strategyStore
.getStrategy(req.params.name)
.then(strategy => res.json(strategy).end())
.catch(() =>
res.status(404).json({ error: 'Could not find strategy' })
);
});
router.delete('/:name', (req, res) => {
const strategyName = req.params.name;
strategyStore
.getStrategy(strategyName)
.then(() =>
eventStore.store({
type: eventType.STRATEGY_DELETED,
createdBy: extractUser(req),
data: {
name: strategyName,
},
})
)
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
router.post('/', (req, res) => {
const data = req.body;
validateInput(data)
.then(validateStrategyName)
.then(newStrategy =>
eventStore.store({
type: eventType.STRATEGY_CREATED,
createdBy: extractUser(req),
data: newStrategy,
})
)
.then(() => res.status(201).end())
.catch(error => handleError(req, res, error));
});
router.put('/:strategyName', (req, res) => {
const strategyName = req.params.strategyName;
const updatedStrategy = req.body;
updatedStrategy.name = strategyName;
strategyStore
.getStrategy(strategyName)
.then(() => validateInput(updatedStrategy))
.then(() =>
eventStore.store({
type: eventType.STRATEGY_UPDATED,
createdBy: extractUser(req),
data: updatedStrategy,
})
)
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
function validateStrategyName(data) {
return new Promise((resolve, reject) => {
strategyStore
.getStrategy(data.name)
.then(() =>
reject(new NameExistsError('Feature name already exist'))
)
.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);
});
});
}
return router;
};

View File

@ -1,14 +1,15 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const getApp = require('../../../lib/app');
const getApp = require('../../app');
const logger = require('../../logger');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup () {
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
@ -24,89 +25,101 @@ function getSetup () {
};
}
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('should add version numbers for /stategies', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/strategies`)
.get(`${base}/api/admin/strategies`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.version === 1);
});
});
test('should require a name when creating a new stratey', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({})
.expect(400)
.expect((res) => {
.expect(res => {
t.true(res.body.name === 'ValidationError');
});
});
test('should require parameters array when creating a new stratey', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({ name: 'TestStrat' })
.expect(400)
.expect((res) => {
.expect(res => {
t.true(res.body.name === 'ValidationError');
});
});
test('should create a new stratey with empty parameters', () => {
test('should create a new stratey with empty parameters', t => {
t.plan(0);
const { request, base } = getSetup();
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({ name: 'TestStrat', parameters: [] })
.expect(201);
});
test('should not be possible to override name', () => {
test('should not be possible to override name', t => {
t.plan(0);
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name: 'Testing', parameters: [] });
return request
.post(`${base}/api/strategies`)
.post(`${base}/api/admin/strategies`)
.send({ name: 'Testing', parameters: [] })
.expect(403);
});
test('should update strategy', () => {
test('should update strategy', t => {
t.plan(0);
const name = 'AnotherStrat';
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name, parameters: [] });
return request
.put(`${base}/api/strategies/${name}`)
.put(`${base}/api/admin/strategies/${name}`)
.send({ name, parameters: [], description: 'added' })
.expect(200);
});
test('should not update uknown strategy', () => {
test('should not update uknown strategy', t => {
t.plan(0);
const name = 'UnknownStrat';
const { request, base } = getSetup();
return request
.put(`${base}/api/strategies/${name}`)
.put(`${base}/api/admin/strategies/${name}`)
.send({ name, parameters: [], description: 'added' })
.expect(404);
});
test('should validate format when updating strategy', () => {
test('should validate format when updating strategy', t => {
t.plan(0);
const name = 'AnotherStrat';
const { request, base, strategyStore } = getSetup();
strategyStore.addStrategy({ name, parameters: [] });
return request
.put(`${base}/api/strategies/${name}`)
.send({ })
.put(`${base}/api/admin/strategies/${name}`)
.send({})
.expect(400);
});

View File

@ -1,19 +0,0 @@
'use strict';
const apiDef = {
version: 1,
links: {
'feature-toggles': { uri: '/api/features' },
'strategies': { uri: '/api/strategies' },
'events': { uri: '/api/events' },
'client-register': { uri: '/api/client/register' },
'client-metrics': { uri: '/api/client/register' },
'seen-toggles': { uri: '/api/client/seen-toggles' },
},
};
module.exports = (app) => {
app.get('/', (req, res) => {
res.json(apiDef);
});
};

View File

@ -1,12 +1,17 @@
'use strict';
const { Router } = require('express');
const prometheusRegister = require('prom-client/lib/register');
module.exports = function (app, config) {
exports.router = config => {
const router = Router();
if (config.serverMetrics) {
app.get('/internal-backstage/prometheus', (req, res) => {
router.get('/prometheus', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(prometheusRegister.metrics());
});
}
return router;
};

View File

@ -1,19 +1,20 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../logger');
const getApp = require('../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('should use enable prometheus', t => {
t.plan(0);
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
@ -27,5 +28,5 @@ test('should use enable prometheus', t => {
return request
.get('/internal-backstage/prometheus')
.expect('Content-Type', /text/)
.expect(200)
.expect(200);
});

View File

@ -0,0 +1,18 @@
'use strict';
const { Router } = require('express');
const version = 1;
exports.router = config => {
const router = Router();
const { featureToggleStore } = config.stores;
router.get('/', (req, res) => {
featureToggleStore
.getFeatures()
.then(features => res.json({ version, features }));
});
return router;
};

View File

@ -1,10 +1,10 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
@ -13,7 +13,7 @@ test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
@ -29,13 +29,14 @@ function getSetup () {
};
}
test('should get api defintion', t => {
test('should get empty getFeatures via client', t => {
t.plan(1);
const { request, base } = getSetup();
return request
.get(`${base}/api/`)
.get(`${base}/api/client/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
t.true(res.body.links['feature-toggles'].uri === '/api/features');
.expect(res => {
t.true(res.body.features.length === 0);
});
});

View File

@ -0,0 +1,30 @@
'use strict';
const { Router } = require('express');
const features = require('./feature.js');
const metrics = require('./metrics.js');
const register = require('./register.js');
const apiDef = {
version: 2,
links: {
'feature-toggles': { uri: '/api/client/features' },
register: { uri: '/api/client/register' },
metrics: { uri: '/api/client/metrics' },
},
};
exports.apiDef = apiDef;
exports.router = config => {
const router = Router();
router.get('/', (req, res) => {
res.json(apiDef);
});
router.use('/features', features.router(config));
router.use('/metrics', metrics.router(config));
router.use('/register', register.router(config));
return router;
};

View File

@ -5,8 +5,7 @@ const joi = require('joi');
const clientMetricsSchema = joi.object().keys({
appName: joi.string().required(),
instanceId: joi.string().required(),
bucket: joi.object().required()
.keys({
bucket: joi.object().required().keys({
start: joi.date().required(),
stop: joi.date().required(),
toggles: joi.object(),
@ -16,9 +15,7 @@ const clientMetricsSchema = joi.object().keys({
const clientRegisterSchema = joi.object().keys({
appName: joi.string().required(),
instanceId: joi.string().required(),
strategies: joi.array()
.required()
.items(joi.string(), joi.any().strip()),
strategies: joi.array().required().items(joi.string(), joi.any().strip()),
started: joi.date().required(),
interval: joi.number().required(),
});

View File

@ -0,0 +1,39 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const logger = require('../../logger');
const { clientMetricsSchema } = require('./metrics-schema');
exports.router = config => {
const { clientMetricsStore, clientInstanceStore } = config.stores;
const router = Router();
router.post('/', (req, res) => {
const data = req.body;
const clientIp = req.ip;
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
if (err) {
logger.warn('Invalid metrics posted', err);
return res.status(400).json(err);
}
clientMetricsStore
.insert(cleaned)
.then(() =>
clientInstanceStore.insert({
appName: cleaned.appName,
instanceId: cleaned.instanceId,
clientIp,
})
)
.catch(err => logger.error('failed to store metrics', err));
res.status(202).end();
});
});
return router;
};

View File

@ -0,0 +1,54 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should validate client metrics', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({ random: 'blush' })
.expect(400);
});
test('should accept client metrics', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({
appName: 'demo',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {},
},
})
.expect(202);
});

View File

@ -0,0 +1,38 @@
'use strict';
const { Router } = require('express');
const joi = require('joi');
const logger = require('../../logger');
const { clientRegisterSchema } = require('./metrics-schema');
exports.router = config => {
const { clientInstanceStore, clientApplicationsStore } = config.stores;
const router = Router();
router.post('/', (req, res) => {
const data = req.body;
joi.validate(data, clientRegisterSchema, (err, clientRegistration) => {
if (err) {
logger.warn('Invalid client data posted', err);
return res.status(400).json(err);
}
clientRegistration.clientIp = req.ip;
clientApplicationsStore
.upsert(clientRegistration)
.then(() => clientInstanceStore.insert(clientRegistration))
.then(() =>
logger.info(`New client registered with
appName=${clientRegistration.appName} and instanceId=${clientRegistration.instanceId}`)
)
.catch(err => logger.error('failed to register client', err));
res.status(202).end();
});
});
return router;
};

View File

@ -0,0 +1,64 @@
'use strict';
const { test } = require('ava');
const store = require('./../../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../logger');
const getApp = require('../../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should register client', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(202);
});
test('should require appName field', t => {
t.plan(0);
const { request } = getSetup();
return request.post('/api/client/register').expect(400);
});
test('should require strategies field', t => {
t.plan(0);
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
// strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(400);
});

View File

@ -1,44 +0,0 @@
'use strict';
const logger = require('../logger');
const { FEATURE_REVIVED } = require('../event-type');
const ValidationError = require('../error/validation-error');
const validateRequest = require('../error/validate-request');
const handleErrors = (req, res, error) => {
switch (error.constructor) {
case ValidationError:
return res
.status(400)
.json(req.validationErrors())
.end();
default:
logger.error('Server failed executing request', error);
return res
.status(500)
.end();
}
};
module.exports = function (app, config) {
const { featureToggleStore, eventStore } = config.stores;
app.get('/archive/features', (req, res) => {
featureToggleStore.getArchivedFeatures().then(archivedFeatures => {
res.json({ features: archivedFeatures });
});
});
app.post('/archive/revive/:name', (req, res) => {
req.checkParams('name', 'Name is required').notEmpty();
validateRequest(req)
.then(() => eventStore.store({
type: FEATURE_REVIVED,
createdBy: req.connection.remoteAddress,
data: { name: req.params.name },
}))
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
};

View File

@ -1,178 +0,0 @@
'use strict';
const joi = require('joi');
const logger = require('../logger');
const { FEATURE_CREATED, FEATURE_UPDATED, FEATURE_ARCHIVED } = require('../event-type');
const NameExistsError = require('../error/name-exists-error');
const NotFoundError = require('../error/notfound-error');
const ValidationError = require('../error/validation-error.js');
const validateRequest = require('../error/validate-request');
const extractUser = require('../extract-user');
const legacyFeatureMapper = require('../data-helper/legacy-feature-mapper');
const version = 1;
const handleErrors = (req, res, error) => {
logger.warn('Error creating or updating feature', error);
switch (error.constructor) {
case NotFoundError:
return res
.status(404)
.end();
case NameExistsError:
return res
.status(403)
.json([{ msg: 'A feature with this name already exists. Try re-activating it from the archive.' }])
.end();
case ValidationError:
return res
.status(400)
.json(req.validationErrors())
.end();
default:
logger.error('Server failed executing request', error);
return res
.status(500)
.end();
}
};
module.exports = function (app, config) {
const { featureToggleStore, eventStore } = config.stores;
app.get('/features', (req, res) => {
featureToggleStore.getFeatures()
.then((features) => features.map(legacyFeatureMapper.addOldFields))
.then(features => res.json({ version, features }));
});
app.get('/features/:featureName', (req, res) => {
featureToggleStore.getFeature(req.params.featureName)
.then(legacyFeatureMapper.addOldFields)
.then(feature => res.json(feature).end())
.catch(() => res.status(404).json({ error: 'Could not find feature' }));
});
app.post('/features-validate', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$').matches(/^[0-9a-zA-Z\\.\\-]+$/i);
validateRequest(req)
.then(validateFormat)
.then(validateUniqueName)
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
app.post('/features', (req, res) => {
req.checkBody('name', 'Name is required').notEmpty();
req.checkBody('name', 'Name must match format ^[0-9a-zA-Z\\.\\-]+$').matches(/^[0-9a-zA-Z\\.\\-]+$/i);
const userName = extractUser(req);
validateRequest(req)
.then(validateFormat)
.then(validateUniqueName)
.then((_req) => legacyFeatureMapper.toNewFormat(_req.body))
.then(validateStrategy)
.then((featureToggle) => eventStore.store({
type: FEATURE_CREATED,
createdBy: userName,
data: featureToggle,
}))
.then(() => res.status(201).end())
.catch(error => handleErrors(req, res, error));
});
app.put('/features/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
const updatedFeature = legacyFeatureMapper.toNewFormat(req.body);
updatedFeature.name = featureName;
featureToggleStore.getFeature(featureName)
.then(() => validateStrategy(updatedFeature))
.then(() => eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: updatedFeature,
}))
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
app.post('/features/:featureName/toggle', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore.getFeature(featureName)
.then((feature) => {
feature.enabled = !feature.enabled;
return eventStore.store({
type: FEATURE_UPDATED,
createdBy: userName,
data: feature,
});
})
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
app.delete('/features/:featureName', (req, res) => {
const featureName = req.params.featureName;
const userName = extractUser(req);
featureToggleStore.getFeature(featureName)
.then(() => eventStore.store({
type: FEATURE_ARCHIVED,
createdBy: userName,
data: {
name: featureName,
},
}))
.then(() => res.status(200).end())
.catch(error => handleErrors(req, res, error));
});
function validateUniqueName (req) {
return new Promise((resolve, reject) => {
featureToggleStore.getFeature(req.body.name)
.then(() => reject(new NameExistsError('Feature name already exist')))
.catch(() => resolve(req));
});
}
function validateFormat (req) {
if (req.body.strategy && req.body.strategies) {
return Promise.reject(new ValidationError('Cannot use both "strategy" and "strategies".'));
}
return Promise.resolve(req);
}
const strategiesSchema = joi.object().keys({
name: joi.string()
.regex(/^[a-zA-Z0-9\\.\\-]{3,100}$/)
.required(),
parameters: joi.object(),
});
function validateStrategy (featureToggle) {
return new Promise((resolve, reject) => {
if (!featureToggle.strategies || featureToggle.strategies.length === 0) {
return reject(new ValidationError('You must define at least one strategy'));
}
featureToggle.strategies = featureToggle.strategies.map((strategyConfig) => {
const result = joi.validate(strategyConfig, strategiesSchema);
if (result.error) {
throw result.error;
}
return result.value;
});
return resolve(featureToggle);
});
}
};

View File

@ -1,15 +1,24 @@
'use strict';
const logger = require('../logger');
const { Router } = require('express');
module.exports = function (app, config) {
app.get('/health', (req, res) => {
config.stores.db.select(1)
exports.router = function(config) {
const router = Router();
router.get('/', (req, res) => {
config.stores.db
.select(1)
.from('features')
.then(() => res.json({ health: 'GOOD' }))
.catch(err => {
logger.error('Could not select from features, error was: ', err);
logger.error(
'Could not select from features, error was: ',
err
);
res.status(500).json({ health: 'BAD' });
});
});
return router;
};

View File

@ -1,20 +1,19 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const { test } = require('ava');
const store = require('./../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const logger = require('../logger');
const getApp = require('../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
function getSetup() {
const stores = store.createStores();
const db = stores.db;
const app = getApp({
@ -30,34 +29,28 @@ function getSetup () {
}
test('should give 500 when db is failing', t => {
t.plan(2);
const { request, db } = getSetup();
db.select = () => ({
from: () => Promise.reject(new Error('db error')),
});
return request
.get('/health')
.expect(500)
.expect((res) => {
t.true(res.status === 500);
t.true(res.body.health === 'BAD');
});
return request.get('/health').expect(500).expect(res => {
t.true(res.status === 500);
t.true(res.body.health === 'BAD');
});
});
test('should give 200 when db is not failing', () => {
test('should give 200 when db is not failing', t => {
t.plan(0);
const { request } = getSetup();
return request
.get('/health')
.expect(200);
return request.get('/health').expect(200);
});
test('should give health=GOOD when db is not failing', t => {
t.plan(2);
const { request } = getSetup();
return request
.get('/health')
.expect(200)
.expect((res) => {
t.true(res.status === 200);
t.true(res.body.health === 'GOOD');
});
return request.get('/health').expect(200).expect(res => {
t.true(res.status === 200);
t.true(res.body.health === 'GOOD');
});
});

View File

@ -1,18 +1,44 @@
'use strict';
const { Router } = require('express');
exports.createAPI = function (router, config) {
require('./api')(router, config);
require('./event')(router, config);
require('./feature')(router, config);
require('./feature-archive')(router, config);
require('./strategy')(router, config);
require('./health-check')(router, config);
require('./metrics')(router, config);
};
const adminApi = require('./admin-api');
const clientApi = require('./client-api');
const clientFeatures = require('./client-api/feature.js');
exports.createLegacy = function (router, config) {
require('./feature')(router, config);
require('./health-check')(router, config);
require('./backstage')(router, config);
const health = require('./health-check');
const backstage = require('./backstage.js');
exports.router = function(config) {
const router = Router();
router.use('/health', health.router(config));
router.use('/internal-backstage', backstage.router(config));
router.get('/api', (req, res) => {
res.json({
version: 2,
links: {
admin: {
uri: '/api/admin',
links: adminApi.apiDef.links,
},
client: {
uri: '/api/client',
links: clientApi.apiDef.links,
},
},
});
});
router.use('/api/admin', adminApi.router(config));
router.use('/api/client', clientApi.router(config));
// legacy support
// $root/features
// $root/client/register
// $root/client/metrics
router.use('/api/features', clientFeatures.router(config));
return router;
};

91
lib/routes/index.test.js Normal file
View File

@ -0,0 +1,91 @@
'use strict';
const { test } = require('ava');
const store = require('./../../test/fixtures/store');
const supertest = require('supertest');
const logger = require('../logger');
const getApp = require('../app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
});
return {
base,
featureToggleStore: stores.featureToggleStore,
request: supertest(app),
};
}
test('api defintion', t => {
t.plan(5);
const { request, base } = getSetup();
return request
.get(`${base}/api/`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
const { admin, client } = res.body.links;
t.true(admin.uri === '/api/admin');
t.true(client.uri === '/api/client');
t.true(
admin.links['feature-toggles'].uri === '/api/admin/features'
);
t.true(client.links.metrics.uri === '/api/client/metrics');
});
});
test('admin api defintion', t => {
t.plan(2);
const { request, base } = getSetup();
return request
.get(`${base}/api/admin`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
t.true(
res.body.links['feature-toggles'].uri === '/api/admin/features'
);
});
});
test('client api defintion', t => {
t.plan(2);
const { request, base } = getSetup();
return request
.get(`${base}/api/client`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
t.true(res.body.links.metrics.uri === '/api/client/metrics');
});
});
test('client legacy features uri', t => {
t.plan(3);
const { request, base } = getSetup();
return request
.get(`${base}/api/features`)
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.truthy(res.body);
t.true(res.body.version === 1);
t.deepEqual(res.body.features, []);
});
});

View File

@ -1,170 +0,0 @@
'use strict';
const logger = require('../logger');
const ClientMetrics = require('../client-metrics');
const joi = require('joi');
const { clientMetricsSchema, clientRegisterSchema } = require('./metrics-schema');
const { catchLogAndSendErrorResponse } = require('./route-utils');
module.exports = function (app, config) {
const {
clientMetricsStore,
clientInstanceStore,
clientApplicationsStore,
strategyStore,
featureToggleStore,
} = config.stores;
const metrics = new ClientMetrics(clientMetricsStore);
app.get('/client/seen-toggles', (req, res) => {
const seenAppToggles = metrics.getAppsWithToggles();
res.json(seenAppToggles);
});
app.get('/client/seen-apps', (req, res) => {
const seenApps = metrics.getSeenAppsPerToggle();
clientApplicationsStore.getApplications()
.then(toLookup)
.then(metaData => {
Object.keys(seenApps).forEach(key => {
seenApps[key] = seenApps[key].map(entry => {
if (metaData[entry.appName]) {
return Object.assign({}, entry, metaData[entry.appName]);
}
return entry;
});
});
res.json(seenApps);
});
});
app.get('/client/metrics/feature-toggles', (req, res) => {
res.json(metrics.getTogglesMetrics());
});
app.get('/client/metrics/feature-toggles/:name', (req, res) => {
const name = req.params.name;
const data = metrics.getTogglesMetrics();
const lastHour = data.lastHour[name] || {};
const lastMinute = data.lastMinute[name] || {};
res.json({
lastHour,
lastMinute,
});
});
app.post('/client/metrics', (req, res) => {
const data = req.body;
const clientIp = req.ip;
joi.validate(data, clientMetricsSchema, (err, cleaned) => {
if (err) {
logger.warn('Invalid metrics posted', err);
return res.status(400).json(err);
}
clientMetricsStore
.insert(cleaned)
.then(() => clientInstanceStore.insert({
appName: cleaned.appName,
instanceId: cleaned.instanceId,
clientIp,
}))
.catch(err => logger.error('failed to store metrics', err));
res.status(202).end();
});
});
app.post('/client/register', (req, res) => {
const data = req.body;
joi.validate(data, clientRegisterSchema, (err, clientRegistration) => {
if (err) {
logger.warn('Invalid client data posted', err);
return res.status(400).json(err);
}
clientRegistration.clientIp = req.ip;
clientApplicationsStore
.upsert(clientRegistration)
.then(() => clientInstanceStore.insert(clientRegistration))
.then(() => logger.info(`New client registered with
appName=${clientRegistration.appName} and instanceId=${clientRegistration.instanceId}`))
.catch(err => logger.error('failed to register client', err));
res.status(202).end();
});
});
app.post('/client/applications/:appName', (req, res) => {
const input = Object.assign({}, req.body, {
appName: req.params.appName,
});
clientApplicationsStore
.upsert(input)
.then(() => res.status(202).end())
.catch((e) => {
logger.error(e);
res.status(500).end();
});
});
function toLookup (metaData) {
return metaData.reduce((result, entry) => {
result[entry.appName] = entry;
return result;
}, {});
}
app.get('/client/applications/', (req, res) => {
clientApplicationsStore
.getApplications(req.query)
.then(applications => res.json({ applications }))
.catch(err => catchLogAndSendErrorResponse(err, res));
});
app.get('/client/applications/:appName', (req, res) => {
const appName = req.params.appName;
const seenToggles = metrics.getSeenTogglesByAppName(appName);
Promise.all([
clientApplicationsStore.getApplication(appName),
clientInstanceStore.getByAppName(appName),
strategyStore.getStrategies(),
featureToggleStore.getFeatures(),
])
.then(([application, instances, strategies, features]) => {
const appDetails = {
appName: application.appName,
createdAt: application.createdAt,
description: application.description,
url: application.url,
color: application.color,
icon: application.icon,
strategies: application.strategies.map(name => {
const found = strategies.find((feature) => feature.name === name);
if (found) {
return found;
}
return { name, notFound: true };
}),
instances,
seenToggles: seenToggles.map(name => {
const found = features.find((feature) => feature.name === name);
if (found) {
return found;
}
return { name, notFound: true };
}),
links: {
self: `/api/client/applications/${application.appName}`,
},
};
res.json(appDetails);
})
.catch(err => catchLogAndSendErrorResponse(err, res));
});
};

View File

@ -1,114 +0,0 @@
'use strict';
const joi = require('joi');
const eventType = require('../event-type');
const logger = require('../logger');
const NameExistsError = require('../error/name-exists-error');
const extractUser = require('../extract-user');
const strategySchema = require('./strategy-schema');
const version = 1;
const handleError = (req, res, error) => {
logger.warn('Error creating or updating strategy', error);
switch (error.name) {
case 'NotFoundError':
return res
.status(404)
.end();
case 'NameExistsError':
return res
.status(403)
.json([{ msg: `A strategy named '${req.body.name}' already exists.` }])
.end();
case 'ValidationError':
return res
.status(400)
.json(error)
.end();
default:
logger.error('Could perfom operation', error);
return res
.status(500)
.end();
}
};
module.exports = function (app, config) {
const { strategyStore, eventStore } = config.stores;
app.get('/strategies', (req, res) => {
strategyStore.getStrategies().then(strategies => {
res.json({ version, strategies });
});
});
app.get('/strategies/:name', (req, res) => {
strategyStore.getStrategy(req.params.name)
.then(strategy => res.json(strategy).end())
.catch(() => res.status(404).json({ error: 'Could not find strategy' }));
});
app.delete('/strategies/:name', (req, res) => {
const strategyName = req.params.name;
strategyStore.getStrategy(strategyName)
.then(() => eventStore.store({
type: eventType.STRATEGY_DELETED,
createdBy: extractUser(req),
data: {
name: strategyName,
},
}))
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
app.post('/strategies', (req, res) => {
const data = req.body;
validateInput(data)
.then(validateStrategyName)
.then((newStrategy) => eventStore.store({
type: eventType.STRATEGY_CREATED,
createdBy: extractUser(req),
data: newStrategy,
}))
.then(() => res.status(201).end())
.catch(error => handleError(req, res, error));
});
app.put('/strategies/:strategyName', (req, res) => {
const strategyName = req.params.strategyName;
const updatedStrategy = req.body;
updatedStrategy.name = strategyName;
strategyStore.getStrategy(strategyName)
.then(() => validateInput(updatedStrategy))
.then(() => eventStore.store({
type: eventType.STRATEGY_UPDATED,
createdBy: extractUser(req),
data: updatedStrategy,
}))
.then(() => res.status(200).end())
.catch(error => handleError(req, res, error));
});
function validateStrategyName (data) {
return new Promise((resolve, reject) => {
strategyStore.getStrategy(data.name)
.then(() => reject(new NameExistsError('Feature name already exist')))
.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

@ -10,15 +10,18 @@ const { startMonitoring } = require('./metrics');
const { createStores } = require('./db');
const { createOptions } = require('./options');
function createApp (options) {
function createApp(options) {
// Database dependecies (statefull)
const stores = createStores(options);
const eventBus = new EventEmitter();
const config = Object.assign({
stores,
eventBus,
}, options);
const config = Object.assign(
{
stores,
eventBus,
},
options
);
const app = getApp(config);
const server = app.listen(app.get('port'), () => {
@ -30,7 +33,7 @@ function createApp (options) {
return { app, server, eventBus };
}
function start (opts) {
function start(opts) {
const options = createOptions(opts);
return migrator({ databaseUrl: options.databaseUrl })

View File

@ -1,33 +1,33 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const proxyquire = require('proxyquire');
const express = require('express');
const getApp = proxyquire('./app', {
'./routes': {
createAPI: () => {},
createLegacy: () => {},
router: () => express.Router(),
},
});
const serverImpl = proxyquire('./server-impl', {
'./app': getApp,
'./metrics': {
startMonitoring (o) {
startMonitoring(o) {
return o;
},
},
'./db': {
createStores (o) {
createStores(o) {
return o;
},
},
'./options': {
createOptions (o) {
createOptions(o) {
return o;
},
},
'../migrator' () {
'../migrator'() {
return Promise.resolve();
},
});
@ -35,6 +35,7 @@ const serverImpl = proxyquire('./server-impl', {
test('should call preHook', async t => {
let called = 0;
await serverImpl.start({
port: 0,
preHook: () => {
called++;
},
@ -44,8 +45,11 @@ test('should call preHook', async t => {
test('should call preRouterHook', async t => {
let called = 0;
await serverImpl.start({ preRouterHook: () => {
called++;
} });
await serverImpl.start({
port: 0,
preRouterHook: () => {
called++;
},
});
t.true(called === 1);
});

View File

@ -39,7 +39,7 @@
"db-migrate": "db-migrate",
"lint": "eslint lib",
"pretest": "npm run lint",
"test": "PORT=4243 ava test lib/*.test.js lib/**/*.test.js",
"test": "PORT=4243 ava lib/*.test.js lib/**/*.test.js lib/**/**/*.test.js lib/**/**/**/*.test.js test",
"test:docker": "./scripts/docker-postgres.sh",
"test:watch": "npm run test -- --watch",
"test:pg-virtualenv": "pg_virtualenv npm run test:pg-virtualenv-chai",
@ -81,11 +81,13 @@
},
"devDependencies": {
"@types/node": "^7.0.5",
"ava": "^0.18.2",
"ava": "^0.19.1",
"coveralls": "^2.11.16",
"eslint": "^3.16.1",
"eslint-config-finn": "^1.0.0-beta.1",
"eslint": "^4.0.0",
"eslint-config-finn": "^1.0.2",
"eslint-config-finn-prettier": "^2.0.0",
"nyc": "^10.1.2",
"prettier": "^1.4.4",
"proxyquire": "^1.7.11",
"sinon": "^1.17.7",
"superagent": "^3.5.0",

View File

@ -1,26 +1,28 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns events', async (t) => {
test.serial('returns events', async t => {
t.plan(0);
const { request, destroy } = await setupApp('event_api_serial');
return request
.get('/api/events')
.get('/api/admin/events')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('returns events given a name', async (t) => {
test.serial('returns events given a name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('event_api_serial');
return request
.get('/api/events/myname')
.get('/api/admin/events/myname')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);

View File

@ -1,38 +1,38 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns three archived toggles', async t => {
t.plan(1);
const { request, destroy } = await setupApp('archive_serial');
return request
.get('/api/archive/features')
.get('/api/admin/archive/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
.expect(res => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('revives a feature by name', async t => {
const { request, destroy } = await setupApp('archive_serial');
t.plan(0);
const { request, destroy } = await setupApp('archive_serial');
return request
.post('/api/archive/revive/featureArchivedX')
.post('/api/admin/archive/revive/featureArchivedX')
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('must set name when reviving toggle', async t => {
const { request, destroy } = await setupApp('archive_serial');
return request
.post('/api/archive/revive/')
.expect(404)
.then(destroy);
t.plan(0);
const { request, destroy } = await setupApp('archive_serial');
return request.post('/api/admin/archive/revive/').expect(404).then(destroy);
});

View File

@ -0,0 +1,200 @@
'use strict';
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns three feature toggles', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/api/admin/features')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('gets a feature by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/api/admin/features/featureX')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get feature that dose not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/api/admin/features/myfeature')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});
test.serial('creates new feature toggle', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features')
.send({
name: 'com.test.feature',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(201)
.then(destroy);
});
test.serial('creates new feature toggle with createdBy', async t => {
t.plan(1);
const { request, destroy } = await setupApp('feature_api_serial');
await request
.post('/api/admin/features')
.send({
name: 'com.test.Username',
enabled: false,
strategies: [{ name: 'default' }],
})
.set('Cookie', ['username=ivaosthu'])
.set('Content-Type', 'application/json');
await request
.get('/api/admin/events')
.expect(res => {
t.true(res.body.events[0].createdBy === 'ivaosthu');
})
.then(destroy);
});
test.serial('require new feature toggle to have a name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features')
.send({ name: '' })
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});
test.serial(
'can not change status of feature toggle that does not exist',
async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/api/admin/features/should-not-exist')
.send({ name: 'should-not-exist', enabled: false })
.set('Content-Type', 'application/json')
.expect(404)
.then(destroy);
}
);
test.serial('can change status of feature toggle that does exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/api/admin/features/featureY')
.send({
name: 'featureY',
enabled: true,
strategies: [{ name: 'default' }],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('can not toggle of feature that does not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features/should-not-exist/toggle')
.set('Content-Type', 'application/json')
.expect(404)
.then(destroy);
});
test.serial('can toggle a feature that does exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features/featureY/toggle')
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('archives a feature by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/api/admin/features/featureX')
.expect(200)
.then(destroy);
});
test.serial('can not archive unknown feature', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/api/admin/features/featureUnknown')
.expect(404)
.then(destroy);
});
test.serial('refuses to create a feature with an existing name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403)
.then(destroy);
});
test.serial('refuses to validate a feature with an existing name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/api/admin/features/validate')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403)
.then(destroy);
});
test.serial(
'new strategies api can add two strategies to a feature toggle',
async t => {
t.plan(0);
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/api/admin/features/featureY')
.send({
name: 'featureY',
description: 'soon to be the #14 feature',
enabled: false,
strategies: [
{
name: 'baz',
parameters: { foo: 'bar' },
},
],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
}
);

View File

@ -1,14 +1,16 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
test.beforeEach(() => {
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('should register client', async (t) => {
const { request, destroy } = await setupApp('metrics_serial');
test.serial('should register client', async t => {
t.plan(0);
const { request, destroy } = await setupApp('metrics_serial');
return request
.post('/api/client/register')
.send({
@ -16,35 +18,39 @@ test.serial('should register client', async (t) => {
instanceId: 'test',
strategies: ['default'],
started: Date.now(),
interval: 10
interval: 10,
})
.expect(202)
.then(destroy);
});
test.serial('should allow client to register multiple times', async (t) => {
const { request, destroy } = await setupApp('metrics_serial');
test.serial('should allow client to register multiple times', async t => {
t.plan(0);
const { request, destroy } = await setupApp('metrics_serial');
const clientRegistration = {
appName: 'multipleRegistration',
instanceId: 'test',
strategies: ['default', 'another'],
started: Date.now(),
interval: 10
appName: 'multipleRegistration',
instanceId: 'test',
strategies: ['default', 'another'],
started: Date.now(),
interval: 10,
};
return request
.post('/api/client/register')
.send(clientRegistration)
.expect(202)
.then(() => request
.post('/api/client/register')
.send(clientRegistration)
.expect(202))
.then(() =>
request
.post('/api/client/register')
.send(clientRegistration)
.expect(202)
)
.then(destroy);
});
test.serial('should accept client metrics', async t => {
const { request, destroy } = await setupApp('metrics_serial');
t.plan(0);
const { request, destroy } = await setupApp('metrics_serial');
return request
.post('/api/client/metrics')
.send({
@ -53,19 +59,20 @@ test.serial('should accept client metrics', async t => {
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {}
}
toggles: {},
},
})
.expect(202)
.then(destroy);
});
test.serial('should get application details', async t => {
const { request, destroy } = await setupApp('metrics_serial');
t.plan(3);
const { request, destroy } = await setupApp('metrics_serial');
return request
.get('/api/client/applications/demo-app-1')
.get('/api/admin/metrics/applications/demo-app-1')
.expect('Content-Type', /json/)
.expect((res) => {
.expect(res => {
t.true(res.status === 200);
t.true(res.body.appName === 'demo-app-1');
t.true(res.body.instances.length === 1);
@ -74,11 +81,12 @@ test.serial('should get application details', async t => {
});
test.serial('should get list of applications', async t => {
const { request, destroy } = await setupApp('metrics_serial');
t.plan(2);
const { request, destroy } = await setupApp('metrics_serial');
return request
.get('/api/client/applications')
.get('/api/admin/metrics/applications')
.expect('Content-Type', /json/)
.expect((res) => {
.expect(res => {
t.true(res.status === 200);
t.true(res.body.applications.length === 2);
})

View File

@ -1,102 +1,124 @@
'use strict';
const test = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
const { test } = require('ava');
const { setupApp } = require('./../../helpers/test-helper');
const logger = require('../../../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('gets all strategies', async (t) => {
test.serial('gets all strategies', async t => {
t.plan(1);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.get('/api/strategies')
.get('/api/admin/strategies')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
t.true(res.body.strategies.length === 2, 'expected to have two strategies');
.expect(res => {
t.true(
res.body.strategies.length === 2,
'expected to have two strategies'
);
})
.then(destroy);
});
test.serial('gets a strategy by name', async (t) => {
test.serial('gets a strategy by name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.get('/api/strategies/default')
.get('/api/admin/strategies/default')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get a strategy by name that dose not exist', async (t) => {
test.serial('cant get a strategy by name that dose not exist', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.get('/api/strategies/mystrategy')
.get('/api/admin/strategies/mystrategy')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});
test.serial('creates a new strategy', async (t) => {
test.serial('creates a new strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.post('/api/strategies')
.send({ name: 'myCustomStrategy', description: 'Best strategy ever.', parameters: [] })
.post('/api/admin/strategies')
.send({
name: 'myCustomStrategy',
description: 'Best strategy ever.',
parameters: [],
})
.set('Content-Type', 'application/json')
.expect(201)
.then(destroy);
});
test.serial('requires new strategies to have a name', async (t) => {
test.serial('requires new strategies to have a name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.post('/api/strategies')
.post('/api/admin/strategies')
.send({ name: '' })
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});
test.serial('refuses to create a strategy with an existing name', async (t) => {
test.serial('refuses to create a strategy with an existing name', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.post('/api/strategies')
.post('/api/admin/strategies')
.send({ name: 'default', parameters: [] })
.set('Content-Type', 'application/json')
.expect(403)
.then(destroy);
});
test.serial('deletes a new strategy', async (t) => {
test.serial('deletes a new strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.delete('/api/strategies/usersWithEmail')
.delete('/api/admin/strategies/usersWithEmail')
.expect(200)
.then(destroy);
});
test.serial('can\'t delete a strategy that dose not exist', async (t) => {
test.serial("can't delete a strategy that dose not exist", async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial', false);
return request
.delete('/api/strategies/unknown')
.expect(404);
.delete('/api/admin/strategies/unknown')
.expect(404)
.then(destroy);
});
test.serial('updates a exiting strategy', async (t) => {
test.serial('updates a exiting strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.put('/api/strategies/default')
.send({ name: 'default', description: 'Default is the best!', parameters: [] })
.put('/api/admin/strategies/default')
.send({
name: 'default',
description: 'Default is the best!',
parameters: [],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('cant update a unknown strategy', async (t) => {
test.serial('cant update a unknown strategy', async t => {
t.plan(0);
const { request, destroy } = await setupApp('strategy_api_serial');
return request
.put('/api/strategies/unknown')
.put('/api/admin/strategies/unknown')
.send({ name: 'unkown', parameters: [] })
.set('Content-Type', 'application/json')
.expect(404)

View File

@ -0,0 +1,5 @@
'use strict';
const { test } = require('ava');
test.todo('e2e client feature');

View File

@ -0,0 +1,5 @@
'use strict';
const { test } = require('ava');
test.todo('e2e client metrics');

View File

@ -1,208 +0,0 @@
'use strict';
const { test } = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test.serial('returns three feature toggles', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
t.true(res.body.features.length === 3);
})
.then(destroy);
});
test.serial('gets a feature by name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.get('/features/featureX')
.expect('Content-Type', /json/)
.expect(200)
.then(destroy);
});
test.serial('cant get feature that dose not exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.get('/features/myfeature')
.expect('Content-Type', /json/)
.expect(404)
.then(destroy);
});
test.serial('creates new feature toggle', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/features')
.send({ name: 'com.test.feature', enabled: false, strategies: [{name: 'default'}] })
.set('Content-Type', 'application/json')
.expect(201)
.then(destroy);
});
test.serial('creates new feature toggle with createdBy', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
request
.post('/features')
.send({ name: 'com.test.Username', enabled: false, strategies: [{name: 'default'}] })
.set('Cookie', ['username=ivaosthu'])
.set('Content-Type', 'application/json')
.end(() => {
return request
.get('/api/events')
.expect((res) => {
t.true(res.body.events[0].createdBy === 'ivaosthu');
})
.then(destroy);
});
});
test.serial('require new feature toggle to have a name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features')
.send({ name: '' })
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});
test.serial('can not change status of feature toggle that does not exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.put('/features/should-not-exist')
.send({ name: 'should-not-exist', enabled: false })
.set('Content-Type', 'application/json')
.expect(404).then(destroy);
});
test.serial('can change status of feature toggle that does exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.put('/features/featureY')
.send({ name: 'featureY', enabled: true, strategies: [{name: 'default'}] })
.set('Content-Type', 'application/json')
.expect(200).then(destroy);
});
test.serial('can not toggle of feature that does not exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features/should-not-exist/toggle')
.set('Content-Type', 'application/json')
.expect(404).then(destroy);
});
test.serial('can toggle a feature that does exist', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features/featureY/toggle')
.set('Content-Type', 'application/json')
.expect(200).then(destroy);
});
test.serial('archives a feature by name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/features/featureX')
.expect(200).then(destroy);
});
test.serial('can not archive unknown feature', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.delete('/features/featureUnknown')
.expect(404).then(destroy);
});
test.serial('refuses to create a feature with an existing name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/features')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403).then(destroy);
});
test.serial('refuses to validate a feature with an existing name', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.post('/features-validate')
.send({ name: 'featureX' })
.set('Content-Type', 'application/json')
.expect(403).then(destroy);
});
test.serial('new strategies api automatically map existing strategy to strategies array', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
t.plan(3);
return request
.get('/features/featureY')
.expect('Content-Type', /json/)
.expect((res) => {
t.true(res.body.strategies.length === 1, 'expected strategy added to strategies');
t.true(res.body.strategy === res.body.strategies[0].name);
t.deepEqual(res.body.parameters, res.body.strategies[0].parameters);
})
.then(destroy);
});
test.serial('new strategies api can add two strategies to a feature toggle', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
return request
.put('/features/featureY')
.send({
name: 'featureY',
description: 'soon to be the #14 feature',
enabled: false,
strategies: [
{
name: 'baz',
parameters: { foo: 'bar' },
},
],
})
.set('Content-Type', 'application/json')
.expect(200)
.then(destroy);
});
test.serial('new strategies api should not be allowed to post both strategy and strategies', async t => {
const { request, destroy } = await setupApp('feature_api_serial');
logger.setLevel('FATAL');
return request
.post('/features')
.send({
name: 'featureConfusing',
description: 'soon to be the #14 feature',
enabled: false,
strategy: 'baz',
parameters: {},
strategies: [
{
name: 'baz',
parameters: { foo: 'bar' },
},
],
})
.set('Content-Type', 'application/json')
.expect(400)
.then(destroy);
});

View File

@ -1,16 +1,18 @@
'use strict';
const test = require('ava');
const { test } = require('ava');
const { setupApp } = require('./helpers/test-helper');
const logger = require('../../lib/logger');
test.beforeEach(() => {
test.beforeEach(() => {
logger.setLevel('FATAL');
});
test('returns health good', async (t) => {
test('returns health good', async t => {
t.plan(0);
const { request, destroy } = await setupApp('health');
return request.get('/health')
return request
.get('/health')
.expect('Content-Type', /json/)
.expect(200)
.expect('{"health":"GOOD"}')

View File

@ -1,6 +1,6 @@
'use strict';
function getDatabaseUrl () {
function getDatabaseUrl() {
if (process.env.TEST_DATABASE_URL) {
return process.env.TEST_DATABASE_URL;
} else {

View File

@ -18,16 +18,21 @@ delete process.env.DATABASE_URL;
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function createApp (databaseSchema = 'test') {
function createApp(databaseSchema = 'test') {
const options = {
databaseUrl: require('./database-config').getDatabaseUrl(),
databaseSchema,
minPool: 0,
maxPool: 0,
};
const db = createDb({ databaseUrl: options.databaseUrl, minPool: 0, maxPool: 0 });
const db = createDb({
databaseUrl: options.databaseUrl,
minPool: 0,
maxPool: 0,
});
return db.raw(`CREATE SCHEMA IF NOT EXISTS ${options.databaseSchema}`)
return db
.raw(`CREATE SCHEMA IF NOT EXISTS ${options.databaseSchema}`)
.then(() => migrator(options))
.then(() => {
db.destroy();
@ -36,14 +41,14 @@ function createApp (databaseSchema = 'test') {
return {
stores,
request: supertest(app),
destroy () {
destroy() {
return stores.db.destroy();
},
};
});
}
function createStrategies (stores) {
function createStrategies(stores) {
return [
{
name: 'default',
@ -52,15 +57,14 @@ function createStrategies (stores) {
},
{
name: 'usersWithEmail',
description: 'Active for users defined in the comma-separated emails-parameter.',
parameters: [
{ name: 'emails', type: 'string' },
],
description:
'Active for users defined in the comma-separated emails-parameter.',
parameters: [{ name: 'emails', type: 'string' }],
},
].map(strategy => stores.strategyStore._createStrategy(strategy));
}
function createApplications (stores) {
function createApplications(stores) {
return [
{
appName: 'demo-app-1',
@ -74,7 +78,7 @@ function createApplications (stores) {
].map(client => stores.clientApplicationsStore.upsert(client));
}
function createClientInstance (stores) {
function createClientInstance(stores) {
return [
{
appName: 'demo-app-1',
@ -93,7 +97,7 @@ function createClientInstance (stores) {
].map(client => stores.clientInstanceStore.insert(client));
}
function createFeatures (stores) {
function createFeatures(stores) {
return [
{
name: 'featureX',
@ -105,23 +109,27 @@ function createFeatures (stores) {
name: 'featureY',
description: 'soon to be the #1 feature',
enabled: false,
strategies: [{
name: 'baz',
parameters: {
foo: 'bar',
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
}],
],
},
{
name: 'featureZ',
description: 'terrible feature',
enabled: true,
strategies: [{
name: 'baz',
parameters: {
foo: 'rab',
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
}],
],
},
{
name: 'featureArchivedX',
@ -135,29 +143,33 @@ function createFeatures (stores) {
description: 'soon to be the #1 feature',
enabled: false,
archived: true,
strategies: [{
name: 'baz',
parameters: {
foo: 'bar',
strategies: [
{
name: 'baz',
parameters: {
foo: 'bar',
},
},
}],
],
},
{
name: 'featureArchivedZ',
description: 'terrible feature',
enabled: true,
archived: true,
strategies: [{
name: 'baz',
parameters: {
foo: 'rab',
strategies: [
{
name: 'baz',
parameters: {
foo: 'rab',
},
},
}],
],
},
].map(feature => stores.featureToggleStore._createFeature(feature));
}
function resetDatabase (stores) {
function resetDatabase(stores) {
return Promise.all([
stores.db('strategies').del(),
stores.db('features').del(),
@ -166,20 +178,22 @@ function resetDatabase (stores) {
]);
}
function setupDatabase (stores) {
function setupDatabase(stores) {
return Promise.all(
createStrategies(stores)
.concat(createFeatures(stores)
.concat(createClientInstance(stores))
.concat(createApplications(stores))));
createStrategies(stores).concat(
createFeatures(stores)
.concat(createClientInstance(stores))
.concat(createApplications(stores))
)
);
}
module.exports = {
setupApp (name) {
return createApp(name).then((app) => {
return resetDatabase(app.stores)
setupApp(name) {
return createApp(name).then(app =>
resetDatabase(app.stores)
.then(() => setupDatabase(app.stores))
.then(() => app);
});
.then(() => app)
);
},
};

6
test/fixtures/fake-event-store.js vendored Normal file
View File

@ -0,0 +1,6 @@
'use strict';
module.exports = () => ({
store: () => Promise.resolve(),
getEvents: () => Promise.resolve([]),
});

View File

@ -1,10 +1,9 @@
'use strict';
module.exports = () => {
module.exports = () => {
const _features = [];
return {
getFeature: (name) => {
getFeature: name => {
const toggle = _features.find(f => f.name === name);
if (toggle) {
return Promise.resolve(toggle);
@ -13,6 +12,6 @@ module.exports = () => {
}
},
getFeatures: () => Promise.resolve(_features),
addFeature: (feature) => _features.push(feature),
addFeature: feature => _features.push(feature),
};
};

View File

@ -3,12 +3,12 @@
const { EventEmitter } = require('events');
class FakeMetricsStore extends EventEmitter {
getMetricsLastHour () {
getMetricsLastHour() {
return Promise.resolve([]);
}
insert () {
insert() {
return Promise.resolve();
}
}
}
module.exports = FakeMetricsStore;
module.exports = FakeMetricsStore;

View File

@ -1,15 +1,13 @@
'use strict';
const NotFoundError = require('../../../../lib/error/notfound-error');
const NotFoundError = require('../../lib/error/notfound-error');
module.exports = () => {
const _strategies = [{ name: 'default', parameters: {} }];
return {
getStrategies: () => Promise.resolve(_strategies),
getStrategy: (name) => {
getStrategy: name => {
const strategy = _strategies.find(s => s.name === name);
if (strategy) {
return Promise.resolve(strategy);
@ -17,6 +15,6 @@ module.exports = () => {
return Promise.reject(new NotFoundError('Not found!'));
}
},
addStrategy: (strat) => _strategies.push(strat),
addStrategy: strat => _strategies.push(strat),
};
};

View File

@ -7,8 +7,6 @@ const featureToggleStore = require('./fake-feature-toggle-store');
const eventStore = require('./fake-event-store');
const strategyStore = require('./fake-strategies-store');
module.exports = {
createStores: () => {
const db = {

View File

@ -1,7 +0,0 @@
'use strict';
module.exports = () => {
return {
store: () => Promise.resolve(),
};
};

View File

@ -1,168 +0,0 @@
'use strict';
const test = require('ava');
const store = require('./fixtures/store');
const supertest = require('supertest');
const logger = require('../../../lib/logger');
const getApp = require('../../../lib/app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
test.beforeEach(() => {
logger.setLevel('FATAL');
});
function getSetup () {
const stores = store.createStores();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
});
return {
request: supertest(app),
stores,
};
}
test('should register client', () => {
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(202);
});
test('should require appName field', () => {
const { request } = getSetup();
return request
.post('/api/client/register')
.expect(400);
});
test('should require strategies field', () => {
const { request } = getSetup();
return request
.post('/api/client/register')
.send({
appName: 'demo',
instanceId: 'test',
// strategies: ['default'],
started: Date.now(),
interval: 10,
})
.expect(400);
});
test('should validate client metrics', () => {
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({ random: 'blush' })
.expect(400);
});
test('should accept client metrics', () => {
const { request } = getSetup();
return request
.post('/api/client/metrics')
.send({
appName: 'demo',
instanceId: '1',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {},
},
})
.expect(202);
});
test('should return seen toggles even when there is nothing', t => {
const { request } = getSetup();
return request
.get('/api/client/seen-toggles')
.expect(200)
.expect((res) => {
t.true(res.body.length === 0);
});
});
test('should return list of seen-toggles per app', t => {
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 }
},
},
});
return request
.get('/api/client/seen-toggles')
.expect(200)
.expect((res) => {
const seenAppsWithToggles = res.body;
t.true(seenAppsWithToggles.length === 1);
t.true(seenAppsWithToggles[0].appName === appName);
t.true(seenAppsWithToggles[0].seenToggles.length === 2);
});
});
test('should return feature-toggles metrics even when there is nothing', t => {
const { request } = getSetup();
return request
.get('/api/client/metrics/feature-toggles')
.expect(200);
});
test('should return metrics for all toggles', t => {
const { request, stores } = getSetup();
const appName = 'asd!23';
stores.clientMetricsStore.emit('metrics', {
appName,
instanceId: 'instanceId',
bucket: {
start: new Date(),
stop: new Date(),
toggles: {
toggleX: { yes: 123, no: 0 },
toggleY: { yes: 123, no: 0 },
},
},
});
return request
.get('/api/client/metrics/feature-toggles')
.expect(200)
.expect((res) => {
const metrics = res.body;
t.true(metrics.lastHour !== undefined);
t.true(metrics.lastMinute !== undefined);
});
});
test('should return list of client applications', t => {
const { request } = getSetup();
return request
.get('/api/client/applications')
.expect(200)
.expect((res) => {
t.true(res.body.applications.length === 0);
});
});

4947
yarn.lock Normal file

File diff suppressed because it is too large Load Diff