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:
commit
cae55e6031
14
.eslintrc
14
.eslintrc
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
```
|
@ -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
|
@ -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.
|
||||
|
@ -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**
|
||||
|
89
docs/api/client/feature-toggles-api.md
Normal file
89
docs/api/client/feature-toggles-api.md
Normal 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": {}
|
||||
}
|
||||
```
|
53
docs/api/client/metrics-api.md
Normal file
53
docs/api/client/metrics-api.md
Normal 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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
@ -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)
|
||||
|
40
lib/app.js
40
lib/app.js
@ -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());
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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 };
|
@ -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);
|
||||
});
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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) => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
class NameExistsError extends Error {
|
||||
constructor (message) {
|
||||
constructor(message) {
|
||||
super();
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
class NotFoundError extends Error {
|
||||
constructor (message) {
|
||||
constructor(message) {
|
||||
super();
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
|
||||
|
@ -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'));
|
||||
|
@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
class ValidationError extends Error {
|
||||
constructor (message) {
|
||||
constructor(message) {
|
||||
super();
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
function extractUsername (req) {
|
||||
function extractUsername(req) {
|
||||
return req.cookies.username || 'unknown';
|
||||
}
|
||||
module.exports = extractUsername;
|
||||
|
@ -2,9 +2,7 @@
|
||||
|
||||
const log4js = require('log4js');
|
||||
log4js.configure({
|
||||
appenders: [
|
||||
{ type: 'console' },
|
||||
],
|
||||
appenders: [{ type: 'console' }],
|
||||
});
|
||||
|
||||
const logger = log4js.getLogger('unleash');
|
||||
|
@ -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);
|
||||
|
@ -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/
|
||||
);
|
||||
});
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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 => {
|
||||
|
0
lib/routes/admin-api/applications.js
Normal file
0
lib/routes/admin-api/applications.js
Normal file
39
lib/routes/admin-api/applications.test.js
Normal file
39
lib/routes/admin-api/applications.test.js
Normal 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);
|
||||
});
|
||||
});
|
46
lib/routes/admin-api/archive.js
Normal file
46
lib/routes/admin-api/archive.js
Normal 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;
|
||||
};
|
5
lib/routes/admin-api/archive.test.js
Normal file
5
lib/routes/admin-api/archive.test.js
Normal file
@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('ava');
|
||||
|
||||
test.todo('should unit test archive');
|
@ -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;
|
||||
};
|
39
lib/routes/admin-api/events.test.js
Normal file
39
lib/routes/admin-api/events.test.js
Normal 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);
|
||||
});
|
||||
});
|
196
lib/routes/admin-api/feature.js
Normal file
196
lib/routes/admin-api/feature.js
Normal 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;
|
||||
};
|
@ -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);
|
||||
});
|
38
lib/routes/admin-api/index.js
Normal file
38
lib/routes/admin-api/index.js
Normal 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;
|
||||
};
|
137
lib/routes/admin-api/metrics.js
Normal file
137
lib/routes/admin-api/metrics.js
Normal 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;
|
||||
};
|
100
lib/routes/admin-api/metrics.test.js
Normal file
100
lib/routes/admin-api/metrics.test.js
Normal 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);
|
||||
});
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const logger = require('../logger');
|
||||
const logger = require('../../logger');
|
||||
|
||||
const catchLogAndSendErrorResponse = (err, res) => {
|
||||
logger.error(err);
|
@ -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;
|
131
lib/routes/admin-api/strategy.js
Normal file
131
lib/routes/admin-api/strategy.js
Normal 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;
|
||||
};
|
@ -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);
|
||||
});
|
@ -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);
|
||||
});
|
||||
};
|
@ -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;
|
||||
};
|
||||
|
@ -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);
|
||||
});
|
18
lib/routes/client-api/feature.js
Normal file
18
lib/routes/client-api/feature.js
Normal 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;
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
30
lib/routes/client-api/index.js
Normal file
30
lib/routes/client-api/index.js
Normal 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;
|
||||
};
|
@ -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(),
|
||||
});
|
39
lib/routes/client-api/metrics.js
Normal file
39
lib/routes/client-api/metrics.js
Normal 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;
|
||||
};
|
54
lib/routes/client-api/metrics.test.js
Normal file
54
lib/routes/client-api/metrics.test.js
Normal 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);
|
||||
});
|
38
lib/routes/client-api/register.js
Normal file
38
lib/routes/client-api/register.js
Normal 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;
|
||||
};
|
64
lib/routes/client-api/register.test.js
Normal file
64
lib/routes/client-api/register.test.js
Normal 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);
|
||||
});
|
@ -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));
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
@ -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;
|
||||
};
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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
91
lib/routes/index.test.js
Normal 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, []);
|
||||
});
|
||||
});
|
@ -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));
|
||||
});
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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 })
|
||||
|
@ -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);
|
||||
});
|
||||
|
10
package.json
10
package.json
@ -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",
|
||||
|
@ -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);
|
@ -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);
|
||||
});
|
200
test/e2e/api/admin/feature.e2e.test.js
Normal file
200
test/e2e/api/admin/feature.e2e.test.js
Normal 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);
|
||||
}
|
||||
);
|
@ -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);
|
||||
})
|
@ -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)
|
5
test/e2e/api/client/feature.e2e.test.js
Normal file
5
test/e2e/api/client/feature.e2e.test.js
Normal file
@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('ava');
|
||||
|
||||
test.todo('e2e client feature');
|
5
test/e2e/api/client/metrics.e2e.test.js
Normal file
5
test/e2e/api/client/metrics.e2e.test.js
Normal file
@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const { test } = require('ava');
|
||||
|
||||
test.todo('e2e client metrics');
|
@ -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);
|
||||
});
|
||||
|
@ -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"}')
|
@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
function getDatabaseUrl () {
|
||||
function getDatabaseUrl() {
|
||||
if (process.env.TEST_DATABASE_URL) {
|
||||
return process.env.TEST_DATABASE_URL;
|
||||
} else {
|
||||
|
@ -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
6
test/fixtures/fake-event-store.js
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = () => ({
|
||||
store: () => Promise.resolve(),
|
||||
getEvents: () => Promise.resolve([]),
|
||||
});
|
@ -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),
|
||||
};
|
||||
};
|
@ -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;
|
@ -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),
|
||||
};
|
||||
};
|
@ -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 = {
|
@ -1,7 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = () => {
|
||||
return {
|
||||
store: () => Promise.resolve(),
|
||||
};
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user